Skip to content

Commit

Permalink
fix: v2 widget in modal checkout (#3645)
Browse files Browse the repository at this point in the history
* fix: v2 widget in modal checkout

* fix: render v2 widgets on form visible; create utils module
  • Loading branch information
dkoo authored Jan 7, 2025
1 parent 9baa0e1 commit 97583e0
Show file tree
Hide file tree
Showing 3 changed files with 245 additions and 193 deletions.
26 changes: 14 additions & 12 deletions includes/class-recaptcha.php
Original file line number Diff line number Diff line change
Expand Up @@ -344,18 +344,6 @@ public static function can_use_captcha( $version = null ) {
return false;
}

// Only use v2 if we are in modal checkout context.
// TODO: Remove this check once we have a way to enable v2 for non-modal checkouts.
if (
( 'v2' === $version || 'v2_invisible' === $settings['version'] ) &&
(
! method_exists( 'Newspack_Blocks\Modal_Checkout', 'is_modal_checkout' ) ||
! \Newspack_Blocks\Modal_Checkout::is_modal_checkout()
)
) {
return false;
}

if ( empty( self::get_site_key() ) || empty( self::get_site_secret() ) ) {
return false;
}
Expand Down Expand Up @@ -510,6 +498,20 @@ function refreshToken() {
public static function verify_recaptcha_on_checkout() {
$url = \home_url( \add_query_arg( null, null ) );
$should_verify_captcha = apply_filters( 'newspack_recaptcha_verify_captcha', self::can_use_captcha(), $url );
$version = self::get_setting( 'version' );

// Only use v2 if we are in modal checkout context.
// TODO: Remove this check once we have a way to enable v2 for non-modal checkouts.
if (
( 'v2' === $version || 'v2_invisible' === $version ) &&
(
! method_exists( 'Newspack_Blocks\Modal_Checkout', 'is_modal_checkout' ) ||
! \Newspack_Blocks\Modal_Checkout::is_modal_checkout()
)
) {
$should_verify_captcha = false;
}

if ( ! $should_verify_captcha ) {
return;
}
Expand Down
237 changes: 56 additions & 181 deletions src/other-scripts/recaptcha/index.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,16 @@
/* globals jQuery, grecaptcha, newspack_recaptcha_data */

import {
addErrorMessage,
addHiddenV3Field,
destroyV3Field,
domReady,
getIntersectionObserver,
refreshV2Widget,
removeErrorMessages
} from './utils';
import './style.scss';

/**
* Specify a function to execute when the DOM is fully loaded.
*
* @see https://github.com/WordPress/gutenberg/blob/trunk/packages/dom-ready/
*
* @param {Function} callback A function to execute after the DOM is ready.
* @return {void}
*/
function domReady( callback ) {
if ( typeof document === 'undefined' ) {
return;
}
if (
document.readyState === 'complete' || // DOMContentLoaded + Images/Styles/etc loaded, so we call directly.
document.readyState === 'interactive' // DOMContentLoaded fires at this point, so we call directly.
) {
return void callback();
}
// DOMContentLoaded has not fired yet, delay callback until then.
document.addEventListener( 'DOMContentLoaded', callback );
}

window.newspack_grecaptcha = window.newspack_grecaptcha || {
destroy: destroyV3Field,
render,
Expand All @@ -35,87 +22,6 @@ const isV3 = 'v3' === newspack_recaptcha_data.version;
const siteKey = newspack_recaptcha_data.site_key;
const isInvisible = 'v2_invisible' === newspack_recaptcha_data.version;

/**
* Destroy hidden reCAPTCHA v3 token fields to avoid unnecessary reCAPTCHA checks.
*/
function destroyV3Field( forms = [] ) {
if ( isV3 ) {
const formsToHandle = forms.length
? forms
: [ ...document.querySelectorAll( 'form[data-newspack-recaptcha]' ) ];

formsToHandle.forEach( form => {
removeHiddenV3Field( form );
} );
}
}

/**
* Refresh the reCAPTCHA v3 token for the given form and action.
*
* @param {HTMLElement} field The hidden input field storing the token for a form.
* @param {string} action The action name to pass to reCAPTCHA.
*
* @return {Promise<void>|void} A promise that resolves when the token is refreshed.
*/
function refreshV3Token( field, action = 'submit' ) {
if ( field ) {
// Get a token to pass to the server. See https://developers.google.com/recaptcha/docs/v3 for API reference.
return grecaptcha.execute( siteKey, { action } ).then( token => {
field.value = token;
} );
}
}

/**
* Append a hidden reCAPTCHA v3 token field to the given form.
*
* @param {HTMLElement} form The form element.
*/
function addHiddenV3Field( form ) {
let field = form.querySelector( 'input[name="g-recaptcha-response"]' );
if ( ! field ) {
field = document.createElement( 'input' );
field.type = 'hidden';
field.name = 'g-recaptcha-response';
form.appendChild( field );

const action = form.getAttribute( 'data-newspack-recaptcha' ) || 'submit';
refreshV3Token( field, action );
setInterval( () => refreshV3Token( field, action ), 30000 ); // Refresh token every 30 seconds.

// Refresh reCAPTCHAs on Woo checkout update and error.
if ( jQuery ) {
jQuery( document ).on( 'updated_checkout', () => refreshV3Token( field, action ) );
jQuery( document.body ).on( 'checkout_error', () => refreshV3Token( field, action ) );
}
}
}

/**
* Remove the hidden reCAPTCHA v3 token field from the given form.
*
* @param {HTMLElement} form The form element.
*/
function removeHiddenV3Field( form ) {
const field = form.querySelector( 'input[name="g-recaptcha-response"]' );
if ( field ) {
field.parentElement.removeChild( field );
}
}

/**
* Refresh the reCAPTCHA v2 widget attached to the given element.
*
* @param {HTMLElement} el Element with the reCAPTCHA widget to refresh.
*/
function refreshV2Widget( el ) {
const widgetId = parseInt( el.getAttribute( 'data-recaptcha-widget-id' ) );
if ( ! isNaN( widgetId ) ) {
grecaptcha.reset( widgetId );
}
}

/**
* Render reCAPTCHA v2 widget on the given form.
*
Expand All @@ -139,14 +45,18 @@ function renderV2Widget( form, onSuccess = null, onError = null ) {
if ( button.hasAttribute( 'data-skip-recaptcha' ) ) {
return;
}
// Refresh widget if it already exists.
if ( button.hasAttribute( 'data-recaptcha-widget-id' ) ) {
refreshV2Widget( button );
return;
}
// Callback when reCAPTCHA passes validation or skip flag is present.
const successCallback = () => {
onSuccess?.()
const successCallback = token => {
onSuccess?.();
// Ensure the token gets submitted with the form submission.
let hiddenField = form.querySelector( '[name="g-recaptcha-response"]' );
if ( ! hiddenField ) {
hiddenField = document.createElement( 'input' );
hiddenField.type = 'hidden';
hiddenField.name = 'g-recaptcha-response';
form.appendChild( hiddenField );
}
hiddenField.value = token;
form.requestSubmit( button );
refreshV2Widget( button );
};
Expand All @@ -166,76 +76,51 @@ function renderV2Widget( form, onSuccess = null, onError = null ) {
}
}
}
// Attach widget to form events.
const attachListeners = () => {
getIntersectionObserver( () => renderV2Widget( form, onSuccess, onError ) ).observe( form, { attributes: true } );
button.addEventListener( 'click', e => {
e.preventDefault();
e.stopImmediatePropagation();
// Empty error messages if present.
removeErrorMessages( form );
// Skip reCAPTCHA verification if the button has a data-skip-recaptcha attribute.
if ( button.hasAttribute( 'data-skip-recaptcha' ) ) {
successCallback();
} else {
grecaptcha.execute( widgetId ).then( () => {
// If we are in an iframe scroll to top.
if ( window?.location !== window?.parent?.location ) {
document.body.scrollIntoView( { behavior: 'smooth' } );
}
} );
}
} );
}
// Refresh reCAPTCHA widgets on Woo checkout update and error.
if ( jQuery ) {
jQuery( document ).on( 'updated_checkout', () => attachListeners );
jQuery( document.body ).on( 'checkout_error', () => attachListeners );
}
// Refresh widget if it already exists.
if ( button.hasAttribute( 'data-recaptcha-widget-id' ) ) {
refreshV2Widget( button );
return;
}
const container = document.createElement( 'div' );
container.classList.add( 'grecaptcha-container' );
button.parentElement.append( container );
document.body.append( container );
const widgetId = grecaptcha.render( container, {
...options,
callback: successCallback,
'error-callback': errorCallback,
'expired-callback': errorCallback,
} );
button.setAttribute( 'data-recaptcha-widget-id', widgetId );

// Refresh reCAPTCHA widgets on Woo checkout update and error.
if ( jQuery ) {
jQuery( document ).on( 'updated_checkout', () => renderV2Widget( form, onSuccess, onError ) );
jQuery( document.body ).on( 'checkout_error', () => renderV2Widget( form, onSuccess, onError ) );
}

button.addEventListener( 'click', e => {
e.preventDefault();
e.stopImmediatePropagation();
// Empty error messages if present.
removeErrorMessages( form );
// Skip reCAPTCHA verification if the button has a data-skip-recaptcha attribute.
if ( button.hasAttribute( 'data-skip-recaptcha' ) ) {
successCallback();
} else {
grecaptcha.execute( widgetId ).then( () => {
// If we are in an iframe scroll to top.
if ( window?.location !== window?.parent?.location ) {
document.body.scrollIntoView( { behavior: 'smooth' } );
}
} );
}
} );
attachListeners();
} );
}

/**
* Append a generic error message above the given form.
*
* @param {HTMLElement} form The form element.
* @param {string} message The error message to display.
*/
function addErrorMessage( form, message ) {
const errorText = document.createElement( 'p' );
errorText.textContent = message;
const container = document.createElement( 'div' );
container.classList.add( 'newspack-recaptcha-error' );
container.appendChild( errorText );
// Newsletters block errors render below the form.
if ( form.parentElement.classList.contains( 'newspack-newsletters-subscribe' ) ) {
form.append( container );
} else {
container.classList.add( 'newspack-ui__notice', 'newspack-ui__notice--error' );
form.insertBefore( container, form.firstChild );
}
}

/**
* Remove generic error messages from form if present.
*
* @param {HTMLElement} form The form element.
*/
function removeErrorMessages( form ) {
const errors = form.querySelectorAll( '.newspack-recaptcha-error' );
for ( const error of errors ) {
error.parentElement.removeChild( error );
}
}

/**
* Render reCAPTCHA elements.
*
Expand All @@ -254,25 +139,15 @@ function render( forms = [], onSuccess = null, onError = null ) {
: [ ...document.querySelectorAll( 'form[data-newspack-recaptcha]' ) ];

formsToHandle.forEach( form => {
if ( ! form.hasAttribute( 'data-recaptcha-rendered' ) ) {
form.addEventListener( 'focusin', () => {
if ( isV2 ) {
renderV2Widget( form, onSuccess, onError );
}
if ( isV3 ) {
addHiddenV3Field( form );
}
} );
form.setAttribute( 'data-recaptcha-rendered', 'true' );
} else {
// Call render methods to trigger refresh.
const renderForm = () => {
if ( isV2 ) {
renderV2Widget( form, onSuccess, onError );
}
if ( isV3 ) {
addHiddenV3Field( form );
}
}
};
getIntersectionObserver( renderForm ).observe( form, { attributes: true } );
} );
}

Expand Down
Loading

0 comments on commit 97583e0

Please sign in to comment.