PluginProbe
Pay with Vipps and MobilePay for WooCommerce / 6.3.1
Pay with Vipps and MobilePay for WooCommerce v6.3.1
6.3.1 6.2.6 6.3.0 6.2.5 6.2.4 6.2.3 6.2.2 6.2.1 6.2.0 6.1.10 6.1.9 6.1.8 6.1.7 6.1.6 6.1.5 6.1.4 6.1.3 6.1.2 6.1.1 6.1.0 6.0.5 6.0.4 6.0.3 6.0.2 6.0.1 All 190 releases
woo-vipps / payment / js / vipps-classic-checkout.js

vipps-classic-checkout.js in Pay with Vipps and MobilePay for WooCommerce 6.3.1, at payment/js/vipps-classic-checkout.js

564 lines 22.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /* global jQuery */
2 /**
3 * SDK adapter for the classic checkout form, using its existing submit button.
4 * Load after wc-checkout and vipps-gw. PHP enqueue instructions: Payment/README.md.
5 * jQuery is intentional: both classic checkout and pay-for-order use WooCommerce's
6 * legacy jQuery form/events, including pay-for-order pages reached from Blocks.
7 * The order-pay branch uses WooCommerce's existing-order Store API route. Never
8 * send an order-pay form to the cart checkout endpoint.
9 */
10 jQuery(($) => {
11 'use strict';
12 const boundSubmitButtons = new WeakSet();
13
14 /** Keep the native WooCommerce submit control as the non-Vipps fallback. */
15 function updateVippsButton($form) {
16 const isVipps = $form.find('input[name="payment_method"]:checked').val() === 'vipps';
17 const $vippsButton = $form.find('#vipps-classic-checkout-submit').first();
18 const $normalButton = $form.find('#place_order').first();
19 if (!$vippsButton.length || !$normalButton.length) return;
20 $vippsButton.toggleClass('hidden', !isVipps)
21 .prop('disabled', !isVipps)
22 .attr('aria-hidden', isVipps ? 'false' : 'true')
23 .attr('tabindex', isVipps ? '0' : '-1');
24 $normalButton.toggleClass('hidden', isVipps)
25 .attr('aria-hidden', isVipps ? 'true' : 'false');
26 }
27
28 function bindVippsButton($form) {
29 const refresh = () => {
30 updateVippsButton($form);
31 const button = $form.find('#vipps-classic-checkout-submit').first()[0];
32 if (!button || boundSubmitButtons.has(button)) return;
33
34 // A click inside the nested web component may not activate the
35 // outer native submit button in every browser. Capture that click
36 // and use one explicit form submission instead of its default action.
37 button.addEventListener('click', (event) => {
38 if (button.disabled) return;
39 event.preventDefault();
40 const form = button.form || $form[0];
41 if (typeof form.requestSubmit === 'function') {
42 form.requestSubmit(button.form === form ? button : undefined);
43 } else {
44 $form.trigger('submit');
45 }
46 }, true);
47 boundSubmitButtons.add(button);
48 };
49 $form.on('change.vippsButtons', 'input[name="payment_method"]', refresh);
50 $(document.body).on('payment_method_selected.vippsButtons updated_checkout.vippsButtons', refresh);
51 refresh();
52 }
53
54 if (document.body.classList.contains('woocommerce-order-pay')) {
55 initOrderPay($);
56 return;
57 }
58
59 /**
60 * Pay an existing order through POST /wc/store/v1/checkout/{id}. PHP must
61 * localize VippsOrderPayConfig with an authorized order id, addresses, and
62 * a Store API nonce. The order key/email are sent for guest authorization.
63 */
64 function initOrderPay($) {
65 const config = window.VippsOrderPayConfig || {};
66 const $form = $('#order_review').first();
67 if (!$form.length || !config.orderId || !config.endpoint ||
68 !config.billingAddress || !config.shippingAddress ||
69 !window.vipps?.trigger || !window.vipps?.host ||
70 typeof window.ensureVippsWidgetHostStarted !== 'function' ||
71 typeof window.setVippsPaymentBusy !== 'function' ||
72 $form.data('vippsOrderPay')) {
73 return;
74 }
75 $form.data('vippsOrderPay', true);
76 bindVippsButton($form);
77
78 const owner = 'order-pay';
79 let attempt = null;
80
81 function message(key, fallback) {
82 return window.VippsLocale?.[key] || fallback;
83 }
84
85 function showWooNotice(text) {
86 const $wrapper = $form.closest('.woocommerce').find('.woocommerce-notices-wrapper').first();
87 const $notice = $('<ul>').addClass('woocommerce-error').attr('role', 'alert')
88 .append($('<li>').text(text));
89 $form.find('.woocommerce-NoticeGroup-checkout, .vipps-order-pay-notice').remove();
90 const $group = $('<div>').addClass('woocommerce-NoticeGroup woocommerce-NoticeGroup-checkout vipps-order-pay-notice')
91 .append($notice);
92 ($wrapper.length ? $wrapper : $form).first().prepend($group);
93 $group.trigger('focus');
94 if (typeof $.scroll_to_notices === 'function') {
95 $.scroll_to_notices($group);
96 } else if ($group.offset()) {
97 $('html, body').stop(true).animate({ scrollTop: $group.offset().top - 100 }, 400);
98 }
99 }
100
101 function responseMessage(xhr, fallback) {
102 const response = xhr?.responseJSON || xhr?.response || xhr;
103 return response?.message || response?.data?.message || response?.messages || fallback;
104 }
105
106 function setBusy(busy) {
107 window.setVippsPaymentBusy(busy, owner);
108 $form.toggleClass('processing', busy).attr('aria-busy', busy ? 'true' : 'false');
109 if (!busy && typeof $form.unblock === 'function') {
110 $form.unblock();
111 }
112 $form.find(':input').prop('disabled', busy);
113 }
114
115 function urlFromResponse(response) {
116 const result = response?.payment_result || response?.paymentResult || {};
117 const details = result.payment_details || result.paymentDetails || [];
118 const normalized = Array.isArray(details)
119 ? details.reduce((out, item) => { if (item?.key) out[item.key] = item.value; return out; }, {})
120 : details;
121 return normalized.vippsPaymentUrl || normalized.vipps_payment_url ||
122 result.redirect_url || result.redirectUrl || response?.redirect || '';
123 }
124
125 function validUrl(value) {
126 try {
127 const url = new URL(value, window.location.href);
128 return ['https:', 'http:'].includes(url.protocol) ? url.href : '';
129 } catch {
130 return '';
131 }
132 }
133
134 function release() {
135 if (!attempt) return;
136 attempt = null;
137 setBusy(false);
138 $form.find(':input').prop('disabled', false);
139 $form.removeClass('processing').removeAttr('aria-busy');
140 }
141
142 function navigate(url, successful = false) {
143 if (successful && url) {
144 // The widget is about to close, but this page remains visible until navigation.
145 window.showVippsSuccessRedirectOverlay('order-pay-redirect');
146 window.setVippsPaymentBusy(false, owner);
147 } else {
148 setBusy(false);
149 }
150 window.location.assign(url || window.location.href);
151 }
152
153 function requestPayment(current) {
154 const paymentData = current.paymentData;
155
156 return $.ajax({
157 type: 'POST',
158 url: config.endpoint,
159 data: JSON.stringify({
160 key: config.orderKey || undefined,
161 billing_email: config.billingEmail || undefined,
162 billing_address: config.billingAddress,
163 shipping_address: config.shippingAddress,
164 payment_method: 'vipps',
165 payment_data: paymentData
166 }),
167 contentType: 'application/json',
168 dataType: 'json',
169 headers: config.nonce ? { Nonce: config.nonce } : undefined
170 }).then((response) => {
171 if (attempt !== current) return null;
172 const url = validUrl(urlFromResponse(response));
173 if (!url) {
174 release();
175 showWooNotice(message('missingPaymentUrl', 'The order did not return a payment URL.'));
176 return null;
177 }
178 current.phase = 'payment';
179 current.paymentUrl = url;
180 setBusy(false);
181 if (current.closed) {
182 navigate('');
183 return null;
184 }
185 return url;
186 }).catch((xhr) => {
187 if (attempt !== current) return null;
188 release();
189 showWooNotice(responseMessage(xhr, message('orderPayFailed', 'Could not start payment for this order. Please try again.')));
190 return null;
191 });
192 }
193
194 function submit(event) {
195 if (attempt) return false;
196 if ($form.find('[name="payment_method"]:checked').val() !== 'vipps') return true;
197 event.preventDefault();
198
199 if ($form.find('[name="terms-field"]').length && !$form.find('[name="terms"]:checked').length) {
200 setBusy(false);
201 $form.removeClass('processing').removeAttr('aria-busy');
202 showWooNotice(message('termsRequired', 'Please read and accept the terms and conditions to proceed with your order.'));
203 $form.find('[name="terms"]').first().trigger('focus');
204 return false;
205 }
206
207 const paymentData = $form.serializeArray()
208 .filter(({ name }) => !['payment_method', 'woocommerce-pay-nonce', '_wpnonce'].includes(name))
209 .map(({ name, value }) => ({ key: name, value }));
210 paymentData.push({ key: 'vipps_checkout_widget', value: '1' });
211 attempt = { phase: 'submitting', closed: false, paymentUrl: '', paymentData };
212 const current = attempt;
213 setBusy(true);
214 try {
215 window.ensureVippsWidgetHostStarted();
216 current.trigger = window.vipps.trigger(() => requestPayment(current))
217 .on('success', (close, url) => {
218 current.phase = 'leaving';
219 navigate(url || current.paymentUrl, Boolean(url));
220 close();
221 })
222 .on('cancel', (close, url) => { close(); navigate(url || window.location.href); })
223 .on('close', () => {
224 if (attempt !== current) return;
225 if (current.phase === 'leaving') return;
226 if (current.phase === 'submitting') {
227 current.closed = true;
228 } else {
229 navigate('');
230 }
231 })
232 .on('error', () => {
233 if (current.paymentUrl) navigate(current.paymentUrl);
234 else if (current.phase === 'submitting') current.closed = true;
235 else release();
236 });
237 Promise.resolve(current.trigger.open()).catch(() => {
238 if (attempt === current && !current.paymentUrl) {
239 release();
240 showWooNotice(message('widgetStartFailed', 'Could not start the payment. Please try again.'));
241 }
242 });
243 } catch {
244 release();
245 showWooNotice(message('widgetStartFailed', 'Could not start the payment. Please try again.'));
246 }
247 return false;
248 }
249
250 $form.on('submit.vippsOrderPay', submit);
251 // Returning through browser history restores the covered page and its
252 // disabled controls; reload to reconcile the order's current status.
253 window.addEventListener('pageshow', (event) => {
254 if (event.persisted && attempt?.phase === 'leaving') {
255 window.location.reload();
256 }
257 });
258 }
259
260 const params = window.wc_checkout_params;
261 const $form = $('form.checkout').first();
262 if (!params?.checkout_url || !$form.length || document.querySelector('.wc-block-checkout')) {
263 return;
264 }
265 if ($form.data('vippsClassicCheckout')) {
266 return;
267 }
268 $form.data('vippsClassicCheckout', true);
269 bindVippsButton($form);
270
271 const owner = 'classic-checkout';
272 let activeAttempt = null;
273 let updatingCheckout = false;
274 let handedOff = false;
275
276 /** Keep new messages in the gateway's existing localization dictionary. */
277 function translate(key, fallback) {
278 return window.VippsLocale?.[key] || fallback;
279 }
280
281 /** Only server-provided checkout notices are HTML; local messages are escaped. */
282 function errorMarkup(message) {
283 return $('<div>').addClass('woocommerce-error').text(message).prop('outerHTML');
284 }
285
286 /** Use WooCommerce's notice/focus handling when supplied by its submit hook. */
287 function showError(attempt, messages) {
288 if (typeof attempt.checkout?.submit_error === 'function') {
289 attempt.checkout.submit_error(messages);
290 } else {
291 $form.find('.woocommerce-NoticeGroup-checkout').remove();
292 const $notice = $('<div>')
293 .addClass('woocommerce-NoticeGroup woocommerce-NoticeGroup-checkout')
294 .attr({ role: 'alert', tabindex: '-1' })
295 .html(messages);
296 $form.prepend($notice);
297 $notice.trigger('focus');
298 $(document.body).trigger('checkout_error', [messages]);
299 }
300 }
301
302 /**
303 * Snapshot before disabling fields. Keep them locked through the SDK session
304 * so edits or another payment method cannot submit a competing order/payment.
305 * Remember prior disabled states, including fields replaced by checkout updates.
306 */
307 function lockForm(attempt) {
308 $form.addClass('processing').attr('aria-busy', 'true');
309 $form.find(':input').each(function () {
310 if (!attempt.fields.has(this)) {
311 attempt.fields.set(this, this.disabled);
312 }
313 this.disabled = true;
314 });
315 }
316
317 /** Release the shared overlay independently of the form's submission guard. */
318 function stopSpinner() {
319 window.setVippsPaymentBusy(false, owner);
320 $form.removeAttr('aria-busy');
321 }
322
323 /** Known checkout failures can be retried without discarding form values. */
324 function release(attempt) {
325 if (activeAttempt !== attempt) {
326 return;
327 }
328 activeAttempt = null;
329 stopSpinner();
330 $form.removeClass('processing');
331 attempt.fields.forEach((disabled, field) => {
332 field.disabled = disabled;
333 });
334 // Clear our reference first: close() may synchronously emit another event.
335 attempt.trigger?.close();
336 }
337
338 /**
339 * A timeout/malformed success may hide an already-created payment. Do not
340 * automatically POST again or silently enable another submission. Reloading
341 * lets WooCommerce restore its session/order state before the user retries.
342 */
343 function requireReload(attempt, message) {
344 if (activeAttempt !== attempt) {
345 return;
346 }
347 attempt.phase = 'uncertain';
348 stopSpinner();
349 const link = $('<a>').attr('href', window.location.href)
350 .text(translate('reloadCheckout', 'Reload checkout'));
351 showError(attempt, errorMarkup(message) + link.prop('outerHTML'));
352 // submit_error removes processing; restore the guard for this uncertain result.
353 lockForm(attempt);
354 $form.removeAttr('aria-busy');
355 attempt.trigger?.close();
356 }
357
358 /** Navigate once, retaining locked state until the page has actually left. */
359 function navigate(attempt, url, successful = false) {
360 if (activeAttempt !== attempt || attempt.phase === 'leaving') {
361 return;
362 }
363 attempt.phase = 'leaving';
364 handedOff = true;
365 if (successful && url) {
366 // Closing the widget exposes this page while the completed order loads.
367 window.showVippsSuccessRedirectOverlay('classic-redirect');
368 }
369 stopSpinner();
370 if (url) {
371 window.location.assign(url);
372 } else {
373 window.location.reload();
374 }
375 }
376
377 /** A close during submission waits for the outstanding request, not another POST. */
378 function finishWidget(attempt, close, redirectUrl, successful = false) {
379 if (activeAttempt !== attempt || ['uncertain', 'leaving'].includes(attempt.phase)) {
380 return;
381 }
382 if (attempt.phase === 'submitting') {
383 attempt.closed = true;
384 if (close) {
385 close();
386 }
387 return;
388 }
389 // Set the phase before close(), which may emit a nested close event.
390 navigate(attempt, redirectUrl, successful);
391 if (close) {
392 close();
393 }
394 }
395
396 /**
397 * If the SDK fails after a request was sent, wait for its result. A known URL
398 * can use full-page fallback without creating a second order or payment.
399 */
400 function sdkError(attempt) {
401 if (activeAttempt !== attempt || ['uncertain', 'leaving'].includes(attempt.phase)) {
402 return;
403 }
404 if (attempt.paymentUrl) {
405 navigate(attempt, attempt.paymentUrl);
406 } else if (attempt.requestSent) {
407 attempt.sdkFailed = true;
408 } else {
409 release(attempt);
410 showError(attempt, errorMarkup(translate('widgetStartFailed', 'Could not start the payment. Please try again.')));
411 }
412 }
413
414 /** The response URL is supplied by our gateway; reject unusable URL schemes. */
415 function getPaymentUrl(result) {
416 const value = result.vippsPaymentUrl || result.redirect;
417 if (typeof value !== 'string' || !value.trim()) {
418 return null;
419 }
420 try {
421 const url = new URL(value, window.location.href);
422 return ['https:', 'http:'].includes(url.protocol) ? url.href : null;
423 } catch {
424 return null;
425 }
426 }
427
428 /**
429 * The SDK calls this resolver from open(). WooCommerce still creates/resumes
430 * and validates the order; only the browser handling of the result is replaced.
431 */
432 async function createSession(attempt) {
433 let result;
434 try {
435 attempt.requestSent = true;
436 result = await $.ajax({
437 type: 'POST',
438 url: params.checkout_url,
439 data: attempt.formData,
440 dataType: 'json'
441 });
442 } catch {
443 requireReload(attempt, translate('checkoutResultUnknown',
444 'The checkout result could not be confirmed. Check your order before trying again.'));
445 return null;
446 }
447
448 if (activeAttempt !== attempt) {
449 return null;
450 }
451 if (result?.result === 'failure') {
452 release(attempt);
453 if (result.reload === true) {
454 window.location.reload();
455 return null;
456 }
457 showError(attempt, typeof result.messages === 'string' && result.messages
458 ? result.messages
459 : errorMarkup(translate('checkoutFailed', 'Checkout failed. Please check your details and try again.')));
460 if (result.refresh === true) {
461 $(document.body).trigger('update_checkout');
462 }
463 return null;
464 }
465
466 const paymentUrl = result?.result === 'success' ? getPaymentUrl(result) : null;
467 if (!paymentUrl) {
468 requireReload(attempt, translate('missingPaymentUrl',
469 'Checkout did not return a payment URL. Reload checkout to check your order before trying again.'));
470 return null;
471 }
472
473 attempt.paymentUrl = paymentUrl;
474 attempt.phase = 'payment';
475 handedOff = true;
476 stopSpinner();
477 if (attempt.closed || attempt.sdkFailed) {
478 navigate(attempt, attempt.closed ? null : paymentUrl);
479 return null;
480 }
481 return paymentUrl;
482 }
483
484 /**
485 * This must return false synchronously. Returning a Promise would allow core
486 * checkout.js to also POST and redirect. Do not intercept its success event:
487 * returning false there enters core's generic checkout error path.
488 */
489 function submitVipps(event, checkout) {
490 if (event.result === false) {
491 return false;
492 }
493 if (activeAttempt) {
494 event.stopImmediatePropagation();
495 return false;
496 }
497 if ($form.find('input[name="payment_method"]:checked').val() !== 'vipps' ||
498 !window.vipps?.trigger || !window.vipps?.host ||
499 typeof window.ensureVippsWidgetHostStarted !== 'function' ||
500 typeof window.setVippsPaymentBusy !== 'function') {
501 return;
502 }
503
504 event.stopImmediatePropagation();
505 if (updatingCheckout || (checkout?.xhr && checkout.xhr.readyState !== 4)) {
506 showError({ checkout }, errorMarkup(translate('checkoutUpdating',
507 'Checkout totals are updating. Please wait, then place your order again.')));
508 // Core's submit handler clears its update timer before invoking us.
509 // Restart it so an update that had been queued is not lost.
510 $(document.body).trigger('update_checkout');
511 return false;
512 }
513
514 const fields = $form.serializeArray().filter(({ name }) => name !== 'vipps_checkout_widget');
515 fields.push({ name: 'vipps_checkout_widget', value: '1' });
516 const attempt = {
517 checkout,
518 formData: $.param(fields).replace(/'/g, '%27'),
519 fields: new Map(),
520 phase: 'submitting',
521 requestSent: false,
522 trigger: null
523 };
524 activeAttempt = attempt;
525 lockForm(attempt);
526 $form.find('.woocommerce-NoticeGroup-checkout').remove();
527
528 try {
529 window.setVippsPaymentBusy(true, owner);
530 window.ensureVippsWidgetHostStarted();
531 attempt.trigger = window.vipps.trigger(() => createSession(attempt))
532 .on('success', (close, url) => finishWidget(attempt, close, url, true))
533 .on('cancel', (close, url) => finishWidget(attempt, close, url))
534 .on('close', () => finishWidget(attempt))
535 .on('error', () => sdkError(attempt));
536 Promise.resolve(attempt.trigger.open()).catch(() => sdkError(attempt));
537 } catch {
538 sdkError(attempt);
539 }
540 return false;
541 }
542
543 $form.on('checkout_place_order_vipps.vippsClassic', submitVipps);
544 $(document.body)
545 .on('update_checkout.vippsClassic', () => { updatingCheckout = true; })
546 .on('updated_checkout.vippsClassic', () => {
547 updatingCheckout = false;
548 if (activeAttempt) {
549 lockForm(activeAttempt);
550 if (activeAttempt.phase !== 'submitting') {
551 $form.removeAttr('aria-busy');
552 }
553 }
554 });
555
556 // A full navigation reconstructs the page normally. A bfcache restoration
557 // retains this controller, so reload once to reconcile the pending/paid order.
558 window.addEventListener('pageshow', (event) => {
559 if (event.persisted && (activeAttempt || handedOff)) {
560 window.location.reload();
561 }
562 });
563 });
564