Skip to content

Commit 33201bf

Browse files
authored
feat(clerk-js,types,localizations): Collect user email when needed during checkout (#5671)
1 parent f36155f commit 33201bf

File tree

20 files changed

+249
-147
lines changed

20 files changed

+249
-147
lines changed

.changeset/grumpy-carrots-love.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@clerk/localizations': patch
3+
'@clerk/clerk-js': patch
4+
'@clerk/types': patch
5+
---
6+
7+
- Adds support for collecting and verifying user email (when they don't already have one associated with their payer) during checkout
8+
- Fixes incorrect org invoices endpoint.
9+
- Extracts plan CTA button styling, labeling, and selecting into context methods.
10+
- Adds UserProfile / OrgProfile specific scrollbox IDs for drawer portal-ing (fixes issue where both could be open)
11+
- Fixes incorrect button action in SubscriptionList for active but expiring subscriptions.

packages/clerk-js/bundlewatch.config.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,16 @@
1414
{ "path": "./dist/signin*.js", "maxSize": "14KB" },
1515
{ "path": "./dist/signup*.js", "maxSize": "6.75KB" },
1616
{ "path": "./dist/userbutton*.js", "maxSize": "5KB" },
17-
{ "path": "./dist/userprofile*.js", "maxSize": "16KB" },
17+
{ "path": "./dist/userprofile*.js", "maxSize": "16.5KB" },
1818
{ "path": "./dist/userverification*.js", "maxSize": "5KB" },
1919
{ "path": "./dist/onetap*.js", "maxSize": "1KB" },
2020
{ "path": "./dist/waitlist*.js", "maxSize": "1.3KB" },
2121
{ "path": "./dist/keylessPrompt*.js", "maxSize": "6.5KB" },
22-
{ "path": "./dist/pricingTable*.js", "maxSize": "5.5KB" },
23-
{ "path": "./dist/checkout*.js", "maxSize": "3.05KB" },
22+
{ "path": "./dist/pricingTable*.js", "maxSize": "4KB" },
23+
{ "path": "./dist/checkout*.js", "maxSize": "4.9KB" },
2424
{ "path": "./dist/paymentSources*.js", "maxSize": "8.5KB" },
25-
{ "path": "./dist/up-billing-page*.js", "maxSize": "2.5KB" },
26-
{ "path": "./dist/op-billing-page*.js", "maxSize": "2.5KB" },
25+
{ "path": "./dist/up-billing-page*.js", "maxSize": "1KB" },
26+
{ "path": "./dist/op-billing-page*.js", "maxSize": "1KB" },
2727
{ "path": "./dist/sessionTasks*.js", "maxSize": "1KB" }
2828
]
2929
}

packages/clerk-js/src/core/modules/commerce/CommerceBilling.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export class __experimental_CommerceBilling implements __experimental_CommerceBi
6161
const { orgId, ...rest } = params;
6262

