@salla.sa/applepay 3.0.0-alpha.0 → 3.0.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/deploy.json +8 -0
- package/package.json +3 -3
- package/src/DetectOS.js +77 -68
- package/src/apple-pay-support.d.ts +24 -0
- package/src/apple-pay-support.js +187 -0
- package/src/http.js +8 -0
- package/src/index.js +818 -77
- package/src/utils.js +119 -25
package/src/index.js
CHANGED
|
@@ -1,9 +1,26 @@
|
|
|
1
|
-
import
|
|
1
|
+
import http from './http';
|
|
2
2
|
import DetectOS from './DetectOS';
|
|
3
|
-
import {
|
|
3
|
+
import { mutateShippingContact } from './utils';
|
|
4
|
+
import { resolveApplePayNetworks } from './apple-pay-support';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
// SSR-safe: only access window in browser environment
|
|
7
|
+
if (typeof window !== 'undefined') {
|
|
8
|
+
window.Salla = window.Salla || {};
|
|
9
|
+
window.Salla.Payments = window.Salla.Payments || {};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Apple Pay method identifier for the W3C Payment Request API.
|
|
13
|
+
const APPLE_PAY_METHOD = 'https://apple.com/apple-pay';
|
|
14
|
+
|
|
15
|
+
// Captured at module load, before the Apple Pay SDK is injected: real Safari /
|
|
16
|
+
// Apple devices expose `ApplePaySession` natively, whereas non-Safari browsers
|
|
17
|
+
// only get it once the SDK loads. Keeps native-vs-PaymentRequest routing stable.
|
|
18
|
+
const HAS_NATIVE_APPLE_PAY = typeof window !== 'undefined' && !!window.ApplePaySession;
|
|
19
|
+
|
|
20
|
+
// Shown when the backend offers no usable network. twilight can ship ahead of Languages-Module,
|
|
21
|
+
// so fall through to `payment_failed` while the key is missing — never a hardcoded sentence.
|
|
22
|
+
const NO_SUPPORTED_NETWORK_KEY = 'pages.checkout.apple_pay_no_supported_network';
|
|
23
|
+
const NO_SUPPORTED_NETWORK_FALLBACK_KEY = 'pages.checkout.payment_failed';
|
|
7
24
|
|
|
8
25
|
/**
|
|
9
26
|
* Full Example
|
|
@@ -35,7 +52,9 @@ window.Salla.Payments = window.Salla.Payments || {};
|
|
|
35
52
|
* // }
|
|
36
53
|
* });
|
|
37
54
|
*/
|
|
38
|
-
|
|
55
|
+
|
|
56
|
+
// SSR-safe: define SallaApplePay as a stub during SSR, actual object in browser
|
|
57
|
+
const SallaApplePay = typeof window !== 'undefined' ? {
|
|
39
58
|
session: null,
|
|
40
59
|
detail: null,
|
|
41
60
|
address_id: null,
|
|
@@ -43,6 +62,14 @@ window.SallaApplePay = {
|
|
|
43
62
|
total: undefined,
|
|
44
63
|
request: undefined,
|
|
45
64
|
id: undefined,
|
|
65
|
+
countryCode: null,
|
|
66
|
+
totals: [],
|
|
67
|
+
shippingCompany: null,
|
|
68
|
+
// Set true once onCancel has fired for the current transaction, so the
|
|
69
|
+
// QR (PaymentRequest) dismissal catch can notify on a plain sheet-close
|
|
70
|
+
// without double-firing when a prior handler (e.g. merchant validation)
|
|
71
|
+
// already routed through onCancel. Reset at the start of each transaction.
|
|
72
|
+
canceled: false,
|
|
46
73
|
init: function () {
|
|
47
74
|
document.removeEventListener('payments::apple-pay.start-transaction', SallaApplePay.startSession);
|
|
48
75
|
Salla.event.addEventListener('payments::apple-pay.start-transaction', SallaApplePay.startSession);
|
|
@@ -66,7 +93,7 @@ window.SallaApplePay = {
|
|
|
66
93
|
if (!SallaApplePay.detail.validateMerchant.onFailed) {
|
|
67
94
|
SallaApplePay.detail.validateMerchant.onFailed = (response) => {
|
|
68
95
|
salla.logger.log(JSON.stringify(response))
|
|
69
|
-
salla.api.handleErrorResponse(response);
|
|
96
|
+
salla.api.handleErrorResponse({response});
|
|
70
97
|
SallaApplePay.onCancel({}, response.data.error.message);
|
|
71
98
|
};
|
|
72
99
|
}
|
|
@@ -80,11 +107,11 @@ window.SallaApplePay = {
|
|
|
80
107
|
},
|
|
81
108
|
|
|
82
109
|
prepareLineItems: function () {
|
|
83
|
-
if(!SallaApplePay.detail?.items?.length){
|
|
110
|
+
if (!SallaApplePay.detail?.items?.length) {
|
|
84
111
|
SallaApplePay.detail.items = [
|
|
85
112
|
{
|
|
86
113
|
label: salla.lang.get('pages.cart.items_total'),
|
|
87
|
-
amount: SallaApplePay.detail.amount
|
|
114
|
+
amount: parseFloat(SallaApplePay.detail.amount).toString()
|
|
88
115
|
}
|
|
89
116
|
]
|
|
90
117
|
}
|
|
@@ -93,31 +120,87 @@ window.SallaApplePay = {
|
|
|
93
120
|
},
|
|
94
121
|
|
|
95
122
|
prepareTotal: function () {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
123
|
+
return {
|
|
124
|
+
// apple ask to use business name
|
|
125
|
+
label: window.location.hostname || 'Salla',
|
|
126
|
+
//label: salla.lang.get('pages.cart.final_total'),
|
|
127
|
+
amount: parseFloat(SallaApplePay.detail.amount).toString()
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
isPhysical() {
|
|
132
|
+
return SallaApplePay.detail?.requiredShippingContactFields?.includes('postalAddress');
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Whether the native Apple Pay flow (Safari / Apple devices) can run. Gated
|
|
137
|
+
* on the module-load capture so the SDK loading later can't flip a
|
|
138
|
+
* non-Safari browser into the native path.
|
|
139
|
+
*/
|
|
140
|
+
canUseNative: function () {
|
|
141
|
+
if (!HAS_NATIVE_APPLE_PAY) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
return ApplePaySession.canMakePayments();
|
|
146
|
+
} catch (e) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
// Sourced from config (via the transaction detail). No fallback: a missing
|
|
152
|
+
// identifier means Apple Pay isn't configured for this store, and callers
|
|
153
|
+
// treat that null as the guard to skip Apple Pay.
|
|
154
|
+
getMerchantIdentifier: function () {
|
|
155
|
+
return (SallaApplePay.detail && SallaApplePay.detail.merchantIdentifier) || null;
|
|
102
156
|
},
|
|
103
157
|
|
|
104
158
|
startSession: async function (event) {
|
|
105
159
|
|
|
106
160
|
SallaApplePay.detail = event.detail || event;
|
|
107
161
|
|
|
162
|
+
// Fresh transaction → clear any cancel flag from a previous attempt.
|
|
163
|
+
SallaApplePay.canceled = false;
|
|
164
|
+
|
|
165
|
+
// Analytics latches — one Submitted and one Succeeded per attempt, and
|
|
166
|
+
// at most one Failed, however many callbacks fire.
|
|
167
|
+
SallaApplePay._submittedTracked = false;
|
|
168
|
+
SallaApplePay._failureTracked = false;
|
|
169
|
+
SallaApplePay._succeededTracked = false;
|
|
170
|
+
|
|
171
|
+
// The cart belongs to the attempt, not to the singleton. The mini embed
|
|
172
|
+
// supplies one up front; on the PDP it stays null until checkout/mini
|
|
173
|
+
// answers during merchant validation. Without this a second attempt
|
|
174
|
+
// reports — and submits to — the previous attempt's cart.
|
|
175
|
+
SallaApplePay.id = SallaApplePay.detail?.cartId || null;
|
|
176
|
+
|
|
108
177
|
salla.log('🍏 Pay: payments::apple-pay.start-transaction', SallaApplePay.detail);
|
|
109
178
|
|
|
110
179
|
SallaApplePay.initDefault();
|
|
111
180
|
|
|
181
|
+
// Native (Safari / Apple devices) → classic ApplePaySession. Otherwise
|
|
182
|
+
// (non-Safari / non-Apple device) → Payment Request API, which renders
|
|
183
|
+
// Apple's "Scan Code with iPhone" QR sheet.
|
|
184
|
+
if (!SallaApplePay.canUseNative()) {
|
|
185
|
+
return SallaApplePay.startPaymentRequestSession(event);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return SallaApplePay.startNativeSession(event);
|
|
189
|
+
},
|
|
190
|
+
|
|
191
|
+
startNativeSession: async function (event) {
|
|
192
|
+
|
|
112
193
|
let version = SallaApplePay.getApplePaySessionVersion();
|
|
113
|
-
|
|
194
|
+
// The backend resolves this per shopper, so it goes to Apple verbatim. Adding mada back
|
|
195
|
+
// here is what let a shopper authorize with Face ID and only then be refused.
|
|
196
|
+
let supportedNetworks = resolveApplePayNetworks(SallaApplePay.detail.supportedNetworks);
|
|
114
197
|
|
|
115
|
-
if (
|
|
116
|
-
|
|
198
|
+
if (!supportedNetworks.length) {
|
|
199
|
+
return SallaApplePay.abandonNoSupportedNetwork();
|
|
117
200
|
}
|
|
118
201
|
|
|
119
202
|
SallaApplePay.request = {
|
|
120
|
-
countryCode: 'SA',
|
|
203
|
+
countryCode: SallaApplePay.detail.countryCode || 'SA',
|
|
121
204
|
supportsCouponCode: true,
|
|
122
205
|
couponCode: '',
|
|
123
206
|
currencyCode: SallaApplePay.detail.currency || 'SAR',
|
|
@@ -141,30 +224,79 @@ window.SallaApplePay = {
|
|
|
141
224
|
SallaApplePay.session.onvalidatemerchant = SallaApplePay.onValidateMerchant;
|
|
142
225
|
SallaApplePay.session.onpaymentauthorized = SallaApplePay.onPaymentAuthorized;
|
|
143
226
|
SallaApplePay.session.oncancel = SallaApplePay.onCancel;
|
|
144
|
-
SallaApplePay.session.oncouponcodechanged = SallaApplePay.
|
|
145
|
-
SallaApplePay.session.onpaymentmethodselected = SallaApplePay.
|
|
146
|
-
|
|
147
|
-
|
|
227
|
+
SallaApplePay.session.oncouponcodechanged = SallaApplePay.onCouponCodeChanged;
|
|
228
|
+
SallaApplePay.session.onpaymentmethodselected = SallaApplePay.onPaymentMethodSelected;
|
|
229
|
+
|
|
230
|
+
// Hook between session creation and begin(). Return truthy to skip
|
|
231
|
+
// begin() (caller handles it later, e.g. after async work).
|
|
232
|
+
let skipBegin;
|
|
233
|
+
if (typeof SallaApplePay.detail.onStartedSession === 'function') {
|
|
234
|
+
skipBegin = SallaApplePay.detail.onStartedSession(SallaApplePay.session);
|
|
235
|
+
}
|
|
236
|
+
if (!skipBegin) {
|
|
237
|
+
try {
|
|
238
|
+
SallaApplePay.session.begin();
|
|
239
|
+
} catch (error) {
|
|
240
|
+
salla.logger.error('🍏 Pay: session.begin() failed', error);
|
|
241
|
+
SallaApplePay.trackPaymentFailed('client', 'APPLE_PAY_PRESENTATION_FAILED', error?.message || '');
|
|
242
|
+
SallaApplePay.detail.onError(salla.lang.get('pages.checkout.payment_failed'));
|
|
243
|
+
Salla.event.createAndDispatch('payments::apple-pay.canceled', {});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// skipBegin path: the caller begins later — onValidateMerchant fires
|
|
247
|
+
// once the sheet actually opens and emits the (latched) Submitted.
|
|
148
248
|
},
|
|
149
|
-
async
|
|
150
|
-
|
|
151
|
-
|
|
249
|
+
onPaymentMethodSelected: async (event) => {
|
|
250
|
+
salla.logger.log('🍏 Pay: onPaymentMethodSelected', event);
|
|
251
|
+
|
|
252
|
+
// perform recalculate here only if digital product
|
|
253
|
+
// if physical recalculate performed with shipping company
|
|
254
|
+
if (!SallaApplePay.isPhysical()) {
|
|
255
|
+
try {
|
|
256
|
+
await SallaApplePay.recalculateTotal();
|
|
257
|
+
|
|
258
|
+
const updatedPaymentDetails = {
|
|
259
|
+
newTotal: SallaApplePay.prepareTotal(),
|
|
260
|
+
newLineItems: SallaApplePay.prepareLineItems()
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
salla.logger.log('🍏 Pay: completePaymentMethodSelection', updatedPaymentDetails);
|
|
264
|
+
|
|
265
|
+
SallaApplePay.session.completePaymentMethodSelection(updatedPaymentDetails);
|
|
266
|
+
|
|
267
|
+
} catch (error) {
|
|
268
|
+
salla.logger.warn('🍏 Pay: Failed recalculate total', error);
|
|
269
|
+
|
|
270
|
+
SallaApplePay.session.completePaymentMethodSelection({
|
|
271
|
+
newTotal: SallaApplePay.prepareTotal(),
|
|
272
|
+
newLineItems: SallaApplePay.prepareLineItems(),
|
|
273
|
+
status: SallaApplePay.session.STATUS_FAILURE,
|
|
274
|
+
errors: [new window.ApplePayError("unknown", undefined, error?.response?.data?.error?.message || error?.response?.data?.error?.code || 'Failed to recalculate total')]
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
salla.logger.log('🍏 Pay: completePaymentMethodSelection', {
|
|
152
281
|
newTotal: SallaApplePay.prepareTotal(),
|
|
153
282
|
newLineItems: SallaApplePay.prepareLineItems(),
|
|
154
|
-
};
|
|
283
|
+
});
|
|
155
284
|
|
|
156
|
-
SallaApplePay.session.completePaymentMethodSelection(
|
|
285
|
+
SallaApplePay.session.completePaymentMethodSelection({
|
|
286
|
+
newTotal: SallaApplePay.prepareTotal(),
|
|
287
|
+
newLineItems: SallaApplePay.prepareLineItems(),
|
|
288
|
+
});
|
|
157
289
|
},
|
|
158
290
|
|
|
159
|
-
|
|
291
|
+
onCouponCodeChanged(event) {
|
|
160
292
|
Salla.event.dispatch('payments::apple-pay.coupon.change', event);
|
|
161
293
|
|
|
162
|
-
return
|
|
294
|
+
return http.post(SallaApplePay.detail.onCouponCodeChanged.url.replace('{id}', SallaApplePay.id), {
|
|
163
295
|
'coupon': event.couponCode,
|
|
164
296
|
'payment_method': 'apple_pay',
|
|
165
|
-
}, async ({data}) => {
|
|
166
|
-
if (typeof SallaApplePay.detail.
|
|
167
|
-
SallaApplePay.detail.
|
|
297
|
+
}, async ({ data }) => {
|
|
298
|
+
if (typeof SallaApplePay.detail.onCouponCodeChanged.onSuccess === 'function') {
|
|
299
|
+
SallaApplePay.detail.onCouponCodeChanged.onSuccess(data);
|
|
168
300
|
}
|
|
169
301
|
|
|
170
302
|
salla.log('🍏 Pay: Coupon applied success');
|
|
@@ -181,8 +313,8 @@ window.SallaApplePay = {
|
|
|
181
313
|
Salla.event.dispatch('payments::apple-pay.coupon.failed', response);
|
|
182
314
|
|
|
183
315
|
// SallaApplePay.abortSession();
|
|
184
|
-
if (typeof SallaApplePay.detail.
|
|
185
|
-
SallaApplePay.detail.
|
|
316
|
+
if (typeof SallaApplePay.detail.onCouponCodeChanged.onFailed === 'function') {
|
|
317
|
+
SallaApplePay.detail.onCouponCodeChanged.onFailed(response);
|
|
186
318
|
}
|
|
187
319
|
|
|
188
320
|
await SallaApplePay.recalculateTotal();
|
|
@@ -195,7 +327,29 @@ window.SallaApplePay = {
|
|
|
195
327
|
});
|
|
196
328
|
},
|
|
197
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Give up before the sheet opens: the backend left no usable network.
|
|
332
|
+
*
|
|
333
|
+
* Routed through onCancel so the host gets the same terminal signals as a dismissal and the
|
|
334
|
+
* button recovers. trackPaymentFailed self-gates on Payment Submitted, so the funnel is safe.
|
|
335
|
+
*/
|
|
336
|
+
abandonNoSupportedNetwork: () => {
|
|
337
|
+
salla.logger.error('🍏 Pay: no supported network for this shopper — Apple Pay not offered');
|
|
338
|
+
|
|
339
|
+
const message = salla?.lang?.get?.(NO_SUPPORTED_NETWORK_KEY);
|
|
340
|
+
const resolved = !message || message === NO_SUPPORTED_NETWORK_KEY
|
|
341
|
+
? salla?.lang?.get?.(NO_SUPPORTED_NETWORK_FALLBACK_KEY)
|
|
342
|
+
: message;
|
|
343
|
+
|
|
344
|
+
SallaApplePay.onCancel({}, resolved || null);
|
|
345
|
+
},
|
|
346
|
+
|
|
198
347
|
onCancel: (event = {}, message = null) => {
|
|
348
|
+
SallaApplePay.canceled = true;
|
|
349
|
+
// Sheet opened (Payment Submitted already fired) but the attempt ended
|
|
350
|
+
// without authorization — dismissal, abort, or validation failure.
|
|
351
|
+
// Latched: a server rejection tracked earlier absorbs this call.
|
|
352
|
+
SallaApplePay.trackPaymentFailed('client', 'APPLE_PAY_SESSION_CANCELED', message || '');
|
|
199
353
|
SallaApplePay.detail.onError(message || salla.lang.get('pages.checkout.payment_failed'));
|
|
200
354
|
Salla.event.createAndDispatch('payments::apple-pay.canceled', event);
|
|
201
355
|
},
|
|
@@ -208,35 +362,89 @@ window.SallaApplePay = {
|
|
|
208
362
|
onPaymentAuthorized: async (event) => {
|
|
209
363
|
salla.logger.log('🍏 Pay: onPaymentAuthorized', event.payment);
|
|
210
364
|
|
|
211
|
-
//
|
|
212
|
-
|
|
365
|
+
// update guest details
|
|
366
|
+
try {
|
|
367
|
+
await mutateShippingContact(SallaApplePay, event.payment.shippingContact, true);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
salla.logger.error('🍏 Pay: Failed to update guest contact details', error);
|
|
370
|
+
|
|
371
|
+
const response = error?.response || error;
|
|
372
|
+
|
|
373
|
+
// Parse backend errors for shipping contact fields
|
|
374
|
+
const fields = response?.data?.error?.fields || {};
|
|
375
|
+
const errors = [];
|
|
376
|
+
|
|
377
|
+
// Map backend field errors to Apple Pay contact field errors
|
|
378
|
+
if (fields?.email && fields.email.length > 0) {
|
|
379
|
+
errors.push(new window.ApplePayError('shippingContactInvalid', 'emailAddress', fields.email[0]));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (fields?.phone && fields.phone.length > 0) {
|
|
383
|
+
errors.push(new window.ApplePayError('shippingContactInvalid', 'phoneNumber', fields.phone[0]));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (fields?.first_name && fields.first_name.length > 0) {
|
|
387
|
+
errors.push(new window.ApplePayError('shippingContactInvalid', 'name', fields.first_name[0]));
|
|
388
|
+
} else if (fields?.last_name && fields.last_name.length > 0) {
|
|
389
|
+
errors.push(new window.ApplePayError('shippingContactInvalid', 'name', fields.last_name[0]));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// If no specific field errors, use general error message
|
|
393
|
+
if (errors.length === 0) {
|
|
394
|
+
const errorMessage = response?.data?.error?.message || response?.data?.error?.code || 'Invalid shipping contact details';
|
|
395
|
+
errors.push(new window.ApplePayError('shippingContactInvalid', 'name', errorMessage));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
Salla.event.dispatch('payments::apple-pay.authorized.failed', response);
|
|
399
|
+
|
|
400
|
+
if (typeof SallaApplePay.detail.authorized.onFailed === 'function') {
|
|
401
|
+
SallaApplePay.detail.authorized.onFailed(response);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Complete shipping contact selection with invalid contact status
|
|
405
|
+
SallaApplePay.session.completePayment({
|
|
406
|
+
status: ApplePaySession.STATUS_INVALID_SHIPPING_CONTACT,
|
|
407
|
+
errors: errors
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
213
412
|
|
|
214
413
|
Salla.event.dispatch('payments::apple-pay.authorized.init', event);
|
|
215
|
-
|
|
414
|
+
// The shopper has authorised with Face ID / Touch ID and the token is
|
|
415
|
+
// about to go to the gateway — this is the submission, not the tap.
|
|
416
|
+
SallaApplePay.trackPaymentSubmitted();
|
|
417
|
+
http.post(SallaApplePay.detail.authorized.url.replace('{id}', SallaApplePay.id), {
|
|
216
418
|
payment_method: 'apple_pay',
|
|
217
419
|
applepay_token: JSON.stringify(event.payment)
|
|
218
|
-
}, ({data}) => {
|
|
420
|
+
}, ({ data }) => {
|
|
219
421
|
Salla.event.dispatch('payments::apple-pay.authorized.success', data);
|
|
220
|
-
|
|
221
|
-
SallaApplePay.session.completePayment(ApplePaySession.STATUS_SUCCESS);
|
|
422
|
+
SallaApplePay.trackPaymentSucceeded(data);
|
|
222
423
|
|
|
223
424
|
if (typeof SallaApplePay.detail.authorized.onSuccess === 'function') {
|
|
224
425
|
SallaApplePay.detail.authorized.onSuccess(data);
|
|
225
426
|
}
|
|
427
|
+
|
|
428
|
+
SallaApplePay.session.completePayment(ApplePaySession.STATUS_SUCCESS);
|
|
226
429
|
}, (error) => {
|
|
227
430
|
|
|
228
431
|
let response = error?.response;
|
|
229
432
|
|
|
230
433
|
Salla.event.dispatch('payments::apple-pay.authorized.failed', response);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
434
|
+
// Backend rejected the token — latch set so the cancel callback
|
|
435
|
+
// following the failure UI can't emit a second Failed.
|
|
436
|
+
SallaApplePay.trackPaymentFailed('server',
|
|
437
|
+
response?.data?.error?.code || 'APPLE_PAY_FAILED',
|
|
438
|
+
response?.data?.error?.message || '');
|
|
236
439
|
|
|
237
440
|
if (typeof SallaApplePay.detail.authorized.onFailed === 'function') {
|
|
238
441
|
SallaApplePay.detail.authorized.onFailed(response);
|
|
239
442
|
}
|
|
443
|
+
|
|
444
|
+
SallaApplePay.session.completePayment({
|
|
445
|
+
status: ApplePaySession.STATUS_FAILURE,
|
|
446
|
+
errors: [new window.ApplePayError("unknown", undefined, response?.data?.error?.message || response?.data?.error?.code || 'Failed to parse authorized response')]
|
|
447
|
+
})
|
|
240
448
|
});
|
|
241
449
|
},
|
|
242
450
|
|
|
@@ -251,7 +459,7 @@ window.SallaApplePay = {
|
|
|
251
459
|
Salla.event.dispatch('payments::apple-pay.validate-merchant.init', event);
|
|
252
460
|
|
|
253
461
|
// Post request to validate merchant
|
|
254
|
-
const { data } = await
|
|
462
|
+
const { data } = await http.post(SallaApplePay.detail.validateMerchant.url.replace('{id}', SallaApplePay.id), {
|
|
255
463
|
validation_url: event.validationURL
|
|
256
464
|
});
|
|
257
465
|
|
|
@@ -300,7 +508,7 @@ window.SallaApplePay = {
|
|
|
300
508
|
salla.logger.log('🍏 Pay: onShippingContactSelected', event.shippingContact);
|
|
301
509
|
|
|
302
510
|
// create address for shipping calculation
|
|
303
|
-
|
|
511
|
+
await mutateShippingContact(SallaApplePay, event.shippingContact);
|
|
304
512
|
},
|
|
305
513
|
|
|
306
514
|
/**
|
|
@@ -310,32 +518,536 @@ window.SallaApplePay = {
|
|
|
310
518
|
*
|
|
311
519
|
*/
|
|
312
520
|
onShippingMethodSelected: async (event) => {
|
|
313
|
-
salla.logger.log(event);
|
|
314
521
|
|
|
315
522
|
let shipping_ids = event.shippingMethod.identifier.split(',');
|
|
523
|
+
let shippingMethod = {
|
|
524
|
+
ship_id: shipping_ids[0],
|
|
525
|
+
private_ship_id: typeof shipping_ids[1] === 'undefined' ? null : shipping_ids[1],
|
|
526
|
+
type: typeof shipping_ids[2] === 'undefined' ? null : shipping_ids[2],
|
|
527
|
+
route_id: typeof shipping_ids[3] === 'undefined' ? null : shipping_ids[3],
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
salla.logger.log('🍏 Pay: onShippingMethodSelected', {
|
|
531
|
+
event,
|
|
532
|
+
previous: SallaApplePay.shippingCompany,
|
|
533
|
+
current: shippingMethod,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
if (SallaApplePay.shouldUpdateShippingCompany(shippingMethod)) {
|
|
537
|
+
try {
|
|
538
|
+
|
|
539
|
+
await SallaApplePay.selectApplePayShippingMethod(shippingMethod);
|
|
540
|
+
|
|
541
|
+
await SallaApplePay.recalculateTotal();
|
|
542
|
+
|
|
543
|
+
} catch (error) {
|
|
544
|
+
salla.logger.warn('🍏 Pay: Failed set the shipping details to api', error);
|
|
545
|
+
|
|
546
|
+
// todo :: find a better handling for error without abort session
|
|
547
|
+
SallaApplePay.session.completeShippingMethodSelection({
|
|
548
|
+
newTotal: SallaApplePay.prepareTotal(),
|
|
549
|
+
newLineItems: SallaApplePay.prepareLineItems(),
|
|
550
|
+
status: SallaApplePay.session.STATUS_INVALID_SHIPPING_POSTAL_ADDRESS,
|
|
551
|
+
errors: [
|
|
552
|
+
new window.ApplePayError('addressUnserviceable')
|
|
553
|
+
]
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
salla.logger.log('🍏 Pay: completeShippingMethodSelection', {
|
|
561
|
+
newTotal: SallaApplePay.prepareTotal(),
|
|
562
|
+
newLineItems: SallaApplePay.prepareLineItems(),
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
SallaApplePay.session.completeShippingMethodSelection({
|
|
566
|
+
newTotal: SallaApplePay.prepareTotal(),
|
|
567
|
+
newLineItems: SallaApplePay.prepareLineItems(),
|
|
568
|
+
});
|
|
569
|
+
},
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
abortSession: () => {
|
|
573
|
+
if (SallaApplePay.session) {
|
|
574
|
+
SallaApplePay.session.abort();
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
|
|
578
|
+
/* -------------------------------------------------------------------------
|
|
579
|
+
* Payment Request API flow (non-Safari browsers / non-Apple devices).
|
|
580
|
+
* Apple renders the "Scan Code with iPhone" QR sheet. Reuses the same
|
|
581
|
+
* backend endpoints (validate / address / shipping / recalculate / submit)
|
|
582
|
+
* as the native flow; only the sheet-completion model differs
|
|
583
|
+
* (event.updateWith / response.complete instead of session.completeXxx).
|
|
584
|
+
* ---------------------------------------------------------------------- */
|
|
585
|
+
|
|
586
|
+
paymentRequest: null,
|
|
587
|
+
paymentResponse: null,
|
|
588
|
+
|
|
589
|
+
getCurrency: () => SallaApplePay.detail.currency || 'SAR',
|
|
590
|
+
|
|
591
|
+
prAmount: (value) => ({ currency: SallaApplePay.getCurrency(), value: String(value) }),
|
|
592
|
+
|
|
593
|
+
prTotal: () => {
|
|
594
|
+
const total = SallaApplePay.prepareTotal();
|
|
595
|
+
return { label: total.label, amount: SallaApplePay.prAmount(total.amount) };
|
|
596
|
+
},
|
|
597
|
+
|
|
598
|
+
prDisplayItems: () => SallaApplePay.prepareLineItems().map(item => ({
|
|
599
|
+
label: item.label,
|
|
600
|
+
amount: SallaApplePay.prAmount(item.amount),
|
|
601
|
+
})),
|
|
602
|
+
|
|
603
|
+
prShippingOptions: (selectedId = null) => {
|
|
604
|
+
const options = SallaApplePay.mappingShippingMethods(SallaApplePay.shipping_methods || []);
|
|
605
|
+
return options.map((option, index) => ({
|
|
606
|
+
id: option.identifier,
|
|
607
|
+
label: option.label,
|
|
608
|
+
amount: SallaApplePay.prAmount(option.amount),
|
|
609
|
+
selected: selectedId ? option.identifier === selectedId : index === 0,
|
|
610
|
+
}));
|
|
611
|
+
},
|
|
612
|
+
|
|
613
|
+
prContactOptions: () => {
|
|
614
|
+
const fields = SallaApplePay.detail.requiredShippingContactFields || [];
|
|
615
|
+
return {
|
|
616
|
+
requestPayerName: fields.includes('name'),
|
|
617
|
+
requestPayerEmail: fields.includes('email'),
|
|
618
|
+
requestPayerPhone: fields.includes('phone'),
|
|
619
|
+
requestShipping: fields.includes('postalAddress'),
|
|
620
|
+
shippingType: 'shipping',
|
|
621
|
+
};
|
|
622
|
+
},
|
|
623
|
+
|
|
624
|
+
startPaymentRequestSession: async function () {
|
|
625
|
+
// No merchant identifier in config → Apple Pay isn't set up for this
|
|
626
|
+
// store; bail rather than open a QR sheet that can't be validated.
|
|
627
|
+
const merchantIdentifier = SallaApplePay.getMerchantIdentifier();
|
|
628
|
+
if (!merchantIdentifier) {
|
|
629
|
+
salla.logger.error('🍏 Pay: missing merchant identifier — skipping QR Apple Pay');
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const version = SallaApplePay.getApplePaySessionVersion();
|
|
634
|
+
// Same contract as the native flow. resolveApplePayNetworks also handles the
|
|
635
|
+
// object-shaped payload, which the old spread would have thrown on.
|
|
636
|
+
const supportedNetworks = resolveApplePayNetworks(SallaApplePay.detail.supportedNetworks);
|
|
637
|
+
|
|
638
|
+
if (!supportedNetworks.length) {
|
|
639
|
+
return SallaApplePay.abandonNoSupportedNetwork();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const methodData = [{
|
|
643
|
+
supportedMethods: APPLE_PAY_METHOD,
|
|
644
|
+
data: {
|
|
645
|
+
version,
|
|
646
|
+
merchantIdentifier,
|
|
647
|
+
merchantCapabilities: ['supports3DS'],
|
|
648
|
+
supportedNetworks,
|
|
649
|
+
countryCode: SallaApplePay.detail.countryCode || 'SA',
|
|
650
|
+
},
|
|
651
|
+
}];
|
|
652
|
+
|
|
653
|
+
const requiresShipping = SallaApplePay.isPhysical();
|
|
654
|
+
|
|
655
|
+
const details = {
|
|
656
|
+
total: SallaApplePay.prTotal(),
|
|
657
|
+
displayItems: SallaApplePay.prDisplayItems(),
|
|
658
|
+
};
|
|
659
|
+
if (requiresShipping) {
|
|
660
|
+
details.shippingOptions = SallaApplePay.prShippingOptions();
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
let request;
|
|
664
|
+
try {
|
|
665
|
+
request = new PaymentRequest(methodData, details, SallaApplePay.prContactOptions());
|
|
666
|
+
} catch (error) {
|
|
667
|
+
salla.logger.error('🍏 Pay: Failed to create PaymentRequest', error);
|
|
668
|
+
SallaApplePay.detail.onError(salla.lang.get('pages.checkout.payment_failed'));
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
SallaApplePay.paymentRequest = request;
|
|
673
|
+
|
|
674
|
+
request.onmerchantvalidation = (event) => {
|
|
675
|
+
event.complete(SallaApplePay.prValidateMerchant(event));
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
if (requiresShipping) {
|
|
679
|
+
request.onshippingaddresschange = (event) => {
|
|
680
|
+
event.updateWith(SallaApplePay.prShippingAddressChange(request));
|
|
681
|
+
};
|
|
682
|
+
request.onshippingoptionchange = (event) => {
|
|
683
|
+
event.updateWith(SallaApplePay.prShippingOptionChange(request));
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// Showing the sheet is the QR equivalent of ApplePaySession.begin(): it
|
|
688
|
+
// must run inside the user gesture (transient activation). Wrapped so the
|
|
689
|
+
// order-options deferral can drive it — the caller either calls begin()
|
|
690
|
+
// itself (no options → open the QR sheet now) or discards this request
|
|
691
|
+
// and the modal's own Apple Pay button starts a fresh one (options found).
|
|
692
|
+
const beginShow = async () => {
|
|
693
|
+
// The QR sheet is browser chrome (fixed, top layer) that overlays the
|
|
694
|
+
// page. Signal open/closed so hosts embedding Apple Pay (e.g. the
|
|
695
|
+
// mini-checkout modal in an iframe) can hide UI — like their own close
|
|
696
|
+
// button — that would otherwise collide with the sheet.
|
|
697
|
+
Salla.event.dispatch('payments::apple-pay.sheet-opened');
|
|
698
|
+
try {
|
|
699
|
+
const response = await request.show();
|
|
700
|
+
SallaApplePay.paymentResponse = response;
|
|
701
|
+
await SallaApplePay.prPaymentAuthorized(response);
|
|
702
|
+
} catch (error) {
|
|
703
|
+
// AbortError → the shopper dismissed the sheet / QR, or merchant
|
|
704
|
+
// validation rejected. Mirror the native oncancel path so hosts
|
|
705
|
+
// listening for payments::apple-pay.canceled (to reset checkout UI)
|
|
706
|
+
// still get notified on a plain dismissal. The `canceled` guard
|
|
707
|
+
// skips the double-fire when validation already routed through
|
|
708
|
+
// onCancel via validateMerchant.onFailed.
|
|
709
|
+
salla.logger.log('🍏 Pay: PaymentRequest closed', error);
|
|
710
|
+
if (error?.name === 'AbortError' && !SallaApplePay.canceled) {
|
|
711
|
+
SallaApplePay.onCancel({});
|
|
712
|
+
}
|
|
713
|
+
} finally {
|
|
714
|
+
SallaApplePay.paymentRequest = null;
|
|
715
|
+
SallaApplePay.paymentResponse = null;
|
|
716
|
+
Salla.event.dispatch('payments::apple-pay.sheet-closed');
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
// Mirror the native path (startNativeSession): expose a hook between
|
|
721
|
+
// request creation and show(). Returning truthy skips the immediate
|
|
722
|
+
// show() so the order-options modal can open first. Same shim contract
|
|
723
|
+
// as native — the caller only needs `.begin()`.
|
|
724
|
+
let skipBegin;
|
|
725
|
+
if (typeof SallaApplePay.detail.onStartedSession === 'function') {
|
|
726
|
+
skipBegin = SallaApplePay.detail.onStartedSession({ begin: beginShow });
|
|
727
|
+
}
|
|
728
|
+
if (!skipBegin) {
|
|
729
|
+
await beginShow();
|
|
730
|
+
}
|
|
731
|
+
},
|
|
732
|
+
|
|
733
|
+
/** Merchant validation — resolves the promise passed to event.complete(). */
|
|
734
|
+
prValidateMerchant: async function (event) {
|
|
735
|
+
Salla.event.dispatch('payments::apple-pay.validate-merchant.init', event);
|
|
736
|
+
try {
|
|
737
|
+
const { data } = await http.post(
|
|
738
|
+
SallaApplePay.detail.validateMerchant.url.replace('{id}', SallaApplePay.id),
|
|
739
|
+
{ validation_url: event.validationURL }
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
// The BE can return HTTP 200 while Apple actually rejected the session
|
|
743
|
+
// (e.g. statusCode 417 "not a registered merchant in WWDR"), with the
|
|
744
|
+
// real session under data.data. A valid Apple session always carries a
|
|
745
|
+
// merchantSessionIdentifier; its absence means validation failed. Without
|
|
746
|
+
// this the QR sheet just closes silently — notify the shopper and abort.
|
|
747
|
+
if (!data?.data?.merchantSessionIdentifier) {
|
|
748
|
+
salla.logger.error('🍏 Pay: merchant validation failed', data?.statusMessage || data);
|
|
749
|
+
Salla.event.dispatch('payments::apple-pay.validate-merchant.failed', { data });
|
|
750
|
+
SallaApplePay.onCancel({});
|
|
751
|
+
// Dismiss the sheet + paired device immediately. Rejecting
|
|
752
|
+
// event.complete() alone leaves them spinning until Apple's own
|
|
753
|
+
// timeout; abort() forces an instant close. Deferred a tick so it
|
|
754
|
+
// runs after this validation update settles (abort() throws if
|
|
755
|
+
// called while an update is still in flight).
|
|
756
|
+
setTimeout(() => {
|
|
757
|
+
try { SallaApplePay.paymentRequest?.abort(); } catch (e) { /* already closing */ }
|
|
758
|
+
}, 0);
|
|
759
|
+
const validationError = new Error('Apple Pay merchant validation failed');
|
|
760
|
+
validationError.handled = true;
|
|
761
|
+
throw validationError;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
Salla.event.dispatch('payments::apple-pay.validate-merchant.success', data);
|
|
765
|
+
|
|
766
|
+
if (typeof SallaApplePay.detail.validateMerchant.onSuccess === 'function') {
|
|
767
|
+
const response = await SallaApplePay.detail.validateMerchant.onSuccess(data);
|
|
768
|
+
if (response?.redirect) {
|
|
769
|
+
window.location = response.redirect;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
316
772
|
|
|
773
|
+
return data.data;
|
|
774
|
+
} catch (error) {
|
|
775
|
+
// Skip when the invalid-session branch above already notified via onCancel.
|
|
776
|
+
if (!error?.handled) {
|
|
777
|
+
const response = error?.response;
|
|
778
|
+
Salla.event.dispatch('payments::apple-pay.validate-merchant.failed', response);
|
|
779
|
+
if (typeof SallaApplePay.detail.validateMerchant.onFailed === 'function') {
|
|
780
|
+
SallaApplePay.detail.validateMerchant.onFailed(response);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
// Rethrow so PaymentRequest aborts the sheet.
|
|
784
|
+
throw error;
|
|
785
|
+
}
|
|
786
|
+
},
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* ISO 3166 alpha-2 code → localized country name. The native path sends the
|
|
790
|
+
* country *name* in `country` (from ApplePayPaymentContact.country) and the
|
|
791
|
+
* ISO code in `country_code`; W3C PaymentAddress only exposes the ISO code,
|
|
792
|
+
* so we derive the name the backend expects. Falls back to the code if
|
|
793
|
+
* Intl.DisplayNames is unavailable or the code is unknown.
|
|
794
|
+
*/
|
|
795
|
+
prCountryName: (isoCode) => {
|
|
796
|
+
if (!isoCode) {
|
|
797
|
+
return isoCode;
|
|
798
|
+
}
|
|
317
799
|
try {
|
|
318
|
-
|
|
800
|
+
const locale = (salla?.config?.get?.('user.language_code')) || 'en';
|
|
801
|
+
return new Intl.DisplayNames([locale], { type: 'region' }).of(isoCode) || isoCode;
|
|
802
|
+
} catch (e) {
|
|
803
|
+
return isoCode;
|
|
804
|
+
}
|
|
805
|
+
},
|
|
319
806
|
|
|
807
|
+
/** Shipping address selected in the sheet → returns a PaymentDetailsUpdate. */
|
|
808
|
+
prShippingAddressChange: async function (request) {
|
|
809
|
+
const address = request.shippingAddress;
|
|
810
|
+
const fallback = {
|
|
811
|
+
total: SallaApplePay.prTotal(),
|
|
812
|
+
displayItems: SallaApplePay.prDisplayItems(),
|
|
813
|
+
shippingOptions: [],
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
if (!address) {
|
|
817
|
+
return fallback;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
try {
|
|
821
|
+
const { data } = await http.post(
|
|
822
|
+
SallaApplePay.detail.shippingContactSelected.url.replace('{id}', SallaApplePay.id),
|
|
823
|
+
{
|
|
824
|
+
country: SallaApplePay.prCountryName(address.country),
|
|
825
|
+
city: address.city,
|
|
826
|
+
local: address.dependentLocality || address.region || address.city,
|
|
827
|
+
description: address.region,
|
|
828
|
+
street: (address.addressLine || []).join(', ') || address.region,
|
|
829
|
+
country_code: address.country,
|
|
830
|
+
postal_code: address.postalCode,
|
|
831
|
+
}
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
SallaApplePay.address_id = data.data.address_id;
|
|
835
|
+
SallaApplePay.shipping_methods = data.data.shipping_methods;
|
|
836
|
+
|
|
837
|
+
if (!SallaApplePay.shipping_methods?.length) {
|
|
838
|
+
return { ...fallback, error: salla.lang.get('pages.checkout.payment_failed') };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
await SallaApplePay.selectApplePayShippingMethod(SallaApplePay.shipping_methods[0]);
|
|
320
842
|
await SallaApplePay.recalculateTotal();
|
|
321
843
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
844
|
+
return {
|
|
845
|
+
total: SallaApplePay.prTotal(),
|
|
846
|
+
displayItems: SallaApplePay.prDisplayItems(),
|
|
847
|
+
shippingOptions: SallaApplePay.prShippingOptions(),
|
|
848
|
+
};
|
|
849
|
+
} catch (error) {
|
|
850
|
+
salla.logger.warn('🍏 Pay: PR shipping address change failed', error);
|
|
851
|
+
return { ...fallback, error: salla.lang.get('pages.checkout.payment_failed') };
|
|
852
|
+
}
|
|
853
|
+
},
|
|
854
|
+
|
|
855
|
+
/** Shipping option selected in the sheet → returns a PaymentDetailsUpdate. */
|
|
856
|
+
prShippingOptionChange: async function (request) {
|
|
857
|
+
const identifier = request.shippingOption;
|
|
858
|
+
if (!identifier) {
|
|
859
|
+
return {
|
|
860
|
+
total: SallaApplePay.prTotal(),
|
|
861
|
+
displayItems: SallaApplePay.prDisplayItems(),
|
|
862
|
+
shippingOptions: SallaApplePay.prShippingOptions(),
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const ids = identifier.split(',');
|
|
867
|
+
const shippingMethod = {
|
|
868
|
+
ship_id: ids[0],
|
|
869
|
+
private_ship_id: typeof ids[1] === 'undefined' ? null : ids[1],
|
|
870
|
+
type: typeof ids[2] === 'undefined' ? null : ids[2],
|
|
871
|
+
route_id: typeof ids[3] === 'undefined' ? null : ids[3],
|
|
872
|
+
};
|
|
873
|
+
|
|
874
|
+
try {
|
|
875
|
+
if (SallaApplePay.shouldUpdateShippingCompany(shippingMethod)) {
|
|
876
|
+
await SallaApplePay.selectApplePayShippingMethod(shippingMethod);
|
|
877
|
+
await SallaApplePay.recalculateTotal();
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
total: SallaApplePay.prTotal(),
|
|
881
|
+
displayItems: SallaApplePay.prDisplayItems(),
|
|
882
|
+
shippingOptions: SallaApplePay.prShippingOptions(identifier),
|
|
883
|
+
};
|
|
884
|
+
} catch (error) {
|
|
885
|
+
salla.logger.warn('🍏 Pay: PR shipping option change failed', error);
|
|
886
|
+
return {
|
|
887
|
+
total: SallaApplePay.prTotal(),
|
|
888
|
+
displayItems: SallaApplePay.prDisplayItems(),
|
|
889
|
+
shippingOptions: SallaApplePay.prShippingOptions(identifier),
|
|
890
|
+
error: salla.lang.get('pages.checkout.payment_failed'),
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
},
|
|
894
|
+
|
|
895
|
+
/** Map the PaymentResponse payer fields to the contact shape the API expects. */
|
|
896
|
+
prContactFromResponse: (response) => {
|
|
897
|
+
const name = (response.payerName || '').trim();
|
|
898
|
+
const spaceIndex = name.indexOf(' ');
|
|
899
|
+
return {
|
|
900
|
+
givenName: spaceIndex > -1 ? name.slice(0, spaceIndex) : name,
|
|
901
|
+
familyName: spaceIndex > -1 ? name.slice(spaceIndex + 1) : '',
|
|
902
|
+
emailAddress: response.payerEmail || null,
|
|
903
|
+
phoneNumber: response.payerPhone || null,
|
|
904
|
+
countryCode: response.shippingAddress?.country || SallaApplePay.detail.countryCode || null,
|
|
905
|
+
};
|
|
906
|
+
},
|
|
907
|
+
|
|
908
|
+
track: function (event, extra = {}) {
|
|
909
|
+
const id = SallaApplePay.id ? String(SallaApplePay.id) : null;
|
|
910
|
+
try {
|
|
911
|
+
salla.analytics?.track(event, {
|
|
912
|
+
payment_method: 'apple_pay',
|
|
913
|
+
gateway: 'apple_pay',
|
|
914
|
+
cart_id: id,
|
|
915
|
+
checkout_id: id,
|
|
916
|
+
session_id: salla.config?.get?.('jitsu.session_id') || null,
|
|
917
|
+
...extra,
|
|
918
|
+
// The host knows the surface — PDP quick-buy vs the mini-checkout
|
|
919
|
+
// embed both render salla-quick-buy and share this package.
|
|
920
|
+
checkout_type: extra.checkout_type
|
|
921
|
+
|| SallaApplePay.detail?.checkout_type
|
|
922
|
+
|| 'applepay_checkout',
|
|
325
923
|
});
|
|
326
924
|
} catch (error) {
|
|
327
|
-
salla.logger.
|
|
925
|
+
salla.logger.error('🍏 Pay: failed to track ' + event, error);
|
|
926
|
+
}
|
|
927
|
+
},
|
|
328
928
|
|
|
329
|
-
|
|
330
|
-
|
|
929
|
+
trackPaymentSubmitted: function () {
|
|
930
|
+
if (SallaApplePay._submittedTracked) {
|
|
931
|
+
return;
|
|
331
932
|
}
|
|
933
|
+
SallaApplePay._submittedTracked = true;
|
|
934
|
+
// Checkout Started belongs to the host: the mini-checkout embed already
|
|
935
|
+
// emits its own on open, and on the PDP the cart does not exist until
|
|
936
|
+
// checkout/mini answers, so salla-quick-buy fires it there with a real id.
|
|
937
|
+
SallaApplePay.track('Payment Submitted', {});
|
|
332
938
|
},
|
|
333
939
|
|
|
940
|
+
trackPaymentFailed: function (errorType, errorCode, message = '') {
|
|
941
|
+
// Payment Failed answers a Payment Submitted, and Submitted is emitted at
|
|
942
|
+
// authorization. Anything that ends the attempt before that — a dismissed
|
|
943
|
+
// sheet, a refused presentation, merchant validation — is abandonment or a
|
|
944
|
+
// setup error, not a failed payment, and counting it here would make
|
|
945
|
+
// Failed exceed Submitted. Those paths still log, still call
|
|
946
|
+
// detail.onError and still dispatch payments::apple-pay.canceled, so
|
|
947
|
+
// hosts and error reporting are unaffected; only the funnel is.
|
|
948
|
+
if (!SallaApplePay._submittedTracked || SallaApplePay._failureTracked) {
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
SallaApplePay._failureTracked = true;
|
|
952
|
+
SallaApplePay.track('Payment Failed', {
|
|
953
|
+
error_type: errorType,
|
|
954
|
+
error_code: errorCode,
|
|
955
|
+
error_message: message || '',
|
|
956
|
+
});
|
|
957
|
+
},
|
|
334
958
|
|
|
335
|
-
|
|
336
|
-
if (SallaApplePay.
|
|
337
|
-
|
|
959
|
+
trackPaymentSucceeded: function (data) {
|
|
960
|
+
if (SallaApplePay._succeededTracked) {
|
|
961
|
+
return;
|
|
338
962
|
}
|
|
963
|
+
SallaApplePay._succeededTracked = true;
|
|
964
|
+
const payload = data?.data || data || {};
|
|
965
|
+
SallaApplePay.track('Payment Succeeded', {
|
|
966
|
+
order_id: payload.order_id || null,
|
|
967
|
+
cart_total: payload.total || SallaApplePay.detail?.amount || null,
|
|
968
|
+
response_status: 'success',
|
|
969
|
+
has_redirect: false,
|
|
970
|
+
redirect_url: null,
|
|
971
|
+
});
|
|
972
|
+
},
|
|
973
|
+
|
|
974
|
+
/** Authorize + submit the payment, then close the sheet. */
|
|
975
|
+
prPaymentAuthorized: async function (response) {
|
|
976
|
+
// Guest checkout → persist the payer contact before submitting.
|
|
977
|
+
if (SallaApplePay.detail.guestContactSelected) {
|
|
978
|
+
const contact = SallaApplePay.prContactFromResponse(response);
|
|
979
|
+
|
|
980
|
+
// Require familyName too, mirroring the native guest guard
|
|
981
|
+
// (utils.js mutateShippingContact). Keeps the payload the backend
|
|
982
|
+
// receives identical to the production native flow — it has only
|
|
983
|
+
// ever been sent a non-empty last_name from Apple Pay guest checkout.
|
|
984
|
+
if (!contact.emailAddress || !contact.givenName || !contact.familyName || !contact.phoneNumber) {
|
|
985
|
+
await response.complete('fail');
|
|
986
|
+
SallaApplePay.detail.onError(salla.lang.get('common.messages.required_fields'));
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
try {
|
|
991
|
+
await http.post(
|
|
992
|
+
SallaApplePay.detail.guestContactSelected.url.replace('{id}', SallaApplePay.id),
|
|
993
|
+
{
|
|
994
|
+
email: contact.emailAddress,
|
|
995
|
+
first_name: contact.givenName,
|
|
996
|
+
last_name: contact.familyName,
|
|
997
|
+
phone_number: contact.phoneNumber,
|
|
998
|
+
country_code: contact.countryCode,
|
|
999
|
+
}
|
|
1000
|
+
);
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
await response.complete('fail');
|
|
1003
|
+
SallaApplePay.detail.onError(
|
|
1004
|
+
error?.response?.data?.error?.message || salla.lang.get('pages.checkout.payment_failed')
|
|
1005
|
+
);
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
Salla.event.dispatch('payments::apple-pay.authorized.init', response);
|
|
1011
|
+
// Authorised on the paired device; the token is about to be submitted.
|
|
1012
|
+
SallaApplePay.trackPaymentSubmitted();
|
|
1013
|
+
|
|
1014
|
+
let authorizedData;
|
|
1015
|
+
try {
|
|
1016
|
+
const { data } = await http.post(
|
|
1017
|
+
SallaApplePay.detail.authorized.url.replace('{id}', SallaApplePay.id),
|
|
1018
|
+
{ payment_method: 'apple_pay', applepay_token: JSON.stringify(response.details) }
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
authorizedData = data;
|
|
1022
|
+
Salla.event.dispatch('payments::apple-pay.authorized.success', data);
|
|
1023
|
+
SallaApplePay.trackPaymentSucceeded(data);
|
|
1024
|
+
await response.complete('success');
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
const errorResponse = error?.response;
|
|
1027
|
+
Salla.event.dispatch('payments::apple-pay.authorized.failed', errorResponse);
|
|
1028
|
+
// Backend rejected the token — latch set so the sheet-close /
|
|
1029
|
+
// cancel that follows can't emit a second Failed.
|
|
1030
|
+
SallaApplePay.trackPaymentFailed('server',
|
|
1031
|
+
errorResponse?.data?.error?.code || 'APPLE_PAY_FAILED',
|
|
1032
|
+
errorResponse?.data?.error?.message || '');
|
|
1033
|
+
await response.complete('fail');
|
|
1034
|
+
if (typeof SallaApplePay.detail.authorized.onFailed === 'function') {
|
|
1035
|
+
SallaApplePay.detail.authorized.onFailed(errorResponse);
|
|
1036
|
+
}
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// response.complete() has already run above, and it can only be called
|
|
1041
|
+
// once. Run the merchant onSuccess callback OUTSIDE the try/catch so a
|
|
1042
|
+
// throw here (e.g. redirect handling) can't fall into the catch and fire
|
|
1043
|
+
// a second complete('fail') → InvalidStateError that masks the real error.
|
|
1044
|
+
if (typeof SallaApplePay.detail.authorized.onSuccess === 'function') {
|
|
1045
|
+
SallaApplePay.detail.authorized.onSuccess(authorizedData);
|
|
1046
|
+
}
|
|
1047
|
+
},
|
|
1048
|
+
|
|
1049
|
+
shouldUpdateShippingCompany(shippingCompany) {
|
|
1050
|
+
return SallaApplePay.shippingCompany?.ship_id != shippingCompany?.ship_id || SallaApplePay.shippingCompany?.private_ship_id != shippingCompany?.private_ship_id
|
|
339
1051
|
},
|
|
340
1052
|
|
|
341
1053
|
getApplePaySessionVersion: () => {
|
|
@@ -363,24 +1075,40 @@ window.SallaApplePay = {
|
|
|
363
1075
|
},
|
|
364
1076
|
|
|
365
1077
|
recalculateTotal: () => {
|
|
366
|
-
salla.logger.log('
|
|
1078
|
+
salla.logger.log('🍏 Pay: recalculate total');
|
|
367
1079
|
|
|
368
|
-
return
|
|
1080
|
+
return http.get(SallaApplePay.detail.recalculateTotal.url.replace('{id}', SallaApplePay.id), ({ data }) => {
|
|
1081
|
+
salla.logger.log('🍏 Pay: recalculate total success', data);
|
|
369
1082
|
let cart = data.data.initial_data?.cart || data.data.cart;
|
|
370
|
-
|
|
371
|
-
|
|
1083
|
+
let payments = data.data.initial_data?.payments || data.data.payments;
|
|
1084
|
+
|
|
1085
|
+
salla.logger.log('🍏 Pay: recalculate total success', {
|
|
1086
|
+
cart,
|
|
1087
|
+
payments
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
// BE-canary gate keyed on the live payload (not cached config): the unified BE adds
|
|
1091
|
+
// applePay.amount, legacy BE doesn't — so gate and data can never desync.
|
|
1092
|
+
if (payments?.gateways?.applePay?.amount != null) {
|
|
1093
|
+
// Unified BE: Apple Pay is always charged in the store base currency.
|
|
1094
|
+
SallaApplePay.detail.amount = payments.gateways.applePay.amount;
|
|
1095
|
+
SallaApplePay.detail.currency = payments.gateways.applePay.currency ?? SallaApplePay.detail.currency;
|
|
1096
|
+
} else {
|
|
1097
|
+
// Legacy BE .
|
|
1098
|
+
SallaApplePay.detail.amount = payments?.gateways?.applePay?.supportedMultiCurrency ? cart.total_in_customer_currency : cart.total;
|
|
1099
|
+
}
|
|
372
1100
|
SallaApplePay.detail.items = (cart.totals || cart.items).map((item) => {
|
|
373
1101
|
return {
|
|
374
1102
|
label: item.title,
|
|
375
|
-
amount: item.amount === 'مجاني' ? 0 : item.amount.toString().replace('+', ''),
|
|
1103
|
+
amount: (item.amount === 'مجاني' || item.amount === 'Free') ? 0 : item.amount.toString().replace('+', ''),
|
|
376
1104
|
};
|
|
377
1105
|
})
|
|
378
1106
|
|
|
379
1107
|
// lets remove last element (final total)
|
|
380
1108
|
SallaApplePay.detail.items.pop();
|
|
1109
|
+
SallaApplePay.totals = SallaApplePay.detail.items;
|
|
381
1110
|
|
|
382
|
-
|
|
383
|
-
}).catch((error) => {
|
|
1111
|
+
}, (error) => {
|
|
384
1112
|
salla.logger.warn('🍏 Pay: recalculate total failed', error);
|
|
385
1113
|
|
|
386
1114
|
// general error
|
|
@@ -389,22 +1117,26 @@ window.SallaApplePay = {
|
|
|
389
1117
|
},
|
|
390
1118
|
|
|
391
1119
|
|
|
392
|
-
selectApplePayShippingMethod: (
|
|
393
|
-
salla.logger.log('🍏 Pay: select shipping method ',
|
|
1120
|
+
selectApplePayShippingMethod: (shippingMethod) => {
|
|
1121
|
+
salla.logger.log('🍏 Pay: select shipping method ', shippingMethod);
|
|
1122
|
+
SallaApplePay.shippingCompany = shippingMethod;
|
|
394
1123
|
|
|
395
|
-
|
|
1124
|
+
const payload = {
|
|
396
1125
|
address_id: SallaApplePay.address_id,
|
|
397
|
-
company_id:
|
|
398
|
-
private_company_id:
|
|
1126
|
+
company_id: shippingMethod.ship_id,
|
|
1127
|
+
private_company_id: shippingMethod.private_ship_id,
|
|
1128
|
+
type: shippingMethod.type || undefined,
|
|
1129
|
+
route_id: shippingMethod.route_id || undefined,
|
|
399
1130
|
payment_method: 'apple_pay'
|
|
400
|
-
}
|
|
401
|
-
|
|
1131
|
+
}
|
|
1132
|
+
return http.post(SallaApplePay.detail.shippingMethodSelected.url.replace('{id}', SallaApplePay.id), payload, ({ data }) => {
|
|
1133
|
+
if (typeof SallaApplePay.detail.shippingMethodSelected.onSuccess === 'function') {
|
|
402
1134
|
SallaApplePay.detail.shippingMethodSelected.onSuccess(data);
|
|
403
1135
|
}
|
|
404
1136
|
|
|
405
1137
|
// we don't have any data in this request, lets resolve the promise
|
|
406
1138
|
return true;
|
|
407
|
-
}
|
|
1139
|
+
}, (error) => {
|
|
408
1140
|
salla.logger.warn('🍏 Pay: Set shipping method failed', error);
|
|
409
1141
|
|
|
410
1142
|
if (typeof SallaApplePay.detail.shippingMethodSelected.onFailed === 'function') {
|
|
@@ -427,15 +1159,24 @@ window.SallaApplePay = {
|
|
|
427
1159
|
'label': method.shipping_title,
|
|
428
1160
|
'amount': method.enable_free_shipping ? 0 : method.ship_cost,
|
|
429
1161
|
'detail': '',
|
|
430
|
-
'identifier': method.ship_id.toString() + (method.private_ship_id ? ',' + method.private_ship_id.toString() : '')
|
|
1162
|
+
'identifier': method.ship_id.toString() + (method.private_ship_id ? ',' + method.private_ship_id.toString() : '') + (method.type ? ',' + method.type : '') + (method.route_id ? ',' + method.route_id : '')
|
|
431
1163
|
}))
|
|
1164
|
+
} : {};
|
|
1165
|
+
|
|
1166
|
+
// SSR-safe: export and assign to window in browser
|
|
1167
|
+
if (typeof window !== 'undefined') {
|
|
1168
|
+
window.SallaApplePay = SallaApplePay;
|
|
432
1169
|
}
|
|
433
1170
|
|
|
434
1171
|
//applePay doesn't allow iframes
|
|
435
|
-
//
|
|
436
|
-
|
|
1172
|
+
// SSR-safe (init only in the browser). Native (Safari) OR any browser with the
|
|
1173
|
+
// Payment Request API (non-Safari / QR-code fallback) can start a transaction;
|
|
1174
|
+
// capability is confirmed per-flow.
|
|
1175
|
+
if (typeof window !== 'undefined' && (SallaApplePay.canUseNative() || window.PaymentRequest)) {
|
|
437
1176
|
SallaApplePay.init();
|
|
438
|
-
} else {
|
|
1177
|
+
} else if (typeof document !== 'undefined') {
|
|
439
1178
|
// You can hide the Apple Pay button easy with add data-show-if-apple-pay-supported to element like <div data-show-if-apple-pay-supported>
|
|
440
1179
|
document.querySelectorAll('data-show-if-apple-pay-supported').forEach(element => element.style.display = 'none');
|
|
441
1180
|
}
|
|
1181
|
+
|
|
1182
|
+
export default SallaApplePay;
|