diff --git a/.changeset/yellow-hairs-refuse.md b/.changeset/yellow-hairs-refuse.md new file mode 100644 index 00000000000..3a8e2cb8cb4 --- /dev/null +++ b/.changeset/yellow-hairs-refuse.md @@ -0,0 +1,8 @@ +--- +'@clerk/localizations': patch +'@clerk/clerk-js': patch +'@clerk/shared': patch +'@clerk/types': patch +--- + +Add billing page to `OrgProfile`, use new `usePlans` hook, and adds new subscription methods diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index f747ecd60b7..1cbfaca43fe 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,7 +1,7 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "580.7kB" }, - { "path": "./dist/clerk.browser.js", "maxSize": "79.25kB" }, + { "path": "./dist/clerk.js", "maxSize": "581.5kB" }, + { "path": "./dist/clerk.browser.js", "maxSize": "79.30kB" }, { "path": "./dist/clerk.headless.js", "maxSize": "55KB" }, { "path": "./dist/ui-common*.js", "maxSize": "96KB" }, { "path": "./dist/vendors*.js", "maxSize": "30KB" }, diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index b1c129f43e4..72d6ef7ddcc 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -83,6 +83,7 @@ import { createAllowedRedirectOrigins, createBeforeUnloadTracker, createPageLifecycle, + disabledCommerceFeature, disabledOrganizationsFeature, errorThrower, generateSignatureWithCoinbaseWallet, @@ -921,6 +922,14 @@ export class Clerk implements ClerkInterface { public __experimental_mountPricingTable = (node: HTMLDivElement, props?: __experimental_PricingTableProps): void => { this.assertComponentsReady(this.#componentControls); + if (disabledCommerceFeature(this, this.environment)) { + if (this.#instanceType === 'development') { + throw new ClerkRuntimeError(warnings.cannotRenderAnyCommerceComponent('PricingTable'), { + code: 'cannot_render_commerce_disabled', + }); + } + return; + } void this.#componentControls.ensureMounted({ preloadHint: 'PricingTable' }).then(controls => controls.mountComponent({ name: 'PricingTable', diff --git a/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts b/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts index 639441bab83..f6b2ec40717 100644 --- a/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts +++ b/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts @@ -3,26 +3,47 @@ import type { __experimental_CommerceCheckoutJSON, __experimental_CommercePlanResource, __experimental_CommerceProductJSON, + __experimental_CommerceSubscriptionJSON, + __experimental_CommerceSubscriptionResource, __experimental_CreateCheckoutParams, __experimental_GetPlansParams, ClerkPaginatedResponse, } from '@clerk/types'; -import { convertPageToOffsetSearchParams } from '../../../utils/convertPageToOffsetSearchParams'; -import { __experimental_CommerceCheckout, __experimental_CommercePlan, BaseResource } from '../../resources/internal'; +import { + __experimental_CommerceCheckout, + __experimental_CommercePlan, + __experimental_CommerceSubscription, + BaseResource, +} from '../../resources/internal'; export class __experimental_CommerceBilling implements __experimental_CommerceBillingNamespace { getPlans = async (params?: __experimental_GetPlansParams): Promise<__experimental_CommercePlanResource[]> => { const { data: products } = (await BaseResource._fetch({ path: `/commerce/products`, method: 'GET', - search: convertPageToOffsetSearchParams(params), + search: { payerType: params?.subscriberType || '' }, })) as unknown as ClerkPaginatedResponse<__experimental_CommerceProductJSON>; const defaultProduct = products.find(product => product.is_default); return defaultProduct?.plans.map(plan => new __experimental_CommercePlan(plan)) || []; }; + getSubscriptions = async (): Promise> => { + return await BaseResource._fetch({ + path: `/me/subscriptions`, + method: 'GET', + }).then(res => { + const { data: subscriptions, total_count } = + res?.response as unknown as ClerkPaginatedResponse<__experimental_CommerceSubscriptionJSON>; + + return { + total_count, + data: subscriptions.map(subscription => new __experimental_CommerceSubscription(subscription)), + }; + }); + }; + startCheckout = async (params: __experimental_CreateCheckoutParams) => { const json = ( await BaseResource._fetch<__experimental_CommerceCheckoutJSON>({ diff --git a/packages/clerk-js/src/core/resources/CommerceCheckout.ts b/packages/clerk-js/src/core/resources/CommerceCheckout.ts index 29c4fa38932..b1f5be96a37 100644 --- a/packages/clerk-js/src/core/resources/CommerceCheckout.ts +++ b/packages/clerk-js/src/core/resources/CommerceCheckout.ts @@ -1,6 +1,7 @@ import type { __experimental_CommerceCheckoutJSON, __experimental_CommerceCheckoutResource, + __experimental_CommerceSubscriptionPlanPeriod, __experimental_CommerceTotals, __experimental_ConfirmCheckoutParams, } from '@clerk/types'; @@ -23,7 +24,7 @@ export class __experimental_CommerceCheckout extends BaseResource implements __e invoice?: __experimental_CommerceInvoice; paymentSource?: __experimental_CommercePaymentSource; plan!: __experimental_CommercePlan; - planPeriod!: string; + planPeriod!: __experimental_CommerceSubscriptionPlanPeriod; status!: string; subscription?: __experimental_CommerceSubscription; totals!: __experimental_CommerceTotals; diff --git a/packages/clerk-js/src/core/resources/CommercePlan.ts b/packages/clerk-js/src/core/resources/CommercePlan.ts index c3acf458714..d2b123e646a 100644 --- a/packages/clerk-js/src/core/resources/CommercePlan.ts +++ b/packages/clerk-js/src/core/resources/CommercePlan.ts @@ -12,7 +12,6 @@ export class __experimental_CommercePlan extends BaseResource implements __exper currencySymbol!: string; currency!: string; description!: string; - isActiveForPayer!: boolean; isRecurring!: boolean; hasBaseFee!: boolean; payerType!: string[]; @@ -20,6 +19,7 @@ export class __experimental_CommercePlan extends BaseResource implements __exper slug!: string; avatarUrl!: string; features!: __experimental_CommerceFeature[]; + subscriptionIdForCurrentSubscriber: string | undefined; constructor(data: __experimental_CommercePlanJSON) { super(); @@ -40,7 +40,6 @@ export class __experimental_CommercePlan extends BaseResource implements __exper this.currencySymbol = data.currency_symbol; this.currency = data.currency; this.description = data.description; - this.isActiveForPayer = data.is_active_for_payer; this.isRecurring = data.is_recurring; this.hasBaseFee = data.has_base_fee; this.payerType = data.payer_type; diff --git a/packages/clerk-js/src/core/resources/CommerceSettings.ts b/packages/clerk-js/src/core/resources/CommerceSettings.ts index 3bee52e021f..65b19ffedbf 100644 --- a/packages/clerk-js/src/core/resources/CommerceSettings.ts +++ b/packages/clerk-js/src/core/resources/CommerceSettings.ts @@ -11,6 +11,7 @@ import { BaseResource } from './internal'; */ export class __experimental_CommerceSettings extends BaseResource implements __experimental_CommerceSettingsResource { stripePublishableKey: string = ''; + enabled: boolean = false; public constructor( data: __experimental_CommerceSettingsJSON | __experimental_CommerceSettingsJSONSnapshot | null = null, @@ -26,12 +27,14 @@ export class __experimental_CommerceSettings extends BaseResource implements __e return this; } this.stripePublishableKey = data.stripe_publishable_key; + this.enabled = data.enabled; return this; } public __internal_toSnapshot(): __experimental_CommerceSettingsJSONSnapshot { return { stripe_publishable_key: this.stripePublishableKey, + enabled: this.enabled, } as unknown as __experimental_CommerceSettingsJSONSnapshot; } } diff --git a/packages/clerk-js/src/core/resources/CommerceSubscription.ts b/packages/clerk-js/src/core/resources/CommerceSubscription.ts index b7f7d5bdf94..e25f95c8612 100644 --- a/packages/clerk-js/src/core/resources/CommerceSubscription.ts +++ b/packages/clerk-js/src/core/resources/CommerceSubscription.ts @@ -1,9 +1,12 @@ import type { __experimental_CommerceSubscriptionJSON, + __experimental_CommerceSubscriptionPlanPeriod, __experimental_CommerceSubscriptionResource, + __experimental_CommerceSubscriptionStatus, + DeletedObjectJSON, } from '@clerk/types'; -import { __experimental_CommercePlan, BaseResource } from './internal'; +import { __experimental_CommercePlan, BaseResource, DeletedObject } from './internal'; export class __experimental_CommerceSubscription extends BaseResource @@ -12,8 +15,8 @@ export class __experimental_CommerceSubscription id!: string; paymentSourceId!: string; plan!: __experimental_CommercePlan; - planPeriod!: string; - status!: string; + planPeriod!: __experimental_CommerceSubscriptionPlanPeriod; + status!: __experimental_CommerceSubscriptionStatus; constructor(data: __experimental_CommerceSubscriptionJSON) { super(); @@ -40,7 +43,8 @@ export class __experimental_CommerceSubscription path: `/me/commerce/subscriptions/${this.id}`, method: 'DELETE', }) - )?.response; - return json; + )?.response as unknown as DeletedObjectJSON; + + return new DeletedObject(json); } } diff --git a/packages/clerk-js/src/core/resources/Organization.ts b/packages/clerk-js/src/core/resources/Organization.ts index 3167f0a3a17..9ca83f101a0 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -1,4 +1,7 @@ import type { + __experimental_CommerceSubscriptionJSON, + __experimental_CommerceSubscriptionResource, + __experimental_GetSubscriptionsParams, AddMemberParams, ClerkPaginatedResponse, ClerkResourceReloadParams, @@ -28,7 +31,12 @@ import type { import { convertPageToOffsetSearchParams } from '../../utils/convertPageToOffsetSearchParams'; import { unixEpochToDate } from '../../utils/date'; -import { BaseResource, OrganizationInvitation, OrganizationMembership } from './internal'; +import { + __experimental_CommerceSubscription, + BaseResource, + OrganizationInvitation, + OrganizationMembership, +} from './internal'; import { OrganizationDomain } from './OrganizationDomain'; import { OrganizationMembershipRequest } from './OrganizationMembershipRequest'; import { Role } from './Role'; @@ -229,6 +237,29 @@ export class Organization extends BaseResource implements OrganizationResource { }).then(res => new OrganizationMembership(res?.response as OrganizationMembershipJSON)); }; + __experimental_getSubscriptions = async ( + getSubscriptionsParams?: __experimental_GetSubscriptionsParams, + ): Promise> => { + return await BaseResource._fetch( + { + path: `/organizations/${this.id}/subscriptions`, + method: 'GET', + search: convertPageToOffsetSearchParams(getSubscriptionsParams), + }, + { + forceUpdateClient: true, + }, + ).then(res => { + const { data: subscriptions, total_count } = + res?.response as unknown as ClerkPaginatedResponse<__experimental_CommerceSubscriptionJSON>; + + return { + total_count, + data: subscriptions.map(subscription => new __experimental_CommerceSubscription(subscription)), + }; + }); + }; + destroy = async (): Promise => { return this._baseDelete(); }; diff --git a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Environment.test.ts.snap b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Environment.test.ts.snap index 085b8619913..2259e9f58ed 100644 --- a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Environment.test.ts.snap +++ b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Environment.test.ts.snap @@ -10,6 +10,7 @@ exports[`Environment __internal_toSnapshot() 1`] = ` "single_session_mode": true, }, "commerce_settings": { + "enabled": false, "stripe_publishable_key": "", }, "display_config": { @@ -271,6 +272,7 @@ exports[`Environment __internal_toSnapshot() 1`] = ` exports[`Environment defaults values when instantiated without arguments 1`] = ` Environment { "__experimental_commerceSettings": __experimental_CommerceSettings { + "enabled": false, "pathRoot": "", "stripePublishableKey": "", }, @@ -497,6 +499,7 @@ Environment { exports[`Environment has the same initial properties 1`] = ` Environment { "__experimental_commerceSettings": __experimental_CommerceSettings { + "enabled": false, "pathRoot": "", "stripePublishableKey": "", }, diff --git a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Organization.test.ts.snap b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Organization.test.ts.snap index b1e85d79416..39122a1625d 100644 --- a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Organization.test.ts.snap +++ b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Organization.test.ts.snap @@ -2,6 +2,7 @@ exports[`Organization has the same initial properties 1`] = ` Organization { + "__experimental_getSubscriptions": [Function], "addMember": [Function], "adminDeleteEnabled": true, "createDomain": [Function], diff --git a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/OrganizationMembership.test.ts.snap b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/OrganizationMembership.test.ts.snap index d0d564b0e39..1196d8108d4 100644 --- a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/OrganizationMembership.test.ts.snap +++ b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/OrganizationMembership.test.ts.snap @@ -6,6 +6,7 @@ OrganizationMembership { "destroy": [Function], "id": "test_id", "organization": Organization { + "__experimental_getSubscriptions": [Function], "addMember": [Function], "adminDeleteEnabled": true, "createDomain": [Function], diff --git a/packages/clerk-js/src/core/warnings.ts b/packages/clerk-js/src/core/warnings.ts index 2513a4d00cc..492d4711acb 100644 --- a/packages/clerk-js/src/core/warnings.ts +++ b/packages/clerk-js/src/core/warnings.ts @@ -11,6 +11,11 @@ const createMessageForDisabledOrganizations = ( `The <${componentName}/> cannot be rendered when the feature is turned off. Visit 'dashboard.clerk.com' to enable the feature. Since the feature is turned off, this is no-op.`, ); }; +const createMessageForDisabledCommerce = (componentName: 'PricingTable' | 'Checkout') => { + return formatWarning( + `The <${componentName}/> component cannot be rendered when commerce is disabled. Visit 'https://dashboard.clerk.com/last-active?path=commerce/settings' to follow the necessary steps to enable commerce. Since commerce is disabled, this is no-op.`, + ); +}; const warnings = { cannotRenderComponentWhenSessionExists: 'The and components cannot render when a user is already signed in, unless the application allows multiple sessions. Since a user is signed in and this application only allows a single session, Clerk is redirecting to the Home URL instead.', @@ -26,6 +31,7 @@ const warnings = { ' cannot render unless a user is signed in. Since no user is signed in, this is no-op.', cannotRenderComponentWhenOrgDoesNotExist: ` cannot render unless an organization is active. Since no organization is currently active, this is no-op.`, cannotRenderAnyOrganizationComponent: createMessageForDisabledOrganizations, + cannotRenderAnyCommerceComponent: createMessageForDisabledCommerce, cannotOpenUserProfile: 'The UserProfile modal cannot render unless a user is signed in. Since no user is signed in, this is no-op.', cannotOpenSignInOrSignUp: diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 7587236d38f..f5b88296c5d 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -125,7 +125,7 @@ const CheckoutFormElements = ({ const [isSubmitting, setIsSubmitting] = useState(false); const [submitError, setSubmitError] = useState(); - const { data } = useFetch(__experimental_commerce?.getPaymentSources, {}); + const { data } = useFetch(__experimental_commerce?.getPaymentSources, 'commerce-payment-sources'); const { data: paymentSources } = data || { data: [] }; const didExpandStripePaymentMethods = useCallback(() => { diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx index cc6f48f174d..8920920f09f 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx @@ -1,4 +1,4 @@ -import type { __experimental_CheckoutProps } from '@clerk/types'; +import type { __experimental_CheckoutProps, __experimental_CommerceCheckoutResource } from '@clerk/types'; import type { Stripe } from '@stripe/stripe-js'; import { loadStripe } from '@stripe/stripe-js'; import { useEffect, useRef, useState } from 'react'; @@ -10,7 +10,7 @@ import { CheckoutComplete } from './CheckoutComplete'; import { CheckoutForm } from './CheckoutForm'; export const CheckoutPage = (props: __experimental_CheckoutProps) => { - const { planId, planPeriod } = props; + const { planId, planPeriod, orgId, onSubscriptionComplete } = props; const stripePromiseRef = useRef | null>(null); const [stripe, setStripe] = useState(null); const { __experimental_commerceSettings } = useEnvironment(); @@ -18,6 +18,7 @@ export const CheckoutPage = (props: __experimental_CheckoutProps) => { const { checkout, updateCheckout, isLoading } = useCheckout({ planId, planPeriod, + orgId, }); useEffect(() => { @@ -35,6 +36,11 @@ export const CheckoutPage = (props: __experimental_CheckoutProps) => { } }, [checkout?.externalGatewayId, __experimental_commerceSettings]); + const onCheckoutComplete = (newCheckout: __experimental_CommerceCheckoutResource) => { + updateCheckout(newCheckout); + onSubscriptionComplete?.(); + }; + if (isLoading) { return ( { ); }; diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx new file mode 100644 index 00000000000..6998ab2c89e --- /dev/null +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx @@ -0,0 +1,67 @@ +import { __experimental_PricingTableContext } from '../../contexts'; +import { Col, descriptors, localizationKeys } from '../../customizables'; +import { + Card, + Header, + Tab, + TabPanel, + TabPanels, + Tabs, + TabsList, + useCardState, + withCardStateProvider, +} from '../../elements'; +import { __experimental_PricingTable } from '../PricingTable'; + +export const OrganizationBillingPage = withCardStateProvider(() => { + const card = useCardState(); + + return ( + ({ gap: t.space.$8, color: t.colors.$colorText })} + > + + + + + + {card.error} + + + ({ gap: t.space.$6 })}> + + + + + + + <__experimental_PricingTableContext.Provider + value={{ componentName: 'PricingTable', mode: 'modal', subscriberType: 'org' }} + > + <__experimental_PricingTable /> + + + Invoices + Payment Sources + + + + + ); +}); diff --git a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx index 4a399eb6f16..a67e6f36264 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx @@ -1,12 +1,22 @@ +import { lazy, Suspense } from 'react'; + import { Protect } from '../../common'; import { CustomPageContentContainer } from '../../common/CustomPageContentContainer'; -import { useOrganizationProfileContext } from '../../contexts'; +import { useEnvironment, useOptions, useOrganizationProfileContext } from '../../contexts'; import { Route, Switch } from '../../router'; import { OrganizationGeneralPage } from './OrganizationGeneralPage'; import { OrganizationMembers } from './OrganizationMembers'; +const OrganizationBillingPage = lazy(() => + import(/* webpackChunkName: "op-billing-page"*/ './OrganizationBillingPage').then(module => ({ + default: module.OrganizationBillingPage, + })), +); + export const OrganizationProfileRoutes = () => { - const { pages, isMembersPageRoot, isGeneralPageRoot } = useOrganizationProfileContext(); + const { pages, isMembersPageRoot, isGeneralPageRoot, isBillingPageRoot } = useOrganizationProfileContext(); + const { experimental } = useOptions(); + const { __experimental_commerceSettings } = useEnvironment(); const customPageRoutesWithContents = pages.contents?.map((customPage, index) => { const shouldFirstCustomItemBeOnRoot = !isGeneralPageRoot && !isMembersPageRoot && index === 0; @@ -49,6 +59,17 @@ export const OrganizationProfileRoutes = () => { + {experimental?.commerce && __experimental_commerceSettings.enabled && ( + + + + + + + + + + )} ); diff --git a/packages/clerk-js/src/ui/components/PricingTable/PlanCard.tsx b/packages/clerk-js/src/ui/components/PricingTable/PlanCard.tsx index cc6d0d65056..de5b2fee4f9 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/PlanCard.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/PlanCard.tsx @@ -1,4 +1,8 @@ -import type { __experimental_CommercePlanResource, __experimental_PricingTableProps } from '@clerk/types'; +import type { + __experimental_CommercePlanResource, + __experimental_CommerceSubscriptionPlanPeriod, + __experimental_PricingTableProps, +} from '@clerk/types'; import * as React from 'react'; import { @@ -22,16 +26,14 @@ import type { ThemableCssProp } from '../../styledSystem'; import { common } from '../../styledSystem'; import { colors } from '../../utils'; -export type PlanPeriod = 'month' | 'annual'; - /* ------------------------------------------------------------------------------------------------- * PlanCard * -----------------------------------------------------------------------------------------------*/ interface PlanCardProps { plan: __experimental_CommercePlanResource; - planPeriod: PlanPeriod; - setPlanPeriod: (p: PlanPeriod) => void; + planPeriod: __experimental_CommerceSubscriptionPlanPeriod; + setPlanPeriod: (p: __experimental_CommerceSubscriptionPlanPeriod) => void; onSelect: (plan: __experimental_CommercePlanResource) => void; isCompact?: boolean; props: __experimental_PricingTableProps; @@ -42,7 +44,7 @@ export function PlanCard(props: PlanCardProps) { const isDefaultLayout = pricingTableProps.layout === 'default'; const ctaPosition = (isDefaultLayout && pricingTableProps.ctaPosition) || 'top'; const collapseFeatures = (isDefaultLayout && pricingTableProps.collapseFeatures) || false; - const { id, slug, isActiveForPayer, features } = plan; + const { id, slug, subscriptionIdForCurrentSubscriber, features } = plan; const totalFeatures = features.length; const hasFeatures = totalFeatures > 0; @@ -70,7 +72,7 @@ export function PlanCard(props: PlanCardProps) { @@ -109,10 +111,10 @@ export function PlanCard(props: PlanCardProps) {