Skip to content

Commit 8feb59b

Browse files
authored
feat(clerk-react, nextjs): Introduce commerce buttons (#6365)
1 parent cd59c0e commit 8feb59b

16 files changed

+966
-3
lines changed

.changeset/curly-jeans-sleep.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/clerk-react': minor
3+
---
4+
5+
Expose `<CheckoutButton/>`, `<SubscriptionDetailsButton/>`, `<PlanDetailsButton/>` from `@clerk/clerk-react/experimental`.

.changeset/dark-coins-shake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/nextjs': minor
3+
---
4+
5+
Expose `<CheckoutButton/>`, `<SubscriptionDetailsButton/>`, `<PlanDetailsButton/>` from `@clerk/nextjs/experimental`.

.typedoc/__tests__/__snapshots__/file-structure.test.ts.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = `
6363
"types/create-organization-params.mdx",
6464
"types/element-object-key.mdx",
6565
"types/elements-config.mdx",
66+
"types/experimental_checkout-button-props.mdx",
67+
"types/experimental_plan-details-button-props.mdx",
68+
"types/experimental_subscription-details-button-props.mdx",
6669
"types/get-payment-attempts-params.mdx",
6770
"types/get-payment-sources-params.mdx",
6871
"types/get-plans-params.mdx",
@@ -74,6 +77,7 @@ exports[`Typedoc output > should have a deliberate file structure 1`] = `
7477
"types/initialize-payment-source-params.mdx",
7578
"types/internal_checkout-props.mdx",
7679
"types/internal_plan-details-props.mdx",
80+
"types/internal_subscription-details-props.mdx",
7781
"types/jwt-claims.mdx",
7882
"types/jwt-header.mdx",
7983
"types/legacy-redirect-props.mdx",

packages/nextjs/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@
4949
"types": "./dist/types/webhooks.d.ts",
5050
"import": "./dist/esm/webhooks.js",
5151
"require": "./dist/cjs/webhooks.js"
52+
},
53+
"./experimental": {
54+
"types": "./dist/types/experimental.d.ts",
55+
"import": "./dist/esm/experimental.js",
56+
"require": "./dist/cjs/experimental.js"
5257
}
5358
},
5459
"types": "./dist/types/index.d.ts",

packages/nextjs/src/experimental.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
'use client';
2+
3+
export { CheckoutButton, PlanDetailsButton, SubscriptionDetailsButton } from '@clerk/clerk-react/experimental';
4+
export type {
5+
__experimental_CheckoutButtonProps as CheckoutButtonProps,
6+
__experimental_SubscriptionDetailsButtonProps as SubscriptionDetailsButtonProps,
7+
__experimental_PlanDetailsButtonProps as PlanDetailsButtonProps,
8+
} from '@clerk/types';

