@o2vend/theme-cli 1.0.36 → 1.0.38

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.
Files changed (90) hide show
  1. package/README.md +4 -0
  2. package/lib/lib/dev-server.js +344 -48
  3. package/lib/lib/liquid-engine.js +3 -1
  4. package/lib/lib/mock-data.js +473 -119
  5. package/lib/lib/widget-service.js +12 -4
  6. package/package.json +2 -2
  7. package/test-theme/assets/async-sections.js +32 -24
  8. package/test-theme/assets/cart-drawer.js +20 -22
  9. package/test-theme/assets/cart-manager.js +1 -15
  10. package/test-theme/assets/checkout-price-handler.js +12 -11
  11. package/test-theme/assets/checkout.css +1415 -0
  12. package/test-theme/assets/checkout.js +3174 -0
  13. package/test-theme/assets/components.css +178 -29
  14. package/test-theme/assets/delivery-zone.js +1 -1
  15. package/test-theme/assets/product-detail.css +1050 -0
  16. package/test-theme/assets/product-detail.js +2940 -0
  17. package/test-theme/assets/theme.css +95 -120
  18. package/test-theme/assets/theme.js +781 -186
  19. package/test-theme/layout/theme.liquid +91 -17
  20. package/test-theme/sections/content.liquid +64 -57
  21. package/test-theme/sections/footer-fallback.liquid +57 -7
  22. package/test-theme/sections/footer.liquid +63 -12
  23. package/test-theme/sections/header-fallback.liquid +41 -41
  24. package/test-theme/sections/header.liquid +41 -51
  25. package/test-theme/sections/hero-fallback.liquid +1 -1
  26. package/test-theme/sections/hero.liquid +159 -136
  27. package/test-theme/snippets/account-sidebar.liquid +121 -29
  28. package/test-theme/snippets/add-to-cart-modal.liquid +258 -206
  29. package/test-theme/snippets/breadcrumbs.liquid +98 -11
  30. package/test-theme/snippets/cart-drawer.liquid +93 -0
  31. package/test-theme/snippets/delivery-zone-city-selector.liquid +101 -15
  32. package/test-theme/snippets/delivery-zone-modal.liquid +529 -84
  33. package/test-theme/snippets/delivery-zone-search.liquid +104 -18
  34. package/test-theme/snippets/login-modal.liquid +269 -82
  35. package/test-theme/snippets/mega-menu.liquid +130 -43
  36. package/test-theme/snippets/news-thumbnail.liquid +120 -28
  37. package/test-theme/snippets/pagination.liquid +1 -1
  38. package/test-theme/snippets/price.liquid +100 -9
  39. package/test-theme/snippets/product-card-related.liquid +22 -4
  40. package/test-theme/snippets/product-card-simple.liquid +521 -25
  41. package/test-theme/snippets/product-card.liquid +145 -232
  42. package/test-theme/snippets/rating.liquid +100 -9
  43. package/test-theme/snippets/skeleton-collection-grid.liquid +94 -8
  44. package/test-theme/snippets/skeleton-product-card.liquid +102 -16
  45. package/test-theme/snippets/skeleton-product-grid.liquid +87 -1
  46. package/test-theme/snippets/social-sharing.liquid +133 -32
  47. package/test-theme/templates/account/dashboard.liquid +30 -0
  48. package/test-theme/templates/account/loyalty-redemption.liquid +29 -28
  49. package/test-theme/templates/account/loyalty.liquid +45 -43
  50. package/test-theme/templates/account/order-detail.liquid +15 -8
  51. package/test-theme/templates/account/orders.liquid +189 -35
  52. package/test-theme/templates/account/profile.liquid +509 -114
  53. package/test-theme/templates/account/register.liquid +18 -8
  54. package/test-theme/templates/account/return-orders.liquid +31 -30
  55. package/test-theme/templates/account/store-credit.liquid +27 -26
  56. package/test-theme/templates/account/subscriptions.liquid +22 -5
  57. package/test-theme/templates/account/wishlist.liquid +88 -19
  58. package/test-theme/templates/address-book.liquid +166 -69
  59. package/test-theme/templates/categories.liquid +90 -30
  60. package/test-theme/templates/checkout.liquid +137 -3834
  61. package/test-theme/templates/error.liquid +23 -21
  62. package/test-theme/templates/index.liquid +29 -0
  63. package/test-theme/templates/login.liquid +33 -6
  64. package/test-theme/templates/order-confirmation.liquid +67 -9
  65. package/test-theme/templates/page.liquid +418 -206
  66. package/test-theme/templates/product-detail.liquid +124 -3878
  67. package/test-theme/templates/products.liquid +155 -30
  68. package/test-theme/templates/search.liquid +739 -225
  69. package/test-theme/widgets/brand-carousel.liquid +102 -82
  70. package/test-theme/widgets/brand.liquid +78 -50
  71. package/test-theme/widgets/carousel.liquid +253 -121
  72. package/test-theme/widgets/category-list-carousel.liquid +32 -8
  73. package/test-theme/widgets/category-list.liquid +21 -6
  74. package/test-theme/widgets/category.liquid +104 -37
  75. package/test-theme/widgets/discount-time.liquid +326 -119
  76. package/test-theme/widgets/footer-menu.liquid +115 -23
  77. package/test-theme/widgets/footer.liquid +118 -5
  78. package/test-theme/widgets/gallery.liquid +29 -5
  79. package/test-theme/widgets/header-menu.liquid +25 -13
  80. package/test-theme/widgets/header.liquid +64 -26
  81. package/test-theme/widgets/html.liquid +29 -6
  82. package/test-theme/widgets/news.liquid +6 -0
  83. package/test-theme/widgets/product-canvas.liquid +20 -12
  84. package/test-theme/widgets/product-carousel.liquid +118 -56
  85. package/test-theme/widgets/shared/product-grid.liquid +12 -0
  86. package/test-theme/widgets/single-product.liquid +688 -250
  87. package/test-theme/widgets/spacebar-carousel.liquid +39 -10
  88. package/test-theme/widgets/spacebar.liquid +77 -6
  89. package/test-theme/widgets/splash.liquid +40 -30
  90. package/test-theme/widgets/testimonial-carousel.liquid +111 -67
