From 5999289701085edf114c3640ae65e4c6f4f94005 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Fri, 14 Mar 2025 12:08:50 -0700 Subject: [PATCH 01/17] preliminary add PricingTable to OrgProfile, filter plans by type --- .../core/modules/commerce/CommerceBilling.ts | 3 +- .../OrganizationBillingPage.tsx | 67 +++++++++++++++++++ .../OrganizationProfileRoutes.tsx | 15 ++++- .../components/PricingTable/PricingTable.tsx | 4 +- packages/clerk-js/src/ui/constants.ts | 1 + .../components/OrganizationProfile.ts | 3 + packages/clerk-js/src/ui/types.ts | 2 + .../src/ui/utils/createCustomPages.tsx | 15 ++--- packages/localizations/src/en-US.ts | 1 + packages/types/src/commerce.ts | 4 +- packages/types/src/localization.ts | 1 + 11 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationBillingPage.tsx diff --git a/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts b/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts index 639441bab83..1e21e7f3a13 100644 --- a/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts +++ b/packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts @@ -8,7 +8,6 @@ import type { ClerkPaginatedResponse, } from '@clerk/types'; -import { convertPageToOffsetSearchParams } from '../../../utils/convertPageToOffsetSearchParams'; import { __experimental_CommerceCheckout, __experimental_CommercePlan, BaseResource } from '../../resources/internal'; export class __experimental_CommerceBilling implements __experimental_CommerceBillingNamespace { @@ -16,7 +15,7 @@ export class __experimental_CommerceBilling implements __experimental_CommerceBi 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); 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..b4ad33de9ab --- /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..003822503f6 100644 --- a/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx +++ b/packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfileRoutes.tsx @@ -1,12 +1,14 @@ import { Protect } from '../../common'; import { CustomPageContentContainer } from '../../common/CustomPageContentContainer'; -import { useOrganizationProfileContext } from '../../contexts'; +import { useOptions, useOrganizationProfileContext } from '../../contexts'; import { Route, Switch } from '../../router'; +import { OrganizationBillingPage } from './OrganizationBillingPage'; import { OrganizationGeneralPage } from './OrganizationGeneralPage'; import { OrganizationMembers } from './OrganizationMembers'; export const OrganizationProfileRoutes = () => { - const { pages, isMembersPageRoot, isGeneralPageRoot } = useOrganizationProfileContext(); + const { pages, isMembersPageRoot, isGeneralPageRoot, isBillingPageRoot } = useOrganizationProfileContext(); + const { experimental } = useOptions(); const customPageRoutesWithContents = pages.contents?.map((customPage, index) => { const shouldFirstCustomItemBeOnRoot = !isGeneralPageRoot && !isMembersPageRoot && index === 0; @@ -49,6 +51,15 @@ export const OrganizationProfileRoutes = () => { + {experimental?.commerce && ( + + + + + + + + )} ); diff --git a/packages/clerk-js/src/ui/components/PricingTable/PricingTable.tsx b/packages/clerk-js/src/ui/components/PricingTable/PricingTable.tsx index 6b788ae4472..05b772cf2cd 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/PricingTable.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/PricingTable.tsx @@ -12,14 +12,14 @@ import { PlanDetailBlade } from './PlanDetailBlade'; export const __experimental_PricingTable = (props: __experimental_PricingTableProps) => { const { __experimental_commerce } = useClerk(); - const { mode = 'mounted' } = usePricingTableContext(); + const { mode = 'mounted', subscriberType = 'user' } = usePricingTableContext(); const [planPeriod, setPlanPeriod] = useState('month'); const [selectedPlan, setSelectedPlan] = useState<__experimental_CommercePlanResource>(); const [showCheckout, setShowCheckout] = useState(false); const [showPlanDetail, setShowPlanDetail] = useState(false); const isCompact = mode === 'modal'; - const { data: plans } = useFetch(__experimental_commerce?.__experimental_billing.getPlans, 'commerce-plans'); + const { data: plans } = useFetch(__experimental_commerce?.__experimental_billing.getPlans, { subscriberType }); const selectPlan = (plan: __experimental_CommercePlanResource) => { setSelectedPlan(plan); diff --git a/packages/clerk-js/src/ui/constants.ts b/packages/clerk-js/src/ui/constants.ts index 1752d530dfc..ab919439859 100644 --- a/packages/clerk-js/src/ui/constants.ts +++ b/packages/clerk-js/src/ui/constants.ts @@ -7,6 +7,7 @@ export const USER_PROFILE_NAVBAR_ROUTE_ID = { export const ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID = { GENERAL: 'general', MEMBERS: 'members', + BILLING: 'billing', }; export const USER_BUTTON_ITEM_ID = { diff --git a/packages/clerk-js/src/ui/contexts/components/OrganizationProfile.ts b/packages/clerk-js/src/ui/contexts/components/OrganizationProfile.ts index 3ea72a4a159..eba37152aa0 100644 --- a/packages/clerk-js/src/ui/contexts/components/OrganizationProfile.ts +++ b/packages/clerk-js/src/ui/contexts/components/OrganizationProfile.ts @@ -21,6 +21,7 @@ export type OrganizationProfileContextType = OrganizationProfileCtx & { navigateToGeneralPageRoot: () => Promise; isMembersPageRoot: boolean; isGeneralPageRoot: boolean; + isBillingPageRoot: boolean; }; export const OrganizationProfileContext = createContext(null); @@ -44,6 +45,7 @@ export const useOrganizationProfileContext = (): OrganizationProfileContextType const isMembersPageRoot = pages.routes[0].id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.MEMBERS; const isGeneralPageRoot = pages.routes[0].id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.GENERAL; + const isBillingPageRoot = pages.routes[0].id === ORGANIZATION_PROFILE_NAVBAR_ROUTE_ID.BILLING; const navigateToGeneralPageRoot = () => navigate(isGeneralPageRoot ? '../' : isMembersPageRoot ? './organization-general' : '../organization-general'); @@ -55,5 +57,6 @@ export const useOrganizationProfileContext = (): OrganizationProfileContextType navigateToGeneralPageRoot, isMembersPageRoot, isGeneralPageRoot, + isBillingPageRoot, }; }; diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts index 743b154c78b..b28ef1879e4 100644 --- a/packages/clerk-js/src/ui/types.ts +++ b/packages/clerk-js/src/ui/types.ts @@ -1,5 +1,6 @@ import type { __experimental_CheckoutProps, + __experimental_CommerceSubscriberType, __experimental_PricingTableProps, __internal_UserVerificationProps, CreateOrganizationProps, @@ -103,6 +104,7 @@ export type WaitlistCtx = WaitlistProps & { export type __experimental_PricingTableCtx = __experimental_PricingTableProps & { componentName: 'PricingTable'; mode?: ComponentMode; + subscriberType?: __experimental_CommerceSubscriberType; }; export type __experimental_CheckoutCtx = __experimental_CheckoutProps & { diff --git a/packages/clerk-js/src/ui/utils/createCustomPages.tsx b/packages/clerk-js/src/ui/utils/createCustomPages.tsx index 7f5c5b6c4ce..ed888c44a0f 100644 --- a/packages/clerk-js/src/ui/utils/createCustomPages.tsx +++ b/packages/clerk-js/src/ui/utils/createCustomPages.tsx @@ -286,15 +286,12 @@ const getOrganizationProfileDefaultRoutes = ({ commerce }: { commerce: boolean } }, ]; if (commerce) { - // TODO(@COMMERCE) Uncomment when OrgProfile is ready - // INITIAL_ROUTES.push( - // { - // name: localizationKeys('userProfile.navbar.billing'), - // id: USER_PROFILE_NAVBAR_ROUTE_ID.BILLING, - // icon: CreditCard, - // path: 'billing', - // }, - // ); + INITIAL_ROUTES.push({ + name: localizationKeys('organizationProfile.navbar.billing'), + id: USER_PROFILE_NAVBAR_ROUTE_ID.BILLING, + icon: CreditCard, + path: 'organization-billing', + }); } const pageToRootNavbarRouteMap: Record = { diff --git a/packages/localizations/src/en-US.ts b/packages/localizations/src/en-US.ts index c3e438b53c4..4fa6dbc2f91 100644 --- a/packages/localizations/src/en-US.ts +++ b/packages/localizations/src/en-US.ts @@ -172,6 +172,7 @@ export const enUS: LocalizationResource = { general: 'General', members: 'Members', title: 'Organization', + billing: 'Billing', }, profilePage: { dangerSection: { diff --git a/packages/types/src/commerce.ts b/packages/types/src/commerce.ts index b523cd68691..021dbd95e06 100644 --- a/packages/types/src/commerce.ts +++ b/packages/types/src/commerce.ts @@ -14,6 +14,8 @@ export interface __experimental_CommerceBillingNamespace { startCheckout: (params: __experimental_CreateCheckoutParams) => Promise<__experimental_CommerceCheckoutResource>; } +export type __experimental_CommerceSubscriberType = 'org' | 'user'; + export interface __experimental_CommerceProductResource extends ClerkResource { id: string; slug: string | null; @@ -23,7 +25,7 @@ export interface __experimental_CommerceProductResource extends ClerkResource { } export interface __experimental_GetPlansParams { - subscriberType?: string; + subscriberType?: __experimental_CommerceSubscriberType; } export interface __experimental_CommercePlanResource extends ClerkResource { diff --git a/packages/types/src/localization.ts b/packages/types/src/localization.ts index 792b3815651..de0610b5e39 100644 --- a/packages/types/src/localization.ts +++ b/packages/types/src/localization.ts @@ -687,6 +687,7 @@ type _LocalizationResource = { description: LocalizationValue; general: LocalizationValue; members: LocalizationValue; + billing: LocalizationValue; }; badge__unverified: LocalizationValue; badge__automaticInvitation: LocalizationValue; From b6ea065322dbcf97980908d16eb706f389b47532 Mon Sep 17 00:00:00 2001 From: Keiran Flanigan Date: Tue, 18 Mar 2025 18:01:11 -0700 Subject: [PATCH 02/17] add usePlans hook, rename PlanDetailDrawer -> SubscriptionDetailDrawer --- .../src/core/resources/CommerceCheckout.ts | 3 +- .../src/core/resources/CommercePlan.ts | 4 +- .../core/resources/CommerceSubscription.ts | 14 +++-- .../src/core/resources/Organization.ts | 33 +++++++++- .../ui/components/PricingTable/PlanCard.tsx | 36 +++++------ .../components/PricingTable/PricingTable.tsx | 54 +++++++++------- ...rawer.tsx => SubscriptionDetailDrawer.tsx} | 36 +++++------ packages/clerk-js/src/ui/hooks/index.ts | 1 + packages/clerk-js/src/ui/hooks/usePlans.ts | 32 ++++++++++ .../src/react/hooks/useOrganization.tsx | 61 +++++++++++++++++++ packages/types/src/clerk.ts | 5 +- packages/types/src/commerce.ts | 15 +++-- packages/types/src/json.ts | 12 ++-- packages/types/src/organization.ts | 11 ++++ 14 files changed, 237 insertions(+), 80 deletions(-) rename packages/clerk-js/src/ui/components/PricingTable/{PlanDetailDrawer.tsx => SubscriptionDetailDrawer.tsx} (83%) create mode 100644 packages/clerk-js/src/ui/hooks/usePlans.ts 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..c163642cf0c 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; @@ -48,6 +47,7 @@ export class __experimental_CommercePlan extends BaseResource implements __exper this.slug = data.slug; this.avatarUrl = data.avatar_url; this.features = data.features.map(feature => new __experimental_CommerceFeature(feature)); + this.subscriptionIdForCurrentSubscriber = undefined; return this; } 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..d70eff6b1cc 100644 --- a/packages/clerk-js/src/core/resources/Organization.ts +++ b/packages/clerk-js/src/core/resources/Organization.ts @@ -1,4 +1,6 @@ import type { + __experimental_CommerceSubscriptionJSON, + __experimental_CommerceSubscriptionResource, AddMemberParams, ClerkPaginatedResponse, ClerkResourceReloadParams, @@ -8,6 +10,7 @@ import type { GetMembershipRequestParams, GetMemberships, GetRolesParams, + GetSubscriptionsParams, InviteMemberParams, InviteMembersParams, OrganizationDomainJSON, @@ -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?: 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/ui/components/PricingTable/PlanCard.tsx b/packages/clerk-js/src/ui/components/PricingTable/PlanCard.tsx index 691c52b4742..372b1ec2cea 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/PlanCard.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/PlanCard.tsx @@ -1,5 +1,9 @@ import { useClerk } from '@clerk/shared/react'; -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 { @@ -23,16 +27,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 clerk = useClerk(); const { plan, planPeriod, setPlanPeriod, onSelect, props: pricingTableProps, isCompact = false } = props; const { ctaPosition = 'top', collapseFeatures = false } = pricingTableProps; - 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) {