diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index d67438da176..e48ac39362d 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,40 @@ 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); + 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/core/resources/CommerceSubscription.ts b/packages/clerk-js/src/core/resources/CommerceSubscription.ts index aa45426968a..26ad8a2f74d 100644 --- a/packages/clerk-js/src/core/resources/CommerceSubscription.ts +++ b/packages/clerk-js/src/core/resources/CommerceSubscription.ts @@ -9,6 +9,7 @@ import type { } from '@clerk/types'; import { commerceMoneyFromJSON } from '../../utils'; +import { unixEpochToDate } from '../../utils/date'; import { BaseResource, CommercePlan, DeletedObject } from './internal'; export class CommerceSubscription extends BaseResource implements CommerceSubscriptionResource { @@ -17,9 +18,10 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr plan!: CommercePlan; planPeriod!: CommerceSubscriptionPlanPeriod; status!: CommerceSubscriptionStatus; - periodStart!: number; - periodEnd!: number; - canceledAt!: number | null; + createdAt!: Date; + periodStart!: Date; + periodEnd!: Date; + canceledAt!: Date | null; amount?: CommerceMoney; credit?: { amount: CommerceMoney; @@ -39,9 +41,10 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr this.plan = new CommercePlan(data.plan); this.planPeriod = data.plan_period; this.status = data.status; - this.periodStart = data.period_start; - this.periodEnd = data.period_end; - this.canceledAt = data.canceled_at; + this.createdAt = unixEpochToDate(data.created_at); + this.periodStart = unixEpochToDate(data.period_start); + this.periodEnd = unixEpochToDate(data.period_end); + this.canceledAt = data.canceled_at ? unixEpochToDate(data.canceled_at) : null; this.amount = data.amount ? commerceMoneyFromJSON(data.amount) : undefined; this.credit = data.credit && data.credit.amount ? { amount: commerceMoneyFromJSON(data.credit.amount) } : undefined; return this; diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index 5d3cca0b7dd..59b124f1fb9 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, @@ -36,7 +38,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 +108,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 +162,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 +257,10 @@ const Components = (props: ComponentsProps) => { open: false, props: null, }, + subscriptionDetailsDrawer: { + open: false, + props: null, + }, nodes: new Map(), impersonationFab: false, }); @@ -265,6 +277,7 @@ const Components = (props: ComponentsProps) => { blankCaptchaModal, checkoutDrawer, planDetailsDrawer, + subscriptionDetailsDrawer, nodes, } = state; @@ -588,6 +601,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 ? ( -