@funnelsgrove/payments 0.38.0 → 0.40.0

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/README.md CHANGED
@@ -41,10 +41,13 @@ elsewhere, preselected, with Stripe's own button, and the card form as the secon
41
41
  `checkoutAnalytics`, a complete `StripeWalletFirstCheckoutCopy` from the funnel's
42
42
  content, and `themeColor`/`buttonColor`.
43
43
  - The card form has no ZIP or terms text.
44
+ - `onCancel` runs when the visitor closes the Apple Pay or Google Pay sheet without paying,
45
+ for example to show an extra discount. The form is unlocked again by then.
44
46
  - Where the browser cannot pay with that wallet, the wallet stays first and its slot shows
45
47
  `walletUnavailableNote` (`{wallet}` is replaced with the wallet name). The card remains
46
48
  one tap away. This is deliberate: the layout must not move.
47
- - The component owns `checkout_started` (when the card form opens, or on wallet intent),
49
+ - The component owns `checkout_shown` (once per mount, `checkout_surface: inline`),
50
+ `checkout_started` (when the card form opens, or on wallet intent),
48
51
  `add_payment_info` and verified `checkout_completed`.
49
52
  - After confirmation, the payment status stays in the form until the caller replaces it.
50
53
  With the runtime's embedded completion, the success screen takes over. Otherwise
@@ -220,11 +223,12 @@ conversion event is introduced.
220
223
  - Wallet unavailable state must fall back to card/manual checkout or disappear.
221
224
  - Count `checkout_started` only at shared UI boundaries: when a shared card dialog opens or when Stripe reports Apple Pay / Google Pay intent. Checkout Sessions use confirmation or cancellation as a fallback because Stripe omits their click callback. Cancellation recovers the start with `checkout_start_source: wallet_cancel`; its timestamp is dismissal time, not wallet-open time. Closing the browser before either callback is still unobservable. Cancel has no payment-method field, so the method is included only when the element enables exactly one method.
222
225
  - Pass `checkoutAnalytics` to every current shared dialog and wallet surface. The package then owns `checkout_started`, `add_payment_info`, and verified `checkout_completed`, all deduplicated by the actual Checkout Session.
