Skip to content

Commit 8b52d7a

Browse files
authored
chore(clerk-js,types): Update checkout flow to support free trials (#6494)
1 parent 3a216d4 commit 8b52d7a

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+844
-45
lines changed

.changeset/rotten-lines-cough.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@clerk/localizations': minor
3+
'@clerk/clerk-js': minor
4+
'@clerk/shared': minor
5+
'@clerk/types': minor
6+
---
7+
8+
Add support for trials in `<Checkout/>`
9+
- Added `freeTrialEndsAt` property to `CommerceCheckoutResource` interface.

packages/clerk-js/bundlewatch.config.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"files": [
3-
{ "path": "./dist/clerk.js", "maxSize": "622KB" },
3+
{ "path": "./dist/clerk.js", "maxSize": "622.25KB" },
44
{ "path": "./dist/clerk.browser.js", "maxSize": "76KB" },
55
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "117KB" },
66
{ "path": "./dist/clerk.headless*.js", "maxSize": "58KB" },
@@ -23,7 +23,7 @@
2323
{ "path": "./dist/waitlist*.js", "maxSize": "1.5KB" },
2424
{ "path": "./dist/keylessPrompt*.js", "maxSize": "6.5KB" },
2525
{ "path": "./dist/pricingTable*.js", "maxSize": "4.02KB" },
26-
{ "path": "./dist/checkout*.js", "maxSize": "8.5KB" },
26+
{ "path": "./dist/checkout*.js", "maxSize": "8.75KB" },
2727
{ "path": "./dist/up-billing-page*.js", "maxSize": "3.0KB" },
2828
{ "path": "./dist/op-billing-page*.js", "maxSize": "3.0KB" },
2929
{ "path": "./dist/up-plans-page*.js", "maxSize": "1.0KB" },

packages/clerk-js/jest.setup.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ if (typeof window !== 'undefined') {
3333
})),
3434
});
3535

36-
//@ts-expect-error
36+
//@ts-expect-error - JSDOM doesn't provide IntersectionObserver, so we mock it for testing
3737
global.IntersectionObserver = class IntersectionObserver {
3838
constructor() {}
3939

@@ -53,4 +53,18 @@ if (typeof window !== 'undefined') {
5353
return null;
5454
}
5555
};
56+
57+
// Mock HTMLCanvasElement.prototype.getContext to prevent errors
58+
HTMLCanvasElement.prototype.getContext = jest.fn().mockImplementation(((contextType: string) => {
59+
if (contextType === '2d') {
60+
return {
61+
fillRect: jest.fn(),
62+
getImageData: jest.fn(() => ({ data: new Uint8ClampedArray([255, 255, 255, 255]) }) as unknown as ImageData),
63+
} as unknown as CanvasRenderingContext2D;
64+
}
65+
if (contextType === 'webgl' || contextType === 'webgl2') {
66+
return {} as unknown as WebGLRenderingContext;
67+
}
68+
return null;
69+
}) as any) as jest.MockedFunction<HTMLCanvasElement['getContext']>;
5670
}

packages/clerk-js/src/core/resources/CommerceCheckout.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import type {
77
ConfirmCheckoutParams,
88
} from '@clerk/types';
99

10+
import { unixEpochToDate } from '@/utils/date';
11+
1012
import { commerceTotalsFromJSON } from '../../utils';
1113
import { BaseResource, CommercePaymentSource, CommercePlan, isClerkAPIResponseError } from './internal';
1214

@@ -21,6 +23,7 @@ export class CommerceCheckout extends BaseResource implements CommerceCheckoutRe
2123
status!: 'needs_confirmation' | 'completed';
2224
totals!: CommerceCheckoutTotals;
2325
isImmediatePlanChange!: boolean;
26+
freeTrialEndsAt!: Date | null;
2427

2528
constructor(data: CommerceCheckoutJSON, orgId?: string) {
2629
super();
@@ -43,6 +46,7 @@ export class CommerceCheckout extends BaseResource implements CommerceCheckoutRe
4346
this.status = data.status;
4447
this.totals = commerceTotalsFromJSON(data.totals);
4548
this.isImmediatePlanChange = data.is_immediate_plan_change;
49+
this.freeTrialEndsAt = data.free_trial_ends_at ? unixEpochToDate(data.free_trial_ends_at) : null;
4650
return this;
4751
}
4852

packages/clerk-js/src/core/resources/CommerceSubscription.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr
5252
}
5353
: null;
5454
this.subscriptionItems = (data.subscription_items || []).map(item => new CommerceSubscriptionItem(item));
55-
this.eligibleForFreeTrial = data.eligible_for_free_trial;
55+
this.eligibleForFreeTrial = this.withDefault(data.eligible_for_free_trial, false);
5656
return this;
5757
}
5858
}

packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const CheckoutComplete = () => {
1919
const { setIsOpen } = useDrawerContext();
2020
const { newSubscriptionRedirectUrl } = useCheckoutContext();
2121
const { checkout } = useCheckout();
22-
const { totals, paymentSource, planPeriodStart } = checkout;
22+
const { totals, paymentSource, planPeriodStart, freeTrialEndsAt } = checkout;
2323
const [mousePosition, setMousePosition] = useState({ x: 256, y: 256 });
2424
const [currentPosition, setCurrentPosition] = useState({ x: 256, y: 256 });
2525

@@ -310,9 +310,11 @@ export const CheckoutComplete = () => {
310310
as='h2'
311311
textVariant='h2'
312312
localizationKey={
313-
totals.totalDueNow.amount > 0
314-
? localizationKeys('commerce.checkout.title__paymentSuccessful')
315-
: localizationKeys('commerce.checkout.title__subscriptionSuccessful')
313+
freeTrialEndsAt
314+
? localizationKeys('commerce.checkout.title__trialSuccess')
315+
: totals.totalDueNow.amount > 0
316+
? localizationKeys('commerce.checkout.title__paymentSuccessful')
317+
: localizationKeys('commerce.checkout.title__subscriptionSuccessful')
316318
}
317319
sx={t => ({
318320
opacity: 0,
@@ -399,17 +401,24 @@ export const CheckoutComplete = () => {
399401
<LineItems.Title title={localizationKeys('commerce.checkout.lineItems.title__totalPaid')} />
400402
<LineItems.Description text={`${totals.totalDueNow.currencySymbol}${totals.totalDueNow.amountFormatted}`} />
401403
</LineItems.Group>
404+
405+
{freeTrialEndsAt ? (
406+
<LineItems.Group variant='secondary'>
407+
<LineItems.Title title={localizationKeys('commerce.checkout.lineItems.title__freeTrialEndsAt')} />
408+
<LineItems.Description text={formatDate(freeTrialEndsAt)} />
409+
</LineItems.Group>
410+
) : null}
402411
<LineItems.Group variant='secondary'>
403412
<LineItems.Title
404413
title={
405-
totals.totalDueNow.amount > 0
414+
totals.totalDueNow.amount > 0 || freeTrialEndsAt !== null
406415
? localizationKeys('commerce.checkout.lineItems.title__paymentMethod')
407416
: localizationKeys('commerce.checkout.lineItems.title__subscriptionBegins')
408417
}
409418
/>
410419
<LineItems.Description
411420
text={
412-
totals.totalDueNow.amount > 0
421+
totals.totalDueNow.amount > 0 || freeTrialEndsAt !== null
413422
? paymentSource
414423
? paymentSource.paymentMethod !== 'card'
415424
? `${capitalize(paymentSource.paymentMethod)}`

packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { Box, Button, Col, descriptors, Flex, Form, localizationKeys, Text } fro
1717
import { ChevronUpDown, InformationCircle } from '../../icons';
1818
import * as AddPaymentSource from '../PaymentSources/AddPaymentSource';
1919
import { PaymentSourceRow } from '../PaymentSources/PaymentSourceRow';
20+
import { SubscriptionBadge } from '../Subscriptions/badge';
2021

2122
type PaymentMethodSource = 'existing' | 'new';
2223

@@ -25,7 +26,7 @@ const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1);
2526
export const CheckoutForm = withCardStateProvider(() => {
2627
const { checkout } = useCheckout();
2728

28-
const { id, plan, totals, isImmediatePlanChange, planPeriod } = checkout;
29+
const { id, plan, totals, isImmediatePlanChange, planPeriod, freeTrialEndsAt } = checkout;
2930

3031
if (!id) {
3132
return null;
@@ -53,6 +54,11 @@ export const CheckoutForm = withCardStateProvider(() => {
5354
<LineItems.Title
5455
title={plan.name}
5556
description={planPeriod === 'annual' ? localizationKeys('commerce.billedAnnually') : undefined}
57+
badge={
58+
plan.freeTrialEnabled && freeTrialEndsAt ? (
59+
<SubscriptionBadge subscription={{ status: 'free_trial' }} />
60+
) : null
61+
}
5662
/>
5763
<LineItems.Description
5864
prefix={planPeriod === 'annual' ? 'x12' : undefined}
@@ -87,6 +93,20 @@ export const CheckoutForm = withCardStateProvider(() => {
8793
<LineItems.Description text={`${totals.pastDue?.currencySymbol}${totals.pastDue?.amountFormatted}`} />
8894
</LineItems.Group>
8995
)}
96+
97+
{!!freeTrialEndsAt && !!plan.freeTrialDays && (
98+
<LineItems.Group variant='tertiary'>
99+
<LineItems.Title
100+
title={localizationKeys('commerce.checkout.totalDueAfterTrial', {
101+
days: plan.freeTrialDays,
102+
})}
103+
/>
104+
<LineItems.Description
105+
text={`${totals.grandTotal?.currencySymbol}${totals.grandTotal?.amountFormatted}`}
106+
/>
107+
</LineItems.Group>
108+
)}
109+
90110
<LineItems.Group borderTop>
91111
<LineItems.Title title={localizationKeys('commerce.totalDueToday')} />
92112
<LineItems.Description text={`${totals.totalDueNow.currencySymbol}${totals.totalDueNow.amountFormatted}`} />
@@ -278,15 +298,32 @@ export const PayWithTestPaymentSource = () => {
278298
);
279299
};
280300

281-
const AddPaymentSourceForCheckout = withCardStateProvider(() => {
282-
const { addPaymentSourceAndPay } = useCheckoutMutations();
301+
const useSubmitLabel = () => {
283302
const { checkout } = useCheckout();
284-
const { status, totals } = checkout;
303+
const { status, freeTrialEndsAt, totals } = checkout;
285304

286305
if (status === 'needs_initialization') {
287-
return null;
306+
throw new Error('Clerk: Invalid state');
307+
}
308+
309+
if (freeTrialEndsAt) {
310+
return localizationKeys('commerce.startFreeTrial');
288311
}
289312

313+
if (totals.totalDueNow.amount > 0) {
314+
return localizationKeys('commerce.pay', {
315+
amount: `${totals.totalDueNow.currencySymbol}${totals.totalDueNow.amountFormatted}`,
316+
});
317+
}
318+
319+
return localizationKeys('commerce.subscribe');
320+
};
321+
322+
const AddPaymentSourceForCheckout = withCardStateProvider(() => {
323+
const { addPaymentSourceAndPay } = useCheckoutMutations();
324+
const submitLabel = useSubmitLabel();
325+
const { checkout } = useCheckout();
326+
290327
return (
291328
<AddPaymentSource.Root
292329
onSuccess={addPaymentSourceAndPay}
@@ -296,15 +333,7 @@ const AddPaymentSourceForCheckout = withCardStateProvider(() => {
296333
<PayWithTestPaymentSource />
297334
</DevOnly>
298335

299-
{totals.totalDueNow.amount > 0 ? (
300-
<AddPaymentSource.FormButton
301-
text={localizationKeys('commerce.pay', {
302-
amount: `${totals.totalDueNow.currencySymbol}${totals.totalDueNow.amountFormatted}`,
303-
})}
304-
/>
305-
) : (
306-
<AddPaymentSource.FormButton text={localizationKeys('commerce.subscribe')} />
307-
)}
336+
<AddPaymentSource.FormButton text={submitLabel} />
308337
</AddPaymentSource.Root>
309338
);
310339
});
@@ -317,8 +346,9 @@ const ExistingPaymentSourceForm = withCardStateProvider(
317346
totalDueNow: CommerceMoneyAmount;
318347
paymentSources: CommercePaymentSourceResource[];
319348
}) => {
349+
const submitLabel = useSubmitLabel();
320350
const { checkout } = useCheckout();
321-
const { paymentSource } = checkout;
351+
const { paymentSource, freeTrialEndsAt } = checkout;
322352

323353
const { payWithExistingPaymentSource } = useCheckoutMutations();
324354
const card = useCardState();
@@ -340,6 +370,8 @@ const ExistingPaymentSourceForm = withCardStateProvider(
340370
});
341371
}, [paymentSources]);
342372

373+
const isSchedulePayment = totalDueNow.amount > 0 && !freeTrialEndsAt;
374+
343375
return (
344376
<Form
345377
onSubmit={payWithExistingPaymentSource}
@@ -349,7 +381,7 @@ const ExistingPaymentSourceForm = withCardStateProvider(
349381
rowGap: t.space.$4,
350382
})}
351383
>
352-
{totalDueNow.amount > 0 ? (
384+
{isSchedulePayment ? (
353385
<Select
354386
elementId='paymentSource'
355387
options={options}
@@ -399,17 +431,8 @@ const ExistingPaymentSourceForm = withCardStateProvider(
399431
width: '100%',
400432
}}
401433
isLoading={card.isLoading}
402-
>
403-
<Text
404-
localizationKey={
405-
totalDueNow.amount > 0
406-
? localizationKeys('commerce.pay', {
407-
amount: `${totalDueNow.currencySymbol}${totalDueNow.amountFormatted}`,
408-
})
409-
: localizationKeys('commerce.subscribe')
410-
}
411-
/>
412-
</Button>
434+
localizationKey={submitLabel}
435+
/>
413436
</Form>
414437
);
415438
},

0 commit comments

Comments
 (0)