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.