packages/react/package.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,24 @@
5353
"default": "./dist/errors.js"
5454
}
5555
},
56+
"./experimental": {
57+
"import": {
58+
"types": "./dist/experimental.d.mts",
59+
"default": "./dist/experimental.mjs"
60+
},
61+
"require": {
62+
"types": "./dist/experimental.d.ts",
63+
"default": "./dist/experimental.js"
64+
}
65+
},
5666
"./package.json": "./package.json"
5767
},
5868
"main": "./dist/index.js",
5969
"files": [
6070
"dist",
6171
"internal",
62-
"errors"
72+
"errors",
73+
"experimental"
6374
],
6475
"scripts": {
6576
"build": "tsup",
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { __experimental_CheckoutButtonProps } from '@clerk/types';
2+
import React from 'react';
3+
4+
import { useAuth } from '../hooks';
5+
import type { WithClerkProp } from '../types';
6+
import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils';
7+
import { withClerk } from './withClerk';
8+
9+
/**
10+
* @experimental A button component that opens the Clerk Checkout drawer when clicked. This component must be rendered
11+
* inside a `<SignedIn />` component to ensure the user is authenticated.
12+
*
13+
* @example
14+
* ```tsx
15+
* import { SignedIn } from '@clerk/clerk-react';
16+
* import { CheckoutButton } from '@clerk/clerk-react/experimental';
17+
*
18+
* // Basic usage with default "Checkout" text
19+
* function BasicCheckout() {
20+
* return (
21+
* <SignedIn>
22+
* <CheckoutButton planId="plan_123" />
23+
* </SignedIn>
24+
* );
25+
* }
26+
*
27+
* // Custom button with organization subscription
28+
* function OrganizationCheckout() {
29+
* return (
30+
* <SignedIn>
31+
* <CheckoutButton
32+
* planId="plan_123"
33+
* planPeriod="month"
34+
* subscriberType="org"
35+
* onSubscriptionComplete={() => console.log('Subscription completed!')}
36+
* >
37+
* <button className="custom-button">Subscribe Now</button>
38+
* </CheckoutButton>
39+
* </SignedIn>
40+
* );
41+
* }
42+
* ```
43+
*
44+
* @throws {Error} When rendered outside of a `<SignedIn />` component
45+
* @throws {Error} When `subscriberType="org"` is used without an active organization context
46+
*/
47+
export const CheckoutButton = withClerk(
48+
({ clerk, children, ...props }: WithClerkProp<React.PropsWithChildren<__experimental_CheckoutButtonProps>>) => {
49+
const {
50+
planId,
51+
planPeriod,
52+
subscriberType,
53+
onSubscriptionComplete,
54+
newSubscriptionRedirectUrl,
55+
checkoutProps,
56+
...rest
57+
} = props;
58+
59+
const { userId, orgId } = useAuth();
60+
61+
if (userId === null) {
62+
throw new Error('Ensure that `<CheckoutButton />` is rendered inside a `<SignedIn />` component.');
63+
}
64+
65+
if (orgId === null && subscriberType === 'org') {
66+
throw new Error('Wrap `<CheckoutButton for="organization" />` with a check for an active organization.');
67+
}
68+
69+
children = normalizeWithDefaultValue(children, 'Checkout');
70+
const child = assertSingleChild(children)('CheckoutButton');
71+
72+
const clickHandler = () => {
73+
if (!clerk) {
74+
return;
75+
}
76+
77+
return clerk.__internal_openCheckout({
78+
planId,
79+
planPeriod,
80+
subscriberType,
81+
onSubscriptionComplete,
82+
newSubscriptionRedirectUrl,
83+
...checkoutProps,
84+
});
85+
};
86+
87+
const wrappedChildClickHandler: React.MouseEventHandler = async e => {
88+
if (child && typeof child === 'object' && 'props' in child) {
89+
await safeExecute(child.props.onClick)(e);
90+
}
91+
return clickHandler();
92+
};
93+
94+
const childProps = { ...rest, onClick: wrappedChildClickHandler };
95+
return React.cloneElement(child as React.ReactElement<unknown>, childProps);
96+
},
97+
{ component: 'CheckoutButton', renderWhileLoading: true },
98+
);
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { __experimental_PlanDetailsButtonProps } from '@clerk/types';
2+
import React from 'react';
3+
4+
import type { WithClerkProp } from '../types';
5+
import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils';
6+
import { withClerk } from './withClerk';
7+
8+
/**
9+
* @experimental A button component that opens the Clerk Plan Details drawer when clicked. This component is part of
10+
* Clerk's Billing feature which is available under a public beta.
11+
*
12+
* @example
13+
* ```tsx
14+
* import { SignedIn } from '@clerk/clerk-react';
15+
* import { PlanDetailsButton } from '@clerk/clerk-react/experimental';
16+
*
17+
* // Basic usage with default "Plan details" text
18+
* function BasicPlanDetails() {
19+
* return (
20+
* <PlanDetailsButton planId="plan_123" />
21+
* );
22+
* }
23+
*
24+
* // Custom button with custom text
25+
* function CustomPlanDetails() {
26+
* return (
27+
* <PlanDetailsButton planId="plan_123">
28+
* <button>View Plan Details</button>
29+
* </PlanDetailsButton>
30+
* );
31+
* }
32+
* ```
33+
*
34+
* @see https://clerk.com/docs/billing/overview
35+
*/
36+
export const PlanDetailsButton = withClerk(
37+
({ clerk, children, ...props }: WithClerkProp<React.PropsWithChildren<__experimental_PlanDetailsButtonProps>>) => {
38+
const { plan, planId, initialPlanPeriod, planDetailsProps, ...rest } = props;
39+
children = normalizeWithDefaultValue(children, 'Plan details');
40+
const child = assertSingleChild(children)('PlanDetailsButton');
41+
42+
const clickHandler = () => {
43+
if (!clerk) {
44+
return;
45+
}
46+
47+
return clerk.__internal_openPlanDetails({
48+
plan,
49+
planId,
50+
initialPlanPeriod,
51+
...planDetailsProps,
52+
});
53+
};
54+
55+
const wrappedChildClickHandler: React.MouseEventHandler = async e => {
56+
if (child && typeof child === 'object' && 'props' in child) {
57+
await safeExecute(child.props.onClick)(e);
58+
}
59+
return clickHandler();
60+
};
61+
62+
const childProps = { ...rest, onClick: wrappedChildClickHandler };
63+
return React.cloneElement(child as React.ReactElement<unknown>, childProps);
64+
},
65+
{ component: 'PlanDetailsButton', renderWhileLoading: true },
66+
);
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { __experimental_SubscriptionDetailsButtonProps } from '@clerk/types';
2+
import React from 'react';
3+
4+
import { useAuth } from '../hooks';
5+
import type { WithClerkProp } from '../types';
6+
import { assertSingleChild, normalizeWithDefaultValue, safeExecute } from '../utils';
7+
import { withClerk } from './withClerk';
8+
9+
/**
10+
* @experimental A button component that opens the Clerk Subscription Details drawer when clicked. This component must be rendered
11+
* inside a `<SignedIn />` component to ensure the user is authenticated.
12+
*
13+
* @example
14+
* ```tsx
15+
* import { SignedIn } from '@clerk/clerk-react';
16+
* import { SubscriptionDetailsButton } from '@clerk/clerk-react/experimental';
17+
*
18+
* // Basic usage with default "Subscription details" text
19+
* function BasicSubscriptionDetails() {
20+
* return (
21+
* <SubscriptionDetailsButton />
22+
* );
23+
* }
24+
*
25+
* // Custom button with organization subscription
26+
* function OrganizationSubscriptionDetails() {
27+
* return (
28+
* <SubscriptionDetailsButton
29+
* for="org"
30+
* onSubscriptionCancel={() => console.log('Subscription canceled')}
31+
* >
32+
* <button>View Organization Subscription</button>
33+
* </SubscriptionDetailsButton>
34+
* );
35+
* }
36+
* ```
37+
*
38+
* @throws {Error} When rendered outside of a `<SignedIn />` component
39+
* @throws {Error} When `for="org"` is used without an active organization context
40+
*
41+
* @see https://clerk.com/docs/billing/overview
42+
*/
43+
export const SubscriptionDetailsButton = withClerk(
44+
({
45+
clerk,
46+
children,
47+
...props
48+
}: WithClerkProp<React.PropsWithChildren<__experimental_SubscriptionDetailsButtonProps>>) => {
49+
const { for: forProp, subscriptionDetailsProps, onSubscriptionCancel, ...rest } = props;
50+
children = normalizeWithDefaultValue(children, 'Subscription details');
51+
const child = assertSingleChild(children)('SubscriptionDetailsButton');
52+
53+
const { userId, orgId } = useAuth();
54+
55+
if (userId === null) {
56+
throw new Error('Ensure that `<SubscriptionDetailsButton />` is rendered inside a `<SignedIn />` component.');
57+
}
58+
59+
if (orgId === null && forProp === 'org') {
60+
throw new Error(
61+
'Wrap `<SubscriptionDetailsButton for="organization" />` with a check for an active organization.',
62+
);
63+
}
64+
65+
const clickHandler = () => {
66+
if (!clerk) {
67+
return;
68+
}
69+
70+
return clerk.__internal_openSubscriptionDetails({
71+
for: forProp,
72+
onSubscriptionCancel,
73+
...subscriptionDetailsProps,
74+
});
75+
};
76+
77+
const wrappedChildClickHandler: React.MouseEventHandler = async e => {
78+
if (child && typeof child === 'object' && 'props' in child) {
79+
await safeExecute(child.props.onClick)(e);
80+
}
81+
return clickHandler();
82+
};
83+
84+
const childProps = { ...rest, onClick: wrappedChildClickHandler };
85+
return React.cloneElement(child as React.ReactElement<unknown>, childProps);
86+
},
87+
{ component: 'SubscriptionDetailsButton', renderWhileLoading: true },
88+
);

0 commit comments

Comments
 (0)