@funnelfox/billing 0.9.1 → 0.9.3-feat-defer-session-on-interaction.34

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.
@@ -495,7 +495,7 @@
495
495
  /**
496
496
  * @fileoverview Constants for Funnefox SDK
497
497
  */
498
- const SDK_VERSION = '0.9.1';
498
+ const SDK_VERSION = '0.9.3-feat-defer-session-on-interaction.34';
499
499
  const DEFAULTS = {
500
500
  BASE_URL: 'https://billing.funnelfox.com',
501
501
  REGION: 'default',
@@ -632,6 +632,141 @@
632
632
  return true;
633
633
  }
634
634
 
635
+ /**
636
+ * @fileoverview Support for Apple's official <apple-pay-button> custom element.
637
+ *
638
+ * Primer's checkout-web v2 renders the Apple Pay button through the CSS
639
+ * approach (-webkit-appearance: -apple-pay-button), and WebKit only accepts
640
+ * the 7 legacy values for -apple-pay-button-type. Newer types such as
641
+ * 'continue' are silently dropped and fall back to 'plain' (verified in
642
+ * Safari 26 on macOS and iOS — see PRD-1426).
643
+ *
644
+ * Apple's official custom element supports the full set of button types, so
645
+ * for "native-only" types we overlay it on top of the (visually hidden)
646
+ * Primer button and forward clicks, keeping Primer's payment flow untouched.
647
+ */
648
+ const APPLE_PAY_SDK_URL = 'https://applepay.cdn-apple.com/jsapi/v1/apple-pay-sdk.js';
649
+ /** How long to wait for the <apple-pay-button> element definition before
650
+ * giving up and falling back to Primer's own button. */
651
+ const ELEMENT_DEFINITION_TIMEOUT_MS = 4000;
652
+ /**
653
+ * Button types WebKit can render through Primer's CSS approach.
654
+ * Everything outside this list requires the native <apple-pay-button> element.
655
+ */
656
+ const CSS_RENDERABLE_APPLE_PAY_TYPES = [
657
+ 'plain',
658
+ 'buy',
659
+ 'set-up',
660
+ 'donate',
661
+ 'check-out',
662
+ 'book',
663
+ 'subscribe',
664
+ ];
665
+ /**
666
+ * Returns true when the requested Apple Pay buttonType can only be rendered
667
+ * with Apple's native <apple-pay-button> element.
668
+ */
669
+ function isNativeOnlyApplePayButtonType(type) {
670
+ return !!type && !CSS_RENDERABLE_APPLE_PAY_TYPES.includes(type);
671
+ }
672
+ /**
673
+ * Loads Apple's apple-pay-sdk.js (defines the <apple-pay-button> custom
674
+ * element). Resolves to true when the element is available.
675
+ */
676
+ async function loadApplePayButtonElement() {
677
+ if (typeof window === 'undefined' || !window.customElements) {
678
+ return false;
679
+ }
680
+ if (window.customElements.get('apple-pay-button')) {
681
+ return true;
682
+ }
683
+ try {
684
+ await loadScript$1({
685
+ src: APPLE_PAY_SDK_URL,
686
+ crossOrigin: 'anonymous',
687
+ appendTo: 'head',
688
+ });
689
+ // Never hang on a script that loaded but did not define the element —
690
+ // fall back to Primer's own button instead.
691
+ const defined = window.customElements
692
+ .whenDefined('apple-pay-button')
693
+ .then(() => true);
694
+ const timeout = new Promise(resolve => {
695
+ setTimeout(() => resolve(false), ELEMENT_DEFINITION_TIMEOUT_MS);
696
+ });
697
+ return await Promise.race([defined, timeout]);
698
+ }
699
+ catch {
700
+ return false;
701
+ }
702
+ }
703
+ function createNativeApplePayElement(options) {
704
+ const native = document.createElement('apple-pay-button');
705
+ native.setAttribute('type', options.buttonType);
706
+ native.setAttribute('buttonstyle', options.buttonStyle || 'black');
707
+ native.setAttribute('locale', (typeof navigator !== 'undefined' && navigator.language) || 'en-US');
708
+ native.style.cssText = [
709
+ 'position:absolute',
710
+ 'inset:0',
711
+ 'width:100%',
712
+ 'height:100%',
713
+ '--apple-pay-button-width:100%',
714
+ '--apple-pay-button-height:100%',
715
+ '--apple-pay-button-border-radius:28px',
716
+ 'display:block',
717
+ 'cursor:pointer',
718
+ 'z-index:1',
719
+ ].join(';');
720
+ return native;
721
+ }
722
+ /**
723
+ * Renders Apple's native <apple-pay-button> stretched over `wrapper` and
724
+ * forwards clicks to the Primer-rendered <button> inside it. The synchronous
725
+ * forwarded click preserves the user activation required by ApplePaySession.
726
+ *
727
+ * Returns the overlay element, or null when the Apple Pay SDK could not be
728
+ * loaded (callers should keep Primer's own button visible as a fallback).
729
+ */
730
+ async function overlayNativeApplePayButton(wrapper, options) {
731
+ const available = await loadApplePayButtonElement();
732
+ if (!available) {
733
+ return null;
734
+ }
735
+ // The overlay is only safe when Primer's own clickable button is present —
736
+ // otherwise there is nothing to forward the click to.
737
+ if (!wrapper.querySelector('button')) {
738
+ return null;
739
+ }
740
+ const native = createNativeApplePayElement(options);
741
+ wrapper.style.position = 'relative';
742
+ native.addEventListener('click', () => {
743
+ const primerButton = wrapper.querySelector('button');
744
+ primerButton?.click();
745
+ });
746
+ wrapper.appendChild(native);
747
+ return native;
748
+ }
749
+ /**
750
+ * Renders the native button with no Primer button behind it, for the deferred
751
+ * flow: the visitor sees the real Apple Pay button immediately while the
752
+ * client session is still un-created. `onActivate` fires on tap so the caller
753
+ * can start (or await) initialization.
754
+ *
755
+ * Returns null when Apple's SDK is unavailable — callers must fall back to
756
+ * creating the session eagerly so a button of some kind is always rendered.
757
+ */
758
+ async function renderPlaceholderApplePayButton(wrapper, options) {
759
+ const available = await loadApplePayButtonElement();
760
+ if (!available) {
761
+ return null;
762
+ }
763
+ const native = createNativeApplePayElement(options);
764
+ wrapper.style.position = 'relative';
765
+ native.addEventListener('click', options.onActivate);
766
+ wrapper.appendChild(native);
767
+ return native;
768
+ }
769
+
635
770
  /**
636
771
  * @fileoverview Primer SDK integration wrapper
637
772
  */
