@silverassist/recaptcha 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,39 @@ var RECAPTCHA_CONFIG = {
17
17
  /** Default token refresh interval */
18
18
  tokenRefreshInterval: DEFAULT_TOKEN_REFRESH_INTERVAL
19
19
  };
20
+ function loadRecaptchaScript(siteKey, onLoad, onError) {
21
+ if (typeof window !== "undefined" && window.__recaptchaLoaded) {
22
+ onLoad();
23
+ return;
24
+ }
25
+ if (typeof window !== "undefined" && window.__recaptchaLoading) {
26
+ window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];
27
+ window.__recaptchaCallbacks.push({ onLoad, onError });
28
+ return;
29
+ }
30
+ if (typeof window !== "undefined") {
31
+ window.__recaptchaLoading = true;
32
+ window.__recaptchaCallbacks = [];
33
+ const script = document.createElement("script");
34
+ script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`;
35
+ script.async = true;
36
+ script.onload = () => {
37
+ window.__recaptchaLoaded = true;
38
+ window.__recaptchaLoading = false;
39
+ onLoad();
40
+ window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());
41
+ window.__recaptchaCallbacks = [];
42
+ };
43
+ script.onerror = () => {
44
+ window.__recaptchaLoading = false;
45
+ const error = new Error("Failed to load reCAPTCHA script");
46
+ onError(error);
47
+ window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));
48
+ window.__recaptchaCallbacks = [];
49
+ };
50
+ document.head.appendChild(script);
51
+ }
52
+ }
20
53
  function RecaptchaWrapper({
21
54
  action,
22
55
  inputName = "recaptchaToken",
@@ -24,34 +57,60 @@ function RecaptchaWrapper({
24
57
  siteKey: propSiteKey,
25
58
  refreshInterval = RECAPTCHA_CONFIG.tokenRefreshInterval,
26
59
  onTokenGenerated,
27
- onError
60
+ onError,
61
+ lazy = false,
62
+ lazyRootMargin = "200px"
28
63
  }) {
29
64
  const siteKey = propSiteKey ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;
30
65
  const tokenInputRef = react.useRef(null);
31
66
  const refreshIntervalRef = react.useRef(null);
67
+ const containerRef = react.useRef(null);
68
+ const isMountedRef = react.useRef(true);
69
+ const [isVisible, setIsVisible] = react.useState(!lazy);
70
+ const [scriptLoaded, setScriptLoaded] = react.useState(false);
32
71
  const executeRecaptcha = react.useCallback(async () => {
33
72
  if (!siteKey) {
34
73
  return;
35
74
  }
36
75
  try {
37
- if (typeof window !== "undefined" && window.grecaptcha) {
38
- window.grecaptcha.ready(async () => {
39
- try {
40
- const token = await window.grecaptcha.execute(siteKey, { action });
41
- if (tokenInputRef.current) {
42
- tokenInputRef.current.value = token;
43
- }
44
- if (onTokenGenerated) {
45
- onTokenGenerated(token);
46
- }
47
- } catch (error) {
48
- console.error("[reCAPTCHA] Error executing reCAPTCHA:", error);
49
- if (onError && error instanceof Error) {
50
- onError(error);
51
- }
76
+ const waitForGrecaptcha = async (maxAttempts = 20, delayMs = 100) => {
77
+ for (let i = 0; i < maxAttempts; i++) {
78
+ if (!isMountedRef.current) {
79
+ return false;
52
80
  }
53
- });
81
+ if (typeof window !== "undefined" && window.grecaptcha) {
82
+ return true;
83
+ }
84
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
85
+ }
86
+ return false;
87
+ };
88
+ const grecaptchaAvailable = await waitForGrecaptcha();
89
+ if (!isMountedRef.current || !grecaptchaAvailable) {
90
+ return;
54
91
  }
92
+ window.grecaptcha.ready(async () => {
93
+ if (!isMountedRef.current) {
94
+ return;
95
+ }
96
+ try {
97
+ const token = await window.grecaptcha.execute(siteKey, { action });
98
+ if (!isMountedRef.current) {
99
+ return;
100
+ }
101
+ if (tokenInputRef.current) {
102
+ tokenInputRef.current.value = token;
103
+ }
104
+ if (onTokenGenerated) {
105
+ onTokenGenerated(token);
106
+ }
107
+ } catch (error) {
108
+ console.error("[reCAPTCHA] Error executing reCAPTCHA:", error);
109
+ if (onError && error instanceof Error) {
110
+ onError(error);
111
+ }
112
+ }
113
+ });
55
114
  } catch (error) {
56
115
  console.error("[reCAPTCHA] Error:", error);
57
116
  if (onError && error instanceof Error) {
@@ -60,7 +119,49 @@ function RecaptchaWrapper({
60
119
  }
61
120
  }, [siteKey, action, onTokenGenerated, onError]);
62
121
  react.useEffect(() => {
63
- executeRecaptcha();
122
+ if (!lazy || !containerRef.current) return;
123
+ if (typeof IntersectionObserver === "undefined") {
124
+ setIsVisible(true);
125
+ return;
126
+ }
127
+ const observer = new IntersectionObserver(
128
+ ([entry]) => {
129
+ if (entry.isIntersecting) {
130
+ setIsVisible(true);
131
+ observer.disconnect();
132
+ }
133
+ },
134
+ { rootMargin: lazyRootMargin }
135
+ );
136
+ observer.observe(containerRef.current);
137
+ return () => observer.disconnect();
138
+ }, [lazy, lazyRootMargin]);
139
+ react.useEffect(() => {
140
+ if (!siteKey) return;
141
+ if (lazy) return;
142
+ if (typeof window !== "undefined" && !window.__recaptchaLoaded && !window.__recaptchaLoading) {
143
+ window.__recaptchaLoading = true;
144
+ window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];
145
+ }
146
+ }, [siteKey, lazy]);
147
+ react.useEffect(() => {
148
+ if (!siteKey) return;
149
+ if (!lazy) return;
150
+ if (!isVisible) return;
151
+ const handleLoad = () => {
152
+ setScriptLoaded(true);
153
+ executeRecaptcha();
154
+ };
155
+ const handleError = (error) => {
156
+ console.error("[reCAPTCHA] Failed to load reCAPTCHA script");
157
+ if (onError) {
158
+ onError(error);
159
+ }
160
+ };
161
+ loadRecaptchaScript(siteKey, handleLoad, handleError);
162
+ }, [siteKey, lazy, isVisible, executeRecaptcha, onError]);
163
+ react.useEffect(() => {
164
+ if (!scriptLoaded) return;
64
165
  refreshIntervalRef.current = setInterval(() => {
65
166
  executeRecaptcha();
66
167
  }, refreshInterval);
@@ -69,7 +170,13 @@ function RecaptchaWrapper({
69
170
  clearInterval(refreshIntervalRef.current);
70
171
  }
71
172
  };
72
- }, [executeRecaptcha, refreshInterval]);
173
+ }, [scriptLoaded, executeRecaptcha, refreshInterval]);
174
+ react.useEffect(() => {
175
+ isMountedRef.current = true;
176
+ return () => {
177
+ isMountedRef.current = false;
178
+ };
179
+ }, []);
73
180
  if (!siteKey) {
74
181
  if (process.env.NODE_ENV === "development") {
75
182
  console.warn(
@@ -78,7 +185,7 @@ function RecaptchaWrapper({
78
185
  }
79
186
  return null;
80
187
  }
81
- return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
188
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: containerRef, style: { display: "contents" }, children: [
82
189
  /* @__PURE__ */ jsxRuntime.jsx(
83
190
  "input",
84
191
  {
@@ -89,15 +196,28 @@ function RecaptchaWrapper({
89
196
  "data-testid": "recaptcha-token-input"
90
197
  }
91
198
  ),
92
- /* @__PURE__ */ jsxRuntime.jsx(
199
+ !lazy && /* @__PURE__ */ jsxRuntime.jsx(
93
200
  Script__default.default,
94
201
  {
95
202
  src: `https://www.google.com/recaptcha/api.js?render=${siteKey}`,
96
203
  strategy: "afterInteractive",
97
204
  onLoad: () => {
205
+ if (typeof window !== "undefined") {
206
+ window.__recaptchaLoaded = true;
207
+ window.__recaptchaLoading = false;
208
+ window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());
209
+ window.__recaptchaCallbacks = [];
210
+ }
211
+ setScriptLoaded(true);
98
212
  executeRecaptcha();
99
213
  },
