@salla.sa/applepay 3.0.0-beta.1 → 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/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@salla.sa/applepay",
3
- "version": "3.0.0-beta.1",
3
+ "version": "3.0.0",
4
4
  "description": "Salla Apple Pay light",
5
5
  "main": "dist/app.js",
6
+ "scripts": {
7
+ "watch": "echo \"There is no watch!\"",
8
+ "build": "echo \"There is no build!\"",
9
+ "test": "echo \"Error: no test specified\" && exit 1"
10
+ },
6
11
  "repository": {
7
12
  "type": "git",
8
13
  "url": "git+https://github.com/SallaApp/twilight.git"
@@ -21,9 +26,5 @@
21
26
  "dependencies": {
22
27
  "axios": "^1.10.0"
23
28
  },
24
- "scripts": {
25
- "watch": "echo \"There is no watch!\"",
26
- "build": "echo \"There is no build!\"",
27
- "test": "echo \"Error: no test specified\" && exit 1"
28
- }
29
- }
29
+ "gitHead": "43f7a57e278a226a3e1f77924f9da8f10c7e49ba"
30
+ }
@@ -0,0 +1,24 @@
1
+ export declare const APPLE_PAY_METHOD: string;
2
+
3
+ export declare function applePayMerchantId(): string | null;
4
+
5
+ export declare function buildApplePayMethodData(opts: {
6
+ version: number;
7
+ merchantIdentifier: string;
8
+ supportedNetworks: string[];
9
+ countryCode: string;
10
+ }): PaymentMethodData[];
11
+
12
+ export declare function isApplePayHostedRedirect(): boolean;
13
+
14
+ export declare function ensureApplePaySdk(): Promise<void>;
15
+
16
+ export declare function normalizeApplePayNetworks(raw: string[] | Record<string, string> | null | undefined): string[];
17
+
18
+ export declare function applePayNetworks(): string[];
19
+
20
+ export declare function resolveApplePayNetworks(
21
+ networks: string[] | Record<string, string> | null | undefined,
22
+ ): string[];
23
+
24
+ export declare function canCompleteApplePay(currency?: string, networks?: string[] | null): Promise<boolean>;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Shared Apple Pay support helpers: SDK loading, capability detection, merchant
3
+ * identifier resolution, and Payment Request method-data construction.
4
+ *
5
+ * Lives in @salla.sa/applepay (not a theme) because it is Apple Pay domain
6
+ * logic and is consumed by every Apple Pay entry point — the storefront
7
+ * components (salla-quick-buy, salla-add-product-button) and the shared
8
+ * SallaApplePay session in ./index.js.
9
+ *
10
+ * Apple Pay runs through two flows:
11
+ * - Native (Safari / Apple devices) → window.ApplePaySession
12
+ * - Payment Request API (other browsers) → Apple's "Scan Code with iPhone"
13
+ * QR sheet, which requires Apple's JS SDK to be *loaded and registered*
14
+ * before PaymentRequest.canMakePayment() reports the method as available.
15
+ */
16
+
17
+ export const APPLE_PAY_METHOD = 'https://apple.com/apple-pay';
18
+ const APPLE_PAY_SDK_ID = 'apple-pay-sdk';
19
+ const APPLE_PAY_SDK_SRC = 'https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js';
20
+
21
+ let sdkPromise = null;
22
+
23
+ /**
24
+ * Salla-native Apple merchant identifier, sourced from store settings. Returns
25
+ * null when the store has no Apple Pay merchant configured — callers treat that
26
+ * absence as the guard and skip Apple Pay entirely (no hardcoded fallback).
27
+ * @returns {string|null}
28
+ */
29
+ export function applePayMerchantId() {
30
+ return salla?.config?.get?.('store.settings.buy_now.merchant_identifier') || null;
31
+ }
32
+
33
+ /**
34
+ * Whether the store's Apple Pay is a hosted redirect rather than a native sheet.
35
+ *
36
+ * MyFatoorah Apple Pay without S2S has no native flow: the checkout page hands off as a
37
+ * credit_card redirect to MyFatoorah's hosted page. The buy-now surfaces (PDP, sticky bar)
38
+ * have no redirect path, so they must not offer an Apple Pay button at all.
39
+ *
40
+ * The backend publishes this as a single boolean (`ApplePayFeature::isHostedRedirect`).
41
+ * Compared with `=== true` on purpose: against a backend that predates the field the key is
42
+ * absent, so this returns false and every surface keeps its current behaviour. That is what
43
+ * makes the storefront half safe to ship independently of the backend half.
44
+ * @returns {boolean}
45
+ */
46
+ export function isApplePayHostedRedirect() {
47
+ return salla?.config?.get?.('store.settings.buy_now.hostedRedirect') === true;
48
+ }
49
+
50
+ /**
51
+ * Build the W3C Payment Request `methodData` for the Apple Pay method. Callers
52
+ * supply the values (the probe reads them from config; the live session reads
53
+ * them from the transaction detail), this owns the constant scaffold so the two
54
+ * can't drift apart.
55
+ * @param {{ version: number, merchantIdentifier: string, supportedNetworks: string[], countryCode: string }} opts
56
+ * @returns {PaymentMethodData[]}
57
+ */
58
+ export function buildApplePayMethodData({ version, merchantIdentifier, supportedNetworks, countryCode }) {
59
+ return [{
60
+ supportedMethods: APPLE_PAY_METHOD,
61
+ data: {
62
+ version,
63
+ merchantIdentifier,
64
+ merchantCapabilities: ['supports3DS'],
65
+ supportedNetworks,
66
+ countryCode,
67
+ },
68
+ }];
69
+ }
70
+
71
+ /**
72
+ * Inject Apple's Pay JS SDK (`1.latest` ships the cross-browser / QR support)
73
+ * and resolve once it has loaded. Idempotent: a second call reuses the same
74
+ * in-flight promise and never appends a second <script>. Resolves (rather than
75
+ * rejects) on load error so a failed CDN fetch simply falls back to "no QR".
76
+ * @returns {Promise<void>}
77
+ */
78
+ export function ensureApplePaySdk() {
79
+ if (typeof document === 'undefined') return Promise.resolve();
80
+
81
+ if (sdkPromise) return sdkPromise;
82
+
83
+ const existing = document.getElementById(APPLE_PAY_SDK_ID);
84
+ if (existing) {
85
+ // Script already in the DOM (e.g. added before this refactor). We can't know
86
+ // if it finished loading, so resolve immediately — the native/PaymentRequest
87
+ // probe will just report unavailable until the browser registers the method.
88
+ sdkPromise = Promise.resolve();
89
+ return sdkPromise;
90
+ }
91
+
92
+ sdkPromise = new Promise((resolve) => {
93
+ const script = document.createElement('script');
94
+ script.src = APPLE_PAY_SDK_SRC;
95
+ script.setAttribute('id', APPLE_PAY_SDK_ID);
96
+ script.async = true;
97
+ script.onload = () => resolve();
98
+ script.onerror = () => resolve();
99
+ document.body.appendChild(script);
100
+ });
101
+
102
+ return sdkPromise;
103
+ }
104
+
105
+ /**
106
+ * Coerce a networks payload into an array. PHP serializes the list as an object when the
107
+ * array keys are non-sequential, so both shapes arrive.
108
+ *
109
+ * @param {string[]|Object|null|undefined} raw
110
+ * @returns {string[]}
111
+ */
112
+ export function normalizeApplePayNetworks(raw) {
113
+ if (Array.isArray(raw)) return [...raw];
114
+ if (raw && typeof raw === 'object') return Object.values(raw);
115
+
116
+ return [];
117
+ }
118
+
119
+ /**
120
+ * The store's buy-now networks from config.
121
+ *
122
+ * @returns {string[]}
123
+ */
124
+ export function applePayNetworks() {
125
+ return normalizeApplePayNetworks(
126
+ salla?.config?.get?.('store.settings.buy_now.supportedNetworks')
127
+ || salla?.config?.get?.('store.settings.buy_now.networks'),
128
+ );
129
+ }
130
+
131
+ /**
132
+ * The networks for this attempt. `null`/`undefined` means "not supplied" and defers to config;
133
+ * an array — empty included — is the backend's answer and is used as-is.
134
+ *
135
+ * @param {string[]|Object|null|undefined} networks
136
+ * @returns {string[]}
137
+ */
138
+ export function resolveApplePayNetworks(networks) {
139
+ return networks == null ? applePayNetworks() : normalizeApplePayNetworks(networks);
140
+ }
141
+
142
+ /**
143
+ * Whether Apple Pay can be completed in the current browser: natively
144
+ * (Safari / Apple devices) or via the Payment Request API (non-Safari browsers
145
+ * / non-Apple devices → QR-code fallback).
146
+ *
147
+ * For the QR path this awaits the SDK before probing, so callers get a stable
148
+ * answer on first load instead of racing the async script fetch.
149
+ * @param {string} [currency]
150
+ * @param {string[]} [networks] The backend's list; null/undefined defers to the buy-now config.
151
+ * @returns {Promise<boolean>}
152
+ */
153
+ export async function canCompleteApplePay(currency = 'SAR', networks = null) {
154
+ const supportedNetworks = resolveApplePayNetworks(networks);
155
+
156
+ // Nothing to charge with — checked before the native probe, which would report "available"
157
+ // and only fail after the shopper taps pay.
158
+ if (!supportedNetworks.length) return false;
159
+
160
+ try {
161
+ if (window.ApplePaySession?.canMakePayments?.()) return true;
162
+ } catch (e) { /* not supported natively */ }
163
+
164
+ if (typeof window === 'undefined' || !window.PaymentRequest) return false;
165
+
166
+ // QR path only: it can't validate the merchant without a merchant identifier,
167
+ // so treat a missing config key as the guard. Native (above) is unaffected —
168
+ // it never uses the client-side merchant id.
169
+ if (!applePayMerchantId()) return false;
170
+
171
+ await ensureApplePaySdk();
172
+
173
+ try {
174
+ const request = new PaymentRequest(
175
+ buildApplePayMethodData({
176
+ version: 3,
177
+ merchantIdentifier: applePayMerchantId(),
178
+ supportedNetworks,
179
+ countryCode: salla?.config?.get?.('store.store_country') || 'SA',
180
+ }),
181
+ { total: { label: 'Salla', amount: { currency: currency || 'SAR', value: '0.00' } } },
182
+ );
183
+ return await request.canMakePayment();
184
+ } catch (e) {
185
+ return false;
186
+ }
187
+ }
package/src/http.js CHANGED
@@ -1,7 +1,12 @@
1
1
  import axios from 'axios'