@@ -670,6 +805,7 @@
670
805
  }
671
806
  async createHeadlessCheckout(clientToken, options, method) {
672
807
  await this.ensurePrimerLoaded();
808
+ this.applePayOptions = options.applePay;
673
809
  this.currentHeadless = PrimerWrapper.headlessManager.getOrCreate(clientToken, options, method);
674
810
  return this.currentHeadless;
675
811
  }
@@ -716,16 +852,53 @@
716
852
  }
717
853
  htmlNode.appendChild(wrapper);
718
854
  /* end hack */
855
+ /* PRD-1426: WebKit cannot render newer Apple Pay button types (e.g.
856
+ * 'continue') through the CSS approach Primer v2 uses. For those types
857
+ * we overlay Apple's official <apple-pay-button> element on top of the
858
+ * visually hidden Primer button and forward clicks, keeping Primer's
859
+ * payment flow untouched. The Primer button is hidden BEFORE it renders
860
+ * to avoid a flash of the plain button; if the Apple SDK fails to load,
861
+ * the rule is removed and Primer's own button stays visible (graceful
862
+ * fallback to 'plain'). */
863
+ const applePayConfig = this.applePayOptions;
864
+ const nativeButtonType = allowedPaymentMethod === exports.PaymentMethod.APPLE_PAY &&
865
+ applePayConfig?.buttonType &&
866
+ isNativeOnlyApplePayButtonType(applePayConfig.buttonType)
867
+ ? applePayConfig.buttonType
868
+ : null;
869
+ let hideRuleIndex = null;
870
+ if (nativeButtonType && sheet) {
871
+ hideRuleIndex = sheet.insertRule(`
872
+ .${wrapper.className} > button {
873
+ opacity: 0 !important;
874
+ }
875
+ `);
876
+ }
719
877
  button = pmManager.createButton();