100
214
  onError: () => {
215
+ if (typeof window !== "undefined") {
216
+ window.__recaptchaLoading = false;
217
+ const error = new Error("Failed to load reCAPTCHA script");
218
+ window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));
219
+ window.__recaptchaCallbacks = [];
220
+ }
101
221
  console.error("[reCAPTCHA] Failed to load reCAPTCHA script");
102
222
  if (onError) {
103
223
  onError(new Error("Failed to load reCAPTCHA script"));
@@ -108,6 +228,27 @@ function RecaptchaWrapper({
108
228
  ] });
109
229
  }
110
230
  var client_default = RecaptchaWrapper;
231
+ /**
232
+ * @module @silverassist/recaptcha/constants
233
+ * @description reCAPTCHA Configuration Constants - Default configuration values
234
+ * for reCAPTCHA v3 integration.
235
+ *
236
+ * @author Miguel Colmenares <me@miguelcolmenares.com>
237
+ * @license Polyform-Noncommercial-1.0.0
238
+ * @version 0.2.1
239
+ * @see {@link https://github.com/SilverAssist/recaptcha|GitHub Repository}
240
+ */
241
+ /**
242
+ * @module @silverassist/recaptcha/client
243
+ * @description reCAPTCHA v3 Client Component - Loads the Google reCAPTCHA script
244
+ * and generates tokens automatically. Place inside a form to add invisible spam protection.
245
+ *
246
+ * @author Miguel Colmenares <me@miguelcolmenares.com>
247
+ * @license Polyform-Noncommercial-1.0.0
248
+ * @version 0.2.1
249
+ * @see {@link https://developers.google.com/recaptcha/docs/v3|Google reCAPTCHA v3 Documentation}
250
+ * @see {@link https://github.com/SilverAssist/recaptcha|GitHub Repository}
251
+ */
111
252
 
112
253
  exports.RecaptchaWrapper = RecaptchaWrapper;
113
254
  exports.default = client_default;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/constants/index.ts","../../src/client/index.tsx"],"names":["useRef","useCallback","useEffect","jsxs","Fragment","jsx","Script"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * reCAPTCHA Configuration Constants\n *\n * Default configuration values for reCAPTCHA v3 integration.\n *\n * @packageDocumentation\n */\n\nimport type { RecaptchaConfig } from \"../types\";\n\n/**\n * Default score threshold for validation\n * Scores below this value are considered suspicious\n * Range: 0.0 (bot) to 1.0 (human)\n */\nexport const DEFAULT_SCORE_THRESHOLD = 0.5;\n\n/**\n * Token refresh interval in milliseconds\n * reCAPTCHA tokens expire after 2 minutes, so we refresh at 90 seconds\n * to ensure tokens are always valid when forms are submitted\n */\nexport const DEFAULT_TOKEN_REFRESH_INTERVAL = 90000;\n\n/**\n * reCAPTCHA v3 configuration constants\n */\nexport const RECAPTCHA_CONFIG: RecaptchaConfig = {\n /** Google reCAPTCHA verification endpoint */\n verifyUrl: \"https://www.google.com/recaptcha/api/siteverify\",\n /** Default score threshold for validation */\n defaultScoreThreshold: DEFAULT_SCORE_THRESHOLD,\n /** Default token refresh interval */\n tokenRefreshInterval: DEFAULT_TOKEN_REFRESH_INTERVAL,\n} as const;\n","/**\n * reCAPTCHA v3 Client Component\n *\n * Loads the Google reCAPTCHA script and generates tokens automatically.\n * Place inside a form to add invisible spam protection.\n *\n * @see https://developers.google.com/recaptcha/docs/v3\n * @packageDocumentation\n */\n\n\"use client\";\n\nimport Script from \"next/script\";\nimport { useCallback, useEffect, useRef } from \"react\";\nimport type { RecaptchaWrapperProps } from \"../types\";\nimport { RECAPTCHA_CONFIG } from \"../constants\";\n\n/**\n * RecaptchaWrapper - Client component for reCAPTCHA v3 integration\n *\n * Features:\n * - Loads reCAPTCHA script automatically\n * - Generates token when script loads\n * - Refreshes token periodically (tokens expire after 2 minutes)\n * - Stores token in hidden input field for form submission\n * - Graceful fallback when not configured\n *\n * @example Basic usage\n * ```tsx\n * <form action={formAction}>\n * <RecaptchaWrapper action=\"contact_form\" />\n * <input name=\"email\" type=\"email\" required />\n * <button type=\"submit\">Submit</button>\n * </form>\n * ```\n *\n * @example Custom input name\n * ```tsx\n * <RecaptchaWrapper\n * action=\"signup\"\n * inputName=\"captchaToken\"\n * inputId=\"signup-captcha\"\n * />\n * ```\n *\n * @example With callbacks\n * ```tsx\n * <RecaptchaWrapper\n * action=\"payment\"\n * onTokenGenerated={(token) => console.log(\"Token:\", token)}\n * onError={(error) => console.error(\"Error:\", error)}\n * />\n * ```\n */\nexport function RecaptchaWrapper({\n action,\n inputName = \"recaptchaToken\",\n inputId = \"recaptcha-token\",\n siteKey: propSiteKey,\n refreshInterval = RECAPTCHA_CONFIG.tokenRefreshInterval,\n onTokenGenerated,\n onError,\n}: RecaptchaWrapperProps) {\n // Use prop siteKey or fall back to environment variable\n const siteKey = propSiteKey ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;\n const tokenInputRef = useRef<HTMLInputElement>(null);\n const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null);\n\n // Execute reCAPTCHA and store token\n const executeRecaptcha = useCallback(async () => {\n if (!siteKey) {\n return;\n }\n\n try {\n if (typeof window !== \"undefined\" && window.grecaptcha) {\n window.grecaptcha.ready(async () => {\n try {\n const token = await window.grecaptcha.execute(siteKey, { action });\n\n // Store token in hidden input\n if (tokenInputRef.current) {\n tokenInputRef.current.value = token;\n }\n\n // Call callback if provided\n if (onTokenGenerated) {\n onTokenGenerated(token);\n }\n } catch (error) {\n console.error(\"[reCAPTCHA] Error executing reCAPTCHA:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n });\n }\n } catch (error) {\n console.error(\"[reCAPTCHA] Error:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n }, [siteKey, action, onTokenGenerated, onError]);\n\n // Set up token refresh interval (tokens expire after 2 minutes)\n useEffect(() => {\n // Generate token immediately when component mounts\n executeRecaptcha();\n\n // Set up refresh interval\n refreshIntervalRef.current = setInterval(() => {\n executeRecaptcha();\n }, refreshInterval);\n\n // Cleanup interval on unmount\n return () => {\n if (refreshIntervalRef.current) {\n clearInterval(refreshIntervalRef.current);\n }\n };\n }, [executeRecaptcha, refreshInterval]);\n\n // Don't render anything if site key is not configured\n if (!siteKey) {\n if (process.env.NODE_ENV === \"development\") {\n console.warn(\n \"[reCAPTCHA] Site key not configured. Set NEXT_PUBLIC_RECAPTCHA_SITE_KEY environment variable.\"\n );\n }\n return null;\n }\n\n return (\n <>\n {/* Hidden input to store the token */}\n <input\n ref={tokenInputRef}\n type=\"hidden\"\n name={inputName}\n id={inputId}\n data-testid=\"recaptcha-token-input\"\n />\n\n {/* Load reCAPTCHA script */}\n <Script\n src={`https://www.google.com/recaptcha/api.js?render=${siteKey}`}\n strategy=\"afterInteractive\"\n onLoad={() => {\n executeRecaptcha();\n }}\n onError={() => {\n console.error(\"[reCAPTCHA] Failed to load reCAPTCHA script\");\n if (onError) {\n onError(new Error(\"Failed to load reCAPTCHA script\"));\n }\n }}\n />\n </>\n );\n}\n\nexport default RecaptchaWrapper;\n"]}
