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

wc-payment-method-vipps.js in Pay with Vipps and MobilePay for WooCommerce 6.3.1, at payment/Blocks/Payment/js/wc-payment-method-vipps.js

777 lines 27.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1
2 (function () {
3
4 // Imports
5 const { __ } = wp.i18n;
6 const { decodeEntities } = wp.htmlEntities;
7 const { getSetting } = wc.wcSettings;
8 const { registerPaymentMethod } = wc.wcBlocksRegistry;
9 const { registerExpressPaymentMethod } = wc.wcBlocksRegistry;
10 const { applyFilters } = wp.hooks;
11 const { createElement, useEffect, useRef, useState } = wp.element;
12
13 //( 'hookName', content, arg1, arg2, ... )
14
15 // Data
16 const settings = getSetting('vipps_data', {});
17 const defaultLabel = VippsLocale['Vipps'];
18 const label = decodeEntities(settings.title) || defaultLabel;
19 const iconsrc = settings.iconsrc;
20 const CHECKOUT_HANDOFF_STORAGE_KEY = 'vippsCheckoutPaymentHandoff';
21 let vippsCheckoutController = null;
22 let setVippsCheckoutBusy = () => {};
23
24 /**
25 * Report recoverable integration problems without logging routine checkout traffic.
26 */
27 function warnVippsCheckout(message, data) {
28 if (data === undefined) {
29 console.warn('[Vipps Checkout Block]', message);
30 return;
31 }
32
33 console.warn('[Vipps Checkout Block]', message, data);
34 }
35
36 /**
37 * Report failures that prevent the SDK or Store API handoff from completing.
38 */
39 function errorVippsCheckout(message, data) {
40 if (data === undefined) {
41 console.error('[Vipps Checkout Block]', message);
42 return;
43 }
44
45 console.error('[Vipps Checkout Block]', message, data);
46 }
47
48 /**
49 * Use the gateway localization dictionary, with a readable fallback for missing keys.
50 */
51 function translate(key, fallback) {
52 return VippsLocale?.[key] || fallback;
53 }
54
55 /**
56 * Use the configured brand/title in customer-facing errors.
57 */
58 function getPaymentMethodName() {
59 return decodeEntities(settings.title || defaultLabel || '');
60 }
61
62 /**
63 * Prefix fallback errors with the configured payment method name.
64 */
65 function getPaymentMethodMessage(message) {
66 return `${getPaymentMethodName()} ${message}`.trim();
67 }
68
69 /**
70 * Remember an order handoff across navigation. Refresh a restored checkout at most
71 * once per path so Back/app-return does not expose a stale cart or order form.
72 */
73 function createVippsCheckoutHandoff() {
74 const maxAgeMs = 30 * 60 * 1000;
75
76 /**
77 * Read a valid handoff marker; discard expired or malformed session data.
78 */
79 function get() {
80 let raw;
81
82 try {
83 raw = window.sessionStorage.getItem(CHECKOUT_HANDOFF_STORAGE_KEY);
84 } catch (error) {
85 warnVippsCheckout('Could not read checkout handoff marker from sessionStorage.', error);
86 return null;
87 }
88
89 if (!raw) {
90 return null;
91 }
92
93 try {
94 const handoff = JSON.parse(raw);
95
96 if (!handoff.createdAt || Date.now() - handoff.createdAt >= maxAgeMs) {
97 clear();
98 return null;
99 }
100
101 return handoff;
102 } catch (error) {
103 warnVippsCheckout('Could not parse checkout handoff marker; clearing.', {
104 raw,
105 error
106 });
107 clear();
108 return null;
109 }
110 }
111
112 /**
113 * Record the handoff before giving the SDK a URL, since it may navigate immediately.
114 */
115 function mark(data = {}) {
116 try {
117 window.sessionStorage.setItem(CHECKOUT_HANDOFF_STORAGE_KEY, JSON.stringify({
118 createdAt: Date.now(),
119 path: window.location.pathname,
120 orderId: data.orderId || null,
121 paymentReference: data.paymentReference || null,
122 refreshedPaths: []
123 }));
124 } catch (error) {
125 warnVippsCheckout('Could not write checkout handoff marker to sessionStorage.', error);
126 // Checkout must still work when storage is unavailable.
127 }
128 }
129
130 /**
131 * Release local state after completion, cancellation, or failure.
132 */
133 function clear() {
134 try {
135 window.sessionStorage.removeItem(CHECKOUT_HANDOFF_STORAGE_KEY);
136 } catch (error) {
137 warnVippsCheckout('Could not clear checkout handoff marker from sessionStorage.', error);
138 // Checkout must still work when storage is unavailable.
139 }
140 }
141
142 /**
143 * Persist the per-path refresh guard before reloading to avoid a reload loop.
144 */
145 function reloadOnceForStalePage() {
146 const handoff = get();
147
148 if (!handoff) {
149 return;
150 }
151
152 const refreshedPaths = Array.isArray(handoff.refreshedPaths)
153 ? handoff.refreshedPaths
154 : [];
155
156 if (refreshedPaths.includes(window.location.pathname)) {
157 return;
158 }
159
160 try {
161 window.sessionStorage.setItem(CHECKOUT_HANDOFF_STORAGE_KEY, JSON.stringify({
162 ...handoff,
163 refreshedPaths: [...refreshedPaths, window.location.pathname]
164 }));
165 } catch (error) {
166 warnVippsCheckout('Could not update checkout handoff reload marker; clearing.', error);
167 clear();
168 }
169
170 window.location.reload();
171 }
172
173 /**
174 * Recognize both back-forward cache restoration and history navigation.
175 */
176 function wasHistoryRestore(event) {
177 const navigation = window.performance?.getEntriesByType?.('navigation')?.[0];
178 return event.persisted || navigation?.type === 'back_forward';
179 }
180
181 /**
182 * Refresh history-restored checkout state; clear the marker on a fresh page load.
183 */
184 function handlePageshow(event) {
185 const handoff = get();
186
187 if (!handoff) {
188 return;
189 }
190
191 if (wasHistoryRestore(event)) {
192 reloadOnceForStalePage();
193 return;
194 }
195
196 clear();
197 }
198
199 window.addEventListener('pageshow', handlePageshow);
200 window.addEventListener('focus', reloadOnceForStalePage);
201 document.addEventListener('visibilitychange', () => {
202 if (document.visibilityState === 'visible') {
203 reloadOnceForStalePage();
204 }
205 });
206
207 return { mark, clear, reloadOnceForStalePage };
208 }
209
210 const checkoutHandoff = createVippsCheckoutHandoff();
211
212 /**
213 * Accept object details and Store API key/value arrays from different response paths.
214 */
215 function normalizeVippsCheckoutPaymentDetails(details) {
216 if (!Array.isArray(details)) {
217 return details || {};
218 }
219
220 return details.reduce((normalized, item) => {
221 if (Array.isArray(item) && item.length >= 2) {
222 normalized[item[0]] = item[1];
223 return normalized;
224 }
225
226 if (item && typeof item === 'object') {
227 const key = item.key || item.name;
228
229 if (key) {
230 normalized[key] = item.value;
231 }
232 }
233
234 return normalized;
235 }, {});
236 }
237
238 /**
239 * Current Blocks events expose processingResponse.paymentDetails. Retain older
240 * paymentResult shapes and raw Store API shapes for compatibility.
241 */
242 function getVippsCheckoutPaymentDetails(data) {
243 const rawDetails = data?.processingResponse?.paymentDetails ||
244 data?.processingResponse?.payment_details ||
245 data?.paymentResult?.paymentDetails ||
246 data?.paymentResult?.payment_details ||
247 data?.payment_result?.payment_details ||
248 data?.payment_result?.paymentDetails ||
249 {};
250 const details = normalizeVippsCheckoutPaymentDetails(rawDetails);
251
252 return details;
253 }
254
255 /**
256 * Prefer widget-specific payment details; use the normal gateway redirect as a
257 * fallback when checkout was processed through the legacy Store API adapter.
258 */
259 function getVippsCheckoutPaymentUrl(data) {
260 const details = getVippsCheckoutPaymentDetails(data);
261 const paymentUrl = details.vippsPaymentUrl ||
262 details.vipps_payment_url ||
263 details.paymentUrl ||
264 details.payment_url ||
265 data?.redirectUrl ||
266 data?.redirect_url ||
267 '';
268
269 return paymentUrl;
270 }
271
272 /**
273 * Read optional reference metadata for the handoff marker; it is not needed to open the SDK.
274 */
275 function getVippsCheckoutPaymentReference(data) {
276 const details = getVippsCheckoutPaymentDetails(data);
277 const paymentReference = details.vippsPaymentReference ||
278 details.vipps_payment_reference ||
279 details.paymentReference ||
280 details.payment_reference ||
281 '';
282
283 return paymentReference;
284 }
285
286 /**
287 * Bridge the custom button and the checkout event subscriber with one pending URL
288 * promise. WooCommerce creates the order; the SDK waits for its payment URL.
289 * An attempt covers order submission and URL delivery, not the whole payment session.
290 */
291 function createVippsCheckoutController(setBusy) {
292 let currentAttempt = null;
293 let nextAttemptId = 0;
294 let trigger = null;
295
296 /**
297 * Reuse one trigger and start the shared host once. The resolver waits for Store API
298 * completion; SDK success/cancel callbacks own subsequent merchant-return navigation.
299 */
300 function getTrigger() {
301 if (trigger || !window.vipps?.trigger) {
302 return trigger;
303 }
304
305 if (typeof window.ensureVippsWidgetHostStarted === 'function') {
306 window.ensureVippsWidgetHostStarted();
307 } else if (!window.__vippsWidgetHostStarted && window.vipps?.host) {
308 window.vipps.host().start();
309 window.__vippsWidgetHostStarted = true;
310 }
311
312 trigger = window.vipps
313 .trigger(async () => {
314
315 if (!currentAttempt) {
316 throw new Error(getPaymentMethodMessage('checkout attempt is no longer active.'));
317 }
318
319 const paymentUrl = await currentAttempt.paymentUrlPromise;
320
321 return paymentUrl;
322 })
323 .on('success', (close, redirectUrl) => {
324 clear();
325 close();
326
327 if (redirectUrl) {
328 window.location.assign(redirectUrl);
329 } else {
330 checkoutHandoff.clear();
331 }
332 })
333 .on('cancel', (close, redirectUrl) => {
334 clear();
335 close();
336
337 if (redirectUrl) {
338 window.location.assign(redirectUrl);
339 } else {
340 checkoutHandoff.clear();
341 }
342 })
343 .on('close', () => {
344 clear();
345 checkoutHandoff.clear();
346 })
347 .on('error', (error) => {
348 clear();
349 checkoutHandoff.clear();
350
351 // A null resolver URL is the SDK's deliberate no-session signal.
352 if (
353 window.vipps?.InvalidTriggerUrlError &&
354 error instanceof window.vipps.InvalidTriggerUrlError &&
355 error.url === null
356 ) {
357 return;
358 }
359
360 errorVippsCheckout('Widget SDK error event.', error);
361 });
362
363 return trigger;
364 }
365
366 /**
367 * Create one pending URL promise and disable the button to prevent duplicate submissions.
368 */
369 function begin() {
370 if (currentAttempt) {
371 warnVippsCheckout('Checkout attempt already active; refusing to begin another.', currentAttempt);
372 return null;
373 }
374
375 let resolvePaymentUrl;
376 let rejectPaymentUrl;
377 const paymentUrlPromise = new Promise((resolve, reject) => {
378 resolvePaymentUrl = resolve;
379 rejectPaymentUrl = reject;
380 });
381
382 currentAttempt = {
383 id: ++nextAttemptId,
384 paymentUrlPromise,
385 resolvePaymentUrl,
386 rejectPaymentUrl
387 };
388
389 setBusy(true);
390 return currentAttempt;
391 }
392
393 /**
394 * Start the SDK resolver before submitting checkout; do not await URL delivery here.
395 */
396 function open() {
397 const vippsTrigger = getTrigger();
398
399 if (!vippsTrigger) {
400 warnVippsCheckout('Widget SDK trigger is unavailable; cannot open trigger.');
401 return Promise.resolve(false);
402 }
403
404 return vippsTrigger.open();
405 }
406
407 /**
408 * Deliver the successful Store API payment URL to the SDK, or navigate directly if
409 * the SDK is unavailable. Return false for missing URLs so Blocks can display an error.
410 */
411 function resolve(data) {
412 if (!currentAttempt) {
413 warnVippsCheckout('Received checkout success without active attempt.');
414 return false;
415 }
416
417 const paymentUrl = getVippsCheckoutPaymentUrl(data);
418
419 if (!paymentUrl) {
420 errorVippsCheckout('Missing checkout payment URL in Store API success payload.');
421 currentAttempt.rejectPaymentUrl(new Error(
422 translate('missingPaymentUrl', getPaymentMethodMessage('did not return a payment URL.'))
423 ));
424 clear();
425 return false;
426 }
427
428 checkoutHandoff.mark({
429 orderId: data?.orderId,
430 paymentReference: getVippsCheckoutPaymentReference(data)
431 });
432
433 if (!window.vipps?.trigger) {
434 warnVippsCheckout('Widget SDK trigger disappeared; falling back to full-page redirect.');
435 clear();
436 window.location.assign(paymentUrl);
437 return true;
438 }
439
440 currentAttempt.resolvePaymentUrl(paymentUrl);
441 currentAttempt = null;
442 setBusy(false);
443 return true;
444 }
445
446 /**
447 * Reject the waiting SDK resolver when WooCommerce fails, then release the attempt.
448 */
449 function reject(error) {
450 warnVippsCheckout('Rejecting checkout attempt.', {
451 attemptId: currentAttempt?.id,
452 error
453 });
454
455 if (currentAttempt) {
456 currentAttempt.rejectPaymentUrl(error || new Error(getPaymentMethodMessage('checkout failed.')));
457 }
458
459 clear();
460 }
461
462 /**
463 * Release local state after completion, cancellation, or failure.
464 */
465 function clear() {
466 currentAttempt = null;
467 setBusy(false);
468 }
469
470 /**
471 * Identify whether this controller owns a pending order submission.
472 */
473 function hasAttempt() {
474 return Boolean(currentAttempt);
475 }
476
477 return { begin, open, resolve, reject, clear, hasAttempt };
478 }
479
480 /**
481 * Share the controller between Content and the custom button, which Blocks mounts
482 * as separate components. Keep the button state setter available to event callbacks.
483 */
484 function getVippsCheckoutController(setBusy) {
485 if (setBusy) {
486 setVippsCheckoutBusy = setBusy;
487 }
488
489 if (!vippsCheckoutController) {
490 vippsCheckoutController = createVippsCheckoutController((busy) => {
491 // Use the same overlay as express checkout while the Store API
492 // creates the order. resolve/reject/clear release it for the SDK UI.
493 window.setVippsPaymentBusy(busy, 'checkout-block');
494 setVippsCheckoutBusy(busy);
495 });
496 }
497
498 return vippsCheckoutController;
499 }
500
501 /**
502 * Render the description and subscribe to Blocks checkout events. Payment setup
503 * supplies request metadata; checkout success hands off the URL and suppresses
504 * WooCommerce navigation only for an active widget attempt. Refs keep subscriptions
505 * using current props; effect cleanup removes observers when Blocks unmounts content.
506 */
507 const Content = (props) => {
508 const { eventRegistration, emitResponse } = props;
509 const propsRef = useRef(props);
510 const emitResponseRef = useRef(emitResponse);
511
512 propsRef.current = props;
513 emitResponseRef.current = emitResponse;
514
515 useEffect(() => {
516 if (!eventRegistration) {
517 return;
518 }
519
520 // SUCCESS here means payment data is ready, not that payment has been collected.
521 const unsubscribePaymentSetup = eventRegistration.onPaymentSetup
522 ? eventRegistration.onPaymentSetup(() => {
523
524 if (
525 propsRef.current.activePaymentMethod &&
526 propsRef.current.activePaymentMethod !== 'vipps'
527 ) {
528 return true;
529 }
530
531 const response = {
532 type: emitResponseRef.current.responseTypes.SUCCESS,
533 meta: {
534 paymentMethodData: {
535 payment_method: 'vipps',
536 vipps_checkout_widget: '1'
537 }
538 }
539 };
540
541 return response;
542 })
543 : () => {};
544
545 const unsubscribeCheckoutSuccess = eventRegistration.onCheckoutSuccess
546 ? eventRegistration.onCheckoutSuccess((data) => {
547 const controller = getVippsCheckoutController();
548
549 if (!controller?.hasAttempt()) {
550 return true;
551 }
552
553 if (!controller.resolve(data)) {
554 const response = {
555 type: emitResponseRef.current.responseTypes.ERROR,
556 message: translate('missingPaymentUrl', getPaymentMethodMessage('did not return a payment URL.')),
557 retry: true
558 };
559
560 return response;
561 }
562
563 const response = {
564 type: emitResponseRef.current.responseTypes.SUCCESS,
565 // WooCommerce completes checkout after this callback. An explicit
566 // empty redirect clears its stored URL; the widget owns navigation.
567 // SET_COMPLETE checks typeof redirectUrl === 'string', so omitting
568 // this field (or returning true) would retain the gateway redirect.
569 // See Payment/README.md for the WooCommerce source references.
570 redirectUrl: ''
571 };
572
573 return response;
574 })
575 : () => {};
576
577 // Let WooCommerce display its checkout error; release the waiting SDK resolver.
578 const unsubscribeCheckoutFail = eventRegistration.onCheckoutFail
579 ? eventRegistration.onCheckoutFail(() => {
580 getVippsCheckoutController().reject(new Error('Checkout failed'));
581 return true;
582 })
583 : () => {};
584
585 return () => {
586 unsubscribePaymentSetup();
587 unsubscribeCheckoutSuccess();
588 unsubscribeCheckoutFail();
589 };
590 }, [eventRegistration]);
591
592 var content = createElement(
593 'div',
594 null,
595 decodeEntities(settings.description || '')
596 );
597 return applyFilters('woo_vipps_checkout_description', content, settings);
598 };
599
600 /**
601 * Render the configured method name and logo through WooCommerce styling and plugin filters.
602 */
603 const Label = props => {
604 const { PaymentMethodLabel } = props.components;
605 let textlabel = createElement( 'span', null, decodeEntities(settings.title || ''));
606 let icon = createElement('img', { style: {display: 'inline-block'}, alt: decodeEntities(settings.title || ''), title: decodeEntities(settings.title || ''), className: 'vipps-payment-logo', src:iconsrc});
607 let label = createElement(PaymentMethodLabel, { text: textlabel, icon: icon });
608 return applyFilters('woo_vipps_checkout_label', label, settings);
609 };
610
611 /**
612 * Render the server-generated express button markup. vipps.js handles its clicks
613 * and session creation independently of the standard Checkout Block submission.
614 */
615 const ExpressCheckoutButton = props => {
616 const ref = useRef(null);
617
618 useEffect(() => {
619 const root = ref.current;
620 const li = root?.closest('#express-payment-method-vippsexpress');
621 const button = root?.querySelector('vipps-mobilepay-button');
622 const compact = button?.getAttribute('compact');
623
624 if (!li || !button || compact !== 'false' || typeof ResizeObserver !== 'function') {
625 return;
626 }
627
628 const observer = new ResizeObserver(([entry]) => {
629 const width = entry.contentRect.width;
630 const nextCompact = String(width < 250);
631 const previousCompact = button.getAttribute('compact');
632 if (previousCompact !== nextCompact) { button.setAttribute('compact', nextCompact); }
633 });
634
635 observer.observe(li);
636
637 return () => { observer.disconnect(); };
638 }, []);
639
640 const expressbutton = createElement('div', { ref, dangerouslySetInnerHTML: { __html: settings.expressbutton }, className: 'vipps-express-container' });
641 return applyFilters('woo_vipps_checkout_block_express_button', expressbutton, settings);
642 };
643
644 /**
645 * Validate the Checkout Block, create a pending attempt, start the SDK, and then
646 * call WooCommerce onSubmit to create/process the order through the Store API.
647 * Awaiting trigger.open before onSubmit would deadlock the resolver waiting for that order.
648 * Editor/preview rendering must not start a payment.
649 */
650 const VippsCheckoutPlaceOrderButton = (props) => {
651 const {
652 validate,
653 onSubmit,
654 disabled,
655 waitingForProcessing,
656 waitingForRedirect,
657 isEditor,
658 isPreview
659 } = props;
660 const [busy, setBusy] = useState(false);
661 const controllerRef = useRef(null);
662
663 useEffect(() => () => {
664 // Blocks can unmount the button when the selected method changes.
665 window.setVippsPaymentBusy(false, 'checkout-block');
666 }, []);
667
668 if (!controllerRef.current) {
669 controllerRef.current = getVippsCheckoutController(setBusy);
670 }
671
672 if (isEditor || isPreview) {
673 return createElement('button', {
674 type: 'button',
675 disabled: true
676 }, decodeEntities(settings.title || defaultLabel));
677 }
678
679 const isDisabled = disabled || waitingForProcessing || waitingForRedirect || busy;
680
681 const handleClick = async (event) => {
682
683 event.preventDefault();
684
685 if (isDisabled || controllerRef.current.hasAttempt()) {
686 return;
687 }
688
689 const validationResult = validate
690 ? await validate()
691 : { hasError: false };
692
693 if (validationResult?.hasError) {
694 return;
695 }
696
697 const attempt = controllerRef.current.begin();
698
699 if (!attempt) {
700 warnVippsCheckout('Could not begin checkout attempt after validation.');
701 return;
702 }
703
704 controllerRef.current.open().catch((error) => {
705 errorVippsCheckout('Widget SDK trigger open promise rejected.', error);
706 controllerRef.current.reject(error);
707 });
708
709 if (onSubmit) {
710 onSubmit();
711 } else {
712 warnVippsCheckout('No onSubmit prop available on Vipps checkout button.');
713 }
714 };
715
716 const buttonProps = {
717 type: 'button',
718 brand: settings.brand || 'vipps',
719 language: settings.language || 'no',
720 verb: 'continue',
721 variant: 'primary',
722 branded: 'true',
723 rounded: 'true',
724 stretched: 'true',
725 className: `vipps-checkout-widget-button${busy ? ' loading' : ''}`,
726 disabled: isDisabled ? true : undefined,
727 'aria-disabled': isDisabled ? 'true' : 'false',
728 'aria-busy': busy ? 'true' : 'false',
729 onClick: handleClick
730 };
731
732 return createElement('vipps-mobilepay-button', buttonProps);
733 };
734
735 /**
736 * Combine the server cart/settings eligibility with the existing express visibility filter.
737 */
738 const canMakeExpressPayment = (args) => {
739 var candoit = settings.show_express_checkout;
740 return applyFilters('woo_vipps_checkout_block_show_express_checkout', candoit, settings);
741 };
742
743 /**
744 * Preserve the plugin visibility filter; server-side availability is supplied by the PHP integration.
745 */
746 const canMakePayment = (args) => {
747 var candoit = true;
748 return applyFilters('woo_vipps_checkout_block_show_vipps', candoit, settings);
749 };
750
751 /**
752 * Vipps payment method config object.
753 */
754 const VippsPaymentMethod = {
755 name: 'vipps',
756 label: createElement(Label, null),
757 content: createElement(Content, null),
758 edit: createElement(Content, null),
759 placeOrderButton: VippsCheckoutPlaceOrderButton,
760 icons: null,
761 canMakePayment: canMakePayment,
762 ariaLabel: label
763 };
764 const VippsExpressPaymentMethod = {
765 name: 'vippsexpress',
766 content: createElement(ExpressCheckoutButton, null),
767 edit: createElement(ExpressCheckoutButton, null),
768 paymentMethodId: 'vipps',
769 canMakePayment: canMakeExpressPayment,
770 };
771
772 registerPaymentMethod(VippsPaymentMethod);
773 registerExpressPaymentMethod(VippsExpressPaymentMethod);
774
775
776 }());
777