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
← All changes | payment/js/vipps.js +1403 -253 6.2.2 → 6.3.0 View file →
@@ -1,295 +1,1445 @@
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 +})();
1 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;
2 184
3 -This file is part of the plugin Pay with Vipps and MobilePay for WooCommerce
4 -Copyright (c) 2019 WP-Hosting AS
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 + }
5 192
6 -MIT License
193 + const handoff = JSON.parse(raw);
7 194
8 -Copyright (c) 2019 WP-Hosting AS
195 + if (!handoff.createdAt || Date.now() - handoff.createdAt >= maxAgeMs) {
196 + clear();
197 + return null;
198 + }
9 199
10 -Permission is hereby granted, free of charge, to any person obtaining a copy
11 -of this software and associated documentation files (the "Software"), to deal
12 -in the Software without restriction, including without limitation the rights
13 -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14 -copies of the Software, and to permit persons to whom the Software is
15 -furnished to do so, subject to the following conditions:
200 + return handoff;
201 + } catch {
202 + clear();
203 + return null;
204 + }
205 + }
16 206
17 -The above copyright notice and this permission notice shall be included in all
18 -copies or substantial portions of the Software.
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 + }
19 221
20 -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21 -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22 -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23 -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24 -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25 -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26 -SOFTWARE.
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 + }
27 231
28 -*/
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 + }
29 237
30 -// Hook vippsInit to woocommerce/product-collection render event. LP 29.11.2024
31 -// IOK 2026-01-14 available from woo 9.4 - so the buy now block will not be available until that version
32 -document.body.addEventListener('wc-blocks_product_list_rendered',
33 - function (event) {
34 - document.body.dispatchEvent(new Event('vippsInit'));
35 - }
36 -);
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 + });
37 245
246 + if ("disabled" in button) {
247 + button.disabled = true;
248 + }
38 249
39 -jQuery( document ).ready( function() {
250 + button.setAttribute("disabled", "");
251 + button.setAttribute("aria-disabled", "true");
252 + });
253 + }
40 254
41 - // This is for WooCommerce Product Bundles, which potentially have *several* variant-products or configurable products, so
42 - // the standard Woo hooks don't apply. Luckily, we have other, simpler events to use.
43 - // For this to work, we also have to force the compat-mode thing on. IOK 2020-04-21
44 - jQuery('.cart.bundle_data').on('woocommerce-product-bundle-hide', function (e, variant) {
45 - jQuery('form .button.single-product.vipps-buy-now').removeClass('variation-found');
46 - jQuery('form .button.single-product.vipps-buy-now').attr('disabled','disabled');
47 - jQuery('form .button.single-product.vipps-buy-now').addClass('disabled');
48 - // We don't know why the button is hidden, so ensure we use compatibility-mode IOK 2020-04-21
49 - jQuery('.button.single-product.vipps-buy-now').addClass('compat-mode');
50 - removeErrorMessages();
51 - });
52 - jQuery('.cart.bundle_data').on('woocommerce-product-bundle-show', function (e, variant) {
53 - jQuery('form .button.single-product.vipps-buy-now').addClass('variation-found');
54 - jQuery('form .button.single-product.vipps-buy-now').removeAttr('disabled');
55 - jQuery('form .button.single-product.vipps-buy-now').removeClass('disabled');
56 - removeErrorMessages();
57 - });
58 -
59 - // Hooks for the 'buy now with vipps' button on product pages etc
60 - jQuery('body').on('found_variation', function (e,variation) {
61 - const form = jQuery(e.target);
62 - const target = form.find(".button.single-product.vipps-buy-now.variable-product");
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 + }
63 260
64 - var purchasable=true;
65 - if ( ! variation.is_purchasable || ! variation.is_in_stock || ! variation.variation_is_visible ) {
66 - purchasable = false;
67 - }
68 - target.addClass('variation-found');
69 - if (purchasable) {
70 - target.removeAttr('disabled');
71 - target.removeClass('disabled');
72 - removeErrorMessages();
73 - } else {
74 - target.attr('disabled','disabled');
75 - target.addClass('disabled');
76 - removeErrorMessages();
77 - }
78 - });
79 - jQuery('body').on('reset_data', function (e) {
80 - const form = jQuery(e.target)
81 - const target = form.find(".button.single-product.vipps-buy-now.variable-product");
82 - target.removeClass('variation-found');
83 - target.attr('disabled','disabled');
84 - target.addClass('disabled');
85 - removeErrorMessages();
86 - });
87 - // If this is triggered, somebody just loaded a variation form, so we need to redo the button init scripts
88 - jQuery('body').on('wc_variation_form', function () {
89 - vippsInit();
90 - });
91 - // Allow other guys to do this too
92 - jQuery('body').on('vippsInit', function () {
93 - vippsInit();
94 - });
261 + document.documentElement.classList.remove("payment-handoff-active");
262 + disabledButtonStates.forEach((state, button) => {
263 + if (state.disabled !== undefined) {
264 + button.disabled = state.disabled;
265 + }
95 266
267 + if (state.disabledAttribute === null) {
268 + button.removeAttribute("disabled");
269 + } else {
270 + button.setAttribute("disabled", state.disabledAttribute);
271 + }
96 272
97 - // Using ajax, get a session and a key and redirect to the "buy single product using express checkout" page.
98 - function buySingleProduct (event) {
99 - event.preventDefault();
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 + }
100 281
101 - var element = jQuery(this);
102 - if (jQuery('body').hasClass('processing')) return; // Trying to stop doublecclickers. IOK 2018-09-27
282 + /** Refresh a revisited page once per path to obtain current order state. */
283 + function reloadOnceForStalePage() {
284 + const handoff = get();
103 285
104 - if (jQuery(element).hasClass('disabled')) {
105 - if (typeof wc_add_to_cart_variation_params != 'undefined') {
106 - if (jQuery(element).hasClass('variation-found')) {
107 - window.alert(wc_add_to_cart_variation_params.i18n_unavailable_text);
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();
108 483 } else {
109 - window.alert(wc_add_to_cart_variation_params.i18n_make_a_selection_text);
484 + vippsSdk.host().start();
110 485 }
111 - } else {
112 - console.log("Missing the wc_add_to_cart_variation_params");
113 - }
114 - return false;
115 - }
116 486
117 - jQuery('body').addClass('processing');
118 - removeErrorMessages();
119 - jQuery(element).attr('disabled','disabled');
120 - jQuery(element).attr('inactive','inactive');
121 - jQuery(element).addClass('disabled');
122 - jQuery(element).addClass('loading');
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 + }
123 494
124 - if (typeof wp !== 'undefined' && typeof wp.hooks !== 'undefined') {
125 - wp.hooks.doAction('vippsBuySingleProduct', element, event);
126 - }
495 + const attemptId = currentAttempt.id;
496 + let result;
127 497
128 - var compatMode = jQuery(element).hasClass('compat-mode');
129 - if (typeof wp !== 'undefined' && typeof wp.hooks !== 'undefined') {
130 - compatMode = wp.hooks.applyFilters('vippsBuySingleProductCompatMode', compatMode, element, event);
131 - }
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 + );
132 504
133 - // In compatibility mode, we delegate to the existing buy now button instead of doing the logic
134 - // ourselves. This allows more filters and actions in existing plugins to run. IOK 2019-02-26
135 - if (compatMode) {
136 - var form = jQuery(element).closest('form');
137 - var otherbutton = form.find('.single_add_to_cart_button').first();
138 - var compatAction = function () {
139 - form.prepend('<input type=hidden id="vipps_compat_mode" name="vipps_compat_mode" value="1">');
140 - if (otherbutton.length>0) otherbutton.click();
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;
141 673 }
142 - // If your theme or product is weird enough, you may need this
143 - if (typeof wp !== 'undefined' && typeof wp.hooks !== 'undefined') {
144 - compatAction = wp.hooks.applyFilters('vippsBuySingleProductCompatModeAction', compatAction, element, event);
145 - }
146 - compatAction();
147 - return false;
148 - }
149 674
150 - var data = {};
151 - if (element.data('product_id') || element.data('product_sku')) {
152 - data = element.data();
153 - } else {
154 - var form = element.closest('form.cart');
155 - if (!form) {
156 - jQuery(element).removeClass('disabled');
157 - jQuery(element).removeClass('loading');
158 - jQuery(element).removeAttr('disabled');
159 - jQuery(element).removeAttr('inactive');
160 - jQuery('body').removeClass('processing');
161 - addErrorMessage('Cannot add product - unknown error');
162 - return false;
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 + }
163 879 }
164 - // Initialize with the entire content of the form if we have it.
165 - var serialized = form.serializeArray();
166 - for (var i=0;i<serialized.length;i++) {
167 - if (serialized[i]['name'] != 'add-to-cart') {
168 - data[serialized[i]['name']] = serialized[i]['value'];
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;
169 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);
170 920 }
171 - var prodid = jQuery(form).find('input[name="product_id"]');
172 - var varid = jQuery(form).find('input[name="variation_id"]');
173 - var quantity = jQuery(form).find('input[name="quantity"]');
174 - data['quantity'] = (quantity.length>0) ? quantity.val() : 1;
175 - data['product_id'] = (prodid.length>0) ? prodid.val() : 0;
176 - data['variation_id'] = (varid.length>0) ? varid.val() : 0;
177 - // Earlier versions, no variation:
178 - if (prodid.length == 0) {
179 - prodid = jQuery(form).find('button[name="add-to-cart"]');
180 - if (prodid.length > 0) {
181 - data['product_id'] = prodid.val();
182 - } else {
183 - // Some weird themes are even more incompatible
184 - prodid = jQuery(form).find('button[data-product_id]');
185 - data['product_id'] = prodid.length > 0 ? prodid.data('product_id') : 0;
186 - }
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();
187 954 }
188 - }
189 - // Finally, create a hook for even weirder themes.
190 - data = wp.hooks.applyFilters('vippsBuySingleProductData', data, element, event);
191 - data['action'] = 'vipps_buy_single_product';
192 955
193 - jQuery.ajax(VippsConfig['vippsajaxurl'], {
194 - "method": "POST",
195 - "data":data,
196 - "cache":false,
197 - "headers": {"Accept-Language": `${VippsConfig['vippslocale']}, *`},
198 - "dataType": "json",
199 - "error": function (xhr, statustext, error) {
200 - console.log("Error creating express checkout:" + statustext + " " + error);
201 - addErrorMessage(error, element);
202 - jQuery(element).prop('disabled',true);
203 - jQuery(element).prop('inactive',true);
204 - jQuery('body').removeClass('processing');
205 - },
206 - "success": function (result, statustext, xhr) {
207 - if (result["ok"]) {
208 - console.log("We created the order!");
209 - /* In case user presses the back button */
210 - setTimeout(function () {
211 - jQuery(element).removeClass('disabled');
212 - jQuery(element).removeClass('loading');
213 - jQuery(element).removeAttr('disabled');
214 - jQuery(element).removeAttr('inactive');
215 - jQuery('body').removeClass('processing');
216 - }
217 - , 1500);
218 - window.location.assign(result["url"]);
219 - } else {
220 - console.log("Failure!");
221 - jQuery(element).removeClass('disabled');
222 - jQuery(element).removeClass('loading');
223 - jQuery(element).removeAttr('disabled');
224 - jQuery(element).removeAttr('inactive');
225 - addErrorMessage(result['msg'],element);
226 - jQuery('body').removeClass('processing');
227 - }
228 - },
229 - "timeout": 0
230 - });
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 + }
231 962
232 - }
963 + removeErrorMessages();
233 964
965 + wp.hooks.doAction("vippsBuySingleProduct", wrapper, event);
234 966
967 + let compatMode = wrapper.classList.contains("compat-mode");
968 + compatMode = wp.hooks.applyFilters(
969 + "vippsBuySingleProductCompatMode",
970 + compatMode,
971 + wrapper,
972 + event
973 + );
235 974
236 - // Remove old error messages
237 - function removeErrorMessages () {
238 - jQuery('.woocommerce-error.vipps-error').fadeOut(300, function () { jQuery(this).remove(); });
239 - jQuery(document).trigger('woo-vipps-remove-errors');
240 - if (typeof wp !== 'undefined' && typeof wp.hooks !== 'undefined') {
241 - wp.hooks.doAction('vippsRemoveErrorMessages');
242 - }
243 - }
975 + if (compatMode) {
976 + runCompatibilityAction(wrapper, event);
977 + return;
978 + }
244 979
245 - // And add new ones
246 - function addErrorMessage(msg,element) {
247 - // Allow developers to customize error message by hiding vipps-default-error-message and hooking woo-vipps-error-message <messsage>,<element>
248 - var msg = "<p><ul class='woocommerce-error vipps-error vipps-default-error-message vipps-buy-now-error'><li>"+msg+"!</li></ul></p>";
249 - if (typeof wp !== 'undefined' && typeof wp.hooks !== 'undefined') {
250 - msg = wp.hooks.applyFilters('vippsErrorMessage',msg, element);
251 - }
252 - jQuery(document).trigger('woo-vipps-error-message',[msg, element]);
253 - if (typeof wp !== 'undefined' && typeof wp.hooks !== 'undefined') {
254 - wp.hooks.doAction('vippsAddErrorMessage', msg, element);
255 - }
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 + }
256 986
257 - jQuery(msg).hide().insertAfter(element).fadeIn(300);
258 - jQuery('.woocommerce-error.vipps-error').click(removeErrorMessages);
259 - }
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 + ));
260 995
261 - // Hooks for the button itself
262 - function vippsInit() {
263 - jQuery('.button.single-product.vipps-buy-now:not(.initialized)').click(buySingleProduct);
264 - jQuery('.button.single-product.vipps-buy-now').addClass('initialized');
265 - if (typeof wp !== 'undefined' && typeof wp.hooks !== 'undefined') {
266 - wp.hooks.doAction('vippsInit');
267 - }
268 - }
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 + }
269 1005
270 - // For modern wp/woo we want to subscribe to events for the cart especially. IOK 2026-02-24
271 - if( window.wp?.data) {
272 - // The interactivity based minicart is only loaded once; so if the cart changes wrt supporting Vipps Checkout we must handle that
273 - // by subscribing to the cart data. IOK 2026-02-23
274 - let prevCheckoutUrl = "";
275 - function handleCartChanges () {
276 - const minicartbutton = document.querySelector('a.wp-block-woocommerce-mini-cart-checkout-button-block');
277 - if (!minicartbutton) return;
1006 + if (!checkout.begin(transaction, wrapper, event, "/woo-vipps/v1/express_checkout_single")) {
1007 + return;
1008 + }
278 1009
279 - const cart = wp.data.select("wc/store/cart")?.getCartData?.();
280 - if (!cart) return;
281 - const vippsdata = cart?.extensions?.['woo-vipps'];
282 - if (!vippsdata) return;
283 - if (vippsdata['checkout_url'] && prevCheckoutUrl != vippsdata['checkout_url']) {
284 - prevCheckoutUrl = vippsdata['checkout_url'];
285 - minicartbutton.href = vippsdata['checkout_url'];
286 - }
287 - }
288 - wp.data.subscribe(handleCartChanges);
289 - }
1010 + await checkout.start();
1011 + }
290 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 + }
291 1021
292 -
293 - vippsInit();
294 -});
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);
295 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 +})();