Skip to content

feat(clerk-js,types,localizations): Collect user email when needed during checkout #5671

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Apr 21, 2025
11 changes: 11 additions & 0 deletions .changeset/grumpy-carrots-love.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@clerk/localizations': patch
'@clerk/clerk-js': patch
'@clerk/types': patch
---

- Adds support for collecting and verifying user email (when they don't already have one associated with their payer) during checkout
- Fixes incorrect org invoices endpoint.
- Extracts plan CTA button styling, labeling, and selecting into context methods.
- Adds UserProfile / OrgProfile specific scrollbox IDs for drawer portal-ing (fixes issue where both could be open)
- Fixes incorrect button action in SubscriptionList for active but expiring subscriptions.
10 changes: 5 additions & 5 deletions packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@
{ "path": "./dist/signin*.js", "maxSize": "14KB" },
{ "path": "./dist/signup*.js", "maxSize": "6.75KB" },
{ "path": "./dist/userbutton*.js", "maxSize": "5KB" },
{ "path": "./dist/userprofile*.js", "maxSize": "16KB" },
{ "path": "./dist/userprofile*.js", "maxSize": "16.5KB" },
{ "path": "./dist/userverification*.js", "maxSize": "5KB" },
{ "path": "./dist/onetap*.js", "maxSize": "1KB" },
{ "path": "./dist/waitlist*.js", "maxSize": "1.3KB" },
{ "path": "./dist/keylessPrompt*.js", "maxSize": "6.5KB" },
{ "path": "./dist/pricingTable*.js", "maxSize": "5.5KB" },
{ "path": "./dist/checkout*.js", "maxSize": "3.05KB" },
{ "path": "./dist/pricingTable*.js", "maxSize": "4KB" },
{ "path": "./dist/checkout*.js", "maxSize": "4.9KB" },
{ "path": "./dist/paymentSources*.js", "maxSize": "8.5KB" },
{ "path": "./dist/up-billing-page*.js", "maxSize": "2.5KB" },
{ "path": "./dist/op-billing-page*.js", "maxSize": "2.5KB" },
{ "path": "./dist/up-billing-page*.js", "maxSize": "1KB" },
{ "path": "./dist/op-billing-page*.js", "maxSize": "1KB" },
{ "path": "./dist/sessionTasks*.js", "maxSize": "1KB" }
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export class __experimental_CommerceBilling implements __experimental_CommerceBi
const { orgId, ...rest } = params;

return await BaseResource._fetch({
path: orgId ? `/organizations/${orgId}/invoices` : `/me/commerce/invoices`,
path: orgId ? `/organizations/${orgId}/commerce/invoices` : `/me/commerce/invoices`,
method: 'GET',
search: convertPageToOffsetSearchParams(rest),
}).then(res => {
Expand Down
69 changes: 44 additions & 25 deletions packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,24 @@
import type { __experimental_CheckoutProps, __experimental_CommerceCheckoutResource } from '@clerk/types';
import { useEffect } from 'react';

import { Alert, Spinner } from '../../customizables';
import { Alert, Box, localizationKeys, Spinner } from '../../customizables';
import { Drawer, useDrawerContext } from '../../elements';
import { useCheckout } from '../../hooks';
import { EmailForm } from '../UserProfile/EmailForm';
import { CheckoutComplete } from './CheckoutComplete';
import { CheckoutForm } from './CheckoutForm';

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

const { checkout, isLoading, invalidate, updateCheckout } = useCheckout({
const { checkout, isLoading, invalidate, revalidate, updateCheckout, isMissingPayerEmail } = useCheckout({
planId,
planPeriod,
subscriberType,
});

useEffect(() => {
return invalidate;
}, []);

const onCheckoutComplete = (newCheckout: __experimental_CommerceCheckoutResource) => {
invalidate(); // invalidate the initial checkout on complete
updateCheckout(newCheckout);
onSubscriptionComplete?.();
};
Expand All @@ -34,30 +33,50 @@ export const CheckoutPage = (props: __experimental_CheckoutProps) => {
);
}

if (!checkout) {
if (checkout) {
if (checkout?.status === 'completed') {
return <CheckoutComplete checkout={checkout} />;
}

return (
<>
{/* TODO(@COMMERCE): needs localization */}
<Alert
colorScheme='danger'
sx={{
margin: 'auto',
}}
>
There was a problem, please try again later.
</Alert>
</>
<CheckoutForm
checkout={checkout}
onCheckoutComplete={onCheckoutComplete}
/>
);
}

if (checkout?.status === 'completed') {
return <CheckoutComplete checkout={checkout} />;
if (isMissingPayerEmail) {
return (
<Drawer.Body>
<Box
sx={t => ({
padding: t.space.$4,
})}
>
<EmailForm
title={localizationKeys('__experimental_commerce.checkout.emailForm.title')}
subtitle={localizationKeys('__experimental_commerce.checkout.emailForm.subtitle')}
onSuccess={revalidate}
onReset={() => setIsOpen(false)}
disableAutoFocus
/>
</Box>
</Drawer.Body>
);
}

return (
<CheckoutForm
checkout={checkout}
onCheckoutComplete={onCheckoutComplete}
/>
<>
{/* TODO(@COMMERCE): needs localization */}
<Alert
colorScheme='danger'
sx={{
margin: 'auto',
}}
>
There was a problem, please try again later.
</Alert>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useOrganization } from '@clerk/shared/react';
import type { OrganizationProfileModalProps, OrganizationProfileProps } from '@clerk/types';
import React from 'react';

import { ORGANIZATION_PROFILE_CARD_SCROLLBOX_ID } from '../../constants';
import { OrganizationProfileContext, withCoreUserGuard } from '../../contexts';
import { Flow, localizationKeys } from '../../customizables';
import { NavbarMenuButtonRow, ProfileCard, withCardStateProvider } from '../../elements';
Expand Down Expand Up @@ -38,7 +39,10 @@ const AuthenticatedRoutes = withCoreUserGuard(() => {
>
<OrganizationProfileNavbar contentRef={contentRef}>
<NavbarMenuButtonRow navbarTitleLocalizationKey={localizationKeys('organizationProfile.navbar.title')} />
<ProfileCard.Content contentRef={contentRef}>
<ProfileCard.Content
contentRef={contentRef}
scrollBoxId={ORGANIZATION_PROFILE_CARD_SCROLLBOX_ID}
>
<OrganizationProfileRoutes />
</ProfileCard.Content>
</OrganizationProfileNavbar>
Expand Down
28 changes: 3 additions & 25 deletions packages/clerk-js/src/ui/components/PricingTable/PricingTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,16 @@ import type {
} from '@clerk/types';
import { useState } from 'react';

import { PROFILE_CARD_SCROLLBOX_ID } from '../../constants';
import { usePlansContext, usePricingTableContext } from '../../contexts';
import { PricingTableDefault } from './PricingTableDefault';
import { PricingTableMatrix } from './PricingTableMatrix';

const PricingTable = (props: __experimental_PricingTableProps) => {
const clerk = useClerk();
const { mode = 'mounted', subscriberType } = usePricingTableContext();
const { mode = 'mounted' } = usePricingTableContext();
const isCompact = mode === 'modal';

const { plans, revalidate, activeOrUpcomingSubscription } = usePlansContext();
const { plans, handleSelectPlan } = usePlansContext();

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

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

const subscription = activeOrUpcomingSubscription(plan);

if (subscription && !subscription.canceledAt) {
clerk.__internal_openSubscriptionDetails({
subscription,
subscriberType,
onSubscriptionCancel: onSubscriptionChange,
portalId: mode === 'modal' ? PROFILE_CARD_SCROLLBOX_ID : undefined,
});
} else {
clerk.__internal_openCheckout({
planId: plan.id,
planPeriod,
subscriberType,
onSubscriptionComplete: onSubscriptionChange,
portalId: mode === 'modal' ? PROFILE_CARD_SCROLLBOX_ID : undefined,
});
}
};

const onSubscriptionChange = () => {
void revalidate();
handleSelectPlan({ mode, plan, planPeriod });
};

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ function Card(props: CardProps) {
const totalFeatures = features.length;
const hasFeatures = totalFeatures > 0;

const activeOrUpcomingSubscription = usePlansContext().activeOrUpcomingSubscription(plan);
const { buttonPropsForPlan } = usePlansContext();

return (
<Box
Expand Down Expand Up @@ -174,15 +174,7 @@ function Card(props: CardProps) {
<Button
block
textVariant={isCompact ? 'buttonSmall' : 'buttonLarge'}
variant={isCompact || !!activeOrUpcomingSubscription ? 'bordered' : 'solid'}
colorScheme={isCompact || !!activeOrUpcomingSubscription ? 'secondary' : 'primary'}
localizationKey={
activeOrUpcomingSubscription
? activeOrUpcomingSubscription.canceledAt
? localizationKeys('__experimental_commerce.reSubscribe')
: localizationKeys('__experimental_commerce.manageSubscription')
: localizationKeys('__experimental_commerce.getStarted')
}
{...buttonPropsForPlan({ plan, isCompact })}
onClick={() => {
onSelect(plan);
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function PricingTableMatrix({
const pricingTableMatrixId = React.useId();
const segmentedControlId = `${pricingTableMatrixId}-segmented-control`;

const { activeOrUpcomingSubscription } = usePlansContext();
const { buttonPropsForPlan } = usePlansContext();

const feePeriodNoticeAnimation: ThemableCssProp = t => ({
transition: isMotionSafe
Expand Down Expand Up @@ -160,8 +160,6 @@ export function PricingTableMatrix({
? plan.annualMonthlyAmountFormatted
: plan.amountFormatted;

const subscription = activeOrUpcomingSubscription(plan);

return (
<Box
elementDescriptor={descriptors.pricingTableMatrixColumnHeader}
Expand Down Expand Up @@ -323,20 +321,13 @@ export function PricingTableMatrix({
>
<Button
block
variant='bordered'
colorScheme={highlight ? 'primary' : 'secondary'}
textVariant='buttonSmall'
size='xs'
onClick={() => {
onSelect(plan);
}}
localizationKey={
subscription?.status === 'active'
? subscription?.canceledAt
? localizationKeys('__experimental_commerce.reSubscribe')
: localizationKeys('__experimental_commerce.manageSubscription')
: localizationKeys('__experimental_commerce.getStarted')
}
{...buttonPropsForPlan({ plan })}
colorScheme={highlight ? 'primary' : 'secondary'}
/>
</Box>
) : null}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { useClerk } from '@clerk/shared/react';
import type { __experimental_CommerceSubscriptionResource } from '@clerk/types';

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

export function SubscriptionsList() {
const { subscriptions, revalidate } = usePlansContext();
const clerk = useClerk();
const { subscriptions, handleSelectPlan, buttonPropsForPlan } = usePlansContext();

const handleSelectSubscription = (subscription: __experimental_CommerceSubscriptionResource) => {
handleSelectPlan({
mode: 'modal', // always modal for now
plan: subscription.plan,
planPeriod: subscription.planPeriod,
});
};

return (
<Table tableHeadVisuallyHidden>
<Thead>
Expand Down Expand Up @@ -63,19 +70,9 @@ export function SubscriptionsList() {
>
<Button
size='xs'
colorScheme='secondary'
variant='bordered'
textVariant='buttonSmall'
onClick={() =>
clerk.__internal_openSubscriptionDetails({
subscription,
onSubscriptionCancel: () => revalidate(),
portalId: PROFILE_CARD_SCROLLBOX_ID,
})
}
localizationKey={localizationKeys(
'userProfile.__experimental_billingPage.subscriptionsSection.actionLabel__default',
)}
onClick={() => handleSelectSubscription(subscription)}
{...buttonPropsForPlan({ plan: subscription.plan })}
/>
</Td>
</Tr>
Expand Down
14 changes: 9 additions & 5 deletions packages/clerk-js/src/ui/components/UserProfile/EmailForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,23 @@ import React from 'react';

import { useWizard, Wizard } from '../../common';
import { useEnvironment } from '../../contexts';
import { localizationKeys } from '../../customizables';
import type { FormProps } from '../../elements';
import { Form, FormButtons, FormContainer, useCardState, withCardStateProvider } from '../../elements';
import type { LocalizationKey } from '../../localization';
import { localizationKeys } from '../../localization';
import { handleError, useFormControl } from '../../utils';
import { VerifyWithCode } from './VerifyWithCode';
import { VerifyWithEnterpriseConnection } from './VerifyWithEnterpriseConnection';
import { VerifyWithLink } from './VerifyWithLink';

type EmailFormProps = FormProps & {
emailId?: string;
title?: LocalizationKey;
subtitle?: LocalizationKey;
disableAutoFocus?: boolean;
};
export const EmailForm = withCardStateProvider((props: EmailFormProps) => {
const { emailId: id, onSuccess, onReset } = props;
const { emailId: id, onSuccess, onReset, disableAutoFocus = false } = props;
const card = useCardState();
const { user } = useUser();
const environment = useEnvironment();
Expand Down Expand Up @@ -57,14 +61,14 @@ export const EmailForm = withCardStateProvider((props: EmailFormProps) => {
return (
<Wizard {...wizard.props}>
<FormContainer
headerTitle={localizationKeys('userProfile.emailAddressPage.title')}
headerSubtitle={localizationKeys('userProfile.emailAddressPage.formHint')}
headerTitle={props.title || localizationKeys('userProfile.emailAddressPage.title')}
headerSubtitle={props.subtitle || localizationKeys('userProfile.emailAddressPage.formHint')}
>
<Form.Root onSubmit={addEmail}>
<Form.ControlRow elementId={emailField.id}>
<Form.PlainInput
{...emailField.props}
autoFocus
autoFocus={!disableAutoFocus}
/>
</Form.ControlRow>
<FormButtons
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { UserProfileModalProps, UserProfileProps } from '@clerk/types';
import React from 'react';

import { USER_PROFILE_CARD_SCROLLBOX_ID } from '../../constants';
import { UserProfileContext, withCoreUserGuard } from '../../contexts';
import { Flow, localizationKeys } from '../../customizables';
import { NavbarMenuButtonRow, ProfileCard, withCardStateProvider } from '../../elements';
Expand Down Expand Up @@ -34,7 +35,10 @@ const AuthenticatedRoutes = withCoreUserGuard(() => {
<ProfileCard.Root>
<UserProfileNavbar contentRef={contentRef}>
<NavbarMenuButtonRow navbarTitleLocalizationKey={localizationKeys('userProfile.navbar.title')} />
<ProfileCard.Content contentRef={contentRef}>
<ProfileCard.Content
contentRef={contentRef}
scrollBoxId={USER_PROFILE_CARD_SCROLLBOX_ID}
>
<UserProfileRoutes />
</ProfileCard.Content>
</UserProfileNavbar>
Expand Down
3 changes: 2 additions & 1 deletion packages/clerk-js/src/ui/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ export const USER_BUTTON_ITEM_ID = {
SIGN_OUT: 'signOut',
};

export const PROFILE_CARD_SCROLLBOX_ID = 'clerk-profileCardScrollBox';
export const USER_PROFILE_CARD_SCROLLBOX_ID = 'clerk-profileCardScrollBox';
export const ORGANIZATION_PROFILE_CARD_SCROLLBOX_ID = 'clerk-organizationProfileScrollBox';
Loading