PageSourceSearch

http://biodynamisk.no/wp-content/plugins/woo-vipps/payment/js/vipps.js?ver=1790851853

js biodynamisk.no collected 2026-10-02 12:59:41 UTC 52,185 bytes, 1,445 lines download raw bytes

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(";
1136").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})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.