PageSourceSearch

https://www.shipstation.com/wp-content/themes/shipstation-blocks/js/utils/recaptcha-loader.js?v=1790232222

js shipstation.com collected 2026-09-24 06:52:02 UTC 9,101 bytes, 302 lines download raw bytes

1/**
2 * Lazy-load Google reCAPTCHA script on-demand
3 *
4 * Loads reCAPTCHA only when the form is visible in viewport OR when user interacts with it.
5 * Prevents render-blocking script from impacting LCP performance.
6 * Ensures script only loads once per page.
7 *
8 * @author ShipStation Dev Team
9 * @version 1.0.0
10 */
11(function(window) {
12	'use strict';
13
14	// Global state tracking - prevents duplicate loads
15	let recaptchaLoadState = {
16		loading: false,
17		loaded: false,
18		promise: null
19	};
20
21	const RECAPTCHA_SITE_KEY = '6LeqeuAbAAAAAHNfIhSsRta-WkFzLz3bEcI7NGKt';
22	const RECAPTCHA_SCRIPT_URL = `https://www.google.com/recaptcha/api.js?render=${RECAPTCHA_SITE_KEY}`;
23
24	/**
25	 * Load reCAPTCHA script and return Promise that resolves when ready
26	 * @returns {Promise<void>}
27	 */
28	function loadRecaptcha() {
29		// If already loaded, resolve immediately
30		if (recaptchaLoadState.loaded && window.grecaptcha) {
31			return Promise.resolve();
32		}
33
34		// If currently loading, return existing promise
35		if (recaptchaLoadState.loading && recaptchaLoadState.promise) {
36			return recaptchaLoadState.promise;
37		}
38
39		// Start loading
40		recaptchaLoadState.loading = true;
41		recaptchaLoadState.promise = new Promise((resolve, reject) => {
42			// Check if script already exists in DOM (edge case)
43			const existingScript = document.querySelector(`script[src*="recaptcha/api.js"]`);
44			if (existingScript) {
45				if (window.grecaptcha && window.grecaptcha.ready) {
46					window.grecaptcha.ready(() => {
47						recaptchaLoadState.loaded = true;
48						recaptchaLoadState.loading = false;
49						console.log('[reCAPTCHA] Already loaded and ready');
50						resolve();
51					});
52				} else {
53					// Script tag exists but not yet loaded - wait for it
54					const handleLoad = () => {
55						if (window.grecaptcha && window.grecaptcha.ready) {
56							window.grecaptcha.ready(() => {
57								recaptchaLoadState.loaded = true;
58								recaptchaLoadState.loading = false;
59								console.log('[reCAPTCHA] Existing script loaded and ready');
60								resolve();
61							});
62						} else {
63							recaptchaLoadState.loaded = true;
64							recaptchaLoadState.loading = false;
65							resolve();
66						}
67					};
68
69					const handleError = () => {
70						console.error('[reCAPTCHA] Existing script failed to load');
71						recaptchaLoadState.loading = false;
72						reject(new Error('Existing reCAPTCHA script failed to load'));
73					};
74
75					existingScript.addEventListener('load', handleLoad);
76					existingScript.addEventListener('error', handleError);
77				}
78				return;
79			}
80
81			// Create and inject script
82			const script = document.createElement('script');
83			script.src = RECAPTCHA_SCRIPT_URL;
84			script.async = true;
85			script.defer = true;
86
87			script.onload = function() {
88				// Poll for grecaptcha.ready with fixed interval
89				// Max wait time: 8 attempts × 400ms = 3.2 seconds
90				let attempts = 0;
91				const maxAttempts = 8;
92				const checkInterval = 400; // milliseconds
93
94				const checkReady = () => {
95					attempts++;
96
97					if (window.grecaptcha && window.grecaptcha.ready) {
98						window.grecaptcha.ready(() => {
99							recaptchaLoadState.loaded = true;
100							recaptchaLoadState.loading = false;
101							console.log('[reCAPTCHA] Loaded and ready');
102							resolve();
103						});
104					} else if (attempts >= maxAttempts) {
105						// Timeout after 3.2 seconds (8 × 400ms)
106						console.error('[reCAPTCHA] Script loaded but grecaptcha.ready never became available');
107						recaptchaLoadState.loading = false;
108						reject(new Error('reCAPTCHA script loaded but grecaptcha.ready not available'));
109					} else {
110						// Retry at fixed 400ms intervals
111						setTimeout(checkReady, checkInterval);
112					}
113				};
114
115				checkReady();
116			};
117
118			script.onerror = function() {
119				console.error('[reCAPTCHA] Failed to load script');
120				recaptchaLoadState.loading = false;
121				reject(new Error('Failed to load reCAPTCHA'));
122			};
123
124			document.head.appendChild(script);
125		});
126
127		return recaptchaLoadState.promise;
128	}
129
130	/**
131	 * Initialize lazy-loading for a form element
132	 * @param {string} formSelector - CSS selector for the form
133	 * @param {Object} options - Configuration options
134	 * @param {string} [options.rootMargin='200px'] - Load when form is this far from viewport
135	 * @param {boolean} [options.loadOnInteraction=true] - Also load on form interaction
136	 * @param {string[]} [options.interactionEvents] - Events that trigger loading
137	 */
138	function initRecaptchaLazyLoad(formSelector, options = {}) {
139		const config = {
140			rootMargin: '200px',
141			loadOnInteraction: true,
142			interactionEvents: ['focus', 'click', 'touchstart'],
143			...options
144		};
145
146		const formElement = document.querySelector(formSelector);
147		if (!formElement) {
148			console.warn(`[reCAPTCHA] Form element not found: ${formSelector}`);
149			return;
150		}
151
152		let hasTriggeredLoad = false;
153
154		// Strategy 1: IntersectionObserver (load when form enters viewport)
155		if ('IntersectionObserver' in window) {
156			const observer = new IntersectionObserver((entries) => {
157				entries.forEach(entry => {
158					if (entry.isIntersecting && !hasTriggeredLoad) {
159						hasTriggeredLoad = true;
160						console.log('[reCAPTCHA] Form in viewport, loading...');
161						loadRecaptcha();
162						observer.disconnect(); // Stop observing once loaded
163					}
164				});
165			}, {
166				rootMargin: config.rootMargin
167			});
168
169			observer.observe(formElement);
170		} else {
171			// Fallback for browsers without IntersectionObserver (IE11, old Safari)
172			// Load immediately on page load
173			console.log('[reCAPTCHA] IntersectionObserver not supported, loading immediately');
174			loadRecaptcha();
175			hasTriggeredLoad = true;
176		}
177
178		// Strategy 2: Interaction-based loading (focus, click, touch)
179		// This catches the case where user jumps directly to form before scrolling
180		if (config.loadOnInteraction) {
181			const handleInteraction = () => {
182				if (!hasTriggeredLoad) {
183					hasTriggeredLoad = true;
184					console.log('[reCAPTCHA] Form interaction detected, loading...');
185					loadRecaptcha();
186					// Remove listeners after first trigger
187					config.interactionEvents.forEach(event => {
188						formElement.removeEventListener(event, handleInteraction, true);
189					});
190				}
191			};
192
193			// Use capture phase to catch events on form children (inputs, buttons)
194			config.interactionEvents.forEach(event => {
195				formElement.addEventListener(event, handleInteraction, true);
196			});
197		}
198	}
199
200	/**
201	 * Ensure reCAPTCHA is loaded, wait if necessary
202	 * Call this before form submission to guarantee reCAPTCHA is ready
203	 * @returns {Promise<void>}
204	 */
205	function ensureRecaptchaLoaded() {
206		if (recaptchaLoadState.loaded && window.grecaptcha) {
207			return Promise.resolve();
208		}
209
210		if (recaptchaLoadState.loading && recaptchaLoadState.promise) {
211			return recaptchaLoadState.promise;
212		}
213
214		// Not yet triggered, load now
215		console.log('[reCAPTCHA] Force loading for form submission');
216		return loadRecaptcha();
217	}
218
219	/**
220	 * Ensure reCAPTCHA is loaded, then mint a v3 token for `action`.
221	 *
222	 * This is the single place that turns "reCAPTCHA is available" into an actual
223	 * token, so callers never need the site key. REJECTS (rather than resolving
224	 * with an empty string) when verification is unavailable or token generation
225	 * fails, so callers can fail CLOSED — submitting an empty token silently marks
226	 * the registration as fraudulent upstream.
227	 *
228	 * @param {string} action - reCAPTCHA v3 action name (e.g. 'submit')
229	 * @returns {Promise<string>} the token
230	 */
231	function executeRecaptcha(action) {
232		return ensureRecaptchaLoaded().then(function() {
233			if (!window.grecaptcha || !window.grecaptcha.execute) {
234				return Promise.reject(
235					new Error('grecaptcha unavailable after ensure()')
236				);
237			}
238			return new Promise(function(resolve, reject) {
239				window.grecaptcha.ready(function() {
240					window.grecaptcha
241						.execute(RECAPTCHA_SITE_KEY, { action: action || 'submit' })
242						.then(function(token) {
243							if (token) {
244								resolve(token);
245							} else {
246								reject(new Error('grecaptcha returned an empty token'));
247							}
248						})
249						.catch(reject);
250				});
251			});
252		});
253	}
254
255	// Expose public API
256	window.RecaptchaLazyLoader = {
257		/**
258		 * Initialize lazy-loading for a form
259		 * @param {string} formSelector - CSS selector
260		 * @param {Object} options - Configuration
261		 */
262		init: initRecaptchaLazyLoad,
263
264		/**
265		 * Manually trigger reCAPTCHA load
266		 * @returns {Promise<void>}
267		 */
268		load: loadRecaptcha,
269
270		/**
271		 * Ensure reCAPTCHA is loaded (wait if loading)
272		 * @returns {Promise<void>}
273		 */
274		ensure: ensureRecaptchaLoaded,
275
276		/**
277		 * Ensure loaded, then mint a v3 token. Rejects if unavailable.
278		 * @param {string} action
279		 * @returns {Promise<string>}
280		 */
281		execute: executeRecaptcha,
282
283		/**
284		 * Check if reCAPTCHA is fully loaded
285		 * @returns {boolean}
286		 */
287		isLoaded: () => recaptchaLoadState.loaded,
288
289		/**
290		 * Check if reCAPTCHA is currently loading
291		 * @returns {boolean}
292		 */
293		isLoading: () => recaptchaLoadState.loading,
294
295		/**
296		 * Get the reCAPTCHA site key (single source of truth)
297		 * @returns {string}
298		 */
299		siteKey: RECAPTCHA_SITE_KEY
300	};
301
302})(window);

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.