720
878
  await button.render(wrapper, {});
879
+ let nativeOverlay = null;
880
+ if (nativeButtonType) {
881
+ nativeOverlay = await overlayNativeApplePayButton(wrapper, {
882
+ buttonType: nativeButtonType,
883
+ buttonStyle: applePayConfig?.buttonStyle,
884
+ });
885
+ if (!nativeOverlay && sheet && hideRuleIndex !== null) {
886
+ sheet.deleteRule(hideRuleIndex);
887
+ }
888
+ }
721
889
  this.destroyCallbacks.push(() => button.clean());
722
890
  onMethodRender(allowedPaymentMethod);
723
891
  return {
724
892
  setDisabled: (disabled) => {
725
893
  button.setDisabled(disabled);
894
+ if (nativeOverlay) {
895
+ nativeOverlay.style.pointerEvents = disabled ? 'none' : '';
896
+ nativeOverlay.style.opacity = disabled ? '0.5' : '';
897
+ }
726
898
  },
727
899
  destroy: () => {
728
900
  styleEl.remove();
901
+ nativeOverlay?.remove();
729
902
  button.clean();
730
903
  },
731
904
  };
@@ -1072,6 +1245,32 @@
1072
1245
  }
1073
1246
  PrimerWrapper.headlessManager = new HeadlessManager();
1074
1247
 
1248
+ /**
1249
+ * @fileoverview Tags the deferred session request so the warm-up trigger can
1250
+ * be measured without adding a single extra network call.
1251
+ *
1252
+ * The backend already logs every request header, so `X-FF-Warmup` lands in
1253
+ * Elasticsearch for free. That gives us, per session actually created:
1254
+ * - which signal started it (dwell vs interaction vs a cold tap)
1255
+ * - how long after mount it fired
1256
+ *
1257
+ * Which is what tells us whether the dwell can be lengthened (more savings)
1258
+ * or must stay short (cold-tap risk), instead of guessing at an interaction
1259
+ * rate nobody has measured.
1260
+ */
1261
+ const WARMUP_HEADER = 'X-FF-Warmup';
1262
+ let pendingTag = null;
1263
+ /** Records the trigger for the session request that is about to be sent. */
1264
+ function setWarmupTag(trigger, msSinceMount) {
1265
+ pendingTag = `${trigger};${Math.max(0, Math.round(msSinceMount))}`;
1266
+ }
1267
+ /** Consumes the tag — it describes one request only. */
1268
+ function takeWarmupTag() {
1269
+ const tag = pendingTag;
1270
+ pendingTag = null;
1271
+ return tag;
1272
+ }
1273
+
1075
1274
  /**
1076
1275
  * @fileoverview API client for Funnefox backend integration
1077
1276
  */
@@ -1089,6 +1288,7 @@
1089
1288
  headers: {
1090
1289
  'Content-Type': 'application/json',
1091
1290
  'X-SDK-Version': SDK_VERSION,
1291
+ ...warmupHeader(),
1092
1292
  ...(options.headers || {}),
1093
1293
  },
1094
1294
  ...options,
@@ -1303,6 +1503,10 @@
1303
1503
  }));
1304
1504
  }
1305
1505
  }
1506
+ function warmupHeader() {
1507
+ const tag = takeWarmupTag();
1508
+ return tag ? { [WARMUP_HEADER]: tag } : {};
1509
+ }
1306
1510
 
1307
1511
  class SessionService {
1308
1512
  constructor() {
@@ -1385,6 +1589,96 @@
1385
1589
  }
1386
1590
  };
1387
1591
 