@@ -0,0 +1,3174 @@
1
+ /**
2
+ * O2VEND Checkout - Main logic
3
+ * Expects globals from inline config: CHECKOUT_TOKEN, CHECKOUT_PRICING, STATES_DATA, COUNTRIES_DATA,
4
+ * CHECKOUT_SHIPPING_STATE, CHECKOUT_BILLING_STATE, CHECKOUT_SHIPPING_METHOD_HANDLE, CHECKOUT_CURRENCY_SYMBOL
5
+ */
6
+ (() => {
7
+ const _log = console.log.bind(console);
8
+ const percentDecode = (s) => {
9
+ if (typeof s !== 'string') return s || '';
10
+ try {
11
+ return s.replace(/\+/g, ' ').replace(/%([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
12
+ } catch {
13
+ return s;
14
+ }
15
+ };
16
+ const debugLog = (...args) => { if (window.DEBUG_CHECKOUT) _log.apply(console, args); };
17
+ debugLog('[CHECKOUT] Checkout pricing data:', typeof CHECKOUT_PRICING !== 'undefined' ? CHECKOUT_PRICING : null);
18
+ debugLog('[CHECKOUT] Cart totals:', {
19
+ total: typeof CHECKOUT_CART_TOTAL !== 'undefined' ? CHECKOUT_CART_TOTAL : 0,
20
+ subTotal: typeof CHECKOUT_CART_SUBTOTAL !== 'undefined' ? CHECKOUT_CART_SUBTOTAL : 0,
21
+ taxAmount: typeof CHECKOUT_CART_TAX !== 'undefined' ? CHECKOUT_CART_TAX : 0
22
+ });
23
+
24
+ // Flag to prevent API calls during checkout completion
25
+ window.checkoutInProgress = false;
26
+ window.checkoutSubmitLock = false;
27
+ window.activeCheckoutToken = window.activeCheckoutToken || null;
28
+
29
+ // Status tracking for API calls
30
+ window.checkoutApiStatus = {
31
+ shippingAddress: 'idle',
32
+ billingAddress: 'idle',
33
+ shippingMethodsFetch: 'idle',
34
+ shippingMethodUpdate: 'idle',
35
+ orderNote: 'idle'
36
+ };
37
+
38
+ // Track if shipping method has been selected/updated
39
+ window.shippingMethodSelected = false;
40
+ window.shippingMethodAutoUpdateInProgress = false;
41
+
42
+ // Initialize payment gateway event system early (before apps try to use it)
43
+ window.checkoutPaymentEvents = (() => {
44
+ const events = {};
45
+ return {
46
+ emit: (eventName, data) => {
47
+ debugLog('[CHECKOUT] Emitting payment event:', eventName, data);
48
+ if (!events[eventName]) {
49
+ events[eventName] = [];
50
+ }
51
+ events[eventName].forEach((handler) => {
52
+ try {
53
+ handler(data);
54
+ } catch (error) {
55
+ console.error('[CHECKOUT] Error in payment event handler:', error);
56
+ }
57
+ });
58
+ },
59
+ on: (eventName, handler) => {
60
+ if (!events[eventName]) {
61
+ events[eventName] = [];
62
+ }
63
+ events[eventName].push(handler);
64
+ debugLog('[CHECKOUT] Registered listener for payment event:', eventName);
65
+ },
66
+ off: (eventName, handler) => {
67
+ if (events[eventName]) {
68
+ events[eventName] = events[eventName].filter(h => h !== handler);
69
+ }
70
+ }
71
+ };
72
+ })();
73
+
74
+ // Check if shipping methods are required (section exists and has methods)
75
+ const isShippingMethodRequired = () => {
76
+ const container = document.getElementById('shipping-methods-container');
77
+
78
+ // Check if container has shipping method radio buttons
79
+ if (container) {
80
+ const shippingMethodRadios = container.querySelectorAll('input[type="radio"][name^="shippingMethod"]');
81
+ return shippingMethodRadios.length > 0;
82
+ }
83
+
84
+ return false;
85
+ }
86
+
87
+ // Update checkout button state based on pending API calls and shipping method selection
88
+ function updateCheckoutButtonState() {
89
+ const submitBtn = document.getElementById('checkout-submit');
90
+ if (!submitBtn) return;
91
+
92
+ const buttonText = submitBtn.querySelector('.checkout-button-text');
93
+ const buttonIcon = submitBtn.querySelector('.checkout-button-icon');
94
+ const originalText = submitBtn.getAttribute('data-original-text') || 'Complete Order';
95
+
96
+ const hasPendingCalls = Object.values(window.checkoutApiStatus).some(status => status === 'pending');
97
+
98
+ // Check if shipping address is complete
99
+ const shippingAddressComplete = isShippingAddressComplete();
100
+
101
+ // Check if shipping method is required and not selected
102
+ const shippingMethodRequired = isShippingMethodRequired();
103
+ const shippingMethodNotSelected = shippingMethodRequired && !window.shippingMethodSelected;
104
+
105
+ // Determine status message based on pending operations (priority order)
106
+ let statusMessage = null;
107
+
108
+ if (window.checkoutApiStatus.shippingMethodUpdate === 'pending') {
109
+ statusMessage = 'Calculating shipping fee...';
110
+ } else if (window.checkoutApiStatus.shippingMethodsFetch === 'pending') {
111
+ statusMessage = 'Loading shipping options...';
112
+ } else if (window.checkoutApiStatus.shippingAddress === 'pending') {
113
+ statusMessage = 'Updating shipping address...';
114
+ } else if (window.checkoutApiStatus.billingAddress === 'pending') {
115
+ statusMessage = 'Updating billing address...';
116
+ } else if (window.checkoutApiStatus.orderNote === 'pending') {
117
+ statusMessage = 'Saving order note...';
118
+ } else if (!shippingAddressComplete) {
119
+ statusMessage = 'Please complete your shipping address';
120
+ } else if (shippingMethodNotSelected) {
121
+ statusMessage = 'Please select a shipping method';
122
+ }
123
+
124
+ // Decide if the button should be disabled for this state
125
+ // Disable if: pending calls, incomplete address, or missing shipping method
126
+ const shouldDisable = hasPendingCalls || !shippingAddressComplete || shippingMethodNotSelected;
127
+ submitBtn.disabled = shouldDisable;
128
+
129
+ // If we are disabling the button but don't have a specific reason,
130
+ // show a generic fallback message so the user always sees some context.
131
+ if (!statusMessage && shouldDisable) {
132
+ statusMessage = 'Updating checkout...';
133
+ }
134
+
135
+ if (statusMessage && buttonText) {
136
+ buttonText.textContent = statusMessage;
137
+ // Add loading class for visual feedback
138
+ submitBtn.classList.add('checkout-button-loading');
139
+ // Optionally show spinner by rotating icon
140
+ if (buttonIcon) {
141
+ buttonIcon.style.animation = 'spin 1s linear infinite';
142
+ }
143
+ } else if (buttonText) {
144
+ // Restore original text
145
+ buttonText.textContent = originalText;
146
+ submitBtn.classList.remove('checkout-button-loading');
147
+ // Remove spinner animation
148
+ if (buttonIcon) {
149
+ buttonIcon.style.animation = '';
150
+ }
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Get checkout token from server context, URL, or cookie
156
+ * This function is used by multiple checkout functions
157
+ * Exposed globally for apps to use
158
+ */
159
+ window.getCheckoutToken = function getCheckoutToken() {
160
+ if (window.activeCheckoutToken) {
161
+ return window.activeCheckoutToken;
162
+ }
163
+
164
+ // First, try to use the token from server-side context (most reliable)
165
+ if (typeof CHECKOUT_TOKEN !== 'undefined' && CHECKOUT_TOKEN) {
166
+ return CHECKOUT_TOKEN;
167
+ }
168
+
169
+ // Check URL parameter (hosted checkout: /checkout/{token})
170
+ const pathParts = window.location.pathname.split('/');
171
+ const tokenIndex = pathParts.indexOf('checkout');
172
+ if (tokenIndex !== -1 && tokenIndex < pathParts.length - 1) {
173
+ const tokenFromPath = pathParts[tokenIndex + 1];
174
+ if (tokenFromPath && tokenFromPath !== 'checkout') {
175
+ return tokenFromPath;
176
+ }
177
+ }
178
+
179
+ // Fallback to cookie
180
+ const cookies = document.cookie.split(';');
181
+ for (let cookie of cookies) {
182
+ const [name, value] = cookie.trim().split('=');
183
+ if (name === 'checkoutToken') {
184
+ return percentDecode(value);
185
+ }
186
+ }
187
+ return null;
188
+ }
189
+
190
+ function updateCheckoutTokenFromApi(result) {
191
+ const nextToken = result?.checkoutToken || result?.data?.checkoutToken || null;
192
+ if (nextToken && nextToken !== window.activeCheckoutToken) {
193
+ console.warn('[CHECKOUT] Checkout token refreshed/recovered');
194
+ window.activeCheckoutToken = nextToken;
195
+ }
196
+ }
197
+
198
+ function getCheckoutApiErrorMessage(result, fallbackMessage) {
199
+ if (result && typeof result.error === 'string' && result.error.trim()) {
200
+ return result.error;
201
+ }
202
+ return fallbackMessage || 'Checkout request failed. Please try again.';
203
+ }
204
+
205
+ function resolveCheckoutErrorMessage(result, fallbackMessage) {
206
+ const code = result?.code || result?.errorCode || '';
207
+ const rawMessage = getCheckoutApiErrorMessage(result, fallbackMessage);
208
+ const normalized = String(rawMessage || '').toLowerCase();
209
+ if (code === 'CHECKOUT_TOKEN_EXPIRED' || normalized.includes('expired')) {
210
+ return 'Your checkout session expired. We refreshed your session. Please review details and continue.';
211
+ }
212
+ if (code === 'CHECKOUT_STALE' || normalized.includes('another device') || normalized.includes('cart/checkout state changed')) {
213
+ return 'Your cart changed on another device. Please review your cart and continue checkout.';
214
+ }
215
+ if (code === 'CHECKOUT_ALREADY_PROCESSING' || normalized.includes('already being processed')) {
216
+ return 'Your order is already being processed. Please wait a few seconds.';
217
+ }
218
+ if (normalized.includes('signature') && normalized.includes('invalid')) {
219
+ return 'Checkout security validation failed once and was retried. Please try again if needed.';
220
+ }
221
+ return rawMessage || 'Checkout request failed. Please try again.';
222
+ }
223
+
224
+ /**
225
+ * Button loading utility function
226
+ * Handles button loading states with optional loading text
227
+ * Supports both checkout-button-text and btn-text class naming conventions
228
+ */
229
+ function setButtonLoading(button, loading, loadingText = null) {
230
+ if (!button) return;
231
+
232
+ // Support both checkout-button-text and btn-text class names
233
+ const btnText = button.querySelector('.checkout-button-text') ||
234
+ button.querySelector('.btn-text');
235
+ const btnLoading = button.querySelector('.btn-loading');
236
+
237
+ if (loading) {
238
+ button.disabled = true;
239
+ button.classList.add('loading');
240
+ if (btnText) btnText.style.display = 'none';
241
+ if (btnLoading) {
242
+ btnLoading.style.display = 'flex';
243
+ if (loadingText && btnLoading.querySelector('span:last-child')) {
244
+ btnLoading.querySelector('span:last-child').textContent = loadingText;
245
+ }
246
+ } else if (loadingText) {
247
+ button.textContent = loadingText;
248
+ }
249
+ } else {
250
+ button.disabled = false;
251
+ button.classList.remove('loading');
252
+ if (btnText) btnText.style.display = 'inline';
253
+ if (btnLoading) btnLoading.style.display = 'none';
254
+ }
255
+ }
256
+
257
+ // STATES_DATA and COUNTRIES_DATA set by inline config in checkout.liquid
258
+
259
+ // Debug: Log states data structure
260
+ debugLog('[CHECKOUT] States data:', STATES_DATA);
261
+ debugLog('[CHECKOUT] Countries data:', COUNTRIES_DATA);
262
+
263
+ // Helper to get states for a country (by ID or code)
264
+ function getStatesForCountry(countryIdentifier) {
265
+ if (!countryIdentifier) return null;
266
+
267
+ // Try to parse as number (country ID)
268
+ const countryId = parseInt(countryIdentifier, 10);
269
+ const isNumericId = !isNaN(countryId);
270
+
271
+ const codeUpper = String(countryIdentifier).toUpperCase();
272
+ const codeLower = String(countryIdentifier).toLowerCase();
273
+
274
+ debugLog('[CHECKOUT] Looking for states for country:', countryIdentifier, isNumericId ? '(ID)' : '(code)');
275
+ debugLog('[CHECKOUT] COUNTRIES_DATA:', COUNTRIES_DATA);
276
+
277
+ // First, check if countries array has statesOrProvinces
278
+ // The structure is: countries array with objects containing id, code2 and statesOrProvinces
279
+ if (Array.isArray(COUNTRIES_DATA) && COUNTRIES_DATA.length > 0) {
280
+ const country = COUNTRIES_DATA.find(c => {
281
+ // Match by ID first (if identifier is numeric)
282
+ if (isNumericId) {
283
+ const cId = c.id || c.countryId;
284
+ if (cId === countryId || String(cId) === String(countryId)) {
285
+ return true;
286
+ }
287
+ }
288
+
289
+ // Match by code2 (primary), code, countryCode, or name
290
+ const cCode2 = c.code2 || '';
291
+ const cCode = c.code || '';
292
+ const cCountryCode = c.countryCode || '';
293
+ const cName = c.name || '';
294
+
295
+ return cCode2.toUpperCase() === codeUpper ||
296
+ cCode2.toLowerCase() === codeLower ||
297
+ cCode.toUpperCase() === codeUpper ||
298
+ cCode.toLowerCase() === codeLower ||
299
+ cCountryCode.toUpperCase() === codeUpper ||
300
+ cCountryCode.toLowerCase() === codeLower ||
301
+ cName === countryIdentifier;
302
+ });
303
+
304
+ if (country) {
305
+ debugLog('[CHECKOUT] Found country:', country);
306
+ // Check for statesOrProvinces property
307
+ if (country.statesOrProvinces && Array.isArray(country.statesOrProvinces)) {
308
+ debugLog('[CHECKOUT] Found statesOrProvinces with', country.statesOrProvinces.length, 'states');
309
+ return country.statesOrProvinces;
310
+ }
311
+ if (country.states && Array.isArray(country.states)) {
312
+ debugLog('[CHECKOUT] Found states with', country.states.length, 'states');
313
+ return country.states;
314
+ }
315
+ } else {
316
+ debugLog('[CHECKOUT] Country not found in COUNTRIES_DATA for:', countryIdentifier);
317
+ }
318
+ }
319
+
320
+ // Fallback: Check if STATES_DATA is an object with country codes as keys (e.g., {IN: [...], US: [...]})
321
+ if (STATES_DATA && typeof STATES_DATA === 'object' && !Array.isArray(STATES_DATA)) {
322
+ // Try exact match
323
+ if (STATES_DATA[countryIdentifier]) {
324
+ return STATES_DATA[countryIdentifier];
325
+ }
326
+ if (STATES_DATA[codeUpper]) {
327
+ return STATES_DATA[codeUpper];
328
+ }
329
+ if (STATES_DATA[codeLower]) {
330
+ return STATES_DATA[codeLower];
331
+ }
332
+
333
+ // Try case-insensitive match
334
+ for (const key in STATES_DATA) {
335
+ if (key.toUpperCase() === codeUpper || key.toLowerCase() === codeLower) {
336
+ return STATES_DATA[key];
337
+ }
338
+ }
339
+ }
340
+
341
+ return null;
342
+ }
343
+
344
+ // Convert state dropdown to text input when no states are available
345
+ let convertStateToTextInput = (stateSelect, selectedState = null) => {
346
+ if (!stateSelect) return;
347
+
348
+ const formGroup = stateSelect.closest('.form-group');
349
+ if (!formGroup) return;
350
+
351
+ // Check if already converted
352
+ if (stateSelect.tagName === 'INPUT') return;
353
+
354
+ // Find or create text input (we have both select and input in the template)
355
+ let textInput = document.getElementById('shipping-state-text');
356
+ if (!textInput) {
357
+ // Create text input if it doesn't exist
358
+ textInput = document.createElement('input');
359
+ textInput.type = 'text';
360
+ textInput.id = stateSelect.id + '-text';
361
+ textInput.name = stateSelect.name;
362
+ textInput.className = 'form-input';
363
+ textInput.required = true;
364
+ textInput.placeholder = 'Enter state/province';
365
+ formGroup.appendChild(textInput);
366
+ }
367
+
368
+ // Hide select and show text input
369
+ stateSelect.style.display = 'none';
370
+ stateSelect.removeAttribute('required');
371
+ textInput.style.display = 'block';
372
+ textInput.required = true;
373
+
374
+ if (selectedState) {
375
+ textInput.value = selectedState;
376
+ }
377
+
378
+ debugLog('[CHECKOUT] Converted state dropdown to text input for country without states');
379
+ }
380
+
381
+ // Populate states dropdown based on selected country
382
+ function populateStates(countrySelect, stateSelect, selectedState = null) {
383
+ if (!stateSelect || !countrySelect) {
384
+ console.warn('[CHECKOUT] Missing country or state select element');
385
+ return;
386
+ }
387
+
388
+ const countryIdentifier = countrySelect.value;
389
+ debugLog('[CHECKOUT] Populating states for country:', countryIdentifier);
390
+
391
+ // Clear existing options
392
+ stateSelect.innerHTML = '<option value="">Select a state</option>';
393
+
394
+ if (!countryIdentifier) {
395
+ debugLog('[CHECKOUT] No country selected');
396
+ return;
397
+ }
398
+
399
+ // Try to find states for this country (by ID or code) using helper function
400
+ let countryStates = getStatesForCountry(countryIdentifier);
401
+
402
+ if (countryStates) {
403
+ debugLog('[CHECKOUT] Found states for country:', countryIdentifier, countryStates);
404
+ }
405
+
406
+ if (!countryStates) {
407
+ debugLog('[CHECKOUT] No states found for country:', countryIdentifier);
408
+ // If no states found, convert dropdown to text input
409
+ convertStateToTextInput(stateSelect, selectedState);
410
+ return;
411
+ }
412
+
413
+ debugLog('[CHECKOUT] Found states for country:', countryStates);
414
+
415
+ // Handle different data structures
416
+ let statesArray = [];
417
+
418
+ if (Array.isArray(countryStates)) {
419
+ // Array format: [{code: 'CA', name: 'California'}, ...]
420
+ statesArray = countryStates;
421
+ } else if (typeof countryStates === 'object') {
422
+ // Object format: {CA: 'California', NY: 'New York'} or {states: [...]}
423
+ if (countryStates.states && Array.isArray(countryStates.states)) {
424
+ statesArray = countryStates.states;
425
+ } else {
426
+ // Convert object to array
427
+ statesArray = Object.entries(countryStates).map(([code, name]) => ({
428
+ code: code,
429
+ name: typeof name === 'string' ? name : (name.name || name.code || code)
430
+ }));
431
+ }
432
+ }
433
+
434
+ // De-duplicate states to avoid repeated options when upstream data contains duplicates.
435
+ const seenStateKeys = new Set();
436
+ const uniqueStates = [];
437
+ statesArray.forEach((state) => {
438
+ const stateCode = state && (state.code || state.abbreviation || state.isoCode || '');
439
+ const stateName = state && (state.name || state.label || '');
440
+ const normalizedCode = String(stateCode).trim().toLowerCase();
441
+ const normalizedName = String(stateName).trim().toLowerCase().replace(/\s+/g, ' ');
442
+ const key = normalizedCode ? `code:${normalizedCode}` : `name:${normalizedName}`;
443
+
444
+ if (!seenStateKeys.has(key)) {
445
+ seenStateKeys.add(key);
446
+ uniqueStates.push(state);
447
+ }
448
+ });
449
+
450
+ // Populate the dropdown
451
+ uniqueStates.forEach(state => {
452
+ const option = document.createElement('option');
453
+ // Handle state object structure: {id: 1, name: 'Andaman and Nicobar Islands', code: 'IN-AN'}
454
+ const stateId = state.id || state.stateOrProvinceId || state.stateId;
455
+ const stateCode = state.code || (typeof state === 'string' ? state : Object.keys(state)[0]);
456
+ const stateName = state.name || state.label || (typeof state === 'string' ? state : state[stateCode]) || stateCode;
457
+
458
+ // Use stateId as the value (required by API)
459
+ option.value = stateId ? String(stateId) : stateCode;
460
+ option.textContent = stateName;
461
+ if (stateId) {
462
+ option.dataset.stateId = String(stateId);
463
+ }
464
+
465
+ // Check if this should be selected
466
+ if (selectedState) {
467
+ const selectedStateStr = String(selectedState).toLowerCase();
468
+ const stateIdStr = stateId ? String(stateId).toLowerCase() : '';
469
+ const stateCodeStr = String(stateCode).toLowerCase();
470
+ const stateNameStr = String(stateName).toLowerCase();
471
+
472
+ if (stateIdStr && stateIdStr === selectedStateStr) {
473
+ option.selected = true;
474
+ } else if (stateCodeStr === selectedStateStr ||
475
+ stateNameStr === selectedStateStr ||
476
+ stateCodeStr.includes(selectedStateStr) ||
477
+ stateNameStr.includes(selectedStateStr)) {
478
+ option.selected = true;
479
+ }
480
+ }
481
+
482
+ stateSelect.appendChild(option);
483
+ });
484
+
485
+ debugLog('[CHECKOUT] Populated', uniqueStates.length, 'states');
486
+ }
487
+
488
+ // Initialize states dropdowns when DOM is ready
489
+ function initializeStateDropdowns() {
490
+ debugLog('[CHECKOUT] initializeStateDropdowns() called');
491
+ const shippingCountrySelect = document.getElementById('shipping-country');
492
+ const shippingStateSelect = document.getElementById('shipping-state');
493
+ const billingCountrySelect = document.getElementById('billing-country');
494
+ const billingStateSelect = document.getElementById('billing-state');
495
+
496
+ debugLog('[CHECKOUT] Country select:', shippingCountrySelect);
497
+ debugLog('[CHECKOUT] State select:', shippingStateSelect);
498
+ debugLog('[CHECKOUT] Selected country value:', shippingCountrySelect?.value);
499
+
500
+ if (!shippingCountrySelect || !shippingStateSelect) {
501
+ console.warn('[CHECKOUT] Shipping country/state selects not found');
502
+ return;
503
+ }
504
+
505
+ // Populate shipping states on page load
506
+ const initShippingStates = () => {
507
+ const selectedCountry = shippingCountrySelect.value;
508
+ // Try to get stateOrProvinceId first, fallback to province name/code
509
+ const selectedState = typeof CHECKOUT_SHIPPING_STATE !== 'undefined' ? CHECKOUT_SHIPPING_STATE : null;
510
+
511
+ debugLog('[CHECKOUT] Initializing shipping states. Country:', selectedCountry, 'State:', selectedState);
512
+
513
+ if (selectedCountry) {
514
+ debugLog('[CHECKOUT] Calling populateStates for country:', selectedCountry);
515
+ try {
516
+ populateStates(shippingCountrySelect, shippingStateSelect, selectedState);
517
+ debugLog('[CHECKOUT] populateStates completed');
518
+ } catch (error) {
519
+ console.error('[CHECKOUT] Error in populateStates:', error);
520
+ }
521
+
522
+ // After populating states, check if address is complete and fetch shipping methods
523
+ setTimeout(() => {
524
+ if (typeof checkAndFetchShippingMethods === 'function') {
525
+ checkAndFetchShippingMethods();
526
+ }
527
+ }, 200);
528
+ }
529
+ };
530
+
531
+ // Initialize immediately if country is already selected
532
+ if (shippingCountrySelect.value) {
533
+ initShippingStates();
534
+ } else {
535
+ // Only use setTimeout if country is not already selected (to wait for DOM)
536
+ setTimeout(initShippingStates, 100);
537
+ }
538
+
539
+ // Handle country change
540
+ shippingCountrySelect.addEventListener('change', (e) => {
541
+ debugLog('[CHECKOUT] Shipping country changed to:', e.target.value);
542
+ populateStates(shippingCountrySelect, shippingStateSelect);
543
+ // Trigger address update to fetch shipping methods
544
+ if (typeof checkAndFetchShippingMethods === 'function') {
545
+ checkAndFetchShippingMethods();
546
+ }
547
+ });
548
+
549
+ // Handle state change
550
+ shippingStateSelect.addEventListener('change', (e) => {
551
+ debugLog('[CHECKOUT] Shipping state changed to:', e.target.value);
552
+ if (typeof checkAndFetchShippingMethods === 'function') {
553
+ checkAndFetchShippingMethods();
554
+ }
555
+ });
556
+
557
+ // Populate billing states
558
+ if (billingCountrySelect && billingStateSelect) {
559
+ const initBillingStates = () => {
560
+ const selectedCountry = billingCountrySelect.value;
561
+ const selectedState = typeof CHECKOUT_BILLING_STATE !== 'undefined' ? CHECKOUT_BILLING_STATE : null;
562
+
563
+ debugLog('[CHECKOUT] Initializing billing states. Country:', selectedCountry, 'State:', selectedState);
564
+
565
+ if (selectedCountry) {
566
+ populateStates(billingCountrySelect, billingStateSelect, selectedState);
567
+ }
568
+ };
569
+
570
+ // Initialize immediately if country is already selected
571
+ if (billingCountrySelect.value) {
572
+ initBillingStates();
573
+ }
574
+
575
+ // Also initialize after a short delay
576
+ setTimeout(initBillingStates, 100);
577
+
578
+ // Handle country change
579
+ billingCountrySelect.addEventListener('change', (e) => {
580
+ debugLog('[CHECKOUT] Billing country changed to:', e.target.value);
581
+ populateStates(billingCountrySelect, billingStateSelect);
582
+ });
583
+ }
584
+ }
585
+
586
+ // Initialize intl-tel-input for shipping phone
587
+ function initializePhoneInput() {
588
+ debugLog('[CHECKOUT] initializePhoneInput() called');
589
+ const shippingPhoneInput = document.getElementById('shipping-phone');
590
+ debugLog('[CHECKOUT] shippingPhoneInput element:', shippingPhoneInput);
591
+ debugLog('[CHECKOUT] intlTelInput available:', typeof intlTelInput !== 'undefined');
592
+
593
+ if (!shippingPhoneInput) {
594
+ console.warn('[CHECKOUT] shipping-phone input element not found');
595
+ return;
596
+ }
597
+
598
+ if (typeof intlTelInput === 'undefined') {
599
+ console.warn('[CHECKOUT] intlTelInput library not loaded yet');
600
+ return;
601
+ }
602
+
603
+ if (shippingPhoneInput && typeof intlTelInput !== 'undefined') {
604
+ // Get existing phone number value
605
+ const existingPhone = shippingPhoneInput.value || '';
606
+
607
+ // Check the country select value synchronously before initializing
608
+ const countrySelect = document.getElementById('shipping-country');
609
+ let initialCountry = 'auto';
610
+ if (countrySelect && countrySelect.value) {
611
+ const countryCode = countrySelect.options[countrySelect.selectedIndex]?.getAttribute('data-country-code2');
612
+ if (countryCode) {
613
+ initialCountry = countryCode.toLowerCase();
614
+ }
615
+ }
616
+
617
+ // Build configuration object for intl-tel-input
618
+ const itiConfig = {
619
+ utilsScript: 'https://cdn.jsdelivr.net/npm/intl-tel-input@23.0.0/build/js/utils.js',
620
+ initialCountry: initialCountry,
621
+ preferredCountries: ['us', 'gb', 'ca', 'au', 'in'],
622
+ separateDialCode: true,
623
+ nationalMode: false,
624
+ // Render outside clipping/overflow parents so country list stays clickable.
625
+ dropdownContainer: document.body
626
+ };
627
+
628
+ // Only include geoIpLookup when using auto-detection
629
+ if (initialCountry === 'auto') {
630
+ itiConfig.geoIpLookup = (callback) => {
631
+ // Try to get country from shipping address country select (use outer scope variable)
632
+ if (countrySelect && countrySelect.value) {
633
+ const countryCode = countrySelect.options[countrySelect.selectedIndex]?.getAttribute('data-country-code2');
634
+ if (countryCode) {
635
+ callback(countryCode.toLowerCase());
636
+ return;
637
+ }
638
+ }
639
+ // Fallback to auto-detection via IP
640
+ fetch('https://ipapi.co/json/')
641
+ .then(res => res.json())
642
+ .then(data => callback(data.country_code ? data.country_code.toLowerCase() : 'us'))
643
+ .catch(() => callback('us'));
644
+ };
645
+ }
646
+
647
+ // Initialize intl-tel-input
648
+ debugLog('[CHECKOUT] Initializing intl-tel-input with config:', itiConfig);
649
+ let iti;
650
+ try {
651
+ iti = intlTelInput(shippingPhoneInput, itiConfig);
652
+ debugLog('[CHECKOUT] intl-tel-input initialized successfully');
653
+ } catch (error) {
654
+ console.error('[CHECKOUT] Error initializing intl-tel-input:', error);
655
+ return;
656
+ }
657
+
658
+ // Store the instance for later use
659
+ window.shippingPhoneIti = iti;
660
+
661
+ // Set existing phone number if available (after a small delay to ensure utils are loaded)
662
+ if (existingPhone) {
663
+ // Use setTimeout to ensure utils script has loaded
664
+ setTimeout(() => {
665
+ iti.setNumber(existingPhone);
666
+ // Phone changes will trigger address update, which will fetch shipping methods
667
+ }, 100);
668
+ }
669
+
670
+ // Update country when shipping country changes (countrySelect already declared above)
671
+ if (countrySelect) {
672
+ countrySelect.addEventListener('change', (e) => {
673
+ const sel = e.target;
674
+ const countryCode = sel.options[sel.selectedIndex]?.getAttribute('data-country-code2');
675
+ if (countryCode) {
676
+ iti.setCountry(countryCode.toLowerCase());
677
+ }
678
+ });
679
+ }
680
+
681
+ // Add listeners to update button state when phone changes
682
+ shippingPhoneInput.addEventListener('input', () => {
683
+ if (typeof updateCheckoutButtonState === 'function') {
684
+ updateCheckoutButtonState();
685
+ }
686
+ });
687
+ shippingPhoneInput.addEventListener('blur', () => {
688
+ if (typeof updateCheckoutButtonState === 'function') {
689
+ updateCheckoutButtonState();
690
+ }
691
+ });
692
+ }
693
+ }
694
+
695
+ // Initialize when DOM is ready
696
+ function initializeCheckout() {
697
+ debugLog('[CHECKOUT] Initializing checkout functions...');
698
+ debugLog('[CHECKOUT] intlTelInput available:', typeof intlTelInput !== 'undefined');
699
+ debugLog('[CHECKOUT] shipping-phone element:', document.getElementById('shipping-phone'));
700
+ debugLog('[CHECKOUT] shipping-country element:', document.getElementById('shipping-country'));
701
+
702
+ try {
703
+ initializeStateDropdowns();
704
+ debugLog('[CHECKOUT] State dropdowns initialized');
705
+ } catch (error) {
706
+ console.error('[CHECKOUT] Error initializing state dropdowns:', error);
707
+ }
708
+
709
+ try {
710
+ initializePhoneInput();
711
+ debugLog('[CHECKOUT] Phone input initialized');
712
+ } catch (error) {
713
+ console.error('[CHECKOUT] Error initializing phone input:', error);
714
+ }
715
+
716
+ // Initialize button state
717
+ if (typeof updateCheckoutButtonState === 'function') {
718
+ updateCheckoutButtonState();
719
+ }
720
+
721
+ // On first load: ensure address is saved to backend, then fetch shipping methods.
722
+ // The shipping-methods API requires the checkout address to be persisted first.
723
+ // Calling updateShippingAddress() will save the address and, on success, call
724
+ // checkAndFetchShippingMethods(). This fixes the case where shipping options stay
725
+ // "Loading..." until the user blurs a field (which triggers updateShippingAddress).
726
+ const shouldRetryInitialShippingFetch = () => {
727
+ if (!isShippingAddressComplete()) return false;
728
+ if (window.checkoutInProgress) return false;
729
+ if (window.shippingMethodsFetchInProgress) return false;
730
+ if (window.checkoutApiStatus.shippingAddress === 'pending') return false;
731
+ if (window.checkoutApiStatus.shippingMethodsFetch === 'pending') return false;
732
+ if (window.checkoutShippingMethods && window.shippingMethodSelected) return false;
733
+ return true;
734
+ };
735
+
736
+ const initialLoadDelay = 1800;
737
+ setTimeout(() => {
738
+ if (!shouldRetryInitialShippingFetch()) return;
739
+ if (typeof updateShippingAddress === 'function') {
740
+ updateShippingAddress();
741
+ } else if (typeof checkAndFetchShippingMethods === 'function') {
742
+ checkAndFetchShippingMethods();
743
+ }
744
+ }, initialLoadDelay);
745
+ // Fallback: if updateShippingAddress returns early (e.g. state not ready), retry
746
+ setTimeout(() => {
747
+ if (typeof checkAndFetchShippingMethods === 'function' && shouldRetryInitialShippingFetch()) {
748
+ checkAndFetchShippingMethods();
749
+ }
750
+ }, initialLoadDelay + 1200);
751
+ // Third retry: handles slow state dropdowns or intl-tel-input init
752
+ setTimeout(() => {
753
+ if (typeof checkAndFetchShippingMethods === 'function' && shouldRetryInitialShippingFetch()) {
754
+ checkAndFetchShippingMethods();
755
+ }
756
+ }, initialLoadDelay + 3200);
757
+ }
758
+
759
+ // Wait for intl-tel-input to load if needed
760
+ function waitForIntlTelInput(callback, maxAttempts = 20, attempt = 0) {
761
+ if (typeof intlTelInput !== 'undefined') {
762
+ callback();
763
+ return;
764
+ }
765
+
766
+ if (attempt >= maxAttempts) {
767
+ console.warn('[CHECKOUT] intlTelInput not loaded after', maxAttempts * 100, 'ms, initializing without it');
768
+ callback();
769
+ return;
770
+ }
771
+
772
+ setTimeout(() => {
773
+ waitForIntlTelInput(callback, maxAttempts, attempt + 1);
774
+ }, 100);
775
+ }
776
+
777
+ if (document.readyState === 'loading') {
778
+ document.addEventListener('DOMContentLoaded', () => {
779
+ waitForIntlTelInput(initializeCheckout);
780
+ });
781
+ } else {
782
+ // DOM is already ready
783
+ waitForIntlTelInput(initializeCheckout);
784
+ }
785
+
786
+ // Update order summary payment fee and total when payment method changes
787
+ window.updatePaymentFeeDisplay = function updatePaymentFeeDisplay(paymentFee) {
788
+ const feeLine = document.querySelector('[data-summary-payment-fee-line]');
789
+ const feeValueEl = document.querySelector('[data-summary-payment-fee]');
790
+ const totalEl = document.querySelector('[data-summary-total]');
791
+ if (!feeLine || !feeValueEl || !totalEl) return;
792
+
793
+ const fee = parseFloat(paymentFee) || 0;
794
+ const oldFeeStr = feeValueEl.textContent.replace(/[^\d.-]/g, '');
795
+ const oldFee = parseFloat(oldFeeStr) || 0;
796
+ const currentTotalStr = totalEl.textContent.replace(/[^\d.-]/g, '');
797
+ const baseTotal = (parseFloat(currentTotalStr) || 0) - oldFee;
798
+
799
+ const fmt = typeof formatMoney === 'function' ? formatMoney : (n) => {
800
+ const sym = document.body.dataset.shopCurrencySymbol || (typeof CHECKOUT_CURRENCY_SYMBOL !== 'undefined' ? CHECKOUT_CURRENCY_SYMBOL : '₹');
801
+ return sym + (parseFloat(n) || 0).toFixed(2);
802
+ };
803
+ feeValueEl.textContent = fee > 0 ? fmt(fee) : (document.body.dataset.shopCurrencySymbol || (typeof CHECKOUT_CURRENCY_SYMBOL !== 'undefined' ? CHECKOUT_CURRENCY_SYMBOL : '₹')) + '0.00';
804
+ feeLine.style.display = fee > 0 ? 'flex' : 'none';
805
+ totalEl.textContent = fmt(baseTotal + fee);
806
+ };
807
+
808
+ function initPaymentFeeFromSelection() {
809
+ const selected = document.querySelector('input[name="paymentMethod"]:checked');
810
+ if (selected && typeof window.updatePaymentFeeDisplay === 'function') {
811
+ const fee = parseFloat(selected.getAttribute('data-payment-fee') || '0') || 0;
812
+ window.updatePaymentFeeDisplay(fee);
813
+ }
814
+ }
815
+
816
+ // Dynamically filter gateway payment options using backend payment-app discovery.
817
+ async function applyPaymentAppAvailability() {
818
+ const radios = Array.from(document.querySelectorAll('input[name="paymentMethod"]'));
819
+ if (!radios.length) return;
820
+
821
+ const gatewayRadios = radios.filter((radio) => (radio.getAttribute('data-payment-type') || '') === 'PaymentGateway');
822
+ if (!gatewayRadios.length) return;
823
+
824
+ try {
825
+ const checkoutToken = getCheckoutToken();
826
+ const endpoint = checkoutToken
827
+ ? `/webstoreapi/payment/apps?token=${encodeURIComponent(checkoutToken)}`
828
+ : '/webstoreapi/payment/apps';
829
+ const response = await fetch(endpoint, {
830
+ method: 'GET',
831
+ headers: { 'Accept': 'application/json' }
832
+ });
833
+ if (!response.ok) return;
834
+
835
+ const result = await response.json();
836
+ const apps = Array.isArray(result?.data) ? result.data : [];
837
+ const methodAvailability = new Map();
838
+ apps.forEach((app) => {
839
+ const isAvailable = !!app.available;
840
+ const methodIds = Array.isArray(app.paymentMethodIds) ? app.paymentMethodIds : [];
841
+ methodIds.forEach((methodId) => methodAvailability.set(String(methodId), isAvailable));
842
+ });
843
+
844
+ // Guard against partial discovery responses.
845
+ // If backend does not report all gateway ids, keep existing checkout methods visible.
846
+ const gatewayMethodIds = gatewayRadios.map((radio) => String(radio.getAttribute('data-payment-id') || radio.value));
847
+ const hasFullCoverage = gatewayMethodIds.length > 0 && gatewayMethodIds.every((id) => methodAvailability.has(id));
848
+ if (!hasFullCoverage) {
849
+ debugLog('[CHECKOUT] Skipping gateway visibility filter due to partial app discovery response');
850
+ return;
851
+ }
852
+
853
+ gatewayRadios.forEach((radio) => {
854
+ const methodId = radio.getAttribute('data-payment-id') || radio.value;
855
+ const isAvailable = methodAvailability.get(String(methodId));
856
+ if (isAvailable === false) {
857
+ radio.disabled = true;
858
+ radio.checked = false;
859
+ const optionContainer = radio.closest('label, .checkout-payment-method, .payment-method-option');
860
+ if (optionContainer) {
861
+ optionContainer.style.display = 'none';
862
+ }
863
+ }
864
+ });
865
+
866
+ const selected = document.querySelector('input[name="paymentMethod"]:checked:not(:disabled)');
867
+ if (!selected) {
868
+ const firstEnabled = document.querySelector('input[name="paymentMethod"]:not(:disabled)');
869
+ if (firstEnabled) {
870
+ firstEnabled.checked = true;
871
+ firstEnabled.dispatchEvent(new Event('change'));
872
+ }
873
+ }
874
+ } catch (error) {
875
+ debugLog('[CHECKOUT] Payment app discovery failed, keeping existing payment options:', error.message);
876
+ }
877
+ }
878
+
879
+ // Handle payment method selection
880
+ function attachPaymentMethodListeners() {
881
+ document.querySelectorAll('input[name="paymentMethod"]').forEach(radio => {
882
+ radio.addEventListener('change', (e) => {
883
+ const el = e.target;
884
+ const paymentType = el.getAttribute('data-payment-type') || '';
885
+ const paymentId = el.getAttribute('data-payment-id') || el.value;
886
+ const gatewayFormsContainer = document.getElementById('payment-gateway-forms');
887
+ const paymentFee = parseFloat(el.getAttribute('data-payment-fee') || '0') || 0;
888
+
889
+ window.updatePaymentFeeDisplay(paymentFee);
890
+
891
+ // Emit payment method selected event for apps to listen
892
+ if (window.checkoutPaymentEvents && window.checkoutPaymentEvents.emit) {
893
+ window.checkoutPaymentEvents.emit('payment:method:selected', {
894
+ paymentMethodId: paymentId,
895
+ paymentType: paymentType,
896
+ value: el.value
897
+ });
898
+ }
899
+
900
+ // Hide all gateway forms
901
+ if (gatewayFormsContainer) {
902
+ gatewayFormsContainer.innerHTML = '';
903
+ gatewayFormsContainer.style.display = 'none';
904
+ }
905
+
906
+ // Show gateway form for PaymentGateway type
907
+ if (paymentType === 'PaymentGateway') {
908
+ loadPaymentGatewayForm(paymentId, gatewayFormsContainer);
909
+ }
910
+
911
+ // Hide card form (legacy support)
912
+ const cardForm = document.getElementById('card-payment-form');
913
+ if (cardForm) {
914
+ cardForm.style.display = 'none';
915
+ }
916
+ });
917
+
918
+ if (radio.checked) {
919
+ radio.dispatchEvent(new Event('change'));
920
+ }
921
+ });
922
+ initPaymentFeeFromSelection();
923
+ }
924
+
925
+ if (document.readyState === 'loading') {
926
+ document.addEventListener('DOMContentLoaded', () => {
927
+ applyPaymentAppAvailability();
928
+ attachPaymentMethodListeners();
929
+ setTimeout(initPaymentFeeFromSelection, 100);
930
+ });
931
+ } else {
932
+ applyPaymentAppAvailability();
933
+ attachPaymentMethodListeners();
934
+ setTimeout(initPaymentFeeFromSelection, 100);
935
+ }
936
+
937
+ // Load payment gateway form
938
+ const loadPaymentGatewayForm = async (paymentMethodId, container) => {
939
+ if (!container || !paymentMethodId) return;
940
+
941
+ try {
942
+ // Check if there's an app snippet for this gateway
943
+ // This would typically be loaded via hook system, but for now we'll handle it client-side
944
+ const gatewayElement = document.querySelector(`[data-gateway-id="${paymentMethodId}"]`);
945
+ if (gatewayElement) {
946
+ // Gateway forms are loaded via app hooks, so we just show the container
947
+ container.style.display = 'block';
948
+ }
949
+ } catch (error) {
950
+ console.error('Error loading payment gateway form:', error);
951
+ }
952
+ }
953
+
954
+ // Update shipping address when form fields change (debounced)
955
+ let shippingAddressTimeout;
956
+ const shippingFields = ['shipping-first-name', 'shipping-last-name', 'shipping-address', 'shipping-city', 'shipping-state', 'shipping-state-text', 'shipping-zip', 'shipping-country', 'shipping-phone'];
957
+
958
+ function attachShippingFieldListeners() {
959
+ const markShippingAddressPending = () => {
960
+ // As soon as the user edits shipping address fields, treat it as a
961
+ // pending update so the checkout button stays disabled until we
962
+ // either send the API call or decide we can't.
963
+ window.checkoutApiStatus.shippingAddress = 'pending';
964
+ updateCheckoutButtonState();
965
+ };
966
+
967
+ shippingFields.forEach(fieldId => {
968
+ const field = document.getElementById(fieldId);
969
+ if (!field) return;
970
+
971
+ // Remove existing listeners by cloning (simple approach)
972
+ const newField = field.cloneNode(true);
973
+ field.parentNode.replaceChild(newField, field);
974
+
975
+ // Re-attach listeners
976
+ newField.addEventListener('blur', () => {
977
+ markShippingAddressPending();
978
+ updateCheckoutButtonState(); // Update button state immediately on blur
979
+ clearTimeout(shippingAddressTimeout);
980
+ shippingAddressTimeout = setTimeout(async () => {
981
+ await updateShippingAddress();
982
+ // updateShippingAddress will call checkAndFetchShippingMethods() after successful update
983
+ }, 500);
984
+ });
985
+
986
+ // Also listen to change/input events for select dropdowns and input fields
987
+ if (newField.tagName === 'SELECT' || newField.tagName === 'INPUT') {
988
+ newField.addEventListener('change', () => {
989
+ markShippingAddressPending();
990
+ updateCheckoutButtonState(); // Update button state immediately on change
991
+ clearTimeout(shippingAddressTimeout);
992
+ shippingAddressTimeout = setTimeout(async () => {
993
+ await updateShippingAddress();
994
+ // updateShippingAddress will call checkAndFetchShippingMethods() after successful update
995
+ }, 500);
996
+ });
997
+ if (newField.tagName === 'INPUT') {
998
+ newField.addEventListener('input', () => {
999
+ markShippingAddressPending();
1000
+ updateCheckoutButtonState(); // Update button state immediately on input
1001
+ clearTimeout(shippingAddressTimeout);
1002
+ shippingAddressTimeout = setTimeout(async () => {
1003
+ await updateShippingAddress();
1004
+ }, 500);
1005
+ });
1006
+ }
1007
+ }
1008
+ });
1009
+ }
1010
+
1011
+ // Attach listeners initially
1012
+ attachShippingFieldListeners();
1013
+
1014
+ // Re-attach after state field might be converted to text input
1015
+ const originalConvertStateToTextInput = convertStateToTextInput;
1016
+ convertStateToTextInput = (stateSelect, selectedState) => {
1017
+ originalConvertStateToTextInput(stateSelect, selectedState);
1018
+ // Re-attach listeners after state field conversion
1019
+ setTimeout(attachShippingFieldListeners, 100);
1020
+ };
1021
+
1022
+ // Helper function to format money using shop settings
1023
+ function formatMoney(amount) {
1024
+ if (amount === null || amount === undefined || isNaN(amount)) {
1025
+ return '0.00';
1026
+ }
1027
+
1028
+ const num = parseFloat(amount);
1029
+ if (isNaN(num)) return String(amount);
1030
+
1031
+ // Get currency settings from page data
1032
+ const currencySymbol = document.body.dataset.shopCurrencySymbol || window.__SHOP_CURRENCY_SYMBOL__ || (typeof CHECKOUT_CURRENCY_SYMBOL !== 'undefined' ? CHECKOUT_CURRENCY_SYMBOL : '₹');
1033
+ const currencyDecimalDigits = 2; // Default to 2 decimal places
1034
+
1035
+ // Check if amount is in cents (if > 1000, likely in cents, otherwise might be in actual currency)
1036
+ // For now, assume API returns actual currency amounts (not cents) based on double type in schema
1037
+ // But handle both cases: if amount seems like cents (> 1000 for typical prices), divide by 100
1038
+ let formattedAmount = num;
1039
+
1040
+ // If the amount is very large (> 1000), it might be in cents/paise
1041
+ // But since API schema shows double (decimal), we'll assume it's already in currency units
1042
+ // However, if prices from API seem to be in cents, we can detect and convert
1043
+
1044
+ // Format with proper decimal places
1045
+ formattedAmount = formattedAmount.toFixed(currencyDecimalDigits);
1046
+
1047
+ // Add thousand separators
1048
+ formattedAmount = formattedAmount.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
1049
+
1050
+ return currencySymbol + formattedAmount;
1051
+ }
1052
+
1053
+ // Check if shipping address is complete
1054
+ function isShippingAddressComplete() {
1055
+ const requiredFields = [
1056
+ 'shipping-first-name',
1057
+ 'shipping-address',
1058
+ 'shipping-city',
1059
+ 'shipping-zip',
1060
+ 'shipping-country'
1061
+ ];
1062
+
1063
+ // State is optional if no states are available for the country
1064
+ const stateField = document.getElementById('shipping-state');
1065
+ const stateTextInput = document.getElementById('shipping-state-text');
1066
+ const stateRequired = stateField && stateField.tagName === 'SELECT' && stateField.options.length > 1;
1067
+
1068
+ const allRequired = requiredFields.every(fieldId => {
1069
+ const field = document.getElementById(fieldId);
1070
+ return field && field.value && field.value.trim() !== '';
1071
+ });
1072
+
1073
+ // Check state if required (dropdown with options)
1074
+ let stateValid = true;
1075
+ if (stateRequired) {
1076
+ stateValid = stateField.value && stateField.value.trim() !== '';
1077
+ } else if (stateTextInput && stateTextInput.style.display !== 'none') {
1078
+ // If state is a text input and visible, it's required
1079
+ stateValid = stateTextInput.value && stateTextInput.value.trim() !== '';
1080
+ }
1081
+
1082
+ // Check phone number (required)
1083
+ const phoneField = document.getElementById('shipping-phone');
1084
+ let phoneValid = false;
1085
+ if (phoneField) {
1086
+ phoneValid = (phoneField.value || '').trim() !== '';
1087
+ }
1088
+
1089
+ return allRequired && stateValid && phoneValid;
1090
+ }
1091
+
1092
+ function getCurrentShippingAddressSyncKey() {
1093
+ const form = document.getElementById('checkout-form');
1094
+ if (!form) return null;
1095
+ const formData = new FormData(form);
1096
+ const countrySelect = document.getElementById('shipping-country');
1097
+ const stateSelect = document.getElementById('shipping-state');
1098
+ const stateTextInput = document.getElementById('shipping-state-text');
1099
+ let stateOrProvinceId = null;
1100
+ if (stateSelect && stateSelect.style.display !== 'none' && stateSelect.value) {
1101
+ stateOrProvinceId = stateSelect.value;
1102
+ } else if (stateTextInput && stateTextInput.style.display !== 'none' && stateTextInput.value) {
1103
+ stateOrProvinceId = stateTextInput.value;
1104
+ }
1105
+ const payload = {
1106
+ shippingFirstName: formData.get('shippingFirstName') || '',
1107
+ shippingLastName: formData.get('shippingLastName') || '',
1108
+ shippingAddress: formData.get('shippingAddress') || '',
1109
+ shippingCity: formData.get('shippingCity') || '',
1110
+ shippingZip: formData.get('shippingZip') || '',
1111
+ shippingPhone: (formData.get('shippingPhone') || '').toString().trim(),
1112
+ countryId: countrySelect ? countrySelect.value : null,
1113
+ stateOrProvinceId: stateOrProvinceId
1114
+ };
1115
+ return JSON.stringify(payload);
1116
+ }
1117
+
1118
+ // Fetch shipping methods when address is complete
1119
+ async function fetchShippingMethods() {
1120
+ // Prevent fetching during checkout completion or if already in progress
1121
+ if (window.checkoutInProgress || window.shippingMethodsFetchInProgress) {
1122
+ debugLog('[CHECKOUT] Skipping shipping methods fetch - checkout in progress or fetch already in progress');
1123
+ return;
1124
+ }
1125
+ if (window.checkoutApiStatus.shippingMethodUpdate === 'pending' || window.shippingMethodAutoUpdateInProgress) {
1126
+ debugLog('[CHECKOUT] Skipping shipping methods fetch while shipping method update is in progress');
1127
+ return;
1128
+ }
1129
+
1130
+ const checkoutToken = getCheckoutToken();
1131
+ if (!checkoutToken) {
1132
+ debugLog('[CHECKOUT] No checkout token, skipping shipping methods fetch');
1133
+ return;
1134
+ }
1135
+
1136
+ const fetchAddressKey = window.lastShippingAddressSyncKey || getCurrentShippingAddressSyncKey();
1137
+ if (
1138
+ fetchAddressKey &&
1139
+ window.lastShippingMethodsFetchKey === fetchAddressKey &&
1140
+ window.checkoutShippingMethods
1141
+ ) {
1142
+ debugLog('[CHECKOUT] Skipping shipping methods API call for already-synced address');
1143
+ return;
1144
+ }
1145
+
1146
+ // Set in-progress flag and create abort controller
1147
+ window.shippingMethodsFetchInProgress = true;
1148
+ window.shippingMethodsAbortController = new AbortController();
1149
+ let shippingFetchTimedOut = false;
1150
+ const shippingFetchTimeoutMs = 15000;
1151
+ const shippingFetchTimeoutId = setTimeout(() => {
1152
+ shippingFetchTimedOut = true;
1153
+ if (window.shippingMethodsAbortController) {
1154
+ window.shippingMethodsAbortController.abort();
1155
+ }
1156
+ }, shippingFetchTimeoutMs);
1157
+
1158
+ // Track status
1159
+ window.checkoutApiStatus.shippingMethodsFetch = 'pending';
1160
+ updateCheckoutButtonState();
1161
+
1162
+ const isHostedCheckout = window.location.pathname.includes('/checkout/') &&
1163
+ window.location.pathname.split('/').length > 2;
1164
+ const endpoint = isHostedCheckout
1165
+ ? `/webstoreapi/checkout/${checkoutToken}/shipping-methods`
1166
+ : '/webstoreapi/checkout/shipping-methods';
1167
+
1168
+ const container = document.getElementById('shipping-methods-container');
1169
+ const section = document.getElementById('shipping-methods-section');
1170
+
1171
+ if (!container || !section) {
1172
+ console.warn('[CHECKOUT] Shipping methods container or section not found');
1173
+ window.checkoutApiStatus.shippingMethodsFetch = 'completed';
1174
+ updateCheckoutButtonState();
1175
+ window.shippingMethodsFetchInProgress = false;
1176
+ window.shippingMethodsAbortController = null;
1177
+ return;
1178
+ }
1179
+
1180
+ container.innerHTML = '<p class="shipping-methods-loading">Loading shipping options...</p>';
1181
+ section.style.display = 'block';
1182
+
1183
+ try {
1184
+ debugLog('[CHECKOUT] Fetching shipping methods from:', endpoint);
1185
+ const response = await fetch(endpoint, {
1186
+ signal: window.shippingMethodsAbortController.signal
1187
+ });
1188
+
1189
+ const result = await response.json();
1190
+ updateCheckoutTokenFromApi(result);
1191
+
1192
+ // Handle error response - check if address is required
1193
+ if (!response.ok) {
1194
+ // Clear previously loaded shipping methods on error
1195
+ window.checkoutShippingMethods = null;
1196
+ window.shippingMethodSelected = false;
1197
+ container.innerHTML = '';
1198
+
1199
+ if (result.requiresAddress || (result.error && result.error.includes('address'))) {
1200
+ debugLog('[CHECKOUT] Shipping address required - address may need to be saved first');
1201
+ // Address form may be complete but not yet persisted. Try saving address, then refetch.
1202
+ if (isShippingAddressComplete() && typeof updateShippingAddress === 'function') {
1203
+ try {
1204
+ await updateShippingAddress();
1205
+ // Schedule refetch after this function returns (and finally clears shippingMethodsFetchInProgress)
1206
+ setTimeout(() => {
1207
+ if (typeof fetchShippingMethods === 'function') fetchShippingMethods();
1208
+ }, 150);
1209
+ } catch (e) {
1210
+ debugLog('[CHECKOUT] Address save failed:', e);
1211
+ }
1212
+ }
1213
+ if (!window.checkoutShippingMethods) {
1214
+ container.innerHTML = '<p class="shipping-methods-message">Please complete your shipping address to see available shipping options.</p>';
1215
+ section.style.display = 'block';
1216
+ }
1217
+ return;
1218
+ }
1219
+ throw new Error(resolveCheckoutErrorMessage(result, `HTTP ${response.status}: ${response.statusText}`));
1220
+ }
1221
+
1222
+ debugLog('[CHECKOUT] Shipping methods response:', result);
1223
+
1224
+ // Handle both wrapped response {success: true, data: [...]} and direct array response
1225
+ let methods = [];
1226
+ if (result.success && result.data && Array.isArray(result.data)) {
1227
+ methods = result.data;
1228
+ } else if (Array.isArray(result)) {
1229
+ methods = result;
1230
+ }
1231
+
1232
+ if (methods.length > 0) {
1233
+ window.lastShippingMethodsFetchKey = fetchAddressKey || window.lastShippingMethodsFetchKey;
1234
+ container.innerHTML = '';
1235
+
1236
+ // Get currently selected shipping method from checkout data
1237
+ const selectedShippingMethodHandle = typeof CHECKOUT_SHIPPING_METHOD_HANDLE !== 'undefined' ? CHECKOUT_SHIPPING_METHOD_HANDLE : null;
1238
+ debugLog('[CHECKOUT] Currently selected shipping method:', selectedShippingMethodHandle);
1239
+
1240
+ // Store methods array for later use
1241
+ window.checkoutShippingMethods = methods;
1242
+
1243
+ // Extract all unique products from all shipping methods
1244
+ const productMap = new Map();
1245
+ methods.forEach((method) => {
1246
+ if (method.products && Array.isArray(method.products)) {
1247
+ method.products.forEach((product) => {
1248
+ const productId = product.productId || product.id;
1249
+ if (productId && !productMap.has(productId)) {
1250
+ productMap.set(productId, product);
1251
+ }
1252
+ });
1253
+ }
1254
+ });
1255
+
1256
+ const allProducts = Array.from(productMap.values());
1257
+
1258
+ // Helper function to get applicable method IDs for a product
1259
+ const getApplicableMethodIds = (productId) => {
1260
+ return methods
1261
+ .filter((method) => {
1262
+ if (!method.products || !Array.isArray(method.products)) return false;
1263
+ return method.products.some((p) => (p.productId || p.id) === productId);
1264
+ })
1265
+ .map((method) => String(method.id || method.index))
1266
+ .sort()
1267
+ .join(',');
1268
+ };
1269
+
1270
+ // Group products by their applicable shipping methods
1271
+ const productGroups = new Map();
1272
+ allProducts.forEach((product) => {
1273
+ const productId = product.productId || product.id;
1274
+ const methodSignature = getApplicableMethodIds(productId);
1275
+
1276
+ if (!productGroups.has(methodSignature)) {
1277
+ productGroups.set(methodSignature, {
1278
+ products: [],
1279
+ methodIds: methodSignature.split(',').filter(id => id)
1280
+ });
1281
+ }
1282
+ productGroups.get(methodSignature).products.push(product);
1283
+ });
1284
+
1285
+ // Create rows for each product group
1286
+ productGroups.forEach((group, methodSignature) => {
1287
+ const productRow = document.createElement('div');
1288
+ productRow.className = 'shipping-product-row';
1289
+
1290
+ // Left side: All products in this group
1291
+ const productColumn = document.createElement('div');
1292
+ productColumn.className = 'shipping-product-column';
1293
+
1294
+ group.products.forEach((product) => {
1295
+ const productItem = document.createElement('div');
1296
+ productItem.className = 'shipping-product-item';
1297
+
1298
+ // Product image
1299
+ const productImage = document.createElement('img');
1300
+ productImage.src = product.thumbnailUrl || '';
1301
+ productImage.alt = product.name || '';
1302
+ productImage.className = 'shipping-product-image';
1303
+ productImage.addEventListener('error', () => {
1304
+ productImage.style.display = 'none';
1305
+ });
1306
+
1307
+ // Product details
1308
+ const productDetails = document.createElement('div');
1309
+ productDetails.className = 'shipping-product-details';
1310
+
1311
+ const productName = document.createElement('div');
1312
+ productName.className = 'shipping-product-name';
1313
+ productName.textContent = product.name || '';
1314
+
1315
+ const productPrice = document.createElement('div');
1316
+ productPrice.className = 'shipping-product-price';
1317
+ const price = product.price !== undefined && product.price !== null ? parseFloat(product.price) : 0;
1318
+ const quantity = product.quantity !== undefined && product.quantity !== null ? parseFloat(product.quantity) : 1;
1319
+ productPrice.textContent = formatMoney(price) + 'x' + quantity;
1320
+
1321
+ productDetails.appendChild(productName);
1322
+ productDetails.appendChild(productPrice);
1323
+
1324
+ productItem.appendChild(productImage);
1325
+ productItem.appendChild(productDetails);
1326
+ productColumn.appendChild(productItem);
1327
+ });
1328
+
1329
+ // Right side: Shared shipping methods for this group
1330
+ const methodsColumn = document.createElement('div');
1331
+ methodsColumn.className = 'shipping-methods-column';
1332
+
1333
+ // Get the applicable methods for this group (all products in group share these)
1334
+ const applicableMethods = methods.filter((method) => {
1335
+ const methodId = String(method.id || method.index);
1336
+ return group.methodIds.includes(methodId);
1337
+ });
1338
+
1339
+ if (applicableMethods.length === 0) {
1340
+ // No shipping methods available for these products
1341
+ const noShippingMsg = document.createElement('div');
1342
+ noShippingMsg.className = 'shipping-unavailable-message';
1343
+ noShippingMsg.textContent = 'Sorry, This product cannot be shipped to your location';
1344
+ methodsColumn.appendChild(noShippingMsg);
1345
+ } else {
1346
+ // Create radio buttons for each applicable shipping method
1347
+ // Use a unique name per group to allow selection for all products in group
1348
+ const radioGroupName = `shippingMethod-group-${methodSignature}`;
1349
+
1350
+ applicableMethods.forEach((method, methodIndex) => {
1351
+ const methodId = method.id || method.index || methodIndex;
1352
+ const methodIdentifier = String(methodId);
1353
+
1354
+ // Check if this method is selected
1355
+ const isSelected = (!selectedShippingMethodHandle && methodIndex === 0) ||
1356
+ (selectedShippingMethodHandle && methodIdentifier === String(selectedShippingMethodHandle));
1357
+
1358
+ const methodDiv = document.createElement('div');
1359
+ methodDiv.className = 'shipping-method';
1360
+
1361
+ const label = document.createElement('label');
1362
+ label.className = 'shipping-method-label';
1363
+
1364
+ const radio = document.createElement('input');
1365
+ radio.type = 'radio';
1366
+ radio.name = radioGroupName;
1367
+ radio.value = methodIdentifier;
1368
+ radio.id = `shipping-method-${methodSignature}-${methodIndex}`;
1369
+ radio.dataset.methodId = methodIdentifier;
1370
+ radio.dataset.methodIndex = methodIndex;
1371
+ radio.checked = isSelected;
1372
+
1373
+ const content = document.createElement('div');
1374
+ content.className = 'shipping-method-content';
1375
+
1376
+ // Title and price row
1377
+ const titleRow = document.createElement('div');
1378
+ titleRow.className = 'shipping-method-title-row';
1379
+
1380
+ const name = document.createElement('span');
1381
+ name.className = 'shipping-method-name';
1382
+ name.textContent = method.title || method.name || 'Shipping';
1383
+
1384
+ const priceSpan = document.createElement('span');
1385
+ priceSpan.className = 'shipping-method-price';
1386
+ if (method.price !== undefined && method.price !== null && parseFloat(method.price) > 0) {
1387
+ priceSpan.textContent = formatMoney(method.price);
1388
+ } else {
1389
+ priceSpan.textContent = formatMoney(0);
1390
+ }
1391
+
1392
+ titleRow.appendChild(name);
1393
+ titleRow.appendChild(priceSpan);
1394
+ content.appendChild(titleRow);
1395
+
1396
+ // Time information row (if available)
1397
+ if (method.shippingTime || method.deliveryTime) {
1398
+ const timeRow = document.createElement('div');
1399
+ timeRow.className = 'shipping-method-time-row';
1400
+
1401
+ if (method.shippingTime) {
1402
+ const shippingTime = document.createElement('div');
1403
+ shippingTime.className = 'shipping-method-time';
1404
+ shippingTime.textContent = 'Ship by: ' + method.shippingTime;
1405
+ timeRow.appendChild(shippingTime);
1406
+ }
1407
+
1408
+ if (method.deliveryTime) {
1409
+ const deliveryTime = document.createElement('div');
1410
+ deliveryTime.className = 'shipping-method-time';
1411
+ deliveryTime.textContent = 'Delivery by: ' + method.deliveryTime;
1412
+ timeRow.appendChild(deliveryTime);
1413
+ }
1414
+
1415
+ content.appendChild(timeRow);
1416
+ }
1417
+
1418
+ label.appendChild(radio);
1419
+ label.appendChild(content);
1420
+ methodDiv.appendChild(label);
1421
+ methodsColumn.appendChild(methodDiv);
1422
+
1423
+ // Add change handler - store method reference in closure
1424
+ ((methodObj) => {
1425
+ radio.addEventListener('change', (e) => {
1426
+ if (e.target.checked) {
1427
+ debugLog('[CHECKOUT] Shipping method changed for product group', methodSignature, 'to method:', methodObj);
1428
+ updateShippingMethod(methodObj);
1429
+ }
1430
+ });
1431
+ })(method);
1432
+ });
1433
+ }
1434
+
1435
+ productRow.appendChild(productColumn);
1436
+ productRow.appendChild(methodsColumn);
1437
+ container.appendChild(productRow);
1438
+ });
1439
+
1440
+ debugLog('[CHECKOUT] Rendered', methods.length, 'shipping methods');
1441
+
1442
+ // Update shipping method on page load if a method is selected but not yet saved
1443
+ // Check if a shipping method is already selected from server
1444
+ if (selectedShippingMethodHandle) {
1445
+ // Shipping method already selected from server, mark as selected
1446
+ window.shippingMethodSelected = true;
1447
+ }
1448
+
1449
+ // Only update if no method was previously selected (to avoid unnecessary API calls)
1450
+ const selectedRadio = container.querySelector('input[type="radio"]:checked');
1451
+ if (selectedRadio && !selectedShippingMethodHandle && !window.shippingMethodSelected) {
1452
+ // No method was previously selected, so update with the default selection
1453
+ const selectedMethodId = selectedRadio.dataset.methodId || selectedRadio.value;
1454
+ const selectedMethod = methods.find((m) => String(m.id || m.index) === String(selectedMethodId));
1455
+ if (selectedMethod) {
1456
+ // Prevent overlapping auto-select PUT/GET race on initial load.
1457
+ window.shippingMethodAutoUpdateInProgress = true;
1458
+ await updateShippingMethod(selectedMethod);
1459
+ window.shippingMethodAutoUpdateInProgress = false;
1460
+ }
1461
+ } else if (selectedRadio && selectedShippingMethodHandle) {
1462
+ // Method is already selected from server, mark as selected
1463
+ window.shippingMethodSelected = true;
1464
+ }
1465
+
1466
+ // Update button state after shipping methods are loaded
1467
+ updateCheckoutButtonState();
1468
+ } else {
1469
+ debugLog('[CHECKOUT] No shipping methods available');
1470
+ container.innerHTML = '<p class="shipping-methods-empty">No shipping methods available for this address.</p>';
1471
+ }
1472
+
1473
+ // Mark as completed
1474
+ window.checkoutApiStatus.shippingMethodsFetch = 'completed';
1475
+ updateCheckoutButtonState();
1476
+ } catch (error) {
1477
+ // Ignore abort errors (they're expected when cancelling)
1478
+ if (error.name === 'AbortError') {
1479
+ debugLog('[CHECKOUT] Shipping methods fetch aborted');
1480
+ // IMPORTANT: Always clear pending status on abort to prevent
1481
+ // "Loading shipping options..." from getting stuck.
1482
+ window.checkoutApiStatus.shippingMethodsFetch = 'completed';
1483
+ updateCheckoutButtonState();
1484
+
1485
+ if (shippingFetchTimedOut) {
1486
+ window.checkoutShippingMethods = null;
1487
+ window.shippingMethodSelected = false;
1488
+ container.innerHTML = '<p class="shipping-methods-error">Loading shipping options timed out. Please try again.</p>';
1489
+ }
1490
+ return;
1491
+ }
1492
+
1493
+ console.error('[CHECKOUT] Error fetching shipping methods:', error);
1494
+
1495
+ // Clear previously loaded shipping methods on error
1496
+ window.checkoutShippingMethods = null;
1497
+ window.shippingMethodSelected = false;
1498
+ container.innerHTML = '';
1499
+
1500
+ // Check if error is about missing address
1501
+ const errorMessage = error.message || '';
1502
+ if (errorMessage.includes('address') || errorMessage.includes('Address')) {
1503
+ container.innerHTML = '<p class="shipping-methods-message">Please complete your shipping address to see available shipping options.</p>';
1504
+ } else {
1505
+ container.innerHTML = '<p class="shipping-methods-error">Unable to load shipping options. Please try again.</p>';
1506
+ }
1507
+
1508
+ // Mark as completed even on error (to allow retry)
1509
+ window.checkoutApiStatus.shippingMethodsFetch = 'completed';
1510
+ updateCheckoutButtonState();
1511
+ } finally {
1512
+ clearTimeout(shippingFetchTimeoutId);
1513
+ window.shippingMethodAutoUpdateInProgress = false;
1514
+ // Always reset in-progress flag and abort controller
1515
+ window.shippingMethodsFetchInProgress = false;
1516
+ window.shippingMethodsAbortController = null;
1517
+
1518
+ // If a refetch was requested while this fetch was running, schedule it now.
1519
+ if (window.shippingMethodsRefetchRequested && !window.checkoutInProgress && isShippingAddressComplete()) {
1520
+ window.shippingMethodsRefetchRequested = false;
1521
+ clearTimeout(window.shippingMethodsTimeout);
1522
+ window.shippingMethodsTimeout = setTimeout(() => {
1523
+ if (!window.shippingMethodsFetchInProgress && !window.checkoutInProgress) {
1524
+ fetchShippingMethods();
1525
+ }
1526
+ }, 150);
1527
+ } else {
1528
+ window.shippingMethodsRefetchRequested = false;
1529
+ }
1530
+ }
1531
+ }
1532
+
1533
+ // Update shipping method
1534
+ async function updateShippingMethod(shippingMethod) {
1535
+ // Prevent updating during checkout completion
1536
+ if (window.checkoutInProgress) return;
1537
+ if (window.checkoutApiStatus.shippingMethodUpdate === 'pending') {
1538
+ debugLog('[CHECKOUT] Shipping method update already in progress, skipping duplicate call');
1539
+ return;
1540
+ }
1541
+
1542
+ const checkoutToken = getCheckoutToken();
1543
+ if (!checkoutToken) {
1544
+ console.error('[CHECKOUT] No checkout token available');
1545
+ return;
1546
+ }
1547
+
1548
+ if (!shippingMethod) {
1549
+ console.error('[CHECKOUT] Shipping method object is required');
1550
+ return;
1551
+ }
1552
+
1553
+ // Track status
1554
+ window.checkoutApiStatus.shippingMethodUpdate = 'pending';
1555
+ updateCheckoutButtonState();
1556
+
1557
+ const isHostedCheckout = window.location.pathname.includes('/checkout/') &&
1558
+ window.location.pathname.split('/').length > 2;
1559
+ const endpoint = isHostedCheckout
1560
+ ? `/webstoreapi/checkout/${checkoutToken}/shipping-method`
1561
+ : '/webstoreapi/checkout/shipping-method';
1562
+
1563
+ debugLog('[CHECKOUT] Updating shipping method:', shippingMethod);
1564
+
1565
+ // Show loading state on selected radio button
1566
+ const methodId = String(shippingMethod.id || shippingMethod.index || '');
1567
+ const allShippingRadios = document.querySelectorAll('input[type="radio"][data-method-id]');
1568
+ let selectedRadio = null;
1569
+ allShippingRadios.forEach((radio) => {
1570
+ if (!selectedRadio && String(radio.dataset.methodId || '') === methodId && radio.checked) {
1571
+ selectedRadio = radio;
1572
+ }
1573
+ });
1574
+ if (!selectedRadio) {
1575
+ allShippingRadios.forEach((radio) => {
1576
+ if (!selectedRadio && String(radio.dataset.methodId || '') === methodId) {
1577
+ selectedRadio = radio;
1578
+ }
1579
+ });
1580
+ }
1581
+ if (selectedRadio) {
1582
+ selectedRadio.disabled = true;
1583
+ }
1584
+
1585
+ try {
1586
+ // API expects shippingMethods array with the selected method object
1587
+ const requestBody = {
1588
+ shippingMethods: [shippingMethod]
1589
+ };
1590
+
1591
+ debugLog('[CHECKOUT] Sending request to:', endpoint, 'with body:', requestBody);
1592
+
1593
+ const response = await fetch(endpoint, {
1594
+ method: 'PUT',
1595
+ headers: {
1596
+ 'Content-Type': 'application/json'
1597
+ },
1598
+ body: JSON.stringify(requestBody)
1599
+ });
1600
+
1601
+ debugLog('[CHECKOUT] Response status:', response.status, response.statusText);
1602
+
1603
+ let result;
1604
+ try {
1605
+ result = await response.json();
1606
+ updateCheckoutTokenFromApi(result);
1607
+ debugLog('[CHECKOUT] Response data:', result);
1608
+ } catch (parseError) {
1609
+ console.error('[CHECKOUT] Failed to parse response as JSON:', parseError);
1610
+ const text = await response.text();
1611
+ console.error('[CHECKOUT] Response text:', text);
1612
+ throw new Error(`Invalid response from server: ${response.status} ${response.statusText}`);
1613
+ }
1614
+
1615
+ if (!response.ok) {
1616
+ const errorMessage = resolveCheckoutErrorMessage(result, `HTTP ${response.status}: ${response.statusText}`);
1617
+ console.error('[CHECKOUT] API error:', errorMessage, result);
1618
+ throw new Error(errorMessage);
1619
+ }
1620
+
1621
+ if (!result.success) {
1622
+ const errorMessage = resolveCheckoutErrorMessage(result, 'Failed to update shipping method');
1623
+ console.error('[CHECKOUT] Request failed:', errorMessage, result);
1624
+ throw new Error(errorMessage);
1625
+ }
1626
+
1627
+ debugLog('[CHECKOUT] Shipping method updated successfully:', result);
1628
+
1629
+ // Re-enable radio button on success
1630
+ if (selectedRadio) {
1631
+ selectedRadio.disabled = false;
1632
+ }
1633
+
1634
+ // Update order summary with new pricing if available
1635
+ if (result.data && result.data.pricing) {
1636
+ // Map API pricing format to template format
1637
+ const mappedPricing = mapPricingFromApi(result.data.pricing);
1638
+ updateOrderSummary(mappedPricing);
1639
+ } else if (result.data) {
1640
+ // Check if pricing fields are directly in result.data
1641
+ const mappedPricing = mapPricingFromApi(result.data);
1642
+ updateOrderSummary(mappedPricing);
1643
+ } else {
1644
+ // Reload page to get updated checkout data with new totals
1645
+ debugLog('[CHECKOUT] Reloading page to update order totals');
1646
+ window.location.reload();
1647
+ }
1648
+
1649
+ // Mark as completed and set shipping method selected flag
1650
+ window.checkoutApiStatus.shippingMethodUpdate = 'completed';
1651
+ window.shippingMethodSelected = true;
1652
+ updateCheckoutButtonState();
1653
+ } catch (error) {
1654
+ console.error('[CHECKOUT] Error updating shipping method:', error);
1655
+ console.error('[CHECKOUT] Error details:', {
1656
+ message: error.message,
1657
+ stack: error.stack,
1658
+ name: error.name
1659
+ });
1660
+
1661
+ // Re-enable radio button on error
1662
+ if (selectedRadio) {
1663
+ selectedRadio.disabled = false;
1664
+ }
1665
+
1666
+ // Show error message to user with more details
1667
+ const container = document.getElementById('shipping-methods-container');
1668
+ if (container) {
1669
+ // Remove existing error messages
1670
+ const existingErrors = container.querySelectorAll('.shipping-methods-error');
1671
+ existingErrors.forEach(el => el.remove());
1672
+
1673
+ const errorMsg = document.createElement('div');
1674
+ errorMsg.className = 'shipping-methods-error';
1675
+ errorMsg.textContent = error.message || 'Failed to update shipping method. Please try again.';
1676
+ errorMsg.style.display = 'block';
1677
+ errorMsg.style.marginTop = '10px';
1678
+ errorMsg.style.padding = '10px';
1679
+ errorMsg.style.backgroundColor = '#fee';
1680
+ errorMsg.style.color = '#c33';
1681
+ errorMsg.style.borderRadius = '4px';
1682
+
1683
+ container.appendChild(errorMsg);
1684
+
1685
+ // Remove error message after 8 seconds
1686
+ setTimeout(() => {
1687
+ errorMsg.remove();
1688
+ }, 8000);
1689
+ }
1690
+
1691
+ // Mark as completed even on error (to allow retry)
1692
+ window.checkoutApiStatus.shippingMethodUpdate = 'completed';
1693
+ window.shippingMethodSelected = false;
1694
+ updateCheckoutButtonState();
1695
+ }
1696
+ }
1697
+
1698
+ // Map API pricing format to template-friendly format
1699
+ function mapPricingFromApi(apiPricing) {
1700
+ if (!apiPricing) return null;
1701
+
1702
+ // API uses: subtotalPrice, totalPrice, totalTax, totalShipping, totalDiscounts, paymentMethodFee
1703
+ // Template expects: subtotal, total, tax, shipping, discount, paymentMethodFee
1704
+ return {
1705
+ subtotal: apiPricing.subtotalPrice || apiPricing.subtotal || 0,
1706
+ total: apiPricing.totalPrice || apiPricing.total || 0,
1707
+ tax: apiPricing.totalTax || apiPricing.tax || 0,
1708
+ shipping: apiPricing.totalShipping || apiPricing.shipping || 0,
1709
+ discount: apiPricing.totalDiscounts || apiPricing.discount || 0,
1710
+ paymentMethodFee: apiPricing.paymentMethodFee ?? apiPricing.paymentFee ?? 0
1711
+ };
1712
+ }
1713
+
1714
+ // Calculate subtotal from cart items
1715
+ function calculateSubtotalFromItems() {
1716
+ const cartItems = document.querySelectorAll('.order-summary-item');
1717
+ let subtotal = 0;
1718
+
1719
+ cartItems.forEach(item => {
1720
+ const priceEl = item.querySelector('[data-item-price]');
1721
+ const qtyEl = item.querySelector('[data-item-quantity]');
1722
+
1723
+ if (priceEl && qtyEl) {
1724
+ const price = parseFloat(priceEl.getAttribute('data-item-price')) || 0;
1725
+ const quantity = parseInt(qtyEl.getAttribute('data-item-quantity')) || 0;
1726
+ subtotal += price * quantity;
1727
+ }
1728
+ });
1729
+
1730
+ return subtotal;
1731
+ }
1732
+
1733
+ // Update subtotal display
1734
+ function updateSubtotalDisplay(subtotal) {
1735
+ const subtotalEl = document.querySelector('[data-summary-subtotal]');
1736
+ if (subtotalEl && subtotal !== null && subtotal !== undefined) {
1737
+ subtotalEl.textContent = formatMoney(subtotal);
1738
+ debugLog('[CHECKOUT] Updated subtotal display:', subtotal);
1739
+ }
1740
+ }
1741
+
1742
+ // Update order summary with new pricing
1743
+ function updateOrderSummary(pricing) {
1744
+ debugLog('[CHECKOUT] Updating order summary with pricing:', pricing);
1745
+
1746
+ if (!pricing) {
1747
+ console.warn('[CHECKOUT] No pricing data provided, calculating from items');
1748
+ // If no pricing provided, calculate from cart items
1749
+ const calculatedSubtotal = calculateSubtotalFromItems();
1750
+ if (calculatedSubtotal > 0) {
1751
+ updateSubtotalDisplay(calculatedSubtotal);
1752
+ }
1753
+ return;
1754
+ }
1755
+
1756
+ // If pricing is in API format, map it first
1757
+ if (pricing.subtotalPrice !== undefined || pricing.totalPrice !== undefined) {
1758
+ pricing = mapPricingFromApi(pricing);
1759
+ debugLog('[CHECKOUT] Mapped pricing from API format:', pricing);
1760
+ }
1761
+
1762
+ const totalsContainer = document.querySelector('.order-summary-totals');
1763
+ if (!totalsContainer) {
1764
+ console.warn('[CHECKOUT] Order summary totals container not found');
1765
+ return;
1766
+ }
1767
+
1768
+ // Update subtotal
1769
+ if (pricing.subtotal !== undefined && pricing.subtotal !== null && pricing.subtotal > 0) {
1770
+ updateSubtotalDisplay(pricing.subtotal);
1771
+ } else {
1772
+ // Fallback to calculating from items
1773
+ const calculatedSubtotal = calculateSubtotalFromItems();
1774
+ if (calculatedSubtotal > 0) {
1775
+ updateSubtotalDisplay(calculatedSubtotal);
1776
+ }
1777
+ }
1778
+
1779
+ // Discount line is handled centrally by updateCheckoutPricing using pricing.discounts/totalDiscounts
1780
+ // to avoid creating duplicate Discount rows in the order summary.
1781
+
1782
+ // Update shipping
1783
+ if (pricing.shipping !== undefined && pricing.shipping !== null) {
1784
+ const shippingEl = document.querySelector('[data-summary-shipping]');
1785
+ if (shippingEl) {
1786
+ shippingEl.textContent = pricing.shipping === 0 ? 'Free' : formatMoney(pricing.shipping);
1787
+ }
1788
+ }
1789
+
1790
+ // Update tax
1791
+ if (pricing.tax !== undefined && pricing.tax !== null) {
1792
+ const taxEl = document.querySelector('[data-summary-tax]');
1793
+ if (taxEl) {
1794
+ taxEl.textContent = formatMoney(pricing.tax);
1795
+ }
1796
+ }
1797
+
1798
+ // Update payment fee - use pricing if provided, else selected method's data-payment-fee
1799
+ let paymentFee = pricing.paymentMethodFee ?? pricing.paymentFee ?? 0;
1800
+ if (paymentFee === 0) {
1801
+ const selectedRadio = document.querySelector('input[name="paymentMethod"]:checked');
1802
+ if (selectedRadio) {
1803
+ paymentFee = parseFloat(selectedRadio.getAttribute('data-payment-fee') || '0') || 0;
1804
+ }
1805
+ }
1806
+ const feeLine = document.querySelector('[data-summary-payment-fee-line]');
1807
+ const feeValueEl = document.querySelector('[data-summary-payment-fee]');
1808
+ if (feeLine && feeValueEl) {
1809
+ const currencySymbol = document.body.dataset.shopCurrencySymbol || (typeof CHECKOUT_CURRENCY_SYMBOL !== 'undefined' ? CHECKOUT_CURRENCY_SYMBOL : '₹');
1810
+ feeValueEl.textContent = paymentFee > 0 ? formatMoney(paymentFee) : currencySymbol + '0.00';
1811
+ feeLine.style.display = paymentFee > 0 ? 'flex' : 'none';
1812
+ }
1813
+
1814
+ // Update total (API totalPrice typically excludes payment fee, so add it)
1815
+ if (pricing.total !== undefined && pricing.total !== null) {
1816
+ const totalEl = document.querySelector('[data-summary-total]');
1817
+ if (totalEl) {
1818
+ const baseTotal = parseFloat(pricing.total) || 0;
1819
+ totalEl.textContent = formatMoney(baseTotal + (paymentFee || 0));
1820
+ }
1821
+ }
1822
+
1823
+ debugLog('[CHECKOUT] Order summary updated successfully');
1824
+ }
1825
+
1826
+ // Initialize subtotal on page load
1827
+ (() => {
1828
+ // Wait for DOM to be ready
1829
+ if (document.readyState === 'loading') {
1830
+ document.addEventListener('DOMContentLoaded', () => {
1831
+ setTimeout(initializeSubtotal, 100);
1832
+ });
1833
+ } else {
1834
+ setTimeout(initializeSubtotal, 100);
1835
+ }
1836
+
1837
+ function initializeSubtotal() {
1838
+ const subtotalEl = document.querySelector('[data-summary-subtotal]');
1839
+ if (!subtotalEl) return;
1840
+
1841
+ // Check if subtotal is already set (from server-side rendering)
1842
+ const currentSubtotal = subtotalEl.textContent.trim();
1843
+
1844
+ // If subtotal is 0 or empty, try to calculate from items
1845
+ if (!currentSubtotal || currentSubtotal === '$0.00' || currentSubtotal === '0.00' || currentSubtotal === '₹0.00') {
1846
+ const calculatedSubtotal = calculateSubtotalFromItems();
1847
+ if (calculatedSubtotal > 0) {
1848
+ updateSubtotalDisplay(calculatedSubtotal);
1849
+ }
1850
+ }
1851
+
1852
+ // Also update if checkout pricing is available
1853
+ if (typeof CHECKOUT_PRICING !== 'undefined' && CHECKOUT_PRICING) {
1854
+ const checkoutPricing = CHECKOUT_PRICING;
1855
+ if (checkoutPricing && checkoutPricing.subtotal) {
1856
+ updateOrderSummary(checkoutPricing);
1857
+ }
1858
+ }
1859
+ // Ensure payment fee is applied (in case updateOrderSummary overwrote it or pricing lacked fee)
1860
+ if (typeof initPaymentFeeFromSelection === 'function') {
1861
+ initPaymentFeeFromSelection();
1862
+ }
1863
+ }
1864
+ })();
1865
+
1866
+ // Track if shipping methods fetch is in progress to prevent duplicate calls
1867
+ window.shippingMethodsFetchInProgress = false;
1868
+ window.shippingMethodsAbortController = null;
1869
+ window.shippingMethodsRefetchRequested = false;
1870
+ window.lastShippingAddressSyncKey = null;
1871
+ window.lastShippingMethodsFetchKey = null;
1872
+
1873
+ // Check and fetch shipping methods if address is complete
1874
+ function checkAndFetchShippingMethods() {
1875
+ // Prevent fetching during checkout completion
1876
+ if (window.checkoutInProgress) return;
1877
+ if (window.checkoutApiStatus.shippingMethodUpdate === 'pending' || window.shippingMethodAutoUpdateInProgress) {
1878
+ debugLog('[CHECKOUT] Delaying shipping methods fetch while shipping method update is pending');
1879
+ return;
1880
+ }
1881
+
1882
+ // Clear any pending timeout
1883
+ clearTimeout(window.shippingMethodsTimeout);
1884
+
1885
+ if (isShippingAddressComplete()) {
1886
+ if (
1887
+ window.lastShippingAddressSyncKey &&
1888
+ window.lastShippingMethodsFetchKey === window.lastShippingAddressSyncKey &&
1889
+ window.checkoutShippingMethods
1890
+ ) {
1891
+ debugLog('[CHECKOUT] Skipping duplicate shipping methods fetch for unchanged address');
1892
+ return;
1893
+ }
1894
+
1895
+ // If a fetch is in progress, request a refetch after cleanup.
1896
+ if (window.shippingMethodsFetchInProgress) {
1897
+ window.shippingMethodsRefetchRequested = true;
1898
+ return;
1899
+ }
1900
+
1901
+ // Debounce the fetch.
1902
+ window.shippingMethodsTimeout = setTimeout(() => {
1903
+ if (!window.shippingMethodsFetchInProgress && !window.checkoutInProgress) {
1904
+ fetchShippingMethods();
1905
+ }
1906
+ }, 500);
1907
+ } else {
1908
+ // Address is incomplete - clear shipping methods
1909
+ const container = document.getElementById('shipping-methods-container');
1910
+ const section = document.getElementById('shipping-methods-section');
1911
+
1912
+ if (container) {
1913
+ container.innerHTML = '<p class="shipping-methods-message">Please complete your shipping address to see available shipping options.</p>';
1914
+ }
1915
+ if (section) {
1916
+ section.style.display = 'block';
1917
+ }
1918
+
1919
+ // Reset shipping method state
1920
+ window.checkoutShippingMethods = null;
1921
+ window.shippingMethodSelected = false;
1922
+ window.shippingMethodsFetchInProgress = false;
1923
+ window.shippingMethodsRefetchRequested = false;
1924
+ window.checkoutApiStatus.shippingMethodsFetch = 'idle';
1925
+ updateCheckoutButtonState();
1926
+ }
1927
+ }
1928
+
1929
+ async function updateShippingAddress() {
1930
+ // Prevent updating during checkout completion
1931
+ if (window.checkoutInProgress) return;
1932
+
1933
+ // Cancel any pending shipping methods fetch when address update starts
1934
+ if (window.shippingMethodsAbortController) {
1935
+ window.shippingMethodsAbortController.abort();
1936
+ window.shippingMethodsAbortController = null;
1937
+ }
1938
+ clearTimeout(window.shippingMethodsTimeout);
1939
+
1940
+ const checkoutToken = getCheckoutToken();
1941
+ if (!checkoutToken) {
1942
+ // No valid checkout, clear pending state if we set it earlier
1943
+ if (window.checkoutApiStatus.shippingAddress === 'pending') {
1944
+ window.checkoutApiStatus.shippingAddress = 'idle';
1945
+ updateCheckoutButtonState();
1946
+ }
1947
+ return;
1948
+ }
1949
+
1950
+ const formData = new FormData(document.getElementById('checkout-form'));
1951
+
1952
+ // Do not send update if phone number is empty
1953
+ // This enforces phone as a required shipping detail at the client side.
1954
+ const phoneField = document.getElementById('shipping-phone');
1955
+ let phoneValid = false;
1956
+ if (phoneField) {
1957
+ phoneValid = (phoneField.value || '').trim() !== '';
1958
+ }
1959
+
1960
+ if (!phoneValid) {
1961
+ console.warn('[CHECKOUT] Shipping address update blocked: phone number is missing or invalid');
1962
+ // Reset pending state if it was set earlier
1963
+ if (window.checkoutApiStatus.shippingAddress === 'pending') {
1964
+ window.checkoutApiStatus.shippingAddress = 'idle';
1965
+ }
1966
+ // Ensure button reflects current validation state
1967
+ updateCheckoutButtonState();
1968
+ return;
1969
+ }
1970
+
1971
+ // Track status
1972
+ window.checkoutApiStatus.shippingAddress = 'pending';
1973
+ updateCheckoutButtonState();
1974
+
1975
+ try {
1976
+ const isHostedCheckout = window.location.pathname.includes('/checkout/') &&
1977
+ window.location.pathname.split('/').length > 2;
1978
+ const endpoint = isHostedCheckout
1979
+ ? `/webstoreapi/checkout/${checkoutToken}/shipping-address`
1980
+ : '/webstoreapi/checkout/shipping-address';
1981
+
1982
+ // Get countryId from select dropdown
1983
+ const countrySelect = document.getElementById('shipping-country');
1984
+ const countryId = countrySelect ? countrySelect.value : null;
1985
+
1986
+ // Get stateOrProvinceId from either select dropdown or text input
1987
+ const stateSelect = document.getElementById('shipping-state');
1988
+ const stateTextInput = document.getElementById('shipping-state-text');
1989
+ let stateOrProvinceId = null;
1990
+
1991
+ if (stateSelect && stateSelect.style.display !== 'none' && stateSelect.value) {
1992
+ stateOrProvinceId = stateSelect.value;
1993
+ } else if (stateTextInput && stateTextInput.style.display !== 'none' && stateTextInput.value) {
1994
+ // For text input, we still need an ID - this should not happen if states are properly configured
1995
+ // But we'll send the text value and let backend handle validation
1996
+ stateOrProvinceId = stateTextInput.value;
1997
+ }
1998
+
1999
+ // Validate that we have both IDs
2000
+ if (!countryId || !stateOrProvinceId) {
2001
+ console.warn('[CHECKOUT] Missing countryId or stateOrProvinceId. Country:', countryId, 'State:', stateOrProvinceId);
2002
+ // Don't send request if IDs are missing. Since we previously
2003
+ // marked this as pending when the user started editing, reset
2004
+ // it back so the button state reflects that no call was sent.
2005
+ window.checkoutApiStatus.shippingAddress = 'idle';
2006
+ updateCheckoutButtonState();
2007
+ return;
2008
+ }
2009
+
2010
+ // Get phone number from intl-tel-input if available
2011
+ let shippingPhone = formData.get('shippingPhone');
2012
+ if (window.shippingPhoneIti) {
2013
+ const phoneNumber = window.shippingPhoneIti.getNumber();
2014
+ if (phoneNumber) {
2015
+ // Remove leading + sign
2016
+ shippingPhone = phoneNumber.replace(/^\+/, '');
2017
+ }
2018
+ }
2019
+
2020
+ const requestBody = {
2021
+ shippingFirstName: formData.get('shippingFirstName'),
2022
+ shippingLastName: formData.get('shippingLastName'),
2023
+ shippingAddress: formData.get('shippingAddress'),
2024
+ shippingCity: formData.get('shippingCity'),
2025
+ shippingZip: formData.get('shippingZip'),
2026
+ shippingPhone: shippingPhone,
2027
+ countryId: countryId,
2028
+ stateOrProvinceId: stateOrProvinceId
2029
+ };
2030
+ const addressSyncKey = JSON.stringify(requestBody);
2031
+
2032
+ if (
2033
+ window.lastShippingAddressSyncKey === addressSyncKey &&
2034
+ window.checkoutApiStatus.shippingAddress === 'completed'
2035
+ ) {
2036
+ debugLog('[CHECKOUT] Skipping duplicate shipping address update');
2037
+ if (!window.checkoutInProgress) {
2038
+ checkAndFetchShippingMethods();
2039
+ }
2040
+ return;
2041
+ }
2042
+
2043
+ debugLog('[CHECKOUT] Updating shipping address with data:', requestBody);
2044
+
2045
+ const response = await fetch(endpoint, {
2046
+ method: 'PUT',
2047
+ headers: {
2048
+ 'Content-Type': 'application/json'
2049
+ },
2050
+ body: JSON.stringify(requestBody)
2051
+ });
2052
+
2053
+ const result = await response.json().catch(() => ({}));
2054
+ updateCheckoutTokenFromApi(result);
2055
+
2056
+ if (!response.ok) {
2057
+ const shippingAddressErrorMessage = resolveCheckoutErrorMessage(result, 'Failed to update shipping address.');
2058
+ console.error('Failed to update shipping address:', shippingAddressErrorMessage);
2059
+
2060
+ // Clear shipping methods when address update fails (400+ errors)
2061
+ // This prevents using stale shipping methods when address is invalid
2062
+ // Use checkAndFetchShippingMethods to handle clearing consistently
2063
+ checkAndFetchShippingMethods();
2064
+ const container = document.getElementById('shipping-methods-container');
2065
+ if (container) {
2066
+ container.innerHTML = `<p class="shipping-methods-error">${shippingAddressErrorMessage}</p>`;
2067
+ }
2068
+
2069
+ // Mark as completed even on error (to allow retry)
2070
+ window.checkoutApiStatus.shippingAddress = 'completed';
2071
+ updateCheckoutButtonState();
2072
+ } else {
2073
+ window.lastShippingAddressSyncKey = addressSyncKey;
2074
+ // Mark as completed
2075
+ window.checkoutApiStatus.shippingAddress = 'completed';
2076
+ updateCheckoutButtonState();
2077
+
2078
+ // Only fetch shipping methods if checkout not in progress
2079
+ if (!window.checkoutInProgress) {
2080
+ checkAndFetchShippingMethods();
2081
+ }
2082
+ }
2083
+ } catch (error) {
2084
+ console.error('Error updating shipping address:', error);
2085
+
2086
+ // Clear shipping methods when address update fails
2087
+ // Use checkAndFetchShippingMethods to handle clearing consistently
2088
+ checkAndFetchShippingMethods();
2089
+
2090
+ // Mark as completed even on error (to allow retry)
2091
+ window.checkoutApiStatus.shippingAddress = 'completed';
2092
+ updateCheckoutButtonState();
2093
+ }
2094
+ }
2095
+
2096
+ // Update billing address when form fields change (debounced)
2097
+ let billingAddressTimeout;
2098
+ const billingFields = ['billing-first-name', 'billing-last-name', 'billing-address', 'billing-city', 'billing-state', 'billing-zip', 'billing-country', 'billing-phone'];
2099
+ billingFields.forEach(fieldId => {
2100
+ const field = document.getElementById(fieldId);
2101
+ if (field) {
2102
+ field.addEventListener('blur', () => {
2103
+ clearTimeout(billingAddressTimeout);
2104
+ billingAddressTimeout = setTimeout(updateBillingAddress, 500);
2105
+ });
2106
+ }
2107
+ });
2108
+
2109
+ async function updateBillingAddress() {
2110
+ // Prevent updating during checkout completion
2111
+ if (window.checkoutInProgress) return;
2112
+
2113
+ const checkoutToken = getCheckoutToken();
2114
+ if (!checkoutToken) return;
2115
+
2116
+ const sameAsShipping = document.getElementById('same-as-shipping')?.checked;
2117
+ if (sameAsShipping) return; // Don't update if same as shipping
2118
+
2119
+ // Track status
2120
+ window.checkoutApiStatus.billingAddress = 'pending';
2121
+ updateCheckoutButtonState();
2122
+
2123
+ const formData = new FormData(document.getElementById('checkout-form'));
2124
+
2125
+ try {
2126
+ // Get countryId from select dropdown
2127
+ const countrySelect = document.getElementById('billing-country');
2128
+ const countryId = countrySelect ? countrySelect.value : null;
2129
+
2130
+ // Get stateOrProvinceId from select dropdown
2131
+ const stateSelect = document.getElementById('billing-state');
2132
+ const stateOrProvinceId = stateSelect && stateSelect.value ? stateSelect.value : null;
2133
+
2134
+ // Validate that we have both IDs
2135
+ if (!countryId || !stateOrProvinceId) {
2136
+ console.warn('[CHECKOUT] Missing billing countryId or stateOrProvinceId. Country:', countryId, 'State:', stateOrProvinceId);
2137
+ // Don't send request if IDs are missing
2138
+ return;
2139
+ }
2140
+
2141
+ const response = await fetch('/webstoreapi/checkout/billing-address', {
2142
+ method: 'PUT',
2143
+ headers: {
2144
+ 'Content-Type': 'application/json'
2145
+ },
2146
+ body: JSON.stringify({
2147
+ billingFirstName: formData.get('billingFirstName'),
2148
+ billingLastName: formData.get('billingLastName'),
2149
+ billingAddress: formData.get('billingAddress'),
2150
+ billingCity: formData.get('billingCity'),
2151
+ billingZip: formData.get('billingZip'),
2152
+ phone: formData.get('billingPhone'),
2153
+ countryId: countryId,
2154
+ stateOrProvinceId: stateOrProvinceId
2155
+ })
2156
+ });
2157
+
2158
+ if (!response.ok) {
2159
+ const result = await response.json();
2160
+ console.error('Failed to update billing address:', result.error);
2161
+ // Mark as completed even on error (to allow retry)
2162
+ window.checkoutApiStatus.billingAddress = 'completed';
2163
+ updateCheckoutButtonState();
2164
+ } else {
2165
+ // Mark as completed
2166
+ window.checkoutApiStatus.billingAddress = 'completed';
2167
+ updateCheckoutButtonState();
2168
+ }
2169
+ } catch (error) {
2170
+ console.error('Error updating billing address:', error);
2171
+ // Mark as completed even on error (to allow retry)
2172
+ window.checkoutApiStatus.billingAddress = 'completed';
2173
+ updateCheckoutButtonState();
2174
+ }
2175
+ }
2176
+
2177
+ // Handle same as shipping checkbox change
2178
+ document.getElementById('same-as-shipping')?.addEventListener('change', (e) => {
2179
+ const billingFields = document.getElementById('billing-address-fields');
2180
+ if (billingFields) {
2181
+ billingFields.style.display = e.target.checked ? 'none' : 'block';
2182
+ }
2183
+ if (e.target.checked) {
2184
+ // Clear billing address when same as shipping
2185
+ updateBillingAddress();
2186
+ } else {
2187
+ // Update billing address when unchecked
2188
+ updateBillingAddress();
2189
+ }
2190
+ });
2191
+
2192
+ // Manual order note save
2193
+ let lastSavedOrderNote = null;
2194
+ const orderNotesField = document.getElementById('order-notes') || document.getElementById('orderNotes');
2195
+ const orderNoteSaveBtn = document.getElementById('order-note-save-btn');
2196
+ const orderNoteSaveStatus = document.getElementById('order-note-save-status');
2197
+
2198
+ function setOrderNoteStatus(message, tone = 'muted') {
2199
+ if (!orderNoteSaveStatus) return;
2200
+ orderNoteSaveStatus.textContent = message || '';
2201
+ if (tone === 'success') {
2202
+ orderNoteSaveStatus.style.color = '#16a34a';
2203
+ } else if (tone === 'error') {
2204
+ orderNoteSaveStatus.style.color = '#dc2626';
2205
+ } else if (tone === 'warning') {
2206
+ orderNoteSaveStatus.style.color = '#d97706';
2207
+ } else {
2208
+ orderNoteSaveStatus.style.color = '#6b7280';
2209
+ }
2210
+ }
2211
+
2212
+ function setOrderNoteButtonLoading(loading) {
2213
+ if (!orderNoteSaveBtn) return;
2214
+ const btnText = orderNoteSaveBtn.querySelector('.btn-text');
2215
+ const btnLoading = orderNoteSaveBtn.querySelector('.btn-loading');
2216
+ orderNoteSaveBtn.disabled = loading;
2217
+ if (btnText) btnText.style.display = loading ? 'none' : 'inline';
2218
+ if (btnLoading) btnLoading.style.display = loading ? 'inline-flex' : 'none';
2219
+ }
2220
+
2221
+ if (orderNotesField) {
2222
+ lastSavedOrderNote = (orderNotesField.value || '').trim();
2223
+ setOrderNoteStatus(lastSavedOrderNote ? 'Saved' : '');
2224
+ orderNotesField.addEventListener('input', () => {
2225
+ const currentNote = (orderNotesField.value || '').trim();
2226
+ if (currentNote !== lastSavedOrderNote) {
2227
+ setOrderNoteStatus('Unsaved changes', 'warning');
2228
+ } else {
2229
+ setOrderNoteStatus('Saved', 'success');
2230
+ }
2231
+ });
2232
+ }
2233
+
2234
+ async function updateNote(options = {}) {
2235
+ const force = options.force === true;
2236
+ const showFeedback = options.showFeedback === true;
2237
+ // Prevent updating during checkout completion
2238
+ if (window.checkoutInProgress && !force) return false;
2239
+
2240
+ const checkoutToken = getCheckoutToken();
2241
+ if (!checkoutToken) {
2242
+ if (showFeedback) {
2243
+ setOrderNoteStatus('Checkout session not found', 'error');
2244
+ setOrderNoteButtonLoading(false);
2245
+ }
2246
+ return false;
2247
+ }
2248
+
2249
+ const formData = new FormData(document.getElementById('checkout-form'));
2250
+ const note = (formData.get('orderNotes') || '').trim();
2251
+ if (!force && note === lastSavedOrderNote) {
2252
+ if (showFeedback) setOrderNoteStatus('Already saved', 'success');
2253
+ return true;
2254
+ }
2255
+
2256
+ // Track status
2257
+ window.checkoutApiStatus.orderNote = 'pending';
2258
+ updateCheckoutButtonState();
2259
+ if (showFeedback) {
2260
+ setOrderNoteButtonLoading(true);
2261
+ setOrderNoteStatus('Saving...');
2262
+ }
2263
+
2264
+ try {
2265
+ const isHostedCheckout = window.location.pathname.includes('/checkout/') &&
2266
+ window.location.pathname.split('/').length > 2;
2267
+ const endpoint = isHostedCheckout
2268
+ ? `/webstoreapi/checkout/${encodeURIComponent(checkoutToken)}/note`
2269
+ : '/webstoreapi/checkout/note';
2270
+
2271
+ const response = await fetch(endpoint, {
2272
+ method: 'PUT',
2273
+ credentials: 'same-origin',
2274
+ headers: {
2275
+ 'Content-Type': 'application/json',
2276
+ 'X-Requested-With': 'XMLHttpRequest'
2277
+ },
2278
+ body: JSON.stringify({ note })
2279
+ });
2280
+
2281
+ if (!response.ok) {
2282
+ let result = {};
2283
+ try {
2284
+ result = await response.json();
2285
+ } catch (_) {
2286
+ // ignore parsing errors for non-JSON errors
2287
+ }
2288
+ console.error('Failed to update note:', result.error);
2289
+ // Mark as completed even on error (to allow retry)
2290
+ window.checkoutApiStatus.orderNote = 'completed';
2291
+ updateCheckoutButtonState();
2292
+ if (showFeedback) {
2293
+ setOrderNoteStatus(result.error || result.message || 'Save failed', 'error');
2294
+ setOrderNoteButtonLoading(false);
2295
+ }
2296
+ return false;
2297
+ } else {
2298
+ lastSavedOrderNote = note;
2299
+ // Mark as completed
2300
+ window.checkoutApiStatus.orderNote = 'completed';
2301
+ updateCheckoutButtonState();
2302
+ if (showFeedback) {
2303
+ setOrderNoteStatus('Saved', 'success');
2304
+ setOrderNoteButtonLoading(false);
2305
+ }
2306
+ return true;
2307
+ }
2308
+ } catch (error) {
2309
+ console.error('Error updating note:', error);
2310
+ // Mark as completed even on error (to allow retry)
2311
+ window.checkoutApiStatus.orderNote = 'completed';
2312
+ updateCheckoutButtonState();
2313
+ if (showFeedback) {
2314
+ setOrderNoteStatus('Save failed. Try again.', 'error');
2315
+ setOrderNoteButtonLoading(false);
2316
+ }
2317
+ return false;
2318
+ }
2319
+ }
2320
+
2321
+ orderNoteSaveBtn?.addEventListener('click', async () => {
2322
+ await updateNote({ showFeedback: true });
2323
+ });
2324
+
2325
+ // ==================== PAYMENT GATEWAY EVENT SYSTEM ====================
2326
+ // Note: checkoutPaymentEvents is initialized earlier in the file (around line 30)
2327
+ // to ensure it's available before payment gateway apps try to use it
2328
+ // Only re-initialize if it doesn't exist (defensive check)
2329
+ if (!window.checkoutPaymentEvents) {
2330
+ window.checkoutPaymentEvents = (() => {
2331
+ const events = {};
2332
+
2333
+ return {
2334
+ /**
2335
+ * Emit an event to registered listeners
2336
+ * @param {string} eventName - Name of the event
2337
+ * @param {Object} data - Event data
2338
+ */
2339
+ emit: (eventName, data) => {
2340
+ debugLog('[CHECKOUT] Emitting payment event:', eventName, data);
2341
+
2342
+ if (!events[eventName]) {
2343
+ events[eventName] = [];
2344
+ }
2345
+
2346
+ // Call all registered handlers
2347
+ events[eventName].forEach((handler) => {
2348
+ try {
2349
+ handler(data);
2350
+ } catch (error) {
2351
+ console.error('[CHECKOUT] Error in payment event handler:', error);
2352
+ }
2353
+ });
2354
+ },
2355
+
2356
+ /**
2357
+ * Register an event listener
2358
+ * @param {string} eventName - Name of the event
2359
+ * @param {Function} handler - Event handler function
2360
+ */
2361
+ on: (eventName, handler) => {
2362
+ if (!events[eventName]) {
2363
+ events[eventName] = [];
2364
+ }
2365
+ events[eventName].push(handler);
2366
+ debugLog('[CHECKOUT] Registered listener for payment event:', eventName);
2367
+ },
2368
+
2369
+ /**
2370
+ * Remove an event listener
2371
+ * @param {string} eventName - Name of the event
2372
+ * @param {Function} handler - Event handler function to remove
2373
+ */
2374
+ off: (eventName, handler) => {
2375
+ if (events[eventName]) {
2376
+ events[eventName] = events[eventName].filter(h => h !== handler);
2377
+ }
2378
+ }
2379
+ };
2380
+ })();
2381
+ }
2382
+
2383
+ /**
2384
+ * Payment Gateway App Registry
2385
+ * Manages registered payment gateway apps and delegates checkout submission
2386
+ */
2387
+ window.paymentGatewayApps = (() => {
2388
+ const apps = {};
2389
+
2390
+ return {
2391
+ /**
2392
+ * Register a payment gateway app
2393
+ * @param {string} paymentMethodId - Payment method ID (e.g., 'Razorpay', 'Stripe')
2394
+ * @param {Object} handler - App handler object with handleCheckoutSubmit method
2395
+ */
2396
+ register: (paymentMethodId, handler) => {
2397
+ if (!paymentMethodId || !handler) {
2398
+ console.error('[CHECKOUT] Invalid app registration:', { paymentMethodId, handler });
2399
+ return false;
2400
+ }
2401
+
2402
+ if (typeof handler.handleCheckoutSubmit !== 'function') {
2403
+ console.error('[CHECKOUT] App handler must implement handleCheckoutSubmit:', paymentMethodId);
2404
+ return false;
2405
+ }
2406
+
2407
+ apps[paymentMethodId] = handler;
2408
+ debugLog('[CHECKOUT] Registered payment gateway app:', paymentMethodId);
2409
+ return true;
2410
+ },
2411
+
2412
+ /**
2413
+ * Check if a payment method is handled by an app
2414
+ * @param {string} paymentMethodId - Payment method ID
2415
+ * @returns {boolean} True if app is registered
2416
+ */
2417
+ isGatewayMethod: (paymentMethodId) => {
2418
+ return !!apps[paymentMethodId];
2419
+ },
2420
+
2421
+ /**
2422
+ * Handle checkout submission for a gateway payment method
2423
+ * @param {string} paymentMethodId - Payment method ID
2424
+ * @param {string} checkoutToken - Checkout token
2425
+ * @returns {Promise<Object>} Promise resolving to payment result
2426
+ */
2427
+ handleSubmit: async (paymentMethodId, checkoutToken) => {
2428
+ const app = apps[paymentMethodId];
2429
+
2430
+ if (!app) {
2431
+ return Promise.reject(new Error(`No app registered for payment method: ${paymentMethodId}`));
2432
+ }
2433
+
2434
+ debugLog('[CHECKOUT] Delegating checkout submission to app:', paymentMethodId);
2435
+
2436
+ try {
2437
+ return await app.handleCheckoutSubmit(paymentMethodId, checkoutToken);
2438
+ } catch (error) {
2439
+ console.error('[CHECKOUT] App checkout submission error:', error);
2440
+ throw error;
2441
+ }
2442
+ },
2443
+
2444
+ /**
2445
+ * Get all registered app IDs
2446
+ * @returns {Array<string>} Array of registered payment method IDs
2447
+ */
2448
+ getRegisteredApps: () => {
2449
+ return Object.keys(apps);
2450
+ }
2451
+ };
2452
+ })();
2453
+
2454
+ // Emit ready event when checkout is initialized
2455
+ if (document.readyState === 'loading') {
2456
+ document.addEventListener('DOMContentLoaded', () => {
2457
+ window.checkoutPaymentEvents.emit('payment:checkout:ready', {
2458
+ checkoutToken: typeof CHECKOUT_TOKEN !== 'undefined' ? CHECKOUT_TOKEN : getCheckoutToken()
2459
+ });
2460
+ });
2461
+ } else {
2462
+ // DOM already loaded
2463
+ window.checkoutPaymentEvents.emit('payment:checkout:ready', {
2464
+ checkoutToken: typeof CHECKOUT_TOKEN !== 'undefined' ? CHECKOUT_TOKEN : getCheckoutToken()
2465
+ });
2466
+ }
2467
+
2468
+ // Handle form submission
2469
+ document.getElementById('checkout-form')?.addEventListener('submit', async (e) => {
2470
+ e.preventDefault();
2471
+ if (window.checkoutSubmitLock || window.checkoutInProgress) {
2472
+ return;
2473
+ }
2474
+ window.checkoutSubmitLock = true;
2475
+
2476
+ // Save latest order note before checkout completion.
2477
+ const noteSaved = await updateNote({ force: true, showFeedback: true });
2478
+ if (!noteSaved) {
2479
+ alert('Unable to save order note. Please click Save Note and try again.');
2480
+ window.checkoutSubmitLock = false;
2481
+ return;
2482
+ }
2483
+
2484
+ // CRITICAL: Set flag immediately to prevent any API calls
2485
+ window.checkoutInProgress = true;
2486
+ clearTimeout(window.shippingMethodsTimeout);
2487
+ clearTimeout(shippingAddressTimeout);
2488
+ clearTimeout(billingAddressTimeout);
2489
+
2490
+ const submitBtn = document.getElementById('checkout-submit');
2491
+ const checkoutForm = document.getElementById('checkout-form');
2492
+ if (!checkoutForm) {
2493
+ console.error('[CHECKOUT] Checkout form not found');
2494
+ window.checkoutInProgress = false;
2495
+ window.checkoutSubmitLock = false;
2496
+ return;
2497
+ }
2498
+ const formData = new FormData(checkoutForm);
2499
+ const paymentMethod = formData.get('paymentMethod');
2500
+
2501
+ setButtonLoading(submitBtn, true, 'Processing...');
2502
+
2503
+ const checkoutToken = getCheckoutToken();
2504
+ if (!checkoutToken) {
2505
+ window.checkoutInProgress = false; // Reset flag on error
2506
+ alert('Checkout session expired. Please refresh the page and try again.');
2507
+ setButtonLoading(submitBtn, false);
2508
+ window.checkoutSubmitLock = false;
2509
+ return;
2510
+ }
2511
+
2512
+ if (!paymentMethod) {
2513
+ window.checkoutInProgress = false; // Reset flag on error
2514
+ alert('Please select a payment method.');
2515
+ setButtonLoading(submitBtn, false);
2516
+ window.checkoutSubmitLock = false;
2517
+ return;
2518
+ }
2519
+ const idempotencyKey = window.checkoutIdempotencyKey || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
2520
+ window.checkoutIdempotencyKey = idempotencyKey;
2521
+
2522
+ // Get the selected payment method's fee from the radio input
2523
+ const selectedPaymentInput = document.querySelector(`input[name="paymentMethod"][value="${paymentMethod}"]`);
2524
+ const paymentFee = selectedPaymentInput ? parseFloat(selectedPaymentInput.getAttribute('data-payment-fee') || '0') : 0;
2525
+ const paymentType = selectedPaymentInput ? (selectedPaymentInput.getAttribute('data-payment-type') || '') : '';
2526
+
2527
+ // Emit checkout submit event for apps to listen
2528
+ window.checkoutPaymentEvents.emit('payment:checkout:submit', {
2529
+ paymentMethod: paymentMethod,
2530
+ paymentType: paymentType,
2531
+ checkoutToken: checkoutToken,
2532
+ paymentFee: paymentFee
2533
+ });
2534
+
2535
+ // Check if this is a gateway payment method that should be handled by an app
2536
+ if (paymentType === 'PaymentGateway' && window.paymentGatewayApps.isGatewayMethod(paymentMethod)) {
2537
+ debugLog('[CHECKOUT] Gateway payment method detected, delegating to app:', paymentMethod);
2538
+
2539
+ try {
2540
+ setButtonLoading(submitBtn, true, 'Initializing payment...');
2541
+
2542
+ // Delegate to app for payment handling
2543
+ await window.paymentGatewayApps.handleSubmit(paymentMethod, checkoutToken);
2544
+
2545
+ // Note: Don't reset checkoutInProgress here - app will handle payment flow
2546
+ // App should emit payment:app:complete or payment:app:error events
2547
+ debugLog('[CHECKOUT] Payment gateway app handling payment, awaiting response...');
2548
+ return;
2549
+ } catch (error) {
2550
+ console.error('[CHECKOUT] Gateway payment app error:', error);
2551
+ window.checkoutInProgress = false; // Reset flag on error
2552
+
2553
+ const errorMessage = error.message || 'Unable to initialize payment. Please try again or contact support.';
2554
+ alert(errorMessage);
2555
+ setButtonLoading(submitBtn, false);
2556
+ window.checkoutSubmitLock = false;
2557
+ return;
2558
+ }
2559
+ }
2560
+
2561
+ // For non-gateway payment methods, proceed with standard checkout completion
2562
+ // Directly call complete checkout API (do not re-call shipping/billing/shipping method updates here)
2563
+ try {
2564
+ // Use the payment method value (which is the id/name from API) as paymentMethodHandle
2565
+ const isHostedCheckout = window.location.pathname.includes('/checkout/') &&
2566
+ window.location.pathname.split('/').length > 2;
2567
+
2568
+ const endpoint = isHostedCheckout
2569
+ ? `/webstoreapi/checkout/${checkoutToken}/complete`
2570
+ : '/webstoreapi/checkout/complete';
2571
+
2572
+ const response = await fetch(endpoint, {
2573
+ method: 'POST',
2574
+ headers: {
2575
+ 'Content-Type': 'application/json'
2576
+ },
2577
+ body: JSON.stringify({
2578
+ paymentMethodHandle: paymentMethod,
2579
+ paymentFeeAmount: paymentFee,
2580
+ idempotencyKey: idempotencyKey
2581
+ })
2582
+ });
2583
+
2584
+ const result = await response.json();
2585
+ updateCheckoutTokenFromApi(result);
2586
+
2587
+ if (result.success && result.data) {
2588
+ // Handle array of orders from CompleteCheckoutResponse
2589
+ const orders = Array.isArray(result.data) ? result.data : [result.data];
2590
+ const orderIds = orders.map(o => o.orderId || o.orderNumber).filter(Boolean).join(',');
2591
+ if (orderIds) {
2592
+ window.location.href = `/order-confirmation?orderIds=${orderIds}`;
2593
+ } else {
2594
+ window.location.href = '/order-confirmation';
2595
+ }
2596
+ } else {
2597
+ throw new Error(resolveCheckoutErrorMessage(result, 'Checkout failed'));
2598
+ }
2599
+ } catch (error) {
2600
+ console.error('Checkout error:', error);
2601
+ window.checkoutInProgress = false; // Reset flag on error
2602
+ alert(error.message || 'Checkout failed. Please try again.');
2603
+ setButtonLoading(submitBtn, false);
2604
+ window.checkoutSubmitLock = false;
2605
+ }
2606
+ });
2607
+
2608
+ // ==================== COUPON FUNCTIONALITY ====================
2609
+
2610
+ // Global tracking for applied coupon codes to keep UI state in sync
2611
+ window.appliedCouponCodes = [];
2612
+
2613
+ /**
2614
+ * Initialize applied coupon codes from initial checkout pricing data
2615
+ */
2616
+ function initializeAppliedCouponCodesFromPricing() {
2617
+ const applied = [];
2618
+
2619
+ if (
2620
+ typeof CHECKOUT_PRICING !== 'undefined' &&
2621
+ CHECKOUT_PRICING &&
2622
+ Array.isArray(CHECKOUT_PRICING.discounts)
2623
+ ) {
2624
+ CHECKOUT_PRICING.discounts.forEach(discount => {
2625
+ const code = discount.code || discount.couponCode || discount.discountCode;
2626
+ if (code && !applied.includes(code)) {
2627
+ applied.push(code);
2628
+ }
2629
+ });
2630
+ }
2631
+
2632
+ window.appliedCouponCodes = applied;
2633
+ }
2634
+
2635
+ // Initialize applied coupons on first load
2636
+ initializeAppliedCouponCodesFromPricing();
2637
+
2638
+ /**
2639
+ * Fetch available discount codes for checkout
2640
+ */
2641
+ async function fetchDiscountCodes() {
2642
+ const checkoutToken = getCheckoutToken();
2643
+ if (!checkoutToken) {
2644
+ console.warn('[COUPONS] No checkout token available');
2645
+ return;
2646
+ }
2647
+
2648
+ const couponsContainer = document.getElementById('coupons-container');
2649
+ const couponsError = document.getElementById('coupons-error');
2650
+
2651
+ if (couponsContainer) {
2652
+ couponsContainer.innerHTML = '<p class="coupons-loading">Loading available coupons...</p>';
2653
+ }
2654
+
2655
+ if (couponsError) {
2656
+ couponsError.style.display = 'none';
2657
+ }
2658
+
2659
+ try {
2660
+ const response = await fetch(`/webstoreapi/checkout/${checkoutToken}/discount-codes`, {
2661
+ method: 'GET',
2662
+ headers: {
2663
+ 'Content-Type': 'application/json'
2664
+ }
2665
+ });
2666
+
2667
+ if (!response.ok) {
2668
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
2669
+ }
2670
+
2671
+ const result = await response.json();
2672
+
2673
+ if (!result.success) {
2674
+ throw new Error(result.error || 'Failed to fetch discount codes');
2675
+ }
2676
+
2677
+ const discountCodes = result.data;
2678
+ displayDiscountCodes(discountCodes);
2679
+ } catch (error) {
2680
+ console.error('[COUPONS] Error fetching discount codes:', error);
2681
+ if (couponsContainer) {
2682
+ couponsContainer.innerHTML = '<p class="coupons-error">Unable to load coupons. Please try again later.</p>';
2683
+ }
2684
+ if (couponsError) {
2685
+ couponsError.textContent = error.message || 'Failed to load coupons';
2686
+ couponsError.style.display = 'block';
2687
+ }
2688
+ }
2689
+ }
2690
+
2691
+ /**
2692
+ * Get currently applied coupon codes
2693
+ */
2694
+ function getAppliedCouponCodes() {
2695
+ // Prefer the normalized global list
2696
+ if (Array.isArray(window.appliedCouponCodes) && window.appliedCouponCodes.length > 0) {
2697
+ return [...window.appliedCouponCodes];
2698
+ }
2699
+
2700
+ const appliedCoupons = [];
2701
+
2702
+ // Fallback: derive from CHECKOUT_PRICING if available
2703
+ if (
2704
+ typeof CHECKOUT_PRICING !== 'undefined' &&
2705
+ CHECKOUT_PRICING &&
2706
+ Array.isArray(CHECKOUT_PRICING.discounts)
2707
+ ) {
2708
+ CHECKOUT_PRICING.discounts.forEach(discount => {
2709
+ const code = discount.code || discount.couponCode || discount.discountCode;
2710
+ if (code && !appliedCoupons.includes(code)) {
2711
+ appliedCoupons.push(code);
2712
+ }
2713
+ });
2714
+ }
2715
+
2716
+ return appliedCoupons;
2717
+ }
2718
+
2719
+ /**
2720
+ * Display available discount codes
2721
+ */
2722
+ function displayDiscountCodes(discountCodes) {
2723
+ const couponsContainer = document.getElementById('coupons-container');
2724
+ if (!couponsContainer) return;
2725
+
2726
+ // Handle different response formats
2727
+ let codes = [];
2728
+ if (Array.isArray(discountCodes)) {
2729
+ codes = discountCodes;
2730
+ } else if (discountCodes && typeof discountCodes === 'object') {
2731
+ // If it's a single object, wrap it in an array
2732
+ if (discountCodes.code) {
2733
+ codes = [discountCodes];
2734
+ } else if (discountCodes.codes && Array.isArray(discountCodes.codes)) {
2735
+ codes = discountCodes.codes;
2736
+ }
2737
+ }
2738
+
2739
+ if (codes.length === 0) {
2740
+ couponsContainer.innerHTML = '<p class="coupons-empty">No coupons available at this time.</p>';
2741
+ return;
2742
+ }
2743
+
2744
+ const couponsList = document.createElement('div');
2745
+ couponsList.className = 'coupons-list';
2746
+
2747
+ // Get currently applied coupon codes
2748
+ let appliedCodes = getAppliedCouponCodes();
2749
+
2750
+ // Heuristic: if no explicit applied codes are known but there is a discount
2751
+ // on checkout pricing and only one coupon exists, treat that coupon as applied.
2752
+ if (
2753
+ appliedCodes.length === 0 &&
2754
+ typeof CHECKOUT_PRICING !== 'undefined' &&
2755
+ CHECKOUT_PRICING &&
2756
+ (
2757
+ (CHECKOUT_PRICING.totalDiscounts !== undefined && CHECKOUT_PRICING.totalDiscounts > 0) ||
2758
+ (CHECKOUT_PRICING.discount !== undefined && CHECKOUT_PRICING.discount > 0)
2759
+ ) &&
2760
+ codes.length === 1
2761
+ ) {
2762
+ const inferredCode = codes[0].code || codes[0].couponCode || codes[0].discountCode || '';
2763
+ if (inferredCode) {
2764
+ appliedCodes = [inferredCode];
2765
+ // Keep global tracking in sync with the inferred state
2766
+ window.appliedCouponCodes = [inferredCode];
2767
+ }
2768
+ }
2769
+
2770
+ codes.forEach((coupon, index) => {
2771
+ const couponCode = coupon.code || coupon.couponCode || '';
2772
+ const isApplied = appliedCodes.includes(couponCode);
2773
+
2774
+ const couponItem = document.createElement('div');
2775
+ couponItem.className = 'coupon-item';
2776
+ couponItem.dataset.couponCode = couponCode;
2777
+
2778
+ const couponInfo = document.createElement('div');
2779
+ couponInfo.className = 'coupon-info';
2780
+
2781
+ const couponCodeDiv = document.createElement('div');
2782
+ couponCodeDiv.className = 'coupon-code';
2783
+ couponCodeDiv.textContent = couponCode || 'N/A';
2784
+
2785
+ const couponDescription = document.createElement('div');
2786
+ couponDescription.className = 'coupon-description';
2787
+ couponDescription.textContent = coupon.description || '';
2788
+
2789
+ if (coupon.minCartAmount) {
2790
+ const minAmount = document.createElement('div');
2791
+ minAmount.className = 'coupon-min-amount';
2792
+ minAmount.textContent = `Minimum order: ${formatMoney(coupon.minCartAmount)}`;
2793
+ couponInfo.appendChild(minAmount);
2794
+ }
2795
+
2796
+ couponInfo.appendChild(couponCodeDiv);
2797
+ if (coupon.description) {
2798
+ couponInfo.appendChild(couponDescription);
2799
+ }
2800
+
2801
+ // Show Remove button if already applied, Apply button otherwise
2802
+ const actionButton = document.createElement('button');
2803
+ actionButton.type = 'button';
2804
+ actionButton.dataset.couponCode = couponCode;
2805
+
2806
+ if (isApplied) {
2807
+ actionButton.className = 'btn btn-danger btn-sm remove-coupon-btn';
2808
+ actionButton.textContent = 'Remove';
2809
+ actionButton.addEventListener('click', () => removeCoupon(couponCode));
2810
+ } else {
2811
+ actionButton.className = 'btn btn-primary btn-sm apply-coupon-btn';
2812
+ actionButton.textContent = 'Apply';
2813
+ actionButton.addEventListener('click', () => applyDiscountCode(couponCode));
2814
+ }
2815
+
2816
+ couponItem.appendChild(couponInfo);
2817
+ couponItem.appendChild(actionButton);
2818
+ couponsList.appendChild(couponItem);
2819
+ });
2820
+
2821
+ couponsContainer.innerHTML = '';
2822
+ couponsContainer.appendChild(couponsList);
2823
+ }
2824
+
2825
+ /**
2826
+ * Apply discount code to checkout
2827
+ */
2828
+ async function applyDiscountCode(discountCode) {
2829
+ if (!discountCode) {
2830
+ alert('Please select a valid coupon code.');
2831
+ return;
2832
+ }
2833
+
2834
+ const checkoutToken = getCheckoutToken();
2835
+ if (!checkoutToken) {
2836
+ alert('Checkout session not found. Please refresh the page.');
2837
+ return;
2838
+ }
2839
+
2840
+ // Prevent re-applying an already applied coupon
2841
+ const alreadyAppliedCodes = getAppliedCouponCodes().map(code => String(code).toLowerCase());
2842
+ if (alreadyAppliedCodes.includes(String(discountCode).toLowerCase())) {
2843
+ alert('This coupon is already applied to your order.');
2844
+ return;
2845
+ }
2846
+
2847
+ // Find and disable the specific apply button for this coupon
2848
+ const applyButtons = document.querySelectorAll('.apply-coupon-btn');
2849
+ const targetButton = Array.from(applyButtons).find(btn => btn.dataset.couponCode === discountCode);
2850
+
2851
+ // Disable all apply buttons temporarily
2852
+ applyButtons.forEach(btn => {
2853
+ btn.disabled = true;
2854
+ if (btn === targetButton) {
2855
+ btn.textContent = 'Applying...';
2856
+ }
2857
+ });
2858
+
2859
+ try {
2860
+ const response = await fetch(`/webstoreapi/checkout/${checkoutToken}/apply-discount`, {
2861
+ method: 'POST',
2862
+ headers: {
2863
+ 'Content-Type': 'application/json'
2864
+ },
2865
+ body: JSON.stringify({
2866
+ discountCode: discountCode
2867
+ })
2868
+ });
2869
+
2870
+ if (!response.ok) {
2871
+ const errorData = await response.json().catch(() => ({}));
2872
+ throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
2873
+ }
2874
+
2875
+ const result = await response.json();
2876
+
2877
+ if (!result.success) {
2878
+ throw new Error(result.error || 'Failed to apply discount code');
2879
+ }
2880
+
2881
+ // Update applied coupons tracking from response
2882
+ if (result.data && result.data.pricing && result.data.pricing.discounts) {
2883
+ updateAppliedCouponsTracking(result.data.pricing.discounts);
2884
+ } else {
2885
+ updateAppliedCouponsTracking([]);
2886
+ }
2887
+
2888
+ // Update pricing display
2889
+ updateCheckoutPricing(result.data);
2890
+
2891
+ // Refresh available coupons list to update button states
2892
+ fetchDiscountCodes();
2893
+
2894
+ // Show success message
2895
+ alert(`Coupon "${discountCode}" applied successfully!`);
2896
+
2897
+ // Re-enable all apply buttons (allow multiple applications)
2898
+ applyButtons.forEach(btn => {
2899
+ btn.disabled = false;
2900
+ btn.textContent = 'Apply';
2901
+ });
2902
+ } catch (error) {
2903
+ console.error('[COUPONS] Error applying discount code:', error);
2904
+ alert(error.message || 'Failed to apply coupon. Please try again.');
2905
+
2906
+ // Re-enable buttons
2907
+ applyButtons.forEach(btn => {
2908
+ btn.disabled = false;
2909
+ btn.textContent = 'Apply';
2910
+ });
2911
+ }
2912
+ }
2913
+
2914
+ /**
2915
+ * Update checkout pricing display
2916
+ */
2917
+ function updateCheckoutPricing(checkoutData) {
2918
+ if (!checkoutData || !checkoutData.pricing) return;
2919
+
2920
+ const pricing = checkoutData.pricing;
2921
+
2922
+ // Update subtotal
2923
+ const subtotalEl = document.querySelector('[data-summary-subtotal]');
2924
+ if (subtotalEl && pricing.subtotalPrice !== undefined) {
2925
+ subtotalEl.textContent = formatMoney(pricing.subtotalPrice);
2926
+ }
2927
+
2928
+ // Update discount - ensure it's visible and properly formatted
2929
+ const discountEl = document.querySelector('[data-summary-discount]');
2930
+ const discountLine = document.querySelector('[data-summary-discount-line]');
2931
+
2932
+ // Calculate total discounts from discounts array or use totalDiscounts
2933
+ let totalDiscounts = 0;
2934
+ if (pricing.discounts && Array.isArray(pricing.discounts) && pricing.discounts.length > 0) {
2935
+ totalDiscounts = pricing.discounts.reduce((sum, discount) => sum + (discount.amount || discount.value || 0), 0);
2936
+ } else if (pricing.totalDiscounts !== undefined) {
2937
+ totalDiscounts = pricing.totalDiscounts;
2938
+ }
2939
+
2940
+ if (totalDiscounts > 0) {
2941
+ // Ensure discount line exists and is visible
2942
+ if (!discountLine) {
2943
+ // Create discount line if it doesn't exist
2944
+ const subtotalLine = document.querySelector('[data-summary-subtotal]')?.closest('.summary-line');
2945
+ if (subtotalLine) {
2946
+ const newDiscountLine = document.createElement('div');
2947
+ newDiscountLine.className = 'summary-line summary-discount';
2948
+ newDiscountLine.setAttribute('data-summary-discount-line', '');
2949
+ newDiscountLine.innerHTML = `
2950
+ <span class="summary-label">Discount</span>
2951
+ <span class="summary-value" data-summary-discount>-${formatMoney(totalDiscounts)}</span>
2952
+ `;
2953
+ subtotalLine.insertAdjacentElement('afterend', newDiscountLine);
2954
+ }
2955
+ } else {
2956
+ // Update existing discount line
2957
+ if (discountEl) {
2958
+ discountEl.textContent = `-${formatMoney(totalDiscounts)}`;
2959
+ }
2960
+ discountLine.style.display = 'flex';
2961
+ }
2962
+ } else {
2963
+ // Hide discount line if no discount
2964
+ if (discountLine) {
2965
+ discountLine.style.display = 'none';
2966
+ }
2967
+ }
2968
+
2969
+ // Update shipping
2970
+ const shippingEl = document.querySelector('[data-summary-shipping]');
2971
+ if (shippingEl && pricing.totalShipping !== undefined) {
2972
+ shippingEl.textContent = pricing.totalShipping === 0 ? 'Free' : formatMoney(pricing.totalShipping);
2973
+ }
2974
+
2975
+ // Update tax
2976
+ const taxEl = document.querySelector('[data-summary-tax]');
2977
+ if (taxEl && pricing.totalTax !== undefined) {
2978
+ taxEl.textContent = formatMoney(pricing.totalTax);
2979
+ }
2980
+
2981
+ // Update payment fee - use pricing if provided, else selected method's data-payment-fee
2982
+ let paymentFee = pricing.paymentMethodFee ?? pricing.paymentFee ?? 0;
2983
+ if (paymentFee === 0) {
2984
+ const selectedRadio = document.querySelector('input[name="paymentMethod"]:checked');
2985
+ if (selectedRadio) {
2986
+ paymentFee = parseFloat(selectedRadio.getAttribute('data-payment-fee') || '0') || 0;
2987
+ }
2988
+ }
2989
+ const feeLine = document.querySelector('[data-summary-payment-fee-line]');
2990
+ const feeValueEl = document.querySelector('[data-summary-payment-fee]');
2991
+ if (feeLine && feeValueEl) {
2992
+ const currencySymbol = document.body.dataset.shopCurrencySymbol || (typeof CHECKOUT_CURRENCY_SYMBOL !== 'undefined' ? CHECKOUT_CURRENCY_SYMBOL : '₹');
2993
+ feeValueEl.textContent = paymentFee > 0 ? formatMoney(paymentFee) : currencySymbol + '0.00';
2994
+ feeLine.style.display = paymentFee > 0 ? 'flex' : 'none';
2995
+ }
2996
+
2997
+ // Update total (API totalPrice typically excludes payment fee, so add it)
2998
+ const totalEl = document.querySelector('[data-summary-total]');
2999
+ if (totalEl && pricing.totalPrice !== undefined) {
3000
+ const baseTotal = parseFloat(pricing.totalPrice) || 0;
3001
+ totalEl.textContent = formatMoney(baseTotal + (paymentFee || 0));
3002
+ }
3003
+ }
3004
+
3005
+ /**
3006
+ * Format money value
3007
+ */
3008
+ function formatMoney(amount) {
3009
+ if (typeof amount !== 'number') {
3010
+ amount = parseFloat(amount) || 0;
3011
+ }
3012
+ // Use shop settings if available, otherwise default format
3013
+ const currencySymbol = typeof CHECKOUT_CURRENCY_SYMBOL !== 'undefined' ? CHECKOUT_CURRENCY_SYMBOL : '₹';
3014
+ return `${currencySymbol}${amount.toFixed(2)}`;
3015
+ }
3016
+
3017
+ /**
3018
+ * Remove applied coupon
3019
+ * @param {string} couponCode - The coupon code to remove
3020
+ */
3021
+ async function removeCoupon(couponCode) {
3022
+ if (!couponCode) {
3023
+ console.error('[COUPONS] No coupon code provided for removal');
3024
+ return;
3025
+ }
3026
+
3027
+ const checkoutToken = getCheckoutToken();
3028
+ if (!checkoutToken) {
3029
+ alert('Checkout session not found. Please refresh the page.');
3030
+ return;
3031
+ }
3032
+
3033
+ // Find the remove button for this coupon and disable it
3034
+ const removeButtons = document.querySelectorAll('.remove-coupon-btn');
3035
+ const targetButton = Array.from(removeButtons).find(btn => btn.dataset.couponCode === couponCode);
3036
+
3037
+ if (targetButton) {
3038
+ targetButton.disabled = true;
3039
+ targetButton.textContent = 'Removing...';
3040
+ }
3041
+
3042
+ try {
3043
+ const response = await fetch(`/webstoreapi/checkout/${checkoutToken}/remove-discount`, {
3044
+ method: 'DELETE',
3045
+ headers: {
3046
+ 'Content-Type': 'application/json'
3047
+ },
3048
+ body: JSON.stringify({
3049
+ discountCode: couponCode
3050
+ })
3051
+ });
3052
+
3053
+ if (!response.ok) {
3054
+ const errorData = await response.json().catch(() => ({}));
3055
+ throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
3056
+ }
3057
+
3058
+ const result = await response.json();
3059
+
3060
+ if (!result.success) {
3061
+ throw new Error(result.error || 'Failed to remove discount code');
3062
+ }
3063
+
3064
+ // Update applied coupons tracking from response
3065
+ if (result.data && result.data.pricing && result.data.pricing.discounts) {
3066
+ updateAppliedCouponsTracking(result.data.pricing.discounts);
3067
+ } else {
3068
+ // If no discounts in response, clear the tracking
3069
+ updateAppliedCouponsTracking([]);
3070
+ }
3071
+
3072
+ // Update pricing display
3073
+ updateCheckoutPricing(result.data);
3074
+
3075
+ // Refresh available coupons list to update button states
3076
+ fetchDiscountCodes();
3077
+
3078
+ // Show success message
3079
+ alert(`Coupon "${couponCode}" removed successfully!`);
3080
+ } catch (error) {
3081
+ console.error('[COUPONS] Error removing coupon:', error);
3082
+ alert(error.message || 'Failed to remove coupon. Please try again.');
3083
+
3084
+ // Re-enable the button
3085
+ if (targetButton) {
3086
+ targetButton.disabled = false;
3087
+ targetButton.textContent = 'Remove';
3088
+ }
3089
+ }
3090
+ }
3091
+
3092
+ /**
3093
+ * Update applied coupons tracking from discounts array
3094
+ */
3095
+ function updateAppliedCouponsTracking(discounts) {
3096
+ // Normalize discounts array
3097
+ const normalizedDiscounts = Array.isArray(discounts) ? discounts : [];
3098
+
3099
+ // Update CHECKOUT_PRICING if discounts are provided
3100
+ if (typeof CHECKOUT_PRICING !== 'undefined' && CHECKOUT_PRICING) {
3101
+ CHECKOUT_PRICING.discounts = normalizedDiscounts;
3102
+ }
3103
+
3104
+ // Also keep the global applied coupon list in sync
3105
+ const applied = [];
3106
+ normalizedDiscounts.forEach(discount => {
3107
+ const code = discount.code || discount.couponCode || discount.discountCode;
3108
+ if (code && !applied.includes(code)) {
3109
+ applied.push(code);
3110
+ }
3111
+ });
3112
+ window.appliedCouponCodes = applied;
3113
+ }
3114
+
3115
+ /**
3116
+ * Show currently applied coupons from checkout data
3117
+ */
3118
+ function showAppliedCoupon() {
3119
+ // Try to get discounts from checkout pricing data
3120
+ let discounts = null;
3121
+
3122
+ // First, try from global CHECKOUT_PRICING variable
3123
+ if (typeof CHECKOUT_PRICING !== 'undefined' && CHECKOUT_PRICING && CHECKOUT_PRICING.discounts) {
3124
+ discounts = CHECKOUT_PRICING.discounts;
3125
+ }
3126
+
3127
+ // If not available, try to fetch checkout data
3128
+ if (!discounts) {
3129
+ const checkoutToken = getCheckoutToken();
3130
+ if (checkoutToken) {
3131
+ // Fetch checkout to get current discounts
3132
+ fetch(`/webstoreapi/checkout/${checkoutToken}`)
3133
+ .then(res => res.text())
3134
+ .then(text => {
3135
+ // Some error responses may return HTML instead of JSON.
3136
+ // Try to parse JSON; if it fails, gracefully treat as no discounts.
3137
+ try {
3138
+ return JSON.parse(text);
3139
+ } catch (parseError) {
3140
+ console.warn('[COUPONS] Non-JSON response while fetching checkout for discounts. Treating as no discounts.', {
3141
+ message: parseError.message
3142
+ });
3143
+ return { success: false, data: null };
3144
+ }
3145
+ })
3146
+ .then(result => {
3147
+ if (result.success && result.data && result.data.pricing && result.data.pricing.discounts) {
3148
+ updateAppliedCouponsTracking(result.data.pricing.discounts);
3149
+ } else {
3150
+ updateAppliedCouponsTracking([]);
3151
+ }
3152
+ // Refresh available coupons to update button states
3153
+ fetchDiscountCodes();
3154
+ })
3155
+ .catch(error => {
3156
+ console.error('[COUPONS] Error fetching checkout for discounts:', error);
3157
+ updateAppliedCouponsTracking([]);
3158
+ });
3159
+ return;
3160
+ }
3161
+ }
3162
+
3163
+ updateAppliedCouponsTracking(discounts || []);
3164
+ }
3165
+
3166
+ // Initialize coupon functionality on page load
3167
+ document.addEventListener('DOMContentLoaded', () => {
3168
+ // Fetch discount codes when page loads
3169
+ fetchDiscountCodes();
3170
+
3171
+ // Show applied coupons if any
3172
+ showAppliedCoupon();
3173
+ });
3174
+ })();