226
+ - The package also owns `checkout_shown`: a checkout form or payment button became visible. It is sent once per show with a new event ID and `checkout_surface`: `inline` (wallet-first form, or the V2 dialog with `presentation='inline'`), `dialog` (each V2 dialog open) or `wallet` (a standalone wallet surface, once per mount after Stripe reports a wallet available). It is not a conversion and is not sent to Meta or Google.
223
227
  - Payment submission triggers, validation, declines, and retry identity follow [AE-BR-001–004](../../docs/product-specs/analytics-events.md#payment-information-submission). Keep that behavior inside shared checkout surfaces.
224
228
  - Call funnel lifecycle `completeStep` only from the shared checkout `onSuccess` callback. Payment-info submission is not step completion.
225
229
  - Wallet slots may create render-only Checkout Sessions on mount so Apple Pay and Google Pay render before the visitor taps. Session creation is technical preparation and must never emit `checkout_started`.
226
230
  - Shared checkout analytics deduplicates `checkout_started` by Checkout Session id. Wallet confirmation and cancellation may retry the start signal when the click callback is unavailable, without creating a duplicate. Cancellation never emits payment-info or purchase completion events.
227
- - Do not call `publicAnalyticsSdk.trackCheckoutStarted`, `trackPaymentInfoSubmitted`, or `trackCheckoutCompleted` directly from funnel checkout components.
231
+ - Do not call `publicAnalyticsSdk.trackCheckoutShown`, `trackCheckoutStarted`, `trackPaymentInfoSubmitted`, or `trackCheckoutCompleted` directly from funnel checkout components.
228
232
  - Funnel paywalls should style wallet buttons through the shared slot API: `className` for the Stripe button/placeholder, `slotClassName` for the outer slot, `appearance` for Stripe Elements appearance, and `options` for Stripe Express Checkout options.
229
233
  - Keep discount math reusable here, but keep offer activation timing in funnel code.
230
234
  - `SharedStripeCheckoutV2Dialog` forwards wallet-sheet cancellation to `onClose` by default; `onWalletCancel` may override that callback. Standalone wallet surfaces call their `onCancel` callback. Funnel code chooses whether either callback opens a discount offer.
@@ -1 +1 @@
1
- export declare const PAYMENTS_PACKAGE_VERSION = "0.38.0";
1
+ export declare const PAYMENTS_PACKAGE_VERSION = "0.40.0";
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/generate-package-version.mjs.
2
- export const PAYMENTS_PACKAGE_VERSION = "0.38.0";
2
+ export const PAYMENTS_PACKAGE_VERSION = "0.40.0";
@@ -14,8 +14,9 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
14
14
  import { useCallback, useEffect, useMemo } from 'react';
15
15
  import { CheckoutProvider } from '@stripe/react-stripe-js/checkout';
16
16
  import { normalizeSupportedCheckoutCountries } from './checkoutCountries.js';
17
- import { queueStripeSubscriptionCheckoutStarted } from '../services/checkoutCompletionAnalytics.service.js';
17
+ import { queueStripeSubscriptionCheckoutStarted, trackStripeCheckoutShown } from '../services/checkoutCompletionAnalytics.service.js';
18
18
  import { trackCheckoutCanceled } from '../../../services/checkoutObservability.service.js';
19
+ import { useCheckoutShownOnce } from '../hooks/useCheckoutShownOnce.js';
19
20
  import { SharedStripeCheckoutV2CheckoutSessionForm, getFixedSupportedBillingCountry } from './SharedStripeCheckoutV2Form.internal.js';
20
21
  import { sharedStripeCheckoutV2Styles } from './SharedStripeCheckoutV2.styles.js';
21
22
  const stripeCheckoutV2Appearance = {
@@ -73,6 +74,11 @@ export function SharedStripeCheckoutV2Dialog(_a) {
73
74
  }
74
75
  onClose();
75
76
  }, [checkoutAnalytics, checkoutSessionId, onClose]);
