diff --git a/.changeset/great-bees-teach.md b/.changeset/great-bees-teach.md new file mode 100644 index 00000000000..e0416410717 --- /dev/null +++ b/.changeset/great-bees-teach.md @@ -0,0 +1,5 @@ +--- +'@clerk/chrome-extension': patch +--- + +Refactor re-exports from `@clerk/clerk-react`. diff --git a/.changeset/happy-kiwis-peel.md b/.changeset/happy-kiwis-peel.md new file mode 100644 index 00000000000..e4d1678a80a --- /dev/null +++ b/.changeset/happy-kiwis-peel.md @@ -0,0 +1,10 @@ +--- +'@clerk/nextjs': minor +'@clerk/clerk-react': minor +'@clerk/shared': minor +--- + +Export experimental hooks and components for PaymentElement +- `__experimental_usePaymentElement` +- `__experimental_PaymentElementProvider` +- `__experimental_PaymentElement` diff --git a/.changeset/hot-seas-rhyme.md b/.changeset/hot-seas-rhyme.md new file mode 100644 index 00000000000..e4d2db2d9fa --- /dev/null +++ b/.changeset/hot-seas-rhyme.md @@ -0,0 +1,5 @@ +--- +'@clerk/clerk-js': minor +--- + +Remove `@stripe/react-stripe-js` dependency and only allow loading of stripe-js via `Clerk.__internal_loadStripeJs()`. diff --git a/.changeset/many-mirrors-vanish.md b/.changeset/many-mirrors-vanish.md new file mode 100644 index 00000000000..10e57da157e --- /dev/null +++ b/.changeset/many-mirrors-vanish.md @@ -0,0 +1,10 @@ +--- +'@clerk/tanstack-react-start': minor +'@clerk/react-router': minor +'@clerk/remix': minor +--- + +Export experimental hooks and components for PaymentElement +- `__experimental_usePaymentElement` +- `__experimental_PaymentElementProvider` +- `__experimental_PaymentElement` diff --git a/.changeset/olive-streets-fall.md b/.changeset/olive-streets-fall.md new file mode 100644 index 00000000000..27d6a78a6f2 --- /dev/null +++ b/.changeset/olive-streets-fall.md @@ -0,0 +1,5 @@ +--- +'@clerk/types': minor +--- + +Add `__internal_loadStripeJs` in Clerk interface. 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/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 57ab2fbe893..3d8c2059a70 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -4,9 +4,10 @@ { "path": "./dist/clerk.browser.js", "maxSize": "72.2KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "115KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "55KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "110KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "111.7KB" }, { "path": "./dist/vendors*.js", "maxSize": "40.2KB" }, { "path": "./dist/coinbase*.js", "maxSize": "38KB" }, + { "path": "./dist/stripe-vendors*.js", "maxSize": "1KB" }, { "path": "./dist/createorganization*.js", "maxSize": "5KB" }, { "path": "./dist/impersonationfab*.js", "maxSize": "5KB" }, { "path": "./dist/organizationprofile*.js", "maxSize": "10KB" }, @@ -21,9 +22,8 @@ { "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/up-billing-page*.js", "maxSize": "3.5KB" }, + { "path": "./dist/checkout*.js", "maxSize": "8.34KB" }, + { "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" }, { "path": "./dist/op-plans-page*.js", "maxSize": "1.0KB" }, diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 7e27b7135b4..73cda9820d6 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..3c1aa80a6a9 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,6 @@ 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')) - ), - }, 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..deaf51ee985 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -15,6 +15,8 @@ import { import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url'; import { allSettled, handleValueOrFn, noop } from '@clerk/shared/utils'; import type { + __experimental_CheckoutInstance, + __experimental_CheckoutOptions, __internal_CheckoutProps, __internal_ComponentNavigationContext, __internal_OAuthConsentProps, @@ -136,6 +138,7 @@ import type { FapiClient, FapiRequestCallback } from './fapiClient'; import { createFapiClient } from './fapiClient'; import { createClientFromJwt } from './jwt-client'; import { APIKeys } from './modules/apiKeys'; +import { createCheckoutInstance } from './modules/checkout/instance'; import { CommerceBilling } from './modules/commerce'; import { BaseResource, @@ -195,6 +198,7 @@ export class Clerk implements ClerkInterface { }; private static _billing: CommerceBillingNamespace; private static _apiKeys: APIKeysNamespace; + private _checkout: ClerkInterface['__experimental_checkout'] | undefined; public client: ClientResource | undefined; public session: SignedInSessionResource | null | undefined; @@ -337,6 +341,13 @@ export class Clerk implements ClerkInterface { return Clerk._apiKeys; } + __experimental_checkout(options: __experimental_CheckoutOptions): __experimental_CheckoutInstance { + if (!this._checkout) { + this._checkout = params => createCheckoutInstance(this, params); + } + return this._checkout(options); + } + public __internal_getOption(key: K): ClerkOptions[K] { return this.#options[key]; } @@ -645,6 +656,16 @@ export class Clerk implements ClerkInterface { .then(controls => controls.closeModal('blankCaptcha')); }; + public __internal_loadStripeJs = async () => { + if (__BUILD_DISABLE_RHC__) { + clerkUnsupportedEnvironmentWarning('Stripe'); + return { loadStripe: () => Promise.resolve(null) }; + } + + const { loadStripe } = await import('@stripe/stripe-js'); + return loadStripe; + }; + public openSignUp = (props?: SignUpProps): void => { this.assertComponentsReady(this.#componentControls); if (sessionExistsAndSingleSessionModeEnabled(this, this.environment)) { diff --git a/packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts b/packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts new file mode 100644 index 00000000000..5085014b742 --- /dev/null +++ b/packages/clerk-js/src/core/modules/checkout/__tests__/manager.spec.ts @@ -0,0 +1,634 @@ +import type { ClerkAPIResponseError, CommerceCheckoutResource } from '@clerk/types'; +import type { MockedFunction } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type CheckoutCacheState, type CheckoutKey, createCheckoutManager, FETCH_STATUS } from '../manager'; + +// Type-safe mock for CommerceCheckoutResource +const createMockCheckoutResource = (overrides: Partial = {}): CommerceCheckoutResource => ({ + id: 'checkout_123', + status: 'pending', + externalClientSecret: 'cs_test_123', + externalGatewayId: 'gateway_123', + statement_id: 'stmt_123', + totals: { + totalDueNow: { amount: 1000, currency: 'USD', currencySymbol: '$', amountFormatted: '10.00' }, + credit: { amount: 0, currency: 'USD', currencySymbol: '$', amountFormatted: '0.00' }, + pastDue: { amount: 0, currency: 'USD', currencySymbol: '$', amountFormatted: '0.00' }, + subtotal: { amount: 1000, currency: 'USD', currencySymbol: '$', amountFormatted: '10.00' }, + grandTotal: { amount: 1000, currency: 'USD', currencySymbol: '$', amountFormatted: '10.00' }, + taxTotal: { amount: 0, currency: 'USD', currencySymbol: '$', amountFormatted: '0.00' }, + }, + isImmediatePlanChange: false, + planPeriod: 'month', + plan: { + id: 'plan_123', + name: 'Pro Plan', + description: 'Professional plan', + features: [], + amount: 1000, + amountFormatted: '10.00', + annualAmount: 12000, + annualAmountFormatted: '120.00', + currency: 'USD', + currencySymbol: '$', + slug: 'pro-plan', + }, + paymentSource: undefined, + confirm: vi.fn(), + reload: vi.fn(), + pathRoot: '/checkout', + ...overrides, +}); + +// Type-safe mock for ClerkAPIResponseError +const createMockError = (message = 'Test error'): ClerkAPIResponseError => { + const error = new Error(message) as ClerkAPIResponseError; + error.status = 400; + error.clerkTraceId = 'trace_123'; + error.clerkError = true; + return error; +}; + +// Helper to create a typed cache key +const createCacheKey = (key: string): CheckoutKey => key as CheckoutKey; + +describe('createCheckoutManager', () => { + const testCacheKey = createCacheKey('user-123-plan-456-monthly'); + let manager: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + manager = createCheckoutManager(testCacheKey); + }); + + describe('getCacheState', () => { + it('should return default state when cache is empty', () => { + const state = manager.getCacheState(); + + expect(state).toEqual({ + isStarting: false, + isConfirming: false, + error: null, + checkout: null, + fetchStatus: 'idle', + status: 'awaiting_initialization', + }); + }); + + it('should return immutable state object', () => { + const state = manager.getCacheState(); + + // State should be frozen + expect(Object.isFrozen(state)).toBe(true); + }); + }); + + describe('subscribe', () => { + it('should add listener and return unsubscribe function', () => { + const listener: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + + const unsubscribe = manager.subscribe(listener); + + expect(typeof unsubscribe).toBe('function'); + expect(listener).not.toHaveBeenCalled(); + }); + + it('should remove listener when unsubscribe is called', async () => { + const listener: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + + const unsubscribe = manager.subscribe(listener); + + // Trigger a state change + const mockCheckout = createMockCheckoutResource(); + const mockOperation = vi.fn().mockResolvedValue(mockCheckout); + await manager.executeOperation('start', mockOperation); + + expect(listener).toHaveBeenCalled(); + + // Clear the mock and unsubscribe + listener.mockClear(); + unsubscribe(); + + // Trigger another state change + const anotherMockOperation = vi.fn().mockResolvedValue(mockCheckout); + await manager.executeOperation('confirm', anotherMockOperation); + + // Listener should not be called after unsubscribing + expect(listener).not.toHaveBeenCalled(); + }); + + it('should notify all listeners when state changes', async () => { + const listener1: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + const listener2: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + const mockCheckout = createMockCheckoutResource(); + + manager.subscribe(listener1); + manager.subscribe(listener2); + + const mockOperation = vi.fn().mockResolvedValue(mockCheckout); + await manager.executeOperation('start', mockOperation); + + expect(listener1).toHaveBeenCalled(); + expect(listener2).toHaveBeenCalled(); + + // Verify they were called with the updated state + const expectedState = expect.objectContaining({ + checkout: mockCheckout, + isStarting: false, + error: null, + fetchStatus: 'idle', + status: 'awaiting_confirmation', + }); + + expect(listener1).toHaveBeenCalledWith(expectedState); + expect(listener2).toHaveBeenCalledWith(expectedState); + }); + + it('should handle multiple subscribe/unsubscribe cycles', () => { + const listener: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + + // Subscribe and unsubscribe multiple times + const unsubscribe1 = manager.subscribe(listener); + unsubscribe1(); + + const unsubscribe2 = manager.subscribe(listener); + const unsubscribe3 = manager.subscribe(listener); + + unsubscribe2(); + unsubscribe3(); + + // Should not throw errors + expect(() => unsubscribe1()).not.toThrow(); + expect(() => unsubscribe2()).not.toThrow(); + }); + }); + + describe('executeOperation - start operations', () => { + it('should execute start operation successfully', async () => { + const mockCheckout = createMockCheckoutResource(); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(mockCheckout); + + const result = await manager.executeOperation('start', mockOperation); + + expect(mockOperation).toHaveBeenCalledOnce(); + expect(result).toBe(mockCheckout); + + const finalState = manager.getCacheState(); + expect(finalState).toEqual( + expect.objectContaining({ + isStarting: false, + checkout: mockCheckout, + error: null, + fetchStatus: 'idle', + status: 'awaiting_confirmation', + }), + ); + }); + + it('should set isStarting to true during operation', async () => { + let capturedState: CheckoutCacheState | null = null; + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(async () => { + // Capture state while operation is running + capturedState = manager.getCacheState(); + return createMockCheckoutResource(); + }); + + await manager.executeOperation('start', mockOperation); + + expect(capturedState).toEqual( + expect.objectContaining({ + isStarting: true, + fetchStatus: 'fetching', + }), + ); + }); + + it('should handle operation errors correctly', async () => { + const mockError = createMockError('Operation failed'); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockRejectedValue(mockError); + + await expect(manager.executeOperation('start', mockOperation)).rejects.toThrow('Operation failed'); + + const finalState = manager.getCacheState(); + expect(finalState).toEqual( + expect.objectContaining({ + isStarting: false, + error: mockError, + fetchStatus: 'error', + status: 'awaiting_initialization', + }), + ); + }); + + it('should clear previous errors when starting new operation', async () => { + // First, create an error state + const mockError = createMockError('Previous error'); + const failingOperation: MockedFunction<() => Promise> = vi + .fn() + .mockRejectedValue(mockError); + + await expect(manager.executeOperation('start', failingOperation)).rejects.toThrow(); + + const errorState = manager.getCacheState(); + expect(errorState.error).toBe(mockError); + + // Now start a successful operation + const mockCheckout = createMockCheckoutResource(); + const successfulOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(mockCheckout); + + await manager.executeOperation('start', successfulOperation); + + const finalState = manager.getCacheState(); + expect(finalState.error).toBeNull(); + expect(finalState.checkout).toBe(mockCheckout); + }); + }); + + describe('executeOperation - confirm operations', () => { + it('should execute confirm operation successfully', async () => { + const mockCheckout = createMockCheckoutResource({ status: 'completed' }); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(mockCheckout); + + const result = await manager.executeOperation('confirm', mockOperation); + + expect(result).toBe(mockCheckout); + + const finalState = manager.getCacheState(); + expect(finalState).toEqual( + expect.objectContaining({ + isConfirming: false, + checkout: mockCheckout, + error: null, + fetchStatus: 'idle', + status: 'completed', + }), + ); + }); + + it('should set isConfirming to true during operation', async () => { + let capturedState: CheckoutCacheState | null = null; + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(async () => { + capturedState = manager.getCacheState(); + return createMockCheckoutResource(); + }); + + await manager.executeOperation('confirm', mockOperation); + + expect(capturedState).toEqual( + expect.objectContaining({ + isConfirming: true, + fetchStatus: 'fetching', + }), + ); + }); + + it('should handle confirm operation errors', async () => { + const mockError = createMockError('Confirm failed'); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockRejectedValue(mockError); + + await expect(manager.executeOperation('confirm', mockOperation)).rejects.toThrow('Confirm failed'); + + const finalState = manager.getCacheState(); + expect(finalState).toEqual( + expect.objectContaining({ + isConfirming: false, + error: mockError, + fetchStatus: 'error', + }), + ); + }); + }); + + describe('operation deduplication', () => { + it('should deduplicate concurrent start operations', async () => { + const mockCheckout = createMockCheckoutResource(); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(mockCheckout), 50))); + + // Start multiple operations concurrently + const [result1, result2, result3] = await Promise.all([ + manager.executeOperation('start', mockOperation), + manager.executeOperation('start', mockOperation), + manager.executeOperation('start', mockOperation), + ]); + + // Operation should only be called once + expect(mockOperation).toHaveBeenCalledOnce(); + + // All results should be the same + expect(result1).toBe(mockCheckout); + expect(result2).toBe(mockCheckout); + expect(result3).toBe(mockCheckout); + }); + + it('should deduplicate concurrent confirm operations', async () => { + const mockCheckout = createMockCheckoutResource(); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(mockCheckout), 50))); + + const [result1, result2] = await Promise.all([ + manager.executeOperation('confirm', mockOperation), + manager.executeOperation('confirm', mockOperation), + ]); + + expect(mockOperation).toHaveBeenCalledOnce(); + expect(result1).toBe(result2); + }); + + it('should allow different operation types to run concurrently', async () => { + const startCheckout = createMockCheckoutResource({ id: 'start_checkout' }); + const confirmCheckout = createMockCheckoutResource({ id: 'confirm_checkout' }); + + const startOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(startCheckout), 50))); + const confirmOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(confirmCheckout), 50))); + + const [startResult, confirmResult] = await Promise.all([ + manager.executeOperation('start', startOperation), + manager.executeOperation('confirm', confirmOperation), + ]); + + expect(startOperation).toHaveBeenCalledOnce(); + expect(confirmOperation).toHaveBeenCalledOnce(); + expect(startResult).toBe(startCheckout); + expect(confirmResult).toBe(confirmCheckout); + }); + + it('should propagate errors to all concurrent callers', async () => { + const mockError = createMockError('Concurrent operation failed'); + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise((_, reject) => setTimeout(() => reject(mockError), 50))); + + const promises = [ + manager.executeOperation('start', mockOperation), + manager.executeOperation('start', mockOperation), + manager.executeOperation('start', mockOperation), + ]; + + // All promises should reject with the same error + await expect(Promise.all(promises)).rejects.toThrow('Concurrent operation failed'); + expect(mockOperation).toHaveBeenCalledOnce(); + }); + + it('should allow sequential operations of the same type', async () => { + const checkout1 = createMockCheckoutResource({ id: 'checkout1' }); + const checkout2 = createMockCheckoutResource({ id: 'checkout2' }); + + const operation1: MockedFunction<() => Promise> = vi.fn().mockResolvedValue(checkout1); + const operation2: MockedFunction<() => Promise> = vi.fn().mockResolvedValue(checkout2); + + const result1 = await manager.executeOperation('start', operation1); + const result2 = await manager.executeOperation('start', operation2); + + expect(operation1).toHaveBeenCalledOnce(); + expect(operation2).toHaveBeenCalledOnce(); + expect(result1).toBe(checkout1); + expect(result2).toBe(checkout2); + }); + }); + + describe('clearCheckout', () => { + it('should clear checkout state when no operations are pending', () => { + const listener: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + manager.subscribe(listener); + + manager.clearCheckout(); + + const state = manager.getCacheState(); + expect(state).toEqual({ + isStarting: false, + isConfirming: false, + error: null, + checkout: null, + fetchStatus: 'idle', + status: 'awaiting_initialization', + }); + + // Should notify listeners + expect(listener).toHaveBeenCalledWith(state); + }); + + it('should not clear checkout state when operations are pending', async () => { + const mockCheckout = createMockCheckoutResource(); + let resolveOperation: ((value: CommerceCheckoutResource) => void) | undefined; + + const mockOperation: MockedFunction<() => Promise> = vi.fn().mockImplementation( + () => + new Promise(resolve => { + resolveOperation = resolve; + }), + ); + + // Start an operation but don't resolve it yet + const operationPromise = manager.executeOperation('start', mockOperation); + + // Verify operation is in progress + let state = manager.getCacheState(); + expect(state.isStarting).toBe(true); + expect(state.fetchStatus).toBe('fetching'); + + // Try to clear while operation is pending + manager.clearCheckout(); + + // State should not be cleared + state = manager.getCacheState(); + expect(state.isStarting).toBe(true); + expect(state.fetchStatus).toBe('fetching'); + + // Resolve the operation + resolveOperation!(mockCheckout); + await operationPromise; + + // Now clearing should work + manager.clearCheckout(); + state = manager.getCacheState(); + expect(state.checkout).toBeNull(); + expect(state.status).toBe('awaiting_initialization'); + }); + }); + + describe('state derivation', () => { + it('should derive fetchStatus correctly based on operation state', async () => { + // Initially idle + expect(manager.getCacheState().fetchStatus).toBe(FETCH_STATUS.IDLE); + + // During operation - fetching + let capturedState: CheckoutCacheState | null = null; + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(async () => { + capturedState = manager.getCacheState(); + return createMockCheckoutResource(); + }); + + await manager.executeOperation('start', mockOperation); + expect(capturedState?.fetchStatus).toBe(FETCH_STATUS.FETCHING); + + // After successful operation - idle + expect(manager.getCacheState().fetchStatus).toBe(FETCH_STATUS.IDLE); + + // After error - error + const mockError = createMockError(); + const failingOperation: MockedFunction<() => Promise> = vi + .fn() + .mockRejectedValue(mockError); + + await expect(manager.executeOperation('start', failingOperation)).rejects.toThrow(); + expect(manager.getCacheState().fetchStatus).toBe(FETCH_STATUS.ERROR); + }); + + it('should derive status based on checkout state', async () => { + // Initially awaiting initialization + expect(manager.getCacheState().status).toBe('awaiting_initialization'); + + // After starting checkout - awaiting confirmation + const pendingCheckout = createMockCheckoutResource({ status: 'pending' }); + const startOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(pendingCheckout); + + await manager.executeOperation('start', startOperation); + expect(manager.getCacheState().status).toBe('awaiting_confirmation'); + + // After completing checkout - completed + const completedCheckout = createMockCheckoutResource({ status: 'completed' }); + const confirmOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(completedCheckout); + + await manager.executeOperation('confirm', confirmOperation); + expect(manager.getCacheState().status).toBe('completed'); + }); + + it('should handle both operations running simultaneously', async () => { + let startCapturedState: CheckoutCacheState | null = null; + let confirmCapturedState: CheckoutCacheState | null = null; + + const startOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 30)); + startCapturedState = manager.getCacheState(); + return createMockCheckoutResource({ id: 'start' }); + }); + + const confirmOperation: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(async () => { + await new Promise(resolve => setTimeout(resolve, 20)); + confirmCapturedState = manager.getCacheState(); + return createMockCheckoutResource({ id: 'confirm' }); + }); + + await Promise.all([ + manager.executeOperation('start', startOperation), + manager.executeOperation('confirm', confirmOperation), + ]); + + // Both should have seen fetching status + expect(startCapturedState?.fetchStatus).toBe(FETCH_STATUS.FETCHING); + expect(confirmCapturedState?.fetchStatus).toBe(FETCH_STATUS.FETCHING); + + // At least one should have seen both operations running + expect( + (startCapturedState?.isStarting && startCapturedState?.isConfirming) || + (confirmCapturedState?.isStarting && confirmCapturedState?.isConfirming), + ).toBe(true); + }); + }); + + describe('cache isolation', () => { + it('should isolate state between different cache keys', async () => { + const manager1 = createCheckoutManager(createCacheKey('key1')); + const manager2 = createCheckoutManager(createCacheKey('key2')); + + const checkout1 = createMockCheckoutResource({ id: 'checkout1' }); + const checkout2 = createMockCheckoutResource({ id: 'checkout2' }); + + const operation1: MockedFunction<() => Promise> = vi.fn().mockResolvedValue(checkout1); + const operation2: MockedFunction<() => Promise> = vi.fn().mockResolvedValue(checkout2); + + await manager1.executeOperation('start', operation1); + await manager2.executeOperation('confirm', operation2); + + const state1 = manager1.getCacheState(); + const state2 = manager2.getCacheState(); + + expect(state1.checkout?.id).toBe('checkout1'); + expect(state1.status).toBe('awaiting_confirmation'); + + expect(state2.checkout?.id).toBe('checkout2'); + expect(state2.isStarting).toBe(false); + expect(state2.isConfirming).toBe(false); + }); + + it('should isolate listeners between different cache keys', async () => { + const manager1 = createCheckoutManager(createCacheKey('key1')); + const manager2 = createCheckoutManager(createCacheKey('key2')); + + const listener1: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + const listener2: MockedFunction<(state: CheckoutCacheState) => void> = vi.fn(); + + manager1.subscribe(listener1); + manager2.subscribe(listener2); + + // Trigger operation on manager1 + const mockOperation: MockedFunction<() => Promise> = vi + .fn() + .mockResolvedValue(createMockCheckoutResource()); + await manager1.executeOperation('start', mockOperation); + + // Only listener1 should be called + expect(listener1).toHaveBeenCalled(); + expect(listener2).not.toHaveBeenCalled(); + }); + + it('should isolate pending operations between different cache keys', async () => { + const manager1 = createCheckoutManager(createCacheKey('key1')); + const manager2 = createCheckoutManager(createCacheKey('key2')); + + const checkout1 = createMockCheckoutResource({ id: 'checkout1' }); + const checkout2 = createMockCheckoutResource({ id: 'checkout2' }); + + const operation1: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(checkout1), 50))); + const operation2: MockedFunction<() => Promise> = vi + .fn() + .mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(checkout2), 50))); + + // Start concurrent operations on both managers + const [result1, result2] = await Promise.all([ + manager1.executeOperation('start', operation1), + manager2.executeOperation('start', operation2), + ]); + + // Both operations should execute (not deduplicated across managers) + expect(operation1).toHaveBeenCalledOnce(); + expect(operation2).toHaveBeenCalledOnce(); + expect(result1).toBe(checkout1); + expect(result2).toBe(checkout2); + }); + }); +}); diff --git a/packages/clerk-js/src/core/modules/checkout/instance.ts b/packages/clerk-js/src/core/modules/checkout/instance.ts new file mode 100644 index 00000000000..1b5285724eb --- /dev/null +++ b/packages/clerk-js/src/core/modules/checkout/instance.ts @@ -0,0 +1,87 @@ +import type { + __experimental_CheckoutCacheState, + __experimental_CheckoutInstance, + __experimental_CheckoutOptions, + CommerceCheckoutResource, + ConfirmCheckoutParams, +} from '@clerk/types'; + +import type { Clerk } from '../../clerk'; +import { type CheckoutKey, createCheckoutManager } from './manager'; + +/** + * 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}` as CheckoutKey; +} + +/** + * Create a checkout instance with the given options + */ +function createCheckoutInstance( + clerk: Clerk, + options: __experimental_CheckoutOptions, +): __experimental_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: __experimental_CheckoutCacheState) => void) => { + return manager.subscribe(listener); + }; + + return { + start, + confirm, + finalize, + clear, + subscribe, + getState: manager.getCacheState, + }; +} + +export { createCheckoutInstance }; diff --git a/packages/clerk-js/src/core/modules/checkout/manager.ts b/packages/clerk-js/src/core/modules/checkout/manager.ts new file mode 100644 index 00000000000..7222d524797 --- /dev/null +++ b/packages/clerk-js/src/core/modules/checkout/manager.ts @@ -0,0 +1,177 @@ +import type { __experimental_CheckoutCacheState, ClerkAPIResponseError, CommerceCheckoutResource } from '@clerk/types'; + +type CheckoutKey = string & { readonly __tag: 'CheckoutKey' }; + +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(); + +const CHECKOUT_STATUS = { + AWAITING_INITIALIZATION: 'awaiting_initialization', + AWAITING_CONFIRMATION: 'awaiting_confirmation', + COMPLETED: 'completed', +} as const; + +export const FETCH_STATUS = { + IDLE: 'idle', + FETCHING: 'fetching', + ERROR: 'error', +} as const; + +/** + * Derives the checkout state from the base state. + */ +function deriveCheckoutState( + baseState: Omit<__experimental_CheckoutCacheState, 'fetchStatus' | 'status'>, +): __experimental_CheckoutCacheState { + const fetchStatus = (() => { + if (baseState.isStarting || baseState.isConfirming) return FETCH_STATUS.FETCHING; + if (baseState.error) return FETCH_STATUS.ERROR; + return FETCH_STATUS.IDLE; + })(); + + const status = (() => { + if (baseState.checkout?.status === CHECKOUT_STATUS.COMPLETED) return CHECKOUT_STATUS.COMPLETED; + if (baseState.checkout) return CHECKOUT_STATUS.AWAITING_CONFIRMATION; + return CHECKOUT_STATUS.AWAITING_INITIALIZATION; + })(); + + return { + ...baseState, + fetchStatus, + status, + }; +} + +const defaultCacheState: __experimental_CheckoutCacheState = Object.freeze( + deriveCheckoutState({ + isStarting: false, + isConfirming: false, + error: null, + checkout: null, + }), +); + +/** + * Creates a checkout manager for handling checkout operations and state management. + * + * @param cacheKey - Unique identifier for the checkout instance + * @returns Manager with methods for checkout operations and state subscription + * + * @example + * ```typescript + * const manager = createCheckoutManager('user-123-plan-456-monthly'); + * const unsubscribe = manager.subscribe(state => console.log(state)); + * ``` + */ +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 = (): __experimental_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, Object.freeze(newState)); + notifyListeners(); + }; + + return { + subscribe(listener: (newState: __experimental_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 { + // Mark operation as in progress and clear any previous errors + updateCacheState({ + [isRunningField]: true, + error: null, + ...(operationType === 'start' ? { checkout: null } : {}), + }); + + // Execute the checkout operation + const result = await operationFn(); + + // Update state with successful result + updateCacheState({ [isRunningField]: false, error: null, checkout: result }); + return result; + } catch (error) { + // Cast error to expected type and update state + const clerkError = error as ClerkAPIResponseError; + updateCacheState({ [isRunningField]: false, error: clerkError }); + throw error; + } finally { + // Always clean up pending operation tracker + 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); + } + }, + }; +} + +export { createCheckoutManager, type CheckoutKey }; 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..f183131897d 100644 --- a/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx +++ b/packages/clerk-js/src/ui/components/Checkout/CheckoutPage.tsx @@ -1,130 +1,73 @@ -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_CheckoutProvider as CheckoutProvider, + __experimental_useCheckout as useCheckout, +} 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<{ - 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; -}; - -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 235200f0469..37f0c7cdd0d 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'; @@ -7,10 +8,10 @@ import { LineItems } from '@/ui/elements/LineItems'; import { useCheckoutContext } from '../../contexts'; import { Box, descriptors, Flex, localizationKeys, useLocalizations } from '../../customizables'; 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 ( @@ -28,7 +29,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'))} @@ -36,14 +37,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; @@ -91,7 +91,7 @@ export const InvalidPlanScreen = () => { }; export const AddEmailForm = () => { - const { startCheckout } = useCheckoutContextRoot(); + const { start } = useCheckout(); const { setIsOpen } = useDrawerContext(); return ( @@ -103,7 +103,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 e2048952356..5ce088e1170 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_PaymentElement as PaymentElement, + __experimental_PaymentElementProvider as PaymentElementProvider, + __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 { useEffect, useMemo, useRef, useState } from 'react'; 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 useStripeAppearance = () => { + const theme = useAppearance().parsedInternalTheme; + + return useMemo(() => { + const { colors, fontWeights, fontSizes, radii, space } = theme; + return { + 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, + }; + }, [theme]); +}; + 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 = useStripeAppearance(); 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 { 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, - }, - }; + const { isProviderReady } = usePaymentElement(); - if (!stripe || !externalClientSecret) { + if (!isProviderReady) { return null; } - return ( - - {props.children} - - ); + return <>{props.children}; }; const Root = (props: PropsWithChildren) => { @@ -221,52 +184,28 @@ 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 subscriberType = useSubscriberTypeContext(); 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: window.location.href, - }, - redirect: 'if_required', - }); + const { data, error } = await submitPaymentElement(); if (error) { return; // just return, since stripe will handle the error } - 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 } }; @@ -284,33 +223,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: - subscriberType === 'org' ? displayConfig.organizationProfileUrl : displayConfig.userProfileUrl, - 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..b4389dc9363 100644 --- a/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx +++ b/packages/clerk-js/src/ui/contexts/CoreClerkContextWrapper.tsx @@ -1,4 +1,5 @@ import { + __experimental_CheckoutProvider as 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..ce923cf38f3 100644 --- a/packages/nextjs/src/client-boundary/hooks.ts +++ b/packages/nextjs/src/client-boundary/hooks.ts @@ -11,6 +11,11 @@ export { useSignUp, useUser, useReverification, + __experimental_usePaymentElement, + __experimental_PaymentElementProvider, + __experimental_PaymentElement, + __experimental_useCheckout, + __experimental_CheckoutProvider, } from '@clerk/clerk-react'; export { diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index f57260044ac..e2e94e524fa 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -54,6 +54,11 @@ export { useSignUp, useUser, useReverification, + __experimental_useCheckout, + __experimental_CheckoutProvider, + __experimental_usePaymentElement, + __experimental_PaymentElementProvider, + __experimental_PaymentElement, } from './client-boundary/hooks'; /** diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 3a757e9ec5d..6da9d5ee1cc 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -32,6 +32,11 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "UserButton", "UserProfile", "Waitlist", + "__experimental_PaymentElement", + "__experimental_PaymentElementProvider", + "__experimental_usePaymentElement", + "__experimental_CheckoutProvider", + "__experimental_useCheckout", "useAuth", "useClerk", "useEmailLink", diff --git a/packages/react/src/contexts/ClerkContextProvider.tsx b/packages/react/src/contexts/ClerkContextProvider.tsx index a86a84a47e1..f38f9f785f5 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 { + __experimental_CheckoutProvider as 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..ce5fb607ad1 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -10,4 +10,9 @@ export { useUser, useSession, useReverification, + __experimental_usePaymentElement, + __experimental_PaymentElementProvider, + __experimental_PaymentElement, + __experimental_useCheckout, + __experimental_CheckoutProvider, } from '@clerk/shared/react'; diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index c92f3d57692..37b7b93d24f 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; } + __experimental_checkout = (...args: Parameters) => { + return this.clerkjs?.__experimental_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/remix/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap index e2ae044851f..869e7a9b946 100644 --- a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap @@ -34,6 +34,11 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "UserProfile", "Waitlist", "WithClerkState", + "__experimental_PaymentElement", + "__experimental_PaymentElementProvider", + "__experimental_usePaymentElement", + "__experimental_CheckoutProvider", + "__experimental_useCheckout", "useAuth", "useClerk", "useEmailLink", diff --git a/packages/shared/package.json b/packages/shared/package.json index 318a0cc5500..18004b9923d 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/error.ts b/packages/shared/src/error.ts index 77f2479e516..d5462079bc1 100644 --- a/packages/shared/src/error.ts +++ b/packages/shared/src/error.ts @@ -1,20 +1,36 @@ -import type { ClerkAPIError, ClerkAPIErrorJSON } from '@clerk/types'; +import type { + ClerkAPIError, + ClerkAPIErrorJSON, + ClerkAPIResponseError as ClerkAPIResponseErrorInterface, +} from '@clerk/types'; +/** + * + */ export function isUnauthorizedError(e: any): boolean { const status = e?.status; const code = e?.errors?.[0]?.code; return code === 'authentication_invalid' && status === 401; } +/** + * + */ export function isCaptchaError(e: ClerkAPIResponseError): boolean { return ['captcha_invalid', 'captcha_not_enabled', 'captcha_missing_token'].includes(e.errors[0].code); } +/** + * + */ export function is4xxError(e: any): boolean { const status = e?.status; return !!status && status >= 400 && status < 500; } +/** + * + */ export function isNetworkError(e: any): boolean { // TODO: revise during error handling epic const message = (`${e.message}${e.name}` || '').toLowerCase().replace(/\s+/g, ''); @@ -36,10 +52,16 @@ export interface MetamaskError extends Error { data?: unknown; } +/** + * + */ export function isKnownError(error: any): error is ClerkAPIResponseError | ClerkRuntimeError | MetamaskError { return isClerkAPIResponseError(error) || isMetamaskError(error) || isClerkRuntimeError(error); } +/** + * + */ export function isClerkAPIResponseError(err: any): err is ClerkAPIResponseError { return 'clerkError' in err; } @@ -47,8 +69,8 @@ export function isClerkAPIResponseError(err: any): err is ClerkAPIResponseError /** * Checks if the provided error object is an instance of ClerkRuntimeError. * - * @param {any} err - The error object to check. - * @returns {boolean} True if the error is a ClerkRuntimeError, false otherwise. + * @param err - The error object to check. + * @returns True if the error is a ClerkRuntimeError, false otherwise. * * @example * const error = new ClerkRuntimeError('An error occurred'); @@ -64,26 +86,44 @@ export function isClerkRuntimeError(err: any): err is ClerkRuntimeError { return 'clerkRuntimeError' in err; } +/** + * + */ export function isReverificationCancelledError(err: any) { return isClerkRuntimeError(err) && err.code === 'reverification_cancelled'; } +/** + * + */ export function isMetamaskError(err: any): err is MetamaskError { return 'code' in err && [4001, 32602, 32603].includes(err.code) && 'message' in err; } +/** + * + */ export function isUserLockedError(err: any) { return isClerkAPIResponseError(err) && err.errors?.[0]?.code === 'user_locked'; } +/** + * + */ export function isPasswordPwnedError(err: any) { return isClerkAPIResponseError(err) && err.errors?.[0]?.code === 'form_password_pwned'; } +/** + * + */ export function parseErrors(data: ClerkAPIErrorJSON[] = []): ClerkAPIError[] { return data.length > 0 ? data.map(parseError) : []; } +/** + * + */ export function parseError(error: ClerkAPIErrorJSON): ClerkAPIError { return { code: error.code, @@ -100,6 +140,9 @@ export function parseError(error: ClerkAPIErrorJSON): ClerkAPIError { }; } +/** + * + */ export function errorToJSON(error: ClerkAPIError | null): ClerkAPIErrorJSON { return { code: error?.code || '', @@ -116,7 +159,7 @@ export function errorToJSON(error: ClerkAPIError | null): ClerkAPIErrorJSON { }; } -export class ClerkAPIResponseError extends Error { +export class ClerkAPIResponseError extends Error implements ClerkAPIResponseErrorInterface { clerkError: true; status: number; @@ -156,6 +199,7 @@ export class ClerkAPIResponseError extends Error { * Custom error class for representing Clerk runtime errors. * * @class ClerkRuntimeError + * * @example * throw new ClerkRuntimeError('An error occurred', { code: 'password_invalid' }); */ @@ -194,7 +238,7 @@ export class ClerkRuntimeError extends Error { /** * Returns a string representation of the error. * - * @returns {string} A formatted string with the error name and message. + * @returns A formatted string with the error name and message. */ public toString = () => { return `[${this.name}]\nMessage:${this.message}`; @@ -212,6 +256,9 @@ export class EmailLinkError extends Error { } } +/** + * + */ export function isEmailLinkError(err: Error): err is EmailLinkError { return err.name === 'EmailLinkError'; } @@ -270,6 +317,9 @@ export interface ErrorThrower { throw(message: string): never; } +/** + * + */ export function buildErrorThrower({ packageName, customMessages }: ErrorThrowerOptions): ErrorThrower { let pkg = packageName; @@ -278,6 +328,9 @@ export function buildErrorThrower({ packageName, customMessages }: ErrorThrowerO ...customMessages, }; + /** + * + */ function buildMessage(rawMessage: string, replacements?: Record) { if (!replacements) { return `${pkg}: ${rawMessage}`; 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..8469ef99c9b --- /dev/null +++ b/packages/shared/src/react/commerce.tsx @@ -0,0 +1,302 @@ +/* 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 as StripePaymentElement, useElements, useStripe } from './stripe-react'; + +type LoadStripeFn = typeof import('@stripe/stripe-js').loadStripe; + +const [StripeLibsContext, useStripeLibsContext] = createContextAndHook<{ + loadStripe: LoadStripeFn; +} | null>('StripeLibsContext'); + +const StripeLibsProvider = ({ children }: PropsWithChildren) => { + const clerk = useClerk(); + const { data: stripeClerkLibs } = useSWR( + 'clerk-stripe-sdk', + async () => { + const loadStripe = (await clerk.__internal_loadStripeJs()) as LoadStripeFn; + 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 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(() => { + initializePaymentSource().catch(() => { + // ignore errors + }); + }, []); + + 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 }) => { + 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; + paymentDescription?: string; + } +>('PaymentElementContext'); + +const [StripeUtilsContext, useStripeUtilsContext] = createContextAndHook<{ + stripe: Stripe | undefined | null; + elements: StripeElements | undefined | null; +}>('StripeUtilsContext'); + +const ValidateStripeUtils = ({ children }: PropsWithChildren) => { + const stripe = useStripe(); + const elements = useElements(); + + return {children}; +}; + +const DummyStripeUtils = ({ children }: PropsWithChildren) => { + return {children}; +}; + +type PaymentElementConfig = { + checkout?: CommerceCheckoutResource; + stripeAppearance?: internalStripeAppearance; + // TODO(@COMMERCE): What can we do to remove this ? + for: 'org' | 'user'; + paymentDescription?: string; +}; + +const PaymentElementProvider = (props: PropsWithChildren) => { + return ( + + + + ); +}; + +const PaymentElementInternalRoot = (props: PropsWithChildren) => { + const utils = usePaymentSourceUtils(props.for); + const { stripe, externalClientSecret } = utils; + const [isPaymentElementReady, setIsPaymentElementReady] = useState(false); + + if (stripe && externalClientSecret) { + return ( + + + {props.children} + + + ); + } + + return ( + + {props.children} + + ); +}; + +const PaymentElement = ({ 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: 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 } = useStripeUtilsContext(); + 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) { + return { data: null, error } as const; + } + return { + data: { gateway: 'stripe', paymentToken: setupIntent.payment_method as string }, + error: null, + } as const; + }, [stripe, elements]); + + const isProviderReady = stripe && externalClientSecret; + + return { + submit, + reset: initializePaymentSource, + isFormReady: isPaymentElementReady, + provider: isProviderReady + ? { + name: 'stripe', + instance: stripeFromContext, + } + : undefined, + isProviderReady: isProviderReady, + }; +}; + +export { + PaymentElementProvider as __experimental_PaymentElementProvider, + PaymentElement as __experimental_PaymentElement, + usePaymentElement as __experimental_usePaymentElement, +}; diff --git a/packages/shared/src/react/contexts.tsx b/packages/shared/src/react/contexts.tsx index e3170145c09..33b5e231c9f 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 __experimental_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, + __experimental_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..be14dde4b30 --- /dev/null +++ b/packages/shared/src/react/hooks/useCheckout.ts @@ -0,0 +1,133 @@ +import type { + __experimental_CheckoutCacheState, + 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'; + +/** + * 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 UseCheckoutReturn = (CheckoutProperties | NullableCheckoutProperties) & { + confirm: (params: ConfirmCheckoutParams) => Promise; + start: () => Promise; + isStarting: boolean; + isConfirming: boolean; + error: ClerkAPIResponseError | null; + status: __experimental_CheckoutCacheState['status']; + clear: () => void; + finalize: (params: { redirectUrl?: string }) => void; + fetchStatus: 'idle' | 'fetching' | 'error'; + getState: () => __experimental_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.__experimental_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, + // @ts-expect-error - this is a temporary fix to allow the checkout to be null + checkout: null, + // @ts-expect-error - this is a temporary fix to allow the checkout to be 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..c1f8f761236 100644 --- a/packages/shared/src/react/index.ts +++ b/packages/shared/src/react/index.ts @@ -14,4 +14,7 @@ export { UserContext, useSessionContext, useUserContext, + __experimental_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..4bb03049031 --- /dev/null +++ b/packages/shared/src/react/stripe-react.tsx @@ -0,0 +1,465 @@ +/** + * 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 [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) { + // 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) { + 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, 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/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index 296c327d52e..7ac9e3716d6 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -44,6 +44,11 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "UserButton", "UserProfile", "Waitlist", + "__experimental_PaymentElement", + "__experimental_PaymentElementProvider", + "__experimental_usePaymentElement", + "__experimental_CheckoutProvider", + "__experimental_useCheckout", "useAuth", "useClerk", "useEmailLink", diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 80a96be7a51..5e6e0e840df 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -43,3 +43,15 @@ export interface ClerkRuntimeError { code: string; message: string; } + +/** + * Interface representing a Clerk API Response Error. + */ +export interface ClerkAPIResponseError extends Error { + clerkError: true; + status: number; + message: string; + clerkTraceId?: string; + retryAfter?: number; + errors: ClerkAPIError[]; +} diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index bf0fd935b27..e598aacd3d3 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -1,3 +1,4 @@ +import type { ClerkAPIResponseError } from './api'; import type { APIKeysNamespace } from './apiKeys'; import type { APIKeysTheme, @@ -20,9 +21,11 @@ import type { import type { ClientResource } from './client'; import type { CommerceBillingNamespace, + CommerceCheckoutResource, CommercePlanResource, CommerceSubscriberType, CommerceSubscriptionPlanPeriod, + ConfirmCheckoutParams, } from './commerce'; import type { CustomMenuItem } from './customMenuItems'; import type { CustomPage } from './customPages'; @@ -55,6 +58,34 @@ import type { UserResource } from './user'; import type { Autocomplete, DeepPartial, DeepSnakeToCamel } from './utils'; import type { WaitlistResource } from './waitlist'; +type __experimental_CheckoutStatus = 'awaiting_initialization' | 'awaiting_confirmation' | 'completed'; + +export type __experimental_CheckoutCacheState = Readonly<{ + isStarting: boolean; + isConfirming: boolean; + error: ClerkAPIResponseError | null; + checkout: CommerceCheckoutResource | null; + fetchStatus: 'idle' | 'fetching' | 'error'; + status: __experimental_CheckoutStatus; +}>; + +export type __experimental_CheckoutOptions = { + for?: 'organization'; + planPeriod: CommerceSubscriptionPlanPeriod; + planId: string; +}; + +export type __experimental_CheckoutInstance = { + confirm: (params: ConfirmCheckoutParams) => Promise; + start: () => Promise; + clear: () => void; + finalize: (params: { redirectUrl?: string }) => void; + subscribe: (listener: (state: __experimental_CheckoutCacheState) => void) => () => void; + getState: () => __experimental_CheckoutCacheState; +}; + +type __experimental_CheckoutFunction = (options: __experimental_CheckoutOptions) => __experimental_CheckoutInstance; + /** * @inline */ @@ -494,6 +525,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 +817,13 @@ export interface Clerk { * This API is in early access and may change in future releases. */ apiKeys: APIKeysNamespace; + + /** + * Checkout API + * @experimental + * This API is in early access and may change in future releases. + */ + __experimental_checkout: __experimental_CheckoutFunction; } export type HandleOAuthCallbackParams = TransferableOption & diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fbddbf561ad..b1534a6aaef 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 @@ -2724,7 +2727,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==}