2
2
 
3
+ function setBaseCurrencyHeader() {
4
+ axios.defaults.headers.common.currency = salla?.config?.get?.('user.currency_code');
5
+ }
6
+
3
7
  export default {
4
8
  request(method, url, data, successCb = null, errorCb = null) {
9
+ setBaseCurrencyHeader();
5
10
  return axios
6
11
  .request({url, data, method: method.toLowerCase(), responseType: 'json'})
7
12
  .then(successCb)
@@ -10,6 +15,7 @@ export default {
10
15
 
11
16
  get(url, successCb = null, errorCb = null, data) {
12
17
  // return this.request('get', url, data, successCb, errorCb);
18
+ setBaseCurrencyHeader();
13
19
  return axios
14
20
  .get(url, {params: data})
15
21
  .then(successCb)
@@ -41,10 +47,12 @@ export default {
41
47
  /**
42
48
  * @deprecated to support legacy themes
43
49
  */
50
+ setBaseCurrencyHeader();
44
51
  $.ajax({
45
52
  url: url,
46
53
  method: method.toUpperCase(),
47
54
  data: payload,
55
+ headers: { currency: axios.defaults.headers.common.currency },
48
56
  async: false,
49
57
  success: function ({data}) {
50
58
  return resolve(data);
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import http from './http';
2
2
  import DetectOS from './DetectOS';
3
3
  import { mutateShippingContact } from './utils';
4
+ import { resolveApplePayNetworks } from './apple-pay-support';
4
5
 
5
6
  // SSR-safe: only access window in browser environment
6
7
  if (typeof window !== 'undefined') {
@@ -8,6 +9,19 @@ if (typeof window !== 'undefined') {
8
9
  window.Salla.Payments = window.Salla.Payments || {};
9
10
  }
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';
24
+
11
25
  /**
12
26
  * Full Example
13
27
  *
@@ -51,6 +65,11 @@ const SallaApplePay = typeof window !== 'undefined' ? {
51
65
  countryCode: null,
52
66
  totals: [],
53
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,
54
73
  init: function () {
55
74
  document.removeEventListener('payments::apple-pay.start-transaction', SallaApplePay.startSession);
56
75
  Salla.event.addEventListener('payments::apple-pay.start-transaction', SallaApplePay.startSession);
@@ -113,19 +132,71 @@ const SallaApplePay = typeof window !== 'undefined' ? {
113
132
  return SallaApplePay.detail?.requiredShippingContactFields?.includes('postalAddress');
114
133
  },
115
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;
156
+ },
157
+
116
158
  startSession: async function (event) {
117
159
 
118
160
  SallaApplePay.detail = event.detail || event;
119
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
+
120
177
  salla.log('🍏 Pay: payments::apple-pay.start-transaction', SallaApplePay.detail);
121
178
 
122
179
  SallaApplePay.initDefault();
123
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
+
124
193
  let version = SallaApplePay.getApplePaySessionVersion();
125
- let supportedNetworks = SallaApplePay.detail.supportedNetworks || ['masterCard', 'visa'];
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);
126
197
 
127
- if (version === 5) {
128
- supportedNetworks.push('mada');
198
+ if (!supportedNetworks.length) {
199
+ return SallaApplePay.abandonNoSupportedNetwork();
129
200
  }
130
201
 
131
202
  SallaApplePay.request = {
@@ -163,8 +234,17 @@ const SallaApplePay = typeof window !== 'undefined' ? {
163
234
  skipBegin = SallaApplePay.detail.onStartedSession(SallaApplePay.session);
164
235
  }
165
236
  if (!skipBegin) {
166
- SallaApplePay.session.begin();
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
+ }
167
245
  }
246
+ // skipBegin path: the caller begins later — onValidateMerchant fires
247
+ // once the sheet actually opens and emits the (latched) Submitted.
168
248
  },
169
249
  onPaymentMethodSelected: async (event) => {
170
250
  salla.logger.log('🍏 Pay: onPaymentMethodSelected', event);
@@ -247,7 +327,29 @@ const SallaApplePay = typeof window !== 'undefined' ? {
247
327
  });
248
328
  },
249
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
+
250
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 || '');
251
353
  SallaApplePay.detail.onError(message || salla.lang.get('pages.checkout.payment_failed'));
252
354
  Salla.event.createAndDispatch('payments::apple-pay.canceled', event);
253
355
  },
@@ -309,11 +411,15 @@ const SallaApplePay = typeof window !== 'undefined' ? {
309
411
  }
310
412
 
311
413
  Salla.event.dispatch('payments::apple-pay.authorized.init', event);
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();
312
417
  http.post(SallaApplePay.detail.authorized.url.replace('{id}', SallaApplePay.id), {
313
418
  payment_method: 'apple_pay',
314
419
  applepay_token: JSON.stringify(event.payment)
315
420
  }, ({ data }) => {
316
421
  Salla.event.dispatch('payments::apple-pay.authorized.success', data);
422
+ SallaApplePay.trackPaymentSucceeded(data);
317
423
 
318
424
  if (typeof SallaApplePay.detail.authorized.onSuccess === 'function') {
319
425
  SallaApplePay.detail.authorized.onSuccess(data);
@@ -325,6 +431,11 @@ const SallaApplePay = typeof window !== 'undefined' ? {
325
431
  let response = error?.response;
326
432
 
327
433
  Salla.event.dispatch('payments::apple-pay.authorized.failed', response);
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 || '');
328
439
 
329
440
  if (typeof SallaApplePay.detail.authorized.onFailed === 'function') {
330
441
  SallaApplePay.detail.authorized.onFailed(response);
@@ -464,6 +575,477 @@ const SallaApplePay = typeof window !== 'undefined' ? {
464
575
  }
465
576
  },
466
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
+ }
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
+ }
799
+ try {
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
+ },
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]);
842
+ await SallaApplePay.recalculateTotal();
843
+
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',
923
+ });
924
+ } catch (error) {
925
+ salla.logger.error('🍏 Pay: failed to track ' + event, error);
926
+ }
927
+ },
928
+
929
+ trackPaymentSubmitted: function () {
930
+ if (SallaApplePay._submittedTracked) {
931
+ return;
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', {});
938
+ },
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
+ },
958
+
959
+ trackPaymentSucceeded: function (data) {
960
+ if (SallaApplePay._succeededTracked) {
961
+ return;
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
+
467
1049
  shouldUpdateShippingCompany(shippingCompany) {
468
1050
  return SallaApplePay.shippingCompany?.ship_id != shippingCompany?.ship_id || SallaApplePay.shippingCompany?.private_ship_id != shippingCompany?.private_ship_id
469
1051
  },
@@ -505,8 +1087,16 @@ const SallaApplePay = typeof window !== 'undefined' ? {
505
1087
  payments
506
1088
  });
507
1089
 
508
- // todo :: enhance response from backend
509
- SallaApplePay.detail.amount = payments?.gateways?.applePay?.supportedMultiCurrency ? cart.total_in_customer_currency : cart.total;
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
+ }
510
1100
  SallaApplePay.detail.items = (cart.totals || cart.items).map((item) => {
511
1101
  return {
512
1102
  label: item.title,
@@ -579,8 +1169,10 @@ if (typeof window !== 'undefined') {
579
1169
  }
580
1170
 
581
1171
  //applePay doesn't allow iframes
582
- // SSR-safe: only initialize in browser
583
- if (typeof window !== 'undefined' && window.ApplePaySession?.canMakePayments()) {
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)) {
584
1176
  SallaApplePay.init();
585
1177
  } else if (typeof document !== 'undefined') {
586
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>