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. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 53895dc7e9b..c057b996f26 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,11 +1,11 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "614kB" }, + { "path": "./dist/clerk.js", "maxSize": "616kB" }, { "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*.legacy.*.js", "maxSize": "113.72KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "111KB" }, + { "path": "./dist/ui-common*.legacy.*.js", "maxSize": "115KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, { "path": "./dist/stripe-vendors*.js", "maxSize": "1KB" }, diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 2a216b7b6d0..f4cf76950df 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -21,6 +21,7 @@ import type { __internal_ComponentNavigationContext, __internal_OAuthConsentProps, __internal_PlanDetailsProps, + __internal_SubscriptionDetailsProps, __internal_UserVerificationModalProps, APIKeysNamespace, APIKeysProps, @@ -602,7 +603,7 @@ export class Clerk implements ClerkInterface { void this.#componentControls.ensureMounted().then(controls => controls.closeDrawer('checkout')); }; - public __internal_openPlanDetails = (props?: __internal_PlanDetailsProps): void => { + public __internal_openPlanDetails = (props: __internal_PlanDetailsProps): void => { this.assertComponentsReady(this.#componentControls); if (disabledBillingFeature(this, this.environment)) { if (this.#instanceType === 'development') { @@ -615,6 +616,8 @@ export class Clerk implements ClerkInterface { void this.#componentControls .ensureMounted({ preloadHint: 'PlanDetails' }) .then(controls => controls.openDrawer('planDetails', props || {})); + + this.telemetry?.record(eventPrebuiltComponentOpened(`PlanDetails`, props)); }; public __internal_closePlanDetails = (): void => { @@ -622,6 +625,18 @@ export class Clerk implements ClerkInterface { void this.#componentControls.ensureMounted().then(controls => controls.closeDrawer('planDetails')); }; + public __internal_openSubscriptionDetails = (props?: __internal_SubscriptionDetailsProps): void => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls + .ensureMounted({ preloadHint: 'SubscriptionDetails' }) + .then(controls => controls.openDrawer('subscriptionDetails', props || {})); + }; + + public __internal_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/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index 5d3cca0b7dd..45dfd1cb763 100644 --- a/packages/clerk-js/src/ui/Components.tsx +++ b/packages/clerk-js/src/ui/Components.tsx @@ -2,6 +2,7 @@ import { createDeferredPromise } from '@clerk/shared/utils'; import type { __internal_CheckoutProps, __internal_PlanDetailsProps, + __internal_SubscriptionDetailsProps, __internal_UserVerificationProps, Appearance, Clerk, @@ -36,7 +37,7 @@ import { UserVerificationModal, WaitlistModal, } from './lazyModules/components'; -import { MountedCheckoutDrawer, MountedPlanDetailDrawer } from './lazyModules/drawers'; +import { MountedCheckoutDrawer, MountedPlanDetailDrawer, MountedSubscriptionDetailDrawer } from './lazyModules/drawers'; import { LazyComponentRenderer, LazyImpersonationFabProvider, @@ -106,16 +107,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_SubscriptionDetailsProps + : never, ) => void; closeDrawer: ( - drawer: 'checkout' | 'planDetails', + drawer: 'checkout' | 'planDetails' | 'subscriptionDetails', options?: { notify?: boolean; }, @@ -160,6 +163,10 @@ interface ComponentsState { open: false; props: null | __internal_PlanDetailsProps; }; + subscriptionDetailsDrawer: { + open: false; + props: null | __internal_SubscriptionDetailsProps; + }; nodes: Map; impersonationFab: boolean; } @@ -249,6 +256,10 @@ const Components = (props: ComponentsProps) => { open: false, props: null, }, + subscriptionDetailsDrawer: { + open: false, + props: null, + }, nodes: new Map(), impersonationFab: false, }); @@ -265,6 +276,7 @@ const Components = (props: ComponentsProps) => { blankCaptchaModal, checkoutDrawer, planDetailsDrawer, + subscriptionDetailsDrawer, nodes, } = state; @@ -588,6 +600,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 a64274a1c15..5f570143e2a 100644 --- a/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx +++ b/packages/clerk-js/src/ui/components/Plans/PlanDetails.tsx @@ -1,121 +1,64 @@ -import { useClerk, useOrganization } from '@clerk/shared/react'; -import type { - __internal_PlanDetailsProps, - ClerkAPIError, - ClerkRuntimeError, - CommercePlanResource, - CommerceSubscriptionPlanPeriod, - CommerceSubscriptionResource, -} from '@clerk/types'; +import { useClerk } from '@clerk/shared/react'; +import type { __internal_PlanDetailsProps, CommercePlanResource, CommerceSubscriptionPlanPeriod } 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 { handleError } from '@/ui/utils/errorHandler'; -import { useProtect } from '../../common'; -import { SubscriberTypeContext, usePlansContext, useSubscriberTypeContext, useSubscriptions } from '../../contexts'; -import { Badge, Box, Button, Col, descriptors, Flex, Heading, localizationKeys, Span, Text } from '../../customizables'; +import { SubscriberTypeContext } from '../../contexts'; +import { Box, Col, descriptors, Flex, Heading, localizationKeys, Span, Spinner, Text } from '../../customizables'; export const PlanDetails = (props: __internal_PlanDetailsProps) => { return ( - - - - - + + + ); }; const PlanDetailsInternal = ({ - plan, - onSubscriptionCancel, - portalRoot, + planId, + plan: initialPlan, initialPlanPeriod = 'month', }: __internal_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 we are handling it above + () => 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 ( - <> + !hasFeatures @@ -129,7 +72,6 @@ const PlanDetailsInternal = ({ >
} @@ -206,129 +148,7 @@ const PlanDetailsInternal = ({ ) : null} - - {(!plan.isDefault && !isDefaultPlanImplicitlyActiveOrUpcoming) || !subscription ? ( - - {subscription ? ( - subscription.canceledAtDate ? ( - - )} + /> + ))} diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index d09ba7c7e1f..2cada4a7da1 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -108,7 +108,6 @@ export function ComponentContextProvider({ {children} ); - default: throw new Error(`Unknown component context: ${componentName}`); } diff --git a/packages/clerk-js/src/ui/contexts/components/Plans.tsx b/packages/clerk-js/src/ui/contexts/components/Plans.tsx index f4fb6710a20..56b69be7ad4 100644 --- a/packages/clerk-js/src/ui/contexts/components/Plans.tsx +++ b/packages/clerk-js/src/ui/contexts/components/Plans.tsx @@ -99,7 +99,6 @@ export const usePlans = () => { type HandleSelectPlanProps = { plan: CommercePlanResource; planPeriod: CommerceSubscriptionPlanPeriod; - onSubscriptionChange?: () => void; mode?: 'modal' | 'mounted'; event?: React.MouseEvent; appearance?: Appearance; @@ -294,55 +293,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_openPlanDetails({ - plan, - initialPlanPeriod: planPeriod, - 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(() => { @@ -355,6 +343,7 @@ export const usePlansContext = () => { activeOrUpcomingSubscriptionBasedOnPlanPeriod: activeOrUpcomingSubscriptionWithPlanPeriod, isDefaultPlanImplicitlyActiveOrUpcoming, handleSelectPlan, + openSubscriptionDetails, buttonPropsForPlan, canManageSubscription, captionForSubscription, 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/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/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts index 0dec7cc9338..cd7ffe647ae 100644 --- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts +++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts @@ -388,6 +388,7 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'statementCopyButton', 'menuButton', 'menuButtonEllipsis', + 'menuButtonEllipsisBordered', 'menuList', 'menuItem', @@ -482,6 +483,17 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([ 'apiKeysRevokeModal', 'apiKeysRevokeModalInput', 'apiKeysRevokeModalSubmitButton', + + 'subscriptionDetailsCard', + 'subscriptionDetailsCardHeader', + 'subscriptionDetailsCardBadge', + 'subscriptionDetailsCardTitle', + 'subscriptionDetailsCardBody', + 'subscriptionDetailsCardFooter', + 'subscriptionDetailsCardActions', + 'subscriptionDetailsDetailRow', + 'subscriptionDetailsDetailRowLabel', + 'subscriptionDetailsDetailRowValue', ] as const).map(camelize) as (keyof ElementsConfig)[]; type TargettableClassname = `${typeof CLASS_PREFIX}${K}`; 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/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 ? ( { - 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`}> diff --git a/packages/clerk-js/src/ui/elements/contexts/index.tsx b/packages/clerk-js/src/ui/elements/contexts/index.tsx index efa927e5593..c91fb670ff1 100644 --- a/packages/clerk-js/src/ui/elements/contexts/index.tsx +++ b/packages/clerk-js/src/ui/elements/contexts/index.tsx @@ -88,7 +88,8 @@ export type FlowMetadata = { | 'planDetails' | 'pricingTable' | 'apiKeys' - | 'oauthConsent'; + | 'oauthConsent' + | 'subscriptionDetails'; part?: | 'start' | 'emailCode' diff --git a/packages/clerk-js/src/ui/lazyModules/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/MountedPlanDetailDrawer.tsx b/packages/clerk-js/src/ui/lazyModules/MountedPlanDetailDrawer.tsx index 2d16fd8e4d9..3b703f1c4f2 100644 --- a/packages/clerk-js/src/ui/lazyModules/MountedPlanDetailDrawer.tsx +++ b/packages/clerk-js/src/ui/lazyModules/MountedPlanDetailDrawer.tsx @@ -36,12 +36,7 @@ export function MountedPlanDetailDrawer({ portalId={planDetailsDrawer.props.portalId} portalRoot={planDetailsDrawer.props.portalRoot as HTMLElement | null | undefined} > - {})} - appearance={planDetailsDrawer.props.appearance} - /> + ); } diff --git a/packages/clerk-js/src/ui/lazyModules/MountedSubscriptionDetailDrawer.tsx b/packages/clerk-js/src/ui/lazyModules/MountedSubscriptionDetailDrawer.tsx new file mode 100644 index 00000000000..53835c691f7 --- /dev/null +++ b/packages/clerk-js/src/ui/lazyModules/MountedSubscriptionDetailDrawer.tsx @@ -0,0 +1,42 @@ +import { useUser } from '@clerk/shared/react'; +import type { __internal_SubscriptionDetailsProps, Appearance } from '@clerk/types'; + +import { SubscriptionDetails } from '../components/SubscriptionDetails'; +import { LazyDrawerRenderer } from './providers'; + +export function MountedSubscriptionDetailDrawer({ + appearance, + subscriptionDetailsDrawer, + onOpenChange, +}: { + appearance?: Appearance; + onOpenChange: (open: boolean) => void; + subscriptionDetailsDrawer: { + open: false; + props: null | __internal_SubscriptionDetailsProps; + }; +}) { + const { user } = useUser(); + if (!subscriptionDetailsDrawer.props) { + return null; + } + + return ( + + + + ); +} diff --git a/packages/clerk-js/src/ui/lazyModules/components.ts b/packages/clerk-js/src/ui/lazyModules/components.ts index d0c38843dc2..daa94dc0368 100644 --- a/packages/clerk-js/src/ui/lazyModules/components.ts +++ b/packages/clerk-js/src/ui/lazyModules/components.ts @@ -20,7 +20,8 @@ const componentImportPaths = { PricingTable: () => import(/* webpackChunkName: "pricingTable" */ '../components/PricingTable'), Checkout: () => import(/* webpackChunkName: "checkout" */ '../components/Checkout'), SessionTasks: () => import(/* webpackChunkName: "sessionTasks" */ '../components/SessionTasks'), - PlanDetails: () => import(/* webpackChunkName: "planDetails" */ '../components/Plans'), + PlanDetails: () => import(/* webpackChunkName: "planDetails" */ '../components/Plans/PlanDetails'), + SubscriptionDetails: () => import(/* webpackChunkName: "subscriptionDetails" */ '../components/SubscriptionDetails'), APIKeys: () => import(/* webpackChunkName: "apiKeys" */ '../components/ApiKeys/ApiKeys'), OAuthConsent: () => import(/* webpackChunkName: "oauthConsent" */ '../components/OAuthConsent/OAuthConsent'), } as const; @@ -106,6 +107,10 @@ export const PlanDetails = lazy(() => componentImportPaths.PlanDetails().then(module => ({ default: module.PlanDetails })), ); +export const SubscriptionDetails = lazy(() => + componentImportPaths.SubscriptionDetails().then(module => ({ default: module.SubscriptionDetails })), +); + export const OAuthConsent = lazy(() => componentImportPaths.OAuthConsent().then(module => ({ default: module.OAuthConsent })), ); @@ -143,6 +148,7 @@ export const ClerkComponents = { PlanDetails, APIKeys, OAuthConsent, + SubscriptionDetails, }; export type ClerkComponentName = keyof typeof ClerkComponents; diff --git a/packages/clerk-js/src/ui/lazyModules/drawers.tsx b/packages/clerk-js/src/ui/lazyModules/drawers.tsx index 3e05ccdddef..7022e169c1d 100644 --- a/packages/clerk-js/src/ui/lazyModules/drawers.tsx +++ b/packages/clerk-js/src/ui/lazyModules/drawers.tsx @@ -6,3 +6,9 @@ export const MountedCheckoutDrawer = lazy(() => export const MountedPlanDetailDrawer = lazy(() => import('./MountedPlanDetailDrawer').then(module => ({ default: module.MountedPlanDetailDrawer })), ); + +export const MountedSubscriptionDetailDrawer = lazy(() => + import('./MountedSubscriptionDetailDrawer').then(module => ({ + default: module.MountedSubscriptionDetailDrawer, + })), +); diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index 5c13694a8b8..9673d239350 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -2,6 +2,7 @@ import type { __internal_CheckoutProps, __internal_OAuthConsentProps, __internal_PlanDetailsProps, + __internal_SubscriptionDetailsProps, __internal_UserVerificationProps, APIKeysProps, CreateOrganizationProps, @@ -50,6 +51,7 @@ export type AvailableComponentProps = | PricingTableProps | __internal_CheckoutProps | __internal_UserVerificationProps + | __internal_SubscriptionDetailsProps | __internal_PlanDetailsProps | APIKeysProps; @@ -139,6 +141,14 @@ export type OAuthConsentCtx = __internal_OAuthConsentProps & { componentName: 'OAuthConsent'; }; +export type SubscriptionDetailsCtx = __internal_SubscriptionDetailsProps & { + componentName: 'SubscriptionDetails'; +}; + +export type PlanDetailsCtx = __internal_PlanDetailsProps & { + componentName: 'PlanDetails'; +}; + export type AvailableComponentCtx = | SignInCtx | SignUpCtx @@ -154,5 +164,7 @@ export type AvailableComponentCtx = | PricingTableCtx | CheckoutCtx | APIKeysCtx - | OAuthConsentCtx; + | OAuthConsentCtx + | SubscriptionDetailsCtx + | PlanDetailsCtx; 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..2b82dbaea6e 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, @@ -89,7 +90,7 @@ const unboundCreateFixtures = ( const MockClerkProvider = (props: any) => { const { children } = props; - const componentsWithoutContext = ['UsernameSection', 'UserProfileSection']; + const componentsWithoutContext = ['UsernameSection', 'UserProfileSection', 'SubscriptionDetails', 'PlanDetails']; const contextWrappedChildren = !componentsWithoutContext.includes(componentName) ? ( { mockMethodsOf(session, { exclude: ['checkAuthorization'], diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index 9e04afbb058..fa721a53773 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -120,6 +120,16 @@ export const enUS: LocalizationResource = { billingCycle: 'Billing cycle', included: 'Included', }, + subscriptionDetails: { + title: 'Subscription', + currentBillingCycle: 'Current billing cycle', + nextPaymentOn: 'Next payment on', + nextPaymentAmount: 'Next payment amount', + subscribedOn: 'Subscribed on', + endsOn: 'Ends on', + renewsAt: 'Renews at', + beginsOn: 'Begins on', + }, reSubscribe: 'Resubscribe', seeAllFeatures: 'See all features', subscribe: 'Subscribe', @@ -127,6 +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}} / month', + switchToAnnualWithAnnualPrice: 'Switch to annual {{currency}}{{price}} / year', totalDue: 'Total due', totalDueToday: 'Total Due Today', viewFeatures: 'View features', diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 37b7b93d24f..09dd59fa1b2 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -6,6 +6,7 @@ import type { __internal_CheckoutProps, __internal_OAuthConsentProps, __internal_PlanDetailsProps, + __internal_SubscriptionDetailsProps, __internal_UserVerificationModalProps, __internal_UserVerificationProps, APIKeysNamespace, @@ -119,7 +120,8 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { private preopenUserVerification?: null | __internal_UserVerificationProps = null; private preopenSignIn?: null | SignInProps = null; private preopenCheckout?: null | __internal_CheckoutProps = null; - private preopenPlanDetails?: null | __internal_PlanDetailsProps = null; + private preopenPlanDetails: null | __internal_PlanDetailsProps = null; + private preopenSubscriptionDetails: null | __internal_SubscriptionDetailsProps = null; private preopenSignUp?: null | SignUpProps = null; private preopenUserProfile?: null | UserProfileProps = null; private preopenOrganizationProfile?: null | OrganizationProfileProps = null; @@ -560,6 +562,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { clerkjs.__internal_openPlanDetails(this.preopenPlanDetails); } + if (this.preopenSubscriptionDetails !== null) { + clerkjs.__internal_openSubscriptionDetails(this.preopenSubscriptionDetails); + } + if (this.preopenSignUp !== null) { clerkjs.openSignUp(this.preopenSignUp); } @@ -776,7 +782,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; - __internal_openPlanDetails = (props?: __internal_PlanDetailsProps) => { + __internal_openPlanDetails = (props: __internal_PlanDetailsProps) => { if (this.clerkjs && this.loaded) { this.clerkjs.__internal_openPlanDetails(props); } else { @@ -792,6 +798,22 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } }; + __internal_openSubscriptionDetails = (props?: __internal_SubscriptionDetailsProps) => { + if (this.clerkjs && this.loaded) { + this.clerkjs.__internal_openSubscriptionDetails(props); + } else { + this.preopenSubscriptionDetails = props ?? null; + } + }; + + __internal_closeSubscriptionDetails = () => { + if (this.clerkjs && this.loaded) { + this.clerkjs.__internal_closeSubscriptionDetails(); + } else { + this.preopenSubscriptionDetails = null; + } + }; + __internal_openReverification = (props?: __internal_UserVerificationModalProps) => { if (this.clerkjs && this.loaded) { this.clerkjs.__internal_openReverification(props); diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts index a4e427dfd3b..17c872df239 100644 --- a/packages/types/src/appearance.ts +++ b/packages/types/src/appearance.ts @@ -513,6 +513,7 @@ export type ElementsConfig = { statementCopyButton: WithOptions; menuButton: WithOptions; menuButtonEllipsis: WithOptions; + menuButtonEllipsisBordered: WithOptions; menuList: WithOptions; menuItem: WithOptions; @@ -609,6 +610,17 @@ export type ElementsConfig = { apiKeysRevokeModal: WithOptions; apiKeysRevokeModalInput: WithOptions; apiKeysRevokeModalSubmitButton: WithOptions; + + subscriptionDetailsCard: WithOptions; + subscriptionDetailsCardHeader: WithOptions; + subscriptionDetailsCardBadge: WithOptions; + subscriptionDetailsCardTitle: WithOptions; + subscriptionDetailsCardBody: WithOptions; + subscriptionDetailsCardFooter: WithOptions; + subscriptionDetailsCardActions: WithOptions; + subscriptionDetailsDetailRow: WithOptions; + subscriptionDetailsDetailRowLabel: WithOptions; + subscriptionDetailsDetailRowValue: WithOptions; }; export type Elements = { @@ -870,6 +882,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 1220e102bfa..3283aee5b92 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -13,6 +13,7 @@ import type { PricingTableTheme, SignInTheme, SignUpTheme, + SubscriptionDetailsTheme, UserButtonTheme, UserProfileTheme, UserVerificationTheme, @@ -273,15 +274,27 @@ export interface Clerk { /** * Opens the Clerk PlanDetails drawer component in a drawer. - * @param props Optional subscription details drawer configuration parameters. + * @param props `plan` or `planId` parameters are required. */ - __internal_openPlanDetails: (props?: __internal_PlanDetailsProps) => void; + __internal_openPlanDetails: (props: __internal_PlanDetailsProps) => void; /** * Closes the Clerk PlanDetails drawer. */ __internal_closePlanDetails: () => void; + /** + * Opens the Clerk SubscriptionDetails drawer component in a drawer. + * @param props Optional configuration parameters. + */ + __internal_openSubscriptionDetails: (props?: __internal_SubscriptionDetailsProps) => void; + + /** + * Closes the Clerk SubscriptionDetails drawer. + */ + __internal_closeSubscriptionDetails: () => void; + + /** /** Opens the Clerk UserVerification component in a modal. * @param props Optional user verification configuration parameters. */ @@ -1820,8 +1833,20 @@ export type __internal_CheckoutProps = { export type __internal_PlanDetailsProps = { appearance?: PlanDetailTheme; plan?: CommercePlanResource; - subscriberType?: CommerceSubscriberType; + planId?: string; initialPlanPeriod?: CommerceSubscriptionPlanPeriod; + portalId?: string; + portalRoot?: PortalRoot; +}; + +export type __internal_SubscriptionDetailsProps = { + /** + * The subscriber type to display the subscription details for. + * If `org` is provided, the subscription details will be displayed for the active organization. + * @default 'user' + */ + for?: CommerceSubscriberType; + appearance?: SubscriptionDetailsTheme; onSubscriptionCancel?: () => void; portalId?: string; portalRoot?: PortalRoot; diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 16a1002f4b6..cf1ff595d1c 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; @@ -196,6 +198,16 @@ export type __internal_LocalizationResource = { cancelSubscriptionNoCharge: LocalizationValue; cancelSubscriptionAccessUntil: LocalizationValue<'plan' | 'date'>; popular: LocalizationValue; + subscriptionDetails: { + title: LocalizationValue; + currentBillingCycle: LocalizationValue; + nextPaymentOn: LocalizationValue; + nextPaymentAmount: LocalizationValue; + subscribedOn: LocalizationValue; + endsOn: LocalizationValue; + renewsAt: LocalizationValue; + beginsOn: LocalizationValue; + }; monthly: LocalizationValue; annually: LocalizationValue; cannotSubscribeMonthly: LocalizationValue;