PluginProbe
Pay with Vipps and MobilePay for WooCommerce / 6.3.0
Pay with Vipps and MobilePay for WooCommerce v6.3.0
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 6.0.0 All 189 releases
woo-vipps / payment / js / vipps.js

vipps.js in Pay with Vipps and MobilePay for WooCommerce 6.3.0, at payment/js/vipps.js

1,446 lines 51.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /* Shared loading UI for express checkout and the Checkout Block. */
2 (() => {
3 const busyOwners = new Set();
4
5 /** Reuse the existing overlay markup and CSS, including gateway branding. */
6 function ensureSpinner() {
7 let overlay = document.querySelector(".vippsoverlay");
8 if (overlay) {
9 return overlay;
10 }
11
12 const slug = String(window.VippsConfig?.paymentMethodSlug || "")
13 .toLowerCase()
14 .trim()
15 .replace(/[^a-z0-9_-]+/g, "-")
16 .replace(/^-+|-+$/g, "");
17 const spinner = document.createElement("div");
18 spinner.id = "floatingCirclesG";
19 spinner.className = `vippsspinner ${slug}`.trim();
20
21 for (let i = 1; i <= 8; i += 1) {
22 const circle = document.createElement("div");
23 circle.className = "f_circleG";
24 circle.id = `frotateG_${String(i).padStart(2, "0")}`;
25 spinner.append(circle);
26 }
27
28 overlay = document.createElement("div");
29 overlay.className = "vippsoverlay";
30 overlay.append(spinner);
31 document.body.append(overlay);
32 return overlay;
33 }
34
35 /**
36 * CSS shows the overlay while body.processing is present. Track each flow
37 * separately so clearing one flow cannot hide another flow's loading UI.
38 */
39 window.setVippsPaymentBusy = (busy, owner) => {
40 if (busy) {
41 ensureSpinner();
42 busyOwners.add(owner);
43 } else {
44 busyOwners.delete(owner);
45 }
46 document.body.classList.toggle("processing", busyOwners.size > 0);
47 };
48
49 /** Keep the completed-payment page covered while its return URL loads. */
50 window.showVippsSuccessRedirectOverlay = (owner) => {
51 window.setVippsPaymentBusy(true, owner);
52 const overlay = ensureSpinner();
53 overlay.style.position = "fixed";
54 overlay.style.inset = "0";
55 overlay.style.zIndex = "2147483647";
56 overlay.style.pointerEvents = "auto";
57 overlay.style.background = "rgba(255, 255, 255, 0.88)";
58 overlay.classList.add("vipps-redirecting");
59
60 let status = overlay.querySelector(".vipps-redirect-status");
61 if (!status) {
62 status = document.createElement("p");
63 status.className = "vipps-redirect-status";
64 status.setAttribute("role", "status");
65 status.setAttribute("aria-live", "polite");
66 overlay.insertBefore(status, overlay.querySelector(".vippsspinner"));
67 }
68 status.textContent = window.VippsLocale?.paymentSuccessfulRedirecting ||
69 "Payment successful. Redirecting…";
70 };
71 })();
72 (() => {
73 /**
74 * Share one desktop widget host between express checkout and the Blocks method.
75 * Leave the flag unset when the SDK is unavailable so a later caller can retry.
76 * Starting a host does not suppress WooCommerce's own checkout redirect.
77 */
78 function ensureVippsWidgetHostStarted() {
79 if (window.__vippsWidgetHostStarted) {
80 return;
81 }
82
83 if (!window.vipps?.host) {
84 return;
85 }
86
87 window.vipps.host().start();
88 window.__vippsWidgetHostStarted = true;
89 }
90
91 /**
92 * Keep pointer interaction with the SDK backdrop inside the modal. The SDK
93 * suppresses the final click, but focus normally changes on pointerdown or
94 * mousedown; that would make the merchant window appear to have been revisited.
95 */
96 function preventVippsBackdropFocus(event) {
97 const target = event.target;
98 if (
99 target instanceof HTMLDialogElement &&
100 target.id === "vipps-trigger-dialog" &&
101 target.open
102 ) {
103 event.preventDefault();
104 }
105 }
106
107 document.addEventListener("pointerdown", preventVippsBackdropFocus, true);
108 document.addEventListener("mousedown", preventVippsBackdropFocus, true);
109 document.addEventListener("contextmenu", preventVippsBackdropFocus, true);
110
111 window.ensureVippsWidgetHostStarted = ensureVippsWidgetHostStarted;
112 })();
113 (() => {
114 /** Build the reusable WooCommerce confirmation dialog before any checkout starts. */
115 function createVippsMobilepayDialog() {
116 const actionDialog = document.createElement("dialog");
117 const actionForm = document.createElement("form");
118 const actionHtml = document.createElement("div");
119 const actionMessage = document.createElement("div");
120 const dialogActions = document.createElement("div");
121 const actionCancel = document.createElement("button");
122 const actionConfirm = document.createElement("vipps-mobilepay-button");
123
124 actionDialog.className = "vipps-mobilepay-dialog";
125 actionDialog.id = "action-required-dialog";
126 actionForm.id = "vippsdata";
127 actionForm.className = "woocommerce-checkout";
128 actionForm.method = "dialog";
129 actionHtml.id = "action-required-html";
130 actionMessage.id = "action-required-message";
131 actionMessage.className = "vipps-form-message";
132 actionMessage.setAttribute("role", "alert");
133 actionMessage.setAttribute("aria-live", "polite");
134 actionMessage.hidden = true;
135 dialogActions.className = "dialog-actions";
136
137 actionCancel.id = "action-required-cancel";
138 actionCancel.type = "button";
139 actionCancel.className = "dialog-close";
140 actionCancel.setAttribute("aria-label", window.VippsLocale?.cancel || "Cancel");
141 actionCancel.title = window.VippsLocale?.cancel || "Cancel";
142 actionCancel.textContent = "×";
143
144 actionConfirm.id = "action-required-confirm";
145 actionConfirm.setAttribute("brand", "vipps");
146 actionConfirm.setAttribute("language", "no");
147 actionConfirm.setAttribute("verb", "continue");
148 actionConfirm.setAttribute("variant", "primary");
149 actionConfirm.setAttribute("type", "button");
150 actionConfirm.setAttribute("branded", "true");
151 actionConfirm.setAttribute("rounded", "true");
152
153 dialogActions.append(actionConfirm);
154 actionForm.append(actionHtml, actionMessage, dialogActions);
155 actionDialog.append(actionCancel, actionForm);
156 document.body.append(actionDialog);
157
158 return {
159 dialog: actionDialog,
160 form: actionForm,
161 html: actionHtml,
162 message: actionMessage,
163 cancel: actionCancel,
164 confirm: actionConfirm
165 };
166 }
167
168 window.createVippsMobilepayDialog = createVippsMobilepayDialog;
169 })();
170 /*
171 * Keeps payment-handoff detection independent from the checkout implementation.
172 * The factory is exposed only because this page is currently using classic
173 * script tags rather than JavaScript modules.
174 */
175 (() => {
176 /** Track a payment handoff so a page revisited after an app switch is refreshed. */
177 function createVippsPaymentHandoff(options = {}) {
178 const storageKey = options.storageKey || "vippsPaymentHandoff";
179 const maxAgeMs = options.maxAgeMs || 30 * 60 * 1000;
180 const disabledSelector = options.disabledSelector ||
181 "[data-checkout-button], [data-cart-button]";
182 const disabledButtonStates = new Map();
183 let leftForegroundDuringHandoff = false;
184
185 /** Read only a valid, recent marker; stale or malformed state is discarded. */
186 function get() {
187 try {
188 const raw = sessionStorage.getItem(storageKey);
189 if (!raw) {
190 return null;
191 }
192
193 const handoff = JSON.parse(raw);
194
195 if (!handoff.createdAt || Date.now() - handoff.createdAt >= maxAgeMs) {
196 clear();
197 return null;
198 }
199
200 return handoff;
201 } catch {
202 clear();
203 return null;
204 }
205 }
206
207 /** Record that an order has a payment URL and may outlive this page view. */
208 function mark(result = {}) {
209 leftForegroundDuringHandoff = false;
210 try {
211 sessionStorage.setItem(storageKey, JSON.stringify({
212 createdAt: Date.now(),
213 path: location.pathname,
214 paymentReference: result.paymentReference || result.reference || null,
215 refreshedPaths: []
216 }));
217 } catch {
218 // Storage is optional; the payment URL must still reach the SDK.
219 }
220 }
221
222 /** Remove the marker when the local attempt ends, including after cancellation. */
223 function clear() {
224 leftForegroundDuringHandoff = false;
225 try {
226 sessionStorage.removeItem(storageKey);
227 } catch {
228 // There is no persisted handoff to clear when storage is blocked.
229 }
230 }
231
232 /** Prevent competing cart or checkout actions while payment is active. */
233 function activate() {
234 if (document.documentElement.classList.contains("payment-handoff-active")) {
235 return;
236 }
237
238 document.documentElement.classList.add("payment-handoff-active");
239 document.querySelectorAll(disabledSelector).forEach((button) => {
240 disabledButtonStates.set(button, {
241 disabled: "disabled" in button ? button.disabled : undefined,
242 disabledAttribute: button.getAttribute("disabled"),
243 ariaDisabled: button.getAttribute("aria-disabled")
244 });
245
246 if ("disabled" in button) {
247 button.disabled = true;
248 }
249
250 button.setAttribute("disabled", "");
251 button.setAttribute("aria-disabled", "true");
252 });
253 }
254
255 /** Restore exactly the button states that existed before the handoff. */
256 function deactivate() {
257 if (!document.documentElement.classList.contains("payment-handoff-active")) {
258 return;
259 }
260
261 document.documentElement.classList.remove("payment-handoff-active");
262 disabledButtonStates.forEach((state, button) => {
263 if (state.disabled !== undefined) {
264 button.disabled = state.disabled;
265 }
266
267 if (state.disabledAttribute === null) {
268 button.removeAttribute("disabled");
269 } else {
270 button.setAttribute("disabled", state.disabledAttribute);
271 }
272
273 if (state.ariaDisabled === null) {
274 button.removeAttribute("aria-disabled");
275 } else {
276 button.setAttribute("aria-disabled", state.ariaDisabled);
277 }
278 });
279 disabledButtonStates.clear();
280 }
281
282 /** Refresh a revisited page once per path to obtain current order state. */
283 function reloadOnceForStalePage() {
284 const handoff = get();
285
286 if (!handoff) {
287 return;
288 }
289
290 const refreshedPaths = Array.isArray(handoff.refreshedPaths)
291 ? handoff.refreshedPaths
292 : [];
293
294 if (refreshedPaths.includes(location.pathname)) {
295 return;
296 }
297
298 try {
299 sessionStorage.setItem(storageKey, JSON.stringify({
300 ...handoff,
301 refreshedPaths: [...refreshedPaths, location.pathname]
302 }));
303 } catch {
304 // Reload only when the marker can prevent a reload loop.
305 return;
306 }
307
308 location.reload();
309 }
310
311 /** Identify back navigation and bfcache restores that can show stale HTML. */
312 function wasHistoryRestore(event) {
313 const navigation = performance.getEntriesByType("navigation")[0];
314 return event.persisted || navigation?.type === "back_forward";
315 }
316
317 /** Refresh restored pages; discard a marker on an ordinary fresh load. */
318 function handlePageshow(event) {
319 const handoff = get();
320
321 if (!handoff) {
322 return;
323 }
324
325 if (wasHistoryRestore(event)) {
326 reloadOnceForStalePage();
327 return;
328 }
329
330 clear();
331 }
332
333 /** Record a real page/app departure; focus changes inside a modal do not qualify. */
334 function markForegroundDeparture() {
335 if (get()) {
336 leftForegroundDuringHandoff = true;
337 }
338 }
339
340 /** Refresh only after this document actually left the foreground. */
341 function handleForegroundReturn() {
342 if (leftForegroundDuringHandoff) {
343 reloadOnceForStalePage();
344 }
345 }
346
347 window.addEventListener("pageshow", handlePageshow);
348 window.addEventListener("pagehide", markForegroundDeparture);
349 window.addEventListener("focus", handleForegroundReturn);
350 document.addEventListener("visibilitychange", () => {
351 if (document.visibilityState === "hidden") {
352 markForegroundDeparture();
353 } else if (document.visibilityState === "visible") {
354 handleForegroundReturn();
355 }
356 });
357
358 return {
359 mark,
360 get,
361 clear,
362 activate,
363 deactivate,
364 reloadOnceForStalePage
365 };
366 }
367
368 window.createVippsPaymentHandoff = createVippsPaymentHandoff;
369 })();
370 /*
371 * This implements express checkout for WooCommerce using the Vipps Mobilepay Widget SDK
372 */
373 (() => {
374 const vippsSdk = window.vipps;
375 const config = window.VippsConfig || {};
376 const locale = window.VippsLocale || {};
377 const body = document.body;
378 const paymentHandoff = typeof window.createVippsPaymentHandoff === "function"
379 ? window.createVippsPaymentHandoff()
380 : null;
381
382 if (!vippsSdk) {
383 console.error("vipps: Widget SDK is not available");
384 return;
385 }
386
387 if (typeof window.createVippsMobilepayDialog !== "function") {
388 console.error("vipps: Dialog module is not available");
389 return;
390 }
391
392 const dialogUi = window.createVippsMobilepayDialog();
393 const checkout = createCheckoutController();
394
395 /** Prefix diagnostic warnings so checkout failures are easy to find. */
396 function warnVippsExpress(message, data) {
397 if (data === undefined) {
398 console.warn("[Vipps Express]", message);
399 return;
400 }
401
402 console.warn("[Vipps Express]", message, data);
403 }
404
405 /** Prefix diagnostic errors while retaining optional response details. */
406 function errorVippsExpress(message, data) {
407 if (data === undefined) {
408 console.error("[Vipps Express]", message);
409 return;
410 }
411
412 console.error("[Vipps Express]", message, data);
413 }
414
415 /** Resolve localized text and substitute the plugin's positional values. */
416 function translate(key, fallback, ...values) {
417 let message = locale[key] || fallback;
418
419 values.forEach((value) => {
420 message = message.replace("%s", String(value));
421 });
422
423 return message;
424 }
425
426 /** Notify the automatic-purchase page about an attempt's UI state. */
427 function emitVippsPurchaseEvent(name, detail = {}) {
428 document.dispatchEvent(new CustomEvent(name, { detail }));
429 }
430
431 // Hook vippsInit to woocommerce/product-collection render event. LP 29.11.2024
432 // IOK 2026-01-14 available from woo 9.4 - so the buy now block will not be available until that version
433 body.addEventListener("wc-blocks_product_list_rendered", () => {
434 body.dispatchEvent(new Event("vippsInit"));
435 });
436
437 // Allow other code to trigger and observe this lifecycle event through
438 // either the native or jQuery event API.
439 body.addEventListener("vippsInit", handleVippsInit, true);
440 if (window.jQuery) {
441 window.jQuery(body).on("vippsInit", handleVippsInit);
442 }
443 document.addEventListener("click", handlePurchaseClick);
444
445 bindVariationEvents();
446 subscribeToCartChanges();
447 body.dispatchEvent(new Event("vippsInit"));
448
449 /** Run initialization once when both native and jQuery listeners see an event. */
450 function handleVippsInit(event) {
451 const eventObject = event?.originalEvent || event;
452
453 if (eventObject.__vippsInitHandled) {
454 return;
455 }
456
457 eventObject.__vippsInitHandled = true;
458 vippsInit();
459 }
460
461 /** Mark current buttons ready and notify integrations registered with WP hooks. */
462 function vippsInit() {
463 document.querySelectorAll(
464 ".button.single-product.vipps-buy-now, .vipps-express-checkout"
465 ).forEach((wrapper) => {
466 wrapper.classList.add("initialized");
467 });
468
469 wp.hooks.doAction("vippsInit");
470 }
471
472 /** Own one express attempt from order creation through SDK handoff or retry. */
473 function createCheckoutController() {
474 let locked = false;
475 let currentAttempt = null;
476 let nextAttemptId = 0;
477 let dialogBusy = false;
478 let dialogConfirmed = false;
479 let paymentSucceeded = false;
480
481 if (typeof window.ensureVippsWidgetHostStarted === "function") {
482 window.ensureVippsWidgetHostStarted();
483 } else {
484 vippsSdk.host().start();
485 }
486
487 // Resolve a fresh URL for each SDK opening, including a retry after
488 // the confirmation form adds fields to the same transaction.
489 const trigger = vippsSdk.trigger(async () => {
490 if (!currentAttempt) {
491 warnVippsExpress("Trigger resolver has no active attempt; returning null.");
492 return null;
493 }
494
495 const attemptId = currentAttempt.id;
496 let result;
497
498 // The attempt is locked while WooCommerce creates the order and
499 // requests a payment session; no payment URL exists yet.
500 result = await createPaymentSession(
501 currentAttempt.transaction,
502 currentAttempt.path
503 );
504
505 if (!currentAttempt || currentAttempt.id !== attemptId) {
506 warnVippsExpress("Ignoring stale express response.", {
507 attemptId,
508 currentAttempt
509 });
510 return null;
511 }
512
513 currentAttempt.lastResponse = result;
514
515 if (Number(result.ok) === 1) {
516 if (!result.url) {
517 // Order creation reported success, but payment cannot start.
518 errorVippsExpress("Successful express checkout response has no payment URL.", {
519 result,
520 currentAttempt
521 });
522 const button = currentAttempt.button;
523 const message = translate(
524 "missingPaymentUrl",
525 "Successful checkout response has no payment URL"
526 );
527 clearAttempt();
528 showError(message, button);
529 throw new Error(message);
530 }
531
532 // The order has a payment URL, but payment is still in progress.
533 // Keep the attempt locked and the purchase button hidden beneath
534 // the desktop modal (or while the customer switches apps).
535 paymentHandoff?.mark(result);
536 // The SDK is taking over, so the order creation spinner can stop.
537 // Do not emit vippsPurchaseFinished at this handoff.
538 setPurchaseButtonsBusy(false);
539 paymentHandoff?.activate();
540 return result.url;
541 }
542
543 if (Number(result.ok) === 2) {
544 // WooCommerce needs more input before it can return a payment URL.
545 // Keep this attempt so confirmation can retry the request.
546 showActionRequiredDialog(result.html || "");
547 return null;
548 }
549
550 if (Number(result.ok) === 0) {
551 errorVippsExpress("Express checkout endpoint returned failure.", {
552 result
553 });
554 const button = currentAttempt.button;
555 clearAttempt();
556 showError(
557 result.msg || translate("expressCheckoutFailed", "Express checkout failed"),
558 button
559 );
560
561 if (result.url) {
562 window.location.assign(result.url);
563 }
564
565 throw new Error(
566 result.msg || translate("expressCheckoutFailed", "Express checkout failed")
567 );
568 }
569
570 const button = currentAttempt.button;
571 const message = translate(
572 "unexpectedCheckoutResponse",
573 "Unexpected express checkout response"
574 );
575 errorVippsExpress("Unexpected express checkout response.", {
576 result
577 });
578 clearAttempt();
579 showError(message, button);
580 throw new Error(message);
581 });
582
583 trigger
584 .on("success", (close, redirectUrl) => {
585 // Payment succeeded. Keep the purchase hidden while the SDK
586 // closes its modal and the return URL takes over.
587 paymentSucceeded = true;
588 if (redirectUrl) {
589 window.showVippsSuccessRedirectOverlay("express-redirect");
590 }
591 close();
592
593 if (redirectUrl) {
594 window.location.assign(redirectUrl);
595 }
596 })
597 .on("cancel", (close, redirectUrl) => {
598 // Payment was canceled; the local attempt can end and retry can
599 // become available if the customer stays on this page.
600 clearAttempt();
601 close();
602
603 if (redirectUrl) {
604 window.location.assign(redirectUrl);
605 }
606 })
607 .on("close", () => {
608 // A close without success ends the local attempt. Closing after
609 // success must not reveal the purchase button again.
610 if (!paymentSucceeded) {
611 clearAttempt();
612 }
613 })
614 .on("error", (error) => {
615 errorVippsExpress("Widget SDK error event.", error);
616 if (
617 error instanceof vippsSdk.InvalidTriggerUrlError &&
618 error.url === null
619 ) {
620 return;
621 }
622
623 const button = currentAttempt?.button;
624 if (button) {
625 // The SDK could not complete the handoff. End the attempt
626 // and show the error so the customer can retry.
627 clearAttempt();
628 showError(
629 error.message || translate("vippsCheckoutFailed", "Vipps checkout failed"),
630 button
631 );
632 }
633 console.error(error);
634 });
635
636 // A history restore can bring back this document with its redirect
637 // overlay intact, even when sessionStorage is unavailable.
638 window.addEventListener("pageshow", (event) => {
639 if (event.persisted && paymentSucceeded && !paymentHandoff?.get()) {
640 window.location.reload();
641 }
642 });
643
644 /** Lock a new attempt and show loading state before requesting an order. */
645 function begin(transaction, button, event, path) {
646 if (locked) {
647 warnVippsExpress("Attempt already locked; refusing new attempt.", {
648 transaction,
649 button,
650 path
651 });
652 return false;
653 }
654
655 // A new attempt starts before the order request; prevent duplicate
656 // clicks through the payment handoff until this attempt ends.
657 locked = true;
658 paymentSucceeded = false;
659 currentAttempt = {
660 id: ++nextAttemptId,
661 transaction,
662 path,
663 button,
664 event,
665 lastResponse: null
666 };
667 setPurchaseButtonsBusy(true);
668 body.dataset.vippsPurchaseActive = "true";
669 emitVippsPurchaseEvent("vippsPurchaseStarted", {
670 wrapper: button
671 });
672 return true;
673 }
674
675 /** Carry confirmed form fields into the next order request. */
676 function addPostData(extraPostData) {
677 if (!currentAttempt) {
678 throw new Error("No active checkout attempt");
679 }
680
681 currentAttempt.transaction.post = {
682 ...(currentAttempt.transaction.post || {}),
683 ...extraPostData
684 };
685 }
686
687 /** Let the SDK request a URL and open payment, handling launch failures. */
688 async function start() {
689 if (!currentAttempt) {
690 throw new Error("No active checkout attempt");
691 }
692
693 try {
694 await trigger.open();
695 } catch (error) {
696 errorVippsExpress("Trigger open threw.", error);
697 if (
698 error instanceof vippsSdk.InvalidTriggerUrlError &&
699 error.url === null
700 ) {
701 return;
702 }
703
704 const button = currentAttempt?.button;
705 if (button) {
706 clearAttempt();
707 showError(
708 error.message || translate("vippsCheckoutFailed", "Vipps checkout failed"),
709 button
710 );
711 }
712 console.error(error);
713 }
714 }
715
716 /** Release local state and announce that the page can offer a retry. */
717 function clearAttempt() {
718 const attempt = currentAttempt;
719 locked = false;
720 currentAttempt = null;
721 dialogBusy = false;
722 paymentHandoff?.clear();
723 paymentHandoff?.deactivate();
724 setPurchaseButtonsBusy(false);
725 dialogUi.confirm.disabled = false;
726 dialogUi.confirm.removeAttribute("aria-disabled");
727
728 if (attempt) {
729 delete body.dataset.vippsPurchaseActive;
730 emitVippsPurchaseEvent("vippsPurchaseFinished", {
731 wrapper: attempt.button
732 });
733 }
734 }
735
736 /** Present server-requested fields while keeping the order attempt active. */
737 function showActionRequiredDialog(html) {
738 dialogUi.html.innerHTML = html;
739 clearDialogValidation();
740 dialogUi.confirm.disabled = false;
741 dialogUi.confirm.removeAttribute("aria-disabled");
742 dialogBusy = false;
743 dialogConfirmed = false;
744 dialogUi.dialog.showModal();
745 }
746
747 /** Close the form; its close listener performs common cancellation cleanup. */
748 function cancelActionRequired() {
749 if (dialogUi.dialog.open) {
750 dialogUi.dialog.close();
751 }
752 }
753
754 dialogUi.cancel.addEventListener("click", cancelActionRequired);
755 dialogUi.dialog.addEventListener("close", () => {
756 // Escape, the cancel button, and programmatic closes all arrive here.
757 // Only confirmation continues the current order request.
758 if (!dialogConfirmed) {
759 clearAttempt();
760 }
761 });
762 dialogUi.confirm.addEventListener("click", async () => {
763 if (dialogBusy || !currentAttempt) {
764 return;
765 }
766
767 if (!validateConfirmationForm()) {
768 return;
769 }
770
771 dialogBusy = true;
772 dialogUi.confirm.disabled = true;
773 dialogUi.confirm.setAttribute("aria-disabled", "true");
774
775 addPostData({
776 ...serializeForm(dialogUi.form),
777 confirmed: 1
778 });
779
780 dialogConfirmed = true;
781 dialogUi.dialog.close();
782 await start();
783 });
784
785 dialogUi.form.addEventListener("input", clearDialogValidation);
786 dialogUi.form.addEventListener("change", clearDialogValidation);
787
788 return { begin, addPostData, start };
789
790 /** Combine browser, terms, and integration validation before resubmitting. */
791 function validateConfirmationForm() {
792 clearDialogValidation();
793
794 let validation = validateTermsAndConditions(dialogUi.form);
795
796 if (!dialogUi.form.checkValidity()) {
797 dialogUi.form.reportValidity();
798 validation = false;
799 setDialogValidationMessage(translate(
800 "correctHighlightedFields",
801 "Please correct the highlighted fields."
802 ));
803 }
804
805 validation = wp.hooks.applyFilters(
806 "vippsValidateExpressCheckoutForm",
807 validation,
808 dialogUi.form,
809 currentAttempt
810 );
811
812 if (!validation && !dialogUi.message.textContent) {
813 setDialogValidationMessage(translate(
814 "checkFormBeforeContinuing",
815 "Please check the form before continuing."
816 ));
817 }
818
819 if (validation) {
820 clearDialogValidation();
821 }
822
823 return Boolean(validation);
824 }
825
826 /** Require acceptance when WooCommerce included a terms checkbox. */
827 function validateTermsAndConditions(form) {
828 const termsBoxes = Array.from(
829 form.querySelectorAll('.input-checkbox[name="terms"], input[name="terms"]')
830 );
831
832 if (termsBoxes.length === 0) {
833 return true;
834 }
835
836 const accepted = termsBoxes.some((termsBox) => termsBox.checked);
837 termsBoxes.forEach((termsBox) => {
838 const container = termsBox.closest(".validate-required");
839
840 if (container) {
841 container.classList.toggle("woocommerce-invalid", !accepted);
842 container.classList.toggle(
843 "woocommerce-invalid-required-field",
844 !accepted
845 );
846 }
847 });
848
849 if (!accepted) {
850 setDialogValidationMessage(translate(
851 "termsAndConditionsError",
852 "Please accept the terms and conditions."
853 ));
854 }
855
856 return accepted;
857 }
858
859 /** Remove previous validation feedback before another confirmation attempt. */
860 function clearDialogValidation() {
861 dialogUi.message.textContent = "";
862 dialogUi.message.hidden = true;
863
864 dialogUi.form
865 .querySelectorAll(".woocommerce-invalid, .woocommerce-invalid-required-field")
866 .forEach((element) => {
867 element.classList.remove(
868 "woocommerce-invalid",
869 "woocommerce-invalid-required-field"
870 );
871 });
872 }
873
874 /** Show a single accessible explanation for invalid confirmation input. */
875 function setDialogValidationMessage(message) {
876 dialogUi.message.textContent = message;
877 dialogUi.message.hidden = false;
878 }
879 }
880
881 /** Send the normalized express transaction to the plugin's REST endpoint. */
882 async function createPaymentSession(transaction, path) {
883 const result = await wp.apiFetch({
884 path,
885 method: "POST",
886 headers: {
887 "Accept-Language": `${config.vippslocale || "nb_NO"}, *`
888 },
889 data: transaction
890 });
891
892 return result;
893 }
894
895 // Using the Vipps SDK, get a payment URL from the new REST endpoint and let
896 // the SDK start the express checkout session. IOK 2026-09-04
897 /** Route clicks from supported express buttons to the correct order flow. */
898 async function handlePurchaseClick(event) {
899 const wrapper = event.target.closest?.(
900 ".button.single-product.vipps-buy-now.initialized, " +
901 ".vipps-express-checkout:not(body)"
902 );
903
904 if (!wrapper || !wrapper.querySelector("vipps-mobilepay-button")) {
905 return;
906 }
907
908 event.preventDefault();
909
910 if (body.classList.contains("processing")) {
911 return;
912 }
913
914 if (wrapper.classList.contains("vipps-express-checkout")) {
915 await handleCartPurchase(wrapper, event);
916 return;
917 }
918
919 await handleSingleProductPurchase(wrapper, event);
920 }
921
922 /** Start checkout for the current cart after legacy extension hooks run. */
923 async function handleCartPurchase(wrapper, event) {
924 if (wrapper.classList.contains("disabled") || wrapper.hasAttribute("disabled")) {
925 showError(translate(
926 "cartCheckoutUnavailable",
927 "Cannot start express checkout: cart checkout is unavailable"
928 ), wrapper);
929 return;
930 }
931
932 removeErrorMessages();
933
934 wp.hooks.doAction("vippsBuyCart", wrapper, event);
935
936 const transaction = wp.hooks.applyFilters(
937 "vippsBuyCartData",
938 transactionFromPostData({}),
939 wrapper,
940 event
941 );
942
943 // Cart express checkout gets products from the WooCommerce cart session,
944 // but still sends cookies and post metadata. IOK 2026-09-04
945 const path = wrapper.dataset.sec
946 ? `/woo-vipps/v1/express_checkout?sec=${encodeURIComponent(wrapper.dataset.sec)}`
947 : "/woo-vipps/v1/express_checkout";
948
949 if (!checkout.begin(transaction, wrapper, event, path)) {
950 return;
951 }
952
953 await checkout.start();
954 }
955
956 /** Build a one-product order, preserving compatibility hooks and fallbacks. */
957 async function handleSingleProductPurchase(wrapper, event) {
958 if (wrapper.classList.contains("disabled") || wrapper.hasAttribute("disabled")) {
959 showVariationMessage(wrapper);
960 return;
961 }
962
963 removeErrorMessages();
964
965 wp.hooks.doAction("vippsBuySingleProduct", wrapper, event);
966
967 let compatMode = wrapper.classList.contains("compat-mode");
968 compatMode = wp.hooks.applyFilters(
969 "vippsBuySingleProductCompatMode",
970 compatMode,
971 wrapper,
972 event
973 );
974
975 if (compatMode) {
976 runCompatibilityAction(wrapper, event);
977 return;
978 }
979
980 // First build the legacy data structure so existing hooks can still see
981 // the same input as before. IOK 2026-09-04
982 let transaction = buildTransaction(wrapper);
983 if (!transaction) {
984 return;
985 }
986
987 const legacyData = transaction.legacyData;
988 // Finally, create a hook for even weirder themes.
989 transaction = transactionFromLegacyData(wp.hooks.applyFilters(
990 "vippsBuySingleProductData",
991 legacyData,
992 wrapper,
993 event
994 ));
995
996 // If filters and fallbacks could not identify the product, do not start
997 // an empty transaction against the REST endpoint. IOK 2026-09-04
998 if (!hasProductIdentifier(transaction)) {
999 showError(translate(
1000 "productIdentifiersMissing",
1001 "Cannot buy product: product id, variation id and sku are missing"
1002 ), wrapper);
1003 return;
1004 }
1005
1006 if (!checkout.begin(transaction, wrapper, event, "/woo-vipps/v1/express_checkout_single")) {
1007 return;
1008 }
1009
1010 await checkout.start();
1011 }
1012
1013 /** Reject one-product requests that cannot identify a WooCommerce product. */
1014 function hasProductIdentifier(transaction) {
1015 return Boolean(
1016 transaction?.product_id ||
1017 transaction?.variation_id ||
1018 transaction?.sku
1019 );
1020 }
1021
1022 /** Gather product and form data in the shape expected by older filters. */
1023 function buildTransaction(wrapper) {
1024 // Older buttons may carry product data directly on the button. Preserve
1025 // both jQuery-style underscore attributes and modern dataset names.
1026 // IOK 2026-09-04
1027 const legacyData = getElementData(wrapper);
1028
1029 if (legacyData.product_id || legacyData.product_sku) {
1030 return { legacyData };
1031 }
1032
1033 const form = wrapper.closest("form.cart");
1034 if (!form) {
1035 showError(translate(
1036 "productFormNotFound",
1037 "Cannot buy product: product form not found"
1038 ), wrapper);
1039 return null;
1040 }
1041
1042 // Initialize with the entire content of the form if we have it.
1043 for (const [name, value] of new FormData(form).entries()) {
1044 if (name !== "add-to-cart") {
1045 legacyData[name] = value;
1046 }
1047 }
1048
1049 const productDataButton = form.querySelector("button[data-product_id], button[data-product-id]");
1050 legacyData.product_id = legacyData.product_id ||
1051 form.querySelector('[name="product_id"]')?.value ||
1052 form.querySelector('[name="add-to-cart"]')?.value ||
1053 getElementData(productDataButton).product_id || "";
1054 legacyData.variation_id = legacyData.variation_id ||
1055 form.querySelector('[name="variation_id"]')?.value || "";
1056 legacyData.quantity = legacyData.quantity || "1";
1057 legacyData.product_sku = legacyData.product_sku ||
1058 legacyData.sku ||
1059 form.querySelector('[name="sku"]')?.value ||
1060 form.closest(".product")?.querySelector(".sku")?.textContent.trim() || "";
1061 delete legacyData.sku;
1062
1063 return { legacyData };
1064 }
1065
1066 /** Normalize HTML data attributes from current and older button markup. */
1067 function getElementData(element) {
1068 if (!element) {
1069 return {};
1070 }
1071
1072 const data = {};
1073 Array.from(element.attributes || []).forEach((attribute) => {
1074 if (!attribute.name.startsWith("data-")) {
1075 return;
1076 }
1077
1078 const name = attribute.name.slice(5).replace(/-/g, "_");
1079 data[name] = attribute.value;
1080 });
1081
1082 return {
1083 ...data,
1084 product_id: data.product_id || element.dataset?.productId || "",
1085 variation_id: data.variation_id || element.dataset?.variationId || "",
1086 product_sku: data.product_sku || element.dataset?.productSku || "",
1087 quantity: data.quantity || element.dataset?.quantity || ""
1088 };
1089 }
1090
1091 /** Move filtered legacy fields into the REST endpoint's transaction shape. */
1092 function transactionFromLegacyData(data) {
1093 const source = data || {};
1094 const post = { ...(source.post || {}) };
1095 const mainFields = new Set([
1096 "product_id",
1097 "variation_id",
1098 "product_sku",
1099 "sku",
1100 "quantity",
1101 "post",
1102 "action",
1103 "add-to-cart"
1104 ]);
1105
1106 Object.entries(source).forEach(([name, value]) => {
1107 if (!mainFields.has(name)) {
1108 post[name] = value;
1109 }
1110 });
1111
1112 // The REST endpoint expects the product identifiers at top level, while
1113 // the rest of the serialized WooCommerce form data travels as post data.
1114 // IOK 2026-09-04
1115 return {
1116 product_id: source.product_id || "",
1117 variation_id: source.variation_id || "",
1118 sku: source.sku || source.product_sku || "",
1119 quantity: source.quantity || "1",
1120 ...transactionFromPostData(post)
1121 };
1122 }
1123
1124 /** Attach browser context shared by cart and single-product requests. */
1125 function transactionFromPostData(post) {
1126 return {
1127 cookies: getCookies(),
1128 post: addOrderAttributionData(post)
1129 };
1130 }
1131
1132 /** Pass eligible browser cookies to the express endpoint for session context. */
1133 function getCookies() {
1134 const cookies = {};
1135
1136 document.cookie.split(";").forEach((cookie) => {
1137 const separator = cookie.indexOf("=");
1138 const name = separator >= 0 ? cookie.slice(0, separator).trim() : cookie.trim();
1139
1140 if (!name || name === "wordpress_test_cookie" || name.startsWith("wp-settings")) {
1141 return;
1142 }
1143
1144 const value = separator >= 0 ? cookie.slice(separator + 1) : "";
1145 cookies[decodeCookieValue(name)] = decodeCookieValue(value);
1146 });
1147
1148 return cookies;
1149 }
1150
1151 /** Decode a cookie component without failing on malformed percent escapes. */
1152 function decodeCookieValue(value) {
1153 try {
1154 return decodeURIComponent(value);
1155 } catch {
1156 return value;
1157 }
1158 }
1159
1160 /** Include WooCommerce attribution fields when the merchant enabled them. */
1161 function addOrderAttributionData(post) {
1162 // order attribution can be turned on or off in the settings because of some sites having issues with it in the past. IOK 2026-09-10
1163 if (config.expressOrderAttribution != "yes") {
1164 return post;
1165 }
1166 const attribution = window.wc_order_attribution;
1167
1168 if (
1169 !attribution?.params ||
1170 !attribution?.fields ||
1171 typeof attribution.getAttributionData !== "function"
1172 ) {
1173 return post;
1174 }
1175
1176 const data = attribution.getAttributionData();
1177 const prefix = attribution.params.prefix || "";
1178
1179 // WooCommerce form checkout posts these as prefixed fields, so keep the
1180 // same shape for backwards compatibility. IOK 2026-09-04
1181 Object.keys(attribution.fields).forEach((key) => {
1182 const postKey = `${prefix}${key}`;
1183 if (!(postKey in post)) {
1184 post[postKey] = data?.[key] ?? "";
1185 }
1186 });
1187
1188 return post;
1189 }
1190
1191 /** Delegate buying to the normal add-to-cart button for compatible plugins. */
1192 function runCompatibilityAction(wrapper, event) {
1193 const form = wrapper.closest("form");
1194 const addToCartButton = form?.querySelector(".single_add_to_cart_button");
1195
1196 if (!form || !addToCartButton) {
1197 showError(translate(
1198 "productFormNotFound",
1199 "Cannot add product: product form not found"
1200 ), wrapper);
1201 return;
1202 }
1203
1204 // In compatibility mode, we delegate to the existing buy now button
1205 // instead of doing the logic ourselves. This allows more filters and
1206 // actions in existing plugins to run. IOK 2019-02-26
1207 let action = () => {
1208 let input = form.querySelector("input[name='vipps_compat_mode']");
1209 if (!input) {
1210 input = document.createElement("input");
1211 input.type = "hidden";
1212 input.name = "vipps_compat_mode";
1213 form.prepend(input);
1214 }
1215 input.value = "1";
1216 addToCartButton.click();
1217 };
1218
1219 action = wp.hooks.applyFilters(
1220 "vippsBuySingleProductCompatModeAction",
1221 action,
1222 wrapper,
1223 event
1224 );
1225
1226 setPurchaseButtonsBusy(true);
1227 action();
1228 }
1229
1230 /** Convert confirmation fields to post data, excluding add-to-cart controls. */
1231 function serializeForm(form) {
1232 const data = {};
1233
1234 for (const [name, value] of new FormData(form).entries()) {
1235 if (name !== "add-to-cart") {
1236 data[name] = value;
1237 }
1238 }
1239
1240 return data;
1241 }
1242
1243 /** Synchronize button loading state with the shared page overlay. */
1244 function setPurchaseButtonsBusy(busy) {
1245 document.querySelectorAll(".vipps-buy-now, .vipps-express-checkout").forEach((wrapper) => {
1246 wrapper.classList.toggle("loading", busy);
1247
1248 if (busy) {
1249 wrapper.dataset.vippsBusy = "true";
1250 wrapper.setAttribute("disabled", "");
1251 wrapper.setAttribute("inactive", "inactive");
1252 wrapper.setAttribute("aria-disabled", "true");
1253 } else {
1254 delete wrapper.dataset.vippsBusy;
1255 wrapper.removeAttribute("disabled");
1256 wrapper.removeAttribute("inactive");
1257 wrapper.removeAttribute("aria-disabled");
1258 wrapper.classList.remove("loading");
1259
1260 if (wrapper.classList.contains("disabled")) {
1261 wrapper.setAttribute("disabled", "");
1262 }
1263 }
1264 });
1265
1266 window.setVippsPaymentBusy(busy, "express");
1267 }
1268
1269 /** Follow WooCommerce variation and bundle availability on product pages. */
1270 function bindVariationEvents() {
1271 const $ = window.jQuery;
1272 if (!$) return;
1273
1274 $(body).on("found_variation", (event, variation) => {
1275 const form = event.target.closest?.("form.cart");
1276 const wrapper = form?.querySelector(".vipps-buy-now");
1277 if (!wrapper) return;
1278
1279 wrapper.classList.add("variation-found");
1280 setPurchaseButtonDisabled(wrapper, !variation?.is_purchasable ||
1281 !variation?.is_in_stock || !variation?.variation_is_visible);
1282 removeErrorMessages();
1283 });
1284
1285 $(body).on("reset_data", (event) => {
1286 const form = event.target.closest?.("form.cart");
1287 const wrapper = form?.querySelector(".vipps-buy-now");
1288 if (!wrapper) return;
1289
1290 wrapper.classList.remove("variation-found");
1291 setPurchaseButtonDisabled(wrapper, true);
1292 removeErrorMessages();
1293 });
1294
1295 $(body).on("wc_variation_form", () => {
1296 body.dispatchEvent(new Event("vippsInit"));
1297 });
1298
1299 $(body).on("woocommerce-product-bundle-hide", () => {
1300 document.querySelectorAll("form .vipps-buy-now").forEach((wrapper) => {
1301 wrapper.classList.add("compat-mode");
1302 setPurchaseButtonDisabled(wrapper, true);
1303 });
1304 removeErrorMessages();
1305 });
1306
1307 $(body).on("woocommerce-product-bundle-show", () => {
1308 document.querySelectorAll("form .vipps-buy-now").forEach((wrapper) => {
1309 setPurchaseButtonDisabled(wrapper, false);
1310 });
1311 removeErrorMessages();
1312 });
1313 }
1314
1315 /** Keep wrapper styling and its disabled attribute in agreement. */
1316 function setPurchaseButtonDisabled(wrapper, disabled) {
1317 wrapper.classList.toggle("disabled", disabled);
1318
1319 if (disabled) {
1320 wrapper.setAttribute("disabled", "");
1321 } else {
1322 wrapper.removeAttribute("disabled");
1323 }
1324 }
1325
1326 /** Update the mini-cart checkout link when the Store API supplies a new URL. */
1327 function subscribeToCartChanges() {
1328 if (!wp.data) return;
1329
1330 let previousCheckoutUrl = "";
1331 wp.data.subscribe(() => {
1332 const button = document.querySelector(
1333 "a.wp-block-woocommerce-mini-cart-checkout-button-block"
1334 );
1335 if (!button) return;
1336
1337 const cart = wp.data.select("wc/store/cart")?.getCartData?.();
1338 const checkoutUrl = cart?.extensions?.["woo-vipps"]?.checkout_url;
1339 if (!checkoutUrl || checkoutUrl === previousCheckoutUrl) return;
1340
1341 previousCheckoutUrl = checkoutUrl;
1342 button.href = checkoutUrl;
1343 });
1344 }
1345
1346 /** Explain whether a variation is unselected or unavailable. */
1347 function showVariationMessage(wrapper) {
1348 const params = window.wc_add_to_cart_variation_params;
1349 const message = wrapper.classList.contains("variation-found")
1350 ? params?.i18n_unavailable_text || translate(
1351 "variationUnavailable",
1352 "This product variation is not available."
1353 )
1354 : params?.i18n_make_a_selection_text || translate(
1355 "variationSelectionRequired",
1356 "Please choose a product variation."
1357 );
1358 showError(message, wrapper);
1359 }
1360
1361 /** Remove prior Vipps errors before a new attempt or error is shown. */
1362 function removeErrorMessages() {
1363 document.querySelectorAll(".woocommerce-error.vipps-error").forEach((element) => {
1364 element.remove();
1365 });
1366 document.querySelector("#vipps-error-dialog")?.close();
1367
1368 emitDocumentEvent("woo-vipps-remove-errors");
1369 wp.hooks.doAction("vippsRemoveErrorMessages");
1370 }
1371
1372 /** Publish an error to the purchase page, integrations, and modal UI. */
1373 function showError(message, wrapper) {
1374 removeErrorMessages();
1375
1376 emitVippsPurchaseEvent("vippsPurchaseError", {
1377 message: String(message),
1378 wrapper
1379 });
1380
1381 let markup = `<p><ul class="woocommerce-error vipps-error vipps-default-error-message vipps-buy-now-error"><li>${escapeHtml(message)}</li></ul></p>`;
1382 markup = wp.hooks.applyFilters("vippsErrorMessage", markup, wrapper);
1383
1384 emitDocumentEvent("woo-vipps-error-message", [markup, wrapper]);
1385 wp.hooks.doAction("vippsAddErrorMessage", markup, wrapper);
1386
1387 showErrorDialog(markup);
1388 }
1389
1390 /** Present checkout errors where cart and mini-cart layouts can show them. */
1391 function showErrorDialog(markup) {
1392 const dialog = ensureErrorDialog();
1393 dialog.content.innerHTML = markup;
1394 dialog.dialog.showModal();
1395 }
1396
1397 // Cart and minicart layouts often render awkwardly with inline WooCommerce
1398 // errors, so show checkout errors in a small dialog instead. IOK 2026-09-04
1399 /** Reuse one modal error container across repeated purchase attempts. */
1400 function ensureErrorDialog() {
1401 let dialog = document.querySelector("#vipps-error-dialog");
1402 if (dialog) {
1403 return {
1404 dialog,
1405 content: dialog.querySelector(".vipps-error-dialog-content")
1406 };
1407 }
1408
1409 dialog = document.createElement("dialog");
1410 dialog.id = "vipps-error-dialog";
1411 dialog.className = "vipps-mobilepay-dialog vipps-error-dialog";
1412
1413 const close = document.createElement("button");
1414 close.type = "button";
1415 close.className = "dialog-close";
1416 close.setAttribute("aria-label", locale.close || "Close");
1417 close.title = locale.close || "Close";
1418 close.textContent = "×";
1419
1420 const content = document.createElement("div");
1421 content.className = "vipps-error-dialog-content";
1422
1423 close.addEventListener("click", () => dialog.close());
1424 dialog.append(close, content);
1425 document.body.append(dialog);
1426
1427 return { dialog, content };
1428 }
1429
1430 /** Notify integrations through both native and jQuery event systems. */
1431 function emitDocumentEvent(name, args = []) {
1432 document.dispatchEvent(new CustomEvent(name, { detail: args }));
1433
1434 if (window.jQuery) {
1435 window.jQuery(document).trigger(name, args);
1436 }
1437 }
1438
1439 /** Treat server and validation messages as text before building error HTML. */
1440 function escapeHtml(value) {
1441 const element = document.createElement("div");
1442 element.textContent = String(value || "");
1443 return element.innerHTML;
1444 }
1445 })();
1446