From c7ebb9f131f3c136d6c444f2979a8d3850ebe663 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 21 Jul 2025 19:42:11 +0300 Subject: [PATCH 1/9] feat(clerk-react, nextjs): Introduce commerce buttons --- .changeset/five-coats-smile.md | 6 +++ packages/nextjs/src/experimental.ts | 1 + .../react/src/components/CheckoutButton.tsx | 45 +++++++++++++++++++ .../src/components/PlanDetailsButton.tsx | 45 +++++++++++++++++++ .../components/SubscriptionDetailsButton.tsx | 45 +++++++++++++++++++ packages/react/src/experimental.ts | 3 ++ packages/react/src/utils/childrenUtils.tsx | 11 ++++- packages/react/tsup.config.ts | 1 + 8 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 .changeset/five-coats-smile.md create mode 100644 packages/nextjs/src/experimental.ts create mode 100644 packages/react/src/components/CheckoutButton.tsx create mode 100644 packages/react/src/components/PlanDetailsButton.tsx create mode 100644 packages/react/src/components/SubscriptionDetailsButton.tsx create mode 100644 packages/react/src/experimental.ts diff --git a/.changeset/five-coats-smile.md b/.changeset/five-coats-smile.md new file mode 100644 index 00000000000..e1108493f7c --- /dev/null +++ b/.changeset/five-coats-smile.md @@ -0,0 +1,6 @@ +--- +'@clerk/nextjs': patch +'@clerk/clerk-react': patch +--- + +wip diff --git a/packages/nextjs/src/experimental.ts b/packages/nextjs/src/experimental.ts new file mode 100644 index 00000000000..ebc558497bc --- /dev/null +++ b/packages/nextjs/src/experimental.ts @@ -0,0 +1 @@ +export { CheckoutButton, PlanDetailsButton, SubscriptionDetailsButton } from '@clerk/clerk-react/experimental'; diff --git a/packages/react/src/components/CheckoutButton.tsx b/packages/react/src/components/CheckoutButton.tsx new file mode 100644 index 00000000000..19b29f91745 --- /dev/null +++ b/packages/react/src/components/CheckoutButton.tsx @@ -0,0 +1,45 @@ +import type { __internal_CheckoutProps } from '@clerk/types'; +import React from 'react'; + +import type { WithClerkProp } from '../types'; +import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils'; +import { withClerk } from './withClerk'; + +export type { __internal_CheckoutProps }; + +export const CheckoutButton = withClerk( + ({ clerk, children, ...props }: WithClerkProp>) => { + // const { signUpFallbackRedirectUrl, forceRedirectUrl, fallbackRedirectUrl, signUpForceRedirectUrl, mode, ...rest } = + // props; + children = normalizeWithDefaultValue(children, 'Checkout'); + const child = assertSingleChild(children)('CheckoutButton'); + + const clickHandler = () => { + if (!clerk) { + return; + } + + return clerk.__internal_openCheckout(props); + + // if (mode === 'modal') { + // return clerk.openSignIn({ ...opts, appearance: props.appearance }); + // } + // return clerk.redirectToSignIn({ + // ...opts, + // signInFallbackRedirectUrl: fallbackRedirectUrl, + // signInForceRedirectUrl: forceRedirectUrl, + // }); + }; + + const wrappedChildClickHandler: React.MouseEventHandler = async e => { + if (child && typeof child === 'object' && 'props' in child) { + await safeExecute(child.props.onClick)(e); + } + return clickHandler(); + }; + + const childProps = { ...props, onClick: wrappedChildClickHandler }; + return React.cloneElement(child as React.ReactElement, childProps); + }, + 'SignInButton', +); diff --git a/packages/react/src/components/PlanDetailsButton.tsx b/packages/react/src/components/PlanDetailsButton.tsx new file mode 100644 index 00000000000..e24b8f24888 --- /dev/null +++ b/packages/react/src/components/PlanDetailsButton.tsx @@ -0,0 +1,45 @@ +import type { __internal_PlanDetailsProps } from '@clerk/types'; +import React from 'react'; + +import type { WithClerkProp } from '../types'; +import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils'; +import { withClerk } from './withClerk'; + +export type { __internal_PlanDetailsProps }; + +export const PlanDetailsButton = withClerk( + ({ clerk, children, ...props }: WithClerkProp>) => { + // const { signUpFallbackRedirectUrl, forceRedirectUrl, fallbackRedirectUrl, signUpForceRedirectUrl, mode, ...rest } = + // props; + children = normalizeWithDefaultValue(children, 'Plan details'); + const child = assertSingleChild(children)('PlanDetailsButton'); + + const clickHandler = () => { + if (!clerk) { + return; + } + + return clerk.__internal_openPlanDetails(props); + + // if (mode === 'modal') { + // return clerk.openSignIn({ ...opts, appearance: props.appearance }); + // } + // return clerk.redirectToSignIn({ + // ...opts, + // signInFallbackRedirectUrl: fallbackRedirectUrl, + // signInForceRedirectUrl: forceRedirectUrl, + // }); + }; + + const wrappedChildClickHandler: React.MouseEventHandler = async e => { + if (child && typeof child === 'object' && 'props' in child) { + await safeExecute(child.props.onClick)(e); + } + return clickHandler(); + }; + + const childProps = { ...props, onClick: wrappedChildClickHandler }; + return React.cloneElement(child as React.ReactElement, childProps); + }, + 'SignInButton', +); diff --git a/packages/react/src/components/SubscriptionDetailsButton.tsx b/packages/react/src/components/SubscriptionDetailsButton.tsx new file mode 100644 index 00000000000..3b093ef7439 --- /dev/null +++ b/packages/react/src/components/SubscriptionDetailsButton.tsx @@ -0,0 +1,45 @@ +import type { __internal_SubscriptionDetailsProps } from '@clerk/types'; +import React from 'react'; + +import type { WithClerkProp } from '../types'; +import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils'; +import { withClerk } from './withClerk'; + +export type { __internal_SubscriptionDetailsProps }; + +export const SubscriptionDetailsButton = withClerk( + ({ clerk, children, ...props }: WithClerkProp>) => { + // const { signUpFallbackRedirectUrl, forceRedirectUrl, fallbackRedirectUrl, signUpForceRedirectUrl, mode, ...rest } = + // props; + children = normalizeWithDefaultValue(children, 'Subscription details'); + const child = assertSingleChild(children)('SubscriptionDetailsButton'); + + const clickHandler = () => { + if (!clerk) { + return; + } + + return clerk.__internal_openSubscriptionDetails(props); + + // if (mode === 'modal') { + // return clerk.openSignIn({ ...opts, appearance: props.appearance }); + // } + // return clerk.redirectToSignIn({ + // ...opts, + // signInFallbackRedirectUrl: fallbackRedirectUrl, + // signInForceRedirectUrl: forceRedirectUrl, + // }); + }; + + const wrappedChildClickHandler: React.MouseEventHandler = async e => { + if (child && typeof child === 'object' && 'props' in child) { + await safeExecute(child.props.onClick)(e); + } + return clickHandler(); + }; + + const childProps = { ...props, onClick: wrappedChildClickHandler }; + return React.cloneElement(child as React.ReactElement, childProps); + }, + 'SignInButton', +); diff --git a/packages/react/src/experimental.ts b/packages/react/src/experimental.ts new file mode 100644 index 00000000000..78da2bc0947 --- /dev/null +++ b/packages/react/src/experimental.ts @@ -0,0 +1,3 @@ +export { CheckoutButton } from './components/CheckoutButton'; +export { PlanDetailsButton } from './components/PlanDetailsButton'; +export { SubscriptionDetailsButton } from './components/SubscriptionDetailsButton'; diff --git a/packages/react/src/utils/childrenUtils.tsx b/packages/react/src/utils/childrenUtils.tsx index bfde8c9be28..0e231b2241c 100644 --- a/packages/react/src/utils/childrenUtils.tsx +++ b/packages/react/src/utils/childrenUtils.tsx @@ -5,7 +5,16 @@ import { multipleChildrenInButtonComponent } from '../errors/messages'; export const assertSingleChild = (children: React.ReactNode) => - (name: 'SignInButton' | 'SignUpButton' | 'SignOutButton' | 'SignInWithMetamaskButton') => { + ( + name: + | 'SignInButton' + | 'SignUpButton' + | 'SignOutButton' + | 'SignInWithMetamaskButton' + | 'CheckoutButton' + | 'SubscriptionDetailsButton' + | 'PlanDetailsButton', + ) => { try { return React.Children.only(children); } catch { diff --git a/packages/react/tsup.config.ts b/packages/react/tsup.config.ts index d0290c0e12a..303481e3869 100644 --- a/packages/react/tsup.config.ts +++ b/packages/react/tsup.config.ts @@ -12,6 +12,7 @@ export default defineConfig(overrideOptions => { index: 'src/index.ts', internal: 'src/internal.ts', errors: 'src/errors.ts', + experimental: 'src/experimental.ts', }, dts: true, onSuccess: shouldPublish ? 'pnpm publish:local' : undefined, From 48e99b50d6db452a714119894434a5b0952235ef Mon Sep 17 00:00:00 2001 From: panteliselef Date: Mon, 21 Jul 2025 20:11:51 +0300 Subject: [PATCH 2/9] add missing package json files --- packages/nextjs/package.json | 5 +++++ packages/react/package.json | 13 ++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index d2caa59edf5..74b2b203b68 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -49,6 +49,11 @@ "types": "./dist/types/webhooks.d.ts", "import": "./dist/esm/webhooks.js", "require": "./dist/cjs/webhooks.js" + }, + "./experimental": { + "types": "./dist/types/experimental.d.ts", + "import": "./dist/esm/experimental.js", + "require": "./dist/cjs/experimental.js" } }, "types": "./dist/types/index.d.ts", diff --git a/packages/react/package.json b/packages/react/package.json index 0809143f59d..fd5ff11b5d3 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -53,13 +53,24 @@ "default": "./dist/errors.js" } }, + "./experimental": { + "import": { + "types": "./dist/experimental.d.mts", + "default": "./dist/experimental.mjs" + }, + "require": { + "types": "./dist/experimental.d.ts", + "default": "./dist/experimental.js" + } + }, "./package.json": "./package.json" }, "main": "./dist/index.js", "files": [ "dist", "internal", - "errors" + "errors", + "experimental" ], "scripts": { "build": "tsup", From f328d0fb7b9ef431d0aa559414f9b5c6d6c2b9da Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 22 Jul 2025 13:31:04 +0300 Subject: [PATCH 3/9] cleanup buttons --- packages/nextjs/src/experimental.ts | 2 + .../react/src/components/CheckoutButton.tsx | 51 ++++++++++++++----- .../src/components/PlanDetailsButton.tsx | 25 ++++----- .../components/SubscriptionDetailsButton.tsx | 37 +++++++++----- 4 files changed, 73 insertions(+), 42 deletions(-) diff --git a/packages/nextjs/src/experimental.ts b/packages/nextjs/src/experimental.ts index ebc558497bc..5ec9dd59fd6 100644 --- a/packages/nextjs/src/experimental.ts +++ b/packages/nextjs/src/experimental.ts @@ -1 +1,3 @@ +'use client'; + export { CheckoutButton, PlanDetailsButton, SubscriptionDetailsButton } from '@clerk/clerk-react/experimental'; diff --git a/packages/react/src/components/CheckoutButton.tsx b/packages/react/src/components/CheckoutButton.tsx index 19b29f91745..63535e71f28 100644 --- a/packages/react/src/components/CheckoutButton.tsx +++ b/packages/react/src/components/CheckoutButton.tsx @@ -1,6 +1,7 @@ import type { __internal_CheckoutProps } from '@clerk/types'; import React from 'react'; +import { useAuth } from '../hooks'; import type { WithClerkProp } from '../types'; import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils'; import { withClerk } from './withClerk'; @@ -9,8 +10,29 @@ export type { __internal_CheckoutProps }; export const CheckoutButton = withClerk( ({ clerk, children, ...props }: WithClerkProp>) => { - // const { signUpFallbackRedirectUrl, forceRedirectUrl, fallbackRedirectUrl, signUpForceRedirectUrl, mode, ...rest } = - // props; + const { + appearance, + planId, + planPeriod, + subscriberType, + onSubscriptionComplete, + portalId, + portalRoot, + newSubscriptionRedirectUrl, + onClose, + ...rest + } = props; + + const { userId, orgId } = useAuth(); + + if (userId === null) { + throw new Error('Ensure that `` is rendered inside a `` component.'); + } + + if (orgId === null && subscriberType === 'org') { + throw new Error('Wrap `` with a check for an active organization.'); + } + children = normalizeWithDefaultValue(children, 'Checkout'); const child = assertSingleChild(children)('CheckoutButton'); @@ -19,16 +41,17 @@ export const CheckoutButton = withClerk( return; } - return clerk.__internal_openCheckout(props); - - // if (mode === 'modal') { - // return clerk.openSignIn({ ...opts, appearance: props.appearance }); - // } - // return clerk.redirectToSignIn({ - // ...opts, - // signInFallbackRedirectUrl: fallbackRedirectUrl, - // signInForceRedirectUrl: forceRedirectUrl, - // }); + return clerk.__internal_openCheckout({ + appearance, + planId, + planPeriod, + subscriberType, + onSubscriptionComplete, + portalId, + portalRoot, + newSubscriptionRedirectUrl, + onClose, + }); }; const wrappedChildClickHandler: React.MouseEventHandler = async e => { @@ -38,8 +61,8 @@ export const CheckoutButton = withClerk( return clickHandler(); }; - const childProps = { ...props, onClick: wrappedChildClickHandler }; + const childProps = { ...rest, onClick: wrappedChildClickHandler }; return React.cloneElement(child as React.ReactElement, childProps); }, - 'SignInButton', + { component: 'CheckoutButton', renderWhileLoading: true }, ); diff --git a/packages/react/src/components/PlanDetailsButton.tsx b/packages/react/src/components/PlanDetailsButton.tsx index e24b8f24888..d5158acbd13 100644 --- a/packages/react/src/components/PlanDetailsButton.tsx +++ b/packages/react/src/components/PlanDetailsButton.tsx @@ -9,8 +9,7 @@ export type { __internal_PlanDetailsProps }; export const PlanDetailsButton = withClerk( ({ clerk, children, ...props }: WithClerkProp>) => { - // const { signUpFallbackRedirectUrl, forceRedirectUrl, fallbackRedirectUrl, signUpForceRedirectUrl, mode, ...rest } = - // props; + const { plan, planId, appearance, initialPlanPeriod, portalId, portalRoot, ...rest } = props; children = normalizeWithDefaultValue(children, 'Plan details'); const child = assertSingleChild(children)('PlanDetailsButton'); @@ -19,16 +18,14 @@ export const PlanDetailsButton = withClerk( return; } - return clerk.__internal_openPlanDetails(props); - - // if (mode === 'modal') { - // return clerk.openSignIn({ ...opts, appearance: props.appearance }); - // } - // return clerk.redirectToSignIn({ - // ...opts, - // signInFallbackRedirectUrl: fallbackRedirectUrl, - // signInForceRedirectUrl: forceRedirectUrl, - // }); + return clerk.__internal_openPlanDetails({ + plan, + planId, + appearance, + initialPlanPeriod, + portalId, + portalRoot, + }); }; const wrappedChildClickHandler: React.MouseEventHandler = async e => { @@ -38,8 +35,8 @@ export const PlanDetailsButton = withClerk( return clickHandler(); }; - const childProps = { ...props, onClick: wrappedChildClickHandler }; + const childProps = { ...rest, onClick: wrappedChildClickHandler }; return React.cloneElement(child as React.ReactElement, childProps); }, - 'SignInButton', + { component: 'PlanDetailsButton', renderWhileLoading: true }, ); diff --git a/packages/react/src/components/SubscriptionDetailsButton.tsx b/packages/react/src/components/SubscriptionDetailsButton.tsx index 3b093ef7439..35208bc7895 100644 --- a/packages/react/src/components/SubscriptionDetailsButton.tsx +++ b/packages/react/src/components/SubscriptionDetailsButton.tsx @@ -1,6 +1,7 @@ import type { __internal_SubscriptionDetailsProps } from '@clerk/types'; import React from 'react'; +import { useAuth } from '../hooks'; import type { WithClerkProp } from '../types'; import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils'; import { withClerk } from './withClerk'; @@ -9,26 +10,34 @@ export type { __internal_SubscriptionDetailsProps }; export const SubscriptionDetailsButton = withClerk( ({ clerk, children, ...props }: WithClerkProp>) => { - // const { signUpFallbackRedirectUrl, forceRedirectUrl, fallbackRedirectUrl, signUpForceRedirectUrl, mode, ...rest } = - // props; + const { for: forProp, appearance, onSubscriptionCancel, portalId, portalRoot, ...rest } = props; children = normalizeWithDefaultValue(children, 'Subscription details'); const child = assertSingleChild(children)('SubscriptionDetailsButton'); + const { userId, orgId } = useAuth(); + + if (userId === null) { + throw new Error('Ensure that `` is rendered inside a `` component.'); + } + + if (orgId === null && forProp === 'org') { + throw new Error( + 'Wrap `` with a check for an active organization.', + ); + } + const clickHandler = () => { if (!clerk) { return; } - return clerk.__internal_openSubscriptionDetails(props); - - // if (mode === 'modal') { - // return clerk.openSignIn({ ...opts, appearance: props.appearance }); - // } - // return clerk.redirectToSignIn({ - // ...opts, - // signInFallbackRedirectUrl: fallbackRedirectUrl, - // signInForceRedirectUrl: forceRedirectUrl, - // }); + return clerk.__internal_openSubscriptionDetails({ + for: forProp, + appearance, + onSubscriptionCancel, + portalId, + portalRoot, + }); }; const wrappedChildClickHandler: React.MouseEventHandler = async e => { @@ -38,8 +47,8 @@ export const SubscriptionDetailsButton = withClerk( return clickHandler(); }; - const childProps = { ...props, onClick: wrappedChildClickHandler }; + const childProps = { ...rest, onClick: wrappedChildClickHandler }; return React.cloneElement(child as React.ReactElement, childProps); }, - 'SignInButton', + { component: 'SubscriptionDetailsButton', renderWhileLoading: true }, ); From 216b9295c9a916d9acfd8501004b2d65e6c8ea47 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 22 Jul 2025 13:36:55 +0300 Subject: [PATCH 4/9] add changesets --- .changeset/curly-jeans-sleep.md | 5 +++++ .changeset/dark-coins-shake.md | 5 +++++ .changeset/five-coats-smile.md | 6 ------ 3 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 .changeset/curly-jeans-sleep.md create mode 100644 .changeset/dark-coins-shake.md delete mode 100644 .changeset/five-coats-smile.md diff --git a/.changeset/curly-jeans-sleep.md b/.changeset/curly-jeans-sleep.md new file mode 100644 index 00000000000..82bea3f9e57 --- /dev/null +++ b/.changeset/curly-jeans-sleep.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-react': minor +--- + +Expose ``, ``, `` from `@clerk/clerk-react/experimental`. diff --git a/.changeset/dark-coins-shake.md b/.changeset/dark-coins-shake.md new file mode 100644 index 00000000000..bc850e86fa0 --- /dev/null +++ b/.changeset/dark-coins-shake.md @@ -0,0 +1,5 @@ +--- +'@clerk/nextjs': minor +--- + +Expose ``, ``, `` from `@clerk/nextjs/experimental`. diff --git a/.changeset/five-coats-smile.md b/.changeset/five-coats-smile.md deleted file mode 100644 index e1108493f7c..00000000000 --- a/.changeset/five-coats-smile.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@clerk/nextjs': patch -'@clerk/clerk-react': patch ---- - -wip From 4919ef8bbfbfcfa6571ad77eddb28700ec7e30da Mon Sep 17 00:00:00 2001 From: panteliselef Date: Tue, 22 Jul 2025 14:14:54 +0300 Subject: [PATCH 5/9] add docs and tests --- .../react/src/components/CheckoutButton.tsx | 40 +++- .../src/components/PlanDetailsButton.tsx | 30 ++- .../components/SubscriptionDetailsButton.tsx | 36 +++- .../__tests__/CheckoutButton.test.tsx | 185 +++++++++++++++++ .../__tests__/PlanDetailsButton.test.tsx | 160 +++++++++++++++ .../SubscriptionDetailsButton.test.tsx | 191 ++++++++++++++++++ 6 files changed, 636 insertions(+), 6 deletions(-) create mode 100644 packages/react/src/components/__tests__/CheckoutButton.test.tsx create mode 100644 packages/react/src/components/__tests__/PlanDetailsButton.test.tsx create mode 100644 packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx diff --git a/packages/react/src/components/CheckoutButton.tsx b/packages/react/src/components/CheckoutButton.tsx index 63535e71f28..cee139e225c 100644 --- a/packages/react/src/components/CheckoutButton.tsx +++ b/packages/react/src/components/CheckoutButton.tsx @@ -6,8 +6,44 @@ import type { WithClerkProp } from '../types'; import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils'; import { withClerk } from './withClerk'; -export type { __internal_CheckoutProps }; - +/** + * @experimental A button component that opens the Clerk Checkout drawer when clicked. This component must be rendered + * inside a `` component to ensure the user is authenticated. + * + * @example + * ```tsx + * import { SignedIn } from '@clerk/clerk-react'; + * import { CheckoutButton } from '@clerk/clerk-react/experimental'; + * + * // Basic usage with default "Checkout" text + * function BasicCheckout() { + * return ( + * + * + * + * ); + * } + * + * // Custom button with organization subscription + * function OrganizationCheckout() { + * return ( + * + * console.log('Subscription completed!')} + * > + * + * + * + * ); + * } + * ``` + * + * @throws {Error} When rendered outside of a `` component + * @throws {Error} When `subscriberType="org"` is used without an active organization context + */ export const CheckoutButton = withClerk( ({ clerk, children, ...props }: WithClerkProp>) => { const { diff --git a/packages/react/src/components/PlanDetailsButton.tsx b/packages/react/src/components/PlanDetailsButton.tsx index d5158acbd13..0c7fb0be280 100644 --- a/packages/react/src/components/PlanDetailsButton.tsx +++ b/packages/react/src/components/PlanDetailsButton.tsx @@ -5,8 +5,34 @@ import type { WithClerkProp } from '../types'; import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils'; import { withClerk } from './withClerk'; -export type { __internal_PlanDetailsProps }; - +/** + * @experimental A button component that opens the Clerk Plan Details drawer when clicked. This component is part of + * Clerk's Billing feature which is available under a public beta. + * + * @example + * ```tsx + * import { SignedIn } from '@clerk/clerk-react'; + * import { PlanDetailsButton } from '@clerk/clerk-react/experimental'; + * + * // Basic usage with default "Plan details" text + * function BasicPlanDetails() { + * return ( + * + * ); + * } + * + * // Custom button with custom text + * function CustomPlanDetails() { + * return ( + * + * + * + * ); + * } + * ``` + * + * @see https://clerk.com/docs/billing/overview + */ export const PlanDetailsButton = withClerk( ({ clerk, children, ...props }: WithClerkProp>) => { const { plan, planId, appearance, initialPlanPeriod, portalId, portalRoot, ...rest } = props; diff --git a/packages/react/src/components/SubscriptionDetailsButton.tsx b/packages/react/src/components/SubscriptionDetailsButton.tsx index 35208bc7895..cb4587ac350 100644 --- a/packages/react/src/components/SubscriptionDetailsButton.tsx +++ b/packages/react/src/components/SubscriptionDetailsButton.tsx @@ -6,8 +6,40 @@ import type { WithClerkProp } from '../types'; import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils'; import { withClerk } from './withClerk'; -export type { __internal_SubscriptionDetailsProps }; - +/** + * @experimental A button component that opens the Clerk Subscription Details drawer when clicked. This component must be rendered + * inside a `` component to ensure the user is authenticated. + * + * @example + * ```tsx + * import { SignedIn } from '@clerk/clerk-react'; + * import { SubscriptionDetailsButton } from '@clerk/clerk-react/experimental'; + * + * // Basic usage with default "Subscription details" text + * function BasicSubscriptionDetails() { + * return ( + * + * ); + * } + * + * // Custom button with organization subscription + * function OrganizationSubscriptionDetails() { + * return ( + * console.log('Subscription canceled')} + * > + * + * + * ); + * } + * ``` + * + * @throws {Error} When rendered outside of a `` component + * @throws {Error} When `for="org"` is used without an active organization context + * + * @see https://clerk.com/docs/billing/overview + */ export const SubscriptionDetailsButton = withClerk( ({ clerk, children, ...props }: WithClerkProp>) => { const { for: forProp, appearance, onSubscriptionCancel, portalId, portalRoot, ...rest } = props; diff --git a/packages/react/src/components/__tests__/CheckoutButton.test.tsx b/packages/react/src/components/__tests__/CheckoutButton.test.tsx new file mode 100644 index 00000000000..bdac70319c1 --- /dev/null +++ b/packages/react/src/components/__tests__/CheckoutButton.test.tsx @@ -0,0 +1,185 @@ +import '@testing-library/jest-dom/vitest'; + +import type { Theme } from '@clerk/types'; +import { render, screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAuth } from '../../hooks'; +import { CheckoutButton } from '../CheckoutButton'; + +// Mock the useAuth hook +vi.mock('../../hooks', () => ({ + useAuth: vi.fn(), +})); + +const mockOpenCheckout = vi.fn(); + +const mockClerk = { + __internal_openCheckout: mockOpenCheckout, +}; + +// Mock the withClerk HOC +vi.mock('../withClerk', () => { + return { + withClerk: (Component: any) => (props: any) => { + return ( + + ); + }, + }; +}); + +describe('CheckoutButton', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Authentication Validation', () => { + it('throws error when rendered without authenticated user', () => { + // Mock useAuth to return null userId + (useAuth as any).mockReturnValue({ userId: null, orgId: null }); + + // Expect the component to throw an error + expect(() => render()).toThrow( + 'Ensure that `` is rendered inside a `` component.', + ); + }); + + it('throws error when using org subscriber type without active organization', () => { + // Mock useAuth to return userId but no orgId + (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null }); + + // Expect the component to throw an error when subscriberType is "org" + expect(() => + render( + , + ), + ).toThrow('Wrap `` with a check for an active organization.'); + }); + + it('renders successfully with authenticated user', () => { + // Mock useAuth to return valid userId + (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null }); + + // Component should render without throwing + expect(() => render()).not.toThrow(); + }); + + it('renders successfully with org subscriber type when organization is active', () => { + // Mock useAuth to return both userId and orgId + (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: 'org_123' }); + + // Component should render without throwing + expect(() => + render( + , + ), + ).not.toThrow(); + }); + }); + + describe('Event Handling', () => { + beforeEach(() => { + // Set up valid authentication for all event tests + (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null }); + }); + + it('calls clerk.__internal_openCheckout with correct props when clicked', async () => { + const props = { + planId: 'test_plan', + planPeriod: 'month' as const, + appearance: {} as Theme, + onSubscriptionComplete: vi.fn(), + newSubscriptionRedirectUrl: '/success', + onClose: vi.fn(), + }; + + render(); + + await userEvent.click(screen.getByText('Checkout')); + + await waitFor(() => { + expect(mockOpenCheckout).toHaveBeenCalledWith(expect.objectContaining(props)); + }); + }); + + it('executes child onClick handler before opening checkout', async () => { + const childOnClick = vi.fn(); + const props = { planId: 'test_plan' }; + + render( + + + , + ); + + await userEvent.click(screen.getByText('Custom Button')); + + await waitFor(() => { + expect(childOnClick).toHaveBeenCalled(); + expect(mockOpenCheckout).toHaveBeenCalledWith(expect.objectContaining(props)); + }); + }); + + it('uses default "Checkout" text when no children provided', () => { + render(); + + expect(screen.getByText('Checkout')).toBeInTheDocument(); + }); + + it('renders custom button content when provided', () => { + render( + + + , + ); + + expect(screen.getByText('Subscribe Now')).toBeInTheDocument(); + }); + }); + + describe('Props Handling', () => { + beforeEach(() => { + (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null }); + }); + + it('passes additional props to child element', () => { + render( + + + , + ); + + expect(screen.getByTestId('checkout-btn')).toBeInTheDocument(); + }); + + it('handles portal configuration correctly', async () => { + const portalProps = { + planId: 'test_plan', + portalId: 'custom-portal', + portalRoot: document.createElement('div'), + }; + + render(); + + await userEvent.click(screen.getByText('Checkout')); + await waitFor(() => { + expect(mockOpenCheckout).toHaveBeenCalledWith(expect.objectContaining(portalProps)); + }); + }); + }); +}); diff --git a/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx b/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx new file mode 100644 index 00000000000..2d28ef5e29f --- /dev/null +++ b/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx @@ -0,0 +1,160 @@ +import '@testing-library/jest-dom/vitest'; + +import type { CommercePayerType, CommercePlanResource, Theme } from '@clerk/types'; +import { render, screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PlanDetailsButton } from '../PlanDetailsButton'; + +const mockOpenPlanDetails = vi.fn(); + +const mockClerk = { + __internal_openPlanDetails: mockOpenPlanDetails, +}; + +// Mock the withClerk HOC +vi.mock('../withClerk', () => { + return { + withClerk: (Component: any) => (props: any) => { + return ( + + ); + }, + }; +}); + +const mockPlanResource: CommercePlanResource = { + id: 'plan_123', + name: 'Test Plan', + amount: 1000, + amountFormatted: '10.00', + annualAmount: 10000, + annualAmountFormatted: '100.00', + annualMonthlyAmount: 833, + annualMonthlyAmountFormatted: '8.33', + currencySymbol: '$', + description: 'Test Plan Description', + hasBaseFee: true, + isRecurring: true, + currency: 'USD', + isDefault: false, + forPayerType: 'user' as CommercePayerType, + publiclyVisible: true, + slug: 'test-plan', + avatarUrl: 'https://example.com/avatar.png', + features: [], + __internal_toSnapshot: vi.fn(), + pathRoot: '', + reload: vi.fn(), +}; + +describe('PlanDetailsButton', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Rendering', () => { + it('renders with default "Plan details" text when no children provided', () => { + render(); + + expect(screen.getByText('Plan details')).toBeInTheDocument(); + }); + + it('renders custom button content when provided', () => { + render( + + + , + ); + + expect(screen.getByText('View Plan')).toBeInTheDocument(); + }); + + it('passes additional props to child element', () => { + render( + + + , + ); + + expect(screen.getByTestId('plan-details-btn')).toBeInTheDocument(); + }); + }); + + describe('Event Handling', () => { + it('calls clerk.__internal_openPlanDetails with planId when clicked', async () => { + const props = { + planId: 'test_plan', + appearance: {} as Theme, + initialPlanPeriod: 'month' as const, + }; + + render(); + + await userEvent.click(screen.getByText('Plan details')); + + await waitFor(() => { + expect(mockOpenPlanDetails).toHaveBeenCalledWith(expect.objectContaining(props)); + }); + }); + + it('calls clerk.__internal_openPlanDetails with plan object when clicked', async () => { + const props = { + plan: mockPlanResource, + appearance: {} as Theme, + }; + + render(); + + await userEvent.click(screen.getByText('Plan details')); + + await waitFor(() => { + expect(mockOpenPlanDetails).toHaveBeenCalledWith(expect.objectContaining(props)); + }); + }); + + it('executes child onClick handler before opening plan details', async () => { + const childOnClick = vi.fn(); + const props = { planId: 'test_plan' }; + + render( + + + , + ); + + await userEvent.click(screen.getByText('Custom Button')); + + await waitFor(() => { + expect(childOnClick).toHaveBeenCalled(); + expect(mockOpenPlanDetails).toHaveBeenCalledWith(expect.objectContaining(props)); + }); + }); + }); + + describe('Portal Configuration', () => { + it('handles portal configuration correctly', async () => { + const portalProps = { + planId: 'test_plan', + portalId: 'custom-portal', + portalRoot: document.createElement('div'), + }; + + render(); + + await userEvent.click(screen.getByText('Plan details')); + + await waitFor(() => { + expect(mockOpenPlanDetails).toHaveBeenCalledWith(expect.objectContaining(portalProps)); + }); + }); + }); +}); diff --git a/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx b/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx new file mode 100644 index 00000000000..54f3060982d --- /dev/null +++ b/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx @@ -0,0 +1,191 @@ +import '@testing-library/jest-dom/vitest'; + +import type { Theme } from '@clerk/types'; +import { render, screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAuth } from '../../hooks'; +import { SubscriptionDetailsButton } from '../SubscriptionDetailsButton'; + +// Mock the useAuth hook +vi.mock('../../hooks', () => ({ + useAuth: vi.fn(), +})); + +const mockOpenSubscriptionDetails = vi.fn(); + +const mockClerk = { + __internal_openSubscriptionDetails: mockOpenSubscriptionDetails, +}; + +// Mock the withClerk HOC +vi.mock('../withClerk', () => { + return { + withClerk: (Component: any) => (props: any) => { + return ( + + ); + }, + }; +}); + +describe('SubscriptionDetailsButton', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Authentication Validation', () => { + it('throws error when rendered without authenticated user', () => { + // Mock useAuth to return null userId + (useAuth as any).mockReturnValue({ userId: null, orgId: null }); + + // Expect the component to throw an error + expect(() => render()).toThrow( + 'Ensure that `` is rendered inside a `` component.', + ); + }); + + it('throws error when using org subscriber type without active organization', () => { + // Mock useAuth to return userId but no orgId + (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null }); + + // Expect the component to throw an error when for="org" + expect(() => render()).toThrow( + 'Wrap `` with a check for an active organization.', + ); + }); + + it('renders successfully with authenticated user', () => { + // Mock useAuth to return valid userId + (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null }); + + // Component should render without throwing + expect(() => render()).not.toThrow(); + }); + + it('renders successfully with org subscriber type when organization is active', () => { + // Mock useAuth to return both userId and orgId + (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: 'org_123' }); + + // Component should render without throwing + expect(() => render()).not.toThrow(); + }); + }); + + describe('Rendering', () => { + beforeEach(() => { + // Set up valid authentication for all rendering tests + (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null }); + }); + + it('renders with default "Subscription details" text when no children provided', () => { + render(); + + expect(screen.getByText('Subscription details')).toBeInTheDocument(); + }); + + it('renders custom button content when provided', () => { + render( + + + , + ); + + expect(screen.getByText('View Subscription')).toBeInTheDocument(); + }); + + it('passes additional props to child element', () => { + render( + + + , + ); + + expect(screen.getByTestId('subscription-btn')).toBeInTheDocument(); + }); + }); + + describe('Event Handling', () => { + beforeEach(() => { + // Set up valid authentication for all event tests + (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null }); + }); + + it('calls clerk.__internal_openSubscriptionDetails with correct props when clicked', async () => { + const onSubscriptionCancel = vi.fn(); + const props = { + for: 'user' as const, + appearance: {} as Theme, + onSubscriptionCancel, + }; + + render(); + + await userEvent.click(screen.getByText('Subscription details')); + + await waitFor(() => { + expect(mockOpenSubscriptionDetails).toHaveBeenCalledWith(expect.objectContaining(props)); + }); + }); + + it('executes child onClick handler before opening subscription details', async () => { + const childOnClick = vi.fn(); + + render( + + + , + ); + + await userEvent.click(screen.getByText('Custom Button')); + + await waitFor(() => { + expect(childOnClick).toHaveBeenCalled(); + expect(mockOpenSubscriptionDetails).toHaveBeenCalled(); + }); + }); + + it('calls onSubscriptionCancel when provided', async () => { + const onSubscriptionCancel = vi.fn(); + + render( + + + , + ); + + await userEvent.click(screen.getByText('Cancel Subscription')); + + await waitFor(() => { + expect(mockOpenSubscriptionDetails).toHaveBeenCalledWith(expect.objectContaining({ onSubscriptionCancel })); + }); + }); + }); + + describe('Portal Configuration', () => { + beforeEach(() => { + // Set up valid authentication for portal tests + (useAuth as any).mockReturnValue({ userId: 'user_123', orgId: null }); + }); + + it('handles portal configuration correctly', async () => { + const portalProps = { + portalId: 'custom-portal', + portalRoot: document.createElement('div'), + }; + + render(); + + await userEvent.click(screen.getByText('Subscription details')); + + await waitFor(() => { + expect(mockOpenSubscriptionDetails).toHaveBeenCalledWith(expect.objectContaining(portalProps)); + }); + }); + }); +}); From ae71b75b4053160fbda83d3ad6f6952f200e38ff Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 23 Jul 2025 11:52:10 +0300 Subject: [PATCH 6/9] move portal root one level deep --- packages/nextjs/src/experimental.ts | 5 ++ .../react/src/components/CheckoutButton.tsx | 23 ++--- .../src/components/PlanDetailsButton.tsx | 10 +-- .../components/SubscriptionDetailsButton.tsx | 14 +-- .../__tests__/CheckoutButton.test.tsx | 26 ++++-- .../__tests__/PlanDetailsButton.test.tsx | 26 ++++-- .../SubscriptionDetailsButton.test.tsx | 24 ++++-- packages/types/src/clerk.ts | 86 ++++++++++++++++++- 8 files changed, 165 insertions(+), 49 deletions(-) diff --git a/packages/nextjs/src/experimental.ts b/packages/nextjs/src/experimental.ts index 5ec9dd59fd6..b8a389fac7d 100644 --- a/packages/nextjs/src/experimental.ts +++ b/packages/nextjs/src/experimental.ts @@ -1,3 +1,8 @@ 'use client'; export { CheckoutButton, PlanDetailsButton, SubscriptionDetailsButton } from '@clerk/clerk-react/experimental'; +export type { + __experimental_CheckoutButtonProps as CheckoutButtonProps, + __experimental_SubscriptionDetailsButtonProps as SubscriptionDetailsButtonProps, + __experimental_PlanDetailsButtonProps as PlanDetailsButtonProps, +} from '@clerk/types'; diff --git a/packages/react/src/components/CheckoutButton.tsx b/packages/react/src/components/CheckoutButton.tsx index cee139e225c..4ede56a32c5 100644 --- a/packages/react/src/components/CheckoutButton.tsx +++ b/packages/react/src/components/CheckoutButton.tsx @@ -1,4 +1,4 @@ -import type { __internal_CheckoutProps } from '@clerk/types'; +import type { __experimental_CheckoutButtonProps } from '@clerk/types'; import React from 'react'; import { useAuth } from '../hooks'; @@ -45,19 +45,9 @@ import { withClerk } from './withClerk'; * @throws {Error} When `subscriberType="org"` is used without an active organization context */ export const CheckoutButton = withClerk( - ({ clerk, children, ...props }: WithClerkProp>) => { - const { - appearance, - planId, - planPeriod, - subscriberType, - onSubscriptionComplete, - portalId, - portalRoot, - newSubscriptionRedirectUrl, - onClose, - ...rest - } = props; + ({ clerk, children, ...props }: WithClerkProp>) => { + const { planId, planPeriod, subscriberType, onSubscriptionComplete, newSubscriptionRedirectUrl, drawer, ...rest } = + props; const { userId, orgId } = useAuth(); @@ -78,15 +68,12 @@ export const CheckoutButton = withClerk( } return clerk.__internal_openCheckout({ - appearance, planId, planPeriod, subscriberType, onSubscriptionComplete, - portalId, - portalRoot, newSubscriptionRedirectUrl, - onClose, + ...drawer, }); }; diff --git a/packages/react/src/components/PlanDetailsButton.tsx b/packages/react/src/components/PlanDetailsButton.tsx index 0c7fb0be280..54e7b830c78 100644 --- a/packages/react/src/components/PlanDetailsButton.tsx +++ b/packages/react/src/components/PlanDetailsButton.tsx @@ -1,4 +1,4 @@ -import type { __internal_PlanDetailsProps } from '@clerk/types'; +import type { __experimental_PlanDetailsButtonProps } from '@clerk/types'; import React from 'react'; import type { WithClerkProp } from '../types'; @@ -34,8 +34,8 @@ import { withClerk } from './withClerk'; * @see https://clerk.com/docs/billing/overview */ export const PlanDetailsButton = withClerk( - ({ clerk, children, ...props }: WithClerkProp>) => { - const { plan, planId, appearance, initialPlanPeriod, portalId, portalRoot, ...rest } = props; + ({ clerk, children, ...props }: WithClerkProp>) => { + const { plan, planId, initialPlanPeriod, drawer, ...rest } = props; children = normalizeWithDefaultValue(children, 'Plan details'); const child = assertSingleChild(children)('PlanDetailsButton'); @@ -47,10 +47,8 @@ export const PlanDetailsButton = withClerk( return clerk.__internal_openPlanDetails({ plan, planId, - appearance, initialPlanPeriod, - portalId, - portalRoot, + ...drawer, }); }; diff --git a/packages/react/src/components/SubscriptionDetailsButton.tsx b/packages/react/src/components/SubscriptionDetailsButton.tsx index cb4587ac350..c051325cce2 100644 --- a/packages/react/src/components/SubscriptionDetailsButton.tsx +++ b/packages/react/src/components/SubscriptionDetailsButton.tsx @@ -1,4 +1,4 @@ -import type { __internal_SubscriptionDetailsProps } from '@clerk/types'; +import type { __experimental_SubscriptionDetailsButtonProps } from '@clerk/types'; import React from 'react'; import { useAuth } from '../hooks'; @@ -41,8 +41,12 @@ import { withClerk } from './withClerk'; * @see https://clerk.com/docs/billing/overview */ export const SubscriptionDetailsButton = withClerk( - ({ clerk, children, ...props }: WithClerkProp>) => { - const { for: forProp, appearance, onSubscriptionCancel, portalId, portalRoot, ...rest } = props; + ({ + clerk, + children, + ...props + }: WithClerkProp>) => { + const { for: forProp, drawer, onSubscriptionCancel, ...rest } = props; children = normalizeWithDefaultValue(children, 'Subscription details'); const child = assertSingleChild(children)('SubscriptionDetailsButton'); @@ -65,10 +69,8 @@ export const SubscriptionDetailsButton = withClerk( return clerk.__internal_openSubscriptionDetails({ for: forProp, - appearance, onSubscriptionCancel, - portalId, - portalRoot, + ...drawer, }); }; diff --git a/packages/react/src/components/__tests__/CheckoutButton.test.tsx b/packages/react/src/components/__tests__/CheckoutButton.test.tsx index bdac70319c1..960418f0241 100644 --- a/packages/react/src/components/__tests__/CheckoutButton.test.tsx +++ b/packages/react/src/components/__tests__/CheckoutButton.test.tsx @@ -99,10 +99,12 @@ describe('CheckoutButton', () => { const props = { planId: 'test_plan', planPeriod: 'month' as const, - appearance: {} as Theme, onSubscriptionComplete: vi.fn(), newSubscriptionRedirectUrl: '/success', - onClose: vi.fn(), + drawer: { + appearance: {} as Theme, + onClose: vi.fn(), + }, }; render(); @@ -110,7 +112,15 @@ describe('CheckoutButton', () => { await userEvent.click(screen.getByText('Checkout')); await waitFor(() => { - expect(mockOpenCheckout).toHaveBeenCalledWith(expect.objectContaining(props)); + expect(mockOpenCheckout).toHaveBeenCalledWith( + expect.objectContaining({ + ...props.drawer, + planId: props.planId, + onSubscriptionComplete: props.onSubscriptionComplete, + newSubscriptionRedirectUrl: props.newSubscriptionRedirectUrl, + planPeriod: props.planPeriod, + }), + ); }); }); @@ -170,15 +180,19 @@ describe('CheckoutButton', () => { it('handles portal configuration correctly', async () => { const portalProps = { planId: 'test_plan', - portalId: 'custom-portal', - portalRoot: document.createElement('div'), + drawer: { + portalId: 'custom-portal', + portalRoot: document.createElement('div'), + }, }; render(); await userEvent.click(screen.getByText('Checkout')); await waitFor(() => { - expect(mockOpenCheckout).toHaveBeenCalledWith(expect.objectContaining(portalProps)); + expect(mockOpenCheckout).toHaveBeenCalledWith( + expect.objectContaining({ ...portalProps.drawer, planId: portalProps.planId }), + ); }); }); }); diff --git a/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx b/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx index 2d28ef5e29f..31b6f5ddbd4 100644 --- a/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx +++ b/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx @@ -93,8 +93,10 @@ describe('PlanDetailsButton', () => { it('calls clerk.__internal_openPlanDetails with planId when clicked', async () => { const props = { planId: 'test_plan', - appearance: {} as Theme, initialPlanPeriod: 'month' as const, + drawer: { + appearance: {} as Theme, + }, }; render(); @@ -102,14 +104,18 @@ describe('PlanDetailsButton', () => { await userEvent.click(screen.getByText('Plan details')); await waitFor(() => { - expect(mockOpenPlanDetails).toHaveBeenCalledWith(expect.objectContaining(props)); + expect(mockOpenPlanDetails).toHaveBeenCalledWith( + expect.objectContaining({ ...props.drawer, planId: props.planId }), + ); }); }); it('calls clerk.__internal_openPlanDetails with plan object when clicked', async () => { const props = { plan: mockPlanResource, - appearance: {} as Theme, + drawer: { + appearance: {} as Theme, + }, }; render(); @@ -117,7 +123,9 @@ describe('PlanDetailsButton', () => { await userEvent.click(screen.getByText('Plan details')); await waitFor(() => { - expect(mockOpenPlanDetails).toHaveBeenCalledWith(expect.objectContaining(props)); + expect(mockOpenPlanDetails).toHaveBeenCalledWith( + expect.objectContaining({ ...props.drawer, plan: props.plan }), + ); }); }); @@ -144,8 +152,10 @@ describe('PlanDetailsButton', () => { it('handles portal configuration correctly', async () => { const portalProps = { planId: 'test_plan', - portalId: 'custom-portal', - portalRoot: document.createElement('div'), + drawer: { + portalId: 'custom-portal', + portalRoot: document.createElement('div'), + }, }; render(); @@ -153,7 +163,9 @@ describe('PlanDetailsButton', () => { await userEvent.click(screen.getByText('Plan details')); await waitFor(() => { - expect(mockOpenPlanDetails).toHaveBeenCalledWith(expect.objectContaining(portalProps)); + expect(mockOpenPlanDetails).toHaveBeenCalledWith( + expect.objectContaining({ ...portalProps.drawer, planId: portalProps.planId }), + ); }); }); }); diff --git a/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx b/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx index 54f3060982d..67806f710c2 100644 --- a/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx +++ b/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx @@ -120,8 +120,10 @@ describe('SubscriptionDetailsButton', () => { const onSubscriptionCancel = vi.fn(); const props = { for: 'user' as const, - appearance: {} as Theme, onSubscriptionCancel, + drawer: { + appearance: {} as Theme, + }, }; render(); @@ -129,7 +131,13 @@ describe('SubscriptionDetailsButton', () => { await userEvent.click(screen.getByText('Subscription details')); await waitFor(() => { - expect(mockOpenSubscriptionDetails).toHaveBeenCalledWith(expect.objectContaining(props)); + expect(mockOpenSubscriptionDetails).toHaveBeenCalledWith( + expect.objectContaining({ + ...props.drawer, + for: props.for, + onSubscriptionCancel: props.onSubscriptionCancel, + }), + ); }); }); @@ -175,8 +183,10 @@ describe('SubscriptionDetailsButton', () => { it('handles portal configuration correctly', async () => { const portalProps = { - portalId: 'custom-portal', - portalRoot: document.createElement('div'), + drawer: { + portalId: 'custom-portal', + portalRoot: document.createElement('div'), + }, }; render(); @@ -184,7 +194,11 @@ describe('SubscriptionDetailsButton', () => { await userEvent.click(screen.getByText('Subscription details')); await waitFor(() => { - expect(mockOpenSubscriptionDetails).toHaveBeenCalledWith(expect.objectContaining(portalProps)); + expect(mockOpenSubscriptionDetails).toHaveBeenCalledWith( + expect.objectContaining({ + ...portalProps.drawer, + }), + ); }); }); }); diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 543f45d40a0..d2b73d78f86 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -295,7 +295,7 @@ export interface Clerk { __internal_closeSubscriptionDetails: () => void; /** - /** Opens the Clerk UserVerification component in a modal. + * Opens the Clerk UserVerification component in a modal. * @param props Optional user verification configuration parameters. */ __internal_openReverification: (props?: __internal_UserVerificationModalProps) => void; @@ -1824,6 +1824,34 @@ export type __internal_CheckoutProps = { onClose?: () => void; }; +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. + * @see https://clerk.com/docs/billing/overview + * + * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ +export type __experimental_CheckoutButtonProps = { + planId: string; + planPeriod?: CommerceSubscriptionPlanPeriod; + subscriberType?: CommercePayerType; + onSubscriptionComplete?: () => void; + drawer?: { + appearance?: CheckoutTheme; + portalId?: string; + portalRoot?: HTMLElement | null | undefined; + onClose?: () => void; + }; + /** + * Full URL or path to navigate to after checkout is complete and the user clicks the "Continue" button. + * @default undefined + */ + newSubscriptionRedirectUrl?: string; +}; + /** * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. * @see https://clerk.com/docs/billing/overview @@ -1843,6 +1871,37 @@ export type __internal_PlanDetailsProps = { portalRoot?: PortalRoot; }; +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. + * @see https://clerk.com/docs/billing/overview + * + * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ +export type __experimental_PlanDetailsButtonProps = { + plan?: CommercePlanResource; + planId?: string; + initialPlanPeriod?: CommerceSubscriptionPlanPeriod; + drawer?: { + appearance?: PlanDetailTheme; + portalId?: string; + portalRoot?: PortalRoot; + }; +}; + +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. + * @see https://clerk.com/docs/billing/overview + * + * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ export type __internal_SubscriptionDetailsProps = { /** * The subscriber type to display the subscription details for. @@ -1856,6 +1915,31 @@ export type __internal_SubscriptionDetailsProps = { portalRoot?: PortalRoot; }; +/** + * @experimental This is an experimental API for the Billing feature that is available under a public beta, and the API is subject to change. + * @see https://clerk.com/docs/billing/overview + * + * It is advised to pin the SDK version and the clerk-js version to a specific version to avoid breaking changes. + * @example + * ```tsx + * + * ``` + */ +export type __experimental_SubscriptionDetailsButtonProps = { + /** + * 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?: CommercePayerType; + onSubscriptionCancel?: () => void; + drawer?: { + appearance?: SubscriptionDetailsTheme; + portalId?: string; + portalRoot?: PortalRoot; + }; +}; + export type __internal_OAuthConsentProps = { appearance?: OAuthConsentTheme; /** From 8b1de67cc2b5187e828ccb653aff9d46bf840c05 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 23 Jul 2025 12:46:40 +0300 Subject: [PATCH 7/9] update type docs --- .typedoc/__tests__/__snapshots__/file-structure.test.ts.snap | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap index f24a109e99b..99120b23031 100644 --- a/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap +++ b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap @@ -61,6 +61,9 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = ` "types/create-organization-params.mdx", "types/element-object-key.mdx", "types/elements-config.mdx", + "types/experimental_checkout-button-props.mdx", + "types/experimental_plan-details-button-props.mdx", + "types/experimental_subscription-details-button-props.mdx", "types/get-payment-attempts-params.mdx", "types/get-payment-sources-params.mdx", "types/get-plans-params.mdx", @@ -71,6 +74,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = ` "types/initialize-payment-source-params.mdx", "types/internal_checkout-props.mdx", "types/internal_plan-details-props.mdx", + "types/internal_subscription-details-props.mdx", "types/jwt-claims.mdx", "types/jwt-header.mdx", "types/legacy-redirect-props.mdx", From 8b76c9757a201252f3bfeb40a251cb285bd9bd4b Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 23 Jul 2025 18:49:01 +0300 Subject: [PATCH 8/9] replace drawer object with the name of the component --- packages/react/src/components/CheckoutButton.tsx | 13 ++++++++++--- packages/react/src/components/PlanDetailsButton.tsx | 4 ++-- .../src/components/SubscriptionDetailsButton.tsx | 4 ++-- .../components/__tests__/CheckoutButton.test.tsx | 8 ++++---- .../components/__tests__/PlanDetailsButton.test.tsx | 12 ++++++------ .../__tests__/SubscriptionDetailsButton.test.tsx | 8 ++++---- packages/types/src/clerk.ts | 6 +++--- 7 files changed, 31 insertions(+), 24 deletions(-) diff --git a/packages/react/src/components/CheckoutButton.tsx b/packages/react/src/components/CheckoutButton.tsx index 4ede56a32c5..27ae4d04060 100644 --- a/packages/react/src/components/CheckoutButton.tsx +++ b/packages/react/src/components/CheckoutButton.tsx @@ -46,8 +46,15 @@ import { withClerk } from './withClerk'; */ export const CheckoutButton = withClerk( ({ clerk, children, ...props }: WithClerkProp>) => { - const { planId, planPeriod, subscriberType, onSubscriptionComplete, newSubscriptionRedirectUrl, drawer, ...rest } = - props; + const { + planId, + planPeriod, + subscriberType, + onSubscriptionComplete, + newSubscriptionRedirectUrl, + checkoutProps, + ...rest + } = props; const { userId, orgId } = useAuth(); @@ -73,7 +80,7 @@ export const CheckoutButton = withClerk( subscriberType, onSubscriptionComplete, newSubscriptionRedirectUrl, - ...drawer, + ...checkoutProps, }); }; diff --git a/packages/react/src/components/PlanDetailsButton.tsx b/packages/react/src/components/PlanDetailsButton.tsx index 54e7b830c78..a4d80f06f03 100644 --- a/packages/react/src/components/PlanDetailsButton.tsx +++ b/packages/react/src/components/PlanDetailsButton.tsx @@ -35,7 +35,7 @@ import { withClerk } from './withClerk'; */ export const PlanDetailsButton = withClerk( ({ clerk, children, ...props }: WithClerkProp>) => { - const { plan, planId, initialPlanPeriod, drawer, ...rest } = props; + const { plan, planId, initialPlanPeriod, planDetailsProps, ...rest } = props; children = normalizeWithDefaultValue(children, 'Plan details'); const child = assertSingleChild(children)('PlanDetailsButton'); @@ -48,7 +48,7 @@ export const PlanDetailsButton = withClerk( plan, planId, initialPlanPeriod, - ...drawer, + ...planDetailsProps, }); }; diff --git a/packages/react/src/components/SubscriptionDetailsButton.tsx b/packages/react/src/components/SubscriptionDetailsButton.tsx index c051325cce2..3a8e585b637 100644 --- a/packages/react/src/components/SubscriptionDetailsButton.tsx +++ b/packages/react/src/components/SubscriptionDetailsButton.tsx @@ -46,7 +46,7 @@ export const SubscriptionDetailsButton = withClerk( children, ...props }: WithClerkProp>) => { - const { for: forProp, drawer, onSubscriptionCancel, ...rest } = props; + const { for: forProp, subscriptionDetailsProps, onSubscriptionCancel, ...rest } = props; children = normalizeWithDefaultValue(children, 'Subscription details'); const child = assertSingleChild(children)('SubscriptionDetailsButton'); @@ -70,7 +70,7 @@ export const SubscriptionDetailsButton = withClerk( return clerk.__internal_openSubscriptionDetails({ for: forProp, onSubscriptionCancel, - ...drawer, + ...subscriptionDetailsProps, }); }; diff --git a/packages/react/src/components/__tests__/CheckoutButton.test.tsx b/packages/react/src/components/__tests__/CheckoutButton.test.tsx index 960418f0241..69e94f45632 100644 --- a/packages/react/src/components/__tests__/CheckoutButton.test.tsx +++ b/packages/react/src/components/__tests__/CheckoutButton.test.tsx @@ -101,7 +101,7 @@ describe('CheckoutButton', () => { planPeriod: 'month' as const, onSubscriptionComplete: vi.fn(), newSubscriptionRedirectUrl: '/success', - drawer: { + planDetailsProps: { appearance: {} as Theme, onClose: vi.fn(), }, @@ -114,7 +114,7 @@ describe('CheckoutButton', () => { await waitFor(() => { expect(mockOpenCheckout).toHaveBeenCalledWith( expect.objectContaining({ - ...props.drawer, + ...props.planDetailsProps, planId: props.planId, onSubscriptionComplete: props.onSubscriptionComplete, newSubscriptionRedirectUrl: props.newSubscriptionRedirectUrl, @@ -180,7 +180,7 @@ describe('CheckoutButton', () => { it('handles portal configuration correctly', async () => { const portalProps = { planId: 'test_plan', - drawer: { + planDetailsProps: { portalId: 'custom-portal', portalRoot: document.createElement('div'), }, @@ -191,7 +191,7 @@ describe('CheckoutButton', () => { await userEvent.click(screen.getByText('Checkout')); await waitFor(() => { expect(mockOpenCheckout).toHaveBeenCalledWith( - expect.objectContaining({ ...portalProps.drawer, planId: portalProps.planId }), + expect.objectContaining({ ...portalProps.planDetailsProps, planId: portalProps.planId }), ); }); }); diff --git a/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx b/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx index 31b6f5ddbd4..45a9ca786a7 100644 --- a/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx +++ b/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx @@ -94,7 +94,7 @@ describe('PlanDetailsButton', () => { const props = { planId: 'test_plan', initialPlanPeriod: 'month' as const, - drawer: { + planDetailsProps: { appearance: {} as Theme, }, }; @@ -105,7 +105,7 @@ describe('PlanDetailsButton', () => { await waitFor(() => { expect(mockOpenPlanDetails).toHaveBeenCalledWith( - expect.objectContaining({ ...props.drawer, planId: props.planId }), + expect.objectContaining({ ...props.planDetailsProps, planId: props.planId }), ); }); }); @@ -113,7 +113,7 @@ describe('PlanDetailsButton', () => { it('calls clerk.__internal_openPlanDetails with plan object when clicked', async () => { const props = { plan: mockPlanResource, - drawer: { + planDetailsProps: { appearance: {} as Theme, }, }; @@ -124,7 +124,7 @@ describe('PlanDetailsButton', () => { await waitFor(() => { expect(mockOpenPlanDetails).toHaveBeenCalledWith( - expect.objectContaining({ ...props.drawer, plan: props.plan }), + expect.objectContaining({ ...props.planDetailsProps, plan: props.plan }), ); }); }); @@ -152,7 +152,7 @@ describe('PlanDetailsButton', () => { it('handles portal configuration correctly', async () => { const portalProps = { planId: 'test_plan', - drawer: { + planDetailsProps: { portalId: 'custom-portal', portalRoot: document.createElement('div'), }, @@ -164,7 +164,7 @@ describe('PlanDetailsButton', () => { await waitFor(() => { expect(mockOpenPlanDetails).toHaveBeenCalledWith( - expect.objectContaining({ ...portalProps.drawer, planId: portalProps.planId }), + expect.objectContaining({ ...portalProps.planDetailsProps, planId: portalProps.planId }), ); }); }); diff --git a/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx b/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx index 67806f710c2..27bc63ff948 100644 --- a/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx +++ b/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx @@ -121,7 +121,7 @@ describe('SubscriptionDetailsButton', () => { const props = { for: 'user' as const, onSubscriptionCancel, - drawer: { + planDetailsProps: { appearance: {} as Theme, }, }; @@ -133,7 +133,7 @@ describe('SubscriptionDetailsButton', () => { await waitFor(() => { expect(mockOpenSubscriptionDetails).toHaveBeenCalledWith( expect.objectContaining({ - ...props.drawer, + ...props.planDetailsProps, for: props.for, onSubscriptionCancel: props.onSubscriptionCancel, }), @@ -183,7 +183,7 @@ describe('SubscriptionDetailsButton', () => { it('handles portal configuration correctly', async () => { const portalProps = { - drawer: { + planDetailsProps: { portalId: 'custom-portal', portalRoot: document.createElement('div'), }, @@ -196,7 +196,7 @@ describe('SubscriptionDetailsButton', () => { await waitFor(() => { expect(mockOpenSubscriptionDetails).toHaveBeenCalledWith( expect.objectContaining({ - ...portalProps.drawer, + ...portalProps.planDetailsProps, }), ); }); diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 981c9663e7c..652e06174de 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1847,7 +1847,7 @@ export type __experimental_CheckoutButtonProps = { planPeriod?: CommerceSubscriptionPlanPeriod; subscriberType?: CommercePayerType; onSubscriptionComplete?: () => void; - drawer?: { + checkoutProps?: { appearance?: CheckoutTheme; portalId?: string; portalRoot?: HTMLElement | null | undefined; @@ -1893,7 +1893,7 @@ export type __experimental_PlanDetailsButtonProps = { plan?: CommercePlanResource; planId?: string; initialPlanPeriod?: CommerceSubscriptionPlanPeriod; - drawer?: { + planDetailsProps?: { appearance?: PlanDetailTheme; portalId?: string; portalRoot?: PortalRoot; @@ -1941,7 +1941,7 @@ export type __experimental_SubscriptionDetailsButtonProps = { */ for?: CommercePayerType; onSubscriptionCancel?: () => void; - drawer?: { + subscriptionDetailsProps?: { appearance?: SubscriptionDetailsTheme; portalId?: string; portalRoot?: PortalRoot; From 8f29cb49356876f86b53282d713ff752ffdea479 Mon Sep 17 00:00:00 2001 From: panteliselef Date: Wed, 23 Jul 2025 18:56:25 +0300 Subject: [PATCH 9/9] replace drawer object with the name of the component --- .../src/components/__tests__/CheckoutButton.test.tsx | 8 ++++---- .../__tests__/SubscriptionDetailsButton.test.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/react/src/components/__tests__/CheckoutButton.test.tsx b/packages/react/src/components/__tests__/CheckoutButton.test.tsx index 69e94f45632..6bfb28b0bc0 100644 --- a/packages/react/src/components/__tests__/CheckoutButton.test.tsx +++ b/packages/react/src/components/__tests__/CheckoutButton.test.tsx @@ -101,7 +101,7 @@ describe('CheckoutButton', () => { planPeriod: 'month' as const, onSubscriptionComplete: vi.fn(), newSubscriptionRedirectUrl: '/success', - planDetailsProps: { + checkoutProps: { appearance: {} as Theme, onClose: vi.fn(), }, @@ -114,7 +114,7 @@ describe('CheckoutButton', () => { await waitFor(() => { expect(mockOpenCheckout).toHaveBeenCalledWith( expect.objectContaining({ - ...props.planDetailsProps, + ...props.checkoutProps, planId: props.planId, onSubscriptionComplete: props.onSubscriptionComplete, newSubscriptionRedirectUrl: props.newSubscriptionRedirectUrl, @@ -180,7 +180,7 @@ describe('CheckoutButton', () => { it('handles portal configuration correctly', async () => { const portalProps = { planId: 'test_plan', - planDetailsProps: { + checkoutProps: { portalId: 'custom-portal', portalRoot: document.createElement('div'), }, @@ -191,7 +191,7 @@ describe('CheckoutButton', () => { await userEvent.click(screen.getByText('Checkout')); await waitFor(() => { expect(mockOpenCheckout).toHaveBeenCalledWith( - expect.objectContaining({ ...portalProps.planDetailsProps, planId: portalProps.planId }), + expect.objectContaining({ ...portalProps.checkoutProps, planId: portalProps.planId }), ); }); }); diff --git a/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx b/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx index 27bc63ff948..2c1c974555c 100644 --- a/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx +++ b/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx @@ -121,7 +121,7 @@ describe('SubscriptionDetailsButton', () => { const props = { for: 'user' as const, onSubscriptionCancel, - planDetailsProps: { + subscriptionDetailsProps: { appearance: {} as Theme, }, }; @@ -133,7 +133,7 @@ describe('SubscriptionDetailsButton', () => { await waitFor(() => { expect(mockOpenSubscriptionDetails).toHaveBeenCalledWith( expect.objectContaining({ - ...props.planDetailsProps, + ...props.subscriptionDetailsProps, for: props.for, onSubscriptionCancel: props.onSubscriptionCancel, }), @@ -183,7 +183,7 @@ describe('SubscriptionDetailsButton', () => { it('handles portal configuration correctly', async () => { const portalProps = { - planDetailsProps: { + subscriptionDetailsProps: { portalId: 'custom-portal', portalRoot: document.createElement('div'), }, @@ -196,7 +196,7 @@ describe('SubscriptionDetailsButton', () => { await waitFor(() => { expect(mockOpenSubscriptionDetails).toHaveBeenCalledWith( expect.objectContaining({ - ...portalProps.planDetailsProps, + ...portalProps.subscriptionDetailsProps, }), ); });