@salla.sa/applepay 2.14.532 → 2.14.534

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@salla.sa/applepay",
3
- "version": "2.14.532",
3
+ "version": "2.14.534",
4
4
  "description": "Salla Apple Pay light",
5
5
  "main": "dist/app.js",
6
6
  "scripts": {
@@ -26,5 +26,5 @@
26
26
  "dependencies": {
27
27
  "axios": "^1.10.0"
28
28
  },
29
- "gitHead": "97ac10c8dc2bb41f1419748c849269ad8d7d7316"
29
+ "gitHead": "9d4c5cc5840482e784f9f127bd508b539fb4c2e1"
30
30
  }
@@ -0,0 +1,14 @@
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 ensureApplePaySdk(): Promise<void>;
13
+
14
+ export declare function canCompleteApplePay(currency?: string): Promise<boolean>;
@@ -0,0 +1,128 @@
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
+ * Build the W3C Payment Request `methodData` for the Apple Pay method. Callers
35
+ * supply the values (the probe reads them from config; the live session reads
36
+ * them from the transaction detail), this owns the constant scaffold so the two
37
+ * can't drift apart.
38
+ * @param {{ version: number, merchantIdentifier: string, supportedNetworks: string[], countryCode: string }} opts
39
+ * @returns {PaymentMethodData[]}
40
+ */
41
+ export function buildApplePayMethodData({ version, merchantIdentifier, supportedNetworks, countryCode }) {
42
+ return [{
43
+ supportedMethods: APPLE_PAY_METHOD,
44
+ data: {
45
+ version,
46
+ merchantIdentifier,
47
+ merchantCapabilities: ['supports3DS'],
48
+ supportedNetworks,
49
+ countryCode,
50
+ },
51
+ }];
52
+ }
53
+
54
+ /**
55
+ * Inject Apple's Pay JS SDK (`1.latest` ships the cross-browser / QR support)
56
+ * and resolve once it has loaded. Idempotent: a second call reuses the same
57
+ * in-flight promise and never appends a second <script>. Resolves (rather than
58
+ * rejects) on load error so a failed CDN fetch simply falls back to "no QR".
59
+ * @returns {Promise<void>}
60
+ */
61
+ export function ensureApplePaySdk() {
62
+ if (typeof document === 'undefined') return Promise.resolve();
63
+
64
+ if (sdkPromise) return sdkPromise;
65
+
66
+ const existing = document.getElementById(APPLE_PAY_SDK_ID);
67
+ if (existing) {
68
+ // Script already in the DOM (e.g. added before this refactor). We can't know
69
+ // if it finished loading, so resolve immediately — the native/PaymentRequest
70
+ // probe will just report unavailable until the browser registers the method.
71
+ sdkPromise = Promise.resolve();
72
+ return sdkPromise;
73
+ }
74
+
75
+ sdkPromise = new Promise((resolve) => {
76
+ const script = document.createElement('script');
77
+ script.src = APPLE_PAY_SDK_SRC;
78
+ script.setAttribute('id', APPLE_PAY_SDK_ID);
79
+ script.async = true;
80
+ script.onload = () => resolve();
81
+ script.onerror = () => resolve();
82
+ document.body.appendChild(script);
83
+ });
84
+
85
+ return sdkPromise;
86
+ }
87
+
88
+ /**
89
+ * Whether Apple Pay can be completed in the current browser: natively
90
+ * (Safari / Apple devices) or via the Payment Request API (non-Safari browsers
91
+ * / non-Apple devices → QR-code fallback).
92
+ *
93
+ * For the QR path this awaits the SDK before probing, so callers get a stable
94
+ * answer on first load instead of racing the async script fetch.
95
+ * @param {string} [currency]
96
+ * @returns {Promise<boolean>}
97
+ */
98
+ export async function canCompleteApplePay(currency = 'SAR') {
99
+ try {
100
+ if (window.ApplePaySession?.canMakePayments?.()) return true;
101
+ } catch (e) { /* not supported natively */ }
102
+
103
+ if (typeof window === 'undefined' || !window.PaymentRequest) return false;
104
+
105
+ // QR path only: it can't validate the merchant without a merchant identifier,
106
+ // so treat a missing config key as the guard. Native (above) is unaffected —
107
+ // it never uses the client-side merchant id.
108
+ if (!applePayMerchantId()) return false;
109
+
110
+ await ensureApplePaySdk();
111
+
112
+ try {
113
+ const request = new PaymentRequest(
114
+ buildApplePayMethodData({
115
+ version: 3,
116
+ merchantIdentifier: applePayMerchantId(),
117
+ supportedNetworks: salla?.config?.get?.('store.settings.buy_now.supportedNetworks')
118
+ || salla?.config?.get?.('store.settings.buy_now.networks')
119
+ || ['masterCard', 'visa'],
120
+ countryCode: salla?.config?.get?.('store.store_country') || 'SA',
121
+ }),
122
+ { total: { label: 'Salla', amount: { currency: currency || 'SAR', value: '0.00' } } },
123
+ );
124
+ return await request.canMakePayment();
125
+ } catch (e) {
126
+ return false;
127
+ }
128
+ }
package/src/http.js CHANGED
@@ -1,7 +1,5 @@
1
1
  import axios from 'axios'
