diff --git a/.changeset/stale-pillows-sneeze.md b/.changeset/stale-pillows-sneeze.md new file mode 100644 index 00000000000..7640b43579e --- /dev/null +++ b/.changeset/stale-pillows-sneeze.md @@ -0,0 +1,9 @@ +--- +'@clerk/clerk-js': minor +'@clerk/nextjs': minor +'@clerk/shared': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +wip diff --git a/.changeset/twelve-yaks-love.md b/.changeset/twelve-yaks-love.md new file mode 100644 index 00000000000..1f2160d4b59 --- /dev/null +++ b/.changeset/twelve-yaks-love.md @@ -0,0 +1,8 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/clerk-react': minor +'@clerk/types': minor +--- + +wip diff --git a/integration/tests/pricing-table.test.ts b/integration/tests/pricing-table.test.ts index bbd70bf845c..ee4c7bcaace 100644 --- a/integration/tests/pricing-table.test.ts +++ b/integration/tests/pricing-table.test.ts @@ -309,7 +309,9 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withBilling] })('pricing tabl }); await u.po.checkout.clickPayOrSubscribe(); await expect(u.po.page.locator('.cl-checkout-root').getByText('The card was declined.').first()).toBeVisible(); - await u.po.checkout.waitForStipeElements(); + // It should unmount and remount the payment element + await u.po.checkout.waitForStipeElements({ state: 'hidden' }); + await u.po.checkout.waitForStipeElements({ state: 'visible' }); await u.po.checkout.fillTestCard(); await u.po.checkout.clickPayOrSubscribe(); await expect(u.po.page.locator('.cl-checkout-root').getByText('Payment was successful!')).toBeVisible(); diff --git a/package.json b/package.json index 1cae0b2a3d5..71c0d8a1f19 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "test:integration:ap-flows": "pnpm test:integration:base --grep @ap-flows", "test:integration:astro": "E2E_APP_ID=astro.* pnpm test:integration:base --grep @astro", "test:integration:base": "pnpm playwright test --config integration/playwright.config.ts", - "test:integration:billing": "E2E_APP_ID=withBilling.* pnpm test:integration:base --grep @billing", + "test:integration:billing": "E2E_APP_ID=withBilling.next.appRouter pnpm test:integration:base --grep @billing", "test:integration:cleanup": "pnpm playwright test --config integration/playwright.cleanup.config.ts", "test:integration:deployment:nextjs": "pnpm playwright test --config integration/playwright.deployments.config.ts", "test:integration:elements": "E2E_APP_ID=elements.* pnpm test:integration:base --grep @elements", diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index f58f41096dd..f63ab826703 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -7,6 +7,7 @@ { "path": "./dist/ui-common*.js", "maxSize": "110KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, + { "path": "./dist/stripe-vendors*.js", "maxSize": "5.54KB" }, { "path": "./dist/createorganization*.js", "maxSize": "5KB" }, { "path": "./dist/impersonationfab*.js", "maxSize": "5KB" }, { "path": "./dist/organizationprofile*.js", "maxSize": "12KB" }, @@ -21,8 +22,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": "7.3KB" }, - { "path": "./dist/paymentSources*.js", "maxSize": "9.17KB" }, + { "path": "./dist/checkout*.js", "maxSize": "8.30KB" }, { "path": "./dist/up-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/op-billing-page*.js", "maxSize": "3.0KB" }, { "path": "./dist/sessionTasks*.js", "maxSize": "1.5KB" } diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 3383c9a24dd..939b9ef2673 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -69,7 +69,6 @@ "@floating-ui/react": "0.27.12", "@floating-ui/react-dom": "^2.1.3", "@formkit/auto-animate": "^0.8.2", - "@stripe/react-stripe-js": "3.1.1", "@stripe/stripe-js": "5.6.0", "@swc/helpers": "^0.5.17", "@zxcvbn-ts/core": "3.0.4", diff --git a/packages/clerk-js/rspack.config.js b/packages/clerk-js/rspack.config.js index 912bc51cc46..5164bc21f66 100644 --- a/packages/clerk-js/rspack.config.js +++ b/packages/clerk-js/rspack.config.js @@ -81,6 +81,7 @@ const common = ({ mode, variant, disableRHC = false }) => { * Necessary to prevent the Stripe dependencies from being bundled into * SDKs such as Browser Extensions. */ + // TODO: @COMMERCE: Do we still need this? externals: disableRHC ? ['@stripe/stripe-js', '@stripe/react-stripe-js'] : undefined, optimization: { splitChunks: { @@ -100,6 +101,12 @@ const common = ({ mode, variant, disableRHC = false }) => { name: 'coinbase-wallet-sdk', chunks: 'all', }, + stripeVendor: { + test: /[\\/]node_modules[\\/](@stripe\/stripe-js)[\\/]/, + name: 'stripe-vendors', + chunks: 'all', + enforce: true, + }, /** * Sign up is shared between the SignUp component and the SignIn component. */ @@ -108,17 +115,11 @@ const common = ({ mode, variant, disableRHC = false }) => { name: 'signup', test: module => !!(module.resource && module.resource.includes('/ui/components/SignUp')), }, - paymentSources: { - minChunks: 1, - name: 'paymentSources', - test: module => - !!( - module.resource && - (module.resource.includes('/ui/components/PaymentSources') || - // Include `@stripe/react-stripe-js` and `@stripe/stripe-js` in the checkout chunk - module.resource.includes('/node_modules/@stripe')) - ), - }, + // paymentSources: { + // minChunks: 1, + // name: 'paymentSources', + // test: module => !!(module.resource && module.resource.includes('/ui/components/PaymentSources')), + // }, common: { minChunks: 1, name: 'ui-common', diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 7e27858023f..59ef273483e 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -136,6 +136,8 @@ import type { FapiClient, FapiRequestCallback } from './fapiClient'; import { createFapiClient } from './fapiClient'; import { createClientFromJwt } from './jwt-client'; import { APIKeys } from './modules/apiKeys'; +import type { CheckoutFunction } from './modules/checkout'; +import { createCheckoutInstance } from './modules/checkout'; import { CommerceBilling } from './modules/commerce'; import { BaseResource, @@ -195,6 +197,7 @@ export class Clerk implements ClerkInterface { }; private static _billing: CommerceBillingNamespace; private static _apiKeys: APIKeysNamespace; + private _checkout: CheckoutFunction | undefined; public client: ClientResource | undefined; public session: SignedInSessionResource | null | undefined; @@ -337,6 +340,13 @@ export class Clerk implements ClerkInterface { return Clerk._apiKeys; } + get checkout() { + if (!this._checkout) { + this._checkout = params => createCheckoutInstance(this, params); + } + return this._checkout; + } + public __internal_getOption(key: K): ClerkOptions[K] { return this.#options[key]; } @@ -645,6 +655,11 @@ export class Clerk implements ClerkInterface { .then(controls => controls.closeModal('blankCaptcha')); }; + public __internal_loadStripeJs = async () => { + const { loadStripe } = await import('@stripe/stripe-js'); + return loadStripe; + }; + public openSignUp = (props?: SignUpProps): void => { this.assertComponentsReady(this.#componentControls); if (sessionExistsAndSingleSessionModeEnabled(this, this.environment)) { @@ -1120,6 +1135,7 @@ export class Clerk implements ClerkInterface { */ public setActive = async ({ session, organization, beforeEmit, redirectUrl }: SetActiveParams): Promise => { this.__internal_setActiveInProgress = true; + console.log('session provided', session, redirectUrl); try { if (!this.client) { throw new Error('setActive is being called before the client is loaded. Wait for init.'); @@ -1146,6 +1162,7 @@ export class Clerk implements ClerkInterface { } let newSession = session === undefined ? this.session : session; + console.log('newSession', newSession?.id); // At this point, the `session` variable should contain either an `SignedInSessionResource` // ,`null` or `undefined`. diff --git a/packages/clerk-js/src/core/modules/checkout.ts b/packages/clerk-js/src/core/modules/checkout.ts new file mode 100644 index 00000000000..74d1180e685 --- /dev/null +++ b/packages/clerk-js/src/core/modules/checkout.ts @@ -0,0 +1,243 @@ +import type { CommerceCheckoutResource, CommerceSubscriptionPlanPeriod, ConfirmCheckoutParams } from '@clerk/types'; + +import type { ClerkAPIResponseError } from '../..'; +import type { Clerk } from '../clerk'; + +type CheckoutStatus = 'awaiting_initialization' | 'awaiting_confirmation' | 'completed'; + +export type CheckoutOptions = { + for?: 'organization'; + planPeriod: CommerceSubscriptionPlanPeriod; + planId: string; +}; + +export type CheckoutInstance = { + confirm: (params: ConfirmCheckoutParams) => Promise; + start: () => Promise; + clear: () => void; + finalize: (params: { redirectUrl?: string }) => void; + subscribe: (listener: (state: CheckoutCacheState) => void) => () => void; + getState: () => CheckoutCacheState; +}; + +type CheckoutKey = string; + +type CheckoutCacheState = { + isStarting: boolean; + isConfirming: boolean; + error: ClerkAPIResponseError | null; + checkout: CommerceCheckoutResource | null; + fetchStatus: 'idle' | 'fetching' | 'error'; + status: CheckoutStatus; +}; + +const createManagerCache = () => { + const cache = new Map(); + const listeners = new Map void>>(); + const pendingOperations = new Map>>(); + + return { + cache, + listeners, + pendingOperations, + safeGet>(key: K, map: Map): NonNullable { + if (!map.has(key)) { + map.set(key, new Set() as V); + } + return map.get(key) as NonNullable; + }, + safeGetOperations(key: K): Map> { + if (!this.pendingOperations.has(key)) { + this.pendingOperations.set(key, new Map>()); + } + return this.pendingOperations.get(key) as Map>; + }, + }; +}; + +const managerCache = createManagerCache(); + +/** + * Derives the checkout state from the base state. + */ +function deriveCheckoutState(baseState: Omit): CheckoutCacheState { + const fetchStatus = (() => { + if (baseState.isStarting || baseState.isConfirming) return 'fetching' as const; + if (baseState.error) return 'error' as const; + return 'idle' as const; + })(); + + const status = (() => { + const completedCode = 'completed'; + if (baseState.checkout?.status === completedCode) return 'completed' as const; + if (baseState.checkout) return 'awaiting_confirmation' as const; + return 'awaiting_initialization' as const; + })(); + + return { + ...baseState, + fetchStatus, + status, + }; +} + +const defaultCacheState: CheckoutCacheState = deriveCheckoutState({ + isStarting: false, + isConfirming: false, + error: null, + checkout: null, +}); + +/** + * Factory function that creates a checkout manager for a specific cache key. + */ +function createCheckoutManager(cacheKey: CheckoutKey) { + const listeners = managerCache.safeGet(cacheKey, managerCache.listeners); + const pendingOperations = managerCache.safeGetOperations(cacheKey); + + const notifyListeners = () => { + listeners.forEach(listener => listener(getCacheState())); + }; + + const getCacheState = (): CheckoutCacheState => { + return managerCache.cache.get(cacheKey) || defaultCacheState; + }; + + const updateCacheState = (updates: Partial>): void => { + const currentState = getCacheState(); + const baseState = { ...currentState, ...updates }; + const newState = deriveCheckoutState(baseState); + managerCache.cache.set(cacheKey, newState); + notifyListeners(); + }; + + return { + subscribe(listener: (newState: CheckoutCacheState) => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + + getCacheState, + + // Shared operation handler to eliminate duplication + async executeOperation( + operationType: 'start' | 'confirm', + operationFn: () => Promise, + ): Promise { + const operationId = `${cacheKey}-${operationType}`; + const isRunningField = operationType === 'start' ? 'isStarting' : 'isConfirming'; + + // Check if there's already a pending operation + const existingOperation = pendingOperations.get(operationId); + if (existingOperation) { + // Wait for the existing operation to complete and return its result + // If it fails, all callers should receive the same error + return await existingOperation; + } + + // Create and store the operation promise + const operationPromise = (async () => { + try { + updateCacheState({ [isRunningField]: true, error: null }); + const result = await operationFn(); + updateCacheState({ [isRunningField]: false, error: null, checkout: result }); + return result; + } catch (error) { + const clerkError = error as ClerkAPIResponseError; + updateCacheState({ [isRunningField]: false, error: clerkError }); + throw error; + } finally { + pendingOperations.delete(operationId); + } + })(); + + pendingOperations.set(operationId, operationPromise); + return operationPromise; + }, + + clearCheckout(): void { + // Only reset the state if there are no pending operations + if (pendingOperations.size === 0) { + updateCacheState(defaultCacheState); + } + }, + }; +} + +/** + * Generate cache key for checkout instance + */ +function cacheKey(options: { userId: string; orgId?: string; planId: string; planPeriod: string }): CheckoutKey { + const { userId, orgId, planId, planPeriod } = options; + return `${userId}-${orgId || 'user'}-${planId}-${planPeriod}`; +} + +export type CheckoutFunction = (options: CheckoutOptions) => CheckoutInstance; + +/** + * Create a checkout instance with the given options + */ +function createCheckoutInstance(clerk: Clerk, options: CheckoutOptions): CheckoutInstance { + const { for: forOrganization, planId, planPeriod } = options; + + if (!clerk.user) { + throw new Error('Clerk: User is not authenticated'); + } + + if (forOrganization === 'organization' && !clerk.organization) { + throw new Error('Clerk: Use `setActive` to set the organization'); + } + + const checkoutKey = cacheKey({ + userId: clerk.user.id, + orgId: forOrganization === 'organization' ? clerk.organization?.id : undefined, + planId, + planPeriod, + }); + + const manager = createCheckoutManager(checkoutKey); + + const start = async (): Promise => { + return manager.executeOperation('start', async () => { + const result = await clerk.billing?.startCheckout({ + ...(forOrganization === 'organization' ? { orgId: clerk.organization?.id } : {}), + planId, + planPeriod, + }); + return result; + }); + }; + + const confirm = async (params: ConfirmCheckoutParams): Promise => { + return manager.executeOperation('confirm', async () => { + const checkout = manager.getCacheState().checkout; + if (!checkout) { + throw new Error('Clerk: Call `start` before `confirm`'); + } + return checkout.confirm(params); + }); + }; + + const finalize = ({ redirectUrl }: { redirectUrl?: string }) => { + void clerk.setActive({ session: clerk.session?.id, redirectUrl }); + }; + + const clear = () => manager.clearCheckout(); + + const subscribe = (listener: (state: CheckoutCacheState) => void) => { + return manager.subscribe(listener); + }; + + return { + start, + confirm, + finalize, + clear, + subscribe, + getState: manager.getCacheState, + }; +} + +export { createCheckoutInstance }; diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx index 6b5dad5fd62..eb09c51b776 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutComplete.tsx @@ -1,3 +1,4 @@ +import { __experimental_useCheckout as useCheckout } from '@clerk/shared/react'; import { useEffect, useId, useRef, useState } from 'react'; import { Drawer, useDrawerContext } from '@/ui/elements/Drawer'; @@ -9,7 +10,6 @@ import { Box, Button, descriptors, Heading, localizationKeys, Span, Text, useApp import { transitionDurationValues, transitionTiming } from '../../foundations/transitions'; import { usePrefersReducedMotion } from '../../hooks'; import { useRouter } from '../../router'; -import { useCheckoutContextRoot } from './CheckoutPage'; const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); const lerp = (start: number, end: number, amt: number) => start + (end - start) * amt; @@ -18,7 +18,7 @@ export const CheckoutComplete = () => { const router = useRouter(); const { setIsOpen } = useDrawerContext(); const { newSubscriptionRedirectUrl } = useCheckoutContext(); - const { checkout } = useCheckoutContextRoot(); + const checkout = useCheckout(); const [mousePosition, setMousePosition] = useState({ x: 256, y: 256 }); const [currentPosition, setCurrentPosition] = useState({ x: 256, y: 256 }); diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx index 0c8f5912333..eb267cb3573 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutForm.tsx @@ -1,11 +1,10 @@ -import { useOrganization } from '@clerk/shared/react'; +import { __experimental_useCheckout as useCheckout, useOrganization } from '@clerk/shared/react'; import type { CommerceCheckoutResource, CommerceMoney, CommercePaymentSourceResource, ConfirmCheckoutParams, } from '@clerk/types'; -import type { SetupIntent } from '@stripe/stripe-js'; import { useMemo, useState } from 'react'; import { Card } from '@/ui/elements/Card'; @@ -23,21 +22,19 @@ 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 { useCheckoutContextRoot } from './CheckoutPage'; type PaymentMethodSource = 'existing' | 'new'; const capitalize = (name: string) => name[0].toUpperCase() + name.slice(1); export const CheckoutForm = withCardStateProvider(() => { - const ctx = useCheckoutContextRoot(); - const { checkout } = ctx; + const checkout = useCheckout(); + const { id, plan, totals, isImmediatePlanChange, __internal_checkout, planPeriod } = checkout; - if (!checkout) { + if (!id) { return null; } - const { plan, planPeriod, totals, isImmediatePlanChange } = checkout; const showCredits = !!totals.credit?.amount && totals.credit.amount > 0; const showPastDue = !!totals.pastDue?.amount && totals.pastDue.amount > 0; const showDowngradeInfo = !isImmediatePlanChange; @@ -115,18 +112,18 @@ export const CheckoutForm = withCardStateProvider(() => { )} - + ); }); const useCheckoutMutations = () => { const { organization } = useOrganization(); - const { subscriberType } = useCheckoutContext(); - const { updateCheckout, checkout } = useCheckoutContextRoot(); + const { subscriberType, onSubscriptionComplete } = useCheckoutContext(); + const { id, confirm } = useCheckout(); const card = useCardState(); - if (!checkout) { + if (!id) { throw new Error('Checkout not found'); } @@ -134,11 +131,11 @@ const useCheckoutMutations = () => { card.setLoading(); card.setError(undefined); try { - const newCheckout = await checkout.confirm({ + await confirm({ ...params, ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), }); - updateCheckout(newCheckout); + onSubscriptionComplete?.(); } catch (error) { handleError(error, [], card.setError); } finally { @@ -146,42 +143,24 @@ const useCheckoutMutations = () => { } }; - const payWithExistingPaymentSource = async (e: React.FormEvent) => { + const payWithExistingPaymentSource = (e: React.FormEvent) => { e.preventDefault(); const data = new FormData(e.currentTarget); const paymentSourceId = data.get('payment_source_id') as string; - await confirmCheckout({ + return confirmCheckout({ paymentSourceId, - ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), }); }; - const addPaymentSourceAndPay = async (ctx: { stripeSetupIntent?: SetupIntent }) => { - await confirmCheckout({ + const addPaymentSourceAndPay = (ctx: { gateway: 'stripe'; paymentToken: string }) => confirmCheckout(ctx); + + const payWithTestCard = () => + confirmCheckout({ gateway: 'stripe', - paymentToken: ctx.stripeSetupIntent?.payment_method as string, - ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), + useTestCard: true, }); - }; - - const payWithTestCard = async () => { - card.setLoading(); - card.setError(undefined); - try { - const newCheckout = await checkout.confirm({ - gateway: 'stripe', - useTestCard: true, - ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), - }); - updateCheckout(newCheckout); - } catch (error) { - handleError(error, [], card.setError); - } finally { - card.setIdle(); - } - }; return { payWithExistingPaymentSource, @@ -295,25 +274,25 @@ export const PayWithTestPaymentSource = () => { const AddPaymentSourceForCheckout = withCardStateProvider(() => { const { addPaymentSourceAndPay } = useCheckoutMutations(); - const { checkout } = useCheckoutContextRoot(); + const { id, __internal_checkout, totals } = useCheckout(); - if (!checkout) { + if (!id) { return null; } return ( - {checkout.totals.totalDueNow.amount > 0 ? ( + {totals.totalDueNow.amount > 0 ? ( ) : ( diff --git a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx index 6d8f2747ee2..6d2762bce22 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx @@ -1,130 +1,80 @@ -import { useClerk, useOrganization, useUser } from '@clerk/shared/react'; -import type { ClerkAPIError, CommerceCheckoutResource } from '@clerk/types'; -import { createContext, useContext, useEffect, useMemo } from 'react'; -import useSWR from 'swr'; -import useSWRMutation from 'swr/mutation'; +import { __experimental_useCheckout as useCheckout, CheckoutProvider } from '@clerk/shared/react'; +import { useEffect, useMemo } from 'react'; -import { useCheckoutContext } from '../../contexts'; +import { useCheckoutContext } from '@/ui/contexts/components'; -type CheckoutStatus = 'pending' | 'ready' | 'completed' | 'missing_payer_email' | 'invalid_plan_change' | 'error'; +// const CheckoutContextRoot = createContext | null>(null); -const CheckoutContextRoot = createContext<{ - checkout: CommerceCheckoutResource | undefined; - isLoading: boolean; - updateCheckout: (checkout: CommerceCheckoutResource) => void; - errors: ClerkAPIError[]; - startCheckout: () => void; - status: CheckoutStatus; -} | null>(null); +// export const useCheckoutContextRoot = () => { +// const ctx = useContext(CheckoutContextRoot); +// if (!ctx) { +// throw new Error('CheckoutContextRoot not found'); +// } +// return ctx; +// }; -export const useCheckoutContextRoot = () => { - const ctx = useContext(CheckoutContextRoot); - if (!ctx) { - throw new Error('CheckoutContextRoot not found'); - } - return ctx; -}; - -const useCheckoutCreator = () => { - const { planId, planPeriod, subscriberType = 'user', onSubscriptionComplete } = useCheckoutContext(); - const clerk = useClerk(); - const { organization } = useOrganization(); - - const { user } = useUser(); - - const cacheKey = { - key: `commerce-checkout`, - userId: user?.id, - arguments: { - ...(subscriberType === 'org' ? { orgId: organization?.id } : {}), - planId, - planPeriod, - }, - }; - - // Manually handle the cache - const { data, mutate } = useSWR(cacheKey); - - // Use `useSWRMutation` to avoid revalidations on stale-data/focus etc. - const { - trigger: startCheckout, - isMutating, - error, - } = useSWRMutation( - cacheKey, - key => - clerk.billing?.startCheckout( - // @ts-expect-error things are typed as optional - key.arguments, - ), - { - // Never throw on error, we want to handle it during rendering - throwOnError: false, - onSuccess: data => { - mutate(data, false); - }, - }, - ); +const Initiator = () => { + const checkout = useCheckout(); useEffect(() => { - void startCheckout(); - return () => { - // Clear the cache on unmount - mutate(undefined, false); - }; + checkout.start().catch(() => null); + return checkout.clear; }, []); - - return { - checkout: data, - startCheckout, - updateCheckout: (checkout: CommerceCheckoutResource) => { - void mutate(checkout, false); - onSubscriptionComplete?.(); - }, - isMutating, - errors: error?.errors, - }; + return null; }; const Root = ({ children }: { children: React.ReactNode }) => { - const { checkout, isMutating, updateCheckout, errors, startCheckout } = useCheckoutCreator(); - - const status = useMemo(() => { - if (isMutating) return 'pending'; - const completedCode = 'completed'; - if (checkout?.status === completedCode) return completedCode; - if (checkout) return 'ready'; - - const missingCode = 'missing_payer_email'; - const isMissingPayerEmail = !!errors?.some((e: ClerkAPIError) => e.code === missingCode); - if (isMissingPayerEmail) return missingCode; - const invalidChangeCode = 'invalid_plan_change'; - if (errors?.[0]?.code === invalidChangeCode) return invalidChangeCode; - return 'error'; - }, [isMutating, errors, checkout, checkout?.status]); + const { planId, planPeriod, subscriberType } = useCheckoutContext(); return ( - + {children} - + ); }; -const Stage = ({ children, name }: { children: React.ReactNode; name: CheckoutStatus }) => { - const ctx = useCheckoutContextRoot(); - if (ctx.status !== name) { +const Stage = ({ children, name }: { children: React.ReactNode; name: ReturnType['status'] }) => { + const { status } = useCheckout(); + if (status !== name) { + return null; + } + return children; +}; + +const FetchStatus = ({ + children, + status, +}: { + children: React.ReactNode; + status: 'idle' | 'fetching' | 'error' | 'invalid_plan_change' | 'missing_payer_email'; +}) => { + const { fetchStatus, error } = useCheckout(); + + const internalFetchStatus = useMemo(() => { + if (fetchStatus === 'error' && error?.errors) { + const errorCodes = error.errors.map(e => e.code); + + if (errorCodes.includes('missing_payer_email')) { + return 'missing_payer_email'; + } + + if (errorCodes.includes('invalid_plan_change')) { + return 'invalid_plan_change'; + } + } + + return fetchStatus; + }, [fetchStatus, error]); + + if (internalFetchStatus !== status) { return null; } return children; }; -export { Root, Stage }; +export { Root, Stage, FetchStatus }; diff --git a/packages/clerk-js/src/ui/components/Checkout/index.tsx b/packages/clerk-js/src/ui/components/Checkout/index.tsx index 67c37daecc4..251510e75b4 100644 --- a/packages/clerk-js/src/ui/components/Checkout/index.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/index.tsx @@ -23,31 +23,33 @@ export const Checkout = (props: __internal_CheckoutProps) => { - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - + diff --git a/packages/clerk-js/src/ui/components/Checkout/parts.tsx b/packages/clerk-js/src/ui/components/Checkout/parts.tsx index 4fd79a91562..3bbcd29e5f1 100644 --- a/packages/clerk-js/src/ui/components/Checkout/parts.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/parts.tsx @@ -1,3 +1,4 @@ +import { __experimental_useCheckout as useCheckout } from '@clerk/shared/react'; import { useMemo } from 'react'; import { Alert } from '@/ui/elements/Alert'; @@ -8,10 +9,10 @@ import { useCheckoutContext } from '../../contexts'; import { Box, descriptors, Flex, localizationKeys, useLocalizations } from '../../customizables'; // TODO(@COMMERCE): Is this causing bundle size issues ? import { EmailForm } from '../UserProfile/EmailForm'; -import { useCheckoutContextRoot } from './CheckoutPage'; export const GenericError = () => { - const { errors } = useCheckoutContextRoot(); + const { error } = useCheckout(); + const { translateError } = useLocalizations(); const { t } = useLocalizations(); return ( @@ -29,7 +30,7 @@ export const GenericError = () => { variant='danger' colorScheme='danger' > - {errors ? translateError(errors[0]) : t(localizationKeys('unstable__errors.form_param_value_invalid'))} + {error ? translateError(error.errors[0]) : t(localizationKeys('unstable__errors.form_param_value_invalid'))} @@ -37,14 +38,13 @@ export const GenericError = () => { }; export const InvalidPlanScreen = () => { - const { errors } = useCheckoutContextRoot(); + const { planPeriod } = useCheckoutContext(); + const { error } = useCheckout(); const planFromError = useMemo(() => { - const error = errors?.find(e => e.code === 'invalid_plan_change'); - return error?.meta?.plan; - }, [errors]); - - const { planPeriod } = useCheckoutContext(); + const _error = error?.errors.find(e => e.code === 'invalid_plan_change'); + return _error?.meta?.plan; + }, [error]); if (!planFromError) { return null; @@ -92,7 +92,7 @@ export const InvalidPlanScreen = () => { }; export const AddEmailForm = () => { - const { startCheckout } = useCheckoutContextRoot(); + const { start } = useCheckout(); const { setIsOpen } = useDrawerContext(); return ( @@ -105,7 +105,7 @@ export const AddEmailForm = () => { start().catch(() => null)} onReset={() => setIsOpen(false)} disableAutoFocus /> diff --git a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx index d3168940d15..7e51c027b62 100644 --- a/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx +++ b/packages/clerk-js/src/ui/components/PaymentSources/AddPaymentSource.tsx @@ -1,12 +1,12 @@ -import { createContextAndHook, useOrganization, useUser } from '@clerk/shared/react'; +import { + __experimental_PaymentElementForm as PaymentElementForm, + __experimental_PaymentElementRoot as PaymentElementRoot, + __experimental_usePaymentElement as usePaymentElement, + createContextAndHook, +} from '@clerk/shared/react'; import type { CommerceCheckoutResource } from '@clerk/types'; -import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'; -import type { Appearance as StripeAppearance, SetupIntent } from '@stripe/stripe-js'; -import { loadStripe } from '@stripe/stripe-js'; import type { PropsWithChildren } from 'react'; import { useEffect, useRef, useState } from 'react'; -import useSWR from 'swr'; -import useSWRMutation from 'swr/mutation'; import { Card } from '@/ui/elements/Card'; import { useCardState } from '@/ui/elements/contexts'; @@ -16,77 +16,61 @@ import { FormContainer } from '@/ui/elements/FormContainer'; import { handleError } from '@/ui/utils/errorHandler'; import { normalizeColorString } from '@/ui/utils/normalizeColorString'; -import { clerkUnsupportedEnvironmentWarning } from '../../../core/errors'; -import { useEnvironment, useSubscriberTypeContext, useSubscriberTypeLocalizationRoot } from '../../contexts'; +import { useSubscriberTypeContext, useSubscriberTypeLocalizationRoot } from '../../contexts'; import { descriptors, Flex, localizationKeys, Spinner, useAppearance, useLocalizations } from '../../customizables'; import type { LocalizationKey } from '../../localization'; +const useStipeAppearance = () => { + const { colors, fontWeights, fontSizes, radii, space } = useAppearance().parsedInternalTheme; + const elementsAppearance = { + // variables: { + colorPrimary: normalizeColorString(colors.$primary500), + colorBackground: normalizeColorString(colors.$colorInputBackground), + colorText: normalizeColorString(colors.$colorText), + colorTextSecondary: normalizeColorString(colors.$colorTextSecondary), + colorSuccess: normalizeColorString(colors.$success500), + colorDanger: normalizeColorString(colors.$danger500), + colorWarning: normalizeColorString(colors.$warning500), + fontWeightNormal: fontWeights.$normal.toString(), + fontWeightMedium: fontWeights.$medium.toString(), + fontWeightBold: fontWeights.$bold.toString(), + fontSizeXl: fontSizes.$xl, + fontSizeLg: fontSizes.$lg, + fontSizeSm: fontSizes.$md, + fontSizeXs: fontSizes.$sm, + borderRadius: radii.$md, + spacingUnit: space.$1, + // }, + }; + + return elementsAppearance; +}; + type AddPaymentSourceProps = { - onSuccess: (context: { stripeSetupIntent?: SetupIntent }) => Promise; + onSuccess: (context: { gateway: 'stripe'; paymentToken: string }) => Promise; checkout?: CommerceCheckoutResource; cancelAction?: () => void; }; -const usePaymentSourceUtils = () => { - const { organization } = useOrganization(); - const { user } = useUser(); - const subscriberType = useSubscriberTypeContext(); - const resource = subscriberType === 'org' ? organization : user; - - const { data: initializedPaymentSource, trigger: initializePaymentSource } = useSWRMutation( - { - key: 'commerce-payment-source-initialize', - resourceId: resource?.id, - }, - () => - resource?.initializePaymentSource({ - gateway: 'stripe', - }), - ); - const { commerceSettings } = useEnvironment(); - - const externalGatewayId = initializedPaymentSource?.externalGatewayId; - const externalClientSecret = initializedPaymentSource?.externalClientSecret; - const paymentMethodOrder = initializedPaymentSource?.paymentMethodOrder; - const stripePublishableKey = commerceSettings.billing.stripePublishableKey; - - const { data: stripe } = useSWR( - externalGatewayId && stripePublishableKey ? { key: 'stripe-sdk', externalGatewayId, stripePublishableKey } : null, - ({ stripePublishableKey, externalGatewayId }) => { - if (__BUILD_DISABLE_RHC__) { - clerkUnsupportedEnvironmentWarning('Stripe'); - return; - } - return loadStripe(stripePublishableKey, { - stripeAccount: externalGatewayId, - }); - }, - { - keepPreviousData: true, - revalidateOnFocus: false, - dedupingInterval: 1_000 * 60, // 1 minute - }, - ); - - return { - stripe, - initializePaymentSource, - externalClientSecret, - paymentMethodOrder, - }; -}; - -const [AddPaymentSourceContext, useAddPaymentSourceContext] = createContextAndHook('AddPaymentSourceRoot'); +const [AddPaymentSourceContext, useAddPaymentSourceContext] = createContextAndHook< + AddPaymentSourceProps & { + headerTitle: LocalizationKey | undefined; + headerSubtitle: LocalizationKey | undefined; + submitLabel: LocalizationKey | undefined; + setHeaderTitle: (title: LocalizationKey) => void; + setHeaderSubtitle: (subtitle: LocalizationKey) => void; + setSubmitLabel: (label: LocalizationKey) => void; + onSuccess: (context: { gateway: 'stripe'; paymentToken: string }) => Promise; + } +>('AddPaymentSourceRoot'); -const AddPaymentSourceRoot = ({ children, ...rest }: PropsWithChildren) => { - const { initializePaymentSource, externalClientSecret, stripe, paymentMethodOrder } = usePaymentSourceUtils(); +const AddPaymentSourceRoot = ({ children, checkout, ...rest }: PropsWithChildren) => { + const subscriberType = useSubscriberTypeContext(); + const { t } = useLocalizations(); const [headerTitle, setHeaderTitle] = useState(undefined); const [headerSubtitle, setHeaderSubtitle] = useState(undefined); const [submitLabel, setSubmitLabel] = useState(undefined); - - useEffect(() => { - void initializePaymentSource(); - }, []); + const stripeAppearance = useStipeAppearance(); return ( - {children} + + {children} + ); }; const AddPaymentSourceLoading = (props: PropsWithChildren) => { - const { stripe, externalClientSecret } = useAddPaymentSourceContext(); + const { isProviderReady } = usePaymentElement(); - if (!stripe || !externalClientSecret) { + if (!isProviderReady) { return props.children; } @@ -122,44 +116,13 @@ const AddPaymentSourceLoading = (props: PropsWithChildren) => { }; const AddPaymentSourceReady = (props: PropsWithChildren) => { - const { externalClientSecret, stripe } = useAddPaymentSourceContext(); + const { isProviderReady } = usePaymentElement(); - const { colors, fontWeights, fontSizes, radii, space } = useAppearance().parsedInternalTheme; - const elementsAppearance: StripeAppearance = { - variables: { - colorPrimary: normalizeColorString(colors.$primary500), - colorBackground: normalizeColorString(colors.$colorInputBackground), - colorText: normalizeColorString(colors.$colorText), - colorTextSecondary: normalizeColorString(colors.$colorTextSecondary), - colorSuccess: normalizeColorString(colors.$success500), - colorDanger: normalizeColorString(colors.$danger500), - colorWarning: normalizeColorString(colors.$warning500), - fontWeightNormal: fontWeights.$normal.toString(), - fontWeightMedium: fontWeights.$medium.toString(), - fontWeightBold: fontWeights.$bold.toString(), - fontSizeXl: fontSizes.$xl, - fontSizeLg: fontSizes.$lg, - fontSizeSm: fontSizes.$md, - fontSizeXs: fontSizes.$sm, - borderRadius: radii.$md, - spacingUnit: space.$1, - }, - }; - - if (!stripe || !externalClientSecret) { + if (!isProviderReady) { return null; } - return ( - - {props.children} - - ); + return <>{props.children}; }; const Root = (props: PropsWithChildren) => { @@ -221,51 +184,33 @@ const FormButton = ({ text }: { text: LocalizationKey }) => { }; const AddPaymentSourceForm = ({ children }: PropsWithChildren) => { - const { - headerTitle, - headerSubtitle, - submitLabel, - checkout, - initializePaymentSource, - onSuccess, - cancelAction, - paymentMethodOrder, - } = useAddPaymentSourceContext(); - const [isPaymentElementReady, setIsPaymentElementReady] = useState(false); - const stripe = useStripe(); + const { headerTitle, headerSubtitle, submitLabel, checkout, onSuccess, cancelAction } = useAddPaymentSourceContext(); const card = useCardState(); - const elements = useElements(); - const { displayConfig } = useEnvironment(); - const { t } = useLocalizations(); const localizationRoot = useSubscriberTypeLocalizationRoot(); + const { isFormReady, submit: submitPaymentElement, reset } = usePaymentElement(); + const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!stripe || !elements) { - return; - } - card.setLoading(); card.setError(undefined); - const { setupIntent, error } = await stripe.confirmSetup({ - elements, - confirmParams: { - return_url: '', // TODO(@COMMERCE): need to figure this out - }, - redirect: 'if_required', - }); + const { data, error } = await submitPaymentElement(); if (error) { return; // just return, since stripe will handle the error } + // TODO: @COMMERCE: why is this not infered as defined? + if (!data) { + return; + } try { - await onSuccess({ stripeSetupIntent: setupIntent }); + await onSuccess(data); } catch (error) { void handleError(error, [], card.setError); } finally { card.setIdle(); - initializePaymentSource(); // resets the payment intent + void reset(); // resets the payment intent } }; @@ -283,32 +228,10 @@ const AddPaymentSourceForm = ({ children }: PropsWithChildren) => { })} > {children} - setIsPaymentElementReady(true)} - options={{ - layout: { - type: 'tabs', - defaultCollapsed: false, - }, - paymentMethodOrder, - applePay: checkout - ? { - recurringPaymentRequest: { - paymentDescription: `${t(localizationKeys(checkout.planPeriod === 'month' ? 'commerce.paymentSource.applePayDescription.monthly' : 'commerce.paymentSource.applePayDescription.annual'))}`, - managementURL: displayConfig.homeUrl, // TODO(@COMMERCE): is this the right URL? - regularBilling: { - amount: checkout.totals.totalDueNow?.amount || checkout.totals.grandTotal.amount, - label: checkout.plan.name, - recurringPaymentIntervalUnit: checkout.planPeriod === 'annual' ? 'year' : 'month', - }, - }, - } - : undefined, - }} - /> + {card.error} void const subscriberType = useSubscriberTypeContext(); const localizationRoot = useSubscriberTypeLocalizationRoot(); - const onAddPaymentSourceSuccess = async (context: { stripeSetupIntent?: SetupIntent }) => { + const onAddPaymentSourceSuccess = async (context: { gateway: 'stripe'; paymentToken: string }) => { const resource = subscriberType === 'org' ? clerk?.organization : clerk.user; - await resource?.addPaymentSource({ - gateway: 'stripe', - paymentToken: context.stripeSetupIntent?.payment_method as string, - }); + await resource?.addPaymentSource(context); onSuccess(); close(); return Promise.resolve(); diff --git a/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx b/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx index 78b186bba95..0bbabbf6f1e 100644 --- a/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx +++ b/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx @@ -1,4 +1,5 @@ import { + CheckoutProvider, ClerkInstanceContext, ClientContext, OrganizationProvider, @@ -54,7 +55,14 @@ export function CoreClerkContextWrapper(props: CoreClerkContextWrapperProps): JS {...organizationCtx.value} swrConfig={props.swrConfig} > - {props.children} + + + {props.children} + + diff --git a/packages/nextjs/src/client-boundary/hooks.ts b/packages/nextjs/src/client-boundary/hooks.ts index c7748535767..4f6cac0ddc1 100644 --- a/packages/nextjs/src/client-boundary/hooks.ts +++ b/packages/nextjs/src/client-boundary/hooks.ts @@ -11,6 +11,15 @@ export { useSignUp, useUser, useReverification, + __experimental_useCheckout, + CheckoutProvider, + __experimental_usePaymentMethods, + __experimental_useSubscriptionItems, + __experimental_useStatements, + __experimental_usePaymentAttempts, + __experimental_usePaymentElement, + __experimental_PaymentElementRoot, + __experimental_PaymentElementForm, } from '@clerk/clerk-react'; export { diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index f57260044ac..fac06b5586c 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -54,6 +54,15 @@ export { useSignUp, useUser, useReverification, + __experimental_useCheckout, + CheckoutProvider, + __experimental_usePaymentMethods, + __experimental_useSubscriptionItems, + __experimental_useStatements, + __experimental_usePaymentAttempts, + __experimental_usePaymentElement, + __experimental_PaymentElementRoot, + __experimental_PaymentElementForm, } from './client-boundary/hooks'; /** diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index a86a84a47e1..c1e6b855dd5 100644 --- a/packages/react/src/contexts/ClerkContextProvider.tsx +++ b/packages/react/src/contexts/ClerkContextProvider.tsx @@ -1,5 +1,11 @@ import { deriveState } from '@clerk/shared/deriveState'; -import { ClientContext, OrganizationProvider, SessionContext, UserContext } from '@clerk/shared/react'; +import { + CheckoutProvider, + ClientContext, + OrganizationProvider, + SessionContext, + UserContext, +} from '@clerk/shared/react'; import type { ClientResource, InitialState, Resources } from '@clerk/types'; import React from 'react'; @@ -89,7 +95,14 @@ export function ClerkContextProvider(props: ClerkContextProvider) { - {children} + + + {children} + + diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 5a6cb60cb6b..afda4e12c0d 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -10,4 +10,13 @@ export { useUser, useSession, useReverification, + __experimental_useCheckout, + CheckoutProvider, + __experimental_PaymentElementForm, + __experimental_PaymentElementRoot, + __experimental_usePaymentElement, + __experimental_usePaymentAttempts, + __experimental_usePaymentMethods, + __experimental_useStatements, + __experimental_useSubscriptionItems, } from '@clerk/shared/react'; diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index c92f3d57692..93890f7f35f 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -705,6 +705,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { return this.clerkjs?.apiKeys; } + checkout = (...args: Parameters) => { + return this.clerkjs?.checkout(...args); + }; + __unstable__setEnvironment(...args: any): void { if (this.clerkjs && '__unstable__setEnvironment' in this.clerkjs) { (this.clerkjs as any).__unstable__setEnvironment(args); @@ -1310,6 +1314,11 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { return clerkjs.authenticateWithGoogleOneTap(params); }; + __internal_loadStripeJs = async () => { + const clerkjs = await this.#waitForClerkJS(); + return clerkjs.__internal_loadStripeJs(); + }; + createOrganization = async (params: CreateOrganizationParams): Promise => { const callback = () => this.clerkjs?.createOrganization(params); if (this.clerkjs && this.loaded) { diff --git a/packages/shared/package.json b/packages/shared/package.json index a626b2ec4cc..15ef89a517a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -150,6 +150,8 @@ "swr": "^2.3.3" }, "devDependencies": { + "@stripe/react-stripe-js": "3.1.1", + "@stripe/stripe-js": "5.6.0", "@types/glob-to-regexp": "0.4.4", "@types/js-cookie": "3.0.6", "cross-fetch": "^4.0.0", diff --git a/packages/shared/src/getEnvVariable.ts b/packages/shared/src/getEnvVariable.ts index 24cd5d5e3fe..795a016f902 100644 --- a/packages/shared/src/getEnvVariable.ts +++ b/packages/shared/src/getEnvVariable.ts @@ -10,9 +10,10 @@ const hasCloudflareContext = (context: any): context is CloudflareEnv => { /** * Retrieves an environment variable across runtime environments. - * @param name - The environment variable name to retrieve - * @param context - Optional context object that may contain environment values - * @returns The environment variable value or empty string if not found + * + * @param name - The environment variable name to retrieve. + * @param context - Optional context object that may contain environment values. + * @returns The environment variable value or empty string if not found. */ export const getEnvVariable = (name: string, context?: Record): string => { // Node envs diff --git a/packages/shared/src/react/commerce.tsx b/packages/shared/src/react/commerce.tsx new file mode 100644 index 00000000000..beb4d913221 --- /dev/null +++ b/packages/shared/src/react/commerce.tsx @@ -0,0 +1,323 @@ +/* eslint-disable @typescript-eslint/consistent-type-imports */ +import type { CommerceCheckoutResource, EnvironmentResource } from '@clerk/types'; +import type { Stripe, StripeElements } from '@stripe/stripe-js'; +import { type PropsWithChildren, ReactNode, useCallback, useEffect, useState } from 'react'; +import React from 'react'; +import useSWR from 'swr'; +import useSWRMutation from 'swr/mutation'; + +import { createContextAndHook } from './hooks/createContextAndHook'; +import { useClerk } from './hooks/useClerk'; +import { useOrganization } from './hooks/useOrganization'; +import { useUser } from './hooks/useUser'; +import { Elements, PaymentElement, useElements, useStripe } from './stripe-react'; + +const [StripeLibsContext, useStripeLibsContext] = createContextAndHook<{ + loadStripe: typeof import('@stripe/stripe-js').loadStripe; +} | null>('StripeLibsContext'); + +const StripeLibsProvider = ({ children }: PropsWithChildren) => { + const clerk = useClerk(); + const { isLoaded } = useUser(); + const { data: stripeClerkLibs } = useSWR( + isLoaded ? 'clerk-stripe-sdk' : null, + async () => { + const [loadStripe] = await Promise.all([clerk.__internal_loadStripeJs()]); + return { loadStripe }; + }, + { + keepPreviousData: true, + revalidateOnFocus: false, + dedupingInterval: Infinity, + }, + ); + + return ( + + {children} + + ); +}; + +const useInternalEnvironment = () => { + const clerk = useClerk(); + // @ts-expect-error `__unstable__environment` is not typed + return clerk.__unstable__environment as unknown as EnvironmentResource | null | undefined; +}; + +const usePaymentSourceUtils = (forResource: 'org' | 'user') => { + const { organization } = useOrganization(); + const { user } = useUser(); + // const subscriberType = useSubscriberTypeContext(); + const resource = forResource === 'org' ? organization : user; + const stripeClerkLibs = useStripeLibsContext(); + + const { data: initializedPaymentSource, trigger: initializePaymentSource } = useSWRMutation( + { + key: 'commerce-payment-source-initialize', + resourceId: resource?.id, + }, + () => + resource?.initializePaymentSource({ + gateway: 'stripe', + }), + ); + const environment = useInternalEnvironment(); + + useEffect(() => { + // TODO(@COMMERCE): Handle errors + void initializePaymentSource(); + }, []); + + const externalGatewayId = initializedPaymentSource?.externalGatewayId; + const externalClientSecret = initializedPaymentSource?.externalClientSecret; + const paymentMethodOrder = initializedPaymentSource?.paymentMethodOrder; + const stripePublishableKey = environment?.commerceSettings.billing.stripePublishableKey; + + const { data: stripe } = useSWR( + stripeClerkLibs && externalGatewayId && stripePublishableKey + ? { key: 'stripe-sdk', externalGatewayId, stripePublishableKey } + : null, + ({ stripePublishableKey, externalGatewayId }) => { + // TODO(@COMMERCE): We need to figure out how to handle this + // if (__BUILD_DISABLE_RHC__) { + // clerkUnsupportedEnvironmentWarning('Stripe'); + // return; + // } + return stripeClerkLibs?.loadStripe(stripePublishableKey, { + stripeAccount: externalGatewayId, + }); + }, + { + keepPreviousData: true, + revalidateOnFocus: false, + dedupingInterval: 1_000 * 60, // 1 minute + }, + ); + + return { + stripe, + initializePaymentSource, + externalClientSecret, + paymentMethodOrder, + }; +}; + +type internalStripeAppearance = { + colorPrimary: string; + colorBackground: string; + colorText: string; + colorTextSecondary: string; + colorSuccess: string; + colorDanger: string; + colorWarning: string; + fontWeightNormal: string; + fontWeightMedium: string; + fontWeightBold: string; + fontSizeXl: string; + fontSizeLg: string; + fontSizeSm: string; + fontSizeXs: string; + borderRadius: string; + spacingUnit: string; +}; + +const [PaymentElementContext, usePaymentElementContext] = createContextAndHook< + ReturnType & { + setIsPaymentElementReady: (isPaymentElementReady: boolean) => void; + isPaymentElementReady: boolean; + checkout?: CommerceCheckoutResource; + // TODO(@COMMERCE): What can we do to remove this ? + paymentDescription: string; + } +>('PaymentElementContext'); + +const [StipeUtilsContext, useStipeUtilsContext] = createContextAndHook<{ + stripe: Stripe | undefined | null; + elements: StripeElements | undefined | null; +}>('StipeUtilsContext'); + +const ValidateStripeUtils = ({ children }: PropsWithChildren) => { + const stripe = useStripe(); + const elements = useElements(); + + return {children}; +}; + +const DummyStripeUtils = ({ children }: PropsWithChildren) => { + return ( + + {children} + + ); +}; + +const PaymentElementRoot = ( + props: PropsWithChildren<{ + checkout?: CommerceCheckoutResource; + // TODO(@COMMERCE): What can we do to remove this ? + stripeAppearance?: internalStripeAppearance; + // TODO(@COMMERCE): What can we do to remove this ? + for: 'org' | 'user'; + // TODO(@COMMERCE): What can we do to remove this ? + paymentDescription: string; + }>, +) => { + return ( + + + + ); +}; + +const PaymentElementInternalRoot = ( + props: PropsWithChildren<{ + checkout?: CommerceCheckoutResource; + // TODO(@COMMERCE): What can we do to remove this ? + stripeAppearance?: internalStripeAppearance; + // TODO(@COMMERCE): What can we do to remove this ? + for: 'org' | 'user'; + // TODO(@COMMERCE): What can we do to remove this ? + paymentDescription: string; + }>, +) => { + const utils = usePaymentSourceUtils(props.for); + const { stripe, externalClientSecret } = utils; + const [isPaymentElementReady, setIsPaymentElementReady] = useState(false); + + if (stripe && externalClientSecret) { + return ( + + + {props.children} + + + ); + } + + return ( + + {props.children} + + ); +}; + +const PaymentElementForm = ({ fallback }: { fallback?: ReactNode }) => { + const { setIsPaymentElementReady, paymentMethodOrder, checkout, stripe, externalClientSecret, paymentDescription } = + usePaymentElementContext(); + const environment = useInternalEnvironment(); + + if (!stripe || !externalClientSecret) { + return <>{fallback}; + } + + return ( + setIsPaymentElementReady(true)} + options={{ + layout: { + type: 'tabs', + defaultCollapsed: false, + }, + paymentMethodOrder, + applePay: checkout + ? { + recurringPaymentRequest: { + paymentDescription, + managementURL: environment?.displayConfig.homeUrl || '', // TODO(@COMMERCE): is this the right URL? + regularBilling: { + amount: checkout.totals.totalDueNow?.amount || checkout.totals.grandTotal.amount, + label: checkout.plan.name, + recurringPaymentIntervalUnit: checkout.planPeriod === 'annual' ? 'year' : 'month', + }, + }, + } + : undefined, + }} + /> + ); +}; + +const usePaymentElement = () => { + const { isPaymentElementReady, initializePaymentSource } = usePaymentElementContext(); + const { stripe, elements } = useStipeUtilsContext(); + const { stripe: stripeFromContext, externalClientSecret } = usePaymentElementContext(); + + const submit = useCallback(async () => { + if (!stripe || !elements) { + throw new Error('Stripe and Elements are not yet ready'); + } + + const { setupIntent, error } = await stripe.confirmSetup({ + elements, + confirmParams: { + return_url: '', // TODO(@COMMERCE): need to figure this out + }, + redirect: 'if_required', + }); + if (error || !setupIntent?.payment_method) { + return { data: undefined, error }; // just return, since stripe will handle the error + } + return { + data: { gateway: 'stripe', paymentToken: setupIntent.payment_method as string }, + error: undefined, + } as const; + }, [stripe, elements]); + + const isProviderReady = stripe && externalClientSecret; + + return { + submit, + reset: initializePaymentSource, + isFormReady: isPaymentElementReady, + provider: isProviderReady + ? { + name: 'stripe', + instance: stripeFromContext, + } + : undefined, + isProviderReady: isProviderReady, + }; +}; + +export { + PaymentElementRoot as __experimental_PaymentElementRoot, + PaymentElementForm as __experimental_PaymentElementForm, + usePaymentElement as __experimental_usePaymentElement, +}; diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index e3170145c09..3565d87cd0e 100644 --- a/packages/shared/src/react/contexts.tsx +++ b/packages/shared/src/react/contexts.tsx @@ -3,6 +3,7 @@ import type { ClerkOptions, ClientResource, + CommerceSubscriptionPlanPeriod, LoadedClerk, OrganizationResource, SignedInSessionResource, @@ -23,6 +24,21 @@ const [SessionContext, useSessionContext] = createContextAndHook({}); +type UseCheckoutOptions = { + for?: 'organization'; + planPeriod: CommerceSubscriptionPlanPeriod; + planId: string; +}; + +const [CheckoutContext, useCheckoutContext] = createContextAndHook('CheckoutContext'); + +const CheckoutProvider = ({ children, ...rest }: PropsWithChildren) => { + return {children}; +}; + +/** + * @internal + */ function useOptionsContext(): ClerkOptions { const context = React.useContext(OptionsContext); if (context === undefined) { @@ -61,6 +77,9 @@ const OrganizationProvider = ({ ); }; +/** + * @internal + */ function useAssertWrappedByClerkProvider(displayNameOrFn: string | (() => void)): void { const ctx = React.useContext(ClerkInstanceContext); @@ -95,5 +114,7 @@ export { useSessionContext, ClerkInstanceContext, useClerkInstanceContext, + useCheckoutContext, + CheckoutProvider, useAssertWrappedByClerkProvider, }; diff --git a/packages/shared/src/react/hooks/index.ts b/packages/shared/src/react/hooks/index.ts index e801745a0b7..666b60a2f57 100644 --- a/packages/shared/src/react/hooks/index.ts +++ b/packages/shared/src/react/hooks/index.ts @@ -12,3 +12,4 @@ export { useStatements as __experimental_useStatements } from './useStatements'; export { usePaymentAttempts as __experimental_usePaymentAttempts } from './usePaymentAttempts'; export { usePaymentMethods as __experimental_usePaymentMethods } from './usePaymentMethods'; export { useSubscriptionItems as __experimental_useSubscriptionItems } from './useSubscriptionItems'; +export { useCheckout as __experimental_useCheckout } from './useCheckout'; diff --git a/packages/shared/src/react/hooks/useCheckout.ts b/packages/shared/src/react/hooks/useCheckout.ts new file mode 100644 index 00000000000..75ef99246b5 --- /dev/null +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -0,0 +1,137 @@ +import type { CommerceCheckoutResource, CommerceSubscriptionPlanPeriod, ConfirmCheckoutParams } from '@clerk/types'; +import { useMemo, useSyncExternalStore } from 'react'; + +import type { ClerkAPIResponseError } from '../..'; +import { useCheckoutContext } from '../contexts'; +import { useClerk } from './useClerk'; +import { useOrganization } from './useOrganization'; +import { useUser } from './useUser'; + +type CheckoutStatus = 'awaiting_initialization' | 'awaiting_confirmation' | 'completed'; + +/** + * Utility type that removes function properties from a type. + */ +type RemoveFunctions = { + [K in keyof T as T[K] extends (...args: any[]) => any ? never : K]: T[K]; +}; + +/** + * Utility type that makes all properties nullable. + */ +type Nullable = { + [K in keyof T]: null; +}; + +type CheckoutProperties = Omit< + RemoveFunctions, + 'paymentSource' | 'plan' | 'pathRoot' | 'reload' | 'confirm' +> & { + plan: RemoveFunctions; + paymentSource: RemoveFunctions; + __internal_checkout: CommerceCheckoutResource; +}; +type NullableCheckoutProperties = Nullable< + Omit, 'paymentSource' | 'plan' | 'pathRoot' | 'reload' | 'confirm'> +> & { + plan: null; + paymentSource: null; + __internal_checkout: null; +}; + +type CheckoutCacheState = { + isStarting: boolean; + isConfirming: boolean; + error: ClerkAPIResponseError | null; + checkout: CommerceCheckoutResource | null; + fetchStatus: 'idle' | 'fetching' | 'error'; + status: CheckoutStatus; +}; + +type UseCheckoutReturn = (CheckoutProperties | NullableCheckoutProperties) & { + confirm: (params: ConfirmCheckoutParams) => Promise; + start: () => Promise; + isStarting: boolean; + isConfirming: boolean; + error: ClerkAPIResponseError | null; + status: CheckoutStatus; + clear: () => void; + finalize: (params: { redirectUrl?: string }) => void; + fetchStatus: 'idle' | 'fetching' | 'error'; + getState: () => CheckoutCacheState; +}; + +type UseCheckoutOptions = { + for?: 'organization'; + planPeriod: CommerceSubscriptionPlanPeriod; + planId: string; +}; + +export const useCheckout = (options?: UseCheckoutOptions): UseCheckoutReturn => { + const contextOptions = useCheckoutContext(); + const { for: forOrganization, planId, planPeriod } = options || contextOptions; + + const clerk = useClerk(); + const { organization } = useOrganization(); + const { user } = useUser(); + + if (!user) { + throw new Error('Clerk: User is not authenticated'); + } + + if (forOrganization === 'organization' && !organization) { + throw new Error('Clerk: Use `setActive` to set the organization'); + } + + const manager = useMemo( + () => clerk.checkout({ planId, planPeriod, for: forOrganization }), + [user.id, organization?.id, planId, planPeriod, forOrganization], + ); + + const managerProperties = useSyncExternalStore( + cb => manager.subscribe(cb), + () => manager.getState(), + () => manager.getState(), + ); + + const properties = useMemo(() => { + if (!managerProperties.checkout) { + return { + id: null, + externalClientSecret: null, + externalGatewayId: null, + statement_id: null, + status: null, + totals: null, + isImmediatePlanChange: null, + planPeriod: null, + plan: null, + paymentSource: null, + }; + } + const { + reload, + confirm, + pathRoot, + // All the above need to be removed from the properties + ...rest + } = managerProperties.checkout; + return rest; + }, [managerProperties.checkout]); + + return { + ...properties, + getState: manager.getState, + checkout: null, + __internal_checkout: managerProperties.checkout, + start: manager.start, + confirm: manager.confirm, + clear: manager.clear, + finalize: manager.finalize, + isStarting: managerProperties.isStarting, + isConfirming: managerProperties.isConfirming, + error: managerProperties.error, + status: managerProperties.status, + fetchStatus: managerProperties.fetchStatus, + }; +}; diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts index 4b716f41052..0a638a79be7 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -14,4 +14,7 @@ export { UserContext, useSessionContext, useUserContext, + CheckoutProvider, } from './contexts'; + +export * from './commerce'; diff --git a/packages/shared/src/react/stripe-react.tsx b/packages/shared/src/react/stripe-react.tsx new file mode 100644 index 00000000000..9006564c86e --- /dev/null +++ b/packages/shared/src/react/stripe-react.tsx @@ -0,0 +1,474 @@ +/** + * Original source: https://github.com/stripe/react-stripe-js. + * + * The current version of this file is a fork of the original version. + * The main difference is that we have kept only the necessary parts of the file. + * This is because we don't need it and it's not used in the Clerk codebase. + * + * The original version of this file is licensed under the MIT license. + * Https://github.com/stripe/react-stripe-js/blob/master/LICENSE. + */ + +import type { ElementProps, PaymentElementProps } from '@stripe/react-stripe-js'; +import type { + Stripe, + StripeElement, + StripeElements, + StripeElementsOptions, + StripeElementType, +} from '@stripe/stripe-js'; +import type { FunctionComponent, PropsWithChildren, ReactNode } from 'react'; +import React, { useState } from 'react'; + +import { useAttachEvent, usePrevious } from './use-previous'; + +interface ElementsContextValue { + elements: StripeElements | null; + stripe: Stripe | null; +} + +const ElementsContext = React.createContext(null); +ElementsContext.displayName = 'ElementsContext'; + +const parseElementsContext = (ctx: ElementsContextValue | null, useCase: string): ElementsContextValue => { + if (!ctx) { + throw new Error( + `Could not find Elements context; You need to wrap the part of your app that ${useCase} in an provider.`, + ); + } + + return ctx; +}; + +interface ElementsProps { + /** + * A [Stripe object](https://stripe.com/docs/js/initializing) or a `Promise` resolving to a `Stripe` object. + * The easiest way to initialize a `Stripe` object is with the the [Stripe.js wrapper module](https://github.com/stripe/stripe-js/blob/master/README.md#readme). + * Once this prop has been set, it can not be changed. + * + * You can also pass in `null` or a `Promise` resolving to `null` if you are performing an initial server-side render or when generating a static site. + */ + stripe: PromiseLike | Stripe | null; + + /** + * Optional [Elements configuration options](https://stripe.com/docs/js/elements_object/create). + * Once the stripe prop has been set, these options cannot be changed. + */ + options?: StripeElementsOptions; +} + +type UnknownOptions = { [k: string]: unknown }; + +interface PrivateElementsProps { + stripe: unknown; + options?: UnknownOptions; + children?: ReactNode; +} + +/** + * The `Elements` provider allows you to use [Element components](https://stripe.com/docs/stripe-js/react#element-components) and access the [Stripe object](https://stripe.com/docs/js/initializing) in any nested component. + * Render an `Elements` provider at the root of your React app so that it is available everywhere you need it. + * + * To use the `Elements` provider, call `loadStripe` from `@stripe/stripe-js` with your publishable key. + * The `loadStripe` function will asynchronously load the Stripe.js script and initialize a `Stripe` object. + * Pass the returned `Promise` to `Elements`. + * + * @docs https://stripe.com/docs/stripe-js/react#elements-provider + */ +const Elements: FunctionComponent> = (({ + stripe: rawStripeProp, + options, + children, +}: PrivateElementsProps) => { + const parsed = React.useMemo(() => parseStripeProp(rawStripeProp), [rawStripeProp]); + + // For a sync stripe instance, initialize into context + const [ctx, setContext] = React.useState(() => ({ + stripe: parsed.tag === 'sync' ? parsed.stripe : null, + elements: parsed.tag === 'sync' ? parsed.stripe.elements(options) : null, + })); + + React.useEffect(() => { + let isMounted = true; + + const safeSetContext = (stripe: Stripe) => { + setContext(ctx => { + // no-op if we already have a stripe instance (https://github.com/stripe/react-stripe-js/issues/296) + if (ctx.stripe) return ctx; + return { + stripe, + elements: stripe.elements(options), + }; + }); + }; + + // For an async stripePromise, store it in context once resolved + if (parsed.tag === 'async' && !ctx.stripe) { + parsed.stripePromise.then(stripe => { + if (stripe && isMounted) { + // Only update Elements context if the component is still mounted + // and stripe is not null. We allow stripe to be null to make + // handling SSR easier. + safeSetContext(stripe); + } + }); + } else if (parsed.tag === 'sync' && !ctx.stripe) { + // Or, handle a sync stripe instance going from null -> populated + safeSetContext(parsed.stripe); + } + + return () => { + isMounted = false; + }; + }, [parsed, ctx, options]); + + // Warn on changes to stripe prop + const prevStripe = usePrevious(rawStripeProp); + React.useEffect(() => { + if (prevStripe !== null && prevStripe !== rawStripeProp) { + console.warn('Unsupported prop change on Elements: You cannot change the `stripe` prop after setting it.'); + } + }, [prevStripe, rawStripeProp]); + + // Apply updates to elements when options prop has relevant changes + const prevOptions = usePrevious(options); + React.useEffect(() => { + if (!ctx.elements) { + return; + } + + const updates = extractAllowedOptionsUpdates(options, prevOptions, ['clientSecret', 'fonts']); + + if (updates) { + ctx.elements.update(updates); + } + }, [options, prevOptions, ctx.elements]); + + return {children}; +}) as FunctionComponent>; + +const useElementsContextWithUseCase = (useCaseMessage: string): ElementsContextValue => { + const ctx = React.useContext(ElementsContext); + return parseElementsContext(ctx, useCaseMessage); +}; + +const useElements = (): StripeElements | null => { + const { elements } = useElementsContextWithUseCase('calls useElements()'); + return elements; +}; + +const INVALID_STRIPE_ERROR = + 'Invalid prop `stripe` supplied to `Elements`. We recommend using the `loadStripe` utility from `@stripe/stripe-js`. See https://stripe.com/docs/stripe-js/react#elements-props-stripe for details.'; + +// We are using types to enforce the `stripe` prop in this lib, but in a real +// integration `stripe` could be anything, so we need to do some sanity +// validation to prevent type errors. +const validateStripe = (maybeStripe: unknown, errorMsg = INVALID_STRIPE_ERROR): null | Stripe => { + if (maybeStripe === null || isStripe(maybeStripe)) { + return maybeStripe; + } + + throw new Error(errorMsg); +}; + +type ParsedStripeProp = + | { tag: 'empty' } + | { tag: 'sync'; stripe: Stripe } + | { tag: 'async'; stripePromise: Promise }; + +const parseStripeProp = (raw: unknown, errorMsg = INVALID_STRIPE_ERROR): ParsedStripeProp => { + if (isPromise(raw)) { + return { + tag: 'async', + stripePromise: Promise.resolve(raw).then(result => validateStripe(result, errorMsg)), + }; + } + + const stripe = validateStripe(raw, errorMsg); + + if (stripe === null) { + return { tag: 'empty' }; + } + + return { tag: 'sync', stripe }; +}; + +const isUnknownObject = (raw: unknown): raw is { [key in PropertyKey]: unknown } => { + return raw !== null && typeof raw === 'object'; +}; + +const isPromise = (raw: unknown): raw is PromiseLike => { + return isUnknownObject(raw) && typeof raw.then === 'function'; +}; + +// We are using types to enforce the `stripe` prop in this lib, +// but in an untyped integration `stripe` could be anything, so we need +// to do some sanity validation to prevent type errors. +const isStripe = (raw: unknown): raw is Stripe => { + return ( + isUnknownObject(raw) && + typeof raw.elements === 'function' && + typeof raw.createToken === 'function' && + typeof raw.createPaymentMethod === 'function' && + typeof raw.confirmCardPayment === 'function' + ); +}; + +const extractAllowedOptionsUpdates = ( + options: unknown | void, + prevOptions: unknown | void, + immutableKeys: string[], +): UnknownOptions | null => { + if (!isUnknownObject(options)) { + return null; + } + + return Object.keys(options).reduce((newOptions: null | UnknownOptions, key) => { + const isUpdated = !isUnknownObject(prevOptions) || !isEqual(options[key], prevOptions[key]); + + if (immutableKeys.includes(key)) { + if (isUpdated) { + console.warn(`Unsupported prop change: options.${key} is not a mutable property.`); + } + + return newOptions; + } + + if (!isUpdated) { + return newOptions; + } + + return { ...(newOptions || {}), [key]: options[key] }; + }, null); +}; + +const PLAIN_OBJECT_STR = '[object Object]'; + +const isEqual = (left: unknown, right: unknown): boolean => { + if (!isUnknownObject(left) || !isUnknownObject(right)) { + return left === right; + } + + const leftArray = Array.isArray(left); + const rightArray = Array.isArray(right); + + if (leftArray !== rightArray) return false; + + const leftPlainObject = Object.prototype.toString.call(left) === PLAIN_OBJECT_STR; + const rightPlainObject = Object.prototype.toString.call(right) === PLAIN_OBJECT_STR; + + if (leftPlainObject !== rightPlainObject) return false; + + // not sure what sort of special object this is (regexp is one option), so + // fallback to reference check. + if (!leftPlainObject && !leftArray) return left === right; + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + + if (leftKeys.length !== rightKeys.length) return false; + + const keySet: { [key: string]: boolean } = {}; + for (let i = 0; i < leftKeys.length; i += 1) { + keySet[leftKeys[i]] = true; + } + for (let i = 0; i < rightKeys.length; i += 1) { + keySet[rightKeys[i]] = true; + } + const allKeys = Object.keys(keySet); + if (allKeys.length !== leftKeys.length) { + return false; + } + + const l = left; + const r = right; + const pred = (key: string): boolean => { + return isEqual(l[key], r[key]); + }; + + return allKeys.every(pred); +}; + +const useStripe = (): Stripe | null => { + const { stripe } = useElementsOrCheckoutSdkContextWithUseCase('calls useStripe()'); + return stripe; +}; + +const useElementsOrCheckoutSdkContextWithUseCase = (useCaseString: string): ElementsContextValue => { + const elementsContext = React.useContext(ElementsContext); + + return parseElementsContext(elementsContext, useCaseString); +}; + +type UnknownCallback = (...args: unknown[]) => any; + +interface PrivateElementProps { + id?: string; + className?: string; + fallback?: ReactNode; + onChange?: UnknownCallback; + onBlur?: UnknownCallback; + onFocus?: UnknownCallback; + onEscape?: UnknownCallback; + onReady?: UnknownCallback; + onClick?: UnknownCallback; + onLoadError?: UnknownCallback; + onLoaderStart?: UnknownCallback; + onNetworksChange?: UnknownCallback; + onConfirm?: UnknownCallback; + onCancel?: UnknownCallback; + onShippingAddressChange?: UnknownCallback; + onShippingRateChange?: UnknownCallback; + options?: UnknownOptions; +} + +const capitalized = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); + +const createElementComponent = (type: StripeElementType, isServer: boolean): FunctionComponent => { + const displayName = `${capitalized(type)}Element`; + + const ClientElement: FunctionComponent = ({ + id, + className, + fallback, + options = {}, + onBlur, + onFocus, + onReady, + onChange, + onEscape, + onClick, + onLoadError, + onLoaderStart, + onNetworksChange, + onConfirm, + onCancel, + onShippingAddressChange, + onShippingRateChange, + }) => { + const ctx = useElementsOrCheckoutSdkContextWithUseCase(`mounts <${displayName}>`); + const elements = 'elements' in ctx ? ctx.elements : null; + const checkoutSdk = 'checkoutSdk' in ctx ? ctx.checkoutSdk : null; + const [element, setElement] = React.useState(null); + const elementRef = React.useRef(null); + const domNode = React.useRef(null); + const [isReady, setReady] = useState(false); + + // For every event where the merchant provides a callback, call element.on + // with that callback. If the merchant ever changes the callback, removes + // the old callback with element.off and then call element.on with the new one. + useAttachEvent(element, 'blur', onBlur); + useAttachEvent(element, 'focus', onFocus); + useAttachEvent(element, 'escape', onEscape); + useAttachEvent(element, 'click', onClick); + useAttachEvent(element, 'loaderror', onLoadError); + useAttachEvent(element, 'loaderstart', onLoaderStart); + useAttachEvent(element, 'networkschange', onNetworksChange); + useAttachEvent(element, 'confirm', onConfirm); + useAttachEvent(element, 'cancel', onCancel); + useAttachEvent(element, 'shippingaddresschange', onShippingAddressChange); + useAttachEvent(element, 'shippingratechange', onShippingRateChange); + useAttachEvent(element, 'change', onChange); + + let readyCallback: UnknownCallback | undefined; + if (onReady) { + if (type === 'expressCheckout') { + // Passes through the event, which includes visible PM types + readyCallback = () => { + setReady(true); + onReady(); + }; + } else { + // For other Elements, pass through the Element itself. + readyCallback = () => { + setReady(true); + onReady(element); + }; + } + } + + useAttachEvent(element, 'ready', readyCallback); + + React.useLayoutEffect(() => { + if (elementRef.current === null && domNode.current !== null && (elements || checkoutSdk)) { + let newElement: StripeElement | null = null; + if (elements) { + newElement = elements.create(type as any, options); + } + + // Store element in a ref to ensure it's _immediately_ available in cleanup hooks in StrictMode + elementRef.current = newElement; + // Store element in state to facilitate event listener attachment + setElement(newElement); + + if (newElement) { + newElement.mount(domNode.current); + } + } + }, [elements, checkoutSdk, options]); + + const prevOptions = usePrevious(options); + React.useEffect(() => { + if (!elementRef.current) { + return; + } + + const updates = extractAllowedOptionsUpdates(options, prevOptions, ['paymentRequest']); + + if (updates && 'update' in elementRef.current) { + elementRef.current.update(updates); + } + }, [options, prevOptions]); + + React.useLayoutEffect(() => { + return () => { + if (elementRef.current && typeof elementRef.current.destroy === 'function') { + try { + elementRef.current.destroy(); + elementRef.current = null; + } catch { + // Do nothing + } + } + }; + }, []); + + return ( + <> + {!isReady && fallback} +
+ + ); + }; + + // Only render the Element wrapper in a server environment. + const ServerElement: FunctionComponent = props => { + useElementsOrCheckoutSdkContextWithUseCase(`mounts <${displayName}>`); + const { id, className } = props; + return ( +
+ ); + }; + + const Element = isServer ? ServerElement : ClientElement; + Element.displayName = displayName; + (Element as any).__elementType = type; + + return Element as FunctionComponent; +}; + +const isServer = typeof window === 'undefined'; +const PaymentElement: FunctionComponent< + PaymentElementProps & { + fallback?: ReactNode; + } +> = createElementComponent('payment', isServer); + +export { Elements, useElements, useStripe, PaymentElement }; diff --git a/packages/shared/src/react/use-previous.ts b/packages/shared/src/react/use-previous.ts new file mode 100644 index 00000000000..aabdfa0babf --- /dev/null +++ b/packages/shared/src/react/use-previous.ts @@ -0,0 +1,45 @@ +import type { StripeElement } from '@stripe/stripe-js'; +import { useEffect, useRef } from 'react'; + +export const usePrevious = (value: T): T => { + const ref = useRef(value); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +}; + +export const useAttachEvent = ( + element: StripeElement | null, + event: string, + cb?: (...args: A) => any, +) => { + const cbDefined = !!cb; + const cbRef = useRef(cb); + + // In many integrations the callback prop changes on each render. + // Using a ref saves us from calling element.on/.off every render. + useEffect(() => { + cbRef.current = cb; + }, [cb]); + + useEffect(() => { + if (!cbDefined || !element) { + return () => {}; + } + + const decoratedCb = (...args: A): void => { + if (cbRef.current) { + cbRef.current(...args); + } + }; + + (element as any).on(event, decoratedCb); + + return () => { + (element as any).off(event, decoratedCb); + }; + }, [cbDefined, event, element, cbRef]); +}; diff --git a/packages/testing/src/playwright/unstable/page-objects/checkout.ts b/packages/testing/src/playwright/unstable/page-objects/checkout.ts index 07e8b75e496..3aa123ee3d1 100644 --- a/packages/testing/src/playwright/unstable/page-objects/checkout.ts +++ b/packages/testing/src/playwright/unstable/page-objects/checkout.ts @@ -28,11 +28,8 @@ export const createCheckoutPageObject = (testArgs: { page: EnhancedPage }) => { await frame.getByLabel('Country').selectOption(card.country); await frame.getByLabel('ZIP code').fill(card.zip); }, - waitForStipeElements: async () => { - return page - .frameLocator('iframe[src*="elements-inner-payment"]') - .getByLabel('Card number') - .waitFor({ state: 'visible' }); + waitForStipeElements: async ({ state = 'visible' }: { state?: 'visible' | 'hidden' } = {}) => { + return page.frameLocator('iframe[src*="elements-inner-payment"]').getByLabel('Card number').waitFor({ state }); }, clickPayOrSubscribe: async () => { await self.root.getByRole('button', { name: /subscribe|pay\s\$/i }).click(); diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index bf0fd935b27..39c81f56f8a 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -55,6 +55,26 @@ import type { UserResource } from './user'; import type { Autocomplete, DeepPartial, DeepSnakeToCamel } from './utils'; import type { WaitlistResource } from './waitlist'; +// type CheckoutStatus = 'awaiting_initialization' | 'awaiting_confirmation' | 'completed'; + +// type CheckoutCacheState = { +// isStarting: boolean; +// isConfirming: boolean; +// error: ClerkAPIResponseError | null; +// checkout: CommerceCheckoutResource | null; +// fetchStatus: 'idle' | 'fetching' | 'error'; +// status: CheckoutStatus; +// }; + +// export type CheckoutInstance = { +// confirm: (params: ConfirmCheckoutParams) => Promise; +// start: () => Promise; +// clear: () => void; +// finalize: (params: { redirectUrl?: string }) => void; +// subscribe: (listener: (state: CheckoutInstance) => void) => () => void; +// getState: () => CheckoutCacheState; +// }; + /** * @inline */ @@ -494,6 +514,12 @@ export interface Clerk { */ __internal_unmountOAuthConsent: (targetNode: HTMLDivElement) => void; + /** + * @internal + * Loads Stripe libraries for commerce functionality + */ + __internal_loadStripeJs: () => Promise; + /** * Register a listener that triggers a callback each time important Clerk resources are changed. * Allows to hook up at different steps in the sign up, sign in processes. @@ -780,6 +806,8 @@ export interface Clerk { * This API is in early access and may change in future releases. */ apiKeys: APIKeysNamespace; + + checkout: (options: { for?: 'organization'; planPeriod: CommerceSubscriptionPlanPeriod; planId: string }) => any; } export type HandleOAuthCallbackParams = TransferableOption & diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28d204133b7..dba7f5de548 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -455,9 +455,6 @@ importers: '@formkit/auto-animate': specifier: ^0.8.2 version: 0.8.2 - '@stripe/react-stripe-js': - specifier: 3.1.1 - version: 3.1.1(@stripe/stripe-js@5.6.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@stripe/stripe-js': specifier: 5.6.0 version: 5.6.0 @@ -935,6 +932,12 @@ importers: specifier: ^2.3.3 version: 2.3.3(react@18.3.1) devDependencies: + '@stripe/react-stripe-js': + specifier: 3.1.1 + version: 3.1.1(@stripe/stripe-js@5.6.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@stripe/stripe-js': + specifier: 5.6.0 + version: 5.6.0 '@types/glob-to-regexp': specifier: 0.4.4 version: 0.4.4 @@ -2720,7 +2723,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==}