6363
return await BaseResource._fetch({
64-
path: orgId ? `/organizations/${orgId}/invoices` : `/me/commerce/invoices`,
64+
path: orgId ? `/organizations/${orgId}/commerce/invoices` : `/me/commerce/invoices`,
6565
method: 'GET',
6666
search: convertPageToOffsetSearchParams(rest),
6767
}).then(res => {
Lines changed: 44 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,24 @@
11
import type { __experimental_CheckoutProps, __experimental_CommerceCheckoutResource } from '@clerk/types';
2-
import { useEffect } from 'react';
32

4-
import { Alert, Spinner } from '../../customizables';
3+
import { Alert, Box, localizationKeys, Spinner } from '../../customizables';
4+
import { Drawer, useDrawerContext } from '../../elements';
55
import { useCheckout } from '../../hooks';
6+
import { EmailForm } from '../UserProfile/EmailForm';
67
import { CheckoutComplete } from './CheckoutComplete';
78
import { CheckoutForm } from './CheckoutForm';
89

910
export const CheckoutPage = (props: __experimental_CheckoutProps) => {
1011
const { planId, planPeriod, subscriberType, onSubscriptionComplete } = props;
12+
const { setIsOpen } = useDrawerContext();
1113

12-
const { checkout, isLoading, invalidate, updateCheckout } = useCheckout({
14+
const { checkout, isLoading, invalidate, revalidate, updateCheckout, isMissingPayerEmail } = useCheckout({
1315
planId,
1416
planPeriod,
1517
subscriberType,
1618
});
1719

18-
useEffect(() => {
19-
return invalidate;
20-
}, []);
21-
2220
const onCheckoutComplete = (newCheckout: __experimental_CommerceCheckoutResource) => {
21+
invalidate(); // invalidate the initial checkout on complete
2322
updateCheckout(newCheckout);
2423
onSubscriptionComplete?.();
2524
};
@@ -34,30 +33,50 @@ export const CheckoutPage = (props: __experimental_CheckoutProps) => {
3433
);
3534
}
3635

37-
if (!checkout) {
36+
if (checkout) {
37+
if (checkout?.status === 'completed') {
38+
return <CheckoutComplete checkout={checkout} />;
39+
}
40+
3841
return (
39-
<>
40-
{/* TODO(@COMMERCE): needs localization */}
41-
<Alert
42-
colorScheme='danger'
43-
sx={{
44-
margin: 'auto',
45-
}}
46-
>
47-
There was a problem, please try again later.
48-
</Alert>
49-
</>
42+
<CheckoutForm
43+
checkout={checkout}
44+
onCheckoutComplete={onCheckoutComplete}
45+
/>
5046
);
5147
}
5248

53-
if (checkout?.status === 'completed') {
54-
return <CheckoutComplete checkout={checkout} />;
49+
if (isMissingPayerEmail) {
50+
return (
51+
<Drawer.Body>
52+
<Box
53+
sx={t => ({
54+
padding: t.space.$4,
55+
})}
56+
>
57+
<EmailForm
58+
title={localizationKeys('__experimental_commerce.checkout.emailForm.title')}
59+
subtitle={localizationKeys('__experimental_commerce.checkout.emailForm.subtitle')}
60+
onSuccess={revalidate}
61+
onReset={() => setIsOpen(false)}
62+
disableAutoFocus
63+
/>
64+
</Box>
65+
</Drawer.Body>
66+
);
5567
}
5668

5769
return (
58-
<CheckoutForm
59-
checkout={checkout}
60-
onCheckoutComplete={onCheckoutComplete}
61-
/>
70+
<>
71+
{/* TODO(@COMMERCE): needs localization */}
72+
<Alert
73+
colorScheme='danger'
74+
sx={{
75+
margin: 'auto',
76+
}}
77+
>
78+
There was a problem, please try again later.
79+
</Alert>
80+
</>
6281
);
6382
};

packages/clerk-js/src/ui/components/OrganizationProfile/OrganizationProfile.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useOrganization } from '@clerk/shared/react';
22
import type { OrganizationProfileModalProps, OrganizationProfileProps } from '@clerk/types';
33
import React from 'react';
44

5+
import { ORGANIZATION_PROFILE_CARD_SCROLLBOX_ID } from '../../constants';
56
import { OrganizationProfileContext, withCoreUserGuard } from '../../contexts';
67
import { Flow, localizationKeys } from '../../customizables';
78
import { NavbarMenuButtonRow, ProfileCard, withCardStateProvider } from '../../elements';
@@ -38,7 +39,10 @@ const AuthenticatedRoutes = withCoreUserGuard(() => {
3839
>
3940
<OrganizationProfileNavbar contentRef={contentRef}>
4041
<NavbarMenuButtonRow navbarTitleLocalizationKey={localizationKeys('organizationProfile.navbar.title')} />
41-
<ProfileCard.Content contentRef={contentRef}>
42+
<ProfileCard.Content
43+
contentRef={contentRef}
44+
scrollBoxId={ORGANIZATION_PROFILE_CARD_SCROLLBOX_ID}
45+
>
4246
<OrganizationProfileRoutes />
4347
</ProfileCard.Content>
4448
</OrganizationProfileNavbar>

packages/clerk-js/src/ui/components/PricingTable/PricingTable.tsx

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,16 @@ import type {
66
} from '@clerk/types';
77
import { useState } from 'react';
88

9-
import { PROFILE_CARD_SCROLLBOX_ID } from '../../constants';
109
import { usePlansContext, usePricingTableContext } from '../../contexts';
1110
import { PricingTableDefault } from './PricingTableDefault';
1211
import { PricingTableMatrix } from './PricingTableMatrix';
1312

1413
const PricingTable = (props: __experimental_PricingTableProps) => {
1514
const clerk = useClerk();
16-
const { mode = 'mounted', subscriberType } = usePricingTableContext();
15+
const { mode = 'mounted' } = usePricingTableContext();
1716
const isCompact = mode === 'modal';
1817

19-
const { plans, revalidate, activeOrUpcomingSubscription } = usePlansContext();
18+
const { plans, handleSelectPlan } = usePlansContext();
2019

2120
const [planPeriod, setPlanPeriod] = useState<__experimental_CommerceSubscriptionPlanPeriod>('month');
2221

@@ -25,28 +24,7 @@ const PricingTable = (props: __experimental_PricingTableProps) => {
2524
void clerk.redirectToSignIn();
2625
}
2726

28-
const subscription = activeOrUpcomingSubscription(plan);
29-
30-
if (subscription && !subscription.canceledAt) {
31-
clerk.__internal_openSubscriptionDetails({
32-
subscription,
33-
subscriberType,
34-
onSubscriptionCancel: onSubscriptionChange,
35-
portalId: mode === 'modal' ? PROFILE_CARD_SCROLLBOX_ID : undefined,
36-
});
37-
} else {
38-
clerk.__internal_openCheckout({
39-
planId: plan.id,
40-
planPeriod,
41-
subscriberType,
42-
onSubscriptionComplete: onSubscriptionChange,
43-
portalId: mode === 'modal' ? PROFILE_CARD_SCROLLBOX_ID : undefined,
44-
});
45-
}
46-
};
47-
48-
const onSubscriptionChange = () => {
49-
void revalidate();
27+
handleSelectPlan({ mode, plan, planPeriod });
5028
};
5129

5230
return (

packages/clerk-js/src/ui/components/PricingTable/PricingTableDefault.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ function Card(props: CardProps) {
107107
const totalFeatures = features.length;
108108
const hasFeatures = totalFeatures > 0;
109109

110-
const activeOrUpcomingSubscription = usePlansContext().activeOrUpcomingSubscription(plan);
110+
const { buttonPropsForPlan } = usePlansContext();
111111

112112
return (
113113
<Box
@@ -174,15 +174,7 @@ function Card(props: CardProps) {
174174
<Button
175175
block
176176
textVariant={isCompact ? 'buttonSmall' : 'buttonLarge'}
177-
variant={isCompact || !!activeOrUpcomingSubscription ? 'bordered' : 'solid'}
178-
colorScheme={isCompact || !!activeOrUpcomingSubscription ? 'secondary' : 'primary'}
179-
localizationKey={
180-
activeOrUpcomingSubscription
181-
? activeOrUpcomingSubscription.canceledAt
182-
? localizationKeys('__experimental_commerce.reSubscribe')
183-
: localizationKeys('__experimental_commerce.manageSubscription')
184-
: localizationKeys('__experimental_commerce.getStarted')
185-
}
177+
{...buttonPropsForPlan({ plan, isCompact })}
186178
onClick={() => {
187179
onSelect(plan);
188180
}}

packages/clerk-js/src/ui/components/PricingTable/PricingTableMatrix.tsx

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export function PricingTableMatrix({
4242
const pricingTableMatrixId = React.useId();
4343
const segmentedControlId = `${pricingTableMatrixId}-segmented-control`;
4444

45-
const { activeOrUpcomingSubscription } = usePlansContext();
45+
const { buttonPropsForPlan } = usePlansContext();
4646

4747
const feePeriodNoticeAnimation: ThemableCssProp = t => ({
4848
transition: isMotionSafe
@@ -160,8 +160,6 @@ export function PricingTableMatrix({
160160
? plan.annualMonthlyAmountFormatted
161161
: plan.amountFormatted;
162162

163-
const subscription = activeOrUpcomingSubscription(plan);
164-
165163
return (
166164
<Box
167165
elementDescriptor={descriptors.pricingTableMatrixColumnHeader}
@@ -323,20 +321,13 @@ export function PricingTableMatrix({
323321
>
324322
<Button
325323
block
326-
variant='bordered'
327-
colorScheme={highlight ? 'primary' : 'secondary'}
328324
textVariant='buttonSmall'
329325
size='xs'
330326
onClick={() => {
331327
onSelect(plan);
332328
}}
333-
localizationKey={
334-
subscription?.status === 'active'
335-
? subscription?.canceledAt
336-
? localizationKeys('__experimental_commerce.reSubscribe')
337-
: localizationKeys('__experimental_commerce.manageSubscription')
338-
: localizationKeys('__experimental_commerce.getStarted')
339-
}
329+
{...buttonPropsForPlan({ plan })}
330+
colorScheme={highlight ? 'primary' : 'secondary'}
340331
/>
341332
</Box>
342333
) : null}

packages/clerk-js/src/ui/components/Subscriptions/SubscriptionsList.tsx

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,19 @@
1-
import { useClerk } from '@clerk/shared/react';
1+
import type { __experimental_CommerceSubscriptionResource } from '@clerk/types';
22

3-
import { PROFILE_CARD_SCROLLBOX_ID } from '../../constants';
43
import { usePlansContext } from '../../contexts';
54
import { Badge, Box, Button, localizationKeys, Span, Table, Tbody, Td, Text, Th, Thead, Tr } from '../../customizables';
65

76
export function SubscriptionsList() {
8-
const { subscriptions, revalidate } = usePlansContext();
9-
const clerk = useClerk();
7+
const { subscriptions, handleSelectPlan, buttonPropsForPlan } = usePlansContext();
8+
9+
const handleSelectSubscription = (subscription: __experimental_CommerceSubscriptionResource) => {
10+
handleSelectPlan({
11+
mode: 'modal', // always modal for now
12+
plan: subscription.plan,
13+
planPeriod: subscription.planPeriod,
14+
});
15+
};
16+
1017
return (
1118
<Table tableHeadVisuallyHidden>
1219
<Thead>
@@ -63,19 +70,9 @@ export function SubscriptionsList() {
6370
>
6471
<Button
6572
size='xs'
66-
colorScheme='secondary'
67-
variant='bordered'
6873
textVariant='buttonSmall'
69-
onClick={() =>
70-
clerk.__internal_openSubscriptionDetails({
71-
subscription,
72-
onSubscriptionCancel: () => revalidate(),
73-
portalId: PROFILE_CARD_SCROLLBOX_ID,
74-
})
75-
}
76-
localizationKey={localizationKeys(
77-
'userProfile.__experimental_billingPage.subscriptionsSection.actionLabel__default',
78-
)}
74+
onClick={() => handleSelectSubscription(subscription)}
75+
{...buttonPropsForPlan({ plan: subscription.plan })}
7976
/>
8077
</Td>
8178
</Tr>

packages/clerk-js/src/ui/components/UserProfile/EmailForm.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,23 @@ import React from 'react';
44

55
import { useWizard, Wizard } from '../../common';
66
import { useEnvironment } from '../../contexts';
7-
import { localizationKeys } from '../../customizables';
87
import type { FormProps } from '../../elements';
98
import { Form, FormButtons, FormContainer, useCardState, withCardStateProvider } from '../../elements';
9+
import type { LocalizationKey } from '../../localization';
10+
import { localizationKeys } from '../../localization';
1011
import { handleError, useFormControl } from '../../utils';
1112
import { VerifyWithCode } from './VerifyWithCode';
1213
import { VerifyWithEnterpriseConnection } from './VerifyWithEnterpriseConnection';
1314
import { VerifyWithLink } from './VerifyWithLink';
1415

1516
type EmailFormProps = FormProps & {
1617
emailId?: string;
18+
title?: LocalizationKey;
19+
subtitle?: LocalizationKey;
20+
disableAutoFocus?: boolean;
1721
};
1822
export const EmailForm = withCardStateProvider((props: EmailFormProps) => {
19-
const { emailId: id, onSuccess, onReset } = props;
23+
const { emailId: id, onSuccess, onReset, disableAutoFocus = false } = props;
2024
const card = useCardState();
2125
const { user } = useUser();
2226
const environment = useEnvironment();
@@ -57,14 +61,14 @@ export const EmailForm = withCardStateProvider((props: EmailFormProps) => {
5761
return (
5862
<Wizard {...wizard.props}>
5963
<FormContainer
60-
headerTitle={localizationKeys('userProfile.emailAddressPage.title')}
61-
headerSubtitle={localizationKeys('userProfile.emailAddressPage.formHint')}
64+
headerTitle={props.title || localizationKeys('userProfile.emailAddressPage.title')}
65+
headerSubtitle={props.subtitle || localizationKeys('userProfile.emailAddressPage.formHint')}
6266
>
6367
<Form.Root onSubmit={addEmail}>
6468
<Form.ControlRow elementId={emailField.id}>
6569
<Form.PlainInput
6670
{...emailField.props}
67-
autoFocus
71+
autoFocus={!disableAutoFocus}
6872
/>
6973
</Form.ControlRow>
7074
<FormButtons

0 commit comments

Comments
 (0)