2
- // display currency (these requests use the global axios, not salla's instance). Legacy-BE stores
3
- // (isLegacyBuyNowBE) keep the old behavior — no override. Set once on the axios defaults so
4
- // every method inherits it without overriding the rest of the headers.
2
+
5
3
  function setBaseCurrencyHeader() {
6
4
  axios.defaults.headers.common.currency = salla?.config?.get?.('user.currency_code');
7
5
  }
package/src/index.js CHANGED
@@ -8,6 +8,14 @@ if (typeof window !== 'undefined') {
8
8
  window.Salla.Payments = window.Salla.Payments || {};
9
9
  }
10
10
 
11
+ // Apple Pay method identifier for the W3C Payment Request API.
12
+ const APPLE_PAY_METHOD = 'https://apple.com/apple-pay';
13
+
14
+ // Captured at module load, before the Apple Pay SDK is injected: real Safari /
15
+ // Apple devices expose `ApplePaySession` natively, whereas non-Safari browsers
16
+ // only get it once the SDK loads. Keeps native-vs-PaymentRequest routing stable.
17
+ const HAS_NATIVE_APPLE_PAY = typeof window !== 'undefined' && !!window.ApplePaySession;
18
+
11
19
  /**
12
20
  * Full Example
13
21
  *
@@ -51,6 +59,11 @@ const SallaApplePay = typeof window !== 'undefined' ? {
51
59
  countryCode: null,
52
60
  totals: [],
53
61
  shippingCompany: null,
62
+ // Set true once onCancel has fired for the current transaction, so the
63
+ // QR (PaymentRequest) dismissal catch can notify on a plain sheet-close
64
+ // without double-firing when a prior handler (e.g. merchant validation)
65
+ // already routed through onCancel. Reset at the start of each transaction.
66
+ canceled: false,
54
67
  init: function () {
55
68
  document.removeEventListener('payments::apple-pay.start-transaction', SallaApplePay.startSession);
56
69
  Salla.event.addEventListener('payments::apple-pay.start-transaction', SallaApplePay.startSession);
@@ -113,14 +126,52 @@ const SallaApplePay = typeof window !== 'undefined' ? {
113
126
  return SallaApplePay.detail?.requiredShippingContactFields?.includes('postalAddress');
114
127
  },
115
128
 
129
+ /**
130
+ * Whether the native Apple Pay flow (Safari / Apple devices) can run. Gated
131
+ * on the module-load capture so the SDK loading later can't flip a
132
+ * non-Safari browser into the native path.
133
+ */
134
+ canUseNative: function () {
135
+ if (!HAS_NATIVE_APPLE_PAY) {
136
+ return false;
137
+ }
138
+ try {
139
+ return ApplePaySession.canMakePayments();
140
+ } catch (e) {
141
+ return false;
142
+ }
143
+ },
144
+
145
+ // Sourced from config (via the transaction detail). No fallback: a missing
146
+ // identifier means Apple Pay isn't configured for this store, and callers
147
+ // treat that null as the guard to skip Apple Pay.
148
+ getMerchantIdentifier: function () {
149
+ return (SallaApplePay.detail && SallaApplePay.detail.merchantIdentifier) || null;
150
+ },
151
+
116
152
  startSession: async function (event) {
117
153
 
118
154
  SallaApplePay.detail = event.detail || event;
119
155
 
156
+ // Fresh transaction → clear any cancel flag from a previous attempt.
157
+ SallaApplePay.canceled = false;
158
+
120
159
  salla.log('🍏 Pay: payments::apple-pay.start-transaction', SallaApplePay.detail);
121
160
 
122
161
  SallaApplePay.initDefault();
123
162
 
163
+ // Native (Safari / Apple devices) → classic ApplePaySession. Otherwise
164
+ // (non-Safari / non-Apple device) → Payment Request API, which renders
165
+ // Apple's "Scan Code with iPhone" QR sheet.
166
+ if (!SallaApplePay.canUseNative()) {
167
+ return SallaApplePay.startPaymentRequestSession(event);
168
+ }
169
+
170
+ return SallaApplePay.startNativeSession(event);
171
+ },
172
+
173
+ startNativeSession: async function (event) {
174
+
124
175
  let version = SallaApplePay.getApplePaySessionVersion();
125
176
  // Normalize to an array: the backend can serialize the networks list as a JSON object
126
177
  // (non-sequential PHP array keys). Copy it so we never mutate the shared `detail` reference.
@@ -254,6 +305,7 @@ const SallaApplePay = typeof window !== 'undefined' ? {
254
305
  },
255
306
 
256
307
  onCancel: (event = {}, message = null) => {
308
+ SallaApplePay.canceled = true;
257
309
  SallaApplePay.detail.onError(message || salla.lang.get('pages.checkout.payment_failed'));
258
310
  Salla.event.createAndDispatch('payments::apple-pay.canceled', event);
259
311
  },
@@ -470,6 +522,400 @@ const SallaApplePay = typeof window !== 'undefined' ? {
470
522
  }
471
523
  },