77
+ // The dialog is mounted while open, so one mount is one show.
78
+ useCheckoutShownOnce(Boolean(checkoutAnalytics), () => {
79
+ if (checkoutAnalytics)
80
+ trackStripeCheckoutShown(Object.assign(Object.assign({}, checkoutAnalytics), { checkoutSessionId, checkoutSurface: presentation }));
81
+ });
76
82
  useEffect(() => {
77
83
  if (!checkoutAnalytics) {
78
84
  return;
@@ -26,6 +26,8 @@ export type StripeWalletFirstCheckoutProps = {
26
26
  copy: StripeWalletFirstCheckoutCopy;
27
27
  customerEmail: string;
28
28
  customerName?: string | null;
29
+ /** The visitor closed the Apple Pay or Google Pay sheet without paying, e.g. to offer a discount. */
30
+ onCancel?: () => void;
29
31
  onError?: (message: string | null) => void;
30
32
  onSuccess?: () => void | Promise<void>;
31
33
  returnUrl: string;
@@ -20,8 +20,9 @@ import { completePaywallCheckout } from '@funnelsgrove/runtime';
20
20
  import { StripeCheckoutExpressCheckoutButton } from './StripeCheckoutExpressCheckoutButton.js';
21
21
  import { resolveWalletPlatform } from './walletPlatform.js';
22
22
  import { stripeWalletFirstCheckoutStyles } from './StripeWalletFirstCheckout.styles.js';
23
- import { queueStripeSubscriptionCheckoutStarted, trackPaidStripeSubscriptionCheckoutCompleted, trackStripeSubscriptionPaymentInfoSubmitted, } from '../services/checkoutCompletionAnalytics.service.js';
23
+ import { queueStripeSubscriptionCheckoutStarted, trackPaidStripeSubscriptionCheckoutCompleted, trackStripeCheckoutShown, trackStripeSubscriptionPaymentInfoSubmitted, } from '../services/checkoutCompletionAnalytics.service.js';
24
24
  import { trackCheckoutFailureShown } from '../../../services/checkoutObservability.service.js';
25
+ import { useCheckoutShownOnce } from '../hooks/useCheckoutShownOnce.js';
25
26
  const appearance = {
26
27
  labels: 'above',
27
28
  variables: {
@@ -41,6 +42,11 @@ export function StripeWalletFirstCheckout(props) {
41
42
  clientSecret,
42
43
  elementsOptions: { appearance: Object.assign(Object.assign({}, appearance), { variables: Object.assign(Object.assign({}, appearance.variables), { colorPrimary: themeColor }) }) },
43
44
  } : null, [clientSecret, themeColor]);
45
+ // The whole layout shows on mount, so one mount is one show.
46
+ useCheckoutShownOnce(Boolean(checkoutAnalytics), () => {
47
+ if (checkoutAnalytics)
48
+ trackStripeCheckoutShown(Object.assign(Object.assign({}, checkoutAnalytics), { checkoutSessionId, checkoutSurface: 'inline' }));
49
+ });
44
50
  // Session creation is preparation; the card checkout starts when the visitor opens the card form.
45
51
  useEffect(() => {
46
52
  if (!checkoutAnalytics || !clientSecret || selected !== 'card')
@@ -49,7 +55,7 @@ export function StripeWalletFirstCheckout(props) {
49
55
  }, [checkoutAnalytics, checkoutSessionId, clientSecret, selected]);
50
56
  return (_jsxs(_Fragment, { children: [options && stripePromise ? (_jsx(CheckoutProvider, { stripe: stripePromise, options: options, children: _jsx(StripeWalletFirstCheckoutForm, Object.assign({}, props, { wallet: wallet, selected: selected, onSelect: setSelected, style: style })) }, options.clientSecret)) : (_jsx(CheckoutLayout, { copy: copy, wallet: wallet, selected: selected, onSelect: setSelected, disabled: false, style: style, walletSlot: _jsx("span", { className: 'wfc-progress', role: 'status', "aria-label": copy.processingLabel }), cardBody: _jsx("span", { className: 'wfc-progress wfc-progress--form', "aria-hidden": 'true' }) })), _jsx("style", { children: stripeWalletFirstCheckoutStyles })] }));
51
57
  }
52
- function StripeWalletFirstCheckoutForm({ amountCents, checkoutAnalytics, checkoutSessionId, copy, customerEmail, customerName, onError, onSuccess, returnUrl, summaryLabel, wallet, selected, onSelect, style, }) {
58
+ function StripeWalletFirstCheckoutForm({ amountCents, checkoutAnalytics, checkoutSessionId, copy, customerEmail, customerName, onCancel, onError, onSuccess, returnUrl, summaryLabel, wallet, selected, onSelect, style, }) {
53
59
  const checkoutState = useCheckout();
54
60
  // null until Stripe answers; the slot shows progress at the button's height meanwhile.
55
61
  const [walletAvailable, setWalletAvailable] = useState(null);
@@ -161,7 +167,7 @@ function StripeWalletFirstCheckoutForm({ amountCents, checkoutAnalytics, checkou
161
167
  // where the customer paid, until the success screen takes over.
162
168
  const paymentStatus = completed ? copy.paymentConfirmed : providerConfirmed ? copy.verifyingPayment : null;
163
169
  const locked = Boolean(paymentStatus) && !error;
164
- return (_jsx(CheckoutLayout, { copy: copy, wallet: wallet, selected: selected, onSelect: onSelect, disabled: submitting || walletConfirming || locked, style: style, walletNote: selected === 'wallet' ? paymentStatus : null, walletSlot: _jsxs(_Fragment, { children: [_jsx("div", { className: 'wfc-wallet-button', "data-ready": walletAvailable === true, children: _jsx(StripeCheckoutExpressCheckoutButton, { amountCents: amountCents, availabilityPaymentMethods: [wallet], beforeConfirm: async () => { setWalletConfirming(true); return customerEmail || null; }, checkoutAnalytics: checkoutAnalytics, checkoutSessionId: checkoutSessionId, confirmEmail: false, customerEmail: customerEmail, customerName: customerName, onAvailabilityChange: setWalletAvailable, onCancel: () => setWalletConfirming(false), onEmbeddedComplete: () => setCompleted(true), onError: message => { setWalletConfirming(false); showError(message, undefined, true); }, onPaymentRejected: () => { setWalletConfirming(false); onSelect('card'); }, onSuccess: () => trackSuccess(), onVerificationPending: () => setProviderConfirmed(true), options: expressOptions, returnUrl: returnUrl, summaryLabel: summaryLabel }) }), walletAvailable === null ? _jsx("span", { className: 'wfc-progress wfc-progress--overlay', role: 'status', "aria-label": copy.processingLabel }) : null, walletAvailable === false ? _jsx("p", { className: 'wfc-wallet-unavailable', role: 'status', children: copy.walletUnavailableNote.replace('{wallet}', copy.walletLabels[wallet]) }) : null] }), cardBody: _jsxs("form", { className: 'wfc-card-form', onSubmit: event => void submitCard(event), children: [_jsx(PaymentElement, { options: paymentElementOptions, onChange: event => setPaymentElementComplete(event.complete), onReady: () => setPaymentElementReady(true) }), _jsx("button", { type: 'submit', className: 'wfc-submit', disabled: locked || submitting || !paymentElementReady, "aria-live": 'polite', children: locked && paymentStatus ? paymentStatus : submitting ? copy.processingLabel : copy.cardSubmitLabel }), _jsx("p", { className: 'wfc-secure', children: copy.secureNote })] }), footer: error ? _jsx("p", { className: 'wfc-error', role: 'alert', children: error }) : null }));
170
+ return (_jsx(CheckoutLayout, { copy: copy, wallet: wallet, selected: selected, onSelect: onSelect, disabled: submitting || walletConfirming || locked, style: style, walletNote: selected === 'wallet' ? paymentStatus : null, walletSlot: _jsxs(_Fragment, { children: [_jsx("div", { className: 'wfc-wallet-button', "data-ready": walletAvailable === true, children: _jsx(StripeCheckoutExpressCheckoutButton, { amountCents: amountCents, availabilityPaymentMethods: [wallet], beforeConfirm: async () => { setWalletConfirming(true); return customerEmail || null; }, checkoutAnalytics: checkoutAnalytics, checkoutSessionId: checkoutSessionId, confirmEmail: false, customerEmail: customerEmail, customerName: customerName, onAvailabilityChange: setWalletAvailable, onCancel: () => { setWalletConfirming(false); onCancel === null || onCancel === void 0 ? void 0 : onCancel(); }, onEmbeddedComplete: () => setCompleted(true), onError: message => { setWalletConfirming(false); showError(message, undefined, true); }, onPaymentRejected: () => { setWalletConfirming(false); onSelect('card'); }, onSuccess: () => trackSuccess(), onVerificationPending: () => setProviderConfirmed(true), options: expressOptions, returnUrl: returnUrl, summaryLabel: summaryLabel }) }), walletAvailable === null ? _jsx("span", { className: 'wfc-progress wfc-progress--overlay', role: 'status', "aria-label": copy.processingLabel }) : null, walletAvailable === false ? _jsx("p", { className: 'wfc-wallet-unavailable', role: 'status', children: copy.walletUnavailableNote.replace('{wallet}', copy.walletLabels[wallet]) }) : null] }), cardBody: _jsxs("form", { className: 'wfc-card-form', onSubmit: event => void submitCard(event), children: [_jsx(PaymentElement, { options: paymentElementOptions, onChange: event => setPaymentElementComplete(event.complete), onReady: () => setPaymentElementReady(true) }), _jsx("button", { type: 'submit', className: 'wfc-submit', disabled: locked || submitting || !paymentElementReady, "aria-live": 'polite', children: locked && paymentStatus ? paymentStatus : submitting ? copy.processingLabel : copy.cardSubmitLabel }), _jsx("p", { className: 'wfc-secure', children: copy.secureNote })] }), footer: error ? _jsx("p", { className: 'wfc-error', role: 'alert', children: error }) : null }));
165
171
  }
166
172
  /** Shared by the loading and live states so switching between them moves nothing. */
167
173
  function CheckoutLayout({ cardBody, copy, disabled, footer, onSelect, selected, style, wallet, walletNote, walletSlot }) {
@@ -3,7 +3,7 @@ import type { StripeWalletCheckoutSuccess } from './StripeCheckoutExpressCheckou
3
3
  import { type ReactNode } from 'react';
4
4
  import type { Appearance, StripeCheckoutExpressCheckoutElementOptions } from '@stripe/stripe-js';
5
5
  import { type PlatformWalletPaymentMethod } from './walletPlatform.js';
6
- import type { StripeSubscriptionCheckoutAnalyticsContext } from '../services/checkoutCompletionAnalytics.service.js';
6
+ import { type StripeSubscriptionCheckoutAnalyticsContext } from '../services/checkoutCompletionAnalytics.service.js';
7
7
  import type { StripeWalletCheckoutSession } from './WalletSubscriptionCheckoutSlot.js';
8
8
  export type StripeWalletSurfaceAvailability = {
9
9
  anyAvailable: boolean;
@@ -4,8 +4,10 @@ import { CheckoutEmailDialog } from '../../../components/CheckoutEmailDialog.js'
4
4
  import { isCheckoutEmailError, isValidCheckoutEmail, normalizeCheckoutEmailResult } from '../../../services/checkoutEmail.js';
5
5
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
6
  import { usePlatformWalletPaymentMethods, } from './walletPlatform.js';
7
+ import { trackStripeCheckoutShown } from '../services/checkoutCompletionAnalytics.service.js';
7
8
  import { ApplePaySubscriptionCheckoutSlot } from './ApplePaySubscriptionCheckoutSlot.js';
8
9
  import { GooglePaySubscriptionCheckoutSlot } from './GooglePaySubscriptionCheckoutSlot.js';
10
+ import { useCheckoutShownOnce } from '../hooks/useCheckoutShownOnce.js';
9
11
  export function buildStripeWalletSurfaceAnalyticsMetadata(analyticsMetadata, surfaceId) {
10
12
  return Object.assign(Object.assign({}, (analyticsMetadata !== null && analyticsMetadata !== void 0 ? analyticsMetadata : {})), { checkoutSurfaceId: surfaceId });
11
13
  }
@@ -169,6 +171,12 @@ export function StripeWalletSurface({ amountCents, appearance, applePayOptions,
169
171
  resolved: availability.resolved,
170
172
  suspended,
171
173
  });
174
+ const walletButtonVisible = shouldRenderSurface && availability.anyAvailable && !needsEmail;
175
+ useCheckoutShownOnce(walletButtonVisible && Boolean(surfaceCheckoutAnalytics), () => {
176
+ if (surfaceCheckoutAnalytics) {
177
+ trackStripeCheckoutShown(Object.assign(Object.assign({}, surfaceCheckoutAnalytics), { checkoutSessionId: session.checkoutSessionId, checkoutSurface: 'wallet' }));
178
+ }
179
+ });
172
180
  if (needsEmail && session.configured && !suspended && walletPaymentMethods.length > 0) {
173
181
  if (!emailDialogOpen || !onCustomerEmailCommit)
174
182
  return null;
@@ -0,0 +1,2 @@
1
+ /** Calls `track` once per mount, the first time `enabled` is true; re-renders never repeat it. */
2
+ export declare function useCheckoutShownOnce(enabled: boolean, track: () => void): void;
@@ -0,0 +1,11 @@
1
+ import { useEffect, useRef } from 'react';
2
+ /** Calls `track` once per mount, the first time `enabled` is true; re-renders never repeat it. */
3
+ export function useCheckoutShownOnce(enabled, track) {
4
+ const tracked = useRef(false);
5
+ useEffect(() => {
6
+ if (!enabled || tracked.current)
7
+ return;
8
+ tracked.current = true;
9
+ track();
10
+ }, [enabled, track]);
11
+ }
@@ -23,9 +23,16 @@ export type TrackPaidStripeSubscriptionCheckoutCompletedInput = StripeSubscripti
23
23
  export type TrackStripeSubscriptionPaymentInfoSubmittedInput = StripeSubscriptionCheckoutAnalyticsContext & {
24
24
  checkoutSessionId?: string | null;
25
25
  };
26
+ export type StripeCheckoutSurface = 'inline' | 'dialog' | 'wallet';
27
+ export type TrackStripeCheckoutShownInput = StripeSubscriptionCheckoutAnalyticsContext & {
28
+ checkoutSessionId?: string | null;
29
+ checkoutSurface: StripeCheckoutSurface;
30
+ };
26
31
  export type TrackStripeSubscriptionCheckoutStartedResult = 'already_tracked' | 'analytics_rejected' | 'missing_checkout_session' | 'tracked';
27
32
  export type TrackPaidStripeSubscriptionCheckoutCompletedResult = 'already_tracked' | 'analytics_rejected' | 'missing_checkout_session' | 'tracked' | 'unpaid';
28
33
  export type TrackStripeSubscriptionPaymentInfoSubmittedResult = 'already_tracked' | 'analytics_rejected' | 'missing_checkout_session' | 'tracked';
34
+ /** Each call is one show of a checkout form or wallet button; callers own once-per-show. */
35
+ export declare function trackStripeCheckoutShown(input: TrackStripeCheckoutShownInput): void;
29
36
  export declare function trackStripeSubscriptionCheckoutStarted(input: TrackStripeSubscriptionCheckoutStartedInput): Promise<TrackStripeSubscriptionCheckoutStartedResult>;
30
37
  export declare function queueStripeSubscriptionCheckoutStarted(input: TrackStripeSubscriptionCheckoutStartedInput): void;
31
38
  export declare function trackStripeSubscriptionPaymentInfoSubmitted(input: TrackStripeSubscriptionPaymentInfoSubmittedInput): Promise<TrackStripeSubscriptionPaymentInfoSubmittedResult>;
@@ -49,6 +49,24 @@ const markCheckoutSessionTracked = (eventName, checkoutSessionId, runtimeConfig)
49
49
  // A stable checkout-session event id still protects provider-side dedupe.
50
50
  }
51
51
  };
52
+ /** Each call is one show of a checkout form or wallet button; callers own once-per-show. */
53
+ export function trackStripeCheckoutShown(input) {
54
+ var _a;
55
+ const checkoutSessionId = ((_a = input.checkoutSessionId) === null || _a === void 0 ? void 0 : _a.trim()) || '';
56
+ try {
57
+ publicAnalyticsSdk.trackCheckoutShown({
58
+ featureFlags: input.featureFlags,
59
+ stepId: input.stepId,
60
+ stepName: input.stepName,
61
+ stepType: input.stepType,
62
+ stepContractVersion: input.stepContractVersion,
63
+ metadata: Object.assign(Object.assign(Object.assign(Object.assign({}, asRecord(input.metadata)), { checkout_surface: input.checkoutSurface }), (checkoutSessionId ? { checkoutSessionId, checkout_session_id: checkoutSessionId } : {})), { environment: input.environment || asRecord(input.metadata).environment, payment_provider: 'stripe' }),
64
+ });
65
+ }
66
+ catch (_b) {
67
+ // Analytics must not affect checkout rendering.
68
+ }
69
+ }
52
70
  export async function trackStripeSubscriptionCheckoutStarted(input) {
53
71
  var _a;
54
72
  const checkoutSessionId = ((_a = input.checkoutSessionId) === null || _a === void 0 ? void 0 : _a.trim()) || '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/payments",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/The-Solid-Grove/funnelsgrove.git",
@@ -46,11 +46,11 @@
46
46
  "jsdom": "20.0.3",
47
47
  "typescript": "^5",
48
48
  "vitest": "^3.2.4",
49
- "@funnelsgrove/runtime": "0.30.0",
50
- "@funnelsgrove/analytics": "0.12.0"
49
+ "@funnelsgrove/runtime": "0.31.0",
50
+ "@funnelsgrove/analytics": "0.13.0"
51
51
  },
52
52
  "peerDependencies": {
53
- "@funnelsgrove/runtime": "^0.30.0",
54
- "@funnelsgrove/analytics": "^0.12.0"
53
+ "@funnelsgrove/runtime": "^0.31.0",
54
+ "@funnelsgrove/analytics": "^0.13.0"
55
55
  }
56
56
  }