@funnelsgrove/payments 0.1.33 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -21,3 +21,4 @@ export * from './components/shared/StripeExpressCheckoutButton.js';
21
21
  export * from './components/shared/StripeExpressCheckoutElement.js';
22
22
  export * from './components/shared/StripePlanSelector.js';
23
23
  export * from './components/shared/walletPlatform.js';
24
+ export * from './testing/walletCheckoutSmoke.js';
package/dist/index.js CHANGED
@@ -21,3 +21,4 @@ export * from './components/shared/StripeExpressCheckoutButton.js';
21
21
  export * from './components/shared/StripeExpressCheckoutElement.js';
22
22
  export * from './components/shared/StripePlanSelector.js';
23
23
  export * from './components/shared/walletPlatform.js';
24
+ export * from './testing/walletCheckoutSmoke.js';
@@ -7,13 +7,14 @@ export type WalletSubscriptionCheckoutRenderStateInput = {
7
7
  hasStripePromise: boolean;
8
8
  suspended: boolean;
9
9
  walletAvailable: boolean | null;
10
+ walletPreparing: boolean;
10
11
  };
11
12
  export type WalletSubscriptionCheckoutRenderState = {
12
13
  placeholderIsPreparing: boolean;
13
14
  shouldRenderExpressCheckout: boolean;
14
15
  shouldRenderPlaceholder: boolean;
15
16
  };
