From 234ce35ad8c9586a86ce544ef70094f095e97735 Mon Sep 17 00:00:00 2001 From: panteliselef 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 => { + 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> => { 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: ( + openDrawer: ( 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; 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')} /> + componentsControls.closeDrawer('subscriptionDetails')} + /> + {state.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 ( - - - - - + + + ); }; 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(); const [planPeriod, setPlanPeriod] = useState(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 ( + + + + ); + } + 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 ( - <> + + {/* TODO: type assertion is a hack, make FAPI stricter */} !hasFeatures @@ -129,7 +77,6 @@ const PlanDetailsInternal = ({ >
} @@ -207,127 +154,17 @@ const PlanDetailsInternal = ({ ) : null} - {(!plan.isDefault && !isDefaultPlanImplicitlyActiveOrUpcoming) || !subscription ? ( + {/* {!plan.isDefault && !isDefaultPlanImplicitlyActiveOrUpcoming ? ( - {subscription ? ( - subscription.canceledAt ? ( - + } + actions={actions} + /> ); }; // New component for individual subscription cards const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscriptionResource }) => { const isActive = subscription.status === 'active'; + const { t } = useLocalizations(); return ( {/* Header with name and badge */} + 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.name} @@ -334,30 +510,26 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription {/* Pricing details */} - ({ - 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' > - - - + ({ + 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'))}`} + + + + {isActive ? ( @@ -400,135 +572,16 @@ const DetailRow = ({ label, value }: { label: string; value: string }) => ( ); -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 ( - ({ - 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, - }, - })} - > - ({ - width: t.space.$8, - paddingInline: t.space.$2, - paddingBlock: t.space.$1x5, - })} - > - {labelIcon ? ( - - ) : null} - - - ({ - 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}`, - })} - > - ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$1x5, - })} - > - - - ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$0x25, - color: t.colors.$colorTextSecondary, - })} - > - {typeof value === 'string' ? ( - - {valueTruncated ? truncateWithEndVisible(value) : value} - - ) : ( - - )} - - - - ); -} - function SummaryItem(props: React.PropsWithChildren) { return ( ({ - paddingInline: t.space.$4, + sx={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', - })} + }} > {props.children} 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(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 ( `${isOpen ? 'Close' : 'Open'} menu`}> - + {props.trigger || ( + + )} {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 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( + {}} + > + + , + { 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( + {}} + > + + , + { 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( + {}} + > + + , + { 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( + {}} + > + + , + { 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( + {}} + > + + , + { 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( + {}} + > + + , + { 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)} /> - + {!isFree && ( + + )} ) : ( ); + case 'SubscriptionDetails': + return ( + + {children} + + ); 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 { mockMethodsOf(session, { exclude: ['checkAuthorization'], From a2303b6377bd6ac74b1a2592a11c1a57f3fde8aa Mon Sep 17 00:00:00 2001 From: panteliselef 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( null, ); + const [confirmationOpen, setConfirmationOpen] = useState(false); const { buttonPropsForPlan: _buttonPropsForPlan, @@ -123,7 +129,12 @@ const SubscriptionDetailsInternal = (props: __experimental_SubscriptionDetailsPr return ( @@ -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(() => { @@ -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 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( + {}} + > + + , + { 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( + {}} + > + + , + { 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( + {}} + > + + , + { 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 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 ( ({ - borderWidth: t.borderWidths.$normal, - borderStyle: t.borderStyles.$solid, - borderColor: t.colors.$neutralAlpha100, borderRadius: t.radii.$md, + boxShadow: t.shadows.$tableBodyShadow, })} > 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} ); - case 'SubscriptionDetails': - return ( - - {children} - - ); - 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) ? ( 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} > - 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 ? ( + 40} + title={subscription.plan.name} + rounded={false} + imageUrl={subscription.plan.avatarUrl} + /> + ) : null} 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 ( ({ @@ -307,7 +308,6 @@ function SubscriptionDetailsSummary() { ({ display: 'flex', alignItems: 'center', @@ -317,15 +317,11 @@ function SubscriptionDetailsSummary() { {anySubscription.plan.currency} - + {anySubscription.plan.currencySymbol} {anySubscription.planPeriod === 'month' ? anySubscription.plan.amountFormatted @@ -474,12 +470,14 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription return ( ({ borderRadius: t.radii.$md, boxShadow: t.shadows.$tableBodyShadow, })} > ({ padding: t.space.$3, @@ -487,12 +485,12 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription > {/* Header with name and badge */} {subscription.plan.avatarUrl ? ( 40} title={subscription.plan.name} @@ -502,6 +500,7 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription ) : null} @@ -519,7 +519,7 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription {/* Pricing details */} @@ -585,7 +585,7 @@ const DetailRow = ({ label, value }: { label: string; value: string }) => ( function SummaryItem(props: React.PropsWithChildren) { return ( ({ display: 'flex', alignItems: 'center', @@ -616,7 +616,7 @@ function SummmaryItemLabel(props: React.PropsWithChildren) { function SummmaryItemValue(props: React.PropsWithChildren) { return ( ({ 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 = `${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 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() { {upcomingSubscription - ? formatDate(upcomingSubscription.periodStart) - : formatDate(anySubscription.periodEnd)} + ? formatDate(upcomingSubscription.periodStartDate) + : anySubscription.periodEndDate + ? formatDate(anySubscription.periodEndDate) + : '-'} @@ -306,28 +308,26 @@ function SubscriptionDetailsSummary() { Next payment amount - - ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$1, - })} + ({ + display: 'flex', + alignItems: 'center', + gap: t.space.$1, + })} + > + - - {anySubscription.plan.currency} - - - {anySubscription.plan.currencySymbol} - {anySubscription.planPeriod === 'month' - ? anySubscription.plan.amountFormatted - : anySubscription.plan.annualAmountFormatted} - - + {anySubscription.plan.currency} + + + {anySubscription.plan.currencySymbol} + {anySubscription.planPeriod === 'month' + ? anySubscription.plan.amountFormatted + : anySubscription.plan.annualAmountFormatted} + @@ -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 && ( )} ) : ( )} @@ -567,6 +567,7 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription // Helper component for detail rows const DetailRow = ({ label, value }: { label: string; value: string }) => ( ({ @@ -577,8 +578,13 @@ const DetailRow = ({ label, value }: { label: string; value: string }) => ( borderBlockStartColor: t.colors.$neutralAlpha100, })} > - {label} - {value} + {label} + + {value} + ); @@ -613,15 +619,19 @@ function SummmaryItemLabel(props: React.PropsWithChildren) { ); } -function SummmaryItemValue(props: React.PropsWithChildren) { +function SummmaryItemValue(props: Parameters[0]) { return ( ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$0x25, - })} + {...props} + sx={[ + t => ({ + display: 'flex', + alignItems: 'center', + gap: t.space.$0x25, + }), + props.sx, + ]} > {props.children} 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 = `${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 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, }} > - + { ? 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() { > - Current billing cycle + - {activeSubscription.planPeriod === 'month' ? 'Monthly' : 'Annually'} + - Next payment on + {t(localizationKeys('commerce.subscriptionDetails.nextPaymentOn'))} @@ -306,7 +319,10 @@ function SubscriptionDetailsSummary() { - Next payment amount + ({ @@ -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 ({ @@ -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 ( {/* The free plan does not have a period end date */} {subscription.periodEndDate && ( )} ) : ( )} @@ -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 }) => ( ( borderBlockStartColor: t.colors.$neutralAlpha100, })} > - {label} + ; 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 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 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 ( - + + + ); 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 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 = ({ ) : null} - - {/* {!plan.isDefault && !isDefaultPlanImplicitlyActiveOrUpcoming ? ( - - - )} + /> + ))} 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; appearance?: Appearance; @@ -345,53 +344,44 @@ export const usePlansContext = () => { return; }, []); + const openSubscriptionDetails = useCallback( + (event?: React.MouseEvent) => { + 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 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 ? ( ) : ( 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 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} {sortedSubscriptions.length > 1 || !!subscription.canceledAtDate ? ( + // here ) : null} + {(!subscription.plan.isDefault || subscription.status === 'upcoming') && ( + // here { 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 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 ? ( ) : ( 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 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} {sortedSubscriptions.length > 1 || !!subscription.canceledAtDate ? ( - // here ) : null} - {(!subscription.plan.isDefault || subscription.status === 'upcoming') && ( - // here { 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 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) > - - ({ - padding: t.space.$4, - overflowY: 'auto', - })} - > - {/* Subscription Cards */} - {subscriptions?.map(subscriptionItem => ( - - ))} - + ({ + display: 'flex', + flexDirection: 'column', + flex: 1, + overflowY: 'auto', + padding: t.space.$4, + gap: t.space.$4, + })} + > + {/* Subscription Cards */} + {subscriptions?.map(subscriptionItem => ( + + ))} @@ -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 ( ({ - width: t.sizes.$6, - height: t.sizes.$6, - })} - elementDescriptor={[descriptors.menuButton, descriptors.menuButtonEllipsis]} - > - ({ - width: t.sizes.$4, - height: t.sizes.$4, - opacity: t.opacity.$inactive, - })} - /> - - } + variant='bordered' actions={actions} /> ); @@ -519,12 +497,13 @@ const SubscriptionCard = ({ subscription }: { subscription: CommerceSubscription ({ + fontSize: t.fontSizes.$lg, + fontWeight: t.fontWeights.$semibold, + color: t.colors.$colorText, marginInlineEnd: 'auto', - }} + })} > {subscription.plan.name} 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(({ title, children, interface BodyProps extends React.HTMLAttributes { children: React.ReactNode; + sx?: ThemableCssProp; } const Body = React.forwardRef(({ children, ...props }, ref) => { @@ -346,13 +347,16 @@ const Body = React.forwardRef(({ children, ...props } ({ + 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 ( `${isOpen ? 'Close' : 'Open'} menu`}> - {props.trigger || ( - - )} + {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; menuButtonEllipsis: WithOptions; + menuButtonEllipsisBordered: WithOptions; menuList: WithOptions; menuItem: WithOptions; From 0ed99f14f6e917b6628694e410b69adaefc36f74 Mon Sep 17 00:00:00 2001 From: panteliselef 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 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 => { + 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> => { 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 { + 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>; getPlans: (params?: GetPlansParams) => Promise; getPlan: (params: { id: string }) => Promise; + getSubscription: (params: GetSubscriptionsParams) => Promise; + /** + * @deprecated Use `getSubscription` + */ getSubscriptions: (params: GetSubscriptionsParams) => Promise>; getStatements: (params: GetStatementsParams) => Promise>; startCheckout: (params: CreateCheckoutParams) => Promise; From 7db88a9606cbce3b8225b7c38545eceb8841fd70 Mon Sep 17 00:00:00 2001 From: panteliselef 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 => { - 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> => { 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 { - 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>; getPlans: (params?: GetPlansParams) => Promise; getPlan: (params: { id: string }) => Promise; - getSubscription: (params: GetSubscriptionsParams) => Promise; - /** - * @deprecated Use `getSubscription` - */ getSubscriptions: (params: GetSubscriptionsParams) => Promise>; getStatements: (params: GetStatementsParams) => Promise>; startCheckout: (params: CreateCheckoutParams) => Promise; From 257a3cc5254bcc1c3e2f544ae0a4816b89407ddb Mon Sep 17 00:00:00 2001 From: panteliselef 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 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 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 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 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 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 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 ( - {/* TODO: type assertion is a hack, make FAPI stricter */} !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 ( - ({ - paddingBlock: t.space.$1, - })} - > - - - - - - - - - - - {t(localizationKeys('commerce.subscriptionDetails.nextPaymentOn'))} - - - - {upcomingSubscription + + + + + + + + - - - - - - - ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$1, - })} - > - - {anySubscription.plan.currency} - - - {anySubscription.plan.currencySymbol} - {anySubscription.planPeriod === 'month' + : '-' + } + /> + + + + - - - + : anySubscription.plan.annualAmountFormatted + }`} + /> + + ); } @@ -591,53 +555,3 @@ const DetailRow = ({ label, value }: { label: LocalizationKey; value: string }) ); - -function SummaryItem(props: React.PropsWithChildren) { - return ( - - {props.children} - - ); -} - -function SummmaryItemLabel(props: React.PropsWithChildren) { - return ( - ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$1x5, - })} - > - {props.children} - - ); -} - -function SummmaryItemValue(props: Parameters[0]) { - return ( - ({ - display: 'flex', - alignItems: 'center', - gap: t.space.$0x25, - }), - props.sx, - ]} - > - {props.children} - - ); -} 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(({ title, descr ...common.textVariants(t)[textVariant], })} > - ({ - display: 'inline-flex', - alignItems: 'center', - gap: t.space.$1, - })} - > - {icon ? ( - - ) : null} - - + {title ? ( + ({ + display: 'inline-flex', + alignItems: 'center', + gap: t.space.$1, + })} + > + {icon ? ( + + ) : null} + + + ) : null} {description ? (