diff --git a/.changeset/rotten-lines-cough.md b/.changeset/rotten-lines-cough.md new file mode 100644 index 00000000000..1b6d7f47140 --- /dev/null +++ b/.changeset/rotten-lines-cough.md @@ -0,0 +1,9 @@ +--- +'@clerk/localizations': minor +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/types': minor +--- + +Add support for trials in `` + - Added `freeTrialEndsAt` property to `CommerceCheckoutResource` interface. diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index da405fd6a95..00856735535 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -1,6 +1,6 @@ { "files": [ - { "path": "./dist/clerk.js", "maxSize": "622KB" }, + { "path": "./dist/clerk.js", "maxSize": "622.25KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "76KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "117KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "58KB" }, @@ -23,7 +23,7 @@ { "path": "./dist/waitlist*.js", "maxSize": "1.5KB" }, { "path": "./dist/keylessPrompt*.js", "maxSize": "6.5KB" }, { "path": "./dist/pricingTable*.js", "maxSize": "4.02KB" }, - { "path": "./dist/checkout*.js", "maxSize": "8.5KB" }, + { "path": "./dist/checkout*.js", "maxSize": "8.75KB" }, { "path": "./dist/up-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/op-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/up-plans-page*.js", "maxSize": "1.0KB" }, diff --git a/packages/clerk-js/jest.setup.ts b/packages/clerk-js/jest.setup.ts index 1cc099eab25..9a2c3aa202d 100644 --- a/packages/clerk-js/jest.setup.ts +++ b/packages/clerk-js/jest.setup.ts @@ -33,7 +33,7 @@ if (typeof window !== 'undefined') { })), }); - //@ts-expect-error + //@ts-expect-error - JSDOM doesn't provide IntersectionObserver, so we mock it for testing global.IntersectionObserver = class IntersectionObserver { constructor() {} @@ -53,4 +53,18 @@ if (typeof window !== 'undefined') { return null; } }; + + // Mock HTMLCanvasElement.prototype.getContext to prevent errors + HTMLCanvasElement.prototype.getContext = jest.fn().mockImplementation(((contextType: string) => { + if (contextType === '2d') { + return { + fillRect: jest.fn(), + getImageData: jest.fn(() => ({ data: new Uint8ClampedArray([255, 255, 255, 255]) }) as unknown as ImageData), + } as unknown as CanvasRenderingContext2D; + } + if (contextType === 'webgl' || contextType === 'webgl2') { + return {} as unknown as WebGLRenderingContext; + } + return null; + }) as any) as jest.MockedFunction; } diff --git a/packages/clerk-js/src/core/resources/CommerceCheckout.ts b/packages/clerk-js/src/core/resources/CommerceCheckout.ts index 04fb7e0465c..f8da65639a8 100644 --- a/packages/clerk-js/src/core/resources/CommerceCheckout.ts +++ b/packages/clerk-js/src/core/resources/CommerceCheckout.ts @@ -7,6 +7,8 @@ import type { ConfirmCheckoutParams, } from '@clerk/types'; +import { unixEpochToDate } from '@/utils/date'; + import { commerceTotalsFromJSON } from '../../utils'; import { BaseResource, CommercePaymentSource, CommercePlan, isClerkAPIResponseError } from './internal'; @@ -21,6 +23,7 @@ export class CommerceCheckout extends BaseResource implements CommerceCheckoutRe status!: 'needs_confirmation' | 'completed'; totals!: CommerceCheckoutTotals; isImmediatePlanChange!: boolean; + freeTrialEndsAt!: Date | null; constructor(data: CommerceCheckoutJSON, orgId?: string) { super(); @@ -43,6 +46,7 @@ export class CommerceCheckout extends BaseResource implements CommerceCheckoutRe this.status = data.status; this.totals = commerceTotalsFromJSON(data.totals); this.isImmediatePlanChange = data.is_immediate_plan_change; + this.freeTrialEndsAt = data.free_trial_ends_at ? unixEpochToDate(data.free_trial_ends_at) : null; return this; } diff --git a/packages/clerk-js/src/core/resources/CommerceSubscription.ts b/packages/clerk-js/src/core/resources/CommerceSubscription.ts index 5c776c0e1d2..5677cff4f01 100644 --- a/packages/clerk-js/src/core/resources/CommerceSubscription.ts +++ b/packages/clerk-js/src/core/resources/CommerceSubscription.ts @@ -52,7 +52,7 @@ export class CommerceSubscription extends BaseResource implements CommerceSubscr } : null; this.subscriptionItems = (data.subscription_items || []).map(item => new CommerceSubscriptionItem(item)); - this.eligibleForFreeTrial = data.eligible_for_free_trial; + this.eligibleForFreeTrial = this.withDefault(data.eligible_for_free_trial, false); return this; } } diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx index 6d5bd306a10..e83895bffd6 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx @@ -19,7 +19,7 @@ export const CheckoutComplete = () => { const { setIsOpen } = useDrawerContext(); const { newSubscriptionRedirectUrl } = useCheckoutContext(); const { checkout } = useCheckout(); - const { totals, paymentSource, planPeriodStart } = checkout; + const { totals, paymentSource, planPeriodStart, freeTrialEndsAt } = checkout; const [mousePosition, setMousePosition] = useState({ x: 256, y: 256 }); const [currentPosition, setCurrentPosition] = useState({ x: 256, y: 256 }); @@ -310,9 +310,11 @@ export const CheckoutComplete = () => { as='h2' textVariant='h2' localizationKey={ - totals.totalDueNow.amount > 0 - ? localizationKeys('commerce.checkout.title__paymentSuccessful') - : localizationKeys('commerce.checkout.title__subscriptionSuccessful') + freeTrialEndsAt + ? localizationKeys('commerce.checkout.title__trialSuccess') + : totals.totalDueNow.amount > 0 + ? localizationKeys('commerce.checkout.title__paymentSuccessful') + : localizationKeys('commerce.checkout.title__subscriptionSuccessful') } sx={t => ({ opacity: 0, @@ -399,17 +401,24 @@ export const CheckoutComplete = () => { + + {freeTrialEndsAt ? ( + + + + + ) : null} 0 + totals.totalDueNow.amount > 0 || freeTrialEndsAt !== null ? localizationKeys('commerce.checkout.lineItems.title__paymentMethod') : localizationKeys('commerce.checkout.lineItems.title__subscriptionBegins') } /> 0 + totals.totalDueNow.amount > 0 || freeTrialEndsAt !== null ? paymentSource ? paymentSource.paymentMethod !== 'card' ? `${capitalize(paymentSource.paymentMethod)}` diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 20e8e9f86cb..0f442f237a3 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -17,6 +17,7 @@ import { Box, Button, Col, descriptors, Flex, Form, localizationKeys, Text } fro import { ChevronUpDown, InformationCircle } from '../../icons'; import * as AddPaymentSource from '../PaymentSources/AddPaymentSource'; import { PaymentSourceRow } from '../PaymentSources/PaymentSourceRow'; +import { SubscriptionBadge } from '../Subscriptions/badge'; type PaymentMethodSource = 'existing' | 'new'; @@ -25,7 +26,7 @@ const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); export const CheckoutForm = withCardStateProvider(() => { const { checkout } = useCheckout(); - const { id, plan, totals, isImmediatePlanChange, planPeriod } = checkout; + const { id, plan, totals, isImmediatePlanChange, planPeriod, freeTrialEndsAt } = checkout; if (!id) { return null; @@ -53,6 +54,11 @@ export const CheckoutForm = withCardStateProvider(() => { + ) : null + } /> { )} + + {!!freeTrialEndsAt && !!plan.freeTrialDays && ( + + + + + )} + @@ -278,15 +298,32 @@ export const PayWithTestPaymentSource = () => { ); }; -const AddPaymentSourceForCheckout = withCardStateProvider(() => { - const { addPaymentSourceAndPay } = useCheckoutMutations(); +const useSubmitLabel = () => { const { checkout } = useCheckout(); - const { status, totals } = checkout; + const { status, freeTrialEndsAt, totals } = checkout; if (status === 'needs_initialization') { - return null; + throw new Error('Clerk: Invalid state'); + } + + if (freeTrialEndsAt) { + return localizationKeys('commerce.startFreeTrial'); } + if (totals.totalDueNow.amount > 0) { + return localizationKeys('commerce.pay', { + amount: `${totals.totalDueNow.currencySymbol}${totals.totalDueNow.amountFormatted}`, + }); + } + + return localizationKeys('commerce.subscribe'); +}; + +const AddPaymentSourceForCheckout = withCardStateProvider(() => { + const { addPaymentSourceAndPay } = useCheckoutMutations(); + const submitLabel = useSubmitLabel(); + const { checkout } = useCheckout(); + return ( { - {totals.totalDueNow.amount > 0 ? ( - - ) : ( - - )} + ); }); @@ -317,8 +346,9 @@ const ExistingPaymentSourceForm = withCardStateProvider( totalDueNow: CommerceMoneyAmount; paymentSources: CommercePaymentSourceResource[]; }) => { + const submitLabel = useSubmitLabel(); const { checkout } = useCheckout(); - const { paymentSource } = checkout; + const { paymentSource, freeTrialEndsAt } = checkout; const { payWithExistingPaymentSource } = useCheckoutMutations(); const card = useCardState(); @@ -340,6 +370,8 @@ const ExistingPaymentSourceForm = withCardStateProvider( }); }, [paymentSources]); + const isSchedulePayment = totalDueNow.amount > 0 && !freeTrialEndsAt; + return (
- {totalDueNow.amount > 0 ? ( + {isSchedulePayment ? (