@o2vend/theme-cli 1.0.37 → 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.
- package/lib/lib/dev-server.js +309 -40
- package/lib/lib/liquid-engine.js +3 -1
- package/lib/lib/mock-data.js +36 -124
- package/lib/lib/widget-service.js +12 -4
- package/package.json +1 -1
- package/test-theme/assets/async-sections.js +32 -24
- package/test-theme/assets/cart-drawer.js +20 -22
- package/test-theme/assets/cart-manager.js +1 -15
- package/test-theme/assets/checkout-price-handler.js +12 -11
- package/test-theme/assets/checkout.css +1415 -0
- package/test-theme/assets/checkout.js +3174 -0
- package/test-theme/assets/components.css +178 -29
- package/test-theme/assets/delivery-zone.js +1 -1
- package/test-theme/assets/product-detail.css +1050 -0
- package/test-theme/assets/product-detail.js +2940 -0
- package/test-theme/assets/theme.css +95 -120
- package/test-theme/assets/theme.js +781 -186
- package/test-theme/layout/theme.liquid +91 -17
- package/test-theme/sections/content.liquid +64 -57
- package/test-theme/sections/footer-fallback.liquid +57 -7
- package/test-theme/sections/footer.liquid +63 -12
- package/test-theme/sections/header-fallback.liquid +41 -41
- package/test-theme/sections/header.liquid +41 -51
- package/test-theme/sections/hero-fallback.liquid +1 -1
- package/test-theme/sections/hero.liquid +159 -136
- package/test-theme/snippets/account-sidebar.liquid +121 -29
- package/test-theme/snippets/add-to-cart-modal.liquid +258 -206
- package/test-theme/snippets/breadcrumbs.liquid +98 -11
- package/test-theme/snippets/cart-drawer.liquid +93 -0
- package/test-theme/snippets/delivery-zone-city-selector.liquid +101 -15
- package/test-theme/snippets/delivery-zone-modal.liquid +529 -84
- package/test-theme/snippets/delivery-zone-search.liquid +104 -18
- package/test-theme/snippets/login-modal.liquid +269 -82
- package/test-theme/snippets/mega-menu.liquid +130 -43
- package/test-theme/snippets/news-thumbnail.liquid +120 -28
- package/test-theme/snippets/pagination.liquid +1 -1
- package/test-theme/snippets/price.liquid +100 -9
- package/test-theme/snippets/product-card-related.liquid +22 -4
- package/test-theme/snippets/product-card-simple.liquid +521 -25
- package/test-theme/snippets/product-card.liquid +145 -232
- package/test-theme/snippets/rating.liquid +100 -9
- package/test-theme/snippets/skeleton-collection-grid.liquid +94 -8
- package/test-theme/snippets/skeleton-product-card.liquid +102 -16
- package/test-theme/snippets/skeleton-product-grid.liquid +87 -1
- package/test-theme/snippets/social-sharing.liquid +133 -32
- package/test-theme/templates/account/dashboard.liquid +30 -0
- package/test-theme/templates/account/loyalty-redemption.liquid +29 -28
- package/test-theme/templates/account/loyalty.liquid +45 -43
- package/test-theme/templates/account/order-detail.liquid +15 -8
- package/test-theme/templates/account/orders.liquid +189 -35
- package/test-theme/templates/account/profile.liquid +509 -114
- package/test-theme/templates/account/register.liquid +18 -8
- package/test-theme/templates/account/return-orders.liquid +31 -30
- package/test-theme/templates/account/store-credit.liquid +27 -26
- package/test-theme/templates/account/subscriptions.liquid +22 -5
- package/test-theme/templates/account/wishlist.liquid +88 -19
- package/test-theme/templates/address-book.liquid +166 -69
- package/test-theme/templates/categories.liquid +90 -30
- package/test-theme/templates/checkout.liquid +137 -3834
- package/test-theme/templates/error.liquid +23 -21
- package/test-theme/templates/index.liquid +29 -0
- package/test-theme/templates/login.liquid +33 -6
- package/test-theme/templates/order-confirmation.liquid +67 -9
- package/test-theme/templates/page.liquid +418 -206
- package/test-theme/templates/product-detail.liquid +124 -3878
- package/test-theme/templates/products.liquid +155 -30
- package/test-theme/templates/search.liquid +739 -225
- package/test-theme/widgets/brand-carousel.liquid +102 -82
- package/test-theme/widgets/brand.liquid +78 -50
- package/test-theme/widgets/carousel.liquid +253 -121
- package/test-theme/widgets/category-list-carousel.liquid +32 -8
- package/test-theme/widgets/category-list.liquid +21 -6
- package/test-theme/widgets/category.liquid +104 -37
- package/test-theme/widgets/discount-time.liquid +326 -119
- package/test-theme/widgets/footer-menu.liquid +115 -23
- package/test-theme/widgets/footer.liquid +118 -5
- package/test-theme/widgets/gallery.liquid +29 -5
- package/test-theme/widgets/header-menu.liquid +25 -13
- package/test-theme/widgets/header.liquid +64 -26
- package/test-theme/widgets/html.liquid +29 -6
- package/test-theme/widgets/news.liquid +6 -0
- package/test-theme/widgets/product-canvas.liquid +20 -12
- package/test-theme/widgets/product-carousel.liquid +118 -56
- package/test-theme/widgets/shared/product-grid.liquid +12 -0
- package/test-theme/widgets/single-product.liquid +688 -250
- package/test-theme/widgets/spacebar-carousel.liquid +39 -10
- package/test-theme/widgets/spacebar.liquid +77 -6
- package/test-theme/widgets/splash.liquid +40 -30
- 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
|
+
})();
|