PluginProbe
Easy Hotel – Powerful Hotel Booking / trunk
Easy Hotel – Powerful Hotel Booking vtrunk
2.0.8 2.0.7 2.0.6 2.0.5 2.0.4 2.0.3 2.0.2 2.0.1 2.0.0 1.9.9 1.9.8 1.9.7 1.9.6 1.9.5 1.9.4 1.9.3 1.9.2 1.8.1 1.8.2 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 All 110 releases
easy-hotel / admin / includes / native-checkout / assets / js / checkout.js

checkout.js in Easy Hotel – Powerful Hotel Booking trunk, at admin/includes/native-checkout/assets/js/checkout.js

598 lines 24.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Easy Hotel — Native Checkout frontend (multi-accommodation cart).
3 *
4 * Server-authoritative pricing: any change that affects the total (editing
5 * extra services on an item, applying/removing a coupon, removing an item)
6 * round-trips to the server, which recomputes the whole-cart pricing and
7 * returns it. The JS just renders whatever the server returns, so the
8 * displayed total always matches what will be charged.
9 */
10 (function ($) {
11 'use strict';
12
13 if (typeof window.eshbNativeCheckout === 'undefined') return;
14
15 var state = {
16 config: window.eshbNativeCheckout,
17 pricing: window.eshbNativeCheckout.pricing || {},
18 coupon: { code: '', valid: false },
19 gateway: '',
20 recalcTimer: null
21 };
22
23 // Seed coupon state from the server payload (e.g. user navigated back).
24 if (state.pricing && state.pricing.couponValid && state.pricing.couponCode) {
25 state.coupon = { code: state.pricing.couponCode, valid: true };
26 }
27
28 /* -----------------------------------------------------------------
29 * Pricing rendering
30 * --------------------------------------------------------------- */
31 function applyPricing(pricing) {
32 if (!pricing || typeof pricing !== 'object') return;
33 state.pricing = pricing;
34
35 // Whole-cart figures bound by [data-eshb-price].
36 $('[data-eshb-price]').each(function () {
37 var key = $(this).attr('data-eshb-price');
38 if (typeof pricing[key] === 'undefined') return;
39 if (key === 'couponDiscountHtml') {
40 $(this).html('- ' + pricing[key]);
41 } else {
42 $(this).html(pricing[key]);
43 }
44 });
45
46 // Conditional rows.
47 $('[data-eshb-row="coupon"]').toggle(parseFloat(pricing.couponDiscount || 0) > 0);
48 $('[data-eshb-row="tax"]').toggle(parseFloat(pricing.taxAmount || 0) > 0);
49 $('[data-eshb-coupon-code]').text(pricing.couponCode || '');
50 $('[data-eshb-tax-rate]').text(pricing.taxRate || 0);
51
52 // Per-item totals (pricing.items is keyed by item key).
53 var items = pricing.items || {};
54 Object.keys(items).forEach(function (key) {
55 var it = items[key] || {};
56 $('[data-eshb-item-total="' + key + '"]').html(it.totalPriceHtml || '');
57 });
58 }
59
60 /* -----------------------------------------------------------------
61 * Collect per-item service selections for the server
62 * --------------------------------------------------------------- */
63 /**
64 * Service Options → Max Quantity for a .eshb-service-option element.
65 * 0 means no cap was configured.
66 */
67 function serviceMaxQty($opt) {
68 var max = parseInt($opt.attr('data-service-max-qty'), 10);
69 return isNaN(max) || max < 1 ? 0 : max;
70 }
71
72 /**
73 * Clamp a qty box to its service cap and return the value actually kept.
74 */
75 function clampServiceQty($opt) {
76 var $input = $opt.find('input[data-service-qty]');
77 var qty = parseInt($input.val(), 10) || 1;
78 var max = serviceMaxQty($opt);
79
80 if (qty < 1) qty = 1;
81 if (max > 0 && qty > max) qty = max;
82
83 if (String(qty) !== String($input.val())) $input.val(qty);
84
85 return qty;
86 }
87
88 function collectItemsServices() {
89 var out = {};
90 $('.eshb-cart-item').each(function () {
91 var key = $(this).attr('data-item-key');
92 if (!key) return;
93 var svcs = [];
94 $(this).find('.eshb-service-option').each(function () {
95 var $opt = $(this);
96 if (!$opt.find('input[type="checkbox"]').prop('checked')) return;
97 var id = parseInt($opt.attr('data-service-id'), 10);
98 if (!id) return;
99 svcs.push({ id: id, quantity: clampServiceQty($opt) });
100 });
101 out[key] = svcs;
102 });
103 return out;
104 }
105
106 function getCustomer() {
107 var $form = $('#eshbNativeCheckoutForm');
108 return {
109 firstName: $form.find('[name="firstName"]').val(),
110 lastName: $form.find('[name="lastName"]').val(),
111 email: $form.find('[name="email"]').val(),
112 phone: $form.find('[name="phone"]').val(),
113 country: $form.find('[name="country"]').val(),
114 state: $form.find('[name="state"]').val(),
115 city: $form.find('[name="city"]').val(),
116 postcode: $form.find('[name="postcode"]').val(),
117 notes: $form.find('textarea[name="notes"]').val()
118 };
119 }
120
121 function ajaxData(extra) {
122 var customer = getCustomer();
123 var payload = {
124 nonce: state.config.nonce,
125 gateway: state.gateway,
126 coupon: state.coupon.valid ? state.coupon.code : '',
127 itemsServices: JSON.stringify(collectItemsServices()),
128 firstName: customer.firstName,
129 lastName: customer.lastName,
130 email: customer.email,
131 phone: customer.phone,
132 country: customer.country,
133 state: customer.state,
134 city: customer.city,
135 postcode: customer.postcode,
136 notes: customer.notes
137 };
138 // Reservation token: cookies can be stripped on some live hosts, so
139 // always carry it in the request body.
140 if (state.config.token && state.config.tokenParam) {
141 payload[state.config.tokenParam] = state.config.token;
142 }
143 return $.extend(payload, extra || {});
144 }
145
146 /* -----------------------------------------------------------------
147 * Live recalculation (debounced) on service edits
148 * --------------------------------------------------------------- */
149 function scheduleRecalc() {
150 if (state.recalcTimer) clearTimeout(state.recalcTimer);
151 state.recalcTimer = setTimeout(recalc, 400);
152 }
153
154 function recalc() {
155 $.post(state.config.ajaxUrl, ajaxData({ action: 'eshb_native_recalculate' }))
156 .done(function (resp) {
157 if (resp && resp.success && resp.data && resp.data.pricing) {
158 applyPricing(resp.data.pricing);
159 }
160 });
161 }
162
163 function updateItemSummary($item) {
164 var titles = [];
165 $item.find('.eshb-service-option').each(function () {
166 var $opt = $(this);
167 if (!$opt.find('input[type="checkbox"]').prop('checked')) return;
168 var title = ($opt.find('.eshb-choice-title').text() || '').trim();
169 var qty = clampServiceQty($opt);
170 titles.push(qty > 1 ? title + ' × ' + qty : title);
171 });
172 var fallback = (state.config.i18n && state.config.i18n.noServicesSelected) || 'None selected';
173 $item.find('.eshb-services-summary-list').text(titles.length ? titles.join(', ') : fallback);
174 }
175
176 function bindServiceEvents() {
177 // Toggle the services editor open/closed per item.
178 $(document).on('click', '.eshb-services-edit-toggle', function (e) {
179 e.preventDefault();
180 var $editor = $(this).closest('.eshb-cart-item').find('.eshb-services-editor');
181 var i18n = state.config.i18n || {};
182 if ($editor.prop('hidden')) {
183 $editor.prop('hidden', false);
184 $(this).text(i18n.doneEditingServices || 'Done');
185 } else {
186 $editor.prop('hidden', true);
187 $(this).text(i18n.editServices || 'Edit');
188 }
189 });
190
191 $(document).on('change', '.eshb-service-option input[type="checkbox"]', function () {
192 var $opt = $(this).closest('.eshb-service-option');
193 $opt.find('.eshb-service-qty').css('display', this.checked ? 'flex' : 'none');
194 updateItemSummary($opt.closest('.eshb-cart-item'));
195 scheduleRecalc();
196 });
197
198 $(document).on('click', '.eshb-service-option .eshb-qty-btn', function () {
199 var dir = parseInt($(this).data('dir'), 10);
200 var $opt = $(this).closest('.eshb-service-option');
201 var $input = $(this).siblings('input[data-service-qty]');
202 var max = serviceMaxQty($opt);
203 var next = (parseInt($input.val(), 10) || 1) + dir;
204
205 if (next < 1) next = 1;
206 if (max > 0 && next > max) next = max;
207
208 $input.val(next);
209 updateItemSummary($(this).closest('.eshb-cart-item'));
210 scheduleRecalc();
211 });
212
213 $(document).on('input change', '.eshb-service-option input[data-service-qty]', function () {
214 clampServiceQty($(this).closest('.eshb-service-option'));
215 updateItemSummary($(this).closest('.eshb-cart-item'));
216 scheduleRecalc();
217 });
218 }
219
220 /* -----------------------------------------------------------------
221 * Remove an accommodation from the cart
222 * --------------------------------------------------------------- */
223 function bindRemoveItem() {
224 $(document).on('click', '.eshb-remove-item', function () {
225 var key = $(this).attr('data-item-key');
226 if (!key) return;
227 var msg = (state.config.i18n && state.config.i18n.confirmRemove) || 'Remove this accommodation?';
228 if (!window.confirm(msg)) return;
229
230 var $btn = $(this);
231 $btn.prop('disabled', true);
232
233 $.post(state.config.ajaxUrl, ajaxData({ action: 'eshb_native_remove_item', item_key: key }))
234 .done(function (resp) {
235 if (resp && resp.success) {
236 if (resp.data.cart_empty) {
237 window.location.reload();
238 return;
239 }
240 $('.eshb-cart-item[data-item-key="' + key + '"]').remove();
241 applyPricing(resp.data.pricing);
242 } else {
243 $btn.prop('disabled', false);
244 showError((resp && resp.data && resp.data.message) || state.config.i18n.paymentFailed);
245 }
246 })
247 .fail(function () {
248 $btn.prop('disabled', false);
249 showError(state.config.i18n.paymentFailed);
250 });
251 });
252 }
253
254 /* -----------------------------------------------------------------
255 * Coupon
256 * --------------------------------------------------------------- */
257 function bindCouponEvents() {
258 var $toggle = $('#eshbCouponToggle');
259 var $prompt = $('.eshb-coupon-prompt');
260 var $panel = $('#eshbCouponPanel');
261 var $code = $('#eshbCouponCode');
262 var $apply = $('#eshbApplyCoupon');
263 var $remove = $('#eshbRemoveCoupon');
264 var $msg = $('#eshbCouponMessage');
265
266 $toggle.on('click', function (e) {
267 e.preventDefault();
268 $prompt.prop('hidden', true);
269 $panel.prop('hidden', false);
270 $toggle.attr('aria-expanded', 'true');
271 $code.trigger('focus');
272 });
273
274 $apply.on('click', function () {
275 var code = ($code.val() || '').trim();
276 if (!code) {
277 $msg.text(state.config.i18n.invalidCoupon || '').addClass('eshb-msg-error');
278 return;
279 }
280 $apply.prop('disabled', true);
281 $msg.removeClass('eshb-msg-error eshb-msg-success').text(state.config.i18n.couponApplying);
282
283 $.post(state.config.ajaxUrl, ajaxData({ action: 'eshb_native_apply_coupon', coupon: code }))
284 .done(function (resp) {
285 if (resp && resp.success && resp.data && resp.data.pricing && resp.data.pricing.couponValid) {
286 state.coupon = { code: resp.data.pricing.couponCode, valid: true };
287 $msg.text(resp.data.pricing.couponMessage || '').addClass('eshb-msg-success').removeClass('eshb-msg-error');
288 $remove.show();
289 $apply.hide();
290 $code.prop('disabled', true);
291 applyPricing(resp.data.pricing);
292 } else {
293 var err = (resp && resp.data && resp.data.message) || 'Invalid coupon';
294 $msg.text(err).addClass('eshb-msg-error').removeClass('eshb-msg-success');
295 state.coupon = { code: '', valid: false };
296 if (resp && resp.data && resp.data.pricing) {
297 applyPricing(resp.data.pricing);
298 }
299 // Per-user coupon needs the email — focus it.
300 if (/email/i.test(err)) {
301 var $emailInput = $('#eshbNativeCheckoutForm [name="email"]');
302 if ($emailInput.length) {
303 $emailInput[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
304 setTimeout(function () { $emailInput.trigger('focus'); }, 350);
305 }
306 }
307 }
308 })
309 .fail(function () {
310 $msg.text(state.config.i18n.paymentFailed).addClass('eshb-msg-error');
311 })
312 .always(function () {
313 $apply.prop('disabled', false);
314 });
315 });
316
317 $remove.on('click', function () {
318 state.coupon = { code: '', valid: false };
319 $code.prop('disabled', false).val('');
320 $msg.text(state.config.i18n.couponRemoved).removeClass('eshb-msg-error eshb-msg-success');
321 $remove.hide();
322 $apply.show();
323 $panel.prop('hidden', true);
324 $prompt.prop('hidden', false);
325 $toggle.attr('aria-expanded', 'false');
326 recalc();
327 });
328 }
329
330 /* -----------------------------------------------------------------
331 * Gateways / form
332 * --------------------------------------------------------------- */
333 function bindGatewaySelection() {
334 $('input[name="eshbPaymentMethod"]').on('change', function () {
335 state.gateway = $(this).val();
336 $('#eshbGatewayMount > div').hide();
337 $('#eshbGatewayMount [data-gateway="' + state.gateway + '"]').show();
338 if (state.gateway === 'paypal') {
339 $('#eshbCheckoutSubmit').hide();
340 initPayPalButtons();
341 } else {
342 $('#eshbCheckoutSubmit').show();
343 }
344 });
345 }
346
347 function showError(msg) {
348 $('#eshbCheckoutError').text(msg).show();
349 setTimeout(function () { $('#eshbCheckoutError').fadeOut(); }, 6000);
350 }
351
352 function clearFieldErrors() {
353 $('#eshbNativeCheckoutForm .eshb-error-input').removeClass('eshb-error-input');
354 }
355
356 function markFieldError(selector) {
357 var $field = $('#eshbNativeCheckoutForm').find(selector).first();
358 if (!$field.length) return null;
359 $field.addClass('eshb-error-input');
360 $field.one('input change', function () { $(this).removeClass('eshb-error-input'); });
361 return $field;
362 }
363
364 function validateForm() {
365 clearFieldErrors();
366 var customer = getCustomer();
367 var requiredFields = [
368 { key: 'firstName', selector: '[name="firstName"]' },
369 { key: 'lastName', selector: '[name="lastName"]' },
370 { key: 'email', selector: '[name="email"]' },
371 { key: 'phone', selector: '[name="phone"]' },
372 { key: 'country', selector: '[name="country"]' },
373 { key: 'city', selector: '[name="city"]' }
374 ];
375
376 var firstInvalid = null;
377 for (var i = 0; i < requiredFields.length; i++) {
378 if (!customer[requiredFields[i].key]) {
379 var $marked = markFieldError(requiredFields[i].selector);
380 if (!firstInvalid && $marked) firstInvalid = $marked;
381 }
382 }
383 if (!$('#eshbStateSelect').prop('disabled') && !customer.state) {
384 var $stateMarked = markFieldError('[name="state"]');
385 if (!firstInvalid && $stateMarked) firstInvalid = $stateMarked;
386 }
387
388 if (firstInvalid) {
389 showError(state.config.i18n.missingFields);
390 firstInvalid.trigger('focus');
391 return null;
392 }
393 if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(customer.email)) {
394 markFieldError('[name="email"]').trigger('focus');
395 showError(state.config.i18n.invalidEmail);
396 return null;
397 }
398 if (!$('#eshbTerms').prop('checked')) {
399 markFieldError('#eshbTerms');
400 showError(state.config.i18n.missingTerms);
401 return null;
402 }
403 if (!state.gateway) {
404 showError(state.config.i18n.missingPayment);
405 return null;
406 }
407 return customer;
408 }
409
410 function initLocationSelects() {
411 var $country = $('#eshbCountrySelect');
412 var $stateSel = $('#eshbStateSelect');
413 var $stateGroup = $('#eshbStateGroup');
414 if (!$country.length || !state.config.countriesJsonUrl) return;
415
416 $.getJSON(state.config.countriesJsonUrl).done(function (data) {
417 if (!Array.isArray(data)) return;
418 state.countries = data.slice().sort(function (a, b) {
419 return (a.name || '').localeCompare(b.name || '');
420 });
421 state.countries.forEach(function (c) {
422 $country.append($('<option/>', { value: c.code2.trim(), text: c.name }));
423 });
424 }).fail(function () {
425 var $fallback = $('<input type="text" name="country" required>');
426 $country.replaceWith($fallback);
427 $stateSel.replaceWith($('<input type="text" name="state">'));
428 });
429
430 $country.on('change', function () {
431 var name = $country.val();
432 $stateSel.empty().append($('<option/>', { value: '', text: ($stateSel.find('option').first().text() || 'Select a state…') }));
433 var match = (state.countries || []).find(function (c) { return c.code2.trim() === name; });
434 var hasStates = match && Array.isArray(match.states) && match.states.length > 0;
435 if (hasStates) {
436 match.states.slice()
437 .sort(function (a, b) { return (a.name || '').localeCompare(b.name || ''); })
438 .forEach(function (s) {
439 $stateSel.append($('<option/>', { value: s.name, text: s.name }));
440 });
441 $stateSel.prop('disabled', false);
442 $stateGroup.show();
443 } else {
444 $stateSel.prop('disabled', true).val('');
445 $stateGroup.hide();
446 }
447 });
448 }
449
450 function completeCheckout(gatewayParams) {
451 return $.post(state.config.ajaxUrl, $.extend(ajaxData({
452 action: 'eshb_native_complete_checkout'
453 }), { gatewayParams: gatewayParams || {} }));
454 }
455
456 // Clear the saved cart token so the next booking starts a fresh cart
457 // (the cart was consumed/cleared server-side on completion).
458 function clearCartToken() {
459 try { sessionStorage.removeItem('eshb_native_checkout_token'); } catch (e) { /* ignore */ }
460 }
461
462 function initPayPalButtons() {
463 if (typeof window.paypal === 'undefined' || !window.paypal.Buttons) return;
464 var $mount = $('#eshbPayPalButtons');
465 if ($mount.data('rendered')) return;
466 $mount.data('rendered', true);
467 $mount.empty();
468
469 window.paypal.Buttons({
470 style: { layout: 'vertical', shape: 'rect' },
471 onClick: function (data, actions) {
472 var customer = validateForm();
473 if (!customer) return actions.reject();
474 return actions.resolve();
475 },
476 createOrder: function () {
477 return $.post(state.config.ajaxUrl, ajaxData({
478 action: 'eshb_native_create_payment'
479 })).then(function (resp) {
480 if (!resp || !resp.success || !resp.data || !resp.data.order_id) {
481 var msg = (resp && resp.data && resp.data.message) || state.config.i18n.paymentFailed;
482 showError(msg);
483 throw new Error(msg);
484 }
485 return resp.data.order_id;
486 });
487 },
488 onApprove: function (data) {
489 return completeCheckout({ order_id: data.orderID }).then(function (resp) {
490 if (resp && resp.success && resp.data && resp.data.redirect_url) {
491 clearCartToken();
492 window.location.href = resp.data.redirect_url;
493 } else {
494 showError((resp && resp.data && resp.data.message) || state.config.i18n.paymentFailed);
495 }
496 }).fail(function () {
497 showError(state.config.i18n.paymentFailed);
498 });
499 },
500 onError: function () {
501 showError(state.config.i18n.paymentFailed);
502 }
503 }).render('#eshbPayPalButtons');
504 }
505
506 function submitOfflineCheckout() {
507 var $btn = $('#eshbCheckoutSubmit');
508 if ($btn.prop('disabled')) return;
509 var originalText = $btn.text();
510 $btn.prop('disabled', true).text(state.config.i18n.processing || 'Processing…');
511
512 completeCheckout({}).done(function (resp) {
513 if (resp && resp.success && resp.data && resp.data.redirect_url) {
514 clearCartToken();
515 window.location.href = resp.data.redirect_url;
516 } else {
517 showError((resp && resp.data && resp.data.message) || state.config.i18n.paymentFailed);
518 $btn.prop('disabled', false).text(originalText);
519 }
520 }).fail(function () {
521 showError(state.config.i18n.paymentFailed);
522 $btn.prop('disabled', false).text(originalText);
523 });
524 }
525
526 function bindFormSubmit() {
527 $('#eshbNativeCheckoutForm').on('submit', function (e) {
528 e.preventDefault();
529 var customer = validateForm();
530 if (!customer) return;
531 if (state.gateway === 'paypal') {
532 showError('Please use the PayPal button above to complete your payment.');
533 return;
534 }
535 submitOfflineCheckout();
536 });
537 }
538
539 /* -----------------------------------------------------------------
540 * Cart-blocking hold countdown
541 * --------------------------------------------------------------- */
542 function releaseReservationAndReload() {
543 var payload = { action: 'eshb_native_release_reservation', nonce: state.config.nonce };
544 if (state.config.token && state.config.tokenParam) {
545 payload[state.config.tokenParam] = state.config.token;
546 }
547 clearCartToken();
548 $.post(state.config.ajaxUrl, payload).always(function () {
549 window.location.reload();
550 });
551 }
552
553 function initCartBlockTimer() {
554 var cb = state.config.cartBlock;
555 if (!cb || !cb.enabled || !cb.until) return;
556 var notice = document.getElementById('eshb-cart-block-notice');
557 if (!notice) return;
558
559 var untilMs = parseInt(cb.until, 10) * 1000;
560 if (!untilMs || untilMs <= Date.now()) {
561 releaseReservationAndReload();
562 return;
563 }
564
565 notice.style.display = 'block';
566 var timerEl = notice.querySelector('.eshb-block-timer');
567
568 var intervalId = setInterval(function () {
569 var remaining = Math.floor((untilMs - Date.now()) / 1000);
570 if (remaining <= 0) {
571 clearInterval(intervalId);
572 if (timerEl) timerEl.textContent = '0:00';
573 releaseReservationAndReload();
574 return;
575 }
576 var mins = Math.floor(remaining / 60);
577 var secs = remaining % 60;
578 if (timerEl) timerEl.textContent = mins + ':' + (secs < 10 ? '0' : '') + secs;
579 }, 1000);
580 }
581
582 /* -----------------------------------------------------------------
583 * Init
584 * --------------------------------------------------------------- */
585 $(function () {
586 bindServiceEvents();
587 bindRemoveItem();
588 bindCouponEvents();
589 bindGatewaySelection();
590 bindFormSubmit();
591 initLocationSelects();
592 applyPricing(state.pricing);
593 initCartBlockTimer();
594
595 $('input[name="eshbPaymentMethod"]:checked').trigger('change');
596 });
597 })(jQuery);
598