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/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap b/.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap index a36c2dbcd77..a1a95c46118 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", diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 7ccfc7f0ead..f7a7839b994 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/nextjs/src/experimental.ts b/packages/nextjs/src/experimental.ts new file mode 100644 index 00000000000..b8a389fac7d --- /dev/null +++ b/packages/nextjs/src/experimental.ts @@ -0,0 +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/package.json b/packages/react/package.json index 71bb8d8f20f..ea64b82e029 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", diff --git a/packages/react/src/components/CheckoutButton.tsx b/packages/react/src/components/CheckoutButton.tsx new file mode 100644 index 00000000000..27ae4d04060 --- /dev/null +++ b/packages/react/src/components/CheckoutButton.tsx @@ -0,0 +1,98 @@ +import type { __experimental_CheckoutButtonProps } 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'; + +/** + * @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 { + planId, + planPeriod, + subscriberType, + onSubscriptionComplete, + newSubscriptionRedirectUrl, + checkoutProps, + ...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'); + + const clickHandler = () => { + if (!clerk) { + return; + } + + return clerk.__internal_openCheckout({ + planId, + planPeriod, + subscriberType, + onSubscriptionComplete, + newSubscriptionRedirectUrl, + ...checkoutProps, + }); + }; + + const wrappedChildClickHandler: React.MouseEventHandler = async e => { + if (child && typeof child === 'object' && 'props' in child) { + await safeExecute(child.props.onClick)(e); + } + return clickHandler(); + }; + + const childProps = { ...rest, onClick: wrappedChildClickHandler }; + return React.cloneElement(child as React.ReactElement, childProps); + }, + { component: 'CheckoutButton', renderWhileLoading: true }, +); diff --git a/packages/react/src/components/PlanDetailsButton.tsx b/packages/react/src/components/PlanDetailsButton.tsx new file mode 100644 index 00000000000..a4d80f06f03 --- /dev/null +++ b/packages/react/src/components/PlanDetailsButton.tsx @@ -0,0 +1,66 @@ +import type { __experimental_PlanDetailsButtonProps } from '@clerk/types'; +import React from 'react'; + +import type { WithClerkProp } from '../types'; +import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils'; +import { withClerk } from './withClerk'; + +/** + * @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, initialPlanPeriod, planDetailsProps, ...rest } = props; + children = normalizeWithDefaultValue(children, 'Plan details'); + const child = assertSingleChild(children)('PlanDetailsButton'); + + const clickHandler = () => { + if (!clerk) { + return; + } + + return clerk.__internal_openPlanDetails({ + plan, + planId, + initialPlanPeriod, + ...planDetailsProps, + }); + }; + + const wrappedChildClickHandler: React.MouseEventHandler = async e => { + if (child && typeof child === 'object' && 'props' in child) { + await safeExecute(child.props.onClick)(e); + } + return clickHandler(); + }; + + const childProps = { ...rest, onClick: wrappedChildClickHandler }; + return React.cloneElement(child as React.ReactElement, childProps); + }, + { component: 'PlanDetailsButton', renderWhileLoading: true }, +); diff --git a/packages/react/src/components/SubscriptionDetailsButton.tsx b/packages/react/src/components/SubscriptionDetailsButton.tsx new file mode 100644 index 00000000000..3a8e585b637 --- /dev/null +++ b/packages/react/src/components/SubscriptionDetailsButton.tsx @@ -0,0 +1,88 @@ +import type { __experimental_SubscriptionDetailsButtonProps } 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'; + +/** + * @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, subscriptionDetailsProps, onSubscriptionCancel, ...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({ + for: forProp, + onSubscriptionCancel, + ...subscriptionDetailsProps, + }); + }; + + const wrappedChildClickHandler: React.MouseEventHandler = async e => { + if (child && typeof child === 'object' && 'props' in child) { + await safeExecute(child.props.onClick)(e); + } + return clickHandler(); + }; + + const childProps = { ...rest, onClick: wrappedChildClickHandler }; + return React.cloneElement(child as React.ReactElement, childProps); + }, + { component: 'SubscriptionDetailsButton', renderWhileLoading: true }, +); 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..6bfb28b0bc0 --- /dev/null +++ b/packages/react/src/components/__tests__/CheckoutButton.test.tsx @@ -0,0 +1,199 @@ +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, + onSubscriptionComplete: vi.fn(), + newSubscriptionRedirectUrl: '/success', + checkoutProps: { + appearance: {} as Theme, + onClose: vi.fn(), + }, + }; + + render(); + + await userEvent.click(screen.getByText('Checkout')); + + await waitFor(() => { + expect(mockOpenCheckout).toHaveBeenCalledWith( + expect.objectContaining({ + ...props.checkoutProps, + planId: props.planId, + onSubscriptionComplete: props.onSubscriptionComplete, + newSubscriptionRedirectUrl: props.newSubscriptionRedirectUrl, + planPeriod: props.planPeriod, + }), + ); + }); + }); + + 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', + checkoutProps: { + portalId: 'custom-portal', + portalRoot: document.createElement('div'), + }, + }; + + render(); + + await userEvent.click(screen.getByText('Checkout')); + await waitFor(() => { + expect(mockOpenCheckout).toHaveBeenCalledWith( + expect.objectContaining({ ...portalProps.checkoutProps, planId: portalProps.planId }), + ); + }); + }); + }); +}); 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..45a9ca786a7 --- /dev/null +++ b/packages/react/src/components/__tests__/PlanDetailsButton.test.tsx @@ -0,0 +1,172 @@ +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', + initialPlanPeriod: 'month' as const, + planDetailsProps: { + appearance: {} as Theme, + }, + }; + + render(); + + await userEvent.click(screen.getByText('Plan details')); + + await waitFor(() => { + expect(mockOpenPlanDetails).toHaveBeenCalledWith( + expect.objectContaining({ ...props.planDetailsProps, planId: props.planId }), + ); + }); + }); + + it('calls clerk.__internal_openPlanDetails with plan object when clicked', async () => { + const props = { + plan: mockPlanResource, + planDetailsProps: { + appearance: {} as Theme, + }, + }; + + render(); + + await userEvent.click(screen.getByText('Plan details')); + + await waitFor(() => { + expect(mockOpenPlanDetails).toHaveBeenCalledWith( + expect.objectContaining({ ...props.planDetailsProps, plan: props.plan }), + ); + }); + }); + + 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', + planDetailsProps: { + portalId: 'custom-portal', + portalRoot: document.createElement('div'), + }, + }; + + render(); + + await userEvent.click(screen.getByText('Plan details')); + + await waitFor(() => { + expect(mockOpenPlanDetails).toHaveBeenCalledWith( + 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 new file mode 100644 index 00000000000..2c1c974555c --- /dev/null +++ b/packages/react/src/components/__tests__/SubscriptionDetailsButton.test.tsx @@ -0,0 +1,205 @@ +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, + onSubscriptionCancel, + subscriptionDetailsProps: { + appearance: {} as Theme, + }, + }; + + render(); + + await userEvent.click(screen.getByText('Subscription details')); + + await waitFor(() => { + expect(mockOpenSubscriptionDetails).toHaveBeenCalledWith( + expect.objectContaining({ + ...props.subscriptionDetailsProps, + for: props.for, + onSubscriptionCancel: props.onSubscriptionCancel, + }), + ); + }); + }); + + 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 = { + subscriptionDetailsProps: { + portalId: 'custom-portal', + portalRoot: document.createElement('div'), + }, + }; + + render(); + + await userEvent.click(screen.getByText('Subscription details')); + + await waitFor(() => { + expect(mockOpenSubscriptionDetails).toHaveBeenCalledWith( + expect.objectContaining({ + ...portalProps.subscriptionDetailsProps, + }), + ); + }); + }); + }); +}); 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, diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 3abdb10a664..652e06174de 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; @@ -1832,6 +1832,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; + checkoutProps?: { + 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 @@ -1851,6 +1879,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; + planDetailsProps?: { + 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. @@ -1864,6 +1923,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; + subscriptionDetailsProps?: { + appearance?: SubscriptionDetailsTheme; + portalId?: string; + portalRoot?: PortalRoot; + }; +}; + export type __internal_OAuthConsentProps = { appearance?: OAuthConsentTheme; /**