1
+ {"version":3,"sources":["../../src/constants/index.ts","../../src/client/index.tsx"],"names":["useRef","useState","useCallback","useEffect","jsxs","jsx","Script"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * @module @silverassist/recaptcha/constants\n * @description reCAPTCHA Configuration Constants - Default configuration values\n * for reCAPTCHA v3 integration.\n *\n * @author Miguel Colmenares <me@miguelcolmenares.com>\n * @license Polyform-Noncommercial-1.0.0\n * @version 0.2.1\n * @see {@link https://github.com/SilverAssist/recaptcha|GitHub Repository}\n */\n\nimport type { RecaptchaConfig } from \"../types\";\n\n/**\n * Default score threshold for validation\n * Scores below this value are considered suspicious\n * Range: 0.0 (bot) to 1.0 (human)\n */\nexport const DEFAULT_SCORE_THRESHOLD = 0.5;\n\n/**\n * Token refresh interval in milliseconds\n * reCAPTCHA tokens expire after 2 minutes, so we refresh at 90 seconds\n * to ensure tokens are always valid when forms are submitted\n */\nexport const DEFAULT_TOKEN_REFRESH_INTERVAL = 90000;\n\n/**\n * reCAPTCHA v3 configuration constants\n */\nexport const RECAPTCHA_CONFIG: RecaptchaConfig = {\n /** Google reCAPTCHA verification endpoint */\n verifyUrl: \"https://www.google.com/recaptcha/api/siteverify\",\n /** Default score threshold for validation */\n defaultScoreThreshold: DEFAULT_SCORE_THRESHOLD,\n /** Default token refresh interval */\n tokenRefreshInterval: DEFAULT_TOKEN_REFRESH_INTERVAL,\n} as const;\n","/**\n * @module @silverassist/recaptcha/client\n * @description reCAPTCHA v3 Client Component - Loads the Google reCAPTCHA script\n * and generates tokens automatically. Place inside a form to add invisible spam protection.\n *\n * @author Miguel Colmenares <me@miguelcolmenares.com>\n * @license Polyform-Noncommercial-1.0.0\n * @version 0.2.1\n * @see {@link https://developers.google.com/recaptcha/docs/v3|Google reCAPTCHA v3 Documentation}\n * @see {@link https://github.com/SilverAssist/recaptcha|GitHub Repository}\n */\n\n\"use client\";\n\nimport Script from \"next/script\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { RecaptchaWrapperProps } from \"../types\";\nimport { RECAPTCHA_CONFIG } from \"../constants\";\n\n/**\n * Load reCAPTCHA script manually (singleton pattern)\n * Ensures script is only loaded once globally\n */\nfunction loadRecaptchaScript(\n siteKey: string,\n onLoad: () => void,\n onError: (error: Error) => void\n): void {\n // Already loaded\n if (typeof window !== \"undefined\" && window.__recaptchaLoaded) {\n onLoad();\n return;\n }\n\n // Currently loading - add callbacks\n if (typeof window !== \"undefined\" && window.__recaptchaLoading) {\n window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];\n window.__recaptchaCallbacks.push({ onLoad, onError });\n return;\n }\n\n // Start loading\n if (typeof window !== \"undefined\") {\n window.__recaptchaLoading = true;\n window.__recaptchaCallbacks = [];\n\n const script = document.createElement(\"script\");\n script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`;\n script.async = true;\n\n script.onload = () => {\n window.__recaptchaLoaded = true;\n window.__recaptchaLoading = false;\n onLoad();\n window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());\n window.__recaptchaCallbacks = [];\n };\n\n script.onerror = () => {\n window.__recaptchaLoading = false;\n const error = new Error(\"Failed to load reCAPTCHA script\");\n onError(error);\n // Notify all queued callbacks about the failure\n window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));\n window.__recaptchaCallbacks = [];\n };\n\n document.head.appendChild(script);\n }\n}\n\n/**\n * RecaptchaWrapper - Client component for reCAPTCHA v3 integration\n *\n * Features:\n * - Loads reCAPTCHA script automatically\n * - Generates token when script loads\n * - Refreshes token periodically (tokens expire after 2 minutes)\n * - Stores token in hidden input field for form submission\n * - Graceful fallback when not configured\n * - Lazy loading support to defer script loading until visible\n *\n * @example Basic usage\n * ```tsx\n * <form action={formAction}>\n * <RecaptchaWrapper action=\"contact_form\" />\n * <input name=\"email\" type=\"email\" required />\n * <button type=\"submit\">Submit</button>\n * </form>\n * ```\n *\n * @example Custom input name\n * ```tsx\n * <RecaptchaWrapper\n * action=\"signup\"\n * inputName=\"captchaToken\"\n * inputId=\"signup-captcha\"\n * />\n * ```\n *\n * @example With callbacks\n * ```tsx\n * // IMPORTANT: Memoize callbacks to prevent unnecessary re-renders\n * const handleToken = useCallback((token: string) => {\n * console.log(\"Token:\", token);\n * }, []);\n *\n * const handleError = useCallback((error: Error) => {\n * console.error(\"Error:\", error);\n * }, []);\n *\n * <RecaptchaWrapper\n * action=\"payment\"\n * onTokenGenerated={handleToken}\n * onError={handleError}\n * />\n * ```\n *\n * @example Lazy loading for better performance\n * ```tsx\n * <RecaptchaWrapper action=\"contact_form\" lazy />\n * ```\n *\n * @example Lazy loading with custom root margin\n * ```tsx\n * <RecaptchaWrapper action=\"contact_form\" lazy lazyRootMargin=\"400px\" />\n * ```\n */\nexport function RecaptchaWrapper({\n action,\n inputName = \"recaptchaToken\",\n inputId = \"recaptcha-token\",\n siteKey: propSiteKey,\n refreshInterval = RECAPTCHA_CONFIG.tokenRefreshInterval,\n onTokenGenerated,\n onError,\n lazy = false,\n lazyRootMargin = \"200px\",\n}: RecaptchaWrapperProps) {\n // Use prop siteKey or fall back to environment variable\n const siteKey = propSiteKey ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;\n const tokenInputRef = useRef<HTMLInputElement>(null);\n const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null);\n const containerRef = useRef<HTMLDivElement>(null);\n const isMountedRef = useRef<boolean>(true);\n const [isVisible, setIsVisible] = useState(!lazy); // If not lazy, start visible\n const [scriptLoaded, setScriptLoaded] = useState(false);\n\n // Execute reCAPTCHA and store token\n const executeRecaptcha = useCallback(async () => {\n if (!siteKey) {\n return;\n }\n\n try {\n // Wait for grecaptcha to be available (with timeout)\n // This handles the race condition where the script loads but\n // window.grecaptcha is not immediately available\n const waitForGrecaptcha = async (maxAttempts = 20, delayMs = 100): Promise<boolean> => {\n for (let i = 0; i < maxAttempts; i++) {\n // Check if component is still mounted\n if (!isMountedRef.current) {\n return false;\n }\n \n if (typeof window !== \"undefined\" && window.grecaptcha) {\n return true;\n }\n await new Promise((resolve) => setTimeout(resolve, delayMs));\n }\n return false;\n };\n\n const grecaptchaAvailable = await waitForGrecaptcha();\n\n // Exit early if component unmounted during polling\n if (!isMountedRef.current || !grecaptchaAvailable) {\n return;\n }\n\n window.grecaptcha.ready(async () => {\n // Check if still mounted before executing\n if (!isMountedRef.current) {\n return;\n }\n\n try {\n const token = await window.grecaptcha.execute(siteKey, { action });\n\n // Check if still mounted before storing token\n if (!isMountedRef.current) {\n return;\n }\n\n // Store token in hidden input\n if (tokenInputRef.current) {\n tokenInputRef.current.value = token;\n }\n\n // Call callback if provided\n if (onTokenGenerated) {\n onTokenGenerated(token);\n }\n } catch (error) {\n console.error(\"[reCAPTCHA] Error executing reCAPTCHA:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n });\n } catch (error) {\n console.error(\"[reCAPTCHA] Error:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n }, [siteKey, action, onTokenGenerated, onError]);\n\n // IntersectionObserver for lazy loading\n useEffect(() => {\n if (!lazy || !containerRef.current) return;\n\n // Fallback to eager loading if IntersectionObserver is not supported\n // (older browsers, some SSR/test environments)\n if (typeof IntersectionObserver === \"undefined\") {\n setIsVisible(true);\n return;\n }\n\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (entry.isIntersecting) {\n setIsVisible(true);\n observer.disconnect();\n }\n },\n { rootMargin: lazyRootMargin }\n );\n\n observer.observe(containerRef.current);\n return () => observer.disconnect();\n }, [lazy, lazyRootMargin]);\n\n // Mark loading flag for non-lazy mode to prevent duplicate loads\n useEffect(() => {\n if (!siteKey) return;\n if (lazy) return; // Only for non-lazy mode\n\n // Set loading flag before Script component loads\n if (typeof window !== \"undefined\" && !window.__recaptchaLoaded && !window.__recaptchaLoading) {\n window.__recaptchaLoading = true;\n window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];\n }\n }, [siteKey, lazy]);\n\n // Load script when visible (only for lazy mode)\n useEffect(() => {\n if (!siteKey) return;\n if (!lazy) return; // Only use manual loading for lazy mode\n if (!isVisible) return; // Wait until visible\n\n const handleLoad = () => {\n setScriptLoaded(true);\n executeRecaptcha();\n };\n\n const handleError = (error: Error) => {\n console.error(\"[reCAPTCHA] Failed to load reCAPTCHA script\");\n if (onError) {\n onError(error);\n }\n };\n\n loadRecaptchaScript(siteKey, handleLoad, handleError);\n }, [siteKey, lazy, isVisible, executeRecaptcha, onError]);\n\n // Set up token refresh interval (tokens expire after 2 minutes)\n useEffect(() => {\n // Only set up refresh if script is loaded\n if (!scriptLoaded) return;\n\n // Set up refresh interval\n refreshIntervalRef.current = setInterval(() => {\n executeRecaptcha();\n }, refreshInterval);\n\n // Cleanup interval on unmount\n return () => {\n if (refreshIntervalRef.current) {\n clearInterval(refreshIntervalRef.current);\n }\n };\n }, [scriptLoaded, executeRecaptcha, refreshInterval]);\n\n // Track mounted state to prevent side effects after unmount\n useEffect(() => {\n isMountedRef.current = true;\n \n return () => {\n isMountedRef.current = false;\n };\n }, []);\n\n // Don't render anything if site key is not configured\n if (!siteKey) {\n if (process.env.NODE_ENV === \"development\") {\n console.warn(\n \"[reCAPTCHA] Site key not configured. Set NEXT_PUBLIC_RECAPTCHA_SITE_KEY environment variable.\"\n );\n }\n return null;\n }\n\n return (\n <div ref={containerRef} style={{ display: \"contents\" }}>\n {/* \n Note: display: contents makes this wrapper transparent to the DOM layout.\n The wrapper is needed for IntersectionObserver but shouldn't affect form layout.\n Browser support: https://caniuse.com/css-display-contents\n */}\n {/* Hidden input to store the token */}\n <input\n ref={tokenInputRef}\n type=\"hidden\"\n name={inputName}\n id={inputId}\n data-testid=\"recaptcha-token-input\"\n />\n\n {/* Load reCAPTCHA script using Next.js Script component for non-lazy mode */}\n {!lazy && (\n <Script\n src={`https://www.google.com/recaptcha/api.js?render=${siteKey}`}\n strategy=\"afterInteractive\"\n onLoad={() => {\n // Mark script as loaded globally for singleton behavior\n if (typeof window !== \"undefined\") {\n window.__recaptchaLoaded = true;\n window.__recaptchaLoading = false;\n // Flush all queued callbacks from lazy instances\n window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());\n window.__recaptchaCallbacks = [];\n }\n setScriptLoaded(true);\n executeRecaptcha();\n }}\n onError={() => {\n // Mark loading as complete on error\n if (typeof window !== \"undefined\") {\n window.__recaptchaLoading = false;\n // Notify all queued callbacks about the failure\n const error = new Error(\"Failed to load reCAPTCHA script\");\n window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));\n window.__recaptchaCallbacks = [];\n }\n console.error(\"[reCAPTCHA] Failed to load reCAPTCHA script\");\n if (onError) {\n onError(new Error(\"Failed to load reCAPTCHA script\"));\n }\n }}\n />\n )}\n </div>\n );\n}\n\nexport default RecaptchaWrapper;\n"]}
@@ -1,14 +1,47 @@
1
1
  "use client";
2
2
 
3
3
  import Script from 'next/script';
4
- import { useRef, useCallback, useEffect } from 'react';
5
- import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
4
+ import { useRef, useState, useCallback, useEffect } from 'react';
5
+ import { jsxs, jsx } from 'react/jsx-runtime';
6
6
 
7
7
  var DEFAULT_TOKEN_REFRESH_INTERVAL = 9e4;
8
8
  var RECAPTCHA_CONFIG = {
9
9
  /** Default token refresh interval */
10
10
  tokenRefreshInterval: DEFAULT_TOKEN_REFRESH_INTERVAL
11
11
  };
12
+ function loadRecaptchaScript(siteKey, onLoad, onError) {
13
+ if (typeof window !== "undefined" && window.__recaptchaLoaded) {
14
+ onLoad();
15
+ return;
16
+ }
17
+ if (typeof window !== "undefined" && window.__recaptchaLoading) {
18
+ window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];
19
+ window.__recaptchaCallbacks.push({ onLoad, onError });
20
+ return;
21
+ }
22
+ if (typeof window !== "undefined") {
23
+ window.__recaptchaLoading = true;
24
+ window.__recaptchaCallbacks = [];
25
+ const script = document.createElement("script");
26
+ script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`;
27
+ script.async = true;
28
+ script.onload = () => {
29
+ window.__recaptchaLoaded = true;
30
+ window.__recaptchaLoading = false;
31
+ onLoad();
32
+ window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());
33
+ window.__recaptchaCallbacks = [];
34
+ };
35
+ script.onerror = () => {
36
+ window.__recaptchaLoading = false;
37
+ const error = new Error("Failed to load reCAPTCHA script");
38
+ onError(error);
39
+ window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));
40
+ window.__recaptchaCallbacks = [];
41
+ };
42
+ document.head.appendChild(script);
43
+ }
44
+ }
12
45
  function RecaptchaWrapper({
13
46
  action,
14
47
  inputName = "recaptchaToken",
@@ -16,34 +49,60 @@ function RecaptchaWrapper({
16
49
  siteKey: propSiteKey,
17
50
  refreshInterval = RECAPTCHA_CONFIG.tokenRefreshInterval,
18
51
  onTokenGenerated,
19
- onError
52
+ onError,
53
+ lazy = false,
54
+ lazyRootMargin = "200px"
20
55
  }) {
21
56
  const siteKey = propSiteKey ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;
22
57
  const tokenInputRef = useRef(null);
23
58
  const refreshIntervalRef = useRef(null);
59
+ const containerRef = useRef(null);
60
+ const isMountedRef = useRef(true);
61
+ const [isVisible, setIsVisible] = useState(!lazy);
62
+ const [scriptLoaded, setScriptLoaded] = useState(false);
24
63
  const executeRecaptcha = useCallback(async () => {
25
64
  if (!siteKey) {
26
65
  return;
27
66
  }
28
67
  try {
29
- if (typeof window !== "undefined" && window.grecaptcha) {
30
- window.grecaptcha.ready(async () => {
31
- try {
32
- const token = await window.grecaptcha.execute(siteKey, { action });
33
- if (tokenInputRef.current) {
34
- tokenInputRef.current.value = token;
35
- }
36
- if (onTokenGenerated) {
37
- onTokenGenerated(token);
38
- }
39
- } catch (error) {
40
- console.error("[reCAPTCHA] Error executing reCAPTCHA:", error);
41
- if (onError && error instanceof Error) {
42
- onError(error);
43
- }
68
+ const waitForGrecaptcha = async (maxAttempts = 20, delayMs = 100) => {
69
+ for (let i = 0; i < maxAttempts; i++) {
70
+ if (!isMountedRef.current) {
71
+ return false;
44
72
  }
45
- });
73
+ if (typeof window !== "undefined" && window.grecaptcha) {
74
+ return true;
75
+ }
76
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
77
+ }
78
+ return false;
79
+ };
80
+ const grecaptchaAvailable = await waitForGrecaptcha();
81
+ if (!isMountedRef.current || !grecaptchaAvailable) {
82
+ return;
46
83
  }
84
+ window.grecaptcha.ready(async () => {
85
+ if (!isMountedRef.current) {
86
+ return;
87
+ }
88
+ try {
89
+ const token = await window.grecaptcha.execute(siteKey, { action });
90
+ if (!isMountedRef.current) {
91
+ return;
92
+ }
93
+ if (tokenInputRef.current) {
94
+ tokenInputRef.current.value = token;
95
+ }
96
+ if (onTokenGenerated) {
97
+ onTokenGenerated(token);
98
+ }
99
+ } catch (error) {
100
+ console.error("[reCAPTCHA] Error executing reCAPTCHA:", error);
101
+ if (onError && error instanceof Error) {
102
+ onError(error);
103
+ }
104
+ }
105
+ });
47
106
  } catch (error) {
48
107
  console.error("[reCAPTCHA] Error:", error);
49
108
  if (onError && error instanceof Error) {
@@ -52,7 +111,49 @@ function RecaptchaWrapper({
52
111
  }
53
112
  }, [siteKey, action, onTokenGenerated, onError]);
54
113
  useEffect(() => {
55
- executeRecaptcha();
114
+ if (!lazy || !containerRef.current) return;
115
+ if (typeof IntersectionObserver === "undefined") {
116
+ setIsVisible(true);
117
+ return;
118
+ }
119
+ const observer = new IntersectionObserver(
120
+ ([entry]) => {
121
+ if (entry.isIntersecting) {
122
+ setIsVisible(true);
123
+ observer.disconnect();
124
+ }
125
+ },
126
+ { rootMargin: lazyRootMargin }
127
+ );
128
+ observer.observe(containerRef.current);
129
+ return () => observer.disconnect();
130
+ }, [lazy, lazyRootMargin]);
131
+ useEffect(() => {
132
+ if (!siteKey) return;
133
+ if (lazy) return;
134
+ if (typeof window !== "undefined" && !window.__recaptchaLoaded && !window.__recaptchaLoading) {
135
+ window.__recaptchaLoading = true;
136
+ window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];
137
+ }
138
+ }, [siteKey, lazy]);
139
+ useEffect(() => {
140
+ if (!siteKey) return;
141
+ if (!lazy) return;
142
+ if (!isVisible) return;
143
+ const handleLoad = () => {
144
+ setScriptLoaded(true);
145
+ executeRecaptcha();
146
+ };
147
+ const handleError = (error) => {
148
+ console.error("[reCAPTCHA] Failed to load reCAPTCHA script");
149
+ if (onError) {
150
+ onError(error);
151
+ }
152
+ };
153
+ loadRecaptchaScript(siteKey, handleLoad, handleError);
154
+ }, [siteKey, lazy, isVisible, executeRecaptcha, onError]);
155
+ useEffect(() => {
156
+ if (!scriptLoaded) return;
56
157
  refreshIntervalRef.current = setInterval(() => {
57
158
  executeRecaptcha();
58
159
  }, refreshInterval);
@@ -61,7 +162,13 @@ function RecaptchaWrapper({
61
162
  clearInterval(refreshIntervalRef.current);
62
163
  }
63
164
  };
64
- }, [executeRecaptcha, refreshInterval]);
165
+ }, [scriptLoaded, executeRecaptcha, refreshInterval]);
166
+ useEffect(() => {
167
+ isMountedRef.current = true;
168
+ return () => {
169
+ isMountedRef.current = false;
170
+ };
171
+ }, []);
65
172
  if (!siteKey) {
66
173
  if (process.env.NODE_ENV === "development") {
67
174
  console.warn(
@@ -70,7 +177,7 @@ function RecaptchaWrapper({
70
177
  }
71
178
  return null;
72
179
  }
73
- return /* @__PURE__ */ jsxs(Fragment, { children: [
180
+ return /* @__PURE__ */ jsxs("div", { ref: containerRef, style: { display: "contents" }, children: [
74
181
  /* @__PURE__ */ jsx(
75
182
  "input",
76
183
  {
@@ -81,15 +188,28 @@ function RecaptchaWrapper({
81
188
  "data-testid": "recaptcha-token-input"
82
189
  }
83
190
  ),
84
- /* @__PURE__ */ jsx(
191
+ !lazy && /* @__PURE__ */ jsx(
85
192
  Script,
86
193
  {
87
194
  src: `https://www.google.com/recaptcha/api.js?render=${siteKey}`,
88
195
  strategy: "afterInteractive",
89
196
  onLoad: () => {
197
+ if (typeof window !== "undefined") {
198
+ window.__recaptchaLoaded = true;
199
+ window.__recaptchaLoading = false;
200
+ window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());
201
+ window.__recaptchaCallbacks = [];
202
+ }
203
+ setScriptLoaded(true);
90
204
  executeRecaptcha();
91
205
  },
92
206
  onError: () => {
207
+ if (typeof window !== "undefined") {
208
+ window.__recaptchaLoading = false;
209
+ const error = new Error("Failed to load reCAPTCHA script");
210
+ window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));
211
+ window.__recaptchaCallbacks = [];
212
+ }
93
213
  console.error("[reCAPTCHA] Failed to load reCAPTCHA script");
94
214
  if (onError) {
95
215
  onError(new Error("Failed to load reCAPTCHA script"));
@@ -100,6 +220,27 @@ function RecaptchaWrapper({
100
220
  ] });
101
221
  }
102
222
  var client_default = RecaptchaWrapper;
223
+ /**
224
+ * @module @silverassist/recaptcha/constants
225
+ * @description reCAPTCHA Configuration Constants - Default configuration values
226
+ * for reCAPTCHA v3 integration.
227
+ *
228
+ * @author Miguel Colmenares <me@miguelcolmenares.com>
229
+ * @license Polyform-Noncommercial-1.0.0
230
+ * @version 0.2.1
231
+ * @see {@link https://github.com/SilverAssist/recaptcha|GitHub Repository}
232
+ */
233
+ /**
234
+ * @module @silverassist/recaptcha/client
235
+ * @description reCAPTCHA v3 Client Component - Loads the Google reCAPTCHA script
236
+ * and generates tokens automatically. Place inside a form to add invisible spam protection.
237
+ *
238
+ * @author Miguel Colmenares <me@miguelcolmenares.com>
239
+ * @license Polyform-Noncommercial-1.0.0
240
+ * @version 0.2.1
241
+ * @see {@link https://developers.google.com/recaptcha/docs/v3|Google reCAPTCHA v3 Documentation}
242
+ * @see {@link https://github.com/SilverAssist/recaptcha|GitHub Repository}
243
+ */
103
244
 
104
245
  export { RecaptchaWrapper, client_default as default };
105
246
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/constants/index.ts","../../src/client/index.tsx"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * reCAPTCHA Configuration Constants\n *\n * Default configuration values for reCAPTCHA v3 integration.\n *\n * @packageDocumentation\n */\n\nimport type { RecaptchaConfig } from \"../types\";\n\n/**\n * Default score threshold for validation\n * Scores below this value are considered suspicious\n * Range: 0.0 (bot) to 1.0 (human)\n */\nexport const DEFAULT_SCORE_THRESHOLD = 0.5;\n\n/**\n * Token refresh interval in milliseconds\n * reCAPTCHA tokens expire after 2 minutes, so we refresh at 90 seconds\n * to ensure tokens are always valid when forms are submitted\n */\nexport const DEFAULT_TOKEN_REFRESH_INTERVAL = 90000;\n\n/**\n * reCAPTCHA v3 configuration constants\n */\nexport const RECAPTCHA_CONFIG: RecaptchaConfig = {\n /** Google reCAPTCHA verification endpoint */\n verifyUrl: \"https://www.google.com/recaptcha/api/siteverify\",\n /** Default score threshold for validation */\n defaultScoreThreshold: DEFAULT_SCORE_THRESHOLD,\n /** Default token refresh interval */\n tokenRefreshInterval: DEFAULT_TOKEN_REFRESH_INTERVAL,\n} as const;\n","/**\n * reCAPTCHA v3 Client Component\n *\n * Loads the Google reCAPTCHA script and generates tokens automatically.\n * Place inside a form to add invisible spam protection.\n *\n * @see https://developers.google.com/recaptcha/docs/v3\n * @packageDocumentation\n */\n\n\"use client\";\n\nimport Script from \"next/script\";\nimport { useCallback, useEffect, useRef } from \"react\";\nimport type { RecaptchaWrapperProps } from \"../types\";\nimport { RECAPTCHA_CONFIG } from \"../constants\";\n\n/**\n * RecaptchaWrapper - Client component for reCAPTCHA v3 integration\n *\n * Features:\n * - Loads reCAPTCHA script automatically\n * - Generates token when script loads\n * - Refreshes token periodically (tokens expire after 2 minutes)\n * - Stores token in hidden input field for form submission\n * - Graceful fallback when not configured\n *\n * @example Basic usage\n * ```tsx\n * <form action={formAction}>\n * <RecaptchaWrapper action=\"contact_form\" />\n * <input name=\"email\" type=\"email\" required />\n * <button type=\"submit\">Submit</button>\n * </form>\n * ```\n *\n * @example Custom input name\n * ```tsx\n * <RecaptchaWrapper\n * action=\"signup\"\n * inputName=\"captchaToken\"\n * inputId=\"signup-captcha\"\n * />\n * ```\n *\n * @example With callbacks\n * ```tsx\n * <RecaptchaWrapper\n * action=\"payment\"\n * onTokenGenerated={(token) => console.log(\"Token:\", token)}\n * onError={(error) => console.error(\"Error:\", error)}\n * />\n * ```\n */\nexport function RecaptchaWrapper({\n action,\n inputName = \"recaptchaToken\",\n inputId = \"recaptcha-token\",\n siteKey: propSiteKey,\n refreshInterval = RECAPTCHA_CONFIG.tokenRefreshInterval,\n onTokenGenerated,\n onError,\n}: RecaptchaWrapperProps) {\n // Use prop siteKey or fall back to environment variable\n const siteKey = propSiteKey ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;\n const tokenInputRef = useRef<HTMLInputElement>(null);\n const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null);\n\n // Execute reCAPTCHA and store token\n const executeRecaptcha = useCallback(async () => {\n if (!siteKey) {\n return;\n }\n\n try {\n if (typeof window !== \"undefined\" && window.grecaptcha) {\n window.grecaptcha.ready(async () => {\n try {\n const token = await window.grecaptcha.execute(siteKey, { action });\n\n // Store token in hidden input\n if (tokenInputRef.current) {\n tokenInputRef.current.value = token;\n }\n\n // Call callback if provided\n if (onTokenGenerated) {\n onTokenGenerated(token);\n }\n } catch (error) {\n console.error(\"[reCAPTCHA] Error executing reCAPTCHA:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n });\n }\n } catch (error) {\n console.error(\"[reCAPTCHA] Error:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n }, [siteKey, action, onTokenGenerated, onError]);\n\n // Set up token refresh interval (tokens expire after 2 minutes)\n useEffect(() => {\n // Generate token immediately when component mounts\n executeRecaptcha();\n\n // Set up refresh interval\n refreshIntervalRef.current = setInterval(() => {\n executeRecaptcha();\n }, refreshInterval);\n\n // Cleanup interval on unmount\n return () => {\n if (refreshIntervalRef.current) {\n clearInterval(refreshIntervalRef.current);\n }\n };\n }, [executeRecaptcha, refreshInterval]);\n\n // Don't render anything if site key is not configured\n if (!siteKey) {\n if (process.env.NODE_ENV === \"development\") {\n console.warn(\n \"[reCAPTCHA] Site key not configured. Set NEXT_PUBLIC_RECAPTCHA_SITE_KEY environment variable.\"\n );\n }\n return null;\n }\n\n return (\n <>\n {/* Hidden input to store the token */}\n <input\n ref={tokenInputRef}\n type=\"hidden\"\n name={inputName}\n id={inputId}\n data-testid=\"recaptcha-token-input\"\n />\n\n {/* Load reCAPTCHA script */}\n <Script\n src={`https://www.google.com/recaptcha/api.js?render=${siteKey}`}\n strategy=\"afterInteractive\"\n onLoad={() => {\n executeRecaptcha();\n }}\n onError={() => {\n console.error(\"[reCAPTCHA] Failed to load reCAPTCHA script\");\n if (onError) {\n onError(new Error(\"Failed to load reCAPTCHA script\"));\n }\n }}\n />\n </>\n );\n}\n\nexport default RecaptchaWrapper;\n"]}
1
+ {"version":3,"sources":["../../src/constants/index.ts","../../src/client/index.tsx"],"names":[],"mappings":"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","file":"index.mjs","sourcesContent":["/**\n * @module @silverassist/recaptcha/constants\n * @description reCAPTCHA Configuration Constants - Default configuration values\n * for reCAPTCHA v3 integration.\n *\n * @author Miguel Colmenares <me@miguelcolmenares.com>\n * @license Polyform-Noncommercial-1.0.0\n * @version 0.2.1\n * @see {@link https://github.com/SilverAssist/recaptcha|GitHub Repository}\n */\n\nimport type { RecaptchaConfig } from \"../types\";\n\n/**\n * Default score threshold for validation\n * Scores below this value are considered suspicious\n * Range: 0.0 (bot) to 1.0 (human)\n */\nexport const DEFAULT_SCORE_THRESHOLD = 0.5;\n\n/**\n * Token refresh interval in milliseconds\n * reCAPTCHA tokens expire after 2 minutes, so we refresh at 90 seconds\n * to ensure tokens are always valid when forms are submitted\n */\nexport const DEFAULT_TOKEN_REFRESH_INTERVAL = 90000;\n\n/**\n * reCAPTCHA v3 configuration constants\n */\nexport const RECAPTCHA_CONFIG: RecaptchaConfig = {\n /** Google reCAPTCHA verification endpoint */\n verifyUrl: \"https://www.google.com/recaptcha/api/siteverify\",\n /** Default score threshold for validation */\n defaultScoreThreshold: DEFAULT_SCORE_THRESHOLD,\n /** Default token refresh interval */\n tokenRefreshInterval: DEFAULT_TOKEN_REFRESH_INTERVAL,\n} as const;\n","/**\n * @module @silverassist/recaptcha/client\n * @description reCAPTCHA v3 Client Component - Loads the Google reCAPTCHA script\n * and generates tokens automatically. Place inside a form to add invisible spam protection.\n *\n * @author Miguel Colmenares <me@miguelcolmenares.com>\n * @license Polyform-Noncommercial-1.0.0\n * @version 0.2.1\n * @see {@link https://developers.google.com/recaptcha/docs/v3|Google reCAPTCHA v3 Documentation}\n * @see {@link https://github.com/SilverAssist/recaptcha|GitHub Repository}\n */\n\n\"use client\";\n\nimport Script from \"next/script\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { RecaptchaWrapperProps } from \"../types\";\nimport { RECAPTCHA_CONFIG } from \"../constants\";\n\n/**\n * Load reCAPTCHA script manually (singleton pattern)\n * Ensures script is only loaded once globally\n */\nfunction loadRecaptchaScript(\n siteKey: string,\n onLoad: () => void,\n onError: (error: Error) => void\n): void {\n // Already loaded\n if (typeof window !== \"undefined\" && window.__recaptchaLoaded) {\n onLoad();\n return;\n }\n\n // Currently loading - add callbacks\n if (typeof window !== \"undefined\" && window.__recaptchaLoading) {\n window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];\n window.__recaptchaCallbacks.push({ onLoad, onError });\n return;\n }\n\n // Start loading\n if (typeof window !== \"undefined\") {\n window.__recaptchaLoading = true;\n window.__recaptchaCallbacks = [];\n\n const script = document.createElement(\"script\");\n script.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`;\n script.async = true;\n\n script.onload = () => {\n window.__recaptchaLoaded = true;\n window.__recaptchaLoading = false;\n onLoad();\n window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());\n window.__recaptchaCallbacks = [];\n };\n\n script.onerror = () => {\n window.__recaptchaLoading = false;\n const error = new Error(\"Failed to load reCAPTCHA script\");\n onError(error);\n // Notify all queued callbacks about the failure\n window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));\n window.__recaptchaCallbacks = [];\n };\n\n document.head.appendChild(script);\n }\n}\n\n/**\n * RecaptchaWrapper - Client component for reCAPTCHA v3 integration\n *\n * Features:\n * - Loads reCAPTCHA script automatically\n * - Generates token when script loads\n * - Refreshes token periodically (tokens expire after 2 minutes)\n * - Stores token in hidden input field for form submission\n * - Graceful fallback when not configured\n * - Lazy loading support to defer script loading until visible\n *\n * @example Basic usage\n * ```tsx\n * <form action={formAction}>\n * <RecaptchaWrapper action=\"contact_form\" />\n * <input name=\"email\" type=\"email\" required />\n * <button type=\"submit\">Submit</button>\n * </form>\n * ```\n *\n * @example Custom input name\n * ```tsx\n * <RecaptchaWrapper\n * action=\"signup\"\n * inputName=\"captchaToken\"\n * inputId=\"signup-captcha\"\n * />\n * ```\n *\n * @example With callbacks\n * ```tsx\n * // IMPORTANT: Memoize callbacks to prevent unnecessary re-renders\n * const handleToken = useCallback((token: string) => {\n * console.log(\"Token:\", token);\n * }, []);\n *\n * const handleError = useCallback((error: Error) => {\n * console.error(\"Error:\", error);\n * }, []);\n *\n * <RecaptchaWrapper\n * action=\"payment\"\n * onTokenGenerated={handleToken}\n * onError={handleError}\n * />\n * ```\n *\n * @example Lazy loading for better performance\n * ```tsx\n * <RecaptchaWrapper action=\"contact_form\" lazy />\n * ```\n *\n * @example Lazy loading with custom root margin\n * ```tsx\n * <RecaptchaWrapper action=\"contact_form\" lazy lazyRootMargin=\"400px\" />\n * ```\n */\nexport function RecaptchaWrapper({\n action,\n inputName = \"recaptchaToken\",\n inputId = \"recaptcha-token\",\n siteKey: propSiteKey,\n refreshInterval = RECAPTCHA_CONFIG.tokenRefreshInterval,\n onTokenGenerated,\n onError,\n lazy = false,\n lazyRootMargin = \"200px\",\n}: RecaptchaWrapperProps) {\n // Use prop siteKey or fall back to environment variable\n const siteKey = propSiteKey ?? process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;\n const tokenInputRef = useRef<HTMLInputElement>(null);\n const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null);\n const containerRef = useRef<HTMLDivElement>(null);\n const isMountedRef = useRef<boolean>(true);\n const [isVisible, setIsVisible] = useState(!lazy); // If not lazy, start visible\n const [scriptLoaded, setScriptLoaded] = useState(false);\n\n // Execute reCAPTCHA and store token\n const executeRecaptcha = useCallback(async () => {\n if (!siteKey) {\n return;\n }\n\n try {\n // Wait for grecaptcha to be available (with timeout)\n // This handles the race condition where the script loads but\n // window.grecaptcha is not immediately available\n const waitForGrecaptcha = async (maxAttempts = 20, delayMs = 100): Promise<boolean> => {\n for (let i = 0; i < maxAttempts; i++) {\n // Check if component is still mounted\n if (!isMountedRef.current) {\n return false;\n }\n \n if (typeof window !== \"undefined\" && window.grecaptcha) {\n return true;\n }\n await new Promise((resolve) => setTimeout(resolve, delayMs));\n }\n return false;\n };\n\n const grecaptchaAvailable = await waitForGrecaptcha();\n\n // Exit early if component unmounted during polling\n if (!isMountedRef.current || !grecaptchaAvailable) {\n return;\n }\n\n window.grecaptcha.ready(async () => {\n // Check if still mounted before executing\n if (!isMountedRef.current) {\n return;\n }\n\n try {\n const token = await window.grecaptcha.execute(siteKey, { action });\n\n // Check if still mounted before storing token\n if (!isMountedRef.current) {\n return;\n }\n\n // Store token in hidden input\n if (tokenInputRef.current) {\n tokenInputRef.current.value = token;\n }\n\n // Call callback if provided\n if (onTokenGenerated) {\n onTokenGenerated(token);\n }\n } catch (error) {\n console.error(\"[reCAPTCHA] Error executing reCAPTCHA:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n });\n } catch (error) {\n console.error(\"[reCAPTCHA] Error:\", error);\n if (onError && error instanceof Error) {\n onError(error);\n }\n }\n }, [siteKey, action, onTokenGenerated, onError]);\n\n // IntersectionObserver for lazy loading\n useEffect(() => {\n if (!lazy || !containerRef.current) return;\n\n // Fallback to eager loading if IntersectionObserver is not supported\n // (older browsers, some SSR/test environments)\n if (typeof IntersectionObserver === \"undefined\") {\n setIsVisible(true);\n return;\n }\n\n const observer = new IntersectionObserver(\n ([entry]) => {\n if (entry.isIntersecting) {\n setIsVisible(true);\n observer.disconnect();\n }\n },\n { rootMargin: lazyRootMargin }\n );\n\n observer.observe(containerRef.current);\n return () => observer.disconnect();\n }, [lazy, lazyRootMargin]);\n\n // Mark loading flag for non-lazy mode to prevent duplicate loads\n useEffect(() => {\n if (!siteKey) return;\n if (lazy) return; // Only for non-lazy mode\n\n // Set loading flag before Script component loads\n if (typeof window !== \"undefined\" && !window.__recaptchaLoaded && !window.__recaptchaLoading) {\n window.__recaptchaLoading = true;\n window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];\n }\n }, [siteKey, lazy]);\n\n // Load script when visible (only for lazy mode)\n useEffect(() => {\n if (!siteKey) return;\n if (!lazy) return; // Only use manual loading for lazy mode\n if (!isVisible) return; // Wait until visible\n\n const handleLoad = () => {\n setScriptLoaded(true);\n executeRecaptcha();\n };\n\n const handleError = (error: Error) => {\n console.error(\"[reCAPTCHA] Failed to load reCAPTCHA script\");\n if (onError) {\n onError(error);\n }\n };\n\n loadRecaptchaScript(siteKey, handleLoad, handleError);\n }, [siteKey, lazy, isVisible, executeRecaptcha, onError]);\n\n // Set up token refresh interval (tokens expire after 2 minutes)\n useEffect(() => {\n // Only set up refresh if script is loaded\n if (!scriptLoaded) return;\n\n // Set up refresh interval\n refreshIntervalRef.current = setInterval(() => {\n executeRecaptcha();\n }, refreshInterval);\n\n // Cleanup interval on unmount\n return () => {\n if (refreshIntervalRef.current) {\n clearInterval(refreshIntervalRef.current);\n }\n };\n }, [scriptLoaded, executeRecaptcha, refreshInterval]);\n\n // Track mounted state to prevent side effects after unmount\n useEffect(() => {\n isMountedRef.current = true;\n \n return () => {\n isMountedRef.current = false;\n };\n }, []);\n\n // Don't render anything if site key is not configured\n if (!siteKey) {\n if (process.env.NODE_ENV === \"development\") {\n console.warn(\n \"[reCAPTCHA] Site key not configured. Set NEXT_PUBLIC_RECAPTCHA_SITE_KEY environment variable.\"\n );\n }\n return null;\n }\n\n return (\n <div ref={containerRef} style={{ display: \"contents\" }}>\n {/* \n Note: display: contents makes this wrapper transparent to the DOM layout.\n The wrapper is needed for IntersectionObserver but shouldn't affect form layout.\n Browser support: https://caniuse.com/css-display-contents\n */}\n {/* Hidden input to store the token */}\n <input\n ref={tokenInputRef}\n type=\"hidden\"\n name={inputName}\n id={inputId}\n data-testid=\"recaptcha-token-input\"\n />\n\n {/* Load reCAPTCHA script using Next.js Script component for non-lazy mode */}\n {!lazy && (\n <Script\n src={`https://www.google.com/recaptcha/api.js?render=${siteKey}`}\n strategy=\"afterInteractive\"\n onLoad={() => {\n // Mark script as loaded globally for singleton behavior\n if (typeof window !== \"undefined\") {\n window.__recaptchaLoaded = true;\n window.__recaptchaLoading = false;\n // Flush all queued callbacks from lazy instances\n window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());\n window.__recaptchaCallbacks = [];\n }\n setScriptLoaded(true);\n executeRecaptcha();\n }}\n onError={() => {\n // Mark loading as complete on error\n if (typeof window !== \"undefined\") {\n window.__recaptchaLoading = false;\n // Notify all queued callbacks about the failure\n const error = new Error(\"Failed to load reCAPTCHA script\");\n window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));\n window.__recaptchaCallbacks = [];\n }\n console.error(\"[reCAPTCHA] Failed to load reCAPTCHA script\");\n if (onError) {\n onError(new Error(\"Failed to load reCAPTCHA script\"));\n }\n }}\n />\n )}\n </div>\n );\n}\n\nexport default RecaptchaWrapper;\n"]}