From 234ce35ad8c9586a86ce544ef70094f095e97735 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Tue, 17 Jun 2025 15:28:18 +0300 Subject: [PATCH 01/34] wip --- packages/clerk-js/src/core/clerk.ts | 37 ++ .../core/modules/commerce/CommerceBilling.ts | 9 + packages/clerk-js/src/ui/Components.tsx | 28 +- .../src/ui/components/Plans/PlanDetails.tsx | 295 ++-------- .../src/ui/components/Plans/index.tsx | 1 - .../ui/components/Plans/old_PlanDetails.tsx | 522 +++++++++++++++++ .../PricingTable/PricingTableDefault.tsx | 5 +- .../components/SubscriptionDetails/index.tsx | 524 ++++++++++++++++++ .../src/ui/contexts/components/Plans.tsx | 5 +- .../src/ui/elements/contexts/index.tsx | 3 +- .../lazyModules/MountedPlanDetailDrawer.tsx | 7 +- .../MountedSubscriptionDetailDrawer.tsx | 42 ++ .../clerk-js/src/ui/lazyModules/components.ts | 8 +- .../clerk-js/src/ui/lazyModules/drawers.tsx | 6 + packages/react/src/isomorphicClerk.ts | 43 +- packages/types/src/clerk.ts | 39 ++ packages/types/src/commerce.ts | 1 + 17 files changed, 1305 insertions(+), 270 deletions(-) delete mode 100644 packages/clerk-js/src/ui/components/Plans/index.tsx create mode 100644 packages/clerk-js/src/ui/components/Plans/old_PlanDetails.tsx create mode 100644 packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx create mode 100644 packages/clerk-js/src/ui/lazyModules/MountedSubscriptionDetailDrawer.tsx diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index d67438da176..aa09557c6c1 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -15,6 +15,8 @@ import { import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url'; import { allSettled, handleValueOrFn, noop } from '@clerk/shared/utils'; import type { + __experimental_PlanDetailsProps, + __experimental_SubscriptionDetailsProps, __internal_CheckoutProps, __internal_ComponentNavigationContext, __internal_OAuthConsentProps, @@ -609,6 +611,41 @@ export class Clerk implements ClerkInterface { void this.#componentControls.ensureMounted().then(controls => controls.closeDrawer('planDetails')); }; + public __experimental_openPlanDetails = (props?: __experimental_PlanDetailsProps): void => { + this.assertComponentsReady(this.#componentControls); + if (disabledBillingFeature(this, this.environment)) { + if (this.#instanceType === 'development') { + throw new ClerkRuntimeError(warnings.cannotRenderAnyCommerceComponent('PlanDetails'), { + code: CANNOT_RENDER_BILLING_DISABLED_ERROR_CODE, + }); + } + return; + } + void this.#componentControls + .ensureMounted({ preloadHint: 'PlanDetails' }) + .then(controls => controls.openDrawer('planDetails', props || {})); + + this.telemetry?.record(eventPrebuiltComponentOpened(`PlanDetails`, props)); + }; + + public __experimental_closePlanDetails = (): void => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls.ensureMounted().then(controls => controls.closeDrawer('planDetails')); + }; + + public __experimental_openSubscriptionDetails = (props?: __experimental_SubscriptionDetailsProps): void => { + this.assertComponentsReady(this.#componentControls); + console.log('__experimental_openSubscriptionDetails', props); + void this.#componentControls + .ensureMounted({ preloadHint: 'SubscriptionDetails' }) + .then(controls => controls.openDrawer('subscriptionDetails', props || {})); + }; + + public __experimental_closeSubscriptionDetails = (): void => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls.ensureMounted().then(controls => controls.closeDrawer('subscriptionDetails')); + }; + public __internal_openReverification = (props?: __internal_UserVerificationModalProps): void => { this.assertComponentsReady(this.#componentControls); if (noUserExists(this)) { diff --git a/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts b/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts index b28e6454137..cfa90076df7 100644 --- a/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts +++ b/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts @@ -4,6 +4,7 @@ import type { CommerceCheckoutJSON, CommercePaymentJSON, CommercePaymentResource, + CommercePlanJSON, CommercePlanResource, CommerceProductJSON, CommerceStatementJSON, @@ -39,6 +40,14 @@ export class CommerceBilling implements CommerceBillingNamespace { return defaultProduct?.plans.map(plan => new CommercePlan(plan)) || []; }; + getPlan = async (params: { id: string }): Promise<CommercePlanResource> => { + const plan = (await BaseResource._fetch({ + path: `/commerce/plans/${params.id}`, + method: 'GET', + })) as unknown as CommercePlanJSON; + return new CommercePlan(plan); + }; + getSubscriptions = async ( params: GetSubscriptionsParams, ): Promise<ClerkPaginatedResponse<CommerceSubscriptionResource>> => { diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index 5d3cca0b7dd..ee5d4dbd96d 100644 --- a/packages/clerk-js/src/ui/Components.tsx +++ b/packages/clerk-js/src/ui/Components.tsx @@ -1,5 +1,7 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import type { + __experimental_PlanDetailsProps, + __experimental_SubscriptionDetailsProps, __internal_CheckoutProps, __internal_PlanDetailsProps, __internal_UserVerificationProps, @@ -37,6 +39,7 @@ import { WaitlistModal, } from './lazyModules/components'; import { MountedCheckoutDrawer, MountedPlanDetailDrawer } from './lazyModules/drawers'; +import { MountedSubscriptionDetailDrawer } from './lazyModules/MountedSubscriptionDetailDrawer'; import { LazyComponentRenderer, LazyImpersonationFabProvider, @@ -106,16 +109,18 @@ export type ComponentControls = { notify?: boolean; }, ) => void; - openDrawer: <T extends 'checkout' | 'planDetails'>( + openDrawer: <T extends 'checkout' | 'planDetails' | 'subscriptionDetails'>( drawer: T, props: T extends 'checkout' ? __internal_CheckoutProps : T extends 'planDetails' ? __internal_PlanDetailsProps - : never, + : T extends 'subscriptionDetails' + ? __internal_PlanDetailsProps + : never, ) => void; closeDrawer: ( - drawer: 'checkout' | 'planDetails', + drawer: 'checkout' | 'planDetails' | 'subscriptionDetails', options?: { notify?: boolean; }, @@ -158,7 +163,11 @@ interface ComponentsState { }; planDetailsDrawer: { open: false; - props: null | __internal_PlanDetailsProps; + props: null | __experimental_PlanDetailsProps; + }; + subscriptionDetailsDrawer: { + open: false; + props: null | __experimental_SubscriptionDetailsProps; }; nodes: Map<HTMLDivElement, HtmlNodeOptions>; impersonationFab: boolean; @@ -249,6 +258,10 @@ const Components = (props: ComponentsProps) => { open: false, props: null, }, + subscriptionDetailsDrawer: { + open: false, + props: null, + }, nodes: new Map(), impersonationFab: false, }); @@ -265,6 +278,7 @@ const Components = (props: ComponentsProps) => { blankCaptchaModal, checkoutDrawer, planDetailsDrawer, + subscriptionDetailsDrawer, nodes, } = state; @@ -588,6 +602,12 @@ const Components = (props: ComponentsProps) => { onOpenChange={() => componentsControls.closeDrawer('planDetails')} /> + <MountedSubscriptionDetailDrawer + appearance={state.appearance} + subscriptionDetailsDrawer={subscriptionDetailsDrawer} + onOpenChange={() => componentsControls.closeDrawer('subscriptionDetails')} + /> + {state.impersonationFab && ( <LazyImpersonationFabProvider globalAppearance={state.appearance}> <ImpersonationFab /> diff --git a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx index 4bfb535d1df..7c30cd7f980 100644 --- a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx +++ b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx @@ -1,121 +1,69 @@ -import { useClerk, useOrganization } from '@clerk/shared/react'; +import { useClerk } from '@clerk/shared/react'; import type { - __internal_PlanDetailsProps, - ClerkAPIError, - ClerkRuntimeError, + __experimental_PlanDetailsProps, CommercePlanResource, CommerceSubscriptionPlanPeriod, - CommerceSubscriptionResource, } from '@clerk/types'; import * as React from 'react'; import { useMemo, useState } from 'react'; +import useSWR from 'swr'; -import { Alert } from '@/ui/elements/Alert'; import { Avatar } from '@/ui/elements/Avatar'; -import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; +import { Drawer } from '@/ui/elements/Drawer'; import { Switch } from '@/ui/elements/Switch'; -import { useProtect } from '../../common'; -import { SubscriberTypeContext, usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; -import { Badge, Box, Button, Col, descriptors, Flex, Heading, localizationKeys, Span, Text } from '../../customizables'; -import { handleError } from '../../utils'; +import { SubscriberTypeContext } from '../../contexts'; +import { Box, Col, descriptors, Flex, Heading, localizationKeys, Span, Spinner, Text } from '../../customizables'; -export const PlanDetails = (props: __internal_PlanDetailsProps) => { +export const PlanDetails = (props: __experimental_PlanDetailsProps) => { return ( - <SubscriberTypeContext.Provider value={props.subscriberType || 'user'}> - <Drawer.Content> - <PlanDetailsInternal {...props} /> - </Drawer.Content> - </SubscriberTypeContext.Provider> + <Drawer.Content> + <PlanDetailsInternal {...props} /> + </Drawer.Content> ); }; const PlanDetailsInternal = ({ - plan, - onSubscriptionCancel, - portalRoot, + planId, + plan: initialPlan, initialPlanPeriod = 'month', -}: __internal_PlanDetailsProps) => { +}: __experimental_PlanDetailsProps) => { const clerk = useClerk(); - const { organization } = useOrganization(); - const [showConfirmation, setShowConfirmation] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [cancelError, setCancelError] = useState<ClerkRuntimeError | ClerkAPIError | string | undefined>(); const [planPeriod, setPlanPeriod] = useState<CommerceSubscriptionPlanPeriod>(initialPlanPeriod); - const { setIsOpen } = useDrawerContext(); - const { - activeOrUpcomingSubscriptionBasedOnPlanPeriod, - revalidateAll, - buttonPropsForPlan, - isDefaultPlanImplicitlyActiveOrUpcoming, - } = usePlansContext(); - const subscriberType = useSubscriberTypeContext(); - const canManageBilling = useProtect( - has => has({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user', + const { data: plan, isLoading } = useSWR( + planId || initialPlan ? { type: 'plan', id: planId || initialPlan?.id } : null, + // @ts-expect-error + () => clerk.billing.getPlan({ id: planId || initialPlan?.id }), + { + fallbackData: initialPlan, + }, ); + if (isLoading && !initialPlan) { + return ( + <Flex + justify='center' + align='center' + sx={{ + height: '100%', + }} + > + <Spinner /> + </Flex> + ); + } + if (!plan) { return null; } - const subscription = activeOrUpcomingSubscriptionBasedOnPlanPeriod(plan, planPeriod); - - const handleClose = () => { - if (setIsOpen) { - setIsOpen(false); - } - }; - const features = plan.features; const hasFeatures = features.length > 0; - const cancelSubscription = async () => { - if (!subscription) { - return; - } - - setCancelError(undefined); - setIsSubmitting(true); - - await subscription - .cancel({ orgId: subscriberType === 'org' ? organization?.id : undefined }) - .then(() => { - setIsSubmitting(false); - onSubscriptionCancel?.(); - handleClose(); - }) - .catch(error => { - handleError(error, [], setCancelError); - setIsSubmitting(false); - }); - }; - - type Open__internal_CheckoutProps = { - planPeriod?: CommerceSubscriptionPlanPeriod; - }; - - const openCheckout = (props?: Open__internal_CheckoutProps) => { - handleClose(); - - // if the plan doesn't support annual, use monthly - let _planPeriod = props?.planPeriod || planPeriod; - if (_planPeriod === 'annual' && plan.annualMonthlyAmount === 0) { - _planPeriod = 'month'; - } - - clerk.__internal_openCheckout({ - planId: plan.id, - planPeriod: _planPeriod, - subscriberType: subscriberType, - onSubscriptionComplete: () => { - void revalidateAll(); - }, - portalRoot, - }); - }; return ( - <> + <SubscriberTypeContext.Provider value={plan.payerType[0] as 'user' | 'org'}> + {/* TODO: type assertion is a hack, make FAPI stricter */} <Drawer.Header sx={t => !hasFeatures @@ -129,7 +77,6 @@ const PlanDetailsInternal = ({ > <Header plan={plan} - subscription={subscription} planPeriod={planPeriod} setPlanPeriod={setPlanPeriod} closeSlot={<Drawer.Close />} @@ -207,127 +154,17 @@ const PlanDetailsInternal = ({ </Drawer.Body> ) : null} - {(!plan.isDefault && !isDefaultPlanImplicitlyActiveOrUpcoming) || !subscription ? ( + {/* {!plan.isDefault && !isDefaultPlanImplicitlyActiveOrUpcoming ? ( <Drawer.Footer> - {subscription ? ( - subscription.canceledAt ? ( - <Button - block - textVariant='buttonLarge' - {...buttonPropsForPlan({ plan })} - onClick={() => openCheckout()} - /> - ) : ( - <Col gap={4}> - {!!subscription && - subscription.planPeriod === 'month' && - plan.annualMonthlyAmount > 0 && - planPeriod === 'annual' ? ( - <Button - block - variant='bordered' - colorScheme='secondary' - textVariant='buttonLarge' - isDisabled={!canManageBilling} - onClick={() => openCheckout({ planPeriod: 'annual' })} - localizationKey={localizationKeys('commerce.switchToAnnual')} - /> - ) : null} - {!!subscription && subscription.planPeriod === 'annual' && planPeriod === 'month' ? ( - <Button - block - variant='bordered' - colorScheme='secondary' - textVariant='buttonLarge' - isDisabled={!canManageBilling} - onClick={() => openCheckout({ planPeriod: 'month' })} - localizationKey={localizationKeys('commerce.switchToMonthly')} - /> - ) : null} - <Button - block - variant='bordered' - colorScheme='danger' - textVariant='buttonLarge' - isDisabled={!canManageBilling} - onClick={() => setShowConfirmation(true)} - localizationKey={localizationKeys('commerce.cancelSubscription')} - /> - </Col> - ) - ) : ( - <Button - block - textVariant='buttonLarge' - {...buttonPropsForPlan({ plan })} - onClick={() => openCheckout()} - /> - )} - </Drawer.Footer> - ) : null} - - {subscription ? ( - <Drawer.Confirmation - open={showConfirmation} - onOpenChange={setShowConfirmation} - actionsSlot={ - <> - {!isSubmitting && ( - <Button - variant='ghost' - size='sm' - textVariant='buttonLarge' - isDisabled={!canManageBilling} - onClick={() => { - setCancelError(undefined); - setShowConfirmation(false); - }} - localizationKey={localizationKeys('commerce.keepSubscription')} - /> - )} - <Button - variant='solid' - colorScheme='danger' - size='sm' - textVariant='buttonLarge' - isLoading={isSubmitting} - isDisabled={!canManageBilling} - onClick={() => { - setCancelError(undefined); - setShowConfirmation(false); - void cancelSubscription(); - }} - localizationKey={localizationKeys('commerce.cancelSubscription')} - /> - </> - } - > - <Heading - elementDescriptor={descriptors.drawerConfirmationTitle} - as='h2' - textVariant='h3' - localizationKey={localizationKeys('commerce.cancelSubscriptionTitle', { - plan: `${subscription.status === 'upcoming' ? 'upcoming ' : ''}${subscription.plan.name}`, - })} - /> - <Text - elementDescriptor={descriptors.drawerConfirmationDescription} - colorScheme='secondary' - localizationKey={ - subscription.status === 'upcoming' - ? localizationKeys('commerce.cancelSubscriptionNoCharge') - : localizationKeys('commerce.cancelSubscriptionAccessUntil', { - plan: subscription.plan.name, - date: subscription.periodEnd, - }) - } + <Button + block + textVariant='buttonLarge' + {...buttonPropsForPlan({ plan })} + onClick={() => openCheckout()} /> - {cancelError && ( - <Alert colorScheme='danger'>{typeof cancelError === 'string' ? cancelError : cancelError.message}</Alert> - )} - </Drawer.Confirmation> - ) : null} - </> + </Drawer.Footer> + ) : null} */} + </SubscriberTypeContext.Provider> ); }; @@ -337,21 +174,13 @@ const PlanDetailsInternal = ({ interface HeaderProps { plan: CommercePlanResource; - subscription?: CommerceSubscriptionResource; planPeriod: CommerceSubscriptionPlanPeriod; setPlanPeriod: (val: CommerceSubscriptionPlanPeriod) => void; closeSlot?: React.ReactNode; } const Header = React.forwardRef<HTMLDivElement, HeaderProps>((props, ref) => { - const { plan, subscription, closeSlot, planPeriod, setPlanPeriod } = props; - - const { captionForSubscription, isDefaultPlanImplicitlyActiveOrUpcoming } = usePlansContext(); - const { data: subscriptions } = useSubscriptions(); - - const isImplicitlyActiveOrUpcoming = isDefaultPlanImplicitlyActiveOrUpcoming && plan.isDefault; - - const showBadge = !!subscription; + const { plan, closeSlot, planPeriod, setPlanPeriod } = props; const getPlanFee = useMemo(() => { if (plan.annualMonthlyAmount <= 0) { @@ -386,38 +215,6 @@ const Header = React.forwardRef<HTMLDivElement, HeaderProps>((props, ref) => { gap={3} elementDescriptor={descriptors.planDetailBadgeAvatarTitleDescriptionContainer} > - {showBadge ? ( - <Flex - align='center' - gap={3} - elementDescriptor={descriptors.planDetailBadgeContainer} - sx={t => ({ - paddingInlineEnd: t.space.$10, - })} - > - {subscription?.status === 'active' || (isImplicitlyActiveOrUpcoming && subscriptions.length === 0) ? ( - <Badge - elementDescriptor={descriptors.planDetailBadge} - localizationKey={localizationKeys('badge__activePlan')} - colorScheme={'secondary'} - /> - ) : ( - <Badge - elementDescriptor={descriptors.planDetailBadge} - localizationKey={localizationKeys('badge__upcomingPlan')} - colorScheme={'primary'} - /> - )} - {!!subscription && ( - <Text - elementDescriptor={descriptors.planDetailCaption} - variant={'caption'} - localizationKey={captionForSubscription(subscription)} - colorScheme='secondary' - /> - )} - </Flex> - ) : null} {plan.avatarUrl ? ( <Avatar boxElementDescriptor={descriptors.planDetailAvatar} diff --git a/packages/clerk-js/src/ui/components/Plans/index.tsx b/packages/clerk-js/src/ui/components/Plans/index.tsx deleted file mode 100644 index 612088fd9aa..00000000000 --- a/packages/clerk-js/src/ui/components/Plans/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export * from './PlanDetails'; diff --git a/packages/clerk-js/src/ui/components/Plans/old_PlanDetails.tsx b/packages/clerk-js/src/ui/components/Plans/old_PlanDetails.tsx new file mode 100644 index 00000000000..4bfb535d1df --- /dev/null +++ b/packages/clerk-js/src/ui/components/Plans/old_PlanDetails.tsx @@ -0,0 +1,522 @@ +import { useClerk, useOrganization } from '@clerk/shared/react'; +import type { + __internal_PlanDetailsProps, + ClerkAPIError, + ClerkRuntimeError, + CommercePlanResource, + CommerceSubscriptionPlanPeriod, + CommerceSubscriptionResource, +} from '@clerk/types'; +import * as React from 'react'; +import { useMemo, useState } from 'react'; + +import { Alert } from '@/ui/elements/Alert'; +import { Avatar } from '@/ui/elements/Avatar'; +import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; +import { Switch } from '@/ui/elements/Switch'; + +import { useProtect } from '../../common'; +import { SubscriberTypeContext, usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; +import { Badge, Box, Button, Col, descriptors, Flex, Heading, localizationKeys, Span, Text } from '../../customizables'; +import { handleError } from '../../utils'; + +export const PlanDetails = (props: __internal_PlanDetailsProps) => { + return ( + <SubscriberTypeContext.Provider value={props.subscriberType || 'user'}> + <Drawer.Content> + <PlanDetailsInternal {...props} /> + </Drawer.Content> + </SubscriberTypeContext.Provider> + ); +}; + +const PlanDetailsInternal = ({ + plan, + onSubscriptionCancel, + portalRoot, + initialPlanPeriod = 'month', +}: __internal_PlanDetailsProps) => { + const clerk = useClerk(); + const { organization } = useOrganization(); + const [showConfirmation, setShowConfirmation] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [cancelError, setCancelError] = useState<ClerkRuntimeError | ClerkAPIError | string | undefined>(); + const [planPeriod, setPlanPeriod] = useState<CommerceSubscriptionPlanPeriod>(initialPlanPeriod); + + const { setIsOpen } = useDrawerContext(); + const { + activeOrUpcomingSubscriptionBasedOnPlanPeriod, + revalidateAll, + buttonPropsForPlan, + isDefaultPlanImplicitlyActiveOrUpcoming, + } = usePlansContext(); + const subscriberType = useSubscriberTypeContext(); + const canManageBilling = useProtect( + has => has({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user', + ); + + if (!plan) { + return null; + } + + const subscription = activeOrUpcomingSubscriptionBasedOnPlanPeriod(plan, planPeriod); + + const handleClose = () => { + if (setIsOpen) { + setIsOpen(false); + } + }; + + const features = plan.features; + const hasFeatures = features.length > 0; + const cancelSubscription = async () => { + if (!subscription) { + return; + } + + setCancelError(undefined); + setIsSubmitting(true); + + await subscription + .cancel({ orgId: subscriberType === 'org' ? organization?.id : undefined }) + .then(() => { + setIsSubmitting(false); + onSubscriptionCancel?.(); + handleClose(); + }) + .catch(error => { + handleError(error, [], setCancelError); + setIsSubmitting(false); + }); + }; + + type Open__internal_CheckoutProps = { + planPeriod?: CommerceSubscriptionPlanPeriod; + }; + + const openCheckout = (props?: Open__internal_CheckoutProps) => { + handleClose(); + + // if the plan doesn't support annual, use monthly + let _planPeriod = props?.planPeriod || planPeriod; + if (_planPeriod === 'annual' && plan.annualMonthlyAmount === 0) { + _planPeriod = 'month'; + } + + clerk.__internal_openCheckout({ + planId: plan.id, + planPeriod: _planPeriod, + subscriberType: subscriberType, + onSubscriptionComplete: () => { + void revalidateAll(); + }, + portalRoot, + }); + }; + + return ( + <> + <Drawer.Header + sx={t => + !hasFeatures + ? { + flex: 1, + borderBottomWidth: 0, + background: t.colors.$colorBackground, + } + : null + } + > + <Header + plan={plan} + subscription={subscription} + planPeriod={planPeriod} + setPlanPeriod={setPlanPeriod} + closeSlot={<Drawer.Close />} + /> + </Drawer.Header> + + {hasFeatures ? ( + <Drawer.Body> + <Text + elementDescriptor={descriptors.planDetailCaption} + variant={'caption'} + localizationKey={localizationKeys('commerce.availableFeatures')} + colorScheme='secondary' + sx={t => ({ + padding: t.space.$4, + paddingBottom: 0, + })} + /> + <Box + elementDescriptor={descriptors.planDetailFeaturesList} + as='ul' + role='list' + sx={t => ({ + display: 'grid', + rowGap: t.space.$6, + padding: t.space.$4, + margin: 0, + })} + > + {features.map(feature => ( + <Box + key={feature.id} + elementDescriptor={descriptors.planDetailFeaturesListItem} + as='li' + sx={t => ({ + display: 'flex', + alignItems: 'baseline', + gap: t.space.$3, + })} + > + {feature.avatarUrl ? ( + <Avatar + size={_ => 24} + title={feature.name} + initials={feature.name[0]} + rounded={false} + imageUrl={feature.avatarUrl} + /> + ) : null} + <Span elementDescriptor={descriptors.planDetailFeaturesListItemContent}> + <Text + elementDescriptor={descriptors.planDetailFeaturesListItemTitle} + colorScheme='body' + sx={t => ({ + fontWeight: t.fontWeights.$medium, + })} + > + {feature.name} + </Text> + {feature.description ? ( + <Text + elementDescriptor={descriptors.planDetailFeaturesListItemDescription} + colorScheme='secondary' + sx={t => ({ + marginBlockStart: t.space.$0x25, + })} + > + {feature.description} + </Text> + ) : null} + </Span> + </Box> + ))} + </Box> + </Drawer.Body> + ) : null} + + {(!plan.isDefault && !isDefaultPlanImplicitlyActiveOrUpcoming) || !subscription ? ( + <Drawer.Footer> + {subscription ? ( + subscription.canceledAt ? ( + <Button + block + textVariant='buttonLarge' + {...buttonPropsForPlan({ plan })} + onClick={() => openCheckout()} + /> + ) : ( + <Col gap={4}> + {!!subscription && + subscription.planPeriod === 'month' && + plan.annualMonthlyAmount > 0 && + planPeriod === 'annual' ? ( + <Button + block + variant='bordered' + colorScheme='secondary' + textVariant='buttonLarge' + isDisabled={!canManageBilling} + onClick={() => openCheckout({ planPeriod: 'annual' })} + localizationKey={localizationKeys('commerce.switchToAnnual')} + /> + ) : null} + {!!subscription && subscription.planPeriod === 'annual' && planPeriod === 'month' ? ( + <Button + block + variant='bordered' + colorScheme='secondary' + textVariant='buttonLarge' + isDisabled={!canManageBilling} + onClick={() => openCheckout({ planPeriod: 'month' })} + localizationKey={localizationKeys('commerce.switchToMonthly')} + /> + ) : null} + <Button + block + variant='bordered' + colorScheme='danger' + textVariant='buttonLarge' + isDisabled={!canManageBilling} + onClick={() => setShowConfirmation(true)} + localizationKey={localizationKeys('commerce.cancelSubscription')} + /> + </Col> + ) + ) : ( + <Button + block + textVariant='buttonLarge' + {...buttonPropsForPlan({ plan })} + onClick={() => openCheckout()} + /> + )} + </Drawer.Footer> + ) : null} + + {subscription ? ( + <Drawer.Confirmation + open={showConfirmation} + onOpenChange={setShowConfirmation} + actionsSlot={ + <> + {!isSubmitting && ( + <Button + variant='ghost' + size='sm' + textVariant='buttonLarge' + isDisabled={!canManageBilling} + onClick={() => { + setCancelError(undefined); + setShowConfirmation(false); + }} + localizationKey={localizationKeys('commerce.keepSubscription')} + /> + )} + <Button + variant='solid' + colorScheme='danger' + size='sm' + textVariant='buttonLarge' + isLoading={isSubmitting} + isDisabled={!canManageBilling} + onClick={() => { + setCancelError(undefined); + setShowConfirmation(false); + void cancelSubscription(); + }} + localizationKey={localizationKeys('commerce.cancelSubscription')} + /> + </> + } + > + <Heading + elementDescriptor={descriptors.drawerConfirmationTitle} + as='h2' + textVariant='h3' + localizationKey={localizationKeys('commerce.cancelSubscriptionTitle', { + plan: `${subscription.status === 'upcoming' ? 'upcoming ' : ''}${subscription.plan.name}`, + })} + /> + <Text + elementDescriptor={descriptors.drawerConfirmationDescription} + colorScheme='secondary' + localizationKey={ + subscription.status === 'upcoming' + ? localizationKeys('commerce.cancelSubscriptionNoCharge') + : localizationKeys('commerce.cancelSubscriptionAccessUntil', { + plan: subscription.plan.name, + date: subscription.periodEnd, + }) + } + /> + {cancelError && ( + <Alert colorScheme='danger'>{typeof cancelError === 'string' ? cancelError : cancelError.message}</Alert> + )} + </Drawer.Confirmation> + ) : null} + </> + ); +}; + +/* ------------------------------------------------------------------------------------------------- + * Header + * -----------------------------------------------------------------------------------------------*/ + +interface HeaderProps { + plan: CommercePlanResource; + subscription?: CommerceSubscriptionResource; + planPeriod: CommerceSubscriptionPlanPeriod; + setPlanPeriod: (val: CommerceSubscriptionPlanPeriod) => void; + closeSlot?: React.ReactNode; +} + +const Header = React.forwardRef<HTMLDivElement, HeaderProps>((props, ref) => { + const { plan, subscription, closeSlot, planPeriod, setPlanPeriod } = props; + + const { captionForSubscription, isDefaultPlanImplicitlyActiveOrUpcoming } = usePlansContext(); + const { data: subscriptions } = useSubscriptions(); + + const isImplicitlyActiveOrUpcoming = isDefaultPlanImplicitlyActiveOrUpcoming && plan.isDefault; + + const showBadge = !!subscription; + + const getPlanFee = useMemo(() => { + if (plan.annualMonthlyAmount <= 0) { + return plan.amountFormatted; + } + return planPeriod === 'annual' ? plan.annualMonthlyAmountFormatted : plan.amountFormatted; + }, [plan, planPeriod]); + + return ( + <Box + ref={ref} + elementDescriptor={descriptors.planDetailHeader} + sx={t => ({ + width: '100%', + padding: t.space.$4, + position: 'relative', + })} + > + {closeSlot ? ( + <Box + sx={t => ({ + position: 'absolute', + top: t.space.$2, + insetInlineEnd: t.space.$2, + })} + > + {closeSlot} + </Box> + ) : null} + + <Col + gap={3} + elementDescriptor={descriptors.planDetailBadgeAvatarTitleDescriptionContainer} + > + {showBadge ? ( + <Flex + align='center' + gap={3} + elementDescriptor={descriptors.planDetailBadgeContainer} + sx={t => ({ + paddingInlineEnd: t.space.$10, + })} + > + {subscription?.status === 'active' || (isImplicitlyActiveOrUpcoming && subscriptions.length === 0) ? ( + <Badge + elementDescriptor={descriptors.planDetailBadge} + localizationKey={localizationKeys('badge__activePlan')} + colorScheme={'secondary'} + /> + ) : ( + <Badge + elementDescriptor={descriptors.planDetailBadge} + localizationKey={localizationKeys('badge__upcomingPlan')} + colorScheme={'primary'} + /> + )} + {!!subscription && ( + <Text + elementDescriptor={descriptors.planDetailCaption} + variant={'caption'} + localizationKey={captionForSubscription(subscription)} + colorScheme='secondary' + /> + )} + </Flex> + ) : null} + {plan.avatarUrl ? ( + <Avatar + boxElementDescriptor={descriptors.planDetailAvatar} + size={_ => 40} + title={plan.name} + initials={plan.name[0]} + rounded={false} + imageUrl={plan.avatarUrl} + sx={t => ({ + marginBlockEnd: t.space.$3, + })} + /> + ) : null} + <Col + gap={1} + elementDescriptor={descriptors.planDetailTitleDescriptionContainer} + > + <Heading + elementDescriptor={descriptors.planDetailTitle} + as='h2' + textVariant='h2' + > + {plan.name} + </Heading> + {plan.description ? ( + <Text + elementDescriptor={descriptors.planDetailDescription} + variant='subtitle' + colorScheme='secondary' + > + {plan.description} + </Text> + ) : null} + </Col> + </Col> + + <Flex + elementDescriptor={descriptors.planDetailFeeContainer} + align='center' + wrap='wrap' + sx={t => ({ + marginTop: t.space.$3, + columnGap: t.space.$1x5, + })} + > + <> + <Text + elementDescriptor={descriptors.planDetailFee} + variant='h1' + colorScheme='body' + > + {plan.currencySymbol} + {getPlanFee} + </Text> + <Text + elementDescriptor={descriptors.planDetailFeePeriod} + variant='caption' + colorScheme='secondary' + sx={t => ({ + textTransform: 'lowercase', + ':before': { + content: '"/"', + marginInlineEnd: t.space.$1, + }, + })} + localizationKey={localizationKeys('commerce.month')} + /> + </> + </Flex> + + {plan.annualMonthlyAmount > 0 ? ( + <Box + elementDescriptor={descriptors.planDetailPeriodToggle} + sx={t => ({ + display: 'flex', + marginTop: t.space.$3, + })} + > + <Switch + isChecked={planPeriod === 'annual'} + onChange={(checked: boolean) => setPlanPeriod(checked ? 'annual' : 'month')} + label={localizationKeys('commerce.billedAnnually')} + /> + </Box> + ) : ( + <Text + elementDescriptor={descriptors.pricingTableCardFeePeriodNotice} + variant='caption' + colorScheme='secondary' + localizationKey={ + plan.isDefault ? localizationKeys('commerce.alwaysFree') : localizationKeys('commerce.billedMonthlyOnly') + } + sx={t => ({ + justifySelf: 'flex-start', + alignSelf: 'center', + marginTop: t.space.$3, + })} + /> + )} + </Box> + ); +}); diff --git a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx index fc47b2de578..792c3eb0cdf 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx @@ -116,9 +116,10 @@ function Card(props: CardProps) { const showPlanDetails = (event?: React.MouseEvent<HTMLElement>) => { const portalRoot = getClosestProfileScrollBox(mode, event); - clerk.__internal_openPlanDetails({ + clerk.__experimental_openPlanDetails({ plan, - subscriberType, + // planId: plan.id, + // subscriberType, initialPlanPeriod: planPeriod, portalRoot, }); diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx new file mode 100644 index 00000000000..66cdc9a8d7b --- /dev/null +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -0,0 +1,524 @@ +import { useClerk, useOrganization } from '@clerk/shared/react'; +import type { + __experimental_SubscriptionDetailsProps, + __internal_PlanDetailsProps, + ClerkAPIError, + ClerkRuntimeError, + CommercePlanResource, + CommerceSubscriptionPlanPeriod, + CommerceSubscriptionResource, +} from '@clerk/types'; +import * as React from 'react'; +import { useMemo, useState } from 'react'; + +import { Alert } from '@/ui/elements/Alert'; +import { Avatar } from '@/ui/elements/Avatar'; +import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; +import { Switch } from '@/ui/elements/Switch'; + +import { useProtect } from '../../common'; +import { SubscriberTypeContext, usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; +import { Badge, Box, Button, Col, descriptors, Flex, Heading, localizationKeys, Span, Text } from '../../customizables'; +import { handleError } from '../../utils'; + +export const SubscriptionDetails = (props: __experimental_SubscriptionDetailsProps) => { + console.log('SubscriptionDetails', props); + return ( + <SubscriberTypeContext.Provider value={'user'}> + <Drawer.Content> + <PlanDetailsInternal {...props} /> + </Drawer.Content> + </SubscriberTypeContext.Provider> + ); +}; + +const PlanDetailsInternal = ({ + plan, + onSubscriptionCancel, + portalRoot, + initialPlanPeriod = 'month', +}: __internal_PlanDetailsProps) => { + const clerk = useClerk(); + const { organization } = useOrganization(); + const [showConfirmation, setShowConfirmation] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [cancelError, setCancelError] = useState<ClerkRuntimeError | ClerkAPIError | string | undefined>(); + const [planPeriod, setPlanPeriod] = useState<CommerceSubscriptionPlanPeriod>(initialPlanPeriod); + + const { setIsOpen } = useDrawerContext(); + const { + activeOrUpcomingSubscriptionBasedOnPlanPeriod, + revalidateAll, + buttonPropsForPlan, + isDefaultPlanImplicitlyActiveOrUpcoming, + } = usePlansContext(); + const subscriberType = useSubscriberTypeContext(); + const canManageBilling = useProtect( + has => has({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user', + ); + + if (!plan) { + return null; + } + + const subscription = activeOrUpcomingSubscriptionBasedOnPlanPeriod(plan, planPeriod); + + const handleClose = () => { + if (setIsOpen) { + setIsOpen(false); + } + }; + + const features = plan.features; + const hasFeatures = features.length > 0; + const cancelSubscription = async () => { + if (!subscription) { + return; + } + + setCancelError(undefined); + setIsSubmitting(true); + + await subscription + .cancel({ orgId: subscriberType === 'org' ? organization?.id : undefined }) + .then(() => { + setIsSubmitting(false); + onSubscriptionCancel?.(); + handleClose(); + }) + .catch(error => { + handleError(error, [], setCancelError); + setIsSubmitting(false); + }); + }; + + type Open__internal_CheckoutProps = { + planPeriod?: CommerceSubscriptionPlanPeriod; + }; + + const openCheckout = (props?: Open__internal_CheckoutProps) => { + handleClose(); + + // if the plan doesn't support annual, use monthly + let _planPeriod = props?.planPeriod || planPeriod; + if (_planPeriod === 'annual' && plan.annualMonthlyAmount === 0) { + _planPeriod = 'month'; + } + + clerk.__internal_openCheckout({ + planId: plan.id, + planPeriod: _planPeriod, + subscriberType: subscriberType, + onSubscriptionComplete: () => { + void revalidateAll(); + }, + portalRoot, + }); + }; + + return ( + <> + <Drawer.Header + sx={t => + !hasFeatures + ? { + flex: 1, + borderBottomWidth: 0, + background: t.colors.$colorBackground, + } + : null + } + > + <Header + plan={plan} + subscription={subscription} + planPeriod={planPeriod} + setPlanPeriod={setPlanPeriod} + closeSlot={<Drawer.Close />} + /> + </Drawer.Header> + + {hasFeatures ? ( + <Drawer.Body> + <Text + elementDescriptor={descriptors.planDetailCaption} + variant={'caption'} + localizationKey={localizationKeys('commerce.availableFeatures')} + colorScheme='secondary' + sx={t => ({ + padding: t.space.$4, + paddingBottom: 0, + })} + /> + <Box + elementDescriptor={descriptors.planDetailFeaturesList} + as='ul' + role='list' + sx={t => ({ + display: 'grid', + rowGap: t.space.$6, + padding: t.space.$4, + margin: 0, + })} + > + {features.map(feature => ( + <Box + key={feature.id} + elementDescriptor={descriptors.planDetailFeaturesListItem} + as='li' + sx={t => ({ + display: 'flex', + alignItems: 'baseline', + gap: t.space.$3, + })} + > + {feature.avatarUrl ? ( + <Avatar + size={_ => 24} + title={feature.name} + initials={feature.name[0]} + rounded={false} + imageUrl={feature.avatarUrl} + /> + ) : null} + <Span elementDescriptor={descriptors.planDetailFeaturesListItemContent}> + <Text + elementDescriptor={descriptors.planDetailFeaturesListItemTitle} + colorScheme='body' + sx={t => ({ + fontWeight: t.fontWeights.$medium, + })} + > + {feature.name} + </Text> + {feature.description ? ( + <Text + elementDescriptor={descriptors.planDetailFeaturesListItemDescription} + colorScheme='secondary' + sx={t => ({ + marginBlockStart: t.space.$0x25, + })} + > + {feature.description} + </Text> + ) : null} + </Span> + </Box> + ))} + </Box> + </Drawer.Body> + ) : null} + + {(!plan.isDefault && !isDefaultPlanImplicitlyActiveOrUpcoming) || !subscription ? ( + <Drawer.Footer> + {subscription ? ( + subscription.canceledAt ? ( + <Button + block + textVariant='buttonLarge' + {...buttonPropsForPlan({ plan })} + onClick={() => openCheckout()} + /> + ) : ( + <Col gap={4}> + {!!subscription && + subscription.planPeriod === 'month' && + plan.annualMonthlyAmount > 0 && + planPeriod === 'annual' ? ( + <Button + block + variant='bordered' + colorScheme='secondary' + textVariant='buttonLarge' + isDisabled={!canManageBilling} + onClick={() => openCheckout({ planPeriod: 'annual' })} + localizationKey={localizationKeys('commerce.switchToAnnual')} + /> + ) : null} + {!!subscription && subscription.planPeriod === 'annual' && planPeriod === 'month' ? ( + <Button + block + variant='bordered' + colorScheme='secondary' + textVariant='buttonLarge' + isDisabled={!canManageBilling} + onClick={() => openCheckout({ planPeriod: 'month' })} + localizationKey={localizationKeys('commerce.switchToMonthly')} + /> + ) : null} + <Button + block + variant='bordered' + colorScheme='danger' + textVariant='buttonLarge' + isDisabled={!canManageBilling} + onClick={() => setShowConfirmation(true)} + localizationKey={localizationKeys('commerce.cancelSubscription')} + /> + </Col> + ) + ) : ( + <Button + block + textVariant='buttonLarge' + {...buttonPropsForPlan({ plan })} + onClick={() => openCheckout()} + /> + )} + </Drawer.Footer> + ) : null} + + {subscription ? ( + <Drawer.Confirmation + open={showConfirmation} + onOpenChange={setShowConfirmation} + actionsSlot={ + <> + {!isSubmitting && ( + <Button + variant='ghost' + size='sm' + textVariant='buttonLarge' + isDisabled={!canManageBilling} + onClick={() => { + setCancelError(undefined); + setShowConfirmation(false); + }} + localizationKey={localizationKeys('commerce.keepSubscription')} + /> + )} + <Button + variant='solid' + colorScheme='danger' + size='sm' + textVariant='buttonLarge' + isLoading={isSubmitting} + isDisabled={!canManageBilling} + onClick={() => { + setCancelError(undefined); + setShowConfirmation(false); + void cancelSubscription(); + }} + localizationKey={localizationKeys('commerce.cancelSubscription')} + /> + </> + } + > + <Heading + elementDescriptor={descriptors.drawerConfirmationTitle} + as='h2' + textVariant='h3' + localizationKey={localizationKeys('commerce.cancelSubscriptionTitle', { + plan: `${subscription.status === 'upcoming' ? 'upcoming ' : ''}${subscription.plan.name}`, + })} + /> + <Text + elementDescriptor={descriptors.drawerConfirmationDescription} + colorScheme='secondary' + localizationKey={ + subscription.status === 'upcoming' + ? localizationKeys('commerce.cancelSubscriptionNoCharge') + : localizationKeys('commerce.cancelSubscriptionAccessUntil', { + plan: subscription.plan.name, + date: subscription.periodEnd, + }) + } + /> + {cancelError && ( + <Alert colorScheme='danger'>{typeof cancelError === 'string' ? cancelError : cancelError.message}</Alert> + )} + </Drawer.Confirmation> + ) : null} + </> + ); +}; + +/* ------------------------------------------------------------------------------------------------- + * Header + * -----------------------------------------------------------------------------------------------*/ + +interface HeaderProps { + plan: CommercePlanResource; + subscription?: CommerceSubscriptionResource; + planPeriod: CommerceSubscriptionPlanPeriod; + setPlanPeriod: (val: CommerceSubscriptionPlanPeriod) => void; + closeSlot?: React.ReactNode; +} + +const Header = React.forwardRef<HTMLDivElement, HeaderProps>((props, ref) => { + const { plan, subscription, closeSlot, planPeriod, setPlanPeriod } = props; + + const { captionForSubscription, isDefaultPlanImplicitlyActiveOrUpcoming } = usePlansContext(); + const { data: subscriptions } = useSubscriptions(); + + const isImplicitlyActiveOrUpcoming = isDefaultPlanImplicitlyActiveOrUpcoming && plan.isDefault; + + const showBadge = !!subscription; + + const getPlanFee = useMemo(() => { + if (plan.annualMonthlyAmount <= 0) { + return plan.amountFormatted; + } + return planPeriod === 'annual' ? plan.annualMonthlyAmountFormatted : plan.amountFormatted; + }, [plan, planPeriod]); + + return ( + <Box + ref={ref} + elementDescriptor={descriptors.planDetailHeader} + sx={t => ({ + width: '100%', + padding: t.space.$4, + position: 'relative', + })} + > + {closeSlot ? ( + <Box + sx={t => ({ + position: 'absolute', + top: t.space.$2, + insetInlineEnd: t.space.$2, + })} + > + {closeSlot} + </Box> + ) : null} + + <Col + gap={3} + elementDescriptor={descriptors.planDetailBadgeAvatarTitleDescriptionContainer} + > + {showBadge ? ( + <Flex + align='center' + gap={3} + elementDescriptor={descriptors.planDetailBadgeContainer} + sx={t => ({ + paddingInlineEnd: t.space.$10, + })} + > + {subscription?.status === 'active' || (isImplicitlyActiveOrUpcoming && subscriptions.length === 0) ? ( + <Badge + elementDescriptor={descriptors.planDetailBadge} + localizationKey={localizationKeys('badge__activePlan')} + colorScheme={'secondary'} + /> + ) : ( + <Badge + elementDescriptor={descriptors.planDetailBadge} + localizationKey={localizationKeys('badge__upcomingPlan')} + colorScheme={'primary'} + /> + )} + {!!subscription && ( + <Text + elementDescriptor={descriptors.planDetailCaption} + variant={'caption'} + localizationKey={captionForSubscription(subscription)} + colorScheme='secondary' + /> + )} + </Flex> + ) : null} + {plan.avatarUrl ? ( + <Avatar + boxElementDescriptor={descriptors.planDetailAvatar} + size={_ => 40} + title={plan.name} + initials={plan.name[0]} + rounded={false} + imageUrl={plan.avatarUrl} + sx={t => ({ + marginBlockEnd: t.space.$3, + })} + /> + ) : null} + <Col + gap={1} + elementDescriptor={descriptors.planDetailTitleDescriptionContainer} + > + <Heading + elementDescriptor={descriptors.planDetailTitle} + as='h2' + textVariant='h2' + > + {plan.name} + </Heading> + {plan.description ? ( + <Text + elementDescriptor={descriptors.planDetailDescription} + variant='subtitle' + colorScheme='secondary' + > + {plan.description} + </Text> + ) : null} + </Col> + </Col> + + <Flex + elementDescriptor={descriptors.planDetailFeeContainer} + align='center' + wrap='wrap' + sx={t => ({ + marginTop: t.space.$3, + columnGap: t.space.$1x5, + })} + > + <> + <Text + elementDescriptor={descriptors.planDetailFee} + variant='h1' + colorScheme='body' + > + {plan.currencySymbol} + {getPlanFee} + </Text> + <Text + elementDescriptor={descriptors.planDetailFeePeriod} + variant='caption' + colorScheme='secondary' + sx={t => ({ + textTransform: 'lowercase', + ':before': { + content: '"/"', + marginInlineEnd: t.space.$1, + }, + })} + localizationKey={localizationKeys('commerce.month')} + /> + </> + </Flex> + + {plan.annualMonthlyAmount > 0 ? ( + <Box + elementDescriptor={descriptors.planDetailPeriodToggle} + sx={t => ({ + display: 'flex', + marginTop: t.space.$3, + })} + > + <Switch + isChecked={planPeriod === 'annual'} + onChange={(checked: boolean) => setPlanPeriod(checked ? 'annual' : 'month')} + label={localizationKeys('commerce.billedAnnually')} + /> + </Box> + ) : ( + <Text + elementDescriptor={descriptors.pricingTableCardFeePeriodNotice} + variant='caption' + colorScheme='secondary' + localizationKey={ + plan.isDefault ? localizationKeys('commerce.alwaysFree') : localizationKeys('commerce.billedMonthlyOnly') + } + sx={t => ({ + justifySelf: 'flex-start', + alignSelf: 'center', + marginTop: t.space.$3, + })} + /> + )} + </Box> + ); +}); diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index 0ec0005d9b5..7e35dcca5c7 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -363,10 +363,7 @@ export const usePlansContext = () => { const portalRoot = getClosestProfileScrollBox(mode, event); if (subscription && subscription.planPeriod === planPeriod && !subscription.canceledAt) { - clerk.__internal_openPlanDetails({ - plan, - initialPlanPeriod: planPeriod, - subscriberType, + clerk.__experimental_openSubscriptionDetails({ onSubscriptionCancel: () => { revalidateAll(); onSubscriptionChange?.(); diff --git a/packages/clerk-js/src/ui/elements/contexts/index.tsx b/packages/clerk-js/src/ui/elements/contexts/index.tsx index efa927e5593..c91fb670ff1 100644 --- a/packages/clerk-js/src/ui/elements/contexts/index.tsx +++ b/packages/clerk-js/src/ui/elements/contexts/index.tsx @@ -88,7 +88,8 @@ export type FlowMetadata = { | 'planDetails' | 'pricingTable' | 'apiKeys' - | 'oauthConsent'; + | 'oauthConsent' + | 'subscriptionDetails'; part?: | 'start' | 'emailCode' diff --git a/packages/clerk-js/src/ui/lazyModules/MountedPlanDetailDrawer.tsx b/packages/clerk-js/src/ui/lazyModules/MountedPlanDetailDrawer.tsx index 2d16fd8e4d9..3b703f1c4f2 100644 --- a/packages/clerk-js/src/ui/lazyModules/MountedPlanDetailDrawer.tsx +++ b/packages/clerk-js/src/ui/lazyModules/MountedPlanDetailDrawer.tsx @@ -36,12 +36,7 @@ export function MountedPlanDetailDrawer({ portalId={planDetailsDrawer.props.portalId} portalRoot={planDetailsDrawer.props.portalRoot as HTMLElement | null | undefined} > - <PlanDetails - {...planDetailsDrawer.props} - subscriberType={planDetailsDrawer.props.subscriberType || 'user'} - onSubscriptionCancel={planDetailsDrawer.props.onSubscriptionCancel || (() => {})} - appearance={planDetailsDrawer.props.appearance} - /> + <PlanDetails {...planDetailsDrawer.props} /> </LazyDrawerRenderer> ); } diff --git a/packages/clerk-js/src/ui/lazyModules/MountedSubscriptionDetailDrawer.tsx b/packages/clerk-js/src/ui/lazyModules/MountedSubscriptionDetailDrawer.tsx new file mode 100644 index 00000000000..4a6186c4701 --- /dev/null +++ b/packages/clerk-js/src/ui/lazyModules/MountedSubscriptionDetailDrawer.tsx @@ -0,0 +1,42 @@ +import { useUser } from '@clerk/shared/react'; +import type { __experimental_SubscriptionDetailsProps, Appearance } from '@clerk/types'; + +import { SubscriptionDetails } from '../components/SubscriptionDetails'; +import { LazyDrawerRenderer } from './providers'; + +export function MountedSubscriptionDetailDrawer({ + appearance, + subscriptionDetailsDrawer, + onOpenChange, +}: { + appearance?: Appearance; + onOpenChange: (open: boolean) => void; + subscriptionDetailsDrawer: { + open: false; + props: null | __experimental_SubscriptionDetailsProps; + }; +}) { + const { user } = useUser(); + if (!subscriptionDetailsDrawer.props) { + return null; + } + + return ( + <LazyDrawerRenderer + // We set `key` to be the user id to "reset" floating ui portals on session switch. + // Without this, the drawer would not be rendered after a session switch. + key={user?.id} + globalAppearance={appearance} + appearanceKey={'planDetails' as any} + componentAppearance={subscriptionDetailsDrawer.props.appearance || {}} + flowName={'subscriptionDetails'} + open={subscriptionDetailsDrawer.open} + onOpenChange={onOpenChange} + componentName={'SubscriptionDetails'} + portalId={subscriptionDetailsDrawer.props.portalId} + portalRoot={subscriptionDetailsDrawer.props.portalRoot as HTMLElement | null | undefined} + > + <SubscriptionDetails {...subscriptionDetailsDrawer.props} /> + </LazyDrawerRenderer> + ); +} diff --git a/packages/clerk-js/src/ui/lazyModules/components.ts b/packages/clerk-js/src/ui/lazyModules/components.ts index d0c38843dc2..daa94dc0368 100644 --- a/packages/clerk-js/src/ui/lazyModules/components.ts +++ b/packages/clerk-js/src/ui/lazyModules/components.ts @@ -20,7 +20,8 @@ const componentImportPaths = { PricingTable: () => import(/* webpackChunkName: "pricingTable" */ '../components/PricingTable'), Checkout: () => import(/* webpackChunkName: "checkout" */ '../components/Checkout'), SessionTasks: () => import(/* webpackChunkName: "sessionTasks" */ '../components/SessionTasks'), - PlanDetails: () => import(/* webpackChunkName: "planDetails" */ '../components/Plans'), + PlanDetails: () => import(/* webpackChunkName: "planDetails" */ '../components/Plans/PlanDetails'), + SubscriptionDetails: () => import(/* webpackChunkName: "subscriptionDetails" */ '../components/SubscriptionDetails'), APIKeys: () => import(/* webpackChunkName: "apiKeys" */ '../components/ApiKeys/ApiKeys'), OAuthConsent: () => import(/* webpackChunkName: "oauthConsent" */ '../components/OAuthConsent/OAuthConsent'), } as const; @@ -106,6 +107,10 @@ export const PlanDetails = lazy(() => componentImportPaths.PlanDetails().then(module => ({ default: module.PlanDetails })), ); +export const SubscriptionDetails = lazy(() => + componentImportPaths.SubscriptionDetails().then(module => ({ default: module.SubscriptionDetails })), +); + export const OAuthConsent = lazy(() => componentImportPaths.OAuthConsent().then(module => ({ default: module.OAuthConsent })), ); @@ -143,6 +148,7 @@ export const ClerkComponents = { PlanDetails, APIKeys, OAuthConsent, + SubscriptionDetails, }; export type ClerkComponentName = keyof typeof ClerkComponents; diff --git a/packages/clerk-js/src/ui/lazyModules/drawers.tsx b/packages/clerk-js/src/ui/lazyModules/drawers.tsx index 3e05ccdddef..7022e169c1d 100644 --- a/packages/clerk-js/src/ui/lazyModules/drawers.tsx +++ b/packages/clerk-js/src/ui/lazyModules/drawers.tsx @@ -6,3 +6,9 @@ export const MountedCheckoutDrawer = lazy(() => export const MountedPlanDetailDrawer = lazy(() => import('./MountedPlanDetailDrawer').then(module => ({ default: module.MountedPlanDetailDrawer })), ); + +export const MountedSubscriptionDetailDrawer = lazy(() => + import('./MountedSubscriptionDetailDrawer').then(module => ({ + default: module.MountedSubscriptionDetailDrawer, + })), +); diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index c92f3d57692..d2f0894733c 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -3,6 +3,8 @@ import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus'; import { loadClerkJsScript } from '@clerk/shared/loadClerkJsScript'; import { handleValueOrFn } from '@clerk/shared/utils'; import type { + __experimental_PlanDetailsProps, + __experimental_SubscriptionDetailsProps, __internal_CheckoutProps, __internal_OAuthConsentProps, __internal_PlanDetailsProps, @@ -119,7 +121,8 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private preopenUserVerification?: null | __internal_UserVerificationProps = null; private preopenSignIn?: null | SignInProps = null; private preopenCheckout?: null | __internal_CheckoutProps = null; - private preopenPlanDetails?: null | __internal_PlanDetailsProps = null; + private preopenPlanDetails?: null | __experimental_PlanDetailsProps = null; + private preopenSubscriptionDetails?: null | __experimental_SubscriptionDetailsProps = null; private preopenSignUp?: null | SignUpProps = null; private preopenUserProfile?: null | UserProfileProps = null; private preopenOrganizationProfile?: null | OrganizationProfileProps = null; @@ -557,7 +560,11 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } if (this.preopenPlanDetails !== null) { - clerkjs.__internal_openPlanDetails(this.preopenPlanDetails); + clerkjs.__experimental_openPlanDetails(this.preopenPlanDetails); + } + + if (this.preopenSubscriptionDetails !== null) { + clerkjs.__experimental_openSubscriptionDetails(this.preopenSubscriptionDetails); } if (this.preopenSignUp !== null) { @@ -788,6 +795,38 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + __experimental_openPlanDetails = (props?: __experimental_PlanDetailsProps) => { + if (this.clerkjs && this.loaded) { + this.clerkjs.__experimental_openPlanDetails(props); + } else { + this.preopenPlanDetails = props; + } + }; + + __experimental_closePlanDetails = () => { + if (this.clerkjs && this.loaded) { + this.clerkjs.__experimental_closePlanDetails(); + } else { + this.preopenPlanDetails = null; + } + }; + + __experimental_openSubscriptionDetails = (props?: __experimental_SubscriptionDetailsProps) => { + if (this.clerkjs && this.loaded) { + this.clerkjs.__experimental_openSubscriptionDetails(props); + } else { + this.preopenSubscriptionDetails = props; + } + }; + + __experimental_closeSubscriptionDetails = () => { + if (this.clerkjs && this.loaded) { + this.clerkjs.__experimental_closeSubscriptionDetails(); + } else { + this.preopenSubscriptionDetails = null; + } + }; + __internal_openReverification = (props?: __internal_UserVerificationModalProps) => { if (this.clerkjs && this.loaded) { this.clerkjs.__internal_openReverification(props); diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index e25115150a9..a5a9f89fbda 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -232,6 +232,29 @@ export interface Clerk { */ __internal_closePlanDetails: () => void; + /** + * Opens the Clerk PlanDetails drawer component in a drawer. + * @param props Optional subscription details drawer configuration parameters. + */ + __experimental_openPlanDetails: (props?: __experimental_PlanDetailsProps) => void; + + /** + * Closes the Clerk PlanDetails drawer. + */ + __experimental_closePlanDetails: () => void; + + /** + * Opens the Clerk PlanDetails drawer component in a drawer. + * @param props Optional subscription details drawer configuration parameters. + */ + __experimental_openSubscriptionDetails: (props?: __experimental_SubscriptionDetailsProps) => void; + + /** + * Closes the Clerk PlanDetails drawer. + */ + __experimental_closeSubscriptionDetails: () => void; + + /** /** Opens the Clerk UserVerification component in a modal. * @param props Optional user verification configuration parameters. */ @@ -1728,6 +1751,22 @@ export type __internal_PlanDetailsProps = { portalRoot?: PortalRoot; }; +export type __experimental_PlanDetailsProps = { + appearance?: PlanDetailTheme; + plan?: CommercePlanResource; + planId?: string; + initialPlanPeriod?: CommerceSubscriptionPlanPeriod; + portalId?: string; + portalRoot?: PortalRoot; +}; + +export type __experimental_SubscriptionDetailsProps = { + appearance?: PlanDetailTheme; + onSubscriptionCancel?: () => void; + portalId?: string; + portalRoot?: PortalRoot; +}; + export type __internal_OAuthConsentProps = { appearance?: OAuthConsentTheme; /** diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index 8b27f61d081..3caf459548d 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -10,6 +10,7 @@ type WithOptionalOrgType<T> = T & { export interface CommerceBillingNamespace { getPaymentAttempts: (params: GetPaymentAttemptsParams) => Promise<ClerkPaginatedResponse<CommercePaymentResource>>; getPlans: (params?: GetPlansParams) => Promise<CommercePlanResource[]>; + getPlan: (params: { id: string }) => Promise<CommercePlanResource>; getSubscriptions: (params: GetSubscriptionsParams) => Promise<ClerkPaginatedResponse<CommerceSubscriptionResource>>; getStatements: (params: GetStatementsParams) => Promise<ClerkPaginatedResponse<CommerceStatementResource>>; startCheckout: (params: CreateCheckoutParams) => Promise<CommerceCheckoutResource>; From 2041111d6adcae5b872a66c0e7c61ac1f9fbd89e Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Tue, 17 Jun 2025 18:58:04 +0300 Subject: [PATCH 02/34] wip 2 --- packages/clerk-js/src/ui/Components.tsx | 3 +- .../components/SubscriptionDetails/index.tsx | 262 +++++++++++++----- 2 files changed, 200 insertions(+), 65 deletions(-) diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index ee5d4dbd96d..59b124f1fb9 100644 --- a/packages/clerk-js/src/ui/Components.tsx +++ b/packages/clerk-js/src/ui/Components.tsx @@ -38,8 +38,7 @@ import { UserVerificationModal, WaitlistModal, } from './lazyModules/components'; -import { MountedCheckoutDrawer, MountedPlanDetailDrawer } from './lazyModules/drawers'; -import { MountedSubscriptionDetailDrawer } from './lazyModules/MountedSubscriptionDetailDrawer'; +import { MountedCheckoutDrawer, MountedPlanDetailDrawer, MountedSubscriptionDetailDrawer } from './lazyModules/drawers'; import { LazyComponentRenderer, LazyImpersonationFabProvider, diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index 66cdc9a8d7b..d0d7168f975 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -11,28 +11,37 @@ import type { import * as React from 'react'; import { useMemo, useState } from 'react'; -import { Alert } from '@/ui/elements/Alert'; import { Avatar } from '@/ui/elements/Avatar'; import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; import { Switch } from '@/ui/elements/Switch'; +import { Icon } from '@/ui/primitives/Icon'; +import { truncateWithEndVisible } from '@/ui/utils/truncateTextWithEndVisible'; import { useProtect } from '../../common'; -import { SubscriberTypeContext, usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; -import { Badge, Box, Button, Col, descriptors, Flex, Heading, localizationKeys, Span, Text } from '../../customizables'; -import { handleError } from '../../utils'; +import { usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; +import type { LocalizationKey } from '../../customizables'; +import { + Badge, + Box, + Col, + descriptors, + Flex, + Heading, + localizationKeys, + Span, + Spinner, + Text, +} from '../../customizables'; export const SubscriptionDetails = (props: __experimental_SubscriptionDetailsProps) => { - console.log('SubscriptionDetails', props); return ( - <SubscriberTypeContext.Provider value={'user'}> - <Drawer.Content> - <PlanDetailsInternal {...props} /> - </Drawer.Content> - </SubscriberTypeContext.Provider> + <Drawer.Content> + <SubscriptionDetailsInternal {...props} /> + </Drawer.Content> ); }; -const PlanDetailsInternal = ({ +const SubscriptionDetailsInternal = ({ plan, onSubscriptionCancel, portalRoot, @@ -46,22 +55,23 @@ const PlanDetailsInternal = ({ const [planPeriod, setPlanPeriod] = useState<CommerceSubscriptionPlanPeriod>(initialPlanPeriod); const { setIsOpen } = useDrawerContext(); - const { - activeOrUpcomingSubscriptionBasedOnPlanPeriod, - revalidateAll, - buttonPropsForPlan, - isDefaultPlanImplicitlyActiveOrUpcoming, - } = usePlansContext(); + const { revalidateAll, buttonPropsForPlan, isDefaultPlanImplicitlyActiveOrUpcoming } = usePlansContext(); const subscriberType = useSubscriberTypeContext(); const canManageBilling = useProtect( has => has({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user', ); - if (!plan) { - return null; - } + const { data: subscriptions, isLoading } = useSubscriptions(); - const subscription = activeOrUpcomingSubscriptionBasedOnPlanPeriod(plan, planPeriod); + if (isLoading) { + return ( + <Spinner + sx={{ + margin: 'auto', + }} + /> + ); + } const handleClose = () => { if (setIsOpen) { @@ -69,27 +79,20 @@ const PlanDetailsInternal = ({ } }; - const features = plan.features; - const hasFeatures = features.length > 0; const cancelSubscription = async () => { - if (!subscription) { - return; - } - - setCancelError(undefined); - setIsSubmitting(true); - - await subscription - .cancel({ orgId: subscriberType === 'org' ? organization?.id : undefined }) - .then(() => { - setIsSubmitting(false); - onSubscriptionCancel?.(); - handleClose(); - }) - .catch(error => { - handleError(error, [], setCancelError); - setIsSubmitting(false); - }); + // setCancelError(undefined); + // setIsSubmitting(true); + // await subscription + // .cancel({ orgId: subscriberType === 'org' ? organization?.id : undefined }) + // .then(() => { + // setIsSubmitting(false); + // onSubscriptionCancel?.(); + // handleClose(); + // }) + // .catch(error => { + // handleError(error, [], setCancelError); + // setIsSubmitting(false); + // }); }; type Open__internal_CheckoutProps = { @@ -119,26 +122,75 @@ const PlanDetailsInternal = ({ return ( <> <Drawer.Header - sx={t => - !hasFeatures - ? { - flex: 1, - borderBottomWidth: 0, - background: t.colors.$colorBackground, - } - : null + title={ + 'Subscription' + // localizationKeys('commerce.checkout.title') } + /> + + <Col + gap={4} + sx={t => ({ + padding: t.space.$4, + })} > - <Header - plan={plan} - subscription={subscription} - planPeriod={planPeriod} - setPlanPeriod={setPlanPeriod} - closeSlot={<Drawer.Close />} - /> - </Drawer.Header> + {subscriptions?.map(subscriptionItem => ( + <Col + gap={3} + key={subscriptionItem.id} + sx={t => ({ + padding: t.space.$3, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$neutralAlpha100, + borderRadius: t.radii.$md, + })} + > + <Flex> + <Text + sx={{ + marginRight: 'auto', + }} + > + {subscriptionItem.plan.name} + </Text> + <Badge + colorScheme={subscriptionItem.status === 'active' ? 'secondary' : 'primary'} + localizationKey={ + subscriptionItem.status === 'active' + ? localizationKeys('badge__activePlan') + : localizationKeys('badge__upcomingPlan') + } + /> + </Flex> + + <Box + elementDescriptor={descriptors.statementSectionContentDetailsList} + as='ul' + sx={t => ({ + margin: 0, + padding: 0, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$neutralAlpha100, + borderRadius: t.radii.$md, + overflow: 'hidden', + })} + > + <PriceItem + label={'Monthly price'} + value={`${subscriptionItem.plan.currencySymbol}${subscriptionItem.plan.amountFormatted} / mo`} + /> + <PriceItem + label={'Annual discount'} + value={`${subscriptionItem.plan.currencySymbol}${subscriptionItem.plan.annualMonthlyAmountFormatted} / mo`} + /> + </Box> + </Col> + ))} + </Col> - {hasFeatures ? ( + {/* {hasFeatures ? ( <Drawer.Body> <Text elementDescriptor={descriptors.planDetailCaption} @@ -207,9 +259,9 @@ const PlanDetailsInternal = ({ ))} </Box> </Drawer.Body> - ) : null} + ) : null} */} - {(!plan.isDefault && !isDefaultPlanImplicitlyActiveOrUpcoming) || !subscription ? ( + {/* {(!plan.isDefault && !isDefaultPlanImplicitlyActiveOrUpcoming) || !subscription ? ( <Drawer.Footer> {subscription ? ( subscription.canceledAt ? ( @@ -266,9 +318,9 @@ const PlanDetailsInternal = ({ /> )} </Drawer.Footer> - ) : null} + ) : null} */} - {subscription ? ( + {/* {subscription ? ( <Drawer.Confirmation open={showConfirmation} onOpenChange={setShowConfirmation} @@ -328,11 +380,95 @@ const PlanDetailsInternal = ({ <Alert colorScheme='danger'>{typeof cancelError === 'string' ? cancelError : cancelError.message}</Alert> )} </Drawer.Confirmation> - ) : null} + ) : null} */} </> ); }; +function PriceItem({ + labelIcon, + label, + valueCopyable = false, + value, + valueTruncated = false, +}: { + icon?: React.ReactNode; + label: string | LocalizationKey; + labelIcon?: React.ComponentType; + value: string | LocalizationKey; + valueTruncated?: boolean; + valueCopyable?: boolean; +}) { + return ( + <Box + elementDescriptor={descriptors.statementSectionContentDetailsListItem} + as='li' + sx={t => ({ + margin: 0, + paddingInline: t.space.$2, + paddingBlock: t.space.$1x5, + display: 'flex', + justifyContent: 'space-between', + flexWrap: 'wrap', + columnGap: t.space.$2, + rowGap: t.space.$0x5, + '&:not(:first-child)': { + borderBlockStartWidth: t.borderWidths.$normal, + borderBlockStartStyle: t.borderStyles.$solid, + borderBlockStartColor: t.colors.$neutralAlpha100, + }, + })} + > + <Span + elementDescriptor={descriptors.statementSectionContentDetailsListItemLabelContainer} + sx={t => ({ + display: 'flex', + alignItems: 'center', + gap: t.space.$1x5, + })} + > + {labelIcon ? ( + <Icon + icon={labelIcon} + colorScheme='neutral' + /> + ) : null} + <Text + variant='caption' + colorScheme='secondary' + elementDescriptor={descriptors.statementSectionContentDetailsListItemLabel} + localizationKey={label} + /> + </Span> + <Span + sx={t => ({ + display: 'flex', + alignItems: 'center', + gap: t.space.$0x25, + color: t.colors.$colorTextSecondary, + })} + > + {typeof value === 'string' ? ( + <Text + colorScheme='secondary' + variant='caption' + elementDescriptor={descriptors.statementSectionContentDetailsListItemValue} + > + {valueTruncated ? truncateWithEndVisible(value) : value} + </Text> + ) : ( + <Text + elementDescriptor={descriptors.statementSectionContentDetailsListItemValue} + colorScheme='secondary' + variant='caption' + localizationKey={value} + /> + )} + </Span> + </Box> + ); +} + /* ------------------------------------------------------------------------------------------------- * Header * -----------------------------------------------------------------------------------------------*/ From e7a3f26d7f62e5aa964801fec26bae70c5c33a54 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Wed, 18 Jun 2025 15:17:11 +0300 Subject: [PATCH 03/34] create the layout and functionality --- packages/clerk-js/src/core/clerk.ts | 1 - .../core/resources/CommerceSubscription.ts | 15 +- .../components/SubscriptionDetails/index.tsx | 899 ++++++++---------- .../src/ui/contexts/components/Plans.tsx | 3 +- packages/types/src/commerce.ts | 7 +- packages/types/src/json.ts | 1 + 6 files changed, 418 insertions(+), 508 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index aa09557c6c1..e48ac39362d 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -635,7 +635,6 @@ export class Clerk implements ClerkInterface { public __experimental_openSubscriptionDetails = (props?: __experimental_SubscriptionDetailsProps): void => { this.assertComponentsReady(this.#componentControls); - console.log('__experimental_openSubscriptionDetails', props); void this.#componentControls .ensureMounted({ preloadHint: 'SubscriptionDetails' }) .then(controls => controls.openDrawer('subscriptionDetails', props || {})); diff --git a/packages/clerk-js/src/core/resources/CommerceSubscription.ts b/packages/clerk-js/src/core/resources/CommerceSubscription.ts index aa45426968a..26ad8a2f74d 100644 --- a/packages/clerk-js/src/core/resources/CommerceSubscription.ts +++ b/packages/clerk-js/src/core/resources/CommerceSubscription.ts @@ -9,6 +9,7 @@ import type { } from '@clerk/types'; import { commerceMoneyFromJSON } from '../../utils'; +import { unixEpochToDate } from '../../utils/date'; import { BaseResource, CommercePlan, DeletedObject } from './internal'; export class CommerceSubscription extends BaseResource implements CommerceSubscriptionResource { @@ -17,9 +18,10 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr plan!: CommercePlan; planPeriod!: CommerceSubscriptionPlanPeriod; status!: CommerceSubscriptionStatus; - periodStart!: number; - periodEnd!: number; - canceledAt!: number | null; + createdAt!: Date; + periodStart!: Date; + periodEnd!: Date; + canceledAt!: Date | null; amount?: CommerceMoney; credit?: { amount: CommerceMoney; @@ -39,9 +41,10 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr this.plan = new CommercePlan(data.plan); this.planPeriod = data.plan_period; this.status = data.status; - this.periodStart = data.period_start; - this.periodEnd = data.period_end; - this.canceledAt = data.canceled_at; + this.createdAt = unixEpochToDate(data.created_at); + this.periodStart = unixEpochToDate(data.period_start); + this.periodEnd = unixEpochToDate(data.period_end); + this.canceledAt = data.canceled_at ? unixEpochToDate(data.canceled_at) : null; this.amount = data.amount ? commerceMoneyFromJSON(data.amount) : undefined; this.credit = data.credit && data.credit.amount ? { amount: commerceMoneyFromJSON(data.credit.amount) } : undefined; return this; diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index d0d7168f975..d93a88b03b0 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -1,32 +1,35 @@ import { useClerk, useOrganization } from '@clerk/shared/react'; import type { __experimental_SubscriptionDetailsProps, - __internal_PlanDetailsProps, + __internal_CheckoutProps, ClerkAPIError, ClerkRuntimeError, - CommercePlanResource, - CommerceSubscriptionPlanPeriod, CommerceSubscriptionResource, } from '@clerk/types'; import * as React from 'react'; -import { useMemo, useState } from 'react'; +import { useState } from 'react'; -import { Avatar } from '@/ui/elements/Avatar'; import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; -import { Switch } from '@/ui/elements/Switch'; -import { Icon } from '@/ui/primitives/Icon'; +import { Check } from '@/ui/icons'; +import { common } from '@/ui/styledSystem/common'; +import { colors } from '@/ui/utils/colors'; +import { handleError } from '@/ui/utils/errorHandler'; +import { formatDate } from '@/ui/utils/formatDate'; import { truncateWithEndVisible } from '@/ui/utils/truncateTextWithEndVisible'; import { useProtect } from '../../common'; import { usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; import type { LocalizationKey } from '../../customizables'; import { + Alert, Badge, Box, + Button, Col, descriptors, Flex, Heading, + Icon, localizationKeys, Span, Spinner, @@ -41,21 +44,19 @@ export const SubscriptionDetails = (props: __experimental_SubscriptionDetailsPro ); }; -const SubscriptionDetailsInternal = ({ - plan, - onSubscriptionCancel, - portalRoot, - initialPlanPeriod = 'month', -}: __internal_PlanDetailsProps) => { +const SubscriptionDetailsInternal = ({ onSubscriptionCancel, portalRoot }: __experimental_SubscriptionDetailsProps) => { const clerk = useClerk(); - const { organization } = useOrganization(); + const { organization: _organization } = useOrganization(); const [showConfirmation, setShowConfirmation] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [cancelError, setCancelError] = useState<ClerkRuntimeError | ClerkAPIError | string | undefined>(); - const [planPeriod, setPlanPeriod] = useState<CommerceSubscriptionPlanPeriod>(initialPlanPeriod); const { setIsOpen } = useDrawerContext(); - const { revalidateAll, buttonPropsForPlan, isDefaultPlanImplicitlyActiveOrUpcoming } = usePlansContext(); + const { + revalidateAll, + buttonPropsForPlan: _buttonPropsForPlan, + isDefaultPlanImplicitlyActiveOrUpcoming: _isDefaultPlanImplicitlyActiveOrUpcoming, + } = usePlansContext(); const subscriberType = useSubscriberTypeContext(); const canManageBilling = useProtect( has => has({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user', @@ -79,39 +80,31 @@ const SubscriptionDetailsInternal = ({ } }; - const cancelSubscription = async () => { - // setCancelError(undefined); - // setIsSubmitting(true); - // await subscription - // .cancel({ orgId: subscriberType === 'org' ? organization?.id : undefined }) - // .then(() => { - // setIsSubmitting(false); - // onSubscriptionCancel?.(); - // handleClose(); - // }) - // .catch(error => { - // handleError(error, [], setCancelError); - // setIsSubmitting(false); - // }); - }; + const cancelSubscription = async (subscription: CommerceSubscriptionResource) => { + setCancelError(undefined); + setIsSubmitting(true); - type Open__internal_CheckoutProps = { - planPeriod?: CommerceSubscriptionPlanPeriod; + await subscription + .cancel( + // { orgId: subscriberType === 'org' ? organization?.id : undefined } + {}, + ) + .then(() => { + setIsSubmitting(false); + onSubscriptionCancel?.(); + handleClose(); + }) + .catch(error => { + handleError(error, [], setCancelError); + setIsSubmitting(false); + }); }; - const openCheckout = (props?: Open__internal_CheckoutProps) => { + const openCheckout = (params?: __internal_CheckoutProps) => { handleClose(); - // if the plan doesn't support annual, use monthly - let _planPeriod = props?.planPeriod || planPeriod; - if (_planPeriod === 'annual' && plan.annualMonthlyAmount === 0) { - _planPeriod = 'month'; - } - clerk.__internal_openCheckout({ - planId: plan.id, - planPeriod: _planPeriod, - subscriberType: subscriberType, + ...params, onSubscriptionComplete: () => { void revalidateAll(); }, @@ -119,276 +112,297 @@ const SubscriptionDetailsInternal = ({ }); }; + // Mock data for demonstration - in real implementation this would come from the subscriptions data + const activeSubscription = subscriptions?.find(sub => sub.status === 'active'); + const upcomingSubscription = subscriptions?.find(sub => sub.status === 'upcoming'); + + if (!activeSubscription) { + // Should never happen, but just in case + return null; + } + + const subscription = upcomingSubscription || activeSubscription; + return ( <> - <Drawer.Header - title={ - 'Subscription' - // localizationKeys('commerce.checkout.title') - } - /> + <Drawer.Header title='Subscription' /> - <Col - gap={4} - sx={t => ({ - padding: t.space.$4, - })} - > - {subscriptions?.map(subscriptionItem => ( - <Col - gap={3} - key={subscriptionItem.id} - sx={t => ({ - padding: t.space.$3, - borderWidth: t.borderWidths.$normal, - borderStyle: t.borderStyles.$solid, - borderColor: t.colors.$neutralAlpha100, - borderRadius: t.radii.$md, - })} - > - <Flex> - <Text - sx={{ - marginRight: 'auto', - }} - > - {subscriptionItem.plan.name} + <Drawer.Body> + <Col + gap={4} + sx={t => ({ + padding: t.space.$4, + overflowY: 'auto', + })} + > + {/* Subscription Cards */} + {subscriptions?.map(subscriptionItem => ( + <SubscriptionCard + key={subscriptionItem.id} + subscription={subscriptionItem} + /> + ))} + </Col> + + {/* Billing Information */} + + <Col + gap={3} + as='ul' + sx={t => ({ + marginTop: 'auto', + paddingBlock: t.space.$4, + borderTopWidth: t.borderWidths.$normal, + borderTopStyle: t.borderStyles.$solid, + borderTopColor: t.colors.$neutralAlpha100, + })} + > + <SummaryItem> + <SummmaryItemLabel> + <Text colorScheme='secondary'>Current billing cycle</Text> + </SummmaryItemLabel> + <SummmaryItemValue> + <Text colorScheme='secondary'>{activeSubscription.planPeriod === 'month' ? 'Monthly' : 'Annually'}</Text> + </SummmaryItemValue> + </SummaryItem> + <SummaryItem> + <SummmaryItemLabel> + <Text colorScheme='secondary'>Next payment on</Text> + </SummmaryItemLabel> + <SummmaryItemValue> + <Text colorScheme='secondary'> + {upcomingSubscription + ? formatDate(upcomingSubscription.periodStart) + : formatDate(subscription.periodEnd)} </Text> - <Badge - colorScheme={subscriptionItem.status === 'active' ? 'secondary' : 'primary'} - localizationKey={ - subscriptionItem.status === 'active' - ? localizationKeys('badge__activePlan') - : localizationKeys('badge__upcomingPlan') - } - /> - </Flex> - - <Box - elementDescriptor={descriptors.statementSectionContentDetailsList} - as='ul' - sx={t => ({ - margin: 0, - padding: 0, - borderWidth: t.borderWidths.$normal, - borderStyle: t.borderStyles.$solid, - borderColor: t.colors.$neutralAlpha100, - borderRadius: t.radii.$md, - overflow: 'hidden', - })} - > - <PriceItem - label={'Monthly price'} - value={`${subscriptionItem.plan.currencySymbol}${subscriptionItem.plan.amountFormatted} / mo`} - /> - <PriceItem - label={'Annual discount'} - value={`${subscriptionItem.plan.currencySymbol}${subscriptionItem.plan.annualMonthlyAmountFormatted} / mo`} - /> - </Box> - </Col> - ))} - </Col> + </SummmaryItemValue> + </SummaryItem> + <SummaryItem> + <SummmaryItemLabel> + <Text>Next payment amount</Text> + </SummmaryItemLabel> + <SummmaryItemValue> + <Text> + {`${subscription.plan.currencySymbol}${subscription.planPeriod === 'month' ? subscription.plan.amountFormatted : subscription.plan.annualAmountFormatted}`} + </Text> + </SummmaryItemValue> + </SummaryItem> + </Col> + </Drawer.Body> - {/* {hasFeatures ? ( - <Drawer.Body> - <Text - elementDescriptor={descriptors.planDetailCaption} - variant={'caption'} - localizationKey={localizationKeys('commerce.availableFeatures')} - colorScheme='secondary' - sx={t => ({ - padding: t.space.$4, - paddingBottom: 0, - })} - /> - <Box - elementDescriptor={descriptors.planDetailFeaturesList} - as='ul' - role='list' - sx={t => ({ - display: 'grid', - rowGap: t.space.$6, - padding: t.space.$4, - margin: 0, - })} - > - {features.map(feature => ( - <Box - key={feature.id} - elementDescriptor={descriptors.planDetailFeaturesListItem} - as='li' - sx={t => ({ - display: 'flex', - alignItems: 'baseline', - gap: t.space.$3, - })} - > - {feature.avatarUrl ? ( - <Avatar - size={_ => 24} - title={feature.name} - initials={feature.name[0]} - rounded={false} - imageUrl={feature.avatarUrl} - /> - ) : null} - <Span elementDescriptor={descriptors.planDetailFeaturesListItemContent}> - <Text - elementDescriptor={descriptors.planDetailFeaturesListItemTitle} - colorScheme='body' - sx={t => ({ - fontWeight: t.fontWeights.$medium, - })} - > - {feature.name} - </Text> - {feature.description ? ( - <Text - elementDescriptor={descriptors.planDetailFeaturesListItemDescription} - colorScheme='secondary' - sx={t => ({ - marginBlockStart: t.space.$0x25, - })} - > - {feature.description} - </Text> - ) : null} - </Span> - </Box> - ))} - </Box> - </Drawer.Body> - ) : null} */} - - {/* {(!plan.isDefault && !isDefaultPlanImplicitlyActiveOrUpcoming) || !subscription ? ( + {/* If either the active or upcoming subscription is the free plan, then a C1 cannot switch to a different period or cancel the plan */} + {!subscription?.plan.isDefault ? ( <Drawer.Footer> - {subscription ? ( - subscription.canceledAt ? ( + <Col gap={4}> + {subscription.planPeriod === 'month' && subscription.plan.annualMonthlyAmount > 0 && ( <Button block + variant='bordered' + colorScheme='secondary' textVariant='buttonLarge' - {...buttonPropsForPlan({ plan })} - onClick={() => openCheckout()} + isDisabled={!canManageBilling} + // onClick={() => openCheckout({ planPeriod: 'annual' })} + onClick={() => { + openCheckout({ + planId: subscription.plan.id, + planPeriod: subscription.planPeriod === 'month' ? 'annual' : 'month', + subscriberType: subscriberType, + }); + }} + localizationKey={ + subscription.planPeriod === 'month' + ? localizationKeys('commerce.switchToAnnual') + : localizationKeys('commerce.switchToMonthly') + } /> - ) : ( - <Col gap={4}> - {!!subscription && - subscription.planPeriod === 'month' && - plan.annualMonthlyAmount > 0 && - planPeriod === 'annual' ? ( - <Button - block - variant='bordered' - colorScheme='secondary' - textVariant='buttonLarge' - isDisabled={!canManageBilling} - onClick={() => openCheckout({ planPeriod: 'annual' })} - localizationKey={localizationKeys('commerce.switchToAnnual')} - /> - ) : null} - {!!subscription && subscription.planPeriod === 'annual' && planPeriod === 'month' ? ( + )} + + <Button + block + variant='bordered' + colorScheme='danger' + textVariant='buttonLarge' + isDisabled={!canManageBilling} + onClick={() => setShowConfirmation(true)} + localizationKey={localizationKeys('commerce.cancelSubscription')} + /> + </Col> + + <Drawer.Confirmation + open={showConfirmation} + onOpenChange={setShowConfirmation} + actionsSlot={ + <> + {!isSubmitting && ( <Button - block - variant='bordered' - colorScheme='secondary' + variant='ghost' + size='sm' textVariant='buttonLarge' isDisabled={!canManageBilling} - onClick={() => openCheckout({ planPeriod: 'month' })} - localizationKey={localizationKeys('commerce.switchToMonthly')} + onClick={() => { + setCancelError(undefined); + setShowConfirmation(false); + }} + localizationKey={localizationKeys('commerce.keepSubscription')} /> - ) : null} + )} <Button - block - variant='bordered' + variant='solid' colorScheme='danger' - textVariant='buttonLarge' - isDisabled={!canManageBilling} - onClick={() => setShowConfirmation(true)} - localizationKey={localizationKeys('commerce.cancelSubscription')} - /> - </Col> - ) - ) : ( - <Button - block - textVariant='buttonLarge' - {...buttonPropsForPlan({ plan })} - onClick={() => openCheckout()} - /> - )} - </Drawer.Footer> - ) : null} */} - - {/* {subscription ? ( - <Drawer.Confirmation - open={showConfirmation} - onOpenChange={setShowConfirmation} - actionsSlot={ - <> - {!isSubmitting && ( - <Button - variant='ghost' size='sm' textVariant='buttonLarge' + isLoading={isSubmitting} isDisabled={!canManageBilling} onClick={() => { setCancelError(undefined); setShowConfirmation(false); + void cancelSubscription(subscription); }} - localizationKey={localizationKeys('commerce.keepSubscription')} + localizationKey={localizationKeys('commerce.cancelSubscription')} /> - )} - <Button - variant='solid' - colorScheme='danger' - size='sm' - textVariant='buttonLarge' - isLoading={isSubmitting} - isDisabled={!canManageBilling} - onClick={() => { - setCancelError(undefined); - setShowConfirmation(false); - void cancelSubscription(); - }} - localizationKey={localizationKeys('commerce.cancelSubscription')} - /> - </> - } + </> + } + > + <Heading + elementDescriptor={descriptors.drawerConfirmationTitle} + as='h2' + textVariant='h3' + localizationKey={localizationKeys('commerce.cancelSubscriptionTitle', { + plan: `${subscription.status === 'upcoming' ? 'upcoming ' : ''}${subscription.plan.name}`, + })} + /> + <Text + elementDescriptor={descriptors.drawerConfirmationDescription} + colorScheme='secondary' + localizationKey={ + subscription.status === 'upcoming' + ? localizationKeys('commerce.cancelSubscriptionNoCharge') + : localizationKeys('commerce.cancelSubscriptionAccessUntil', { + plan: subscription.plan.name, + date: subscription.periodEnd, + }) + } + /> + {cancelError && ( + <Alert colorScheme='danger'>{typeof cancelError === 'string' ? cancelError : cancelError.message}</Alert> + )} + </Drawer.Confirmation> + </Drawer.Footer> + ) : null} + </> + ); +}; + +// New component for individual subscription cards +const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscriptionResource }) => { + const isActive = subscription.status === 'active'; + + return ( + <Col + sx={t => ({ + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$neutralAlpha100, + borderRadius: t.radii.$md, + })} + > + <Col + gap={3} + sx={t => ({ + padding: t.space.$3, + })} + > + {/* Header with name and badge */} + <Flex + justify='between' + align='center' > - <Heading - elementDescriptor={descriptors.drawerConfirmationTitle} - as='h2' - textVariant='h3' - localizationKey={localizationKeys('commerce.cancelSubscriptionTitle', { - plan: `${subscription.status === 'upcoming' ? 'upcoming ' : ''}${subscription.plan.name}`, - })} - /> <Text - elementDescriptor={descriptors.drawerConfirmationDescription} - colorScheme='secondary' - localizationKey={ - subscription.status === 'upcoming' - ? localizationKeys('commerce.cancelSubscriptionNoCharge') - : localizationKeys('commerce.cancelSubscriptionAccessUntil', { - plan: subscription.plan.name, - date: subscription.periodEnd, - }) - } + sx={{ + fontSize: '16px', + fontWeight: '600', + color: '#333', + }} + > + {subscription.plan.name} + </Text> + <Badge + colorScheme={isActive ? 'secondary' : 'primary'} + localizationKey={isActive ? localizationKeys('badge__activePlan') : localizationKeys('badge__upcomingPlan')} /> - {cancelError && ( - <Alert colorScheme='danger'>{typeof cancelError === 'string' ? cancelError : cancelError.message}</Alert> - )} - </Drawer.Confirmation> - ) : null} */} - </> + </Flex> + + {/* Pricing details */} + <Box + elementDescriptor={descriptors.statementSectionContentDetailsList} + as='ul' + sx={t => ({ + margin: 0, + padding: 0, + borderWidth: t.borderWidths.$normal, + borderStyle: t.borderStyles.$solid, + borderColor: t.colors.$neutralAlpha100, + borderRadius: t.radii.$md, + overflow: 'hidden', + })} + > + <PriceItem + labelIcon={subscription.planPeriod === 'month' ? Check : undefined} + label='Monthly price' + value={`${subscription.plan.currencySymbol}${subscription.plan.amountFormatted} / mo`} + /> + <PriceItem + labelIcon={subscription.planPeriod === 'annual' ? Check : undefined} + label='Annual discount' + value={`${subscription.plan.currencySymbol}${subscription.plan.annualMonthlyAmountFormatted} / mo`} + /> + </Box> + </Col> + + {isActive ? ( + <> + <DetailRow + label='Subscribed on' + // TODO: Use localization for dates + value={formatDate(subscription.createdAt)} + /> + <DetailRow + label={subscription.canceledAt ? 'Ends on' : 'Renews at'} + value={formatDate(subscription.periodEnd)} + /> + </> + ) : ( + <DetailRow + label='Begins on' + value={formatDate(subscription.periodStart)} + /> + )} + </Col> ); }; +// Helper component for detail rows +const DetailRow = ({ label, value }: { label: string; value: string }) => ( + <Flex + justify='between' + align='center' + sx={t => ({ + paddingInline: t.space.$3, + paddingBlock: t.space.$3, + borderBlockStartWidth: t.borderWidths.$normal, + borderBlockStartStyle: t.borderStyles.$solid, + borderBlockStartColor: t.colors.$neutralAlpha100, + })} + > + <Text>{label}</Text> + <Text colorScheme='secondary'>{value}</Text> + </Flex> +); + function PriceItem({ labelIcon, label, - valueCopyable = false, + valueCopyable: _valueCopyable = false, value, valueTruncated = false, }: { @@ -405,256 +419,147 @@ function PriceItem({ as='li' sx={t => ({ margin: 0, - paddingInline: t.space.$2, - paddingBlock: t.space.$1x5, + background: common.mergedColorsBackground( + colors.setAlpha(t.colors.$colorBackground, 1), + t.colors.$neutralAlpha50, + ), display: 'flex', - justifyContent: 'space-between', - flexWrap: 'wrap', - columnGap: t.space.$2, - rowGap: t.space.$0x5, - '&:not(:first-child)': { + '&:not(:first-of-type)': { borderBlockStartWidth: t.borderWidths.$normal, borderBlockStartStyle: t.borderStyles.$solid, borderBlockStartColor: t.colors.$neutralAlpha100, }, + '&:first-of-type #test': { + borderTopLeftRadius: t.radii.$md, + borderTopRightRadius: t.radii.$md, + }, + '&:last-of-type #test': { + borderBottomLeftRadius: t.radii.$md, + borderBottomRightRadius: t.radii.$md, + }, })} > - <Span - elementDescriptor={descriptors.statementSectionContentDetailsListItemLabelContainer} + <Flex + justify='center' + align='center' sx={t => ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$1x5, + width: t.space.$8, + paddingInline: t.space.$2, + paddingBlock: t.space.$1x5, })} > {labelIcon ? ( <Icon icon={labelIcon} + size='xs' colorScheme='neutral' /> ) : null} - <Text - variant='caption' - colorScheme='secondary' - elementDescriptor={descriptors.statementSectionContentDetailsListItemLabel} - localizationKey={label} - /> - </Span> - <Span + </Flex> + + <Box + id='test' sx={t => ({ + flex: 1, display: 'flex', - alignItems: 'center', - gap: t.space.$0x25, - color: t.colors.$colorTextSecondary, + justifyContent: 'space-between', + flexWrap: 'wrap', + background: t.colors.$colorBackground, + paddingInline: t.space.$2, + paddingBlock: t.space.$1x5, + marginBlock: -1, + marginInline: -1, + boxShadow: `inset 0px 0px 0px ${t.borderWidths.$normal} ${t.colors.$neutralAlpha100}`, })} > - {typeof value === 'string' ? ( + <Span + elementDescriptor={descriptors.statementSectionContentDetailsListItemLabelContainer} + sx={t => ({ + display: 'flex', + alignItems: 'center', + gap: t.space.$1x5, + })} + > <Text - colorScheme='secondary' variant='caption' - elementDescriptor={descriptors.statementSectionContentDetailsListItemValue} - > - {valueTruncated ? truncateWithEndVisible(value) : value} - </Text> - ) : ( - <Text - elementDescriptor={descriptors.statementSectionContentDetailsListItemValue} colorScheme='secondary' - variant='caption' - localizationKey={value} + elementDescriptor={descriptors.statementSectionContentDetailsListItemLabel} + localizationKey={label} /> - )} - </Span> + </Span> + <Span + sx={t => ({ + display: 'flex', + alignItems: 'center', + gap: t.space.$0x25, + color: t.colors.$colorTextSecondary, + })} + > + {typeof value === 'string' ? ( + <Text + colorScheme='secondary' + variant='caption' + elementDescriptor={descriptors.statementSectionContentDetailsListItemValue} + > + {valueTruncated ? truncateWithEndVisible(value) : value} + </Text> + ) : ( + <Text + elementDescriptor={descriptors.statementSectionContentDetailsListItemValue} + colorScheme='secondary' + variant='caption' + localizationKey={value} + /> + )} + </Span> + </Box> </Box> ); } -/* ------------------------------------------------------------------------------------------------- - * Header - * -----------------------------------------------------------------------------------------------*/ - -interface HeaderProps { - plan: CommercePlanResource; - subscription?: CommerceSubscriptionResource; - planPeriod: CommerceSubscriptionPlanPeriod; - setPlanPeriod: (val: CommerceSubscriptionPlanPeriod) => void; - closeSlot?: React.ReactNode; -} - -const Header = React.forwardRef<HTMLDivElement, HeaderProps>((props, ref) => { - const { plan, subscription, closeSlot, planPeriod, setPlanPeriod } = props; - - const { captionForSubscription, isDefaultPlanImplicitlyActiveOrUpcoming } = usePlansContext(); - const { data: subscriptions } = useSubscriptions(); - - const isImplicitlyActiveOrUpcoming = isDefaultPlanImplicitlyActiveOrUpcoming && plan.isDefault; - - const showBadge = !!subscription; - - const getPlanFee = useMemo(() => { - if (plan.annualMonthlyAmount <= 0) { - return plan.amountFormatted; - } - return planPeriod === 'annual' ? plan.annualMonthlyAmountFormatted : plan.amountFormatted; - }, [plan, planPeriod]); - +function SummaryItem(props: React.PropsWithChildren) { return ( <Box - ref={ref} - elementDescriptor={descriptors.planDetailHeader} + elementDescriptor={descriptors.statementSectionContentDetailsListItem} + as='li' sx={t => ({ - width: '100%', - padding: t.space.$4, - position: 'relative', + paddingInline: t.space.$4, + display: 'flex', + justifyContent: 'space-between', + flexWrap: 'wrap', })} > - {closeSlot ? ( - <Box - sx={t => ({ - position: 'absolute', - top: t.space.$2, - insetInlineEnd: t.space.$2, - })} - > - {closeSlot} - </Box> - ) : null} - - <Col - gap={3} - elementDescriptor={descriptors.planDetailBadgeAvatarTitleDescriptionContainer} - > - {showBadge ? ( - <Flex - align='center' - gap={3} - elementDescriptor={descriptors.planDetailBadgeContainer} - sx={t => ({ - paddingInlineEnd: t.space.$10, - })} - > - {subscription?.status === 'active' || (isImplicitlyActiveOrUpcoming && subscriptions.length === 0) ? ( - <Badge - elementDescriptor={descriptors.planDetailBadge} - localizationKey={localizationKeys('badge__activePlan')} - colorScheme={'secondary'} - /> - ) : ( - <Badge - elementDescriptor={descriptors.planDetailBadge} - localizationKey={localizationKeys('badge__upcomingPlan')} - colorScheme={'primary'} - /> - )} - {!!subscription && ( - <Text - elementDescriptor={descriptors.planDetailCaption} - variant={'caption'} - localizationKey={captionForSubscription(subscription)} - colorScheme='secondary' - /> - )} - </Flex> - ) : null} - {plan.avatarUrl ? ( - <Avatar - boxElementDescriptor={descriptors.planDetailAvatar} - size={_ => 40} - title={plan.name} - initials={plan.name[0]} - rounded={false} - imageUrl={plan.avatarUrl} - sx={t => ({ - marginBlockEnd: t.space.$3, - })} - /> - ) : null} - <Col - gap={1} - elementDescriptor={descriptors.planDetailTitleDescriptionContainer} - > - <Heading - elementDescriptor={descriptors.planDetailTitle} - as='h2' - textVariant='h2' - > - {plan.name} - </Heading> - {plan.description ? ( - <Text - elementDescriptor={descriptors.planDetailDescription} - variant='subtitle' - colorScheme='secondary' - > - {plan.description} - </Text> - ) : null} - </Col> - </Col> + {props.children} + </Box> + ); +} - <Flex - elementDescriptor={descriptors.planDetailFeeContainer} - align='center' - wrap='wrap' - sx={t => ({ - marginTop: t.space.$3, - columnGap: t.space.$1x5, - })} - > - <> - <Text - elementDescriptor={descriptors.planDetailFee} - variant='h1' - colorScheme='body' - > - {plan.currencySymbol} - {getPlanFee} - </Text> - <Text - elementDescriptor={descriptors.planDetailFeePeriod} - variant='caption' - colorScheme='secondary' - sx={t => ({ - textTransform: 'lowercase', - ':before': { - content: '"/"', - marginInlineEnd: t.space.$1, - }, - })} - localizationKey={localizationKeys('commerce.month')} - /> - </> - </Flex> +function SummmaryItemLabel(props: React.PropsWithChildren) { + return ( + <Span + elementDescriptor={descriptors.statementSectionContentDetailsListItemLabelContainer} + sx={t => ({ + display: 'flex', + alignItems: 'center', + gap: t.space.$1x5, + })} + > + {props.children} + </Span> + ); +} - {plan.annualMonthlyAmount > 0 ? ( - <Box - elementDescriptor={descriptors.planDetailPeriodToggle} - sx={t => ({ - display: 'flex', - marginTop: t.space.$3, - })} - > - <Switch - isChecked={planPeriod === 'annual'} - onChange={(checked: boolean) => setPlanPeriod(checked ? 'annual' : 'month')} - label={localizationKeys('commerce.billedAnnually')} - /> - </Box> - ) : ( - <Text - elementDescriptor={descriptors.pricingTableCardFeePeriodNotice} - variant='caption' - colorScheme='secondary' - localizationKey={ - plan.isDefault ? localizationKeys('commerce.alwaysFree') : localizationKeys('commerce.billedMonthlyOnly') - } - sx={t => ({ - justifySelf: 'flex-start', - alignSelf: 'center', - marginTop: t.space.$3, - })} - /> - )} - </Box> +function SummmaryItemValue(props: React.PropsWithChildren) { + return ( + <Span + elementDescriptor={descriptors.statementSectionContentDetailsListItemLabelContainer} + sx={t => ({ + display: 'flex', + alignItems: 'center', + gap: t.space.$0x25, + })} + > + {props.children} + </Span> ); -}); +} diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index 7e35dcca5c7..6743234858a 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -117,8 +117,9 @@ export const useSubscriptions = () => { plan_period: 'month', canceled_at: null, status: _subscriptions.data.length === 0 ? 'active' : 'upcoming', - period_start: canceledSubscription?.periodEnd || 0, + period_start: canceledSubscription?.periodEnd?.getTime() || 0, period_end: 0, + created_at: canceledSubscription?.periodEnd?.getTime() || 0, }), ]; } diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index 3caf459548d..685a0305abc 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -155,9 +155,10 @@ export interface CommerceSubscriptionResource extends ClerkResource { plan: CommercePlanResource; planPeriod: CommerceSubscriptionPlanPeriod; status: CommerceSubscriptionStatus; - periodStart: number; - periodEnd: number; - canceledAt: number | null; + createdAt: Date; + periodStart: Date; + periodEnd: Date; + canceledAt: Date | null; amount?: CommerceMoney; credit?: { amount: CommerceMoney; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index dbf38dcb0c7..202d5d0a2bb 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -697,6 +697,7 @@ export interface CommerceSubscriptionJSON extends ClerkResourceJSON { credit?: { amount: CommerceMoneyJSON; }; + created_at: number; payment_source_id: string; plan: CommercePlanJSON; plan_period: CommerceSubscriptionPlanPeriod; From 7af1561a5457728eead21cb360ef5548dff0fd6c Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Wed, 18 Jun 2025 18:32:05 +0300 Subject: [PATCH 04/34] display button for annual to switch to monthly as well --- .../clerk-js/src/ui/components/SubscriptionDetails/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index d93a88b03b0..9aaf8188b94 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -194,7 +194,8 @@ const SubscriptionDetailsInternal = ({ onSubscriptionCancel, portalRoot }: __exp {!subscription?.plan.isDefault ? ( <Drawer.Footer> <Col gap={4}> - {subscription.planPeriod === 'month' && subscription.plan.annualMonthlyAmount > 0 && ( + {((subscription.planPeriod === 'month' && subscription.plan.annualMonthlyAmount > 0) || + subscription.planPeriod === 'annual') && ( <Button block variant='bordered' From 99607d7f001200e75981e61d115e6f78a2156daa Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Tue, 1 Jul 2025 12:06:49 +0300 Subject: [PATCH 05/34] implement new UI --- .../src/ui/components/Plans/PlanDetails.tsx | 7 +- .../ui/components/Plans/old_PlanDetails.tsx | 2 +- .../components/SubscriptionDetails/index.tsx | 705 ++++++++++-------- .../components/SubscriptionDetails.ts | 20 + .../src/ui/elements/ThreeDotsMenu.tsx | 39 +- packages/clerk-js/src/ui/types.ts | 5 + packages/localizations/src/en-US.ts | 2 + packages/types/src/localization.ts | 2 + 8 files changed, 432 insertions(+), 350 deletions(-) create mode 100644 packages/clerk-js/src/ui/contexts/components/SubscriptionDetails.ts diff --git a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx index d2a8ad23b02..7c30cd7f980 100644 --- a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx +++ b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx @@ -11,12 +11,9 @@ import useSWR from 'swr'; import { Avatar } from '@/ui/elements/Avatar'; import { Drawer } from '@/ui/elements/Drawer'; import { Switch } from '@/ui/elements/Switch'; -import { handleError } from '@/ui/utils/errorHandler'; -import { useProtect } from '../../common'; -import { SubscriberTypeContext, usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; -import { Badge, Box, Button, Col, descriptors, Flex, Heading, localizationKeys, Span, Text } from '../../customizables'; -import { handleError } from '../../utils'; +import { SubscriberTypeContext } from '../../contexts'; +import { Box, Col, descriptors, Flex, Heading, localizationKeys, Span, Spinner, Text } from '../../customizables'; export const PlanDetails = (props: __experimental_PlanDetailsProps) => { return ( diff --git a/packages/clerk-js/src/ui/components/Plans/old_PlanDetails.tsx b/packages/clerk-js/src/ui/components/Plans/old_PlanDetails.tsx index 4bfb535d1df..3a967e241ea 100644 --- a/packages/clerk-js/src/ui/components/Plans/old_PlanDetails.tsx +++ b/packages/clerk-js/src/ui/components/Plans/old_PlanDetails.tsx @@ -14,11 +14,11 @@ import { Alert } from '@/ui/elements/Alert'; import { Avatar } from '@/ui/elements/Avatar'; import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; import { Switch } from '@/ui/elements/Switch'; +import { handleError } from '@/ui/utils/errorHandler'; import { useProtect } from '../../common'; import { SubscriberTypeContext, usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; import { Badge, Box, Button, Col, descriptors, Flex, Heading, localizationKeys, Span, Text } from '../../customizables'; -import { handleError } from '../../utils'; export const PlanDetails = (props: __internal_PlanDetailsProps) => { return ( diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index 9aaf8188b94..72e2d29b444 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -2,26 +2,27 @@ import { useClerk, useOrganization } from '@clerk/shared/react'; import type { __experimental_SubscriptionDetailsProps, __internal_CheckoutProps, - ClerkAPIError, - ClerkRuntimeError, CommerceSubscriptionResource, } from '@clerk/types'; import * as React from 'react'; -import { useState } from 'react'; +import { useCallback, useContext, useState } from 'react'; +import { useProtect } from '@/ui/common/Gate'; +import { + SubscriptionDetailsContext, + useSubscriptionDetailsContext, +} from '@/ui/contexts/components/SubscriptionDetails'; +import { Avatar } from '@/ui/elements/Avatar'; +import { CardAlert } from '@/ui/elements/Card/CardAlert'; +import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; -import { Check } from '@/ui/icons'; -import { common } from '@/ui/styledSystem/common'; -import { colors } from '@/ui/utils/colors'; +import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu'; +import { ThreeDots } from '@/ui/icons'; import { handleError } from '@/ui/utils/errorHandler'; import { formatDate } from '@/ui/utils/formatDate'; -import { truncateWithEndVisible } from '@/ui/utils/truncateTextWithEndVisible'; -import { useProtect } from '../../common'; import { usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; -import type { LocalizationKey } from '../../customizables'; import { - Alert, Badge, Box, Button, @@ -34,35 +35,73 @@ import { Span, Spinner, Text, + useLocalizations, } from '../../customizables'; +const SubscriptionForCancellationContext = React.createContext<{ + subscription: CommerceSubscriptionResource | null; + setSubscription: (subscription: CommerceSubscriptionResource | null) => void; +}>({ + subscription: null, + setSubscription: () => {}, +}); + export const SubscriptionDetails = (props: __experimental_SubscriptionDetailsProps) => { return ( <Drawer.Content> - <SubscriptionDetailsInternal {...props} /> + <SubscriptionDetailsContext.Provider value={{ componentName: 'SubscriptionDetails', ...props }}> + <SubscriptionDetailsInternal {...props} /> + </SubscriptionDetailsContext.Provider> </Drawer.Content> ); }; -const SubscriptionDetailsInternal = ({ onSubscriptionCancel, portalRoot }: __experimental_SubscriptionDetailsProps) => { - const clerk = useClerk(); +type UseGuessableSubscriptionResult<Or extends 'throw' | undefined = undefined> = Or extends 'throw' + ? { + upcomingSubscription?: CommerceSubscriptionResource; + activeSubscription: CommerceSubscriptionResource; + anySubscription: CommerceSubscriptionResource; + isLoading: boolean; + } + : { + upcomingSubscription?: CommerceSubscriptionResource; + activeSubscription?: CommerceSubscriptionResource; + anySubscription?: CommerceSubscriptionResource; + isLoading: boolean; + }; + +function useGuessableSubscription<Or extends 'throw' | undefined = undefined>(options?: { + or?: Or; +}): UseGuessableSubscriptionResult<Or> { + const { data: subscriptions, isLoading } = useSubscriptions(); + const activeSubscription = subscriptions?.find(sub => sub.status === 'active'); + const upcomingSubscription = subscriptions?.find(sub => sub.status === 'upcoming'); + + if (options?.or === 'throw' && !activeSubscription) { + throw new Error('No active subscription found'); + } + + return { + upcomingSubscription, + activeSubscription: activeSubscription as any, // Type is correct due to the throw above + anySubscription: (upcomingSubscription || activeSubscription) as any, + isLoading, + }; +} + +const SubscriptionDetailsInternal = (props: __experimental_SubscriptionDetailsProps) => { const { organization: _organization } = useOrganization(); - const [showConfirmation, setShowConfirmation] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [cancelError, setCancelError] = useState<ClerkRuntimeError | ClerkAPIError | string | undefined>(); + const [subscriptionForCancellation, setSubscriptionForCancellation] = useState<CommerceSubscriptionResource | null>( + null, + ); - const { setIsOpen } = useDrawerContext(); const { - revalidateAll, buttonPropsForPlan: _buttonPropsForPlan, isDefaultPlanImplicitlyActiveOrUpcoming: _isDefaultPlanImplicitlyActiveOrUpcoming, } = usePlansContext(); - const subscriberType = useSubscriberTypeContext(); - const canManageBilling = useProtect( - has => has({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user', - ); const { data: subscriptions, isLoading } = useSubscriptions(); + const { activeSubscription } = useGuessableSubscription(); if (isLoading) { return ( @@ -74,57 +113,15 @@ const SubscriptionDetailsInternal = ({ onSubscriptionCancel, portalRoot }: __exp ); } - const handleClose = () => { - if (setIsOpen) { - setIsOpen(false); - } - }; - - const cancelSubscription = async (subscription: CommerceSubscriptionResource) => { - setCancelError(undefined); - setIsSubmitting(true); - - await subscription - .cancel( - // { orgId: subscriberType === 'org' ? organization?.id : undefined } - {}, - ) - .then(() => { - setIsSubmitting(false); - onSubscriptionCancel?.(); - handleClose(); - }) - .catch(error => { - handleError(error, [], setCancelError); - setIsSubmitting(false); - }); - }; - - const openCheckout = (params?: __internal_CheckoutProps) => { - handleClose(); - - clerk.__internal_openCheckout({ - ...params, - onSubscriptionComplete: () => { - void revalidateAll(); - }, - portalRoot, - }); - }; - - // Mock data for demonstration - in real implementation this would come from the subscriptions data - const activeSubscription = subscriptions?.find(sub => sub.status === 'active'); - const upcomingSubscription = subscriptions?.find(sub => sub.status === 'upcoming'); - if (!activeSubscription) { - // Should never happen, but just in case + // Should never happen, since Free will always be active return null; } - const subscription = upcomingSubscription || activeSubscription; - return ( - <> + <SubscriptionForCancellationContext.Provider + value={{ subscription: subscriptionForCancellation, setSubscription: setSubscriptionForCancellation }} + > <Drawer.Header title='Subscription' /> <Drawer.Body> @@ -140,130 +137,96 @@ const SubscriptionDetailsInternal = ({ onSubscriptionCancel, portalRoot }: __exp <SubscriptionCard key={subscriptionItem.id} subscription={subscriptionItem} + {...props} /> ))} </Col> + </Drawer.Body> - {/* Billing Information */} + <SubscriptionDetailsFooter /> + </SubscriptionForCancellationContext.Provider> + ); +}; - <Col - gap={3} - as='ul' - sx={t => ({ - marginTop: 'auto', - paddingBlock: t.space.$4, - borderTopWidth: t.borderWidths.$normal, - borderTopStyle: t.borderStyles.$solid, - borderTopColor: t.colors.$neutralAlpha100, - })} - > - <SummaryItem> - <SummmaryItemLabel> - <Text colorScheme='secondary'>Current billing cycle</Text> - </SummmaryItemLabel> - <SummmaryItemValue> - <Text colorScheme='secondary'>{activeSubscription.planPeriod === 'month' ? 'Monthly' : 'Annually'}</Text> - </SummmaryItemValue> - </SummaryItem> - <SummaryItem> - <SummmaryItemLabel> - <Text colorScheme='secondary'>Next payment on</Text> - </SummmaryItemLabel> - <SummmaryItemValue> - <Text colorScheme='secondary'> - {upcomingSubscription - ? formatDate(upcomingSubscription.periodStart) - : formatDate(subscription.periodEnd)} - </Text> - </SummmaryItemValue> - </SummaryItem> - <SummaryItem> - <SummmaryItemLabel> - <Text>Next payment amount</Text> - </SummmaryItemLabel> - <SummmaryItemValue> - <Text> - {`${subscription.plan.currencySymbol}${subscription.planPeriod === 'month' ? subscription.plan.amountFormatted : subscription.plan.annualAmountFormatted}`} - </Text> - </SummmaryItemValue> - </SummaryItem> - </Col> - </Drawer.Body> +const SubscriptionDetailsFooter = withCardStateProvider(() => { + const subscriberType = useSubscriberTypeContext(); + const { organization } = useOrganization(); + const { isLoading, error, setError, setLoading, setIdle } = useCardState(); + const { subscription, setSubscription } = useContext(SubscriptionForCancellationContext); + const { anySubscription } = useGuessableSubscription({ or: 'throw' }); + const { setIsOpen } = useDrawerContext(); + const { onSubscriptionCancel } = useSubscriptionDetailsContext(); + + const onOpenChange = useCallback( + (open: boolean) => setSubscription(open ? subscription : null), + [subscription, setSubscription], + ); + + const cancelSubscription = useCallback(async () => { + if (!subscription) { + return; + } + + setError(undefined); + setLoading(); + + await subscription + .cancel({ orgId: subscriberType === 'org' ? organization?.id : undefined }) + .then(() => { + onSubscriptionCancel?.(); + if (setIsOpen) { + setIsOpen(false); + } + }) + .catch(error => { + handleError(error, [], setError); + }) + .finally(() => { + setIdle(); + }); + }, [subscription, setError, setLoading, subscriberType, organization?.id, onSubscriptionCancel, setIsOpen, setIdle]); - {/* If either the active or upcoming subscription is the free plan, then a C1 cannot switch to a different period or cancel the plan */} - {!subscription?.plan.isDefault ? ( - <Drawer.Footer> - <Col gap={4}> - {((subscription.planPeriod === 'month' && subscription.plan.annualMonthlyAmount > 0) || - subscription.planPeriod === 'annual') && ( + // If either the active or upcoming subscription is the free plan, then a C1 cannot switch to a different period or cancel the plan + if (anySubscription.plan.isDefault) { + return null; + } + + return ( + <Drawer.Footer> + <SubscriptionDetailsSummary /> + + <Drawer.Confirmation + open={!!subscription} + onOpenChange={onOpenChange} + actionsSlot={ + <> + {!isLoading && ( <Button - block - variant='bordered' - colorScheme='secondary' + variant='ghost' + size='sm' textVariant='buttonLarge' - isDisabled={!canManageBilling} - // onClick={() => openCheckout({ planPeriod: 'annual' })} onClick={() => { - openCheckout({ - planId: subscription.plan.id, - planPeriod: subscription.planPeriod === 'month' ? 'annual' : 'month', - subscriberType: subscriberType, - }); + setIdle(); + setError(undefined); + onOpenChange(false); }} - localizationKey={ - subscription.planPeriod === 'month' - ? localizationKeys('commerce.switchToAnnual') - : localizationKeys('commerce.switchToMonthly') - } + localizationKey={localizationKeys('commerce.keepSubscription')} /> )} - <Button - block - variant='bordered' + variant='solid' colorScheme='danger' + size='sm' textVariant='buttonLarge' - isDisabled={!canManageBilling} - onClick={() => setShowConfirmation(true)} + isLoading={isLoading} + onClick={() => void cancelSubscription()} localizationKey={localizationKeys('commerce.cancelSubscription')} /> - </Col> - - <Drawer.Confirmation - open={showConfirmation} - onOpenChange={setShowConfirmation} - actionsSlot={ - <> - {!isSubmitting && ( - <Button - variant='ghost' - size='sm' - textVariant='buttonLarge' - isDisabled={!canManageBilling} - onClick={() => { - setCancelError(undefined); - setShowConfirmation(false); - }} - localizationKey={localizationKeys('commerce.keepSubscription')} - /> - )} - <Button - variant='solid' - colorScheme='danger' - size='sm' - textVariant='buttonLarge' - isLoading={isSubmitting} - isDisabled={!canManageBilling} - onClick={() => { - setCancelError(undefined); - setShowConfirmation(false); - void cancelSubscription(subscription); - }} - localizationKey={localizationKeys('commerce.cancelSubscription')} - /> - </> - } - > + </> + } + > + {subscription ? ( + <> <Heading elementDescriptor={descriptors.drawerConfirmationTitle} as='h2' @@ -284,19 +247,216 @@ const SubscriptionDetailsInternal = ({ onSubscriptionCancel, portalRoot }: __exp }) } /> - {cancelError && ( - <Alert colorScheme='danger'>{typeof cancelError === 'string' ? cancelError : cancelError.message}</Alert> - )} - </Drawer.Confirmation> - </Drawer.Footer> - ) : null} - </> + <CardAlert>{error}</CardAlert> + </> + ) : null} + </Drawer.Confirmation> + </Drawer.Footer> + ); +}); + +function SubscriptionDetailsSummary() { + const { anySubscription, activeSubscription, upcomingSubscription } = useGuessableSubscription({ or: 'throw' }); + + if (!activeSubscription) { + return null; + } + + return ( + <Col + gap={3} + as='ul' + sx={t => ({ + paddingBlock: t.space.$1, + })} + > + <SummaryItem> + <SummmaryItemLabel> + <Text colorScheme='secondary'>Current billing cycle</Text> + </SummmaryItemLabel> + <SummmaryItemValue> + <Text colorScheme='secondary'>{activeSubscription.planPeriod === 'month' ? 'Monthly' : 'Annually'}</Text> + </SummmaryItemValue> + </SummaryItem> + <SummaryItem> + <SummmaryItemLabel> + <Text colorScheme='secondary'>Next payment on</Text> + </SummmaryItemLabel> + <SummmaryItemValue> + <Text colorScheme='secondary'> + {upcomingSubscription + ? formatDate(upcomingSubscription.periodStart) + : formatDate(anySubscription.periodEnd)} + </Text> + </SummmaryItemValue> + </SummaryItem> + <SummaryItem> + <SummmaryItemLabel> + <Text>Next payment amount</Text> + </SummmaryItemLabel> + <SummmaryItemValue> + <Span + // elementDescriptor={descriptors.paymentAttemptFooterValueContainer} + sx={t => ({ + display: 'flex', + alignItems: 'center', + gap: t.space.$1, + })} + > + <Text + variant='caption' + colorScheme='secondary' + // elementDescriptor={descriptors.paymentAttemptFooterCurrency} + sx={{ textTransform: 'uppercase' }} + > + {anySubscription.plan.currency} + </Text> + <Text + // variant='h3' + // elementDescriptor={descriptors.paymentAttemptFooterValue} + > + {anySubscription.plan.currencySymbol} + {anySubscription.planPeriod === 'month' + ? anySubscription.plan.amountFormatted + : anySubscription.plan.annualAmountFormatted} + </Text> + </Span> + </SummmaryItemValue> + </SummaryItem> + </Col> + ); +} + +const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubscriptionResource }) => { + const { portalRoot } = useSubscriptionDetailsContext(); + const { __internal_openCheckout } = useClerk(); + const subscriberType = useSubscriberTypeContext(); + const { setIsOpen } = useDrawerContext(); + const { revalidateAll } = usePlansContext(); + const { setSubscription } = useContext(SubscriptionForCancellationContext); + const canOrgManageBilling = useProtect(has => has({ permission: 'org:sys_billing:manage' })); + const canManageBilling = subscriberType === 'user' || canOrgManageBilling; + + const isSwitchable = + (subscription.planPeriod === 'month' && subscription.plan.annualMonthlyAmount > 0) || + subscription.planPeriod === 'annual'; + const isFreePlan = subscription.plan.isDefault; + const isCancellable = subscription.canceledAt === null && !isFreePlan; + const isReSubscribable = subscription.canceledAt !== null && !isFreePlan; + + const openCheckout = useCallback( + (params?: __internal_CheckoutProps) => { + if (setIsOpen) { + setIsOpen(false); + } + + __internal_openCheckout({ + ...params, + onSubscriptionComplete: () => { + void revalidateAll(); + }, + portalRoot, + }); + }, + [__internal_openCheckout, revalidateAll, portalRoot, setIsOpen], + ); + + const actions = React.useMemo(() => { + if (!canManageBilling) { + return []; + } + + return [ + isSwitchable + ? { + label: + subscription.planPeriod === 'month' + ? localizationKeys('commerce.switchToAnnualWithAnnualPrice', { + price: subscription.plan.annualAmountFormatted, + currency: subscription.plan.currencySymbol, + }) + : localizationKeys('commerce.switchToMonthlyWithPrice', { + price: subscription.plan.amountFormatted, + currency: subscription.plan.currencySymbol, + }), + onClick: () => { + openCheckout({ + planId: subscription.plan.id, + planPeriod: subscription.planPeriod === 'month' ? 'annual' : 'month', + subscriberType, + }); + }, + } + : null, + isCancellable + ? { + isDestructive: true, + label: localizationKeys('commerce.cancelSubscription'), + onClick: () => { + setSubscription(subscription); + }, + } + : null, + isReSubscribable + ? { + label: localizationKeys('commerce.reSubscribe'), + onClick: () => { + openCheckout({ + planId: subscription.plan.id, + planPeriod: subscription.planPeriod, + subscriberType, + }); + }, + } + : null, + ].filter(a => a !== null); + }, [ + isSwitchable, + subscription, + isCancellable, + openCheckout, + subscriberType, + setSubscription, + canManageBilling, + isReSubscribable, + ]); + + if (actions.length === 0) { + return null; + } + + return ( + <ThreeDotsMenu + trigger={ + <Button + aria-label='Manage subscription' + variant='bordered' + colorScheme='secondary' + sx={t => ({ + width: t.sizes.$6, + height: t.sizes.$6, + })} + elementDescriptor={[descriptors.menuButton, descriptors.menuButtonEllipsis]} + > + <Icon + icon={ThreeDots} + sx={t => ({ + width: t.sizes.$4, + height: t.sizes.$4, + opacity: t.opacity.$inactive, + })} + /> + </Button> + } + actions={actions} + /> ); }; // New component for individual subscription cards const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscriptionResource }) => { const isActive = subscription.status === 'active'; + const { t } = useLocalizations(); return ( <Col @@ -315,14 +475,30 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription > {/* Header with name and badge */} <Flex - justify='between' align='center' + gap={2} > + <Avatar + boxElementDescriptor={descriptors.planDetailAvatar} + // TODO: Use size prop + size={_ => 40} + title={subscription.plan.name} + initials={subscription.plan.name[0]} + rounded={false} + // TODO: remove hardcoded image + imageUrl={subscription.plan.avatarUrl || 'https://i.ibb.co/s9GqfwtK/Frame-106.png'} + // TODO: remove hardcoded background + sx={{ + background: 'unset', + }} + /> + <Text sx={{ fontSize: '16px', fontWeight: '600', color: '#333', + marginInlineEnd: 'auto', }} > {subscription.plan.name} @@ -334,30 +510,26 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription </Flex> {/* Pricing details */} - <Box + <Flex elementDescriptor={descriptors.statementSectionContentDetailsList} - as='ul' - sx={t => ({ - margin: 0, - padding: 0, - borderWidth: t.borderWidths.$normal, - borderStyle: t.borderStyles.$solid, - borderColor: t.colors.$neutralAlpha100, - borderRadius: t.radii.$md, - overflow: 'hidden', - })} + justify='between' + align='center' > - <PriceItem - labelIcon={subscription.planPeriod === 'month' ? Check : undefined} - label='Monthly price' - value={`${subscription.plan.currencySymbol}${subscription.plan.amountFormatted} / mo`} - /> - <PriceItem - labelIcon={subscription.planPeriod === 'annual' ? Check : undefined} - label='Annual discount' - value={`${subscription.plan.currencySymbol}${subscription.plan.annualMonthlyAmountFormatted} / mo`} - /> - </Box> + <Text + variant='body' + colorScheme='secondary' + sx={t => ({ + fontWeight: t.fontWeights.$medium, + textTransform: 'lowercase', + })} + > + {subscription.planPeriod === 'month' + ? `${subscription.plan.currencySymbol}${subscription.plan.amountFormatted} / ${t(localizationKeys('commerce.month'))}` + : `${subscription.plan.currencySymbol}${subscription.plan.annualAmountFormatted} / ${t(localizationKeys('commerce.year'))}`} + </Text> + + <SubscriptionCardActions subscription={subscription} /> + </Flex> </Col> {isActive ? ( @@ -400,135 +572,16 @@ const DetailRow = ({ label, value }: { label: string; value: string }) => ( </Flex> ); -function PriceItem({ - labelIcon, - label, - valueCopyable: _valueCopyable = false, - value, - valueTruncated = false, -}: { - icon?: React.ReactNode; - label: string | LocalizationKey; - labelIcon?: React.ComponentType; - value: string | LocalizationKey; - valueTruncated?: boolean; - valueCopyable?: boolean; -}) { - return ( - <Box - elementDescriptor={descriptors.statementSectionContentDetailsListItem} - as='li' - sx={t => ({ - margin: 0, - background: common.mergedColorsBackground( - colors.setAlpha(t.colors.$colorBackground, 1), - t.colors.$neutralAlpha50, - ), - display: 'flex', - '&:not(:first-of-type)': { - borderBlockStartWidth: t.borderWidths.$normal, - borderBlockStartStyle: t.borderStyles.$solid, - borderBlockStartColor: t.colors.$neutralAlpha100, - }, - '&:first-of-type #test': { - borderTopLeftRadius: t.radii.$md, - borderTopRightRadius: t.radii.$md, - }, - '&:last-of-type #test': { - borderBottomLeftRadius: t.radii.$md, - borderBottomRightRadius: t.radii.$md, - }, - })} - > - <Flex - justify='center' - align='center' - sx={t => ({ - width: t.space.$8, - paddingInline: t.space.$2, - paddingBlock: t.space.$1x5, - })} - > - {labelIcon ? ( - <Icon - icon={labelIcon} - size='xs' - colorScheme='neutral' - /> - ) : null} - </Flex> - - <Box - id='test' - sx={t => ({ - flex: 1, - display: 'flex', - justifyContent: 'space-between', - flexWrap: 'wrap', - background: t.colors.$colorBackground, - paddingInline: t.space.$2, - paddingBlock: t.space.$1x5, - marginBlock: -1, - marginInline: -1, - boxShadow: `inset 0px 0px 0px ${t.borderWidths.$normal} ${t.colors.$neutralAlpha100}`, - })} - > - <Span - elementDescriptor={descriptors.statementSectionContentDetailsListItemLabelContainer} - sx={t => ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$1x5, - })} - > - <Text - variant='caption' - colorScheme='secondary' - elementDescriptor={descriptors.statementSectionContentDetailsListItemLabel} - localizationKey={label} - /> - </Span> - <Span - sx={t => ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$0x25, - color: t.colors.$colorTextSecondary, - })} - > - {typeof value === 'string' ? ( - <Text - colorScheme='secondary' - variant='caption' - elementDescriptor={descriptors.statementSectionContentDetailsListItemValue} - > - {valueTruncated ? truncateWithEndVisible(value) : value} - </Text> - ) : ( - <Text - elementDescriptor={descriptors.statementSectionContentDetailsListItemValue} - colorScheme='secondary' - variant='caption' - localizationKey={value} - /> - )} - </Span> - </Box> - </Box> - ); -} - function SummaryItem(props: React.PropsWithChildren) { return ( <Box elementDescriptor={descriptors.statementSectionContentDetailsListItem} as='li' - sx={t => ({ - paddingInline: t.space.$4, + sx={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', - })} + }} > {props.children} </Box> diff --git a/packages/clerk-js/src/ui/contexts/components/SubscriptionDetails.ts b/packages/clerk-js/src/ui/contexts/components/SubscriptionDetails.ts new file mode 100644 index 00000000000..1121a0f7b15 --- /dev/null +++ b/packages/clerk-js/src/ui/contexts/components/SubscriptionDetails.ts @@ -0,0 +1,20 @@ +import { createContext, useContext } from 'react'; + +import type { SubscriptionDetailsCtx } from '@/ui/types'; + +export const SubscriptionDetailsContext = createContext<SubscriptionDetailsCtx | null>(null); + +export const useSubscriptionDetailsContext = () => { + const context = useContext(SubscriptionDetailsContext); + + if (!context || context.componentName !== 'SubscriptionDetails') { + throw new Error('Clerk: useSubscriptionDetailsContext called outside SubscriptionDetails.'); + } + + const { componentName, ...ctx } = context; + + return { + ...ctx, + componentName, + }; +}; diff --git a/packages/clerk-js/src/ui/elements/ThreeDotsMenu.tsx b/packages/clerk-js/src/ui/elements/ThreeDotsMenu.tsx index d6e5d411319..66b476b45e6 100644 --- a/packages/clerk-js/src/ui/elements/ThreeDotsMenu.tsx +++ b/packages/clerk-js/src/ui/elements/ThreeDotsMenu.tsx @@ -13,6 +13,7 @@ type Action = { }; type ThreeDotsMenuProps = { + trigger?: React.ReactNode; actions: Action[]; elementId?: MenuId; }; @@ -22,24 +23,26 @@ export const ThreeDotsMenu = (props: ThreeDotsMenuProps) => { return ( <Menu elementId={elementId}> <MenuTrigger arialLabel={isOpen => `${isOpen ? 'Close' : 'Open'} menu`}> - <Button - sx={t => ({ - padding: t.space.$0x5, - boxSizing: 'content-box', - opacity: t.opacity.$inactive, - ':hover': { - opacity: 1, - }, - })} - variant='ghost' - colorScheme='neutral' - elementDescriptor={[descriptors.menuButton, descriptors.menuButtonEllipsis]} - > - <Icon - icon={ThreeDots} - sx={t => ({ width: 'auto', height: t.sizes.$5 })} - /> - </Button> + {props.trigger || ( + <Button + sx={t => ({ + padding: t.space.$0x5, + boxSizing: 'content-box', + opacity: t.opacity.$inactive, + ':hover': { + opacity: 1, + }, + })} + variant='ghost' + colorScheme='neutral' + elementDescriptor={[descriptors.menuButton, descriptors.menuButtonEllipsis]} + > + <Icon + icon={ThreeDots} + sx={t => ({ width: 'auto', height: t.sizes.$5 })} + /> + </Button> + )} </MenuTrigger> <MenuList> {actions.map((a, index) => ( diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index 04cf9ee6366..b3df0b937e4 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -1,4 +1,5 @@ import type { + __experimental_SubscriptionDetailsProps, __internal_CheckoutProps, __internal_OAuthConsentProps, __internal_PlanDetailsProps, @@ -138,6 +139,10 @@ export type OAuthConsentCtx = __internal_OAuthConsentProps & { componentName: 'OAuthConsent'; }; +export type SubscriptionDetailsCtx = __experimental_SubscriptionDetailsProps & { + componentName: 'SubscriptionDetails'; +}; + export type AvailableComponentCtx = | SignInCtx | SignUpCtx diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 0729f17a402..308fb5162f5 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -127,6 +127,8 @@ export const enUS: LocalizationResource = { switchPlan: 'Switch to this plan', switchToAnnual: 'Switch to annual', switchToMonthly: 'Switch to monthly', + switchToMonthlyWithPrice: 'Switch to monthly {{currency}}{{price}} per month', + switchToAnnualWithAnnualPrice: 'Switch to annual {{currency}}{{price}} per year', totalDue: 'Total due', totalDueToday: 'Total Due Today', viewFeatures: 'View features', diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 16a1002f4b6..185b278a541 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -175,6 +175,8 @@ export type __internal_LocalizationResource = { switchPlan: LocalizationValue; switchToMonthly: LocalizationValue; switchToAnnual: LocalizationValue; + switchToMonthlyWithPrice: LocalizationValue<'price' | 'currency'>; + switchToAnnualWithAnnualPrice: LocalizationValue<'price' | 'currency'>; billedAnnually: LocalizationValue; billedMonthlyOnly: LocalizationValue; alwaysFree: LocalizationValue; From 0af7fb9e18a88180023d735d467610fe83a4cb68 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Tue, 1 Jul 2025 14:15:44 +0300 Subject: [PATCH 06/34] add unit tests --- .../__tests__/SubscriptionDetails.test.tsx | 493 ++++++++++++++++++ .../components/SubscriptionDetails/index.tsx | 22 +- .../ui/contexts/ClerkUIComponentsContext.tsx | 10 + packages/clerk-js/src/ui/types.ts | 3 +- .../src/ui/utils/test/createFixtures.tsx | 1 + .../clerk-js/src/ui/utils/test/mockHelpers.ts | 1 + 6 files changed, 521 insertions(+), 9 deletions(-) create mode 100644 packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx new file mode 100644 index 00000000000..ca0fa83ceed --- /dev/null +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx @@ -0,0 +1,493 @@ +import { Drawer } from '@/ui/elements/Drawer'; + +import { render, waitFor } from '../../../../testUtils'; +import { bindCreateFixtures } from '../../../utils/test/createFixtures'; +import { SubscriptionDetails } from '..'; + +const { createFixtures } = bindCreateFixtures('SubscriptionDetails'); + +describe('SubscriptionDetails', () => { + it('Displays spinner when init loading', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { baseElement } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <SubscriptionDetails /> + </Drawer.Root>, + { wrapper }, + ); + + await waitFor(() => { + const spinner = baseElement.querySelector('span[aria-live="polite"]'); + expect(spinner).toBeVisible(); + }); + }); + + it('single active monthly subscription', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ + data: [ + { + id: 'sub_123', + plan: { + id: 'plan_123', + name: 'Test Plan', + amount: 1000, + amountFormatted: '10.00', + annualAmount: 10000, + annualAmountFormatted: '100.00', + annualMonthlyAmount: 8333, + annualMonthlyAmountFormatted: '83.33', + currencySymbol: '$', + description: 'Test Plan', + hasBaseFee: true, + isRecurring: true, + currency: 'USD', + isDefault: false, + }, + createdAt: new Date('2021-01-01'), + periodStart: new Date('2021-01-01'), + periodEnd: new Date('2021-02-01'), + canceledAt: null, + paymentSourceId: 'src_123', + planPeriod: 'month', + status: 'active', + }, + ], + total_count: 1, + }); + + const { getByRole, getByText, queryByText, getAllByText, userEvent } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <SubscriptionDetails /> + </Drawer.Root>, + { wrapper }, + ); + await waitFor(() => { + expect(getByRole('heading', { name: /Subscription/i })).toBeVisible(); + + expect(getByText('Test Plan')).toBeVisible(); + expect(getByText('Active')).toBeVisible(); + + expect(getByText('$10.00 / Month')).toBeVisible(); + + expect(getByText('Subscribed on')).toBeVisible(); + expect(getByText('January 1, 2021')).toBeVisible(); + expect(getByText('Renews at')).toBeVisible(); + + expect(getByText('Current billing cycle')).toBeVisible(); + expect(getByText('Monthly')).toBeVisible(); + + expect(getByText('Next payment on')).toBeVisible(); + const nextPaymentElements = getAllByText('February 1, 2021'); + expect(nextPaymentElements.length).toBe(2); + + expect(getByText('Next payment amount')).toBeVisible(); + expect(getByText('$10.00')).toBeVisible(); + expect(queryByText('Ends on')).toBeNull(); + }); + + const menuButton = getByRole('button', { name: /Open menu/i }); + expect(menuButton).toBeVisible(); + await userEvent.click(menuButton); + + await waitFor(() => { + expect(getByText('Switch to annual $100.00 per year')).toBeVisible(); + expect(getByText('Cancel subscription')).toBeVisible(); + }); + }); + + it('single active annual subscription', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ + data: [ + { + id: 'sub_123', + plan: { + id: 'plan_123', + name: 'Test Plan', + amount: 1000, + amountFormatted: '10.00', + annualAmount: 10000, + annualAmountFormatted: '100.00', + annualMonthlyAmount: 8333, + annualMonthlyAmountFormatted: '83.33', + currencySymbol: '$', + description: 'Test Plan', + hasBaseFee: true, + isRecurring: true, + currency: 'USD', + isDefault: false, + }, + createdAt: new Date('2021-01-01'), + periodStart: new Date('2021-01-01'), + periodEnd: new Date('2022-01-01'), + canceledAt: null, + paymentSourceId: 'src_123', + planPeriod: 'annual', + status: 'active', + }, + ], + total_count: 1, + }); + + const { getByRole, getByText, queryByText, getAllByText, userEvent } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <SubscriptionDetails /> + </Drawer.Root>, + { wrapper }, + ); + await waitFor(() => { + expect(getByRole('heading', { name: /Subscription/i })).toBeVisible(); + + expect(getByText('Test Plan')).toBeVisible(); + expect(getByText('Active')).toBeVisible(); + + expect(getByText('$100.00 / Year')).toBeVisible(); + + expect(getByText('Subscribed on')).toBeVisible(); + expect(getByText('January 1, 2021')).toBeVisible(); + expect(getByText('Renews at')).toBeVisible(); + + expect(getByText('Current billing cycle')).toBeVisible(); + expect(getByText('Annually')).toBeVisible(); + + expect(getByText('Next payment on')).toBeVisible(); + const nextPaymentElements = getAllByText('January 1, 2022'); + expect(nextPaymentElements.length).toBe(2); + + expect(getByText('Next payment amount')).toBeVisible(); + expect(getByText('$100.00')).toBeVisible(); + expect(queryByText('Ends on')).toBeNull(); + }); + + const menuButton = getByRole('button', { name: /Open menu/i }); + expect(menuButton).toBeVisible(); + await userEvent.click(menuButton); + + await waitFor(() => { + expect(getByText('Switch to monthly $10.00 per month')).toBeVisible(); + expect(getByText('Cancel subscription')).toBeVisible(); + }); + }); + + it('active free subscription', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ + data: [ + { + id: 'sub_123', + plan: { + id: 'plan_123', + name: 'Free Plan', + amount: 0, + amountFormatted: '0.00', + annualAmount: 0, + annualAmountFormatted: '0.00', + annualMonthlyAmount: 0, + annualMonthlyAmountFormatted: '0.00', + currencySymbol: '$', + description: 'Free Plan description', + hasBaseFee: false, + isRecurring: true, + currency: 'USD', + isDefault: true, + }, + createdAt: new Date('2021-01-01'), + periodStart: new Date('2021-01-01'), + periodEnd: new Date('2021-02-01'), + canceledAt: null, + paymentSourceId: 'src_123', + planPeriod: 'month', + status: 'active', + }, + ], + total_count: 1, + }); + + const { getByRole, getByText, queryByText, queryByRole } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <SubscriptionDetails /> + </Drawer.Root>, + { wrapper }, + ); + await waitFor(() => { + expect(getByRole('heading', { name: /Subscription/i })).toBeVisible(); + + expect(getByText('Free Plan')).toBeVisible(); + expect(getByText('Active')).toBeVisible(); + + expect(getByText('$0.00 / Month')).toBeVisible(); + + expect(getByText('Subscribed on')).toBeVisible(); + expect(getByText('January 1, 2021')).toBeVisible(); + + expect(queryByText('Renews at')).toBeNull(); + expect(queryByText('Ends on')).toBeNull(); + expect(queryByText('Current billing cycle')).toBeNull(); + expect(queryByText('Monthly')).toBeNull(); + expect(queryByText('Next payment on')).toBeNull(); + expect(queryByText('Next payment amount')).toBeNull(); + expect(queryByRole('button', { name: /Open menu/i })).toBeNull(); + }); + }); + + it('one active annual and one upcoming monthly subscription', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const planAnnual = { + id: 'plan_annual', + name: 'Annual Plan', + amount: 1300, + amountFormatted: '13.00', + annualAmount: 12000, + annualAmountFormatted: '120.00', + annualMonthlyAmount: 1000, + annualMonthlyAmountFormatted: '10.00', + currencySymbol: '$', + description: 'Annual Plan', + hasBaseFee: true, + isRecurring: true, + currency: 'USD', + isDefault: false, + payerType: ['user'], + publiclyVisible: true, + slug: 'annual-plan', + avatarUrl: '', + features: [], + }; + const planMonthly = { + id: 'plan_monthly', + name: 'Monthly Plan', + amount: 1000, + amountFormatted: '10.00', + annualAmount: 9000, + annualAmountFormatted: '90.00', + annualMonthlyAmount: 750, + annualMonthlyAmountFormatted: '7.50', + currencySymbol: '$', + description: 'Monthly Plan', + hasBaseFee: true, + isRecurring: true, + currency: 'USD', + isDefault: false, + payerType: ['user'], + publiclyVisible: true, + slug: 'monthly-plan', + avatarUrl: '', + features: [], + }; + + fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ + data: [ + { + id: 'sub_annual', + plan: planAnnual, + createdAt: new Date('2021-01-01'), + periodStart: new Date('2021-01-01'), + periodEnd: new Date('2022-01-01'), + canceledAt: new Date('2021-04-01'), + paymentSourceId: 'src_annual', + planPeriod: 'annual', + status: 'active', + }, + { + id: 'sub_monthly', + plan: planMonthly, + createdAt: new Date('2022-01-01'), + periodStart: new Date('2022-02-01'), + periodEnd: new Date('2022-03-01'), + canceledAt: null, + paymentSourceId: 'src_monthly', + planPeriod: 'month', + status: 'upcoming', + }, + ], + total_count: 2, + }); + + const { getByRole, getByText, getAllByText, queryByText, getAllByRole, userEvent } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <SubscriptionDetails /> + </Drawer.Root>, + { wrapper }, + ); + + await waitFor(() => { + expect(getByRole('heading', { name: /Subscription/i })).toBeVisible(); + expect(getByText('Annual Plan')).toBeVisible(); + expect(getByText('Active')).toBeVisible(); + expect(getByText('$120.00 / Year')).toBeVisible(); + expect(getByText('Subscribed on')).toBeVisible(); + expect(getByText('January 1, 2021')).toBeVisible(); + expect(getByText('Ends on')).toBeVisible(); + expect(getByText('January 1, 2022')).toBeVisible(); + expect(queryByText('Renews at')).toBeNull(); + expect(getByText('Current billing cycle')).toBeVisible(); + expect(getByText('Annually')).toBeVisible(); + expect(getByText('Next payment on')).toBeVisible(); + expect(getAllByText('January 1, 2022').length).toBeGreaterThan(0); + expect(getByText('Next payment amount')).toBeVisible(); + expect(getByText('$10.00')).toBeVisible(); + // Check for the upcoming subscription details + expect(getByText('Upcoming')).toBeVisible(); + expect(getByText('Monthly Plan')).toBeVisible(); + expect(getByText('$10.00 / Month')).toBeVisible(); + expect(getByText('Begins on')).toBeVisible(); + }); + + const [menuButton, upcomingMenuButton] = getAllByRole('button', { name: /Open menu/i }); + await userEvent.click(menuButton); + + await waitFor(() => { + expect(getByText('Switch to monthly $13.00 per month')).toBeVisible(); + expect(getByText('Resubscribe')).toBeVisible(); + expect(queryByText('Cancel subscription')).toBeNull(); + }); + + await userEvent.click(upcomingMenuButton); + + await waitFor(() => { + expect(getByText('Switch to annual $90.00 per year')).toBeVisible(); + expect(getByText('Cancel subscription')).toBeVisible(); + }); + }); + + it('one active and one upcoming FREE subscription', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const planMonthly = { + id: 'plan_monthly', + name: 'Monthly Plan', + amount: 1000, + amountFormatted: '10.00', + annualAmount: 9000, + annualAmountFormatted: '90.00', + annualMonthlyAmount: 750, + annualMonthlyAmountFormatted: '7.50', + currencySymbol: '$', + description: 'Monthly Plan', + hasBaseFee: true, + isRecurring: true, + currency: 'USD', + isDefault: false, + payerType: ['user'], + publiclyVisible: true, + slug: 'monthly-plan', + avatarUrl: '', + features: [], + }; + + const planFreeUpcoming = { + id: 'plan_free_upcoming', + name: 'Free Plan (Upcoming)', + amount: 0, + amountFormatted: '0.00', + annualAmount: 0, + annualAmountFormatted: '0.00', + annualMonthlyAmount: 0, + annualMonthlyAmountFormatted: '0.00', + currencySymbol: '$', + description: 'Upcoming Free Plan', + hasBaseFee: false, + isRecurring: true, + currency: 'USD', + isDefault: false, + payerType: ['user'], + publiclyVisible: true, + slug: 'free-plan-upcoming', + avatarUrl: '', + features: [], + }; + + fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ + data: [ + { + id: 'test_active', + plan: planMonthly, + createdAt: new Date('2021-01-01'), + periodStart: new Date('2021-01-01'), + periodEnd: new Date('2021-02-01'), + canceledAt: new Date('2021-01-03'), + paymentSourceId: 'src_free_active', + planPeriod: 'month', + status: 'active', + }, + { + id: 'sub_free_upcoming', + plan: planFreeUpcoming, + createdAt: new Date('2021-01-03'), + periodStart: new Date('2021-02-01'), + canceledAt: null, + paymentSourceId: 'src_free_upcoming', + planPeriod: 'month', + status: 'upcoming', + }, + ], + total_count: 2, + }); + + const { getByRole, getByText, queryByText, getAllByText } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <SubscriptionDetails /> + </Drawer.Root>, + { wrapper }, + ); + + await waitFor(() => { + expect(getByRole('heading', { name: /Subscription/i })).toBeVisible(); + expect(getByText('Monthly Plan')).toBeVisible(); + expect(getByText('Active')).toBeVisible(); + expect(getByText('$10.00 / Month')).toBeVisible(); + expect(getByText('Subscribed on')).toBeVisible(); + expect(getByText('January 1, 2021')).toBeVisible(); + expect(getByText('Ends on')).toBeVisible(); + + expect(queryByText('Renews at')).toBeNull(); + expect(queryByText('Current billing cycle')).toBeNull(); + expect(queryByText('Next payment on')).toBeNull(); + expect(queryByText('Next payment amount')).toBeNull(); + // Check for the upcoming free subscription details + expect(getByText('Upcoming')).toBeVisible(); + expect(getByText('Free Plan (Upcoming)')).toBeVisible(); + expect(getByText('$0.00 / Month')).toBeVisible(); + expect(getByText('Begins on')).toBeVisible(); + + const nextPaymentElements = getAllByText('February 1, 2021'); + expect(nextPaymentElements.length).toBe(2); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index 72e2d29b444..071b4f334d8 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -2,6 +2,7 @@ import { useClerk, useOrganization } from '@clerk/shared/react'; import type { __experimental_SubscriptionDetailsProps, __internal_CheckoutProps, + CommercePlanResource, CommerceSubscriptionResource, } from '@clerk/types'; import * as React from 'react'; @@ -21,6 +22,8 @@ import { ThreeDots } from '@/ui/icons'; import { handleError } from '@/ui/utils/errorHandler'; import { formatDate } from '@/ui/utils/formatDate'; +const isFreePlan = (plan: CommercePlanResource) => !plan.hasBaseFee; + import { usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; import { Badge, @@ -187,7 +190,7 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => { }, [subscription, setError, setLoading, subscriberType, organization?.id, onSubscriptionCancel, setIsOpen, setIdle]); // If either the active or upcoming subscription is the free plan, then a C1 cannot switch to a different period or cancel the plan - if (anySubscription.plan.isDefault) { + if (isFreePlan(anySubscription.plan)) { return null; } @@ -340,9 +343,9 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc const isSwitchable = (subscription.planPeriod === 'month' && subscription.plan.annualMonthlyAmount > 0) || subscription.planPeriod === 'annual'; - const isFreePlan = subscription.plan.isDefault; - const isCancellable = subscription.canceledAt === null && !isFreePlan; - const isReSubscribable = subscription.canceledAt !== null && !isFreePlan; + const isFree = isFreePlan(subscription.plan); + const isCancellable = subscription.canceledAt === null && !isFree; + const isReSubscribable = subscription.canceledAt !== null && !isFree; const openCheckout = useCallback( (params?: __internal_CheckoutProps) => { @@ -456,6 +459,7 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc // New component for individual subscription cards const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscriptionResource }) => { const isActive = subscription.status === 'active'; + const isFree = isFreePlan(subscription.plan); const { t } = useLocalizations(); return ( @@ -539,10 +543,12 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription // TODO: Use localization for dates value={formatDate(subscription.createdAt)} /> - <DetailRow - label={subscription.canceledAt ? 'Ends on' : 'Renews at'} - value={formatDate(subscription.periodEnd)} - /> + {!isFree && ( + <DetailRow + label={subscription.canceledAt ? 'Ends on' : 'Renews at'} + value={formatDate(subscription.periodEnd)} + /> + )} </> ) : ( <DetailRow diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index d09ba7c7e1f..8575569232d 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -1,4 +1,5 @@ import type { + __experimental_SubscriptionDetailsProps, __internal_OAuthConsentProps, APIKeysProps, PricingTableProps, @@ -25,6 +26,7 @@ import { UserVerificationContext, WaitlistContext, } from './components'; +import { SubscriptionDetailsContext } from './components/SubscriptionDetails'; export function ComponentContextProvider({ componentName, @@ -108,6 +110,14 @@ export function ComponentContextProvider({ {children} </OAuthConsentContext.Provider> ); + case 'SubscriptionDetails': + return ( + <SubscriptionDetailsContext.Provider + value={{ componentName, ...(props as __experimental_SubscriptionDetailsProps) }} + > + {children} + </SubscriptionDetailsContext.Provider> + ); default: throw new Error(`Unknown component context: ${componentName}`); diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index b3df0b937e4..7fbd4bd04e1 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -158,5 +158,6 @@ export type AvailableComponentCtx = | PricingTableCtx | CheckoutCtx | APIKeysCtx - | OAuthConsentCtx; + | OAuthConsentCtx + | SubscriptionDetailsCtx; export type AvailableComponentName = AvailableComponentCtx['componentName']; diff --git a/packages/clerk-js/src/ui/utils/test/createFixtures.tsx b/packages/clerk-js/src/ui/utils/test/createFixtures.tsx index 4187dfdda57..d3491841d03 100644 --- a/packages/clerk-js/src/ui/utils/test/createFixtures.tsx +++ b/packages/clerk-js/src/ui/utils/test/createFixtures.tsx @@ -74,6 +74,7 @@ const unboundCreateFixtures = ( session: clerkMock.session, signIn: clerkMock.client.signIn, signUp: clerkMock.client.signUp, + billing: clerkMock.billing, environment: environmentMock, router: routerMock, options: optionsMock, diff --git a/packages/clerk-js/src/ui/utils/test/mockHelpers.ts b/packages/clerk-js/src/ui/utils/test/mockHelpers.ts index 8e7cf2a3265..82eee90827d 100644 --- a/packages/clerk-js/src/ui/utils/test/mockHelpers.ts +++ b/packages/clerk-js/src/ui/utils/test/mockHelpers.ts @@ -46,6 +46,7 @@ export const mockClerkMethods = (clerk: LoadedClerk): DeepJestMocked<LoadedClerk mockMethodsOf(clerk); mockMethodsOf(clerk.client.signIn); mockMethodsOf(clerk.client.signUp); + mockMethodsOf(clerk.billing); clerk.client.sessions.forEach(session => { mockMethodsOf(session, { exclude: ['checkAuthorization'], From a2303b6377bd6ac74b1a2592a11c1a57f3fde8aa Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Tue, 1 Jul 2025 14:36:46 +0300 Subject: [PATCH 07/34] address issue with animation --- .../components/SubscriptionDetails/index.tsx | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index 071b4f334d8..bd3a2c8a25e 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -41,10 +41,15 @@ import { useLocalizations, } from '../../customizables'; +// We cannot derive the state of confrimation modal from the existance subscription, as it will make the animation laggy when the confimation closes. const SubscriptionForCancellationContext = React.createContext<{ subscription: CommerceSubscriptionResource | null; setSubscription: (subscription: CommerceSubscriptionResource | null) => void; + confirmationOpen: boolean; + setConfirmationOpen: (confirmationOpen: boolean) => void; }>({ + confirmationOpen: false, + setConfirmationOpen: () => {}, subscription: null, setSubscription: () => {}, }); @@ -97,6 +102,7 @@ const SubscriptionDetailsInternal = (props: __experimental_SubscriptionDetailsPr const [subscriptionForCancellation, setSubscriptionForCancellation] = useState<CommerceSubscriptionResource | null>( null, ); + const [confirmationOpen, setConfirmationOpen] = useState(false); const { buttonPropsForPlan: _buttonPropsForPlan, @@ -123,7 +129,12 @@ const SubscriptionDetailsInternal = (props: __experimental_SubscriptionDetailsPr return ( <SubscriptionForCancellationContext.Provider - value={{ subscription: subscriptionForCancellation, setSubscription: setSubscriptionForCancellation }} + value={{ + subscription: subscriptionForCancellation, + setSubscription: setSubscriptionForCancellation, + confirmationOpen, + setConfirmationOpen, + }} > <Drawer.Header title='Subscription' /> @@ -155,15 +166,12 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => { const subscriberType = useSubscriberTypeContext(); const { organization } = useOrganization(); const { isLoading, error, setError, setLoading, setIdle } = useCardState(); - const { subscription, setSubscription } = useContext(SubscriptionForCancellationContext); + const { subscription, confirmationOpen, setConfirmationOpen } = useContext(SubscriptionForCancellationContext); const { anySubscription } = useGuessableSubscription({ or: 'throw' }); const { setIsOpen } = useDrawerContext(); const { onSubscriptionCancel } = useSubscriptionDetailsContext(); - const onOpenChange = useCallback( - (open: boolean) => setSubscription(open ? subscription : null), - [subscription, setSubscription], - ); + const onOpenChange = useCallback((open: boolean) => setConfirmationOpen(open), [setConfirmationOpen]); const cancelSubscription = useCallback(async () => { if (!subscription) { @@ -199,7 +207,7 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => { <SubscriptionDetailsSummary /> <Drawer.Confirmation - open={!!subscription} + open={confirmationOpen} onOpenChange={onOpenChange} actionsSlot={ <> @@ -336,7 +344,7 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc const subscriberType = useSubscriberTypeContext(); const { setIsOpen } = useDrawerContext(); const { revalidateAll } = usePlansContext(); - const { setSubscription } = useContext(SubscriptionForCancellationContext); + const { setSubscription, setConfirmationOpen } = useContext(SubscriptionForCancellationContext); const canOrgManageBilling = useProtect(has => has({ permission: 'org:sys_billing:manage' })); const canManageBilling = subscriberType === 'user' || canOrgManageBilling; @@ -397,6 +405,7 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc label: localizationKeys('commerce.cancelSubscription'), onClick: () => { setSubscription(subscription); + setConfirmationOpen(true); }, } : null, @@ -422,6 +431,7 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc setSubscription, canManageBilling, isReSubscribable, + setConfirmationOpen, ]); if (actions.length === 0) { From e0f8e1aa7e8a55dedf53ff03c903eb49a187f3ed Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Tue, 1 Jul 2025 15:16:17 +0300 Subject: [PATCH 08/34] add more test cases --- .../__tests__/SubscriptionDetails.test.tsx | 264 +++++++++++++++++- 1 file changed, 252 insertions(+), 12 deletions(-) diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx index ca0fa83ceed..55d4a848615 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx @@ -138,8 +138,8 @@ describe('SubscriptionDetails', () => { periodEnd: new Date('2022-01-01'), canceledAt: null, paymentSourceId: 'src_123', - planPeriod: 'annual', - status: 'active', + planPeriod: 'annual' as const, + status: 'active' as const, }, ], total_count: 1, @@ -218,8 +218,8 @@ describe('SubscriptionDetails', () => { periodEnd: new Date('2021-02-01'), canceledAt: null, paymentSourceId: 'src_123', - planPeriod: 'month', - status: 'active', + planPeriod: 'month' as const, + status: 'active' as const, }, ], total_count: 1, @@ -313,8 +313,8 @@ describe('SubscriptionDetails', () => { periodEnd: new Date('2022-01-01'), canceledAt: new Date('2021-04-01'), paymentSourceId: 'src_annual', - planPeriod: 'annual', - status: 'active', + planPeriod: 'annual' as const, + status: 'active' as const, }, { id: 'sub_monthly', @@ -324,8 +324,8 @@ describe('SubscriptionDetails', () => { periodEnd: new Date('2022-03-01'), canceledAt: null, paymentSourceId: 'src_monthly', - planPeriod: 'month', - status: 'upcoming', + planPeriod: 'month' as const, + status: 'upcoming' as const, }, ], total_count: 2, @@ -440,8 +440,8 @@ describe('SubscriptionDetails', () => { periodEnd: new Date('2021-02-01'), canceledAt: new Date('2021-01-03'), paymentSourceId: 'src_free_active', - planPeriod: 'month', - status: 'active', + planPeriod: 'month' as const, + status: 'active' as const, }, { id: 'sub_free_upcoming', @@ -450,8 +450,8 @@ describe('SubscriptionDetails', () => { periodStart: new Date('2021-02-01'), canceledAt: null, paymentSourceId: 'src_free_upcoming', - planPeriod: 'month', - status: 'upcoming', + planPeriod: 'month' as const, + status: 'upcoming' as const, }, ], total_count: 2, @@ -490,4 +490,244 @@ describe('SubscriptionDetails', () => { expect(nextPaymentElements.length).toBe(2); }); }); + + it('allows cancelling a subscription of a monthly plan', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const cancelSubscriptionMock = jest.fn().mockResolvedValue({}); + + fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ + data: [ + { + id: 'sub_123', + plan: { + id: 'plan_123', + name: 'Monthly Plan', + amount: 1000, + amountFormatted: '10.00', + annualAmount: 10000, + annualAmountFormatted: '100.00', + annualMonthlyAmount: 8333, + annualMonthlyAmountFormatted: '83.33', + currencySymbol: '$', + description: 'Monthly Plan', + hasBaseFee: true, + isRecurring: true, + currency: 'USD', + isDefault: false, + }, + createdAt: new Date('2021-01-01'), + periodStart: new Date('2021-01-01'), + periodEnd: new Date('2021-02-01'), + canceledAt: null, + paymentSourceId: 'src_123', + planPeriod: 'month' as const, + status: 'active' as const, + cancel: cancelSubscriptionMock, + }, + ], + total_count: 1, + }); + + const { getByRole, getByText, userEvent } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <SubscriptionDetails /> + </Drawer.Root>, + { wrapper }, + ); + + // Wait for the subscription details to render + await waitFor(() => { + expect(getByText('Monthly Plan')).toBeVisible(); + expect(getByText('Active')).toBeVisible(); + }); + + // Open the menu + const menuButton = getByRole('button', { name: /Open menu/i }); + await userEvent.click(menuButton); + + // Wait for the cancel option to appear and click it + await userEvent.click(getByText('Cancel subscription')); + + await waitFor(() => { + expect(getByText('Cancel Monthly Plan Subscription?')).toBeVisible(); + expect( + getByText( + "You can keep using 'Monthly Plan' features until February 1, 2021, after which you will no longer have access.", + ), + ).toBeVisible(); + expect(getByText('Keep subscription')).toBeVisible(); + }); + + await userEvent.click(getByText('Cancel subscription')); + + // Assert that the cancelSubscription method was called + await waitFor(() => { + expect(cancelSubscriptionMock).toHaveBeenCalled(); + }); + }); + + it('calls resubscribe when the user clicks Resubscribe for a canceled subscription', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const plan = { + id: 'plan_annual', + name: 'Annual Plan', + amount: 12000, + amountFormatted: '120.00', + annualAmount: 12000, + annualAmountFormatted: '120.00', + annualMonthlyAmount: 1000, + annualMonthlyAmountFormatted: '10.00', + currencySymbol: '$', + description: 'Annual Plan', + hasBaseFee: true, + isRecurring: true, + currency: 'USD', + isDefault: false, + payerType: ['user'], + publiclyVisible: true, + slug: 'annual-plan', + avatarUrl: '', + features: [], + __internal_toSnapshot: jest.fn(), + pathRoot: '', + reload: jest.fn(), + }; + + const subscription = { + id: 'sub_annual', + plan, + createdAt: new Date('2021-01-01'), + periodStart: new Date('2021-01-01'), + periodEnd: new Date('2022-01-01'), + canceledAt: new Date('2021-04-01'), + paymentSourceId: 'src_annual', + planPeriod: 'annual' as const, + status: 'active' as const, + cancel: jest.fn(), + pathRoot: '', + reload: jest.fn(), + }; + + // Mock getSubscriptions to return the canceled subscription + fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ + data: [subscription], + total_count: 1, + }); + + const { getByRole, getByText, userEvent } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <SubscriptionDetails /> + </Drawer.Root>, + { wrapper }, + ); + + await waitFor(() => { + expect(getByText('Annual Plan')).toBeVisible(); + }); + + // Open the menu + const menuButton = getByRole('button', { name: /Open menu/i }); + await userEvent.click(menuButton); + + // Wait for the Resubscribe option and click it + await userEvent.click(getByText('Resubscribe')); + + // Assert resubscribe was called + await waitFor(() => { + expect(fixtures.clerk.__internal_openCheckout).toHaveBeenCalled(); + }); + }); + + it('calls switchToMonthly when the user clicks Switch to monthly for an annual subscription', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const switchToMonthlyMock = jest.fn().mockResolvedValue({}); + const plan = { + id: 'plan_annual', + name: 'Annual Plan', + amount: 12000, + amountFormatted: '120.00', + annualAmount: 12000, + annualAmountFormatted: '120.00', + annualMonthlyAmount: 1000, + annualMonthlyAmountFormatted: '10.00', + currencySymbol: '$', + description: 'Annual Plan', + hasBaseFee: true, + isRecurring: true, + currency: 'USD', + isDefault: false, + payerType: ['user'], + publiclyVisible: true, + slug: 'annual-plan', + avatarUrl: '', + features: [], + }; + + const subscription = { + id: 'sub_annual', + plan, + createdAt: new Date('2021-01-01'), + periodStart: new Date('2021-01-01'), + periodEnd: new Date('2022-01-01'), + canceledAt: null, + paymentSourceId: 'src_annual', + planPeriod: 'annual' as const, + status: 'active' as const, + cancel: jest.fn(), + pathRoot: '', + reload: jest.fn(), + }; + + // Mock getSubscriptions to return the annual subscription + fixtures.clerk.billing.getSubscriptions.mockResolvedValue({ + data: [subscription], + total_count: 1, + }); + + const { getByRole, getByText, userEvent } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <SubscriptionDetails /> + </Drawer.Root>, + { wrapper }, + ); + + await waitFor(() => { + expect(getByText('Annual Plan')).toBeVisible(); + }); + + // Open the menu + const menuButton = getByRole('button', { name: /Open menu/i }); + await userEvent.click(menuButton); + + // Wait for the Switch to monthly option and click it + await userEvent.click(getByText(/Switch to monthly/i)); + + // Assert switchToMonthly was called + await waitFor(() => { + expect(fixtures.clerk.__internal_openCheckout).toHaveBeenCalledWith( + expect.objectContaining({ + planId: subscription.plan.id, + planPeriod: 'month' as const, + }), + ); + }); + }); }); From 1b75fe1f1c4e3f2db749d189a6287b4298c51028 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Tue, 1 Jul 2025 15:52:19 +0300 Subject: [PATCH 09/34] use box shadow instead of border --- .../clerk-js/src/ui/components/SubscriptionDetails/index.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index bd3a2c8a25e..9bbc148390b 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -475,10 +475,8 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription return ( <Col sx={t => ({ - borderWidth: t.borderWidths.$normal, - borderStyle: t.borderStyles.$solid, - borderColor: t.colors.$neutralAlpha100, borderRadius: t.radii.$md, + boxShadow: t.shadows.$tableBodyShadow, })} > <Col From 3a0907c3805803d41769b78fb612437abc225fb2 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Tue, 1 Jul 2025 17:12:54 +0300 Subject: [PATCH 10/34] remove unnecessary context --- .../src/ui/contexts/ClerkUIComponentsContext.tsx | 11 ----------- .../clerk-js/src/ui/utils/test/createFixtures.tsx | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index 8575569232d..2cada4a7da1 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -1,5 +1,4 @@ import type { - __experimental_SubscriptionDetailsProps, __internal_OAuthConsentProps, APIKeysProps, PricingTableProps, @@ -26,7 +25,6 @@ import { UserVerificationContext, WaitlistContext, } from './components'; -import { SubscriptionDetailsContext } from './components/SubscriptionDetails'; export function ComponentContextProvider({ componentName, @@ -110,15 +108,6 @@ export function ComponentContextProvider({ {children} </OAuthConsentContext.Provider> ); - case 'SubscriptionDetails': - return ( - <SubscriptionDetailsContext.Provider - value={{ componentName, ...(props as __experimental_SubscriptionDetailsProps) }} - > - {children} - </SubscriptionDetailsContext.Provider> - ); - default: throw new Error(`Unknown component context: ${componentName}`); } diff --git a/packages/clerk-js/src/ui/utils/test/createFixtures.tsx b/packages/clerk-js/src/ui/utils/test/createFixtures.tsx index d3491841d03..bc6532a706a 100644 --- a/packages/clerk-js/src/ui/utils/test/createFixtures.tsx +++ b/packages/clerk-js/src/ui/utils/test/createFixtures.tsx @@ -90,7 +90,7 @@ const unboundCreateFixtures = ( const MockClerkProvider = (props: any) => { const { children } = props; - const componentsWithoutContext = ['UsernameSection', 'UserProfileSection']; + const componentsWithoutContext = ['UsernameSection', 'UserProfileSection', 'SubscriptionDetails']; const contextWrappedChildren = !componentsWithoutContext.includes(componentName) ? ( <ComponentContextProvider componentName={componentName} From 1ddd06b5d1668a21f12b3be851e65444b4dc25f8 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Tue, 1 Jul 2025 17:19:03 +0300 Subject: [PATCH 11/34] handle missing avatar url --- .../components/SubscriptionDetails/index.tsx | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index 9bbc148390b..65462a48b67 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -490,20 +490,16 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription align='center' gap={2} > - <Avatar - boxElementDescriptor={descriptors.planDetailAvatar} - // TODO: Use size prop - size={_ => 40} - title={subscription.plan.name} - initials={subscription.plan.name[0]} - rounded={false} - // TODO: remove hardcoded image - imageUrl={subscription.plan.avatarUrl || 'https://i.ibb.co/s9GqfwtK/Frame-106.png'} - // TODO: remove hardcoded background - sx={{ - background: 'unset', - }} - /> + {subscription.plan.avatarUrl ? ( + <Avatar + // TODO(@commerce): Add correct descriptor + boxElementDescriptor={descriptors.planDetailAvatar} + size={_ => 40} + title={subscription.plan.name} + rounded={false} + imageUrl={subscription.plan.avatarUrl} + /> + ) : null} <Text sx={{ From d84f755de4f51048d2acd97f2b41661dd23f32d9 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Tue, 1 Jul 2025 20:50:40 +0300 Subject: [PATCH 12/34] add descriptors --- .../components/SubscriptionDetails/index.tsx | 22 +++++++++---------- .../ui/customizables/elementDescriptors.ts | 12 ++++++++++ packages/types/src/appearance.ts | 12 ++++++++++ 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index 65462a48b67..ac57bc4f3a7 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -275,6 +275,7 @@ function SubscriptionDetailsSummary() { return ( <Col + elementDescriptor={descriptors.subscriptionDetailsSummaryItems} gap={3} as='ul' sx={t => ({ @@ -307,7 +308,6 @@ function SubscriptionDetailsSummary() { </SummmaryItemLabel> <SummmaryItemValue> <Span - // elementDescriptor={descriptors.paymentAttemptFooterValueContainer} sx={t => ({ display: 'flex', alignItems: 'center', @@ -317,15 +317,11 @@ function SubscriptionDetailsSummary() { <Text variant='caption' colorScheme='secondary' - // elementDescriptor={descriptors.paymentAttemptFooterCurrency} sx={{ textTransform: 'uppercase' }} > {anySubscription.plan.currency} </Text> - <Text - // variant='h3' - // elementDescriptor={descriptors.paymentAttemptFooterValue} - > + <Text> {anySubscription.plan.currencySymbol} {anySubscription.planPeriod === 'month' ? anySubscription.plan.amountFormatted @@ -474,12 +470,14 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription return ( <Col + elementDescriptor={descriptors.subscriptionDetailsCard} sx={t => ({ borderRadius: t.radii.$md, boxShadow: t.shadows.$tableBodyShadow, })} > <Col + elementDescriptor={descriptors.subscriptionDetailsCardBody} gap={3} sx={t => ({ padding: t.space.$3, @@ -487,12 +485,12 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription > {/* Header with name and badge */} <Flex + elementDescriptor={descriptors.subscriptionDetailsCardHeader} align='center' gap={2} > {subscription.plan.avatarUrl ? ( <Avatar - // TODO(@commerce): Add correct descriptor boxElementDescriptor={descriptors.planDetailAvatar} size={_ => 40} title={subscription.plan.name} @@ -502,6 +500,7 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription ) : null} <Text + elementDescriptor={descriptors.subscriptionDetailsCardTitle} sx={{ fontSize: '16px', fontWeight: '600', @@ -512,6 +511,7 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription {subscription.plan.name} </Text> <Badge + elementDescriptor={descriptors.subscriptionDetailsCardBadge} colorScheme={isActive ? 'secondary' : 'primary'} localizationKey={isActive ? localizationKeys('badge__activePlan') : localizationKeys('badge__upcomingPlan')} /> @@ -519,7 +519,7 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription {/* Pricing details */} <Flex - elementDescriptor={descriptors.statementSectionContentDetailsList} + elementDescriptor={descriptors.subscriptionDetailsCardActions} justify='between' align='center' > @@ -585,7 +585,7 @@ const DetailRow = ({ label, value }: { label: string; value: string }) => ( function SummaryItem(props: React.PropsWithChildren) { return ( <Box - elementDescriptor={descriptors.statementSectionContentDetailsListItem} + elementDescriptor={descriptors.subscriptionDetailsSummaryItem} as='li' sx={{ display: 'flex', @@ -601,7 +601,7 @@ function SummaryItem(props: React.PropsWithChildren) { function SummmaryItemLabel(props: React.PropsWithChildren) { return ( <Span - elementDescriptor={descriptors.statementSectionContentDetailsListItemLabelContainer} + elementDescriptor={descriptors.subscriptionDetailsSummaryLabel} sx={t => ({ display: 'flex', alignItems: 'center', @@ -616,7 +616,7 @@ function SummmaryItemLabel(props: React.PropsWithChildren) { function SummmaryItemValue(props: React.PropsWithChildren) { return ( <Span - elementDescriptor={descriptors.statementSectionContentDetailsListItemLabelContainer} + elementDescriptor={descriptors.subscriptionDetailsSummaryValue} sx={t => ({ display: 'flex', alignItems: 'center', diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index 690efe896a6..13f49d7b81c 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -481,6 +481,18 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'apiKeysRevokeModal', 'apiKeysRevokeModalInput', 'apiKeysRevokeModalSubmitButton', + + 'subscriptionDetailsCard', + 'subscriptionDetailsCardHeader', + 'subscriptionDetailsCardBadge', + 'subscriptionDetailsCardTitle', + 'subscriptionDetailsCardBody', + 'subscriptionDetailsCardFooter', + 'subscriptionDetailsCardActions', + 'subscriptionDetailsSummaryItems', + 'subscriptionDetailsSummaryItem', + 'subscriptionDetailsSummaryLabel', + 'subscriptionDetailsSummaryValue', ] as const).map(camelize) as (keyof ElementsConfig)[]; type TargettableClassname<K extends keyof ElementsConfig> = `${typeof CLASS_PREFIX}${K}`; diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index dc647795bee..ea202356f13 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -608,6 +608,18 @@ export type ElementsConfig = { apiKeysRevokeModal: WithOptions; apiKeysRevokeModalInput: WithOptions; apiKeysRevokeModalSubmitButton: WithOptions; + + subscriptionDetailsCard: WithOptions; + subscriptionDetailsCardHeader: WithOptions; + subscriptionDetailsCardBadge: WithOptions; + subscriptionDetailsCardTitle: WithOptions; + subscriptionDetailsCardBody: WithOptions; + subscriptionDetailsCardFooter: WithOptions; + subscriptionDetailsCardActions: WithOptions; + subscriptionDetailsSummaryItems: WithOptions; + subscriptionDetailsSummaryItem: WithOptions; + subscriptionDetailsSummaryLabel: WithOptions; + subscriptionDetailsSummaryValue: WithOptions; }; export type Elements = { From aaf814ceee38d0dfeb19b4e01d0416d23980d3e1 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Wed, 2 Jul 2025 12:11:05 +0300 Subject: [PATCH 13/34] add more descriptors --- .../components/SubscriptionDetails/index.tsx | 82 +++++++++++-------- .../ui/customizables/elementDescriptors.ts | 3 + packages/types/src/appearance.ts | 3 + 3 files changed, 52 insertions(+), 36 deletions(-) diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index ac57bc4f3a7..59675704d58 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -297,8 +297,10 @@ function SubscriptionDetailsSummary() { <SummmaryItemValue> <Text colorScheme='secondary'> {upcomingSubscription - ? formatDate(upcomingSubscription.periodStart) - : formatDate(anySubscription.periodEnd)} + ? formatDate(upcomingSubscription.periodStartDate) + : anySubscription.periodEndDate + ? formatDate(anySubscription.periodEndDate) + : '-'} </Text> </SummmaryItemValue> </SummaryItem> @@ -306,28 +308,26 @@ function SubscriptionDetailsSummary() { <SummmaryItemLabel> <Text>Next payment amount</Text> </SummmaryItemLabel> - <SummmaryItemValue> - <Span - sx={t => ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$1, - })} + <SummmaryItemValue + sx={t => ({ + display: 'flex', + alignItems: 'center', + gap: t.space.$1, + })} + > + <Text + variant='caption' + colorScheme='secondary' + sx={{ textTransform: 'uppercase' }} > - <Text - variant='caption' - colorScheme='secondary' - sx={{ textTransform: 'uppercase' }} - > - {anySubscription.plan.currency} - </Text> - <Text> - {anySubscription.plan.currencySymbol} - {anySubscription.planPeriod === 'month' - ? anySubscription.plan.amountFormatted - : anySubscription.plan.annualAmountFormatted} - </Text> - </Span> + {anySubscription.plan.currency} + </Text> + <Text> + {anySubscription.plan.currencySymbol} + {anySubscription.planPeriod === 'month' + ? anySubscription.plan.amountFormatted + : anySubscription.plan.annualAmountFormatted} + </Text> </SummmaryItemValue> </SummaryItem> </Col> @@ -465,7 +465,6 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc // New component for individual subscription cards const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscriptionResource }) => { const isActive = subscription.status === 'active'; - const isFree = isFreePlan(subscription.plan); const { t } = useLocalizations(); return ( @@ -547,17 +546,18 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription // TODO: Use localization for dates value={formatDate(subscription.createdAt)} /> - {!isFree && ( + {/* The free plan does not have a period end date */} + {subscription.periodEndDate && ( <DetailRow - label={subscription.canceledAt ? 'Ends on' : 'Renews at'} - value={formatDate(subscription.periodEnd)} + label={subscription.canceledAtDate ? 'Ends on' : 'Renews at'} + value={formatDate(subscription.periodEndDate)} /> )} </> ) : ( <DetailRow label='Begins on' - value={formatDate(subscription.periodStart)} + value={formatDate(subscription.periodStartDate)} /> )} </Col> @@ -567,6 +567,7 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription // Helper component for detail rows const DetailRow = ({ label, value }: { label: string; value: string }) => ( <Flex + elementDescriptor={descriptors.subscriptionDetailsDetailRow} justify='between' align='center' sx={t => ({ @@ -577,8 +578,13 @@ const DetailRow = ({ label, value }: { label: string; value: string }) => ( borderBlockStartColor: t.colors.$neutralAlpha100, })} > - <Text>{label}</Text> - <Text colorScheme='secondary'>{value}</Text> + <Text elementDescriptor={descriptors.subscriptionDetailsDetailRowLabel}>{label}</Text> + <Text + elementDescriptor={descriptors.subscriptionDetailsDetailRowValue} + colorScheme='secondary' + > + {value} + </Text> </Flex> ); @@ -613,15 +619,19 @@ function SummmaryItemLabel(props: React.PropsWithChildren) { ); } -function SummmaryItemValue(props: React.PropsWithChildren) { +function SummmaryItemValue(props: Parameters<typeof Span>[0]) { return ( <Span elementDescriptor={descriptors.subscriptionDetailsSummaryValue} - sx={t => ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$0x25, - })} + {...props} + sx={[ + t => ({ + display: 'flex', + alignItems: 'center', + gap: t.space.$0x25, + }), + props.sx, + ]} > {props.children} </Span> diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index 13f49d7b81c..6f8307a8a8f 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -493,6 +493,9 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'subscriptionDetailsSummaryItem', 'subscriptionDetailsSummaryLabel', 'subscriptionDetailsSummaryValue', + 'subscriptionDetailsDetailRow', + 'subscriptionDetailsDetailRowLabel', + 'subscriptionDetailsDetailRowValue', ] as const).map(camelize) as (keyof ElementsConfig)[]; type TargettableClassname<K extends keyof ElementsConfig> = `${typeof CLASS_PREFIX}${K}`; diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index ea202356f13..e9404d8fcdf 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -620,6 +620,9 @@ export type ElementsConfig = { subscriptionDetailsSummaryItem: WithOptions; subscriptionDetailsSummaryLabel: WithOptions; subscriptionDetailsSummaryValue: WithOptions; + subscriptionDetailsDetailRow: WithOptions; + subscriptionDetailsDetailRowLabel: WithOptions; + subscriptionDetailsDetailRowValue: WithOptions; }; export type Elements = { From 55f1ba3d8398865b876569feaef50b43a574f8e0 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Wed, 2 Jul 2025 13:58:40 +0300 Subject: [PATCH 14/34] handle localizations --- .../components/SubscriptionDetails/index.tsx | 55 ++++++++++++++----- packages/localizations/src/en-US.ts | 10 ++++ packages/types/src/localization.ts | 10 ++++ 3 files changed, 60 insertions(+), 15 deletions(-) diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index 59675704d58..6d749c40518 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -25,6 +25,7 @@ import { formatDate } from '@/ui/utils/formatDate'; const isFreePlan = (plan: CommercePlanResource) => !plan.hasBaseFee; import { usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; +import type { LocalizationKey } from '../../customizables'; import { Badge, Box, @@ -136,7 +137,7 @@ const SubscriptionDetailsInternal = (props: __experimental_SubscriptionDetailsPr setConfirmationOpen, }} > - <Drawer.Header title='Subscription' /> + <Drawer.Header title={localizationKeys('commerce.subscriptionDetails.title')} /> <Drawer.Body> <Col @@ -254,7 +255,8 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => { ? localizationKeys('commerce.cancelSubscriptionNoCharge') : localizationKeys('commerce.cancelSubscriptionAccessUntil', { plan: subscription.plan.name, - date: subscription.periodEnd, + // @ts-expect-error this will always be defined in this state + date: subscription.periodEndDate, }) } /> @@ -268,6 +270,7 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => { function SubscriptionDetailsSummary() { const { anySubscription, activeSubscription, upcomingSubscription } = useGuessableSubscription({ or: 'throw' }); + const { t } = useLocalizations(); if (!activeSubscription) { return null; @@ -284,15 +287,25 @@ function SubscriptionDetailsSummary() { > <SummaryItem> <SummmaryItemLabel> - <Text colorScheme='secondary'>Current billing cycle</Text> + <Text + colorScheme='secondary' + localizationKey={localizationKeys('commerce.subscriptionDetails.currentBillingCycle')} + /> </SummmaryItemLabel> <SummmaryItemValue> - <Text colorScheme='secondary'>{activeSubscription.planPeriod === 'month' ? 'Monthly' : 'Annually'}</Text> + <Text + colorScheme='secondary' + localizationKey={ + activeSubscription.planPeriod === 'month' + ? localizationKeys('commerce.monthly') + : localizationKeys('commerce.annually') + } + /> </SummmaryItemValue> </SummaryItem> <SummaryItem> <SummmaryItemLabel> - <Text colorScheme='secondary'>Next payment on</Text> + <Text colorScheme='secondary'>{t(localizationKeys('commerce.subscriptionDetails.nextPaymentOn'))}</Text> </SummmaryItemLabel> <SummmaryItemValue> <Text colorScheme='secondary'> @@ -306,7 +319,10 @@ function SubscriptionDetailsSummary() { </SummaryItem> <SummaryItem> <SummmaryItemLabel> - <Text>Next payment amount</Text> + <Text + colorScheme='secondary' + localizationKey={localizationKeys('commerce.subscriptionDetails.nextPaymentAmount')} + /> </SummmaryItemLabel> <SummmaryItemValue sx={t => ({ @@ -337,6 +353,7 @@ function SubscriptionDetailsSummary() { const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubscriptionResource }) => { const { portalRoot } = useSubscriptionDetailsContext(); const { __internal_openCheckout } = useClerk(); + const { t } = useLocalizations(); const subscriberType = useSubscriberTypeContext(); const { setIsOpen } = useDrawerContext(); const { revalidateAll } = usePlansContext(); @@ -348,8 +365,8 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc (subscription.planPeriod === 'month' && subscription.plan.annualMonthlyAmount > 0) || subscription.planPeriod === 'annual'; const isFree = isFreePlan(subscription.plan); - const isCancellable = subscription.canceledAt === null && !isFree; - const isReSubscribable = subscription.canceledAt !== null && !isFree; + const isCancellable = subscription.canceledAtDate === null && !isFree; + const isReSubscribable = subscription.canceledAtDate !== null && !isFree; const openCheckout = useCallback( (params?: __internal_CheckoutProps) => { @@ -438,7 +455,7 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc <ThreeDotsMenu trigger={ <Button - aria-label='Manage subscription' + aria-label={t(localizationKeys('commerce.manageSubscription'))} variant='bordered' colorScheme='secondary' sx={t => ({ @@ -467,6 +484,8 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription const isActive = subscription.status === 'active'; const { t } = useLocalizations(); + console.log(subscription.periodEndDate, subscription.canceledAtDate, subscription); + return ( <Col elementDescriptor={descriptors.subscriptionDetailsCard} @@ -542,21 +561,24 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription {isActive ? ( <> <DetailRow - label='Subscribed on' - // TODO: Use localization for dates + label={localizationKeys('commerce.subscriptionDetails.subscribedOn')} value={formatDate(subscription.createdAt)} /> {/* The free plan does not have a period end date */} {subscription.periodEndDate && ( <DetailRow - label={subscription.canceledAtDate ? 'Ends on' : 'Renews at'} + label={ + subscription.canceledAtDate + ? localizationKeys('commerce.subscriptionDetails.endsOn') + : localizationKeys('commerce.subscriptionDetails.renewsAt') + } value={formatDate(subscription.periodEndDate)} /> )} </> ) : ( <DetailRow - label='Begins on' + label={localizationKeys('commerce.subscriptionDetails.beginsOn')} value={formatDate(subscription.periodStartDate)} /> )} @@ -565,7 +587,7 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription }; // Helper component for detail rows -const DetailRow = ({ label, value }: { label: string; value: string }) => ( +const DetailRow = ({ label, value }: { label: LocalizationKey; value: string }) => ( <Flex elementDescriptor={descriptors.subscriptionDetailsDetailRow} justify='between' @@ -578,7 +600,10 @@ const DetailRow = ({ label, value }: { label: string; value: string }) => ( borderBlockStartColor: t.colors.$neutralAlpha100, })} > - <Text elementDescriptor={descriptors.subscriptionDetailsDetailRowLabel}>{label}</Text> + <Text + elementDescriptor={descriptors.subscriptionDetailsDetailRowLabel} + localizationKey={label} + /> <Text elementDescriptor={descriptors.subscriptionDetailsDetailRowValue} colorScheme='secondary' diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index ef9c0f1871f..9054b1d041a 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -120,6 +120,16 @@ export const enUS: LocalizationResource = { billingCycle: 'Billing cycle', included: 'Included', }, + subscriptionDetails: { + title: 'Subscription', + currentBillingCycle: 'Current billing cycle', + nextPaymentOn: 'Next payment on', + nextPaymentAmount: 'Next payment amount', + subscribedOn: 'Subscribed on', + endsOn: 'Ends on', + renewsAt: 'Renews at', + beginsOn: 'Begins on', + }, reSubscribe: 'Resubscribe', seeAllFeatures: 'See all features', subscribe: 'Subscribe', diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 185b278a541..cf1ff595d1c 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -198,6 +198,16 @@ export type __internal_LocalizationResource = { cancelSubscriptionNoCharge: LocalizationValue; cancelSubscriptionAccessUntil: LocalizationValue<'plan' | 'date'>; popular: LocalizationValue; + subscriptionDetails: { + title: LocalizationValue; + currentBillingCycle: LocalizationValue; + nextPaymentOn: LocalizationValue; + nextPaymentAmount: LocalizationValue; + subscribedOn: LocalizationValue; + endsOn: LocalizationValue; + renewsAt: LocalizationValue; + beginsOn: LocalizationValue; + }; monthly: LocalizationValue; annually: LocalizationValue; cannotSubscribeMonthly: LocalizationValue; From 962e8d5e8eb89d2105a43c57a3a51d29ffc1b1fd Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Wed, 2 Jul 2025 13:58:52 +0300 Subject: [PATCH 15/34] fix issues from conflicts --- .../__tests__/SubscriptionDetails.test.tsx | 60 +++++++++---------- packages/types/src/json.ts | 1 - 2 files changed, 30 insertions(+), 31 deletions(-) diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx index 55d4a848615..15e3c15af3c 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx @@ -54,9 +54,9 @@ describe('SubscriptionDetails', () => { isDefault: false, }, createdAt: new Date('2021-01-01'), - periodStart: new Date('2021-01-01'), - periodEnd: new Date('2021-02-01'), - canceledAt: null, + periodStartDate: new Date('2021-01-01'), + periodEndDate: new Date('2021-02-01'), + canceledAtDate: null, paymentSourceId: 'src_123', planPeriod: 'month', status: 'active', @@ -134,9 +134,9 @@ describe('SubscriptionDetails', () => { isDefault: false, }, createdAt: new Date('2021-01-01'), - periodStart: new Date('2021-01-01'), - periodEnd: new Date('2022-01-01'), - canceledAt: null, + periodStartDate: new Date('2021-01-01'), + periodEndDate: new Date('2022-01-01'), + canceledAtDate: null, paymentSourceId: 'src_123', planPeriod: 'annual' as const, status: 'active' as const, @@ -214,9 +214,9 @@ describe('SubscriptionDetails', () => { isDefault: true, }, createdAt: new Date('2021-01-01'), - periodStart: new Date('2021-01-01'), - periodEnd: new Date('2021-02-01'), - canceledAt: null, + periodStartDate: new Date('2021-01-01'), + periodEndDate: new Date('2021-02-01'), + canceledAtDate: null, paymentSourceId: 'src_123', planPeriod: 'month' as const, status: 'active' as const, @@ -309,9 +309,9 @@ describe('SubscriptionDetails', () => { id: 'sub_annual', plan: planAnnual, createdAt: new Date('2021-01-01'), - periodStart: new Date('2021-01-01'), - periodEnd: new Date('2022-01-01'), - canceledAt: new Date('2021-04-01'), + periodStartDate: new Date('2021-01-01'), + periodEndDate: new Date('2022-01-01'), + canceledAtDate: new Date('2021-04-01'), paymentSourceId: 'src_annual', planPeriod: 'annual' as const, status: 'active' as const, @@ -320,9 +320,9 @@ describe('SubscriptionDetails', () => { id: 'sub_monthly', plan: planMonthly, createdAt: new Date('2022-01-01'), - periodStart: new Date('2022-02-01'), - periodEnd: new Date('2022-03-01'), - canceledAt: null, + periodStartDate: new Date('2022-02-01'), + periodEndDate: new Date('2022-03-01'), + canceledAtDate: null, paymentSourceId: 'src_monthly', planPeriod: 'month' as const, status: 'upcoming' as const, @@ -436,9 +436,9 @@ describe('SubscriptionDetails', () => { id: 'test_active', plan: planMonthly, createdAt: new Date('2021-01-01'), - periodStart: new Date('2021-01-01'), - periodEnd: new Date('2021-02-01'), - canceledAt: new Date('2021-01-03'), + periodStartDate: new Date('2021-01-01'), + periodEndDate: new Date('2021-02-01'), + canceledAtDate: new Date('2021-01-03'), paymentSourceId: 'src_free_active', planPeriod: 'month' as const, status: 'active' as const, @@ -447,8 +447,8 @@ describe('SubscriptionDetails', () => { id: 'sub_free_upcoming', plan: planFreeUpcoming, createdAt: new Date('2021-01-03'), - periodStart: new Date('2021-02-01'), - canceledAt: null, + periodStartDate: new Date('2021-02-01'), + canceledAtDate: null, paymentSourceId: 'src_free_upcoming', planPeriod: 'month' as const, status: 'upcoming' as const, @@ -491,7 +491,7 @@ describe('SubscriptionDetails', () => { }); }); - it('allows cancelling a subscription of a monthly plan', async () => { + it.only('allows cancelling a subscription of a monthly plan', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); }); @@ -519,9 +519,9 @@ describe('SubscriptionDetails', () => { isDefault: false, }, createdAt: new Date('2021-01-01'), - periodStart: new Date('2021-01-01'), - periodEnd: new Date('2021-02-01'), - canceledAt: null, + periodStartDate: new Date('2021-01-01'), + periodEndDate: new Date('2021-02-01'), + canceledAtDate: null, paymentSourceId: 'src_123', planPeriod: 'month' as const, status: 'active' as const, @@ -606,9 +606,9 @@ describe('SubscriptionDetails', () => { id: 'sub_annual', plan, createdAt: new Date('2021-01-01'), - periodStart: new Date('2021-01-01'), - periodEnd: new Date('2022-01-01'), - canceledAt: new Date('2021-04-01'), + periodStartDate: new Date('2021-01-01'), + periodEndDate: new Date('2022-01-01'), + canceledAtDate: new Date('2021-04-01'), paymentSourceId: 'src_annual', planPeriod: 'annual' as const, status: 'active' as const, @@ -682,9 +682,9 @@ describe('SubscriptionDetails', () => { id: 'sub_annual', plan, createdAt: new Date('2021-01-01'), - periodStart: new Date('2021-01-01'), - periodEnd: new Date('2022-01-01'), - canceledAt: null, + periodStartDate: new Date('2021-01-01'), + periodEndDate: new Date('2022-01-01'), + canceledAtDate: null, paymentSourceId: 'src_annual', planPeriod: 'annual' as const, status: 'active' as const, diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index f2cf9313f68..779d0807f0c 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -697,7 +697,6 @@ export interface CommerceSubscriptionJSON extends ClerkResourceJSON { credit?: { amount: CommerceMoneyJSON; }; - created_at: number; payment_source_id: string; plan: CommercePlanJSON; plan_period: CommerceSubscriptionPlanPeriod; From 84528d0de73b1331200346d349179c00aa0df257 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Wed, 2 Jul 2025 14:39:47 +0300 Subject: [PATCH 16/34] finishing touches on subscription details --- packages/clerk-js/src/ui/Components.tsx | 5 ++--- .../src/ui/components/SubscriptionDetails/index.tsx | 6 ++++-- packages/clerk-js/src/ui/contexts/components/Plans.tsx | 1 + .../clerk-js/src/ui/contexts/components/SubscriberType.ts | 2 +- .../clerk-js/src/ui/lazyModules/MountedCheckoutDrawer.tsx | 2 +- .../src/ui/lazyModules/MountedSubscriptionDetailDrawer.tsx | 2 +- packages/types/src/appearance.ts | 1 + packages/types/src/clerk.ts | 4 +++- 8 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index 59b124f1fb9..5a45a2e8790 100644 --- a/packages/clerk-js/src/ui/Components.tsx +++ b/packages/clerk-js/src/ui/Components.tsx @@ -3,7 +3,6 @@ import type { __experimental_PlanDetailsProps, __experimental_SubscriptionDetailsProps, __internal_CheckoutProps, - __internal_PlanDetailsProps, __internal_UserVerificationProps, Appearance, Clerk, @@ -113,9 +112,9 @@ export type ComponentControls = { props: T extends 'checkout' ? __internal_CheckoutProps : T extends 'planDetails' - ? __internal_PlanDetailsProps + ? __experimental_PlanDetailsProps : T extends 'subscriptionDetails' - ? __internal_PlanDetailsProps + ? __experimental_SubscriptionDetailsProps : never, ) => void; closeDrawer: ( diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index 6d749c40518..2f7e59c34a2 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -24,7 +24,7 @@ import { formatDate } from '@/ui/utils/formatDate'; const isFreePlan = (plan: CommercePlanResource) => !plan.hasBaseFee; -import { usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; +import { SubscriberTypeContext, usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; import type { LocalizationKey } from '../../customizables'; import { Badge, @@ -59,7 +59,9 @@ export const SubscriptionDetails = (props: __experimental_SubscriptionDetailsPro return ( <Drawer.Content> <SubscriptionDetailsContext.Provider value={{ componentName: 'SubscriptionDetails', ...props }}> - <SubscriptionDetailsInternal {...props} /> + <SubscriberTypeContext.Provider value={props.for}> + <SubscriptionDetailsInternal {...props} /> + </SubscriberTypeContext.Provider> </SubscriptionDetailsContext.Provider> </Drawer.Content> ); diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index bc1a3642c16..7419d98b74b 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -362,6 +362,7 @@ export const usePlansContext = () => { if (subscription && subscription.planPeriod === planPeriod && !subscription.canceledAtDate) { clerk.__experimental_openSubscriptionDetails({ + for: subscriberType, onSubscriptionCancel: () => { revalidateAll(); onSubscriptionChange?.(); diff --git a/packages/clerk-js/src/ui/contexts/components/SubscriberType.ts b/packages/clerk-js/src/ui/contexts/components/SubscriberType.ts index 0653ece4067..3ce377b836a 100644 --- a/packages/clerk-js/src/ui/contexts/components/SubscriberType.ts +++ b/packages/clerk-js/src/ui/contexts/components/SubscriberType.ts @@ -1,7 +1,7 @@ import { createContext, useContext } from 'react'; const DEFAUlT = 'user'; -export const SubscriberTypeContext = createContext<'user' | 'org'>(DEFAUlT); +export const SubscriberTypeContext = createContext<'user' | 'org' | undefined>(DEFAUlT); export const useSubscriberTypeContext = () => useContext(SubscriberTypeContext) || DEFAUlT; diff --git a/packages/clerk-js/src/ui/lazyModules/MountedCheckoutDrawer.tsx b/packages/clerk-js/src/ui/lazyModules/MountedCheckoutDrawer.tsx index 3ed00fc63f3..edb88bc9ddb 100644 --- a/packages/clerk-js/src/ui/lazyModules/MountedCheckoutDrawer.tsx +++ b/packages/clerk-js/src/ui/lazyModules/MountedCheckoutDrawer.tsx @@ -27,7 +27,7 @@ export function MountedCheckoutDrawer({ // Without this, the drawer would not be rendered after a session switch. key={user?.id} globalAppearance={appearance} - appearanceKey={'checkout' as any} + appearanceKey={'checkout'} componentAppearance={checkoutDrawer.props.appearance || {}} flowName={'checkout'} open={checkoutDrawer.open} diff --git a/packages/clerk-js/src/ui/lazyModules/MountedSubscriptionDetailDrawer.tsx b/packages/clerk-js/src/ui/lazyModules/MountedSubscriptionDetailDrawer.tsx index 4a6186c4701..7bdec53ecda 100644 --- a/packages/clerk-js/src/ui/lazyModules/MountedSubscriptionDetailDrawer.tsx +++ b/packages/clerk-js/src/ui/lazyModules/MountedSubscriptionDetailDrawer.tsx @@ -27,7 +27,7 @@ export function MountedSubscriptionDetailDrawer({ // Without this, the drawer would not be rendered after a session switch. key={user?.id} globalAppearance={appearance} - appearanceKey={'planDetails' as any} + appearanceKey={'subscriptionDetails' as any} componentAppearance={subscriptionDetailsDrawer.props.appearance || {}} flowName={'subscriptionDetails'} open={subscriptionDetailsDrawer.open} diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index e9404d8fcdf..4a9142bc3c6 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -884,6 +884,7 @@ export type WaitlistTheme = Theme; export type PricingTableTheme = Theme; export type CheckoutTheme = Theme; export type PlanDetailTheme = Theme; +export type SubscriptionDetailsTheme = Theme; export type APIKeysTheme = Theme; export type OAuthConsentTheme = Theme; diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 9e2d811346b..5aa5c3799e3 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -12,6 +12,7 @@ import type { PricingTableTheme, SignInTheme, SignUpTheme, + SubscriptionDetailsTheme, UserButtonTheme, UserProfileTheme, UserVerificationTheme, @@ -1777,7 +1778,8 @@ export type __experimental_PlanDetailsProps = { }; export type __experimental_SubscriptionDetailsProps = { - appearance?: PlanDetailTheme; + for?: CommerceSubscriberType; + appearance?: SubscriptionDetailsTheme; onSubscriptionCancel?: () => void; portalId?: string; portalRoot?: PortalRoot; From dc926ab01178e4ad27e804d21b72cfc2dc1c8d3f Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Wed, 2 Jul 2025 14:58:35 +0300 Subject: [PATCH 17/34] add unit tests for PlanDetails --- .../src/ui/components/Plans/PlanDetails.tsx | 13 +- .../Plans/__tests__/PlanDetails.test.tsx | 281 ++++++++++++++++++ .../components/SubscriptionDetails/index.tsx | 2 - packages/clerk-js/src/ui/types.ts | 9 +- .../src/ui/utils/test/createFixtures.tsx | 2 +- 5 files changed, 290 insertions(+), 17 deletions(-) create mode 100644 packages/clerk-js/src/ui/components/Plans/__tests__/PlanDetails.test.tsx diff --git a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx index 7c30cd7f980..8e1512b0bb4 100644 --- a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx +++ b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx @@ -33,7 +33,7 @@ const PlanDetailsInternal = ({ const { data: plan, isLoading } = useSWR( planId || initialPlan ? { type: 'plan', id: planId || initialPlan?.id } : null, - // @ts-expect-error + // @ts-expect-error we are handling it above () => clerk.billing.getPlan({ id: planId || initialPlan?.id }), { fallbackData: initialPlan, @@ -153,17 +153,6 @@ const PlanDetailsInternal = ({ </Box> </Drawer.Body> ) : null} - - {/* {!plan.isDefault && !isDefaultPlanImplicitlyActiveOrUpcoming ? ( - <Drawer.Footer> - <Button - block - textVariant='buttonLarge' - {...buttonPropsForPlan({ plan })} - onClick={() => openCheckout()} - /> - </Drawer.Footer> - ) : null} */} </SubscriberTypeContext.Provider> ); }; diff --git a/packages/clerk-js/src/ui/components/Plans/__tests__/PlanDetails.test.tsx b/packages/clerk-js/src/ui/components/Plans/__tests__/PlanDetails.test.tsx new file mode 100644 index 00000000000..3b4b4c7cc16 --- /dev/null +++ b/packages/clerk-js/src/ui/components/Plans/__tests__/PlanDetails.test.tsx @@ -0,0 +1,281 @@ +import { Drawer } from '@/ui/elements/Drawer'; + +import { render, waitFor } from '../../../../testUtils'; +import { bindCreateFixtures } from '../../../utils/test/createFixtures'; +import { PlanDetails } from '../PlanDetails'; + +const { createFixtures } = bindCreateFixtures('UserProfile'); + +describe('PlanDetails', () => { + const mockFeature = { + id: 'feature_1', + name: 'Feature 1', + description: 'Feature 1 Description', + avatarUrl: 'https://example.com/feature1.png', + slug: 'feature-1', + __internal_toSnapshot: jest.fn(), + pathRoot: '', + reload: jest.fn(), + }; + + const mockFeature2 = { + id: 'feature_2', + name: 'Feature 2', + description: 'Feature 2 Description', + avatarUrl: 'https://example.com/feature2.png', + slug: 'feature-2', + __internal_toSnapshot: jest.fn(), + pathRoot: '', + reload: jest.fn(), + }; + + const mockPlan = { + id: 'plan_123', + name: 'Test Plan', + amount: 1000, + amountFormatted: '10.00', + annualAmount: 10000, + annualAmountFormatted: '100.00', + annualMonthlyAmount: 833, + annualMonthlyAmountFormatted: '8.33', + currencySymbol: '$', + description: 'Test Plan Description', + hasBaseFee: true, + isRecurring: true, + currency: 'USD', + isDefault: false, + payerType: ['user'], + publiclyVisible: true, + slug: 'test-plan', + avatarUrl: 'https://example.com/avatar.png', + features: [mockFeature, mockFeature2], + __internal_toSnapshot: jest.fn(), + pathRoot: '', + reload: jest.fn(), + }; + + it('displays spinner when loading with planId', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + fixtures.clerk.billing.getPlan.mockImplementation(() => new Promise(() => {})); + + const { baseElement, queryByRole } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <PlanDetails planId='plan_123' /> + </Drawer.Root>, + { wrapper }, + ); + + const spinner = baseElement.querySelector('span[aria-live="polite"]'); + expect(spinner).toBeVisible(); + expect(queryByRole('heading', { name: 'Test Plan' })).toBeNull(); + }); + + it('renders plan details when plan is provided directly', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { getByText, getByRole, baseElement } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <PlanDetails plan={mockPlan} /> + </Drawer.Root>, + { wrapper }, + ); + + const spinner = baseElement.querySelector('span[aria-live="polite"]'); + expect(spinner).toBeNull(); + expect(getByRole('heading', { name: 'Test Plan' })).toBeVisible(); + expect(getByText('Test Plan Description')).toBeVisible(); + expect(getByText('$10.00')).toBeVisible(); + expect(getByText('Feature 1')).toBeVisible(); + expect(getByText('Feature 1 Description')).toBeVisible(); + expect(getByText('Feature 2')).toBeVisible(); + expect(getByText('Feature 2 Description')).toBeVisible(); + }); + + it('fetches and renders plan details when planId is provided', async () => { + const { wrapper, fixtures } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + fixtures.clerk.billing.getPlan.mockResolvedValue(mockPlan); + + const { getByText, getByRole } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <PlanDetails planId='plan_123' /> + </Drawer.Root>, + { wrapper }, + ); + + await waitFor(() => { + expect(fixtures.clerk.billing.getPlan).toHaveBeenCalledWith({ id: 'plan_123' }); + expect(getByRole('heading', { name: 'Test Plan' })).toBeVisible(); + expect(getByText('Test Plan Description')).toBeVisible(); + expect(getByText('$10.00')).toBeVisible(); + }); + }); + + it('uses default monthly plan period when not specified', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { getByText, queryByText } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <PlanDetails plan={mockPlan} /> + </Drawer.Root>, + { wrapper }, + ); + + await waitFor(() => { + expect(getByText('$10.00')).toBeVisible(); + expect(queryByText('$8.33')).toBeNull(); + }); + }); + + it('respects initialPlanPeriod when provided as annual', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { getByText, queryByText } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <PlanDetails + plan={mockPlan} + initialPlanPeriod='annual' + /> + </Drawer.Root>, + { wrapper }, + ); + + await waitFor(() => { + expect(getByText('$8.33')).toBeVisible(); + expect(queryByText('$10.00')).toBeNull(); + }); + }); + + it('toggles between monthly and annual pricing when switch is clicked', async () => { + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { getByText, getByRole, userEvent } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <PlanDetails plan={mockPlan} /> + </Drawer.Root>, + { wrapper }, + ); + + await waitFor(() => { + expect(getByText('$10.00')).toBeVisible(); + }); + + const switchButton = getByRole('switch', { name: /billed annually/i }); + await userEvent.click(switchButton); + + await waitFor(() => { + expect(getByText('$8.33')).toBeVisible(); + }); + }); + + it('does not show period toggle for plans with no annual pricing', async () => { + const planWithoutAnnual = { + ...mockPlan, + annualMonthlyAmount: 0, + }; + + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { queryByRole, getByText } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <PlanDetails plan={planWithoutAnnual} /> + </Drawer.Root>, + { wrapper }, + ); + + await waitFor(() => { + expect(queryByRole('switch')).toBeNull(); + expect(getByText(/only billed monthly/i)).toBeVisible(); + }); + }); + + it('shows "Always free" notice for default free plans', async () => { + const freePlan = { + ...mockPlan, + amount: 0, + amountFormatted: '0.00', + annualMonthlyAmount: 0, + isDefault: true, + }; + + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { getByText } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <PlanDetails plan={freePlan} /> + </Drawer.Root>, + { wrapper }, + ); + + await waitFor(() => { + expect(getByText(/always free/i)).toBeVisible(); + }); + }); + + it('renders plan without features correctly', async () => { + const planWithoutFeatures = { + ...mockPlan, + features: [], + }; + + const { wrapper } = await createFixtures(f => { + f.withUser({ email_addresses: ['test@clerk.com'] }); + }); + + const { queryByText, getByRole } = render( + <Drawer.Root + open + onOpenChange={() => {}} + > + <PlanDetails plan={planWithoutFeatures} /> + </Drawer.Root>, + { wrapper }, + ); + + await waitFor(() => { + expect(getByRole('heading', { name: 'Test Plan' })).toBeVisible(); + expect(queryByText(/available features/i)).toBeNull(); + }); + }); +}); diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index 2f7e59c34a2..109c92fb2e1 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -486,8 +486,6 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription const isActive = subscription.status === 'active'; const { t } = useLocalizations(); - console.log(subscription.periodEndDate, subscription.canceledAtDate, subscription); - return ( <Col elementDescriptor={descriptors.subscriptionDetailsCard} diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index 7fbd4bd04e1..9b70500231b 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -1,8 +1,8 @@ import type { + __experimental_PlanDetailsProps, __experimental_SubscriptionDetailsProps, __internal_CheckoutProps, __internal_OAuthConsentProps, - __internal_PlanDetailsProps, __internal_UserVerificationProps, APIKeysProps, CreateOrganizationProps, @@ -51,7 +51,8 @@ export type AvailableComponentProps = | PricingTableProps | __internal_CheckoutProps | __internal_UserVerificationProps - | __internal_PlanDetailsProps + | __experimental_SubscriptionDetailsProps + | __experimental_PlanDetailsProps | APIKeysProps; type ComponentMode = 'modal' | 'mounted'; @@ -143,6 +144,10 @@ export type SubscriptionDetailsCtx = __experimental_SubscriptionDetailsProps & { componentName: 'SubscriptionDetails'; }; +export type PlanDetailsCtx = __experimental_PlanDetailsProps & { + componentName: 'PlanDetails'; +}; + export type AvailableComponentCtx = | SignInCtx | SignUpCtx diff --git a/packages/clerk-js/src/ui/utils/test/createFixtures.tsx b/packages/clerk-js/src/ui/utils/test/createFixtures.tsx index bc6532a706a..2b82dbaea6e 100644 --- a/packages/clerk-js/src/ui/utils/test/createFixtures.tsx +++ b/packages/clerk-js/src/ui/utils/test/createFixtures.tsx @@ -90,7 +90,7 @@ const unboundCreateFixtures = ( const MockClerkProvider = (props: any) => { const { children } = props; - const componentsWithoutContext = ['UsernameSection', 'UserProfileSection', 'SubscriptionDetails']; + const componentsWithoutContext = ['UsernameSection', 'UserProfileSection', 'SubscriptionDetails', 'PlanDetails']; const contextWrappedChildren = !componentsWithoutContext.includes(componentName) ? ( <ComponentContextProvider componentName={componentName} From 79b3c79e6447c6aac88dac8fb3a4f5a28cb49d9a Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Wed, 2 Jul 2025 15:39:28 +0300 Subject: [PATCH 18/34] remove old file --- .../ui/components/Plans/old_PlanDetails.tsx | 522 ------------------ 1 file changed, 522 deletions(-) delete mode 100644 packages/clerk-js/src/ui/components/Plans/old_PlanDetails.tsx diff --git a/packages/clerk-js/src/ui/components/Plans/old_PlanDetails.tsx b/packages/clerk-js/src/ui/components/Plans/old_PlanDetails.tsx deleted file mode 100644 index 3a967e241ea..00000000000 --- a/packages/clerk-js/src/ui/components/Plans/old_PlanDetails.tsx +++ /dev/null @@ -1,522 +0,0 @@ -import { useClerk, useOrganization } from '@clerk/shared/react'; -import type { - __internal_PlanDetailsProps, - ClerkAPIError, - ClerkRuntimeError, - CommercePlanResource, - CommerceSubscriptionPlanPeriod, - CommerceSubscriptionResource, -} from '@clerk/types'; -import * as React from 'react'; -import { useMemo, useState } from 'react'; - -import { Alert } from '@/ui/elements/Alert'; -import { Avatar } from '@/ui/elements/Avatar'; -import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; -import { Switch } from '@/ui/elements/Switch'; -import { handleError } from '@/ui/utils/errorHandler'; - -import { useProtect } from '../../common'; -import { SubscriberTypeContext, usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; -import { Badge, Box, Button, Col, descriptors, Flex, Heading, localizationKeys, Span, Text } from '../../customizables'; - -export const PlanDetails = (props: __internal_PlanDetailsProps) => { - return ( - <SubscriberTypeContext.Provider value={props.subscriberType || 'user'}> - <Drawer.Content> - <PlanDetailsInternal {...props} /> - </Drawer.Content> - </SubscriberTypeContext.Provider> - ); -}; - -const PlanDetailsInternal = ({ - plan, - onSubscriptionCancel, - portalRoot, - initialPlanPeriod = 'month', -}: __internal_PlanDetailsProps) => { - const clerk = useClerk(); - const { organization } = useOrganization(); - const [showConfirmation, setShowConfirmation] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [cancelError, setCancelError] = useState<ClerkRuntimeError | ClerkAPIError | string | undefined>(); - const [planPeriod, setPlanPeriod] = useState<CommerceSubscriptionPlanPeriod>(initialPlanPeriod); - - const { setIsOpen } = useDrawerContext(); - const { - activeOrUpcomingSubscriptionBasedOnPlanPeriod, - revalidateAll, - buttonPropsForPlan, - isDefaultPlanImplicitlyActiveOrUpcoming, - } = usePlansContext(); - const subscriberType = useSubscriberTypeContext(); - const canManageBilling = useProtect( - has => has({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user', - ); - - if (!plan) { - return null; - } - - const subscription = activeOrUpcomingSubscriptionBasedOnPlanPeriod(plan, planPeriod); - - const handleClose = () => { - if (setIsOpen) { - setIsOpen(false); - } - }; - - const features = plan.features; - const hasFeatures = features.length > 0; - const cancelSubscription = async () => { - if (!subscription) { - return; - } - - setCancelError(undefined); - setIsSubmitting(true); - - await subscription - .cancel({ orgId: subscriberType === 'org' ? organization?.id : undefined }) - .then(() => { - setIsSubmitting(false); - onSubscriptionCancel?.(); - handleClose(); - }) - .catch(error => { - handleError(error, [], setCancelError); - setIsSubmitting(false); - }); - }; - - type Open__internal_CheckoutProps = { - planPeriod?: CommerceSubscriptionPlanPeriod; - }; - - const openCheckout = (props?: Open__internal_CheckoutProps) => { - handleClose(); - - // if the plan doesn't support annual, use monthly - let _planPeriod = props?.planPeriod || planPeriod; - if (_planPeriod === 'annual' && plan.annualMonthlyAmount === 0) { - _planPeriod = 'month'; - } - - clerk.__internal_openCheckout({ - planId: plan.id, - planPeriod: _planPeriod, - subscriberType: subscriberType, - onSubscriptionComplete: () => { - void revalidateAll(); - }, - portalRoot, - }); - }; - - return ( - <> - <Drawer.Header - sx={t => - !hasFeatures - ? { - flex: 1, - borderBottomWidth: 0, - background: t.colors.$colorBackground, - } - : null - } - > - <Header - plan={plan} - subscription={subscription} - planPeriod={planPeriod} - setPlanPeriod={setPlanPeriod} - closeSlot={<Drawer.Close />} - /> - </Drawer.Header> - - {hasFeatures ? ( - <Drawer.Body> - <Text - elementDescriptor={descriptors.planDetailCaption} - variant={'caption'} - localizationKey={localizationKeys('commerce.availableFeatures')} - colorScheme='secondary' - sx={t => ({ - padding: t.space.$4, - paddingBottom: 0, - })} - /> - <Box - elementDescriptor={descriptors.planDetailFeaturesList} - as='ul' - role='list' - sx={t => ({ - display: 'grid', - rowGap: t.space.$6, - padding: t.space.$4, - margin: 0, - })} - > - {features.map(feature => ( - <Box - key={feature.id} - elementDescriptor={descriptors.planDetailFeaturesListItem} - as='li' - sx={t => ({ - display: 'flex', - alignItems: 'baseline', - gap: t.space.$3, - })} - > - {feature.avatarUrl ? ( - <Avatar - size={_ => 24} - title={feature.name} - initials={feature.name[0]} - rounded={false} - imageUrl={feature.avatarUrl} - /> - ) : null} - <Span elementDescriptor={descriptors.planDetailFeaturesListItemContent}> - <Text - elementDescriptor={descriptors.planDetailFeaturesListItemTitle} - colorScheme='body' - sx={t => ({ - fontWeight: t.fontWeights.$medium, - })} - > - {feature.name} - </Text> - {feature.description ? ( - <Text - elementDescriptor={descriptors.planDetailFeaturesListItemDescription} - colorScheme='secondary' - sx={t => ({ - marginBlockStart: t.space.$0x25, - })} - > - {feature.description} - </Text> - ) : null} - </Span> - </Box> - ))} - </Box> - </Drawer.Body> - ) : null} - - {(!plan.isDefault && !isDefaultPlanImplicitlyActiveOrUpcoming) || !subscription ? ( - <Drawer.Footer> - {subscription ? ( - subscription.canceledAt ? ( - <Button - block - textVariant='buttonLarge' - {...buttonPropsForPlan({ plan })} - onClick={() => openCheckout()} - /> - ) : ( - <Col gap={4}> - {!!subscription && - subscription.planPeriod === 'month' && - plan.annualMonthlyAmount > 0 && - planPeriod === 'annual' ? ( - <Button - block - variant='bordered' - colorScheme='secondary' - textVariant='buttonLarge' - isDisabled={!canManageBilling} - onClick={() => openCheckout({ planPeriod: 'annual' })} - localizationKey={localizationKeys('commerce.switchToAnnual')} - /> - ) : null} - {!!subscription && subscription.planPeriod === 'annual' && planPeriod === 'month' ? ( - <Button - block - variant='bordered' - colorScheme='secondary' - textVariant='buttonLarge' - isDisabled={!canManageBilling} - onClick={() => openCheckout({ planPeriod: 'month' })} - localizationKey={localizationKeys('commerce.switchToMonthly')} - /> - ) : null} - <Button - block - variant='bordered' - colorScheme='danger' - textVariant='buttonLarge' - isDisabled={!canManageBilling} - onClick={() => setShowConfirmation(true)} - localizationKey={localizationKeys('commerce.cancelSubscription')} - /> - </Col> - ) - ) : ( - <Button - block - textVariant='buttonLarge' - {...buttonPropsForPlan({ plan })} - onClick={() => openCheckout()} - /> - )} - </Drawer.Footer> - ) : null} - - {subscription ? ( - <Drawer.Confirmation - open={showConfirmation} - onOpenChange={setShowConfirmation} - actionsSlot={ - <> - {!isSubmitting && ( - <Button - variant='ghost' - size='sm' - textVariant='buttonLarge' - isDisabled={!canManageBilling} - onClick={() => { - setCancelError(undefined); - setShowConfirmation(false); - }} - localizationKey={localizationKeys('commerce.keepSubscription')} - /> - )} - <Button - variant='solid' - colorScheme='danger' - size='sm' - textVariant='buttonLarge' - isLoading={isSubmitting} - isDisabled={!canManageBilling} - onClick={() => { - setCancelError(undefined); - setShowConfirmation(false); - void cancelSubscription(); - }} - localizationKey={localizationKeys('commerce.cancelSubscription')} - /> - </> - } - > - <Heading - elementDescriptor={descriptors.drawerConfirmationTitle} - as='h2' - textVariant='h3' - localizationKey={localizationKeys('commerce.cancelSubscriptionTitle', { - plan: `${subscription.status === 'upcoming' ? 'upcoming ' : ''}${subscription.plan.name}`, - })} - /> - <Text - elementDescriptor={descriptors.drawerConfirmationDescription} - colorScheme='secondary' - localizationKey={ - subscription.status === 'upcoming' - ? localizationKeys('commerce.cancelSubscriptionNoCharge') - : localizationKeys('commerce.cancelSubscriptionAccessUntil', { - plan: subscription.plan.name, - date: subscription.periodEnd, - }) - } - /> - {cancelError && ( - <Alert colorScheme='danger'>{typeof cancelError === 'string' ? cancelError : cancelError.message}</Alert> - )} - </Drawer.Confirmation> - ) : null} - </> - ); -}; - -/* ------------------------------------------------------------------------------------------------- - * Header - * -----------------------------------------------------------------------------------------------*/ - -interface HeaderProps { - plan: CommercePlanResource; - subscription?: CommerceSubscriptionResource; - planPeriod: CommerceSubscriptionPlanPeriod; - setPlanPeriod: (val: CommerceSubscriptionPlanPeriod) => void; - closeSlot?: React.ReactNode; -} - -const Header = React.forwardRef<HTMLDivElement, HeaderProps>((props, ref) => { - const { plan, subscription, closeSlot, planPeriod, setPlanPeriod } = props; - - const { captionForSubscription, isDefaultPlanImplicitlyActiveOrUpcoming } = usePlansContext(); - const { data: subscriptions } = useSubscriptions(); - - const isImplicitlyActiveOrUpcoming = isDefaultPlanImplicitlyActiveOrUpcoming && plan.isDefault; - - const showBadge = !!subscription; - - const getPlanFee = useMemo(() => { - if (plan.annualMonthlyAmount <= 0) { - return plan.amountFormatted; - } - return planPeriod === 'annual' ? plan.annualMonthlyAmountFormatted : plan.amountFormatted; - }, [plan, planPeriod]); - - return ( - <Box - ref={ref} - elementDescriptor={descriptors.planDetailHeader} - sx={t => ({ - width: '100%', - padding: t.space.$4, - position: 'relative', - })} - > - {closeSlot ? ( - <Box - sx={t => ({ - position: 'absolute', - top: t.space.$2, - insetInlineEnd: t.space.$2, - })} - > - {closeSlot} - </Box> - ) : null} - - <Col - gap={3} - elementDescriptor={descriptors.planDetailBadgeAvatarTitleDescriptionContainer} - > - {showBadge ? ( - <Flex - align='center' - gap={3} - elementDescriptor={descriptors.planDetailBadgeContainer} - sx={t => ({ - paddingInlineEnd: t.space.$10, - })} - > - {subscription?.status === 'active' || (isImplicitlyActiveOrUpcoming && subscriptions.length === 0) ? ( - <Badge - elementDescriptor={descriptors.planDetailBadge} - localizationKey={localizationKeys('badge__activePlan')} - colorScheme={'secondary'} - /> - ) : ( - <Badge - elementDescriptor={descriptors.planDetailBadge} - localizationKey={localizationKeys('badge__upcomingPlan')} - colorScheme={'primary'} - /> - )} - {!!subscription && ( - <Text - elementDescriptor={descriptors.planDetailCaption} - variant={'caption'} - localizationKey={captionForSubscription(subscription)} - colorScheme='secondary' - /> - )} - </Flex> - ) : null} - {plan.avatarUrl ? ( - <Avatar - boxElementDescriptor={descriptors.planDetailAvatar} - size={_ => 40} - title={plan.name} - initials={plan.name[0]} - rounded={false} - imageUrl={plan.avatarUrl} - sx={t => ({ - marginBlockEnd: t.space.$3, - })} - /> - ) : null} - <Col - gap={1} - elementDescriptor={descriptors.planDetailTitleDescriptionContainer} - > - <Heading - elementDescriptor={descriptors.planDetailTitle} - as='h2' - textVariant='h2' - > - {plan.name} - </Heading> - {plan.description ? ( - <Text - elementDescriptor={descriptors.planDetailDescription} - variant='subtitle' - colorScheme='secondary' - > - {plan.description} - </Text> - ) : null} - </Col> - </Col> - - <Flex - elementDescriptor={descriptors.planDetailFeeContainer} - align='center' - wrap='wrap' - sx={t => ({ - marginTop: t.space.$3, - columnGap: t.space.$1x5, - })} - > - <> - <Text - elementDescriptor={descriptors.planDetailFee} - variant='h1' - colorScheme='body' - > - {plan.currencySymbol} - {getPlanFee} - </Text> - <Text - elementDescriptor={descriptors.planDetailFeePeriod} - variant='caption' - colorScheme='secondary' - sx={t => ({ - textTransform: 'lowercase', - ':before': { - content: '"/"', - marginInlineEnd: t.space.$1, - }, - })} - localizationKey={localizationKeys('commerce.month')} - /> - </> - </Flex> - - {plan.annualMonthlyAmount > 0 ? ( - <Box - elementDescriptor={descriptors.planDetailPeriodToggle} - sx={t => ({ - display: 'flex', - marginTop: t.space.$3, - })} - > - <Switch - isChecked={planPeriod === 'annual'} - onChange={(checked: boolean) => setPlanPeriod(checked ? 'annual' : 'month')} - label={localizationKeys('commerce.billedAnnually')} - /> - </Box> - ) : ( - <Text - elementDescriptor={descriptors.pricingTableCardFeePeriodNotice} - variant='caption' - colorScheme='secondary' - localizationKey={ - plan.isDefault ? localizationKeys('commerce.alwaysFree') : localizationKeys('commerce.billedMonthlyOnly') - } - sx={t => ({ - justifySelf: 'flex-start', - alignSelf: 'center', - marginTop: t.space.$3, - })} - /> - )} - </Box> - ); -}); From 958fdd90764665fe421f7452eaef2753a2947177 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Wed, 2 Jul 2025 16:00:07 +0300 Subject: [PATCH 19/34] replace experimental with internal apis --- packages/clerk-js/src/core/clerk.ts | 31 +++----------- packages/clerk-js/src/ui/Components.tsx | 12 +++--- .../src/ui/components/Plans/PlanDetails.tsx | 10 ++--- .../PricingTable/PricingTableDefault.tsx | 4 +- .../components/SubscriptionDetails/index.tsx | 6 +-- .../src/ui/contexts/components/Plans.tsx | 2 +- .../MountedSubscriptionDetailDrawer.tsx | 4 +- packages/clerk-js/src/ui/types.ts | 15 +++---- packages/react/src/isomorphicClerk.ts | 37 +++++------------ packages/types/src/clerk.ts | 40 ++++++------------- 10 files changed, 51 insertions(+), 110 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index ed31c4c3625..d1bfc3a0761 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -15,12 +15,11 @@ import { import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url'; import { allSettled, handleValueOrFn, noop } from '@clerk/shared/utils'; import type { - __experimental_PlanDetailsProps, - __experimental_SubscriptionDetailsProps, __internal_CheckoutProps, __internal_ComponentNavigationContext, __internal_OAuthConsentProps, __internal_PlanDetailsProps, + __internal_SubscriptionDetailsProps, __internal_UserVerificationModalProps, APIKeysNamespace, APIKeysProps, @@ -591,27 +590,7 @@ export class Clerk implements ClerkInterface { void this.#componentControls.ensureMounted().then(controls => controls.closeDrawer('checkout')); }; - public __internal_openPlanDetails = (props?: __internal_PlanDetailsProps): void => { - this.assertComponentsReady(this.#componentControls); - if (disabledBillingFeature(this, this.environment)) { - if (this.#instanceType === 'development') { - throw new ClerkRuntimeError(warnings.cannotRenderAnyCommerceComponent('PlanDetails'), { - code: CANNOT_RENDER_BILLING_DISABLED_ERROR_CODE, - }); - } - return; - } - void this.#componentControls - .ensureMounted({ preloadHint: 'PlanDetails' }) - .then(controls => controls.openDrawer('planDetails', props || {})); - }; - - public __internal_closePlanDetails = (): void => { - this.assertComponentsReady(this.#componentControls); - void this.#componentControls.ensureMounted().then(controls => controls.closeDrawer('planDetails')); - }; - - public __experimental_openPlanDetails = (props?: __experimental_PlanDetailsProps): void => { + public __internal_openPlanDetails = (props: __internal_PlanDetailsProps): void => { this.assertComponentsReady(this.#componentControls); if (disabledBillingFeature(this, this.environment)) { if (this.#instanceType === 'development') { @@ -628,19 +607,19 @@ export class Clerk implements ClerkInterface { this.telemetry?.record(eventPrebuiltComponentOpened(`PlanDetails`, props)); }; - public __experimental_closePlanDetails = (): void => { + public __internal_closePlanDetails = (): void => { this.assertComponentsReady(this.#componentControls); void this.#componentControls.ensureMounted().then(controls => controls.closeDrawer('planDetails')); }; - public __experimental_openSubscriptionDetails = (props?: __experimental_SubscriptionDetailsProps): void => { + public __internal_openSubscriptionDetails = (props?: __internal_SubscriptionDetailsProps): void => { this.assertComponentsReady(this.#componentControls); void this.#componentControls .ensureMounted({ preloadHint: 'SubscriptionDetails' }) .then(controls => controls.openDrawer('subscriptionDetails', props || {})); }; - public __experimental_closeSubscriptionDetails = (): void => { + public __internal_closeSubscriptionDetails = (): void => { this.assertComponentsReady(this.#componentControls); void this.#componentControls.ensureMounted().then(controls => controls.closeDrawer('subscriptionDetails')); }; diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index 5a45a2e8790..45dfd1cb763 100644 --- a/packages/clerk-js/src/ui/Components.tsx +++ b/packages/clerk-js/src/ui/Components.tsx @@ -1,8 +1,8 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import type { - __experimental_PlanDetailsProps, - __experimental_SubscriptionDetailsProps, __internal_CheckoutProps, + __internal_PlanDetailsProps, + __internal_SubscriptionDetailsProps, __internal_UserVerificationProps, Appearance, Clerk, @@ -112,9 +112,9 @@ export type ComponentControls = { props: T extends 'checkout' ? __internal_CheckoutProps : T extends 'planDetails' - ? __experimental_PlanDetailsProps + ? __internal_PlanDetailsProps : T extends 'subscriptionDetails' - ? __experimental_SubscriptionDetailsProps + ? __internal_SubscriptionDetailsProps : never, ) => void; closeDrawer: ( @@ -161,11 +161,11 @@ interface ComponentsState { }; planDetailsDrawer: { open: false; - props: null | __experimental_PlanDetailsProps; + props: null | __internal_PlanDetailsProps; }; subscriptionDetailsDrawer: { open: false; - props: null | __experimental_SubscriptionDetailsProps; + props: null | __internal_SubscriptionDetailsProps; }; nodes: Map<HTMLDivElement, HtmlNodeOptions>; impersonationFab: boolean; diff --git a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx index 8e1512b0bb4..0836fb70727 100644 --- a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx +++ b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx @@ -1,9 +1,5 @@ import { useClerk } from '@clerk/shared/react'; -import type { - __experimental_PlanDetailsProps, - CommercePlanResource, - CommerceSubscriptionPlanPeriod, -} from '@clerk/types'; +import type { __internal_PlanDetailsProps, CommercePlanResource, CommerceSubscriptionPlanPeriod } from '@clerk/types'; import * as React from 'react'; import { useMemo, useState } from 'react'; import useSWR from 'swr'; @@ -15,7 +11,7 @@ import { Switch } from '@/ui/elements/Switch'; import { SubscriberTypeContext } from '../../contexts'; import { Box, Col, descriptors, Flex, Heading, localizationKeys, Span, Spinner, Text } from '../../customizables'; -export const PlanDetails = (props: __experimental_PlanDetailsProps) => { +export const PlanDetails = (props: __internal_PlanDetailsProps) => { return ( <Drawer.Content> <PlanDetailsInternal {...props} /> @@ -27,7 +23,7 @@ const PlanDetailsInternal = ({ planId, plan: initialPlan, initialPlanPeriod = 'month', -}: __experimental_PlanDetailsProps) => { +}: __internal_PlanDetailsProps) => { const clerk = useClerk(); const [planPeriod, setPlanPeriod] = useState<CommerceSubscriptionPlanPeriod>(initialPlanPeriod); diff --git a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx index a6dd6360a43..ab99e7fe3bc 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx @@ -117,10 +117,8 @@ function Card(props: CardProps) { const showPlanDetails = (event?: React.MouseEvent<HTMLElement>) => { const portalRoot = getClosestProfileScrollBox(mode, event); - clerk.__experimental_openPlanDetails({ + clerk.__internal_openPlanDetails({ plan, - // planId: plan.id, - // subscriberType, initialPlanPeriod: planPeriod, portalRoot, }); diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index 109c92fb2e1..3e36abd05a7 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -1,7 +1,7 @@ import { useClerk, useOrganization } from '@clerk/shared/react'; import type { - __experimental_SubscriptionDetailsProps, __internal_CheckoutProps, + __internal_SubscriptionDetailsProps, CommercePlanResource, CommerceSubscriptionResource, } from '@clerk/types'; @@ -55,7 +55,7 @@ const SubscriptionForCancellationContext = React.createContext<{ setSubscription: () => {}, }); -export const SubscriptionDetails = (props: __experimental_SubscriptionDetailsProps) => { +export const SubscriptionDetails = (props: __internal_SubscriptionDetailsProps) => { return ( <Drawer.Content> <SubscriptionDetailsContext.Provider value={{ componentName: 'SubscriptionDetails', ...props }}> @@ -100,7 +100,7 @@ function useGuessableSubscription<Or extends 'throw' | undefined = undefined>(op }; } -const SubscriptionDetailsInternal = (props: __experimental_SubscriptionDetailsProps) => { +const SubscriptionDetailsInternal = (props: __internal_SubscriptionDetailsProps) => { const { organization: _organization } = useOrganization(); const [subscriptionForCancellation, setSubscriptionForCancellation] = useState<CommerceSubscriptionResource | null>( null, diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index 7419d98b74b..a5a99f2a6fc 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -361,7 +361,7 @@ export const usePlansContext = () => { const portalRoot = getClosestProfileScrollBox(mode, event); if (subscription && subscription.planPeriod === planPeriod && !subscription.canceledAtDate) { - clerk.__experimental_openSubscriptionDetails({ + clerk.__internal_openSubscriptionDetails({ for: subscriberType, onSubscriptionCancel: () => { revalidateAll(); diff --git a/packages/clerk-js/src/ui/lazyModules/MountedSubscriptionDetailDrawer.tsx b/packages/clerk-js/src/ui/lazyModules/MountedSubscriptionDetailDrawer.tsx index 7bdec53ecda..53835c691f7 100644 --- a/packages/clerk-js/src/ui/lazyModules/MountedSubscriptionDetailDrawer.tsx +++ b/packages/clerk-js/src/ui/lazyModules/MountedSubscriptionDetailDrawer.tsx @@ -1,5 +1,5 @@ import { useUser } from '@clerk/shared/react'; -import type { __experimental_SubscriptionDetailsProps, Appearance } from '@clerk/types'; +import type { __internal_SubscriptionDetailsProps, Appearance } from '@clerk/types'; import { SubscriptionDetails } from '../components/SubscriptionDetails'; import { LazyDrawerRenderer } from './providers'; @@ -13,7 +13,7 @@ export function MountedSubscriptionDetailDrawer({ onOpenChange: (open: boolean) => void; subscriptionDetailsDrawer: { open: false; - props: null | __experimental_SubscriptionDetailsProps; + props: null | __internal_SubscriptionDetailsProps; }; }) { const { user } = useUser(); diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index 9b70500231b..3f776335b94 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -1,8 +1,8 @@ import type { - __experimental_PlanDetailsProps, - __experimental_SubscriptionDetailsProps, __internal_CheckoutProps, __internal_OAuthConsentProps, + __internal_PlanDetailsProps, + __internal_SubscriptionDetailsProps, __internal_UserVerificationProps, APIKeysProps, CreateOrganizationProps, @@ -51,8 +51,8 @@ export type AvailableComponentProps = | PricingTableProps | __internal_CheckoutProps | __internal_UserVerificationProps - | __experimental_SubscriptionDetailsProps - | __experimental_PlanDetailsProps + | __internal_SubscriptionDetailsProps + | __internal_PlanDetailsProps | APIKeysProps; type ComponentMode = 'modal' | 'mounted'; @@ -140,11 +140,11 @@ export type OAuthConsentCtx = __internal_OAuthConsentProps & { componentName: 'OAuthConsent'; }; -export type SubscriptionDetailsCtx = __experimental_SubscriptionDetailsProps & { +export type SubscriptionDetailsCtx = __internal_SubscriptionDetailsProps & { componentName: 'SubscriptionDetails'; }; -export type PlanDetailsCtx = __experimental_PlanDetailsProps & { +export type PlanDetailsCtx = __internal_PlanDetailsProps & { componentName: 'PlanDetails'; }; @@ -164,5 +164,6 @@ export type AvailableComponentCtx = | CheckoutCtx | APIKeysCtx | OAuthConsentCtx - | SubscriptionDetailsCtx; + | SubscriptionDetailsCtx + | PlanDetailsCtx; export type AvailableComponentName = AvailableComponentCtx['componentName']; diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index d2f0894733c..43425068470 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -3,11 +3,10 @@ import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus'; import { loadClerkJsScript } from '@clerk/shared/loadClerkJsScript'; import { handleValueOrFn } from '@clerk/shared/utils'; import type { - __experimental_PlanDetailsProps, - __experimental_SubscriptionDetailsProps, __internal_CheckoutProps, __internal_OAuthConsentProps, __internal_PlanDetailsProps, + __internal_SubscriptionDetailsProps, __internal_UserVerificationModalProps, __internal_UserVerificationProps, APIKeysNamespace, @@ -121,8 +120,8 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private preopenUserVerification?: null | __internal_UserVerificationProps = null; private preopenSignIn?: null | SignInProps = null; private preopenCheckout?: null | __internal_CheckoutProps = null; - private preopenPlanDetails?: null | __experimental_PlanDetailsProps = null; - private preopenSubscriptionDetails?: null | __experimental_SubscriptionDetailsProps = null; + private preopenPlanDetails: null | __internal_PlanDetailsProps = null; + private preopenSubscriptionDetails: null | __internal_SubscriptionDetailsProps = null; private preopenSignUp?: null | SignUpProps = null; private preopenUserProfile?: null | UserProfileProps = null; private preopenOrganizationProfile?: null | OrganizationProfileProps = null; @@ -560,11 +559,11 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } if (this.preopenPlanDetails !== null) { - clerkjs.__experimental_openPlanDetails(this.preopenPlanDetails); + clerkjs.__internal_openPlanDetails(this.preopenPlanDetails); } if (this.preopenSubscriptionDetails !== null) { - clerkjs.__experimental_openSubscriptionDetails(this.preopenSubscriptionDetails); + clerkjs.__internal_openSubscriptionDetails(this.preopenSubscriptionDetails); } if (this.preopenSignUp !== null) { @@ -779,7 +778,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; - __internal_openPlanDetails = (props?: __internal_PlanDetailsProps) => { + __internal_openPlanDetails = (props: __internal_PlanDetailsProps) => { if (this.clerkjs && this.loaded) { this.clerkjs.__internal_openPlanDetails(props); } else { @@ -795,33 +794,17 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; - __experimental_openPlanDetails = (props?: __experimental_PlanDetailsProps) => { + __internal_openSubscriptionDetails = (props?: __internal_SubscriptionDetailsProps) => { if (this.clerkjs && this.loaded) { - this.clerkjs.__experimental_openPlanDetails(props); - } else { - this.preopenPlanDetails = props; - } - }; - - __experimental_closePlanDetails = () => { - if (this.clerkjs && this.loaded) { - this.clerkjs.__experimental_closePlanDetails(); - } else { - this.preopenPlanDetails = null; - } - }; - - __experimental_openSubscriptionDetails = (props?: __experimental_SubscriptionDetailsProps) => { - if (this.clerkjs && this.loaded) { - this.clerkjs.__experimental_openSubscriptionDetails(props); + this.clerkjs.__internal_openSubscriptionDetails(props); } else { this.preopenSubscriptionDetails = props; } }; - __experimental_closeSubscriptionDetails = () => { + __internal_closeSubscriptionDetails = () => { if (this.clerkjs && this.loaded) { - this.clerkjs.__experimental_closeSubscriptionDetails(); + this.clerkjs.__internal_closeSubscriptionDetails(); } else { this.preopenSubscriptionDetails = null; } diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 5aa5c3799e3..5bdb23fe518 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -224,9 +224,9 @@ export interface Clerk { /** * Opens the Clerk PlanDetails drawer component in a drawer. - * @param props Optional subscription details drawer configuration parameters. + * @param props `plan` or `planId` parameters are required. */ - __internal_openPlanDetails: (props?: __internal_PlanDetailsProps) => void; + __internal_openPlanDetails: (props: __internal_PlanDetailsProps) => void; /** * Closes the Clerk PlanDetails drawer. @@ -234,26 +234,15 @@ export interface Clerk { __internal_closePlanDetails: () => void; /** - * Opens the Clerk PlanDetails drawer component in a drawer. - * @param props Optional subscription details drawer configuration parameters. - */ - __experimental_openPlanDetails: (props?: __experimental_PlanDetailsProps) => void; - - /** - * Closes the Clerk PlanDetails drawer. + * Opens the Clerk SubscriptionDetails drawer component in a drawer. + * @param props Optional configuration parameters. */ - __experimental_closePlanDetails: () => void; - - /** - * Opens the Clerk PlanDetails drawer component in a drawer. - * @param props Optional subscription details drawer configuration parameters. - */ - __experimental_openSubscriptionDetails: (props?: __experimental_SubscriptionDetailsProps) => void; + __internal_openSubscriptionDetails: (props?: __internal_SubscriptionDetailsProps) => void; /** * Closes the Clerk PlanDetails drawer. */ - __experimental_closeSubscriptionDetails: () => void; + __internal_closeSubscriptionDetails: () => void; /** /** Opens the Clerk UserVerification component in a modal. @@ -1759,16 +1748,6 @@ export type __internal_CheckoutProps = { }; export type __internal_PlanDetailsProps = { - appearance?: PlanDetailTheme; - plan?: CommercePlanResource; - subscriberType?: CommerceSubscriberType; - initialPlanPeriod?: CommerceSubscriptionPlanPeriod; - onSubscriptionCancel?: () => void; - portalId?: string; - portalRoot?: PortalRoot; -}; - -export type __experimental_PlanDetailsProps = { appearance?: PlanDetailTheme; plan?: CommercePlanResource; planId?: string; @@ -1777,7 +1756,12 @@ export type __experimental_PlanDetailsProps = { portalRoot?: PortalRoot; }; -export type __experimental_SubscriptionDetailsProps = { +export type __internal_SubscriptionDetailsProps = { + /** + * The subscriber type to display the subscription details for. + * If `org` is provided, the subscription details will be displayed for the active organization. + * @default 'user' + */ for?: CommerceSubscriberType; appearance?: SubscriptionDetailsTheme; onSubscriptionCancel?: () => void; From c5dafc435e19f36edaaca86ec9a53e1127876faf Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Wed, 2 Jul 2025 16:26:09 +0300 Subject: [PATCH 20/34] Update packages/types/src/clerk.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/types/src/clerk.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 5bdb23fe518..5b32fe26d1b 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -240,7 +240,7 @@ export interface Clerk { __internal_openSubscriptionDetails: (props?: __internal_SubscriptionDetailsProps) => void; /** - * Closes the Clerk PlanDetails drawer. + * Closes the Clerk SubscriptionDetails drawer. */ __internal_closeSubscriptionDetails: () => void; From bd9984f4a5a63440eace1efdd1244db619bdab06 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Thu, 10 Jul 2025 15:57:13 +0300 Subject: [PATCH 21/34] address pr comments --- .../Subscriptions/SubscriptionsList.tsx | 53 +++++-------- .../src/ui/contexts/components/Plans.tsx | 77 ++++++++----------- packages/localizations/src/en-US.ts | 4 +- 3 files changed, 55 insertions(+), 79 deletions(-) diff --git a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx index 9c08b9f1258..17d6eaff437 100644 --- a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx +++ b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx @@ -1,5 +1,3 @@ -import type { CommerceSubscriptionResource } from '@clerk/types'; - import { ProfileSection } from '@/ui/elements/Section'; import { useProtect } from '../../common'; @@ -38,7 +36,7 @@ export function SubscriptionsList({ arrowButtonText: LocalizationKey; arrowButtonEmptyText: LocalizationKey; }) { - const { handleSelectPlan, captionForSubscription, canManageSubscription } = usePlansContext(); + const { captionForSubscription, openSubscriptionDetails } = usePlansContext(); const localizationRoot = useSubscriberTypeLocalizationRoot(); const subscriberType = useSubscriberTypeContext(); const { data: subscriptions } = useSubscriptions(); @@ -46,17 +44,6 @@ export function SubscriptionsList({ has => has({ permission: 'org:sys_billing:manage' }) || subscriberType === 'user', ); const { navigate } = useRouter(); - const handleSelectSubscription = ( - subscription: CommerceSubscriptionResource, - event?: React.MouseEvent<HTMLElement>, - ) => { - handleSelectPlan({ - mode: 'modal', // always modal for now - plan: subscription.plan, - planPeriod: subscription.planPeriod, - event, - }); - }; const sortedSubscriptions = subscriptions.sort((a, b) => { // alway put active subscriptions first @@ -179,28 +166,26 @@ export function SubscriptionsList({ textAlign: 'right', })} > - {canManageSubscription({ subscription }) && subscription.id && !subscription.plan.isDefault && ( - <Button - aria-label='Manage subscription' - onClick={event => handleSelectSubscription(subscription, event)} - variant='bordered' - colorScheme='secondary' - isDisabled={!canManageBilling} + <Button + aria-label='Manage subscription' + onClick={event => openSubscriptionDetails(event)} + variant='bordered' + colorScheme='secondary' + isDisabled={!canManageBilling} + sx={t => ({ + width: t.sizes.$6, + height: t.sizes.$6, + })} + > + <Icon + icon={CogFilled} sx={t => ({ - width: t.sizes.$6, - height: t.sizes.$6, + width: t.sizes.$4, + height: t.sizes.$4, + opacity: t.opacity.$inactive, })} - > - <Icon - icon={CogFilled} - sx={t => ({ - width: t.sizes.$4, - height: t.sizes.$4, - opacity: t.opacity.$inactive, - })} - /> - </Button> - )} + /> + </Button> </Td> </Tr> ))} diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index a5a99f2a6fc..b216c9db257 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -143,7 +143,6 @@ export const usePlans = () => { type HandleSelectPlanProps = { plan: CommercePlanResource; planPeriod: CommerceSubscriptionPlanPeriod; - onSubscriptionChange?: () => void; mode?: 'modal' | 'mounted'; event?: React.MouseEvent<HTMLElement>; appearance?: Appearance; @@ -345,53 +344,44 @@ export const usePlansContext = () => { return; }, []); + const openSubscriptionDetails = useCallback( + (event?: React.MouseEvent<HTMLElement>) => { + const portalRoot = getClosestProfileScrollBox('modal', event); + clerk.__internal_openSubscriptionDetails({ + for: subscriberType, + onSubscriptionCancel: () => { + revalidateAll(); + }, + portalRoot, + }); + }, + [clerk, subscriberType, revalidateAll], + ); + // handle the selection of a plan, either by opening the subscription details or checkout const handleSelectPlan = useCallback( - ({ - plan, - planPeriod, - onSubscriptionChange, - mode = 'mounted', - event, - appearance, - newSubscriptionRedirectUrl, - }: HandleSelectPlanProps) => { - const subscription = activeOrUpcomingSubscriptionWithPlanPeriod(plan, planPeriod); - + ({ plan, planPeriod, mode = 'mounted', event, appearance, newSubscriptionRedirectUrl }: HandleSelectPlanProps) => { const portalRoot = getClosestProfileScrollBox(mode, event); - if (subscription && subscription.planPeriod === planPeriod && !subscription.canceledAtDate) { - clerk.__internal_openSubscriptionDetails({ - for: subscriberType, - onSubscriptionCancel: () => { - revalidateAll(); - onSubscriptionChange?.(); - }, - appearance, - portalRoot, - }); - } else { - clerk.__internal_openCheckout({ - planId: plan.id, - // if the plan doesn't support annual, use monthly - planPeriod: planPeriod === 'annual' && plan.annualMonthlyAmount === 0 ? 'month' : planPeriod, - subscriberType, - onSubscriptionComplete: () => { - revalidateAll(); - onSubscriptionChange?.(); - }, - onClose: () => { - if (session?.id) { - void clerk.setActive({ session: session.id }); - } - }, - appearance, - portalRoot, - newSubscriptionRedirectUrl, - }); - } + clerk.__internal_openCheckout({ + planId: plan.id, + // if the plan doesn't support annual, use monthly + planPeriod: planPeriod === 'annual' && plan.annualMonthlyAmount === 0 ? 'month' : planPeriod, + subscriberType, + onSubscriptionComplete: () => { + revalidateAll(); + }, + onClose: () => { + if (session?.id) { + void clerk.setActive({ session: session.id }); + } + }, + appearance, + portalRoot, + newSubscriptionRedirectUrl, + }); }, - [clerk, revalidateAll, activeOrUpcomingSubscription, subscriberType, session?.id], + [clerk, revalidateAll, subscriberType, session?.id], ); const defaultFreePlan = useMemo(() => { @@ -404,6 +394,7 @@ export const usePlansContext = () => { activeOrUpcomingSubscriptionBasedOnPlanPeriod: activeOrUpcomingSubscriptionWithPlanPeriod, isDefaultPlanImplicitlyActiveOrUpcoming, handleSelectPlan, + openSubscriptionDetails, buttonPropsForPlan, canManageSubscription, captionForSubscription, diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 9054b1d041a..fa721a53773 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -137,8 +137,8 @@ export const enUS: LocalizationResource = { switchPlan: 'Switch to this plan', switchToAnnual: 'Switch to annual', switchToMonthly: 'Switch to monthly', - switchToMonthlyWithPrice: 'Switch to monthly {{currency}}{{price}} per month', - switchToAnnualWithAnnualPrice: 'Switch to annual {{currency}}{{price}} per year', + switchToMonthlyWithPrice: 'Switch to monthly {{currency}}{{price}} / month', + switchToAnnualWithAnnualPrice: 'Switch to annual {{currency}}{{price}} / year', totalDue: 'Total due', totalDueToday: 'Total Due Today', viewFeatures: 'View features', From 587f2b857330337cbb0811cf8c090fec1e9ae3f2 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Thu, 10 Jul 2025 17:51:13 +0300 Subject: [PATCH 22/34] wip --- packages/clerk-js/src/core/resources/CommerceSubscription.ts | 3 +++ .../src/ui/components/PricingTable/PricingTableDefault.tsx | 1 + .../clerk-js/src/ui/components/SubscriptionDetails/index.tsx | 1 + .../src/ui/components/Subscriptions/SubscriptionsList.tsx | 3 +++ packages/clerk-js/src/ui/contexts/components/Plans.tsx | 1 + packages/types/src/commerce.ts | 3 ++- packages/types/src/json.ts | 1 + 7 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/resources/CommerceSubscription.ts b/packages/clerk-js/src/core/resources/CommerceSubscription.ts index ac4d949fdf4..d2f8ae66219 100644 --- a/packages/clerk-js/src/core/resources/CommerceSubscription.ts +++ b/packages/clerk-js/src/core/resources/CommerceSubscription.ts @@ -20,6 +20,7 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr planPeriod!: CommerceSubscriptionPlanPeriod; status!: CommerceSubscriptionStatus; createdAt!: Date; + pastDueAt!: Date | null; periodStartDate!: Date; periodEndDate!: Date | null; canceledAtDate!: Date | null; @@ -51,6 +52,8 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr this.canceledAt = data.canceled_at; this.createdAt = unixEpochToDate(data.created_at); + this.pastDueAt = data.past_due_at ? unixEpochToDate(data.past_due_at) : null; + this.periodStartDate = unixEpochToDate(data.period_start); this.periodEndDate = data.period_end ? unixEpochToDate(data.period_end) : null; this.canceledAtDate = data.canceled_at ? unixEpochToDate(data.canceled_at) : null; diff --git a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx index ab99e7fe3bc..8bc1a1c4291 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx @@ -191,6 +191,7 @@ function Card(props: CardProps) { isPlanActive ? ( <Badge colorScheme='secondary' + // here localizationKey={localizationKeys('badge__activePlan')} /> ) : ( diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index 3e36abd05a7..d76750b7752 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -531,6 +531,7 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription <Badge elementDescriptor={descriptors.subscriptionDetailsCardBadge} colorScheme={isActive ? 'secondary' : 'primary'} + // here localizationKey={isActive ? localizationKeys('badge__activePlan') : localizationKeys('badge__upcomingPlan')} /> </Flex> diff --git a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx index 17d6eaff437..e1e4d600dad 100644 --- a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx +++ b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx @@ -113,6 +113,7 @@ export function SubscriptionsList({ {subscription.plan.name} </Text> {sortedSubscriptions.length > 1 || !!subscription.canceledAtDate ? ( + // here <Badge colorScheme={subscription.status === 'active' ? 'secondary' : 'primary'} localizationKey={ @@ -123,7 +124,9 @@ export function SubscriptionsList({ /> ) : null} </Flex> + {(!subscription.plan.isDefault || subscription.status === 'upcoming') && ( + // here <Text variant='caption' colorScheme='secondary' diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index b216c9db257..9a267c13fa8 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -114,6 +114,7 @@ export const useSubscriptions = () => { created_at: canceledSubscription?.periodEndDate?.getTime() || 0, period_start: canceledSubscription?.periodEndDate?.getTime() || 0, period_end: 0, + past_due_at: null, }), ]; } diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index ea041db90d9..70ba49b9333 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -17,7 +17,7 @@ export interface CommerceBillingNamespace { } export type CommerceSubscriberType = 'org' | 'user'; -export type CommerceSubscriptionStatus = 'active' | 'ended' | 'upcoming'; +export type CommerceSubscriptionStatus = 'active' | 'ended' | 'upcoming' | 'past_due'; export type CommerceSubscriptionPlanPeriod = 'month' | 'annual'; export interface CommercePaymentSourceMethods { @@ -156,6 +156,7 @@ export interface CommerceSubscriptionResource extends ClerkResource { planPeriod: CommerceSubscriptionPlanPeriod; status: CommerceSubscriptionStatus; createdAt: Date; + pastDueAt: Date | null; periodStartDate: Date; periodEndDate: Date | null; canceledAtDate: Date | null; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 779d0807f0c..28989135c66 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -705,6 +705,7 @@ export interface CommerceSubscriptionJSON extends ClerkResourceJSON { period_start: number; period_end: number; canceled_at: number | null; + past_due_at: number | null; } export interface CommerceMoneyJSON { From af6a28baccca874cf55c41e42aa1c901073e1f4d Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Sun, 13 Jul 2025 20:14:33 +0300 Subject: [PATCH 23/34] Revert "wip" This reverts commit 587f2b857330337cbb0811cf8c090fec1e9ae3f2. --- packages/clerk-js/src/core/resources/CommerceSubscription.ts | 3 --- .../src/ui/components/PricingTable/PricingTableDefault.tsx | 1 - .../clerk-js/src/ui/components/SubscriptionDetails/index.tsx | 1 - .../src/ui/components/Subscriptions/SubscriptionsList.tsx | 3 --- packages/clerk-js/src/ui/contexts/components/Plans.tsx | 1 - packages/types/src/commerce.ts | 3 +-- packages/types/src/json.ts | 1 - 7 files changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/clerk-js/src/core/resources/CommerceSubscription.ts b/packages/clerk-js/src/core/resources/CommerceSubscription.ts index d2f8ae66219..ac4d949fdf4 100644 --- a/packages/clerk-js/src/core/resources/CommerceSubscription.ts +++ b/packages/clerk-js/src/core/resources/CommerceSubscription.ts @@ -20,7 +20,6 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr planPeriod!: CommerceSubscriptionPlanPeriod; status!: CommerceSubscriptionStatus; createdAt!: Date; - pastDueAt!: Date | null; periodStartDate!: Date; periodEndDate!: Date | null; canceledAtDate!: Date | null; @@ -52,8 +51,6 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr this.canceledAt = data.canceled_at; this.createdAt = unixEpochToDate(data.created_at); - this.pastDueAt = data.past_due_at ? unixEpochToDate(data.past_due_at) : null; - this.periodStartDate = unixEpochToDate(data.period_start); this.periodEndDate = data.period_end ? unixEpochToDate(data.period_end) : null; this.canceledAtDate = data.canceled_at ? unixEpochToDate(data.canceled_at) : null; diff --git a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx index 8bc1a1c4291..ab99e7fe3bc 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx @@ -191,7 +191,6 @@ function Card(props: CardProps) { isPlanActive ? ( <Badge colorScheme='secondary' - // here localizationKey={localizationKeys('badge__activePlan')} /> ) : ( diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index d76750b7752..3e36abd05a7 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -531,7 +531,6 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription <Badge elementDescriptor={descriptors.subscriptionDetailsCardBadge} colorScheme={isActive ? 'secondary' : 'primary'} - // here localizationKey={isActive ? localizationKeys('badge__activePlan') : localizationKeys('badge__upcomingPlan')} /> </Flex> diff --git a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx index e1e4d600dad..17d6eaff437 100644 --- a/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx +++ b/packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx @@ -113,7 +113,6 @@ export function SubscriptionsList({ {subscription.plan.name} </Text> {sortedSubscriptions.length > 1 || !!subscription.canceledAtDate ? ( - // here <Badge colorScheme={subscription.status === 'active' ? 'secondary' : 'primary'} localizationKey={ @@ -124,9 +123,7 @@ export function SubscriptionsList({ /> ) : null} </Flex> - {(!subscription.plan.isDefault || subscription.status === 'upcoming') && ( - // here <Text variant='caption' colorScheme='secondary' diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index 9a267c13fa8..b216c9db257 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -114,7 +114,6 @@ export const useSubscriptions = () => { created_at: canceledSubscription?.periodEndDate?.getTime() || 0, period_start: canceledSubscription?.periodEndDate?.getTime() || 0, period_end: 0, - past_due_at: null, }), ]; } diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index 70ba49b9333..ea041db90d9 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -17,7 +17,7 @@ export interface CommerceBillingNamespace { } export type CommerceSubscriberType = 'org' | 'user'; -export type CommerceSubscriptionStatus = 'active' | 'ended' | 'upcoming' | 'past_due'; +export type CommerceSubscriptionStatus = 'active' | 'ended' | 'upcoming'; export type CommerceSubscriptionPlanPeriod = 'month' | 'annual'; export interface CommercePaymentSourceMethods { @@ -156,7 +156,6 @@ export interface CommerceSubscriptionResource extends ClerkResource { planPeriod: CommerceSubscriptionPlanPeriod; status: CommerceSubscriptionStatus; createdAt: Date; - pastDueAt: Date | null; periodStartDate: Date; periodEndDate: Date | null; canceledAtDate: Date | null; diff --git a/packages/types/src/json.ts b/packages/types/src/json.ts index 28989135c66..779d0807f0c 100644 --- a/packages/types/src/json.ts +++ b/packages/types/src/json.ts @@ -705,7 +705,6 @@ export interface CommerceSubscriptionJSON extends ClerkResourceJSON { period_start: number; period_end: number; canceled_at: number | null; - past_due_at: number | null; } export interface CommerceMoneyJSON { From 1c161ccbb55131e225407f88547cfbc540161536 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Sun, 13 Jul 2025 21:16:46 +0300 Subject: [PATCH 24/34] address pr feedback --- .../components/SubscriptionDetails/index.tsx | 71 +++++++------------ .../ui/customizables/elementDescriptors.ts | 1 + packages/clerk-js/src/ui/elements/Drawer.tsx | 18 +++-- .../src/ui/elements/ThreeDotsMenu.tsx | 63 ++++++++++------ packages/types/src/appearance.ts | 1 + 5 files changed, 79 insertions(+), 75 deletions(-) diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index 3e36abd05a7..96c8430f2d8 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -18,7 +18,6 @@ import { CardAlert } from '@/ui/elements/Card/CardAlert'; import { useCardState, withCardStateProvider } from '@/ui/elements/contexts'; import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; import { ThreeDotsMenu } from '@/ui/elements/ThreeDotsMenu'; -import { ThreeDots } from '@/ui/icons'; import { handleError } from '@/ui/utils/errorHandler'; import { formatDate } from '@/ui/utils/formatDate'; @@ -34,7 +33,6 @@ import { descriptors, Flex, Heading, - Icon, localizationKeys, Span, Spinner, @@ -141,23 +139,24 @@ const SubscriptionDetailsInternal = (props: __internal_SubscriptionDetailsProps) > <Drawer.Header title={localizationKeys('commerce.subscriptionDetails.title')} /> - <Drawer.Body> - <Col - gap={4} - sx={t => ({ - padding: t.space.$4, - overflowY: 'auto', - })} - > - {/* Subscription Cards */} - {subscriptions?.map(subscriptionItem => ( - <SubscriptionCard - key={subscriptionItem.id} - subscription={subscriptionItem} - {...props} - /> - ))} - </Col> + <Drawer.Body + sx={t => ({ + display: 'flex', + flexDirection: 'column', + flex: 1, + overflowY: 'auto', + padding: t.space.$4, + gap: t.space.$4, + })} + > + {/* Subscription Cards */} + {subscriptions?.map(subscriptionItem => ( + <SubscriptionCard + key={subscriptionItem.id} + subscription={subscriptionItem} + {...props} + /> + ))} </Drawer.Body> <SubscriptionDetailsFooter /> @@ -355,7 +354,6 @@ function SubscriptionDetailsSummary() { const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubscriptionResource }) => { const { portalRoot } = useSubscriptionDetailsContext(); const { __internal_openCheckout } = useClerk(); - const { t } = useLocalizations(); const subscriberType = useSubscriberTypeContext(); const { setIsOpen } = useDrawerContext(); const { revalidateAll } = usePlansContext(); @@ -455,27 +453,7 @@ const SubscriptionCardActions = ({ subscription }: { subscription: CommerceSubsc return ( <ThreeDotsMenu - trigger={ - <Button - aria-label={t(localizationKeys('commerce.manageSubscription'))} - variant='bordered' - colorScheme='secondary' - sx={t => ({ - width: t.sizes.$6, - height: t.sizes.$6, - })} - elementDescriptor={[descriptors.menuButton, descriptors.menuButtonEllipsis]} - > - <Icon - icon={ThreeDots} - sx={t => ({ - width: t.sizes.$4, - height: t.sizes.$4, - opacity: t.opacity.$inactive, - })} - /> - </Button> - } + variant='bordered' actions={actions} /> ); @@ -519,12 +497,13 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription <Text elementDescriptor={descriptors.subscriptionDetailsCardTitle} - sx={{ - fontSize: '16px', - fontWeight: '600', - color: '#333', + variant='h2' + sx={t => ({ + fontSize: t.fontSizes.$lg, + fontWeight: t.fontWeights.$semibold, + color: t.colors.$colorText, marginInlineEnd: 'auto', - }} + })} > {subscription.plan.name} </Text> diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index 6f8307a8a8f..72d44689226 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -387,6 +387,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'statementCopyButton', 'menuButton', 'menuButtonEllipsis', + 'menuButtonEllipsisBordered', 'menuList', 'menuItem', diff --git a/packages/clerk-js/src/ui/elements/Drawer.tsx b/packages/clerk-js/src/ui/elements/Drawer.tsx index 49622abc644..f20e4c44d93 100644 --- a/packages/clerk-js/src/ui/elements/Drawer.tsx +++ b/packages/clerk-js/src/ui/elements/Drawer.tsx @@ -339,6 +339,7 @@ const Header = React.forwardRef<HTMLDivElement, HeaderProps>(({ title, children, interface BodyProps extends React.HTMLAttributes<HTMLDivElement> { children: React.ReactNode; + sx?: ThemableCssProp; } const Body = React.forwardRef<HTMLDivElement, BodyProps>(({ children, ...props }, ref) => { @@ -346,13 +347,16 @@ const Body = React.forwardRef<HTMLDivElement, BodyProps>(({ children, ...props } <Box ref={ref} elementDescriptor={descriptors.drawerBody} - sx={{ - display: 'flex', - flexDirection: 'column', - flex: 1, - overflowY: 'auto', - overflowX: 'hidden', - }} + sx={[ + () => ({ + display: 'flex', + flexDirection: 'column', + flex: 1, + overflowY: 'auto', + overflowX: 'hidden', + }), + props.sx, + ]} {...props} > {children} diff --git a/packages/clerk-js/src/ui/elements/ThreeDotsMenu.tsx b/packages/clerk-js/src/ui/elements/ThreeDotsMenu.tsx index 66b476b45e6..9cc826d1114 100644 --- a/packages/clerk-js/src/ui/elements/ThreeDotsMenu.tsx +++ b/packages/clerk-js/src/ui/elements/ThreeDotsMenu.tsx @@ -2,6 +2,7 @@ import type { MenuId } from '@clerk/types'; import type { LocalizationKey } from '../customizables'; import { Button, descriptors, Icon } from '../customizables'; +import type { InternalTheme } from '../foundations'; import { ThreeDots } from '../icons'; import { Menu, MenuItem, MenuList, MenuTrigger } from './Menu'; @@ -13,36 +14,54 @@ type Action = { }; type ThreeDotsMenuProps = { - trigger?: React.ReactNode; + variant?: 'bordered'; actions: Action[]; elementId?: MenuId; }; export const ThreeDotsMenu = (props: ThreeDotsMenuProps) => { - const { actions, elementId } = props; + const { actions, elementId, variant } = props; + const isBordered = variant === 'bordered'; + + const iconSx = (t: InternalTheme) => + !isBordered + ? { width: 'auto', height: t.sizes.$5 } + : { width: t.sizes.$4, height: t.sizes.$4, opacity: t.opacity.$inactive }; + + const buttonVariant = isBordered ? 'bordered' : 'ghost'; + const colorScheme = isBordered ? 'secondary' : 'neutral'; + return ( <Menu elementId={elementId}> <MenuTrigger arialLabel={isOpen => `${isOpen ? 'Close' : 'Open'} menu`}> - {props.trigger || ( - <Button - sx={t => ({ - padding: t.space.$0x5, - boxSizing: 'content-box', - opacity: t.opacity.$inactive, - ':hover': { - opacity: 1, - }, - })} - variant='ghost' - colorScheme='neutral' - elementDescriptor={[descriptors.menuButton, descriptors.menuButtonEllipsis]} - > - <Icon - icon={ThreeDots} - sx={t => ({ width: 'auto', height: t.sizes.$5 })} - /> - </Button> - )} + <Button + sx={t => + !isBordered + ? { + padding: t.space.$0x5, + boxSizing: 'content-box', + opacity: t.opacity.$inactive, + ':hover': { + opacity: 1, + }, + } + : { + width: t.sizes.$6, + height: t.sizes.$6, + } + } + variant={buttonVariant} + colorScheme={colorScheme} + elementDescriptor={[ + descriptors.menuButton, + isBordered ? descriptors.menuButtonEllipsisBordered : descriptors.menuButtonEllipsis, + ]} + > + <Icon + icon={ThreeDots} + sx={iconSx} + /> + </Button> </MenuTrigger> <MenuList> {actions.map((a, index) => ( diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index 4a9142bc3c6..3c5f7158aa0 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -512,6 +512,7 @@ export type ElementsConfig = { statementCopyButton: WithOptions; menuButton: WithOptions<MenuId>; menuButtonEllipsis: WithOptions; + menuButtonEllipsisBordered: WithOptions; menuList: WithOptions<MenuId>; menuItem: WithOptions<MenuId>; From 0ed99f14f6e917b6628694e410b69adaefc36f74 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Sun, 13 Jul 2025 21:19:05 +0300 Subject: [PATCH 25/34] bump --- packages/clerk-js/bundlewatch.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 57ab2fbe893..71de67ae931 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,6 +1,6 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "612kB" }, + { "path": "./dist/clerk.js", "maxSize": "614kB" }, { "path": "./dist/clerk.browser.js", "maxSize": "72.2KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, From 39100a922c756c158bd5e274b414a939d4ec861f Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Sun, 13 Jul 2025 21:43:34 +0300 Subject: [PATCH 26/34] wip subscription items --- .../core/modules/commerce/CommerceBilling.ts | 9 +++++++ .../src/ui/contexts/components/Plans.tsx | 2 ++ packages/shared/src/react/hooks/index.ts | 1 + .../src/react/hooks/useSubscriptionItems.tsx | 25 ++++++++++++++++++- packages/types/src/commerce.ts | 4 +++ 5 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts b/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts index f454e9ff848..d5e71157b46 100644 --- a/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts +++ b/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts @@ -48,6 +48,15 @@ export class CommerceBilling implements CommerceBillingNamespace { return new CommercePlan(plan); }; + getSubscription = async (params: GetSubscriptionsParams): Promise<any> => { + return await BaseResource._fetch({ + path: params.orgId ? `/organizations/${params.orgId}/commerce/subscription` : `/me/commerce/subscription`, + method: 'GET', + }).then(res => { + return res; + }); + }; + getSubscriptions = async ( params: GetSubscriptionsParams, ): Promise<ClerkPaginatedResponse<CommerceSubscriptionResource>> => { diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index b216c9db257..309b8c35915 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -5,6 +5,7 @@ import { useClerk, useOrganization, useSession, + useSubscription, useUser, } from '@clerk/shared/react'; import type { @@ -72,6 +73,7 @@ export const useStatements = (params?: { mode: 'cache' }) => { }; export const useSubscriptions = () => { + useSubscription(); const { billing } = useClerk(); const { organization } = useOrganization(); const { user, isSignedIn } = useUser(); diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index e801745a0b7..1d689ce2501 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -12,3 +12,4 @@ export { useStatements as __experimental_useStatements } from './useStatements'; export { usePaymentAttempts as __experimental_usePaymentAttempts } from './usePaymentAttempts'; export { usePaymentMethods as __experimental_usePaymentMethods } from './usePaymentMethods'; export { useSubscriptionItems as __experimental_useSubscriptionItems } from './useSubscriptionItems'; +export { useSubscription } from './useSubscriptionItems'; diff --git a/packages/shared/src/react/hooks/useSubscriptionItems.tsx b/packages/shared/src/react/hooks/useSubscriptionItems.tsx index db2db7eb889..0c0e73b9ecb 100644 --- a/packages/shared/src/react/hooks/useSubscriptionItems.tsx +++ b/packages/shared/src/react/hooks/useSubscriptionItems.tsx @@ -1,6 +1,8 @@ import type { CommerceSubscriptionResource, GetSubscriptionsParams } from '@clerk/types'; -import { useClerkInstanceContext } from '../contexts'; +import { eventMethodCalled } from '../../telemetry/events'; +import { useSWR } from '../clerk-swr'; +import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; import { createCommerceHook } from './createCommerceHook'; /** @@ -14,3 +16,24 @@ export const useSubscriptionItems = createCommerceHook<CommerceSubscriptionResou return clerk.billing.getSubscriptions; }, }); + +const dedupeOptions = { + dedupingInterval: 1_000 * 60, // 1 minute, + keepPreviousData: true, +}; + +export const useSubscription = (params?: { for: 'organization' | 'user' }) => { + const clerk = useClerkInstanceContext(); + const user = useUserContext(); + const { organization } = useOrganizationContext(); + clerk.telemetry?.record(eventMethodCalled('useSubscription')); + return useSWR( + { + type: 'commerce-subscription', + userId: user?.id, + args: { orgId: params?.for === 'organization' ? organization?.id : undefined }, + }, + ({ args, userId }) => (userId ? clerk.billing.getSubscription(args) : undefined), + dedupeOptions, + ); +}; diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index ea041db90d9..d907469875a 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -11,6 +11,10 @@ export interface CommerceBillingNamespace { getPaymentAttempts: (params: GetPaymentAttemptsParams) => Promise<ClerkPaginatedResponse<CommercePaymentResource>>; getPlans: (params?: GetPlansParams) => Promise<CommercePlanResource[]>; getPlan: (params: { id: string }) => Promise<CommercePlanResource>; + getSubscription: (params: GetSubscriptionsParams) => Promise<any>; + /** + * @deprecated Use `getSubscription` + */ getSubscriptions: (params: GetSubscriptionsParams) => Promise<ClerkPaginatedResponse<CommerceSubscriptionResource>>; getStatements: (params: GetStatementsParams) => Promise<ClerkPaginatedResponse<CommerceStatementResource>>; startCheckout: (params: CreateCheckoutParams) => Promise<CommerceCheckoutResource>; From 7db88a9606cbce3b8225b7c38545eceb8841fd70 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Sun, 13 Jul 2025 21:43:40 +0300 Subject: [PATCH 27/34] Revert "wip subscription items" This reverts commit 39100a922c756c158bd5e274b414a939d4ec861f. --- .../core/modules/commerce/CommerceBilling.ts | 9 ------- .../src/ui/contexts/components/Plans.tsx | 2 -- packages/shared/src/react/hooks/index.ts | 1 - .../src/react/hooks/useSubscriptionItems.tsx | 25 +------------------ packages/types/src/commerce.ts | 4 --- 5 files changed, 1 insertion(+), 40 deletions(-) diff --git a/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts b/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts index d5e71157b46..f454e9ff848 100644 --- a/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts +++ b/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts @@ -48,15 +48,6 @@ export class CommerceBilling implements CommerceBillingNamespace { return new CommercePlan(plan); }; - getSubscription = async (params: GetSubscriptionsParams): Promise<any> => { - return await BaseResource._fetch({ - path: params.orgId ? `/organizations/${params.orgId}/commerce/subscription` : `/me/commerce/subscription`, - method: 'GET', - }).then(res => { - return res; - }); - }; - getSubscriptions = async ( params: GetSubscriptionsParams, ): Promise<ClerkPaginatedResponse<CommerceSubscriptionResource>> => { diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index 309b8c35915..b216c9db257 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -5,7 +5,6 @@ import { useClerk, useOrganization, useSession, - useSubscription, useUser, } from '@clerk/shared/react'; import type { @@ -73,7 +72,6 @@ export const useStatements = (params?: { mode: 'cache' }) => { }; export const useSubscriptions = () => { - useSubscription(); const { billing } = useClerk(); const { organization } = useOrganization(); const { user, isSignedIn } = useUser(); diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index 1d689ce2501..e801745a0b7 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -12,4 +12,3 @@ export { useStatements as __experimental_useStatements } from './useStatements'; export { usePaymentAttempts as __experimental_usePaymentAttempts } from './usePaymentAttempts'; export { usePaymentMethods as __experimental_usePaymentMethods } from './usePaymentMethods'; export { useSubscriptionItems as __experimental_useSubscriptionItems } from './useSubscriptionItems'; -export { useSubscription } from './useSubscriptionItems'; diff --git a/packages/shared/src/react/hooks/useSubscriptionItems.tsx b/packages/shared/src/react/hooks/useSubscriptionItems.tsx index 0c0e73b9ecb..db2db7eb889 100644 --- a/packages/shared/src/react/hooks/useSubscriptionItems.tsx +++ b/packages/shared/src/react/hooks/useSubscriptionItems.tsx @@ -1,8 +1,6 @@ import type { CommerceSubscriptionResource, GetSubscriptionsParams } from '@clerk/types'; -import { eventMethodCalled } from '../../telemetry/events'; -import { useSWR } from '../clerk-swr'; -import { useClerkInstanceContext, useOrganizationContext, useUserContext } from '../contexts'; +import { useClerkInstanceContext } from '../contexts'; import { createCommerceHook } from './createCommerceHook'; /** @@ -16,24 +14,3 @@ export const useSubscriptionItems = createCommerceHook<CommerceSubscriptionResou return clerk.billing.getSubscriptions; }, }); - -const dedupeOptions = { - dedupingInterval: 1_000 * 60, // 1 minute, - keepPreviousData: true, -}; - -export const useSubscription = (params?: { for: 'organization' | 'user' }) => { - const clerk = useClerkInstanceContext(); - const user = useUserContext(); - const { organization } = useOrganizationContext(); - clerk.telemetry?.record(eventMethodCalled('useSubscription')); - return useSWR( - { - type: 'commerce-subscription', - userId: user?.id, - args: { orgId: params?.for === 'organization' ? organization?.id : undefined }, - }, - ({ args, userId }) => (userId ? clerk.billing.getSubscription(args) : undefined), - dedupeOptions, - ); -}; diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index d907469875a..ea041db90d9 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -11,10 +11,6 @@ export interface CommerceBillingNamespace { getPaymentAttempts: (params: GetPaymentAttemptsParams) => Promise<ClerkPaginatedResponse<CommercePaymentResource>>; getPlans: (params?: GetPlansParams) => Promise<CommercePlanResource[]>; getPlan: (params: { id: string }) => Promise<CommercePlanResource>; - getSubscription: (params: GetSubscriptionsParams) => Promise<any>; - /** - * @deprecated Use `getSubscription` - */ getSubscriptions: (params: GetSubscriptionsParams) => Promise<ClerkPaginatedResponse<CommerceSubscriptionResource>>; getStatements: (params: GetStatementsParams) => Promise<ClerkPaginatedResponse<CommerceStatementResource>>; startCheckout: (params: CreateCheckoutParams) => Promise<CommerceCheckoutResource>; From 257a3cc5254bcc1c3e2f544ae0a4816b89407ddb Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Sun, 13 Jul 2025 21:47:13 +0300 Subject: [PATCH 28/34] wip changeset --- .changeset/lovely-lands-smell.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/lovely-lands-smell.md diff --git a/.changeset/lovely-lands-smell.md b/.changeset/lovely-lands-smell.md new file mode 100644 index 00000000000..9f791bdc28a --- /dev/null +++ b/.changeset/lovely-lands-smell.md @@ -0,0 +1,8 @@ +--- +'@clerk/localizations': minor +'@clerk/clerk-js': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +wip From 3753deeb607149cb8ea3fe9192e1f8b023731179 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Sun, 13 Jul 2025 21:50:06 +0300 Subject: [PATCH 29/34] bundlewatch.config.json --- packages/clerk-js/bundlewatch.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 71de67ae931..f8f54577f6c 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,10 +1,10 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "614kB" }, + { "path": "./dist/clerk.js", "maxSize": "615kB" }, { "path": "./dist/clerk.browser.js", "maxSize": "72.2KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "110KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "115KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, { "path": "./dist/createorganization*.js", "maxSize": "5KB" }, From 28d86b4bedce99e20ae630c26943034ed9d49b37 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Mon, 14 Jul 2025 10:49:15 +0300 Subject: [PATCH 30/34] fix lint --- .../SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx index 15e3c15af3c..0a4bfed164e 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx @@ -655,7 +655,6 @@ describe('SubscriptionDetails', () => { f.withUser({ email_addresses: ['test@clerk.com'] }); }); - const switchToMonthlyMock = jest.fn().mockResolvedValue({}); const plan = { id: 'plan_annual', name: 'Annual Plan', From 38fa5f6baa4e350afd20f99514124b0743c9a7c2 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Mon, 14 Jul 2025 11:30:02 +0300 Subject: [PATCH 31/34] fix build issue --- packages/react/src/isomorphicClerk.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 0da47ee7598..09dd59fa1b2 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -802,7 +802,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { if (this.clerkjs && this.loaded) { this.clerkjs.__internal_openSubscriptionDetails(props); } else { - this.preopenSubscriptionDetails = props; + this.preopenSubscriptionDetails = props ?? null; } }; From 54214037ae85fd1193efc4022e8d7ffee7b6c567 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Mon, 14 Jul 2025 13:18:40 +0300 Subject: [PATCH 32/34] patch tests --- .../__tests__/SubscriptionDetails.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx index 0a4bfed164e..191a8534e90 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/__tests__/SubscriptionDetails.test.tsx @@ -103,7 +103,7 @@ describe('SubscriptionDetails', () => { await userEvent.click(menuButton); await waitFor(() => { - expect(getByText('Switch to annual $100.00 per year')).toBeVisible(); + expect(getByText('Switch to annual $100.00 / year')).toBeVisible(); expect(getByText('Cancel subscription')).toBeVisible(); }); }); @@ -183,7 +183,7 @@ describe('SubscriptionDetails', () => { await userEvent.click(menuButton); await waitFor(() => { - expect(getByText('Switch to monthly $10.00 per month')).toBeVisible(); + expect(getByText('Switch to monthly $10.00 / month')).toBeVisible(); expect(getByText('Cancel subscription')).toBeVisible(); }); }); @@ -245,7 +245,7 @@ describe('SubscriptionDetails', () => { expect(getByText('Subscribed on')).toBeVisible(); expect(getByText('January 1, 2021')).toBeVisible(); - expect(queryByText('Renews at')).toBeNull(); + expect(getByText('Renews at')).toBeVisible(); expect(queryByText('Ends on')).toBeNull(); expect(queryByText('Current billing cycle')).toBeNull(); expect(queryByText('Monthly')).toBeNull(); @@ -368,7 +368,7 @@ describe('SubscriptionDetails', () => { await userEvent.click(menuButton); await waitFor(() => { - expect(getByText('Switch to monthly $13.00 per month')).toBeVisible(); + expect(getByText('Switch to monthly $13.00 / month')).toBeVisible(); expect(getByText('Resubscribe')).toBeVisible(); expect(queryByText('Cancel subscription')).toBeNull(); }); @@ -376,7 +376,7 @@ describe('SubscriptionDetails', () => { await userEvent.click(upcomingMenuButton); await waitFor(() => { - expect(getByText('Switch to annual $90.00 per year')).toBeVisible(); + expect(getByText('Switch to annual $90.00 / year')).toBeVisible(); expect(getByText('Cancel subscription')).toBeVisible(); }); }); @@ -491,7 +491,7 @@ describe('SubscriptionDetails', () => { }); }); - it.only('allows cancelling a subscription of a monthly plan', async () => { + it('allows cancelling a subscription of a monthly plan', async () => { const { wrapper, fixtures } = await createFixtures(f => { f.withUser({ email_addresses: ['test@clerk.com'] }); }); From 9c5beacd3dd5a87003418e1ca29fda64bc9ebce8 Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Mon, 14 Jul 2025 17:24:10 +0300 Subject: [PATCH 33/34] fix changeset --- .changeset/lovely-lands-smell.md | 8 -------- .changeset/tangy-toes-dress.md | 8 ++++++++ 2 files changed, 8 insertions(+), 8 deletions(-) delete mode 100644 .changeset/lovely-lands-smell.md create mode 100644 .changeset/tangy-toes-dress.md diff --git a/.changeset/lovely-lands-smell.md b/.changeset/lovely-lands-smell.md deleted file mode 100644 index 9f791bdc28a..00000000000 --- a/.changeset/lovely-lands-smell.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@clerk/localizations': minor -'@clerk/clerk-js': minor -'@clerk/clerk-react': minor -'@clerk/types': minor ---- - -wip diff --git a/.changeset/tangy-toes-dress.md b/.changeset/tangy-toes-dress.md new file mode 100644 index 00000000000..76cfd7a4bff --- /dev/null +++ b/.changeset/tangy-toes-dress.md @@ -0,0 +1,8 @@ +--- +'@clerk/localizations': minor +'@clerk/clerk-js': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +Extract `SubscriptionDetails`, into its own internal component, out of existing (also internal) `PlanDetails` component. From 86de6849076aeb3f088ba39759f5a2b48a0e208a Mon Sep 17 00:00:00 2001 From: panteliselef <panteliselef@outlook.com> Date: Mon, 14 Jul 2025 19:16:54 +0300 Subject: [PATCH 34/34] use line items --- .../src/ui/components/Plans/PlanDetails.tsx | 1 - .../components/SubscriptionDetails/index.tsx | 152 ++++-------------- .../ui/customizables/elementDescriptors.ts | 4 - .../clerk-js/src/ui/elements/LineItems.tsx | 36 +++-- packages/types/src/appearance.ts | 4 - 5 files changed, 52 insertions(+), 145 deletions(-) diff --git a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx index 0836fb70727..5f570143e2a 100644 --- a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx +++ b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx @@ -59,7 +59,6 @@ const PlanDetailsInternal = ({ return ( <SubscriberTypeContext.Provider value={plan.payerType[0] as 'user' | 'org'}> - {/* TODO: type assertion is a hack, make FAPI stricter */} <Drawer.Header sx={t => !hasFeatures diff --git a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx index 96c8430f2d8..2774763171f 100644 --- a/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx +++ b/packages/clerk-js/src/ui/components/SubscriptionDetails/index.tsx @@ -23,18 +23,18 @@ import { formatDate } from '@/ui/utils/formatDate'; const isFreePlan = (plan: CommercePlanResource) => !plan.hasBaseFee; +import { LineItems } from '@/ui/elements/LineItems'; + import { SubscriberTypeContext, usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; import type { LocalizationKey } from '../../customizables'; import { Badge, - Box, Button, Col, descriptors, Flex, Heading, localizationKeys, - Span, Spinner, Text, useLocalizations, @@ -271,83 +271,47 @@ const SubscriptionDetailsFooter = withCardStateProvider(() => { function SubscriptionDetailsSummary() { const { anySubscription, activeSubscription, upcomingSubscription } = useGuessableSubscription({ or: 'throw' }); - const { t } = useLocalizations(); if (!activeSubscription) { return null; } return ( - <Col - elementDescriptor={descriptors.subscriptionDetailsSummaryItems} - gap={3} - as='ul' - sx={t => ({ - paddingBlock: t.space.$1, - })} - > - <SummaryItem> - <SummmaryItemLabel> - <Text - colorScheme='secondary' - localizationKey={localizationKeys('commerce.subscriptionDetails.currentBillingCycle')} - /> - </SummmaryItemLabel> - <SummmaryItemValue> - <Text - colorScheme='secondary' - localizationKey={ - activeSubscription.planPeriod === 'month' - ? localizationKeys('commerce.monthly') - : localizationKeys('commerce.annually') - } - /> - </SummmaryItemValue> - </SummaryItem> - <SummaryItem> - <SummmaryItemLabel> - <Text colorScheme='secondary'>{t(localizationKeys('commerce.subscriptionDetails.nextPaymentOn'))}</Text> - </SummmaryItemLabel> - <SummmaryItemValue> - <Text colorScheme='secondary'> - {upcomingSubscription + <LineItems.Root> + <LineItems.Group> + <LineItems.Title description={localizationKeys('commerce.subscriptionDetails.currentBillingCycle')} /> + <LineItems.Description + text={ + activeSubscription.planPeriod === 'month' + ? localizationKeys('commerce.monthly') + : localizationKeys('commerce.annually') + } + /> + </LineItems.Group> + <LineItems.Group> + <LineItems.Title description={localizationKeys('commerce.subscriptionDetails.nextPaymentOn')} /> + <LineItems.Description + text={ + upcomingSubscription ? formatDate(upcomingSubscription.periodStartDate) : anySubscription.periodEndDate ? formatDate(anySubscription.periodEndDate) - : '-'} - </Text> - </SummmaryItemValue> - </SummaryItem> - <SummaryItem> - <SummmaryItemLabel> - <Text - colorScheme='secondary' - localizationKey={localizationKeys('commerce.subscriptionDetails.nextPaymentAmount')} - /> - </SummmaryItemLabel> - <SummmaryItemValue - sx={t => ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$1, - })} - > - <Text - variant='caption' - colorScheme='secondary' - sx={{ textTransform: 'uppercase' }} - > - {anySubscription.plan.currency} - </Text> - <Text> - {anySubscription.plan.currencySymbol} - {anySubscription.planPeriod === 'month' + : '-' + } + /> + </LineItems.Group> + <LineItems.Group> + <LineItems.Title description={localizationKeys('commerce.subscriptionDetails.nextPaymentAmount')} /> + <LineItems.Description + prefix={anySubscription.plan.currency} + text={`${anySubscription.plan.currencySymbol}${ + anySubscription.planPeriod === 'month' ? anySubscription.plan.amountFormatted - : anySubscription.plan.annualAmountFormatted} - </Text> - </SummmaryItemValue> - </SummaryItem> - </Col> + : anySubscription.plan.annualAmountFormatted + }`} + /> + </LineItems.Group> + </LineItems.Root> ); } @@ -591,53 +555,3 @@ const DetailRow = ({ label, value }: { label: LocalizationKey; value: string }) </Text> </Flex> ); - -function SummaryItem(props: React.PropsWithChildren) { - return ( - <Box - elementDescriptor={descriptors.subscriptionDetailsSummaryItem} - as='li' - sx={{ - display: 'flex', - justifyContent: 'space-between', - flexWrap: 'wrap', - }} - > - {props.children} - </Box> - ); -} - -function SummmaryItemLabel(props: React.PropsWithChildren) { - return ( - <Span - elementDescriptor={descriptors.subscriptionDetailsSummaryLabel} - sx={t => ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$1x5, - })} - > - {props.children} - </Span> - ); -} - -function SummmaryItemValue(props: Parameters<typeof Span>[0]) { - return ( - <Span - elementDescriptor={descriptors.subscriptionDetailsSummaryValue} - {...props} - sx={[ - t => ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$0x25, - }), - props.sx, - ]} - > - {props.children} - </Span> - ); -} diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index 90b01d9b3d3..cd7ffe647ae 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -491,10 +491,6 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'subscriptionDetailsCardBody', 'subscriptionDetailsCardFooter', 'subscriptionDetailsCardActions', - 'subscriptionDetailsSummaryItems', - 'subscriptionDetailsSummaryItem', - 'subscriptionDetailsSummaryLabel', - 'subscriptionDetailsSummaryValue', 'subscriptionDetailsDetailRow', 'subscriptionDetailsDetailRowLabel', 'subscriptionDetailsDetailRowValue', diff --git a/packages/clerk-js/src/ui/elements/LineItems.tsx b/packages/clerk-js/src/ui/elements/LineItems.tsx index 461806babf4..71bf56673fa 100644 --- a/packages/clerk-js/src/ui/elements/LineItems.tsx +++ b/packages/clerk-js/src/ui/elements/LineItems.tsx @@ -81,7 +81,7 @@ function Group({ children, borderTop = false, variant = 'primary' }: GroupProps) * -----------------------------------------------------------------------------------------------*/ interface TitleProps { - title: string | LocalizationKey; + title?: string | LocalizationKey; description?: string | LocalizationKey; icon?: React.ComponentType; } @@ -104,22 +104,24 @@ const Title = React.forwardRef<HTMLTableCellElement, TitleProps>(({ title, descr ...common.textVariants(t)[textVariant], })} > - <Span - sx={t => ({ - display: 'inline-flex', - alignItems: 'center', - gap: t.space.$1, - })} - > - {icon ? ( - <Icon - size='md' - icon={icon} - aria-hidden - /> - ) : null} - <Span localizationKey={title} /> - </Span> + {title ? ( + <Span + sx={t => ({ + display: 'inline-flex', + alignItems: 'center', + gap: t.space.$1, + })} + > + {icon ? ( + <Icon + size='md' + icon={icon} + aria-hidden + /> + ) : null} + <Span localizationKey={title} /> + </Span> + ) : null} {description ? ( <Span localizationKey={description} diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index a401cd9ce29..17c872df239 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -618,10 +618,6 @@ export type ElementsConfig = { subscriptionDetailsCardBody: WithOptions; subscriptionDetailsCardFooter: WithOptions; subscriptionDetailsCardActions: WithOptions; - subscriptionDetailsSummaryItems: WithOptions; - subscriptionDetailsSummaryItem: WithOptions; - subscriptionDetailsSummaryLabel: WithOptions; - subscriptionDetailsSummaryValue: WithOptions; subscriptionDetailsDetailRow: WithOptions; subscriptionDetailsDetailRowLabel: WithOptions; subscriptionDetailsDetailRowValue: WithOptions;