1592
+ /**
1593
+ * @fileoverview Decides when a deferred client session should be created.
1594
+ *
1595
+ * Fires on whichever comes first: the visitor's first interaction, or a short
1596
+ * dwell once the page is actually visible. The dwell is what makes the
1597
+ * deferral safe — ApplePaySession must be constructed inside the tap gesture,
1598
+ * so the session has to exist *before* the first tap. A human cannot read a
1599
+ * price and tap in under ~800ms, whereas prerenders, background tabs, crawlers
1600
+ * and instant bounces never reach the dwell at all and so never cost a
1601
+ * session (nor the order row behind it).
1602
+ */
1603
+ const INTERACTION_EVENTS = [
1604
+ 'pointerdown',
1605
+ 'touchstart',
1606
+ 'keydown',
1607
+ 'wheel',
1608
+ 'scroll',
1609
+ ];
1610
+ /** Long enough to filter prerenders and instant bounces, far shorter than
1611
+ * any realistic time-to-tap on a paywall. */
1612
+ const DEFAULT_WARMUP_DWELL_MS = 800;
1613
+ /** True while the page is prerendering or not visible to the user. */
1614
+ function isPageHidden() {
1615
+ if (typeof document === 'undefined') {
1616
+ return true;
1617
+ }
1618
+ if (document.prerendering) {
1619
+ return true;
1620
+ }
1621
+ return document.visibilityState === 'hidden';
1622
+ }
1623
+ /**
1624
+ * Invokes `handler` once — on first interaction, or after `dwellMs` of visible
1625
+ * time, whichever happens first. Returns a cleanup for the un-fired case.
1626
+ */
1627
+ function onSessionWarmup(handler, dwellMs = DEFAULT_WARMUP_DWELL_MS) {
1628
+ if (typeof document === 'undefined') {
1629
+ return () => { };
1630
+ }
1631
+ let fired = false;
1632
+ let timer;
1633
+ const cleanup = () => {
1634
+ if (timer !== undefined) {
1635
+ clearTimeout(timer);
1636
+ timer = undefined;
1637
+ }
1638
+ for (const event of INTERACTION_EVENTS) {
1639
+ document.removeEventListener(event, onInteraction, true);
1640
+ }
1641
+ document.removeEventListener('visibilitychange', onVisibilityChange);
1642
+ document.removeEventListener('prerenderingchange', onVisibilityChange);
1643
+ };
1644
+ const fire = (source) => {
1645
+ if (fired) {
1646
+ return;
1647
+ }
1648
+ fired = true;
1649
+ cleanup();
1650
+ handler(source);
1651
+ };
1652
+ function onInteraction() {
1653
+ if (!isPageHidden()) {
1654
+ fire('interaction');
1655
+ }
1656
+ }
1657
+ function startDwell() {
1658
+ if (fired || timer !== undefined) {
1659
+ return;
1660
+ }
1661
+ timer = setTimeout(() => fire('dwell'), dwellMs);
1662
+ }
1663
+ function onVisibilityChange() {
1664
+ if (!isPageHidden()) {
1665
+ startDwell();
1666
+ }
1667
+ }
1668
+ for (const event of INTERACTION_EVENTS) {
1669
+ document.addEventListener(event, onInteraction, {
1670
+ capture: true,
1671
+ passive: true,
1672
+ });
1673
+ }
1674
+ document.addEventListener('visibilitychange', onVisibilityChange);
1675
+ document.addEventListener('prerenderingchange', onVisibilityChange);
1676
+ if (!isPageHidden()) {
1677
+ startDwell();
1678
+ }
1679
+ return cleanup;
1680
+ }
1681
+
1388
1682
  var V3_URL = 'https://js.stripe.com/v3';
1389
1683
  var V3_URL_REGEX = /^https:\/\/js\.stripe\.com\/v3\/?(\?.*)?$/;
1390
1684
  var EXISTING_SCRIPT_MESSAGE = 'loadStripe.setLoadParameters was called but an existing Stripe.js script already exists in the document; existing script parameters will be used';
@@ -2542,6 +2836,98 @@
2542
2836
  }