16
- export declare function getWalletSubscriptionCheckoutRenderState({ expressCheckoutAllowed, hasActiveClientSecret, hasStripePromise, suspended, walletAvailable, }: WalletSubscriptionCheckoutRenderStateInput): WalletSubscriptionCheckoutRenderState;
17
+ export declare function getWalletSubscriptionCheckoutRenderState({ expressCheckoutAllowed, hasActiveClientSecret, hasStripePromise, suspended, walletAvailable, walletPreparing, }: WalletSubscriptionCheckoutRenderStateInput): WalletSubscriptionCheckoutRenderState;
17
18
  type WalletPlaceholderButtonProps = {
18
19
  className?: string;
19
20
  disabled: boolean;
@@ -3,7 +3,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
3
3
  import { useCallback, useEffect, useRef, useState } from 'react';
4
4
  import { StripeExpressCheckoutElement } from '../../components/shared/StripeExpressCheckoutElement.js';
5
5
  import { isInactiveCheckoutSessionError, } from './useStripeSubscriptionCheckoutSession.js';
6
- export function getWalletSubscriptionCheckoutRenderState({ expressCheckoutAllowed, hasActiveClientSecret, hasStripePromise, suspended, walletAvailable, }) {
6
+ export function getWalletSubscriptionCheckoutRenderState({ expressCheckoutAllowed, hasActiveClientSecret, hasStripePromise, suspended, walletAvailable, walletPreparing, }) {
7
7
  const shouldRenderExpressCheckout = !suspended &&
8
8
  expressCheckoutAllowed &&
9
9
  walletAvailable !== false &&
@@ -14,7 +14,7 @@ export function getWalletSubscriptionCheckoutRenderState({ expressCheckoutAllowe
14
14
  expressCheckoutAllowed &&
15
15
  walletAvailable === null;
16
16
  return {
17
- placeholderIsPreparing: shouldRenderPlaceholder,
17
+ placeholderIsPreparing: shouldRenderPlaceholder && walletPreparing,
18
18
  shouldRenderExpressCheckout,
19
19
  shouldRenderPlaceholder,
20
20
  };
@@ -43,19 +43,28 @@ export function WalletSubscriptionCheckoutSlot({ amountCents, availabilityPaymen
43
43
  hasStripePromise: Boolean(session.stripePromise),
44
44
  suspended,
45
45
  walletAvailable,
46
+ walletPreparing: session.loading.wallet,
46
47
  });
47
48
  const serverUpdateKey = session.activeClientSecret &&
48
49
  session.checkoutSessionId &&
49
50
  session.activePlanKey !== session.planKey
50
51
  ? session.planKey
51
52
  : null;
52
- const markWalletUnavailable = useCallback((intentKey) => {
53
+ const reportWalletAvailability = useCallback((intentKey, available) => {
53
54
  setWalletAvailability({
54
- available: false,
55
+ available: available,
55
56
  intentKey,
56
57
  });
57
- onAvailabilityChange === null || onAvailabilityChange === void 0 ? void 0 : onAvailabilityChange(false);
58
+ onAvailabilityChange === null || onAvailabilityChange === void 0 ? void 0 : onAvailabilityChange(available);
58
59
  }, [onAvailabilityChange]);
60
+ const reportWalletPreparationFailure = useCallback((intentKey) => {
61
+ setWalletAvailability((current) => current.intentKey === intentKey
62
+ ? current
63
+ : {
64
+ available: null,
65
+ intentKey,
66
+ });
67
+ }, []);
59
68
  useEffect(() => {
60
69
  if (slotDisabled ||
61
70
  suspended ||
@@ -72,12 +81,12 @@ export function WalletSubscriptionCheckoutSlot({ amountCents, availabilityPaymen
72
81
  void (async () => {
73
82
  const readyClientSecret = await session.prepareWalletCheckout();
74
83
  if (!readyClientSecret) {
75
- markWalletUnavailable(requestKey);
84
+ reportWalletPreparationFailure(requestKey);
76
85
  }
77
86
  })();
78
87
  }, [
79
88
  expressCheckoutAllowed,
80
- markWalletUnavailable,
89
+ reportWalletPreparationFailure,
81
90
  session,
82
91
  slotDisabled,
83
92
  suspended,
@@ -96,11 +105,7 @@ export function WalletSubscriptionCheckoutSlot({ amountCents, availabilityPaymen
96
105
  onError === null || onError === void 0 ? void 0 : onError(message);
97
106
  };
98
107
  const handleAvailabilityChange = (available) => {
99
- setWalletAvailability({
100
- available,
101
- intentKey: session.intentKey,
102
- });
103
- onAvailabilityChange === null || onAvailabilityChange === void 0 ? void 0 : onAvailabilityChange(available);
108
+ reportWalletAvailability(session.intentKey, available);
104
109
  };
105
110
  const handlePlaceholderClick = async () => {
106
111
  if (slotDisabled || session.loading.wallet) {
@@ -112,9 +117,12 @@ export function WalletSubscriptionCheckoutSlot({ amountCents, availabilityPaymen
112
117
  }
113
118
  const readyClientSecret = await session.prepareWalletCheckout();
114
119
  if (!readyClientSecret) {
115
- markWalletUnavailable(session.intentKey);
120
+ reportWalletPreparationFailure(session.intentKey);
116
121
  }
117
122
  };
123
+ const placeholderRenderer = placeholderIsPreparing
124
+ ? renderPreparingPlaceholder !== null && renderPreparingPlaceholder !== void 0 ? renderPreparingPlaceholder : renderPlaceholder
125
+ : renderPlaceholder;
118
126
  return (_jsxs(_Fragment, { children: [shouldRenderPlaceholder || shouldRenderExpressCheckout ? (_jsxs("div", { className: [
119
127
  'wallet-subscription-checkout-slot',
120
128
  placeholderIsPreparing ? 'is-loading' : '',
@@ -123,7 +131,7 @@ export function WalletSubscriptionCheckoutSlot({ amountCents, availabilityPaymen
123
131
  .filter(Boolean)
124
132
  .join(' '), "aria-busy": placeholderIsPreparing || undefined, children: [shouldRenderExpressCheckout &&
125
133
  session.activeClientSecret &&
126
- session.stripePromise ? (_jsx(StripeExpressCheckoutElement, { amountCents: amountCents, availabilityPaymentMethods: [availabilityPaymentMethod], beforeConfirm: beforeConfirm, className: className, clientSecret: session.activeClientSecret, customerEmail: customerEmail, customerName: customerName, onAvailabilityChange: handleAvailabilityChange, onError: handleError, onSuccess: onSuccess, options: options, returnUrl: returnUrl, serverUpdateKey: serverUpdateKey, stripePromise: session.stripePromise, summaryLabel: summaryLabel, onServerUpdate: session.updateCheckoutSessionPlan })) : null, shouldRenderPlaceholder ? (_jsx("div", { className: 'wallet-subscription-checkout-slot__placeholder', children: (renderPreparingPlaceholder !== null && renderPreparingPlaceholder !== void 0 ? renderPreparingPlaceholder : renderPlaceholder)({
134
+ session.stripePromise ? (_jsx(StripeExpressCheckoutElement, { amountCents: amountCents, availabilityPaymentMethods: [availabilityPaymentMethod], beforeConfirm: beforeConfirm, className: className, clientSecret: session.activeClientSecret, customerEmail: customerEmail, customerName: customerName, onAvailabilityChange: handleAvailabilityChange, onError: handleError, onSuccess: onSuccess, options: options, returnUrl: returnUrl, serverUpdateKey: serverUpdateKey, stripePromise: session.stripePromise, summaryLabel: summaryLabel, onServerUpdate: session.updateCheckoutSessionPlan })) : null, shouldRenderPlaceholder ? (_jsx("div", { className: 'wallet-subscription-checkout-slot__placeholder', children: placeholderRenderer({
127
135
  className,
128
136
  disabled: slotDisabled,
129
137
  label: placeholderLabel,
@@ -70,6 +70,10 @@ export function useStripeSubscriptionCheckoutSession({ checkoutMode, analyticsMe
70
70
  const activePlanKey = clientSecretIntentKey === intentKey ? clientSecretPlanKey : null;
71
71
  const stripePromise = useMemo(() => getStripePromise(checkoutMode, runtimeStripePublishableKey), [checkoutMode, runtimeStripePublishableKey]);
72
72
  const previousIntentKeyRef = useRef(intentKey);
73
+ const analyticsMetadataRef = useRef(analyticsMetadata);
74
+ useEffect(() => {
75
+ analyticsMetadataRef.current = analyticsMetadata;
76
+ }, [analyticsMetadata]);
73
77
  const clearActiveCheckoutSession = useCallback(() => {
74
78
  requestIdRef.current += 1;
75
79
  creatingIntentRef.current = null;
@@ -129,7 +133,7 @@ export function useStripeSubscriptionCheckoutSession({ checkoutMode, analyticsMe
129
133
  try {
130
134
  const response = await createStripeSubscriptionCheckout({
131
135
  plan,
132
- analyticsMetadata,
136
+ analyticsMetadata: analyticsMetadataRef.current,
133
137
  couponId,
134
138
  customerEmail,
135
139
  returnUrl,
@@ -173,7 +177,6 @@ export function useStripeSubscriptionCheckoutSession({ checkoutMode, analyticsMe
173
177
  return subscriptionRequest;
174
178
  }, [
175
179
  activeClientSecret,
176
- analyticsMetadata,
177
180
  checkoutMode,
178
181
  clearActiveCheckoutSession,
179
182
  configured,
@@ -228,7 +231,7 @@ export function useStripeSubscriptionCheckoutSession({ checkoutMode, analyticsMe
228
231
  await updateStripeSubscriptionCheckoutPlan({
229
232
  checkoutSessionId: activeCheckoutSessionId,
230
233
  plan,
231
- analyticsMetadata,
234
+ analyticsMetadata: analyticsMetadataRef.current,
232
235
  couponId,
233
236
  customerEmail,
234
237
  user_id: userId,
@@ -245,7 +248,6 @@ export function useStripeSubscriptionCheckoutSession({ checkoutMode, analyticsMe
245
248
  }, [
246
249
  activeCheckoutSessionId,
247
250
  activePlanKey,
248
- analyticsMetadata,
249
251
  checkoutMode,
250
252
  configured,
251
253
  couponId,
@@ -257,7 +259,7 @@ export function useStripeSubscriptionCheckoutSession({ checkoutMode, analyticsMe
257
259
  setError,
258
260
  userId,
259
261
  ]);
260
- return {
262
+ return useMemo(() => ({
261
263
  activeClientSecret,
262
264
  activePlanKey,
263
265
  checkoutSessionId: activeCheckoutSessionId,
@@ -275,5 +277,22 @@ export function useStripeSubscriptionCheckoutSession({ checkoutMode, analyticsMe
275
277
  setError,
276
278
  stripePromise,
277
279
  updateCheckoutSessionPlan,
278
- };
280
+ }), [
281
+ activeCheckoutSessionId,
282
+ activeClientSecret,
283
+ activePlanKey,
284
+ configured,
285
+ error,
286
+ intentKey,
287
+ loading,
288
+ planKey,
289
+ prepareCardCheckout,
290
+ prepareWalletCheckout,
291
+ reset,
292
+ restartCardCheckout,
293
+ restartWalletCheckout,
294
+ setError,
295
+ stripePromise,
296
+ updateCheckoutSessionPlan,
297
+ ]);
279
298
  }
@@ -1 +1,7 @@
1
+ import { type RuntimeMode } from '@funnelsgrove/runtime';
1
2
  export * from '@funnelsgrove/runtime';
3
+ export type PaymentRuntimeConfig = {
4
+ funnelSdkPublishableKey?: string | null;
5
+ };
6
+ export declare const resolvePaymentRuntimeMode: (runtimeMode: RuntimeMode, runtimeConfig?: PaymentRuntimeConfig | null) => RuntimeMode;
7
+ export declare const usePaymentRuntimeMode: (runtimeConfig?: PaymentRuntimeConfig | null) => RuntimeMode;
@@ -1 +1,13 @@
1
+ import { useMemo } from 'react';
2
+ import { useRuntimeMode } from '@funnelsgrove/runtime';
1
3
  export * from '@funnelsgrove/runtime';
4
+ export const resolvePaymentRuntimeMode = (runtimeMode, runtimeConfig) => {
5
+ void runtimeConfig;
6
+ return runtimeMode;
7
+ };
8
+ export const usePaymentRuntimeMode = (runtimeConfig) => {
9
+ var _a;
10
+ const [runtimeMode] = useRuntimeMode();
11
+ const funnelSdkPublishableKey = (_a = runtimeConfig === null || runtimeConfig === void 0 ? void 0 : runtimeConfig.funnelSdkPublishableKey) !== null && _a !== void 0 ? _a : null;
12
+ return useMemo(() => resolvePaymentRuntimeMode(runtimeMode, { funnelSdkPublishableKey }), [funnelSdkPublishableKey, runtimeMode]);
13
+ };
@@ -0,0 +1,92 @@
1
+ export type WalletCheckoutSmokeProfileId = 'mobile-safari' | 'mobile-chrome';
2
+ export type WalletCheckoutSmokeProfile = {
3
+ id: WalletCheckoutSmokeProfileId;
4
+ name: string;
5
+ userAgent: string;
6
+ viewport: {
7
+ width: number;
8
+ height: number;
9
+ isMobile: boolean;
10
+ hasTouch: boolean;
11
+ };
12
+ };
13
+ export type WalletCheckoutSmokeSnapshot = {
14
+ blockingOverlayVisible: boolean;
15
+ cardFallbackVisible: boolean;
16
+ subscriptionRequestSucceeded: boolean;
17
+ walletSlotState: 'ready' | 'unavailable' | 'unknown';
18
+ };
19
+ export type WalletCheckoutSmokeEvaluation = {
20
+ failures: string[];
21
+ ok: boolean;
22
+ };
23
+ export type WalletCheckoutSmokeSelectors = {
24
+ blockingOverlay?: string;
25
+ cardFallback: string;
26
+ walletSlot: string;
27
+ };
28
+ export type WalletCheckoutSmokeOptions = {
29
+ profiles?: WalletCheckoutSmokeProfile[];
30
+ selectors: WalletCheckoutSmokeSelectors;
31
+ subscriptionRequestPath?: string;
32
+ timeoutMs?: number;
33
+ url: string;
34
+ };
35
+ export type WalletCheckoutSmokeProfileResult = WalletCheckoutSmokeEvaluation & {
36
+ profileId: WalletCheckoutSmokeProfileId;
37
+ snapshot: WalletCheckoutSmokeSnapshot;
38
+ };
39
+ export type WalletCheckoutSmokeResult = {
40
+ ok: boolean;
41
+ results: WalletCheckoutSmokeProfileResult[];
42
+ };
43
+ type PlaywrightPage = {
44
+ click: (selector: string, options?: {
45
+ timeout?: number;
46
+ }) => Promise<void>;
47
+ close?: () => Promise<void>;
48
+ goto: (url: string, options?: {
49
+ waitUntil?: 'domcontentloaded' | 'load' | 'networkidle';
50
+ }) => Promise<unknown>;
51
+ isVisible: (selector: string, options?: {
52
+ timeout?: number;
53
+ }) => Promise<boolean>;
54
+ waitForLoadState: (state?: 'domcontentloaded' | 'load' | 'networkidle', options?: {
55
+ timeout?: number;
56
+ }) => Promise<void>;
57
+ waitForResponse: (predicate: (response: {
58
+ status: () => number;
59
+ url: () => string;
60
+ }) => boolean, options?: {
61
+ timeout?: number;
62
+ }) => Promise<unknown>;
63
+ waitForSelector: (selector: string, options?: {
64
+ state?: 'attached' | 'detached' | 'hidden' | 'visible';
65
+ timeout?: number;
66
+ }) => Promise<unknown>;
67
+ };
68
+ type PlaywrightContext = {
69
+ close: () => Promise<void>;
70
+ newPage: () => Promise<PlaywrightPage>;
71
+ };
72
+ type PlaywrightBrowser = {
73
+ close: () => Promise<void>;
74
+ newContext: (options: {
75
+ hasTouch: boolean;
76
+ isMobile: boolean;
77
+ userAgent: string;
78
+ viewport: {
79
+ width: number;
80
+ height: number;
81
+ };
82
+ }) => Promise<PlaywrightContext>;
83
+ };
84
+ type PlaywrightChromium = {
85
+ launch: (options?: {
86
+ headless?: boolean;
87
+ }) => Promise<PlaywrightBrowser>;
88
+ };
89
+ export declare function buildWalletCheckoutSmokeProfiles(): WalletCheckoutSmokeProfile[];
90
+ export declare function evaluateWalletCheckoutSmokeSnapshot(snapshot: WalletCheckoutSmokeSnapshot): WalletCheckoutSmokeEvaluation;
91
+ export declare function runWalletCheckoutSmokeTest(chromium: PlaywrightChromium, options: WalletCheckoutSmokeOptions): Promise<WalletCheckoutSmokeResult>;
92
+ export {};
@@ -0,0 +1,111 @@
1
+ export function buildWalletCheckoutSmokeProfiles() {
2
+ return [
3
+ {
4
+ id: 'mobile-safari',
5
+ name: 'Mobile Safari',
6
+ userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
7
+ viewport: {
8
+ width: 390,
9
+ height: 844,
10
+ hasTouch: true,
11
+ isMobile: true,
12
+ },
13
+ },
14
+ {
15
+ id: 'mobile-chrome',
16
+ name: 'Mobile Chrome',
17
+ userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36',
18
+ viewport: {
19
+ width: 412,
20
+ height: 915,
21
+ hasTouch: true,
22
+ isMobile: true,
23
+ },
24
+ },
25
+ ];
26
+ }
27
+ export function evaluateWalletCheckoutSmokeSnapshot(snapshot) {
28
+ const failures = [];
29
+ if (!snapshot.subscriptionRequestSucceeded) {
30
+ failures.push('subscription checkout session request did not succeed');
31
+ }
32
+ if (snapshot.walletSlotState === 'unknown') {
33
+ failures.push('wallet slot never reached Stripe readiness or known unavailable state');
34
+ }
35
+ if (!snapshot.cardFallbackVisible) {
36
+ failures.push('card fallback checkout is not visible');
37
+ }
38
+ if (snapshot.blockingOverlayVisible) {
39
+ failures.push('a blocking loader or modal is still visible');
40
+ }
41
+ return {
42
+ ok: failures.length === 0,
43
+ failures,
44
+ };
45
+ }
46
+ export async function runWalletCheckoutSmokeTest(chromium, options) {
47
+ var _a, _b, _c;
48
+ const profiles = (_a = options.profiles) !== null && _a !== void 0 ? _a : buildWalletCheckoutSmokeProfiles();
49
+ const timeoutMs = (_b = options.timeoutMs) !== null && _b !== void 0 ? _b : 15000;
50
+ const subscriptionRequestPath = (_c = options.subscriptionRequestPath) !== null && _c !== void 0 ? _c : '/sdk/public/payments/subscriptions';
51
+ const browser = await chromium.launch({ headless: true });
52
+ try {
53
+ const results = [];
54
+ for (const profile of profiles) {
55
+ const context = await browser.newContext({
56
+ hasTouch: profile.viewport.hasTouch,
57
+ isMobile: profile.viewport.isMobile,
58
+ userAgent: profile.userAgent,
59
+ viewport: {
60
+ width: profile.viewport.width,
61
+ height: profile.viewport.height,
62
+ },
63
+ });
64
+ try {
65
+ const page = await context.newPage();
66
+ const subscriptionResponse = page
67
+ .waitForResponse((response) => response.url().includes(subscriptionRequestPath) &&
68
+ response.status() >= 200 &&
69
+ response.status() < 300, { timeout: timeoutMs })
70
+ .then(() => true)
71
+ .catch(() => false);
72
+ await page.goto(options.url, { waitUntil: 'domcontentloaded' });
73
+ await page.waitForLoadState('networkidle', { timeout: timeoutMs }).catch(() => undefined);
74
+ const walletSlotResolved = await Promise.race([
75
+ page.waitForSelector(`${options.selectors.walletSlot}.has-express-checkout`, {
76
+ timeout: timeoutMs,
77
+ }).then(() => 'ready'),
78
+ page.waitForSelector(options.selectors.walletSlot, {
79
+ state: 'detached',
80
+ timeout: timeoutMs,
81
+ }).then(() => 'unavailable'),
82
+ ]).catch(() => 'unknown');
83
+ const snapshot = {
84
+ blockingOverlayVisible: options.selectors.blockingOverlay
85
+ ? await page.isVisible(options.selectors.blockingOverlay, { timeout: 500 }).catch(() => false)
86
+ : false,
87
+ cardFallbackVisible: await page
88
+ .isVisible(options.selectors.cardFallback, { timeout: timeoutMs })
89
+ .catch(() => false),
90
+ subscriptionRequestSucceeded: await subscriptionResponse,
91
+ walletSlotState: walletSlotResolved,
92
+ };
93
+ const evaluation = evaluateWalletCheckoutSmokeSnapshot(snapshot);
94
+ if (snapshot.cardFallbackVisible) {
95
+ await page.click(options.selectors.cardFallback, { timeout: timeoutMs }).catch(() => undefined);
96
+ }
97
+ results.push(Object.assign(Object.assign({}, evaluation), { profileId: profile.id, snapshot }));
98
+ }
99
+ finally {
100
+ await context.close();
101
+ }
102
+ }
103
+ return {
104
+ ok: results.every((result) => result.ok),
105
+ results,
106
+ };
107
+ }
108
+ finally {
109
+ await browser.close();
110
+ }
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/payments",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "main": "./dist/index.js",
@@ -23,10 +23,11 @@
23
23
  "build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/fix-esm-relative-imports.mjs dist",
24
24
  "lint": "eslint .",
25
25
  "prepublishOnly": "npm run build",
26
+ "smoke:wallet": "npm run build && node scripts/wallet-checkout-smoke.mjs",
26
27
  "test:run": "vitest run --passWithNoTests"
27
28
  },
28
29
  "dependencies": {
29
- "@funnelsgrove/runtime": "0.1.14",
30
+ "@funnelsgrove/runtime": "^0.1.18",
30
31
  "@stripe/react-stripe-js": "^5.6.0",
31
32
  "@stripe/stripe-js": "^8.7.0",
32
33
  "react": "19.2.3",