@funnelfox/billing 0.9.3-feat-defer-session-on-interaction.34 → 0.10.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 +4 -0
- package/dist/chunk-index.cjs.js +104 -392
- package/dist/chunk-index.cjs3.js +1 -1
- package/dist/chunk-index.es.js +103 -393
- package/dist/chunk-index.es3.js +1 -1
- package/dist/chunk-stripe-card-form.cjs.js +5 -5
- package/dist/chunk-stripe-card-form.es.js +5 -5
- package/dist/chunk-stripe-wallet.cjs.js +9 -2
- package/dist/chunk-stripe-wallet.es.js +9 -2
- package/dist/funnelfox-billing.js +115 -398
- package/dist/funnelfox-billing.min.js +1 -1
- package/package.json +2 -2
- package/src/types.d.ts +32 -54
package/README.md
CHANGED
|
@@ -421,6 +421,8 @@ try {
|
|
|
421
421
|
console.log('Error Code:', error.errorCode); // e.g., 'double_purchase'
|
|
422
422
|
console.log('Error Type:', error.errorType); // e.g., 'api_exception'
|
|
423
423
|
console.log('Request ID:', error.requestId); // For support
|
|
424
|
+
console.log('Decline Code:', error.declineCode); // e.g., 'INSUFFICIENT_FUNDS', set on failed payments
|
|
425
|
+
console.log('Decline Source:', error.declineCodeSource); // 'primer' | 'stripe' | 'adyen', when the provider reports one
|
|
424
426
|
console.log('Message:', error.message);
|
|
425
427
|
} else if (error instanceof PrimerError) {
|
|
426
428
|
// Primer SDK error
|
|
@@ -634,6 +636,8 @@ const paymentMethod = await Billing.initMethod(
|
|
|
634
636
|
},
|
|
635
637
|
onPaymentFail: error => {
|
|
636
638
|
console.error('Payment failed:', error.message);
|
|
639
|
+
console.log('Decline code:', error.declineCode); // e.g., 'INSUFFICIENT_FUNDS', when the provider reports one
|
|
640
|
+
console.log('Decline source:', error.declineCodeSource); // 'primer' | 'stripe' | 'adyen'
|
|
637
641
|
},
|
|
638
642
|
onPaymentCancel: () => {
|
|
639
643
|
console.log('Payment was cancelled');
|
package/dist/chunk-index.cjs.js
CHANGED
|
@@ -119,6 +119,8 @@ class APIError extends FunnefoxSDKError {
|
|
|
119
119
|
this.errorType = options.errorType || null;
|
|
120
120
|
this.requestId = options.requestId || null;
|
|
121
121
|
this.response = options.response || null;
|
|
122
|
+
this.declineCode = options.declineCode || null;
|
|
123
|
+
this.declineCodeSource = options.declineCodeSource || null;
|
|
122
124
|
}
|
|
123
125
|
}
|
|
124
126
|
class PrimerError extends FunnefoxSDKError {
|
|
@@ -323,6 +325,48 @@ async function loadPrimerSDK(version) {
|
|
|
323
325
|
/**
|
|
324
326
|
* @fileoverview Helper utilities for Funnefox SDK
|
|
325
327
|
*/
|
|
328
|
+
// Mirrors Stripe Elements: a card number that can never become valid is reported while the buyer
|
|
329
|
+
// types, an incomplete value only after they leave the field, and an empty one only after a submit
|
|
330
|
+
// attempt. Primer reports every error on every keystroke, even for fields the buyer has not reached
|
|
331
|
+
// yet, and its expiry codes cannot tell a typo from a half-typed date ("1" is already
|
|
332
|
+
// expiryYearInvalid, "01/2" is cardExpired), so expiry errors wait for the buyer to leave the field.
|
|
333
|
+
const HOSTED_INPUT_ERRORS_SHOWN_WHILE_TYPING = new Set(['cardInvalid']);
|
|
334
|
+
const HOSTED_INPUT_ERRORS_FOR_EMPTY_FIELD = new Set([
|
|
335
|
+
'cardRequired',
|
|
336
|
+
'expiryRequired',
|
|
337
|
+
'cvvRequired',
|
|
338
|
+
]);
|
|
339
|
+
function getVisibleHostedInputError(input) {
|
|
340
|
+
if (!input.error) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
if (input.errorCode &&
|
|
344
|
+
HOSTED_INPUT_ERRORS_SHOWN_WHILE_TYPING.has(input.errorCode)) {
|
|
345
|
+
return input.error;
|
|
346
|
+
}
|
|
347
|
+
if (input.active) {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
if (input.submitted) {
|
|
351
|
+
return input.error;
|
|
352
|
+
}
|
|
353
|
+
if (input.errorCode &&
|
|
354
|
+
HOSTED_INPUT_ERRORS_FOR_EMPTY_FIELD.has(input.errorCode)) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
return input.touched ? input.error : null;
|
|
358
|
+
}
|
|
359
|
+
function resolveTaxDisplay(source) {
|
|
360
|
+
const taxBehavior = source.tax_behavior ??
|
|
361
|
+
((source.tax_amount ?? 0) > 0 ? 'exclusive' : undefined);
|
|
362
|
+
if (taxBehavior === 'inclusive') {
|
|
363
|
+
return { taxBehavior, taxAmount: source.tax_inclusive_amount ?? 0 };
|
|
364
|
+
}
|
|
365
|
+
if (taxBehavior === 'exclusive') {
|
|
366
|
+
return { taxBehavior, taxAmount: source.tax_amount ?? 0 };
|
|
367
|
+
}
|
|
368
|
+
return { taxAmount: 0 };
|
|
369
|
+
}
|
|
326
370
|
function merge(...objects) {
|
|
327
371
|
const result = {};
|
|
328
372
|
for (const obj of objects) {
|
|
@@ -491,7 +535,7 @@ exports.PaymentMethod = void 0;
|
|
|
491
535
|
/**
|
|
492
536
|
* @fileoverview Constants for Funnefox SDK
|
|
493
537
|
*/
|
|
494
|
-
const SDK_VERSION = '0.
|
|
538
|
+
const SDK_VERSION = '0.10.0';
|
|
495
539
|
const DEFAULTS = {
|
|
496
540
|
BASE_URL: 'https://billing.funnelfox.com',
|
|
497
541
|
REGION: 'default',
|
|
@@ -628,141 +672,6 @@ function requireString(value, fieldName) {
|
|
|
628
672
|
return true;
|
|
629
673
|
}
|
|
630
674
|
|
|
631
|
-
/**
|
|
632
|
-
* @fileoverview Support for Apple's official <apple-pay-button> custom element.
|
|
633
|
-
*
|
|
634
|
-
* Primer's checkout-web v2 renders the Apple Pay button through the CSS
|
|
635
|
-
* approach (-webkit-appearance: -apple-pay-button), and WebKit only accepts
|
|
636
|
-
* the 7 legacy values for -apple-pay-button-type. Newer types such as
|
|
637
|
-
* 'continue' are silently dropped and fall back to 'plain' (verified in
|
|
638
|
-
* Safari 26 on macOS and iOS — see PRD-1426).
|
|
639
|
-
*
|
|
640
|
-
* Apple's official custom element supports the full set of button types, so
|
|
641
|
-
* for "native-only" types we overlay it on top of the (visually hidden)
|
|
642
|
-
* Primer button and forward clicks, keeping Primer's payment flow untouched.
|
|
643
|
-
*/
|
|
644
|
-
const APPLE_PAY_SDK_URL = 'https://applepay.cdn-apple.com/jsapi/v1/apple-pay-sdk.js';
|
|
645
|
-
/** How long to wait for the <apple-pay-button> element definition before
|
|
646
|
-
* giving up and falling back to Primer's own button. */
|
|
647
|
-
const ELEMENT_DEFINITION_TIMEOUT_MS = 4000;
|
|
648
|
-
/**
|
|
649
|
-
* Button types WebKit can render through Primer's CSS approach.
|
|
650
|
-
* Everything outside this list requires the native <apple-pay-button> element.
|
|
651
|
-
*/
|
|
652
|
-
const CSS_RENDERABLE_APPLE_PAY_TYPES = [
|
|
653
|
-
'plain',
|
|
654
|
-
'buy',
|
|
655
|
-
'set-up',
|
|
656
|
-
'donate',
|
|
657
|
-
'check-out',
|
|
658
|
-
'book',
|
|
659
|
-
'subscribe',
|
|
660
|
-
];
|
|
661
|
-
/**
|
|
662
|
-
* Returns true when the requested Apple Pay buttonType can only be rendered
|
|
663
|
-
* with Apple's native <apple-pay-button> element.
|
|
664
|
-
*/
|
|
665
|
-
function isNativeOnlyApplePayButtonType(type) {
|
|
666
|
-
return !!type && !CSS_RENDERABLE_APPLE_PAY_TYPES.includes(type);
|
|
667
|
-
}
|
|
668
|
-
/**
|
|
669
|
-
* Loads Apple's apple-pay-sdk.js (defines the <apple-pay-button> custom
|
|
670
|
-
* element). Resolves to true when the element is available.
|
|
671
|
-
*/
|
|
672
|
-
async function loadApplePayButtonElement() {
|
|
673
|
-
if (typeof window === 'undefined' || !window.customElements) {
|
|
674
|
-
return false;
|
|
675
|
-
}
|
|
676
|
-
if (window.customElements.get('apple-pay-button')) {
|
|
677
|
-
return true;
|
|
678
|
-
}
|
|
679
|
-
try {
|
|
680
|
-
await loadScript$1({
|
|
681
|
-
src: APPLE_PAY_SDK_URL,
|
|
682
|
-
crossOrigin: 'anonymous',
|
|
683
|
-
appendTo: 'head',
|
|
684
|
-
});
|
|
685
|
-
// Never hang on a script that loaded but did not define the element —
|
|
686
|
-
// fall back to Primer's own button instead.
|
|
687
|
-
const defined = window.customElements
|
|
688
|
-
.whenDefined('apple-pay-button')
|
|
689
|
-
.then(() => true);
|
|
690
|
-
const timeout = new Promise(resolve => {
|
|
691
|
-
setTimeout(() => resolve(false), ELEMENT_DEFINITION_TIMEOUT_MS);
|
|
692
|
-
});
|
|
693
|
-
return await Promise.race([defined, timeout]);
|
|
694
|
-
}
|
|
695
|
-
catch {
|
|
696
|
-
return false;
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
function createNativeApplePayElement(options) {
|
|
700
|
-
const native = document.createElement('apple-pay-button');
|
|
701
|
-
native.setAttribute('type', options.buttonType);
|
|
702
|
-
native.setAttribute('buttonstyle', options.buttonStyle || 'black');
|
|
703
|
-
native.setAttribute('locale', (typeof navigator !== 'undefined' && navigator.language) || 'en-US');
|
|
704
|
-
native.style.cssText = [
|
|
705
|
-
'position:absolute',
|
|
706
|
-
'inset:0',
|
|
707
|
-
'width:100%',
|
|
708
|
-
'height:100%',
|
|
709
|
-
'--apple-pay-button-width:100%',
|
|
710
|
-
'--apple-pay-button-height:100%',
|
|
711
|
-
'--apple-pay-button-border-radius:28px',
|
|
712
|
-
'display:block',
|
|
713
|
-
'cursor:pointer',
|
|
714
|
-
'z-index:1',
|
|
715
|
-
].join(';');
|
|
716
|
-
return native;
|
|
717
|
-
}
|
|
718
|
-
/**
|
|
719
|
-
* Renders Apple's native <apple-pay-button> stretched over `wrapper` and
|
|
720
|
-
* forwards clicks to the Primer-rendered <button> inside it. The synchronous
|
|
721
|
-
* forwarded click preserves the user activation required by ApplePaySession.
|
|
722
|
-
*
|
|
723
|
-
* Returns the overlay element, or null when the Apple Pay SDK could not be
|
|
724
|
-
* loaded (callers should keep Primer's own button visible as a fallback).
|
|
725
|
-
*/
|
|
726
|
-
async function overlayNativeApplePayButton(wrapper, options) {
|
|
727
|
-
const available = await loadApplePayButtonElement();
|
|
728
|
-
if (!available) {
|
|
729
|
-
return null;
|
|
730
|
-
}
|
|
731
|
-
// The overlay is only safe when Primer's own clickable button is present —
|
|
732
|
-
// otherwise there is nothing to forward the click to.
|
|
733
|
-
if (!wrapper.querySelector('button')) {
|
|
734
|
-
return null;
|
|
735
|
-
}
|
|
736
|
-
const native = createNativeApplePayElement(options);
|
|
737
|
-
wrapper.style.position = 'relative';
|
|
738
|
-
native.addEventListener('click', () => {
|
|
739
|
-
const primerButton = wrapper.querySelector('button');
|
|
740
|
-
primerButton?.click();
|
|
741
|
-
});
|
|
742
|
-
wrapper.appendChild(native);
|
|
743
|
-
return native;
|
|
744
|
-
}
|
|
745
|
-
/**
|
|
746
|
-
* Renders the native button with no Primer button behind it, for the deferred
|
|
747
|
-
* flow: the visitor sees the real Apple Pay button immediately while the
|
|
748
|
-
* client session is still un-created. `onActivate` fires on tap so the caller
|
|
749
|
-
* can start (or await) initialization.
|
|
750
|
-
*
|
|
751
|
-
* Returns null when Apple's SDK is unavailable — callers must fall back to
|
|
752
|
-
* creating the session eagerly so a button of some kind is always rendered.
|
|
753
|
-
*/
|
|
754
|
-
async function renderPlaceholderApplePayButton(wrapper, options) {
|
|
755
|
-
const available = await loadApplePayButtonElement();
|
|
756
|
-
if (!available) {
|
|
757
|
-
return null;
|
|
758
|
-
}
|
|
759
|
-
const native = createNativeApplePayElement(options);
|
|
760
|
-
wrapper.style.position = 'relative';
|
|
761
|
-
native.addEventListener('click', options.onActivate);
|
|
762
|
-
wrapper.appendChild(native);
|
|
763
|
-
return native;
|
|
764
|
-
}
|
|
765
|
-
|
|
766
675
|
/**
|
|
767
676
|
* @fileoverview Primer SDK integration wrapper
|
|
768
677
|
*/
|
|
@@ -801,7 +710,6 @@ class PrimerWrapper {
|
|
|
801
710
|
}
|
|
802
711
|
async createHeadlessCheckout(clientToken, options, method) {
|
|
803
712
|
await this.ensurePrimerLoaded();
|
|
804
|
-
this.applePayOptions = options.applePay;
|
|
805
713
|
this.currentHeadless = PrimerWrapper.headlessManager.getOrCreate(clientToken, options, method);
|
|
806
714
|
return this.currentHeadless;
|
|
807
715
|
}
|
|
@@ -848,53 +756,16 @@ class PrimerWrapper {
|
|
|
848
756
|
}
|
|
849
757
|
htmlNode.appendChild(wrapper);
|
|
850
758
|
/* end hack */
|
|
851
|
-
/* PRD-1426: WebKit cannot render newer Apple Pay button types (e.g.
|
|
852
|
-
* 'continue') through the CSS approach Primer v2 uses. For those types
|
|
853
|
-
* we overlay Apple's official <apple-pay-button> element on top of the
|
|
854
|
-
* visually hidden Primer button and forward clicks, keeping Primer's
|
|
855
|
-
* payment flow untouched. The Primer button is hidden BEFORE it renders
|
|
856
|
-
* to avoid a flash of the plain button; if the Apple SDK fails to load,
|
|
857
|
-
* the rule is removed and Primer's own button stays visible (graceful
|
|
858
|
-
* fallback to 'plain'). */
|
|
859
|
-
const applePayConfig = this.applePayOptions;
|
|
860
|
-
const nativeButtonType = allowedPaymentMethod === exports.PaymentMethod.APPLE_PAY &&
|
|
861
|
-
applePayConfig?.buttonType &&
|
|
862
|
-
isNativeOnlyApplePayButtonType(applePayConfig.buttonType)
|
|
863
|
-
? applePayConfig.buttonType
|
|
864
|
-
: null;
|
|
865
|
-
let hideRuleIndex = null;
|
|
866
|
-
if (nativeButtonType && sheet) {
|
|
867
|
-
hideRuleIndex = sheet.insertRule(`
|
|
868
|
-
.${wrapper.className} > button {
|
|
869
|
-
opacity: 0 !important;
|
|
870
|
-
}
|
|
871
|
-
`);
|
|
872
|
-
}
|
|
873
759
|
button = pmManager.createButton();
|
|
874
760
|
await button.render(wrapper, {});
|
|
875
|
-
let nativeOverlay = null;
|
|
876
|
-
if (nativeButtonType) {
|
|
877
|
-
nativeOverlay = await overlayNativeApplePayButton(wrapper, {
|
|
878
|
-
buttonType: nativeButtonType,
|
|
879
|
-
buttonStyle: applePayConfig?.buttonStyle,
|
|
880
|
-
});
|
|
881
|
-
if (!nativeOverlay && sheet && hideRuleIndex !== null) {
|
|
882
|
-
sheet.deleteRule(hideRuleIndex);
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
761
|
this.destroyCallbacks.push(() => button.clean());
|
|
886
762
|
onMethodRender(allowedPaymentMethod);
|
|
887
763
|
return {
|
|
888
764
|
setDisabled: (disabled) => {
|
|
889
765
|
button.setDisabled(disabled);
|
|
890
|
-
if (nativeOverlay) {
|
|
891
|
-
nativeOverlay.style.pointerEvents = disabled ? 'none' : '';
|
|
892
|
-
nativeOverlay.style.opacity = disabled ? '0.5' : '';
|
|
893
|
-
}
|
|
894
766
|
},
|
|
895
767
|
destroy: () => {
|
|
896
768
|
styleEl.remove();
|
|
897
|
-
nativeOverlay?.remove();
|
|
898
769
|
button.clean();
|
|
899
770
|
},
|
|
900
771
|
};
|
|
@@ -979,11 +850,22 @@ class PrimerWrapper {
|
|
|
979
850
|
? 'Please enter a postal code'
|
|
980
851
|
: null;
|
|
981
852
|
};
|
|
853
|
+
const focusedHostedInputs = new Set();
|
|
982
854
|
const onHostedInputChange = (name) => (event) => {
|
|
983
855
|
const input = event;
|
|
984
|
-
|
|
985
|
-
|
|
856
|
+
const justFocused = input.active && !focusedHostedInputs.has(name);
|
|
857
|
+
if (input.active) {
|
|
858
|
+
focusedHostedInputs.add(name);
|
|
859
|
+
}
|
|
860
|
+
else {
|
|
861
|
+
focusedHostedInputs.delete(name);
|
|
986
862
|
}
|
|
863
|
+
// Primer sends a change on focus before any keystroke; like Stripe, the error the buyer
|
|
864
|
+
// came back to fix stays visible until they start typing.
|
|
865
|
+
if (justFocused) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
dispatchError(name, getVisibleHostedInputError(input));
|
|
987
869
|
};
|
|
988
870
|
const cardHolderOnChange = async (e) => {
|
|
989
871
|
pmManager.setCardholderName(e.target.value);
|
|
@@ -1242,29 +1124,34 @@ class PrimerWrapper {
|
|
|
1242
1124
|
PrimerWrapper.headlessManager = new HeadlessManager();
|
|
1243
1125
|
|
|
1244
1126
|
/**
|
|
1245
|
-
* @fileoverview
|
|
1246
|
-
* be measured without adding a single extra network call.
|
|
1247
|
-
*
|
|
1248
|
-
* The backend already logs every request header, so `X-FF-Warmup` lands in
|
|
1249
|
-
* Elasticsearch for free. That gives us, per session actually created:
|
|
1250
|
-
* - which signal started it (dwell vs interaction vs a cold tap)
|
|
1251
|
-
* - how long after mount it fired
|
|
1252
|
-
*
|
|
1253
|
-
* Which is what tells us whether the dwell can be lengthened (more savings)
|
|
1254
|
-
* or must stay short (cold-tap risk), instead of guessing at an interaction
|
|
1255
|
-
* rate nobody has measured.
|
|
1127
|
+
* @fileoverview Normalization of payment failure errors
|
|
1256
1128
|
*/
|
|
1257
|
-
const
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1129
|
+
const STRIPE_CARD_ERROR = 'card_error';
|
|
1130
|
+
function declineCodeFromStripeError(error) {
|
|
1131
|
+
const { type, code } = error;
|
|
1132
|
+
if (type !== STRIPE_CARD_ERROR || typeof code !== 'string' || !code) {
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1135
|
+
return code.toUpperCase();
|
|
1262
1136
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1137
|
+
function toPaymentFailure(error) {
|
|
1138
|
+
const failure = new Error(error?.message || 'Payment processing failed');
|
|
1139
|
+
if (error instanceof APIError) {
|
|
1140
|
+
if (error.declineCode) {
|
|
1141
|
+
failure.declineCode = error.declineCode;
|
|
1142
|
+
}
|
|
1143
|
+
if (error.declineCodeSource) {
|
|
1144
|
+
failure.declineCodeSource = error.declineCodeSource;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
else if (error instanceof Error) {
|
|
1148
|
+
const declineCode = declineCodeFromStripeError(error);
|
|
1149
|
+
if (declineCode) {
|
|
1150
|
+
failure.declineCode = declineCode;
|
|
1151
|
+
failure.declineCodeSource = 'stripe';
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
return failure;
|
|
1268
1155
|
}
|
|
1269
1156
|
|
|
1270
1157
|
/**
|
|
@@ -1284,7 +1171,6 @@ class APIClient {
|
|
|
1284
1171
|
headers: {
|
|
1285
1172
|
'Content-Type': 'application/json',
|
|
1286
1173
|
'X-SDK-Version': SDK_VERSION,
|
|
1287
|
-
...warmupHeader(),
|
|
1288
1174
|
...(options.headers || {}),
|
|
1289
1175
|
},
|
|
1290
1176
|
...options,
|
|
@@ -1465,7 +1351,11 @@ class APIClient {
|
|
|
1465
1351
|
status: 'succeeded',
|
|
1466
1352
|
};
|
|
1467
1353
|
case 'failed':
|
|
1468
|
-
throw new APIError(data.failed_message_for_user || 'Payment failed', null, {
|
|
1354
|
+
throw new APIError(data.failed_message_for_user || 'Payment failed', null, {
|
|
1355
|
+
response,
|
|
1356
|
+
declineCode: data.decline_code,
|
|
1357
|
+
declineCodeSource: data.decline_code_source,
|
|
1358
|
+
});
|
|
1469
1359
|
case 'cancelled':
|
|
1470
1360
|
throw new APIError('Payment was cancelled by user', null, {
|
|
1471
1361
|
response,
|
|
@@ -1499,10 +1389,6 @@ class APIClient {
|
|
|
1499
1389
|
}));
|
|
1500
1390
|
}
|
|
1501
1391
|
}
|
|
1502
|
-
function warmupHeader() {
|
|
1503
|
-
const tag = takeWarmupTag();
|
|
1504
|
-
return tag ? { [WARMUP_HEADER]: tag } : {};
|
|
1505
|
-
}
|
|
1506
1392
|
|
|
1507
1393
|
class SessionService {
|
|
1508
1394
|
constructor() {
|
|
@@ -1585,96 +1471,6 @@ const hideLoader = () => {
|
|
|
1585
1471
|
}
|
|
1586
1472
|
};
|
|
1587
1473
|
|
|
1588
|
-
/**
|
|
1589
|
-
* @fileoverview Decides when a deferred client session should be created.
|
|
1590
|
-
*
|
|
1591
|
-
* Fires on whichever comes first: the visitor's first interaction, or a short
|
|
1592
|
-
* dwell once the page is actually visible. The dwell is what makes the
|
|
1593
|
-
* deferral safe — ApplePaySession must be constructed inside the tap gesture,
|
|
1594
|
-
* so the session has to exist *before* the first tap. A human cannot read a
|
|
1595
|
-
* price and tap in under ~800ms, whereas prerenders, background tabs, crawlers
|
|
1596
|
-
* and instant bounces never reach the dwell at all and so never cost a
|
|
1597
|
-
* session (nor the order row behind it).
|
|
1598
|
-
*/
|
|
1599
|
-
const INTERACTION_EVENTS = [
|
|
1600
|
-
'pointerdown',
|
|
1601
|
-
'touchstart',
|
|
1602
|
-
'keydown',
|
|
1603
|
-
'wheel',
|
|
1604
|
-
'scroll',
|
|
1605
|
-
];
|
|
1606
|
-
/** Long enough to filter prerenders and instant bounces, far shorter than
|
|
1607
|
-
* any realistic time-to-tap on a paywall. */
|
|
1608
|
-
const DEFAULT_WARMUP_DWELL_MS = 800;
|
|
1609
|
-
/** True while the page is prerendering or not visible to the user. */
|
|
1610
|
-
function isPageHidden() {
|
|
1611
|
-
if (typeof document === 'undefined') {
|
|
1612
|
-
return true;
|
|
1613
|
-
}
|
|
1614
|
-
if (document.prerendering) {
|
|
1615
|
-
return true;
|
|
1616
|
-
}
|
|
1617
|
-
return document.visibilityState === 'hidden';
|
|
1618
|
-
}
|
|
1619
|
-
/**
|
|
1620
|
-
* Invokes `handler` once — on first interaction, or after `dwellMs` of visible
|
|
1621
|
-
* time, whichever happens first. Returns a cleanup for the un-fired case.
|
|
1622
|
-
*/
|
|
1623
|
-
function onSessionWarmup(handler, dwellMs = DEFAULT_WARMUP_DWELL_MS) {
|
|
1624
|
-
if (typeof document === 'undefined') {
|
|
1625
|
-
return () => { };
|
|
1626
|
-
}
|
|
1627
|
-
let fired = false;
|
|
1628
|
-
let timer;
|
|
1629
|
-
const cleanup = () => {
|
|
1630
|
-
if (timer !== undefined) {
|
|
1631
|
-
clearTimeout(timer);
|
|
1632
|
-
timer = undefined;
|
|
1633
|
-
}
|
|
1634
|
-
for (const event of INTERACTION_EVENTS) {
|
|
1635
|
-
document.removeEventListener(event, onInteraction, true);
|
|
1636
|
-
}
|
|
1637
|
-
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
1638
|
-
document.removeEventListener('prerenderingchange', onVisibilityChange);
|
|
1639
|
-
};
|
|
1640
|
-
const fire = (source) => {
|
|
1641
|
-
if (fired) {
|
|
1642
|
-
return;
|
|
1643
|
-
}
|
|
1644
|
-
fired = true;
|
|
1645
|
-
cleanup();
|
|
1646
|
-
handler(source);
|
|
1647
|
-
};
|
|
1648
|
-
function onInteraction() {
|
|
1649
|
-
if (!isPageHidden()) {
|
|
1650
|
-
fire('interaction');
|
|
1651
|
-
}
|
|
1652
|
-
}
|
|
1653
|
-
function startDwell() {
|
|
1654
|
-
if (fired || timer !== undefined) {
|
|
1655
|
-
return;
|
|
1656
|
-
}
|
|
1657
|
-
timer = setTimeout(() => fire('dwell'), dwellMs);
|
|
1658
|
-
}
|
|
1659
|
-
function onVisibilityChange() {
|
|
1660
|
-
if (!isPageHidden()) {
|
|
1661
|
-
startDwell();
|
|
1662
|
-
}
|
|
1663
|
-
}
|
|
1664
|
-
for (const event of INTERACTION_EVENTS) {
|
|
1665
|
-
document.addEventListener(event, onInteraction, {
|
|
1666
|
-
capture: true,
|
|
1667
|
-
passive: true,
|
|
1668
|
-
});
|
|
1669
|
-
}
|
|
1670
|
-
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
1671
|
-
document.addEventListener('prerenderingchange', onVisibilityChange);
|
|
1672
|
-
if (!isPageHidden()) {
|
|
1673
|
-
startDwell();
|
|
1674
|
-
}
|
|
1675
|
-
return cleanup;
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
1474
|
var V3_URL = 'https://js.stripe.com/v3';
|
|
1679
1475
|
var V3_URL_REGEX = /^https:\/\/js\.stripe\.com\/v3\/?(\?.*)?$/;
|
|
1680
1476
|
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';
|
|
@@ -2160,8 +1956,8 @@ class CheckoutInstance extends EventEmitter {
|
|
|
2160
1956
|
}
|
|
2161
1957
|
return {
|
|
2162
1958
|
amountTotal: data.amount_total,
|
|
2163
|
-
taxAmount: data.tax_amount ?? 0,
|
|
2164
1959
|
currency: data.currency ?? '',
|
|
1960
|
+
...resolveTaxDisplay(data),
|
|
2165
1961
|
};
|
|
2166
1962
|
};
|
|
2167
1963
|
this.emitTaxInfo = (info) => {
|
|
@@ -2194,8 +1990,8 @@ class CheckoutInstance extends EventEmitter {
|
|
|
2194
1990
|
}
|
|
2195
1991
|
this.emitTaxInfo({
|
|
2196
1992
|
amountTotal: tax.amount_total,
|
|
2197
|
-
taxAmount: tax.tax_amount,
|
|
2198
1993
|
currency: tax.currency,
|
|
1994
|
+
...resolveTaxDisplay(tax),
|
|
2199
1995
|
});
|
|
2200
1996
|
}
|
|
2201
1997
|
catch (err) {
|
|
@@ -2214,6 +2010,7 @@ class CheckoutInstance extends EventEmitter {
|
|
|
2214
2010
|
if (this.taxRecalcDebounce) {
|
|
2215
2011
|
clearTimeout(this.taxRecalcDebounce);
|
|
2216
2012
|
}
|
|
2013
|
+
this.checkoutConfig.onTaxPending?.();
|
|
2217
2014
|
this.emit(EVENTS.TAX_PENDING);
|
|
2218
2015
|
this.taxRecalcDebounce = setTimeout(this.runTaxRecalc, TAX_RECALC_DEBOUNCE_MS);
|
|
2219
2016
|
};
|
|
@@ -2271,8 +2068,8 @@ class CheckoutInstance extends EventEmitter {
|
|
|
2271
2068
|
}
|
|
2272
2069
|
catch (error) {
|
|
2273
2070
|
this._setState('error');
|
|
2274
|
-
this.emit(EVENTS.PURCHASE_FAILURE,
|
|
2275
|
-
primerHandler.handleFailure(error
|
|
2071
|
+
this.emit(EVENTS.PURCHASE_FAILURE, toPaymentFailure(error));
|
|
2072
|
+
primerHandler.handleFailure(error?.message || 'Payment processing failed');
|
|
2276
2073
|
}
|
|
2277
2074
|
finally {
|
|
2278
2075
|
this.onLoaderChangeWithRace(false);
|
|
@@ -2296,8 +2093,8 @@ class CheckoutInstance extends EventEmitter {
|
|
|
2296
2093
|
}
|
|
2297
2094
|
catch (error) {
|
|
2298
2095
|
this._setState('error');
|
|
2299
|
-
this.emit(EVENTS.PURCHASE_FAILURE,
|
|
2300
|
-
primerHandler.handleFailure(error
|
|
2096
|
+
this.emit(EVENTS.PURCHASE_FAILURE, toPaymentFailure(error));
|
|
2097
|
+
primerHandler.handleFailure(error?.message || 'Payment processing failed');
|
|
2301
2098
|
}
|
|
2302
2099
|
finally {
|
|
2303
2100
|
this.emit(EVENTS.PURCHASE_COMPLETED);
|
|
@@ -2832,98 +2629,6 @@ class CheckoutInstance extends EventEmitter {
|
|
|
2832
2629
|
}
|
|
2833
2630
|
async initMethod(method, element, callbacks) {
|
|
2834
2631
|
this._ensureNotDestroyed();
|
|
2835
|
-
const deferred = await this.initMethodDeferred(method, element, callbacks);
|
|
2836
|
-
if (deferred) {
|
|
2837
|
-
return deferred;
|
|
2838
|
-
}
|
|
2839
|
-
return await this.initMethodNow(method, element, callbacks);
|
|
2840
|
-
}
|
|
2841
|
-
/**
|
|
2842
|
-
* Renders Apple's native button immediately and postpones session creation
|
|
2843
|
-
* until the visitor interacts. Returns null when deferral does not apply, so
|
|
2844
|
-
* the caller falls back to creating the session on mount.
|
|
2845
|
-
*/
|
|
2846
|
-
async initMethodDeferred(method, element, callbacks) {
|
|
2847
|
-
const applePay = this.checkoutConfig.applePay;
|
|
2848
|
-
const buttonType = applePay?.buttonType;
|
|
2849
|
-
if (this.checkoutConfig.sessionCreation !== 'onInteraction' ||
|
|
2850
|
-
method !== exports.PaymentMethod.APPLE_PAY ||
|
|
2851
|
-
this.isReady() ||
|
|
2852
|
-
!buttonType ||
|
|
2853
|
-
!isNativeOnlyApplePayButtonType(buttonType)) {
|
|
2854
|
-
return null;
|
|
2855
|
-
}
|
|
2856
|
-
let started = null;
|
|
2857
|
-
let real = null;
|
|
2858
|
-
let disabled = false;
|
|
2859
|
-
let stopWaiting = () => { };
|
|
2860
|
-
let placeholder = null;
|
|
2861
|
-
const mountedAt = Date.now();
|
|
2862
|
-
const start = (trigger) => {
|
|
2863
|
-
const fromTap = trigger === 'tap';
|
|
2864
|
-
if (!started) {
|
|
2865
|
-
setWarmupTag(trigger, Date.now() - mountedAt);
|
|
2866
|
-
started = (async () => {
|
|
2867
|
-
stopWaiting();
|
|
2868
|
-
if (fromTap) {
|
|
2869
|
-
callbacks.onLoaderChange?.(true);
|
|
2870
|
-
}
|
|
2871
|
-
try {
|
|
2872
|
-
placeholder?.remove();
|
|
2873
|
-
placeholder = null;
|
|
2874
|
-
const methodInterface = await this.initMethodNow(method, element, callbacks);
|
|
2875
|
-
real = methodInterface;
|
|
2876
|
-
if (disabled) {
|
|
2877
|
-
methodInterface.setDisabled(true);
|
|
2878
|
-
}
|
|
2879
|
-
return methodInterface;
|
|
2880
|
-
}
|
|
2881
|
-
finally {
|
|
2882
|
-
if (fromTap) {
|
|
2883
|
-
callbacks.onLoaderChange?.(false);
|
|
2884
|
-
}
|
|
2885
|
-
}
|
|
2886
|
-
})();
|
|
2887
|
-
}
|
|
2888
|
-
return started;
|
|
2889
|
-
};
|
|
2890
|
-
placeholder = await renderPlaceholderApplePayButton(element, {
|
|
2891
|
-
buttonType,
|
|
2892
|
-
buttonStyle: applePay?.buttonStyle,
|
|
2893
|
-
// Cold tap: the visitor's first interaction is the button itself, so the
|
|
2894
|
-
// session cannot exist yet and ApplePaySession would lose the gesture.
|
|
2895
|
-
// Start now and let the loader cover the (rare) second tap.
|
|
2896
|
-
onActivate: () => void start('tap'),
|
|
2897
|
-
});
|
|
2898
|
-
if (!placeholder) {
|
|
2899
|
-
return null;
|
|
2900
|
-
}
|
|
2901
|
-
// Free head start: Primer's SDK is a static asset on their CDN, so
|
|
2902
|
-
// fetching it now costs no backend call and leaves only the session
|
|
2903
|
-
// request to complete before the first tap.
|
|
2904
|
-
void loadPrimerSDK().catch(() => { });
|
|
2905
|
-
stopWaiting = onSessionWarmup(trigger => void start(trigger));
|
|
2906
|
-
return {
|
|
2907
|
-
setDisabled: (value) => {
|
|
2908
|
-
disabled = value;
|
|
2909
|
-
real?.setDisabled(value);
|
|
2910
|
-
if (placeholder) {
|
|
2911
|
-
placeholder.style.pointerEvents = value ? 'none' : '';
|
|
2912
|
-
placeholder.style.opacity = value ? '0.5' : '';
|
|
2913
|
-
}
|
|
2914
|
-
},
|
|
2915
|
-
destroy: async () => {
|
|
2916
|
-
stopWaiting();
|
|
2917
|
-
placeholder?.remove();
|
|
2918
|
-
placeholder = null;
|
|
2919
|
-
if (started) {
|
|
2920
|
-
await (await started).destroy?.();
|
|
2921
|
-
}
|
|
2922
|
-
await this.destroy();
|
|
2923
|
-
},
|
|
2924
|
-
};
|
|
2925
|
-
}
|
|
2926
|
-
async initMethodNow(method, element, callbacks) {
|
|
2927
2632
|
if (!this.isReady()) {
|
|
2928
2633
|
await this.createSession();
|
|
2929
2634
|
}
|
|
@@ -2978,6 +2683,9 @@ class CheckoutInstance extends EventEmitter {
|
|
|
2978
2683
|
await this.primerWrapper.initializeHeadlessCheckout(this.clientToken, checkoutOptions, method);
|
|
2979
2684
|
const methodInterface = await this.primerWrapper.initMethod(method, element, methodOptions);
|
|
2980
2685
|
this.startUnhandledTelemetry(method);
|
|
2686
|
+
if (this.isTaxEnabled() && !this.lastTaxInfo) {
|
|
2687
|
+
this.emitSessionTaxEstimate();
|
|
2688
|
+
}
|
|
2981
2689
|
return {
|
|
2982
2690
|
...methodInterface,
|
|
2983
2691
|
destroy: async () => {
|
|
@@ -3141,7 +2849,9 @@ async function initMethod(method, element, options) {
|
|
|
3141
2849
|
...DEFAULT_BUTTONS_OPTIONS[exports.PaymentMethod.GOOGLE_PAY],
|
|
3142
2850
|
...(options.googlePay || {}),
|
|
3143
2851
|
},
|
|
3144
|
-
|
|
2852
|
+
onTaxChange: options.onTaxChange,
|
|
2853
|
+
onTaxError: options.onTaxError,
|
|
2854
|
+
onTaxPending: options.onTaxPending,
|
|
3145
2855
|
},
|
|
3146
2856
|
});
|
|
3147
2857
|
return await checkoutInstance.initMethod(method, element, {
|
|
@@ -3337,3 +3047,5 @@ exports.createCheckout = createCheckout;
|
|
|
3337
3047
|
exports.createClientSession = createClientSession;
|
|
3338
3048
|
exports.getAvailablePaymentMethods = getAvailablePaymentMethods;
|
|
3339
3049
|
exports.loadStripe = loadStripe;
|
|
3050
|
+
exports.resolveTaxDisplay = resolveTaxDisplay;
|
|
3051
|
+
exports.toPaymentFailure = toPaymentFailure;
|