472
524
 
525
+ /* -------------------------------------------------------------------------
526
+ * Payment Request API flow (non-Safari browsers / non-Apple devices).
527
+ * Apple renders the "Scan Code with iPhone" QR sheet. Reuses the same
528
+ * backend endpoints (validate / address / shipping / recalculate / submit)
529
+ * as the native flow; only the sheet-completion model differs
530
+ * (event.updateWith / response.complete instead of session.completeXxx).
531
+ * ---------------------------------------------------------------------- */
532
+
533
+ paymentRequest: null,
534
+ paymentResponse: null,
535
+
536
+ getCurrency: () => SallaApplePay.detail.currency || 'SAR',
537
+
538
+ prAmount: (value) => ({ currency: SallaApplePay.getCurrency(), value: String(value) }),
539
+
540
+ prTotal: () => {
541
+ const total = SallaApplePay.prepareTotal();
542
+ return { label: total.label, amount: SallaApplePay.prAmount(total.amount) };
543
+ },
544
+
545
+ prDisplayItems: () => SallaApplePay.prepareLineItems().map(item => ({
546
+ label: item.label,
547
+ amount: SallaApplePay.prAmount(item.amount),
548
+ })),
549
+
550
+ prShippingOptions: (selectedId = null) => {
551
+ const options = SallaApplePay.mappingShippingMethods(SallaApplePay.shipping_methods || []);
552
+ return options.map((option, index) => ({
553
+ id: option.identifier,
554
+ label: option.label,
555
+ amount: SallaApplePay.prAmount(option.amount),
556
+ selected: selectedId ? option.identifier === selectedId : index === 0,
557
+ }));
558
+ },
559
+
560
+ prContactOptions: () => {
561
+ const fields = SallaApplePay.detail.requiredShippingContactFields || [];
562
+ return {
563
+ requestPayerName: fields.includes('name'),
564
+ requestPayerEmail: fields.includes('email'),
565
+ requestPayerPhone: fields.includes('phone'),
566
+ requestShipping: fields.includes('postalAddress'),
567
+ shippingType: 'shipping',
568
+ };
569
+ },
570
+
571
+ startPaymentRequestSession: async function () {
572
+ // No merchant identifier in config → Apple Pay isn't set up for this
573
+ // store; bail rather than open a QR sheet that can't be validated.
574
+ const merchantIdentifier = SallaApplePay.getMerchantIdentifier();
575
+ if (!merchantIdentifier) {
576
+ salla.logger.error('🍏 Pay: missing merchant identifier — skipping QR Apple Pay');
577
+ return;
578
+ }
579
+
580
+ const version = SallaApplePay.getApplePaySessionVersion();
581
+ const supportedNetworks = [...(SallaApplePay.detail.supportedNetworks || ['masterCard', 'visa'])];
582
+ if (version === 5 && !supportedNetworks.includes('mada')) {
583
+ supportedNetworks.push('mada');
584
+ }
585
+
586
+ const methodData = [{
587
+ supportedMethods: APPLE_PAY_METHOD,
588
+ data: {
589
+ version,
590
+ merchantIdentifier,
591
+ merchantCapabilities: ['supports3DS'],
592
+ supportedNetworks,
593
+ countryCode: SallaApplePay.detail.countryCode || 'SA',
594
+ },
595
+ }];
596
+
597
+ const requiresShipping = SallaApplePay.isPhysical();
598
+
599
+ const details = {
600
+ total: SallaApplePay.prTotal(),
601
+ displayItems: SallaApplePay.prDisplayItems(),
602
+ };
603
+ if (requiresShipping) {
604
+ details.shippingOptions = SallaApplePay.prShippingOptions();
605
+ }
606
+
607
+ let request;
608
+ try {
609
+ request = new PaymentRequest(methodData, details, SallaApplePay.prContactOptions());
610
+ } catch (error) {
611
+ salla.logger.error('🍏 Pay: Failed to create PaymentRequest', error);
612
+ SallaApplePay.detail.onError(salla.lang.get('pages.checkout.payment_failed'));
613
+ return;
614
+ }
615
+
616
+ SallaApplePay.paymentRequest = request;
617
+
618
+ request.onmerchantvalidation = (event) => {
619
+ event.complete(SallaApplePay.prValidateMerchant(event));
620
+ };
621
+
622
+ if (requiresShipping) {
623
+ request.onshippingaddresschange = (event) => {
624
+ event.updateWith(SallaApplePay.prShippingAddressChange(request));
625
+ };
626
+ request.onshippingoptionchange = (event) => {
627
+ event.updateWith(SallaApplePay.prShippingOptionChange(request));
628
+ };
629
+ }
630
+
631
+ // Showing the sheet is the QR equivalent of ApplePaySession.begin(): it
632
+ // must run inside the user gesture (transient activation). Wrapped so the
633
+ // order-options deferral can drive it — the caller either calls begin()
634
+ // itself (no options → open the QR sheet now) or discards this request
635
+ // and the modal's own Apple Pay button starts a fresh one (options found).
636
+ const beginShow = async () => {
637
+ // The QR sheet is browser chrome (fixed, top layer) that overlays the
638
+ // page. Signal open/closed so hosts embedding Apple Pay (e.g. the
639
+ // mini-checkout modal in an iframe) can hide UI — like their own close
640
+ // button — that would otherwise collide with the sheet.
641
+ Salla.event.dispatch('payments::apple-pay.sheet-opened');
642
+ try {
643
+ const response = await request.show();
644
+ SallaApplePay.paymentResponse = response;
645
+ await SallaApplePay.prPaymentAuthorized(response);
646
+ } catch (error) {
647
+ // AbortError → the shopper dismissed the sheet / QR, or merchant
648
+ // validation rejected. Mirror the native oncancel path so hosts
649
+ // listening for payments::apple-pay.canceled (to reset checkout UI)
650
+ // still get notified on a plain dismissal. The `canceled` guard
651
+ // skips the double-fire when validation already routed through
652
+ // onCancel via validateMerchant.onFailed.
653
+ salla.logger.log('🍏 Pay: PaymentRequest closed', error);
654
+ if (error?.name === 'AbortError' && !SallaApplePay.canceled) {
655
+ SallaApplePay.onCancel({});
656
+ }
657
+ } finally {
658
+ SallaApplePay.paymentRequest = null;
659
+ SallaApplePay.paymentResponse = null;
660
+ Salla.event.dispatch('payments::apple-pay.sheet-closed');
661
+ }
662
+ };
663
+
664
+ // Mirror the native path (startNativeSession): expose a hook between
665
+ // request creation and show(). Returning truthy skips the immediate
666
+ // show() so the order-options modal can open first. Same shim contract
667
+ // as native — the caller only needs `.begin()`.
668
+ let skipBegin;
669
+ if (typeof SallaApplePay.detail.onStartedSession === 'function') {
670
+ skipBegin = SallaApplePay.detail.onStartedSession({ begin: beginShow });
671
+ }
672
+ if (!skipBegin) {
673
+ await beginShow();
674
+ }
675
+ },
676
+
677
+ /** Merchant validation — resolves the promise passed to event.complete(). */
678
+ prValidateMerchant: async function (event) {
679
+ Salla.event.dispatch('payments::apple-pay.validate-merchant.init', event);
680
+ try {
681
+ const { data } = await http.post(
682
+ SallaApplePay.detail.validateMerchant.url.replace('{id}', SallaApplePay.id),
683
+ { validation_url: event.validationURL }
684
+ );
685
+
686
+ // The BE can return HTTP 200 while Apple actually rejected the session
687
+ // (e.g. statusCode 417 "not a registered merchant in WWDR"), with the
688
+ // real session under data.data. A valid Apple session always carries a
689
+ // merchantSessionIdentifier; its absence means validation failed. Without
690
+ // this the QR sheet just closes silently — notify the shopper and abort.
691
+ if (!data?.data?.merchantSessionIdentifier) {
692
+ salla.logger.error('🍏 Pay: merchant validation failed', data?.statusMessage || data);
693
+ Salla.event.dispatch('payments::apple-pay.validate-merchant.failed', { data });
694
+ SallaApplePay.onCancel({});
695
+ // Dismiss the sheet + paired device immediately. Rejecting
696
+ // event.complete() alone leaves them spinning until Apple's own
697
+ // timeout; abort() forces an instant close. Deferred a tick so it
698
+ // runs after this validation update settles (abort() throws if
699
+ // called while an update is still in flight).
700
+ setTimeout(() => {
701
+ try { SallaApplePay.paymentRequest?.abort(); } catch (e) { /* already closing */ }
702
+ }, 0);
703
+ const validationError = new Error('Apple Pay merchant validation failed');
704
+ validationError.handled = true;
705
+ throw validationError;
706
+ }
707
+
708
+ Salla.event.dispatch('payments::apple-pay.validate-merchant.success', data);
709
+
710
+ if (typeof SallaApplePay.detail.validateMerchant.onSuccess === 'function') {
711
+ const response = await SallaApplePay.detail.validateMerchant.onSuccess(data);
712
+ if (response?.redirect) {
713
+ window.location = response.redirect;
714
+ }
715
+ }
716
+
717
+ return data.data;
718
+ } catch (error) {
719
+ // Skip when the invalid-session branch above already notified via onCancel.
720
+ if (!error?.handled) {
721
+ const response = error?.response;
722
+ Salla.event.dispatch('payments::apple-pay.validate-merchant.failed', response);
723
+ if (typeof SallaApplePay.detail.validateMerchant.onFailed === 'function') {
724
+ SallaApplePay.detail.validateMerchant.onFailed(response);
725
+ }
726
+ }
727
+ // Rethrow so PaymentRequest aborts the sheet.
728
+ throw error;
729
+ }
730
+ },
731
+
732
+ /**
733
+ * ISO 3166 alpha-2 code → localized country name. The native path sends the
734
+ * country *name* in `country` (from ApplePayPaymentContact.country) and the
735
+ * ISO code in `country_code`; W3C PaymentAddress only exposes the ISO code,
736
+ * so we derive the name the backend expects. Falls back to the code if
737
+ * Intl.DisplayNames is unavailable or the code is unknown.
738
+ */
739
+ prCountryName: (isoCode) => {
740
+ if (!isoCode) {
741
+ return isoCode;
742
+ }
743
+ try {
744
+ const locale = (salla?.config?.get?.('user.language_code')) || 'en';
745
+ return new Intl.DisplayNames([locale], { type: 'region' }).of(isoCode) || isoCode;
746
+ } catch (e) {
747
+ return isoCode;
748
+ }
749
+ },
750
+
751
+ /** Shipping address selected in the sheet → returns a PaymentDetailsUpdate. */
752
+ prShippingAddressChange: async function (request) {
753
+ const address = request.shippingAddress;
754
+ const fallback = {
755
+ total: SallaApplePay.prTotal(),
756
+ displayItems: SallaApplePay.prDisplayItems(),
757
+ shippingOptions: [],
758
+ };
759
+
760
+ if (!address) {
761
+ return fallback;
762
+ }
763
+
764
+ try {
765
+ const { data } = await http.post(
766
+ SallaApplePay.detail.shippingContactSelected.url.replace('{id}', SallaApplePay.id),
767
+ {
768
+ country: SallaApplePay.prCountryName(address.country),
769
+ city: address.city,
770
+ local: address.dependentLocality || address.region || address.city,
771
+ description: address.region,
772
+ street: (address.addressLine || []).join(', ') || address.region,
773
+ country_code: address.country,
774
+ postal_code: address.postalCode,
775
+ }
776
+ );
777
+
778
+ SallaApplePay.address_id = data.data.address_id;
779
+ SallaApplePay.shipping_methods = data.data.shipping_methods;
780
+
781
+ if (!SallaApplePay.shipping_methods?.length) {
782
+ return { ...fallback, error: salla.lang.get('pages.checkout.payment_failed') };
783
+ }
784
+
785
+ await SallaApplePay.selectApplePayShippingMethod(SallaApplePay.shipping_methods[0]);
786
+ await SallaApplePay.recalculateTotal();
787
+
788
+ return {
789
+ total: SallaApplePay.prTotal(),
790
+ displayItems: SallaApplePay.prDisplayItems(),
791
+ shippingOptions: SallaApplePay.prShippingOptions(),
792
+ };
793
+ } catch (error) {
794
+ salla.logger.warn('🍏 Pay: PR shipping address change failed', error);
795
+ return { ...fallback, error: salla.lang.get('pages.checkout.payment_failed') };
796
+ }
797
+ },
798
+
799
+ /** Shipping option selected in the sheet → returns a PaymentDetailsUpdate. */
800
+ prShippingOptionChange: async function (request) {
801
+ const identifier = request.shippingOption;
802
+ if (!identifier) {
803
+ return {
804
+ total: SallaApplePay.prTotal(),
805
+ displayItems: SallaApplePay.prDisplayItems(),
806
+ shippingOptions: SallaApplePay.prShippingOptions(),
807
+ };
808
+ }
809
+
810
+ const ids = identifier.split(',');
811
+ const shippingMethod = {
812
+ ship_id: ids[0],
813
+ private_ship_id: typeof ids[1] === 'undefined' ? null : ids[1],
814
+ type: typeof ids[2] === 'undefined' ? null : ids[2],
815
+ route_id: typeof ids[3] === 'undefined' ? null : ids[3],
816
+ };
817
+
818
+ try {
819
+ if (SallaApplePay.shouldUpdateShippingCompany(shippingMethod)) {
820
+ await SallaApplePay.selectApplePayShippingMethod(shippingMethod);
821
+ await SallaApplePay.recalculateTotal();
822
+ }
823
+ return {
824
+ total: SallaApplePay.prTotal(),
825
+ displayItems: SallaApplePay.prDisplayItems(),
826
+ shippingOptions: SallaApplePay.prShippingOptions(identifier),
827
+ };
828
+ } catch (error) {
829
+ salla.logger.warn('🍏 Pay: PR shipping option change failed', error);
830
+ return {
831
+ total: SallaApplePay.prTotal(),
832
+ displayItems: SallaApplePay.prDisplayItems(),
833
+ shippingOptions: SallaApplePay.prShippingOptions(identifier),
834
+ error: salla.lang.get('pages.checkout.payment_failed'),
835
+ };
836
+ }
837
+ },
838
+
839
+ /** Map the PaymentResponse payer fields to the contact shape the API expects. */
840
+ prContactFromResponse: (response) => {
841
+ const name = (response.payerName || '').trim();
842
+ const spaceIndex = name.indexOf(' ');
843
+ return {
844
+ givenName: spaceIndex > -1 ? name.slice(0, spaceIndex) : name,
845
+ familyName: spaceIndex > -1 ? name.slice(spaceIndex + 1) : '',
846
+ emailAddress: response.payerEmail || null,
847
+ phoneNumber: response.payerPhone || null,
848
+ countryCode: response.shippingAddress?.country || SallaApplePay.detail.countryCode || null,
849
+ };
850
+ },
851
+
852
+ /** Authorize + submit the payment, then close the sheet. */
853
+ prPaymentAuthorized: async function (response) {
854
+ // Guest checkout → persist the payer contact before submitting.
855
+ if (SallaApplePay.detail.guestContactSelected) {
856
+ const contact = SallaApplePay.prContactFromResponse(response);
857
+
858
+ // Require familyName too, mirroring the native guest guard
859
+ // (utils.js mutateShippingContact). Keeps the payload the backend
860
+ // receives identical to the production native flow — it has only
861
+ // ever been sent a non-empty last_name from Apple Pay guest checkout.
862
+ if (!contact.emailAddress || !contact.givenName || !contact.familyName || !contact.phoneNumber) {
863
+ await response.complete('fail');
864
+ SallaApplePay.detail.onError(salla.lang.get('common.messages.required_fields'));
865
+ return;
866
+ }
867
+
868
+ try {
869
+ await http.post(
870
+ SallaApplePay.detail.guestContactSelected.url.replace('{id}', SallaApplePay.id),
871
+ {
872
+ email: contact.emailAddress,
873
+ first_name: contact.givenName,
874
+ last_name: contact.familyName,
875
+ phone_number: contact.phoneNumber,
876
+ country_code: contact.countryCode,
877
+ }
878
+ );
879
+ } catch (error) {
880
+ await response.complete('fail');
881
+ SallaApplePay.detail.onError(
882
+ error?.response?.data?.error?.message || salla.lang.get('pages.checkout.payment_failed')
883
+ );
884
+ return;
885
+ }
886
+ }
887
+
888
+ Salla.event.dispatch('payments::apple-pay.authorized.init', response);
889
+
890
+ let authorizedData;
891
+ try {
892
+ const { data } = await http.post(
893
+ SallaApplePay.detail.authorized.url.replace('{id}', SallaApplePay.id),
894
+ { payment_method: 'apple_pay', applepay_token: JSON.stringify(response.details) }
895
+ );
896
+
897
+ authorizedData = data;
898
+ Salla.event.dispatch('payments::apple-pay.authorized.success', data);
899
+ await response.complete('success');
900
+ } catch (error) {
901
+ const errorResponse = error?.response;
902
+ Salla.event.dispatch('payments::apple-pay.authorized.failed', errorResponse);
903
+ await response.complete('fail');
904
+ if (typeof SallaApplePay.detail.authorized.onFailed === 'function') {
905
+ SallaApplePay.detail.authorized.onFailed(errorResponse);
906
+ }
907
+ return;
908
+ }
909
+
910
+ // response.complete() has already run above, and it can only be called
911
+ // once. Run the merchant onSuccess callback OUTSIDE the try/catch so a
912
+ // throw here (e.g. redirect handling) can't fall into the catch and fire
913
+ // a second complete('fail') → InvalidStateError that masks the real error.
914
+ if (typeof SallaApplePay.detail.authorized.onSuccess === 'function') {
915
+ SallaApplePay.detail.authorized.onSuccess(authorizedData);
916
+ }
917
+ },
918
+
473
919
  shouldUpdateShippingCompany(shippingCompany) {
474
920
  return SallaApplePay.shippingCompany?.ship_id != shippingCompany?.ship_id || SallaApplePay.shippingCompany?.private_ship_id != shippingCompany?.private_ship_id
475
921
  },
@@ -593,8 +1039,10 @@ if (typeof window !== 'undefined') {
593
1039
  }
594
1040
 
595
1041
  //applePay doesn't allow iframes
596
- // SSR-safe: only initialize in browser
597
- if (typeof window !== 'undefined' && window.ApplePaySession?.canMakePayments()) {
1042
+ // SSR-safe (init only in the browser). Native (Safari) OR any browser with the
1043
+ // Payment Request API (non-Safari / QR-code fallback) can start a transaction;
1044
+ // capability is confirmed per-flow.
1045
+ if (typeof window !== 'undefined' && (SallaApplePay.canUseNative() || window.PaymentRequest)) {
598
1046
  SallaApplePay.init();
599
1047
  } else if (typeof document !== 'undefined') {
600
1048
  // 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>