Skip to content

Commit

Permalink
fix: render v2 widgets on form visible; create utils module
Browse files Browse the repository at this point in the history
  • Loading branch information
dkoo committed Jan 6, 2025
1 parent 9407179 commit dca8d9a
Show file tree
Hide file tree
Showing 2 changed files with 188 additions and 139 deletions.
152 changes: 13 additions & 139 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 Down Expand Up @@ -172,7 +78,7 @@ function renderV2Widget( form, onSuccess = null, onError = null ) {
}
// Attach widget to form events.
const attachListeners = () => {
form.addEventListener( 'focusin', () => renderV2Widget( form, onSuccess, onError ) );
getIntersectionObserver( () => renderV2Widget( form, onSuccess, onError ) ).observe( form, { attributes: true } );
button.addEventListener( 'click', e => {
e.preventDefault();
e.stopImmediatePropagation();
Expand Down Expand Up @@ -215,39 +121,6 @@ function renderV2Widget( form, onSuccess = null, onError = null ) {
} );
}

/**
* 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 @@ -266,14 +139,15 @@ function render( forms = [], onSuccess = null, onError = null ) {
: [ ...document.querySelectorAll( 'form[data-newspack-recaptcha]' ) ];

formsToHandle.forEach( form => {
form.addEventListener( 'focusin', () => {
const renderForm = () => {
if ( isV2 ) {
renderV2Widget( form, onSuccess, onError );
}
if ( isV3 ) {
addHiddenV3Field( form );
}
} );
};
getIntersectionObserver( renderForm ).observe( form, { attributes: true } );
} );
}

Expand Down
175 changes: 175 additions & 0 deletions src/other-scripts/recaptcha/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/* globals jQuery, grecaptcha, newspack_recaptcha_data */

// The minimum continuous amount of time an element must be in the viewport before being considered visible.
const MINIMUM_VISIBLE_TIME = 250;

// The minimum percentage of an element that must be in the viewport before being considered visible.
const MINIMUM_VISIBLE_PERCENTAGE = 0.5;

/**
* 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}
*/
export 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 );
}

/**
* Create an IntersectionObserver to execute function `handleEvent` when an element becomes visible.
*
* @param {Function} handleEvent
* @return {IntersectionObserver} Observer instance.
*/
export function getIntersectionObserver( handleEvent ) {
let timer;
const observer = new IntersectionObserver(
entries => {
entries.forEach( observerEntry => {
if ( observerEntry.isIntersecting ) {
if ( ! timer ) {
timer = setTimeout( () => {
handleEvent();
observer.unobserve( observerEntry.target );
}, MINIMUM_VISIBLE_TIME || 0 );
}
} else if ( timer ) {
clearTimeout( timer );
timer = false;
}
} );
},
{
threshold: MINIMUM_VISIBLE_PERCENTAGE,
}
);

return observer;
};

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

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

/**
* Append a hidden reCAPTCHA v3 token field to the given form.
*
* @param {HTMLElement} form The form element.
*/
export 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 ) );
}
}
}

/**
* 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 ) {
const siteKey = newspack_recaptcha_data?.site_key;

// 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;
} );
}
}

/**
* 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.
*/
export function refreshV2Widget( el ) {
const widgetId = parseInt( el.getAttribute( 'data-recaptcha-widget-id' ) );
if ( ! isNaN( widgetId ) ) {
grecaptcha.reset( widgetId );
}
}

/**
* Append a generic error message above the given form.
*
* @param {HTMLElement} form The form element.
* @param {string} message The error message to display.
*/
export 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.
*/
export function removeErrorMessages( form ) {
const errors = form.querySelectorAll( '.newspack-recaptcha-error' );
for ( const error of errors ) {
error.parentElement.removeChild( error );
}
}

0 comments on commit dca8d9a

Please sign in to comment.