2543
2837
  async initMethod(method, element, callbacks) {
2544
2838
  this._ensureNotDestroyed();
2839
+ const deferred = await this.initMethodDeferred(method, element, callbacks);
2840
+ if (deferred) {
2841
+ return deferred;
2842
+ }
2843
+ return await this.initMethodNow(method, element, callbacks);
2844
+ }
2845
+ /**
2846
+ * Renders Apple's native button immediately and postpones session creation
2847
+ * until the visitor interacts. Returns null when deferral does not apply, so
2848
+ * the caller falls back to creating the session on mount.
2849
+ */
2850
+ async initMethodDeferred(method, element, callbacks) {
2851
+ const applePay = this.checkoutConfig.applePay;
2852
+ const buttonType = applePay?.buttonType;
2853
+ if (this.checkoutConfig.sessionCreation !== 'onInteraction' ||
2854
+ method !== exports.PaymentMethod.APPLE_PAY ||
2855
+ this.isReady() ||
2856
+ !buttonType ||
2857
+ !isNativeOnlyApplePayButtonType(buttonType)) {
2858
+ return null;
2859
+ }
2860
+ let started = null;
2861
+ let real = null;
2862
+ let disabled = false;
2863
+ let stopWaiting = () => { };
2864
+ let placeholder = null;
2865
+ const mountedAt = Date.now();
2866
+ const start = (trigger) => {
2867
+ const fromTap = trigger === 'tap';
2868
+ if (!started) {
2869
+ setWarmupTag(trigger, Date.now() - mountedAt);
2870
+ started = (async () => {
2871
+ stopWaiting();
2872
+ if (fromTap) {
2873
+ callbacks.onLoaderChange?.(true);
2874
+ }
2875
+ try {
2876
+ placeholder?.remove();
2877
+ placeholder = null;
2878
+ const methodInterface = await this.initMethodNow(method, element, callbacks);
2879
+ real = methodInterface;
2880
+ if (disabled) {
2881
+ methodInterface.setDisabled(true);
2882
+ }
2883
+ return methodInterface;
2884
+ }
2885
+ finally {
2886
+ if (fromTap) {
2887
+ callbacks.onLoaderChange?.(false);
2888
+ }
2889
+ }
2890
+ })();
2891
+ }
2892
+ return started;
2893
+ };
2894
+ placeholder = await renderPlaceholderApplePayButton(element, {
2895
+ buttonType,
2896
+ buttonStyle: applePay?.buttonStyle,
2897
+ // Cold tap: the visitor's first interaction is the button itself, so the
2898
+ // session cannot exist yet and ApplePaySession would lose the gesture.
2899
+ // Start now and let the loader cover the (rare) second tap.
2900
+ onActivate: () => void start('tap'),
2901
+ });
2902
+ if (!placeholder) {
2903
+ return null;
2904
+ }
2905
+ // Free head start: Primer's SDK is a static asset on their CDN, so
2906
+ // fetching it now costs no backend call and leaves only the session
2907
+ // request to complete before the first tap.
2908
+ void loadPrimerSDK().catch(() => { });
2909
+ stopWaiting = onSessionWarmup(trigger => void start(trigger));
2910
+ return {
2911
+ setDisabled: (value) => {
2912
+ disabled = value;
2913
+ real?.setDisabled(value);
2914
+ if (placeholder) {
2915
+ placeholder.style.pointerEvents = value ? 'none' : '';
2916
+ placeholder.style.opacity = value ? '0.5' : '';
2917
+ }
2918
+ },
2919
+ destroy: async () => {
2920
+ stopWaiting();
2921
+ placeholder?.remove();
2922
+ placeholder = null;
2923
+ if (started) {
2924
+ await (await started).destroy?.();
2925
+ }
2926
+ await this.destroy();
2927
+ },
2928
+ };
2929
+ }
2930
+ async initMethodNow(method, element, callbacks) {
2545
2931
  if (!this.isReady()) {
2546
2932
  await this.createSession();
2547
2933
  }
@@ -2759,6 +3145,7 @@
2759
3145
  ...DEFAULT_BUTTONS_OPTIONS[exports.PaymentMethod.GOOGLE_PAY],
2760
3146
  ...(options.googlePay || {}),
2761
3147
  },
3148
+ sessionCreation: options.sessionCreation,
2762
3149
  },
2763
3150
  });
2764
3151
  return await checkoutInstance.initMethod(method, element, {