@silverassist/recaptcha 0.1.0 → 0.2.0

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.0] - 2026-02-02
9
+
10
+ ### Added
11
+
12
+ - **Lazy loading support** for better page performance
13
+ - New `lazy` prop to defer reCAPTCHA script loading until form is visible
14
+ - New `lazyRootMargin` prop to configure IntersectionObserver threshold (default: "200px")
15
+ - Reduces initial JS by ~320KB and improves TBT/TTI metrics
16
+ - **Singleton script loading** prevents duplicate script loads across multiple forms
17
+ - Comprehensive test coverage for lazy loading scenarios
18
+ - Documentation for lazy loading with performance metrics
19
+
20
+ ### Fixed
21
+
22
+ - Add IntersectionObserver feature detection with fallback to eager loading for older browsers/SSR environments
23
+ - Add error handling tests for non-lazy Script component to ensure proper callback notification
24
+ - Queued callbacks now properly receive error notifications on script load failure
25
+
8
26
  ## [0.1.0] - 2026-01-27
9
27
 
10
28
  ### Added
package/README.md CHANGED
@@ -14,6 +14,8 @@ Google reCAPTCHA v3 integration for Next.js applications with Server Actions sup
14
14
  - ✅ **Auto Token Refresh**: Tokens refresh automatically before expiration
15
15
  - ✅ **Graceful Degradation**: Works in development without credentials
16
16
  - ✅ **Configurable Thresholds**: Custom score thresholds per form
17
+ - ✅ **Lazy Loading**: Optional lazy loading for better performance
18
+ - ✅ **Singleton Script Loading**: Prevents duplicate script loads across multiple forms
17
19
 
18
20
  ## Installation
19
21
 
@@ -92,6 +94,197 @@ export async function submitForm(formData: FormData) {
92
94
  }
93
95
  ```
94
96
 
97
+ ## ⚠️ Important: Custom FormData
98
+
99
+ `RecaptchaWrapper` injects a **hidden input field** containing the reCAPTCHA token. If your form handler creates a custom `FormData` object, you must ensure the hidden token is included.
100
+
101
+ ### ❌ This will fail (token is missing):
102
+
103
+ ```tsx
104
+ "use client";
105
+
106
+ import { RecaptchaWrapper } from "@silverassist/recaptcha";
107
+ import { submitForm } from "./actions"; // Your server action
108
+
109
+ export function ContactForm() {
110
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
111
+ e.preventDefault();
112
+
113
+ // ❌ Creating empty FormData - hidden reCAPTCHA input is NOT included!
114
+ const formData = new FormData();
115
+ formData.set("email", "user@example.com");
116
+ formData.set("message", "Hello");
117
+
118
+ await submitForm(formData);
119
+ };
120
+
121
+ return (
122
+ <form onSubmit={handleSubmit}>
123
+ <RecaptchaWrapper action="contact_form" />
124
+ <input name="email" type="email" required />
125
+ <textarea name="message" required />
126
+ <button type="submit">Send</button>
127
+ </form>
128
+ );
129
+ }
130
+ ```
131
+
132
+ ### ✅ Recommended: Pass form element to capture all inputs
133
+
134
+ ```tsx
135
+ "use client";
136
+
137
+ import { RecaptchaWrapper } from "@silverassist/recaptcha";
138
+ import { submitForm } from "./actions"; // Your server action
139
+
140
+ export function ContactForm() {
141
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
142
+ e.preventDefault();
143
+
144
+ // ✅ Pass form element - captures ALL inputs including hidden reCAPTCHA token
145
+ const formData = new FormData(e.currentTarget);
146
+
147
+ await submitForm(formData);
148
+ };
149
+
150
+ return (
151
+ <form onSubmit={handleSubmit}>
152
+ <RecaptchaWrapper action="contact_form" />
153
+ <input name="email" type="email" required />
154
+ <textarea name="message" required />
155
+ <button type="submit">Send</button>
156
+ </form>
157
+ );
158
+ }
159
+ ```
160
+
161
+ ### ✅ Alternative: Start with form element, then modify
162
+
163
+ ```tsx
164
+ "use client";
165
+
166
+ import { RecaptchaWrapper } from "@silverassist/recaptcha";
167
+ import { submitForm } from "./actions"; // Your server action
168
+
169
+ export function ContactForm() {
170
+ const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
171
+ e.preventDefault();
172
+
173
+ // ✅ Start with form element (includes hidden token)
174
+ const formData = new FormData(e.currentTarget);
175
+
176
+ // Then add/override specific fields
177
+ formData.set("customField", "customValue");
178
+ formData.set("timestamp", Date.now().toString());
179
+
180
+ await submitForm(formData);
181
+ };
182
+
183
+ return (
184
+ <form onSubmit={handleSubmit}>
185
+ <RecaptchaWrapper action="contact_form" />
186
+ <input name="email" type="email" required />
187
+ <textarea name="message" required />
188
+ <button type="submit">Send</button>
189
+ </form>
190
+ );
191
+ }
192
+ ```
193
+
194
+ ## Performance Optimization: Lazy Loading
195
+
196
+ The `lazy` prop enables lazy loading of the reCAPTCHA script, which defers loading until the form becomes visible in the viewport. This significantly improves initial page load performance.
197
+
198
+ ### Performance Benefits
199
+
200
+ > **Note**: These metrics are approximate values measured on production websites using Google reCAPTCHA v3. Actual performance improvements will vary based on network conditions, device capabilities, and page complexity.
201
+
202
+ | Metric | Without Lazy Loading | With Lazy Loading | Improvement |
203
+ |--------|---------------------|-------------------|-------------|
204
+ | **Initial JS** | 320KB+ | 0 KB (until visible) | -320KB |
205
+ | **TBT (Total Blocking Time)** | ~470ms | ~0ms (deferred) | -470ms |
206
+ | **TTI (Time to Interactive)** | +2-3s | Minimal impact | -2-3s |
207
+
208
+ ### Basic Lazy Loading
209
+
210
+ Enable lazy loading by adding the `lazy` prop:
211
+
212
+ ```tsx
213
+ "use client";
214
+
215
+ import { RecaptchaWrapper } from "@silverassist/recaptcha";
216
+
217
+ export function ContactForm() {
218
+ return (
219
+ <form action={submitForm}>
220
+ {/* Script loads only when form is near viewport */}
221
+ <RecaptchaWrapper action="contact_form" lazy />
222
+ <input name="email" type="email" required />
223
+ <textarea name="message" required />
224
+ <button type="submit">Send</button>
225
+ </form>
226
+ );
227
+ }
228
+ ```
229
+
230
+ ### Custom Root Margin
231
+
232
+ Control when the script loads with the `lazyRootMargin` prop (default: `"200px"`):
233
+
234
+ ```tsx
235
+ // Load script earlier (400px before entering viewport)
236
+ <RecaptchaWrapper action="contact_form" lazy lazyRootMargin="400px" />
237
+
238
+ // Load script later (load only when fully visible)
239
+ <RecaptchaWrapper action="contact_form" lazy lazyRootMargin="0px" />
240
+
241
+ // Load with negative margin (load only after scrolling past)
242
+ <RecaptchaWrapper action="contact_form" lazy lazyRootMargin="-100px" />
243
+ ```
244
+
245
+ ### Best Practices
246
+
247
+ #### 1. Use lazy loading for below-the-fold forms
248
+
249
+ ```tsx
250
+ // Hero form (above the fold) - load immediately
251
+ <RecaptchaWrapper action="hero_signup" />
252
+
253
+ // Footer form (below the fold) - lazy load
254
+ <RecaptchaWrapper action="footer_contact" lazy />
255
+ ```
256
+
257
+ #### 2. Multiple forms on the same page
258
+
259
+ The package automatically uses singleton script loading, so the script is only loaded once even with multiple forms:
260
+
261
+ ```tsx
262
+ export function MultiFormPage() {
263
+ return (
264
+ <>
265
+ {/* First form triggers script load */}
266
+ <RecaptchaWrapper action="newsletter" lazy />
267
+
268
+ {/* Second form reuses the same script */}
269
+ <RecaptchaWrapper action="contact" lazy />
270
+
271
+ {/* Third form also reuses the script */}
272
+ <RecaptchaWrapper action="feedback" lazy />
273
+ </>
274
+ );
275
+ }
276
+ ```
277
+
278
+ #### 3. Adjust root margin based on form position
279
+
280
+ ```tsx
281
+ // Form near top - smaller margin for faster load
282
+ <RecaptchaWrapper action="signup" lazy lazyRootMargin="100px" />
283
+
284
+ // Form far down page - larger margin to load in advance
285
+ <RecaptchaWrapper action="newsletter" lazy lazyRootMargin="500px" />
286
+ ```
287
+
95
288
  ## API Reference
96
289
 
97
290
  ### RecaptchaWrapper
@@ -107,9 +300,25 @@ Client component that loads reCAPTCHA and generates tokens.
107
300
  refreshInterval={90000} // Optional: token refresh interval in ms (default: 90000)
108
301
  onTokenGenerated={(token) => {}} // Optional: callback when token is generated
109
302
  onError={(error) => {}} // Optional: callback on error
303
+ lazy={false} // Optional: enable lazy loading (default: false)
304
+ lazyRootMargin="200px" // Optional: IntersectionObserver rootMargin (default: "200px")
110
305
  />
111
306
  ```
112
307
 
308
+ #### Props
309
+
310
+ | Prop | Type | Default | Description |
311
+ |------|------|---------|-------------|
312
+ | `action` | `string` | **Required** | Action name for reCAPTCHA analytics (e.g., "contact_form", "signup") |
313
+ | `inputName` | `string` | `"recaptchaToken"` | Name attribute for the hidden input field |
314
+ | `inputId` | `string` | `"recaptcha-token"` | ID attribute for the hidden input field |
315
+ | `siteKey` | `string` | `process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY` | Override the site key from environment variable |
316
+ | `refreshInterval` | `number` | `90000` | Token refresh interval in milliseconds (90 seconds) |
317
+ | `onTokenGenerated` | `(token: string) => void` | `undefined` | Callback invoked when a new token is generated |
318
+ | `onError` | `(error: Error) => void` | `undefined` | Callback invoked when an error occurs |
319
+ | `lazy` | `boolean` | `false` | Enable lazy loading to defer script until form is visible |
320
+ | `lazyRootMargin` | `string` | `"200px"` | IntersectionObserver rootMargin (used when `lazy` is true) |
321
+
113
322
  ### validateRecaptcha
114
323
 
115
324
  Server-side token validation function.
@@ -18,6 +18,10 @@ interface RecaptchaWrapperProps {
18
18
  onTokenGenerated?: (token: string) => void;
19
19
  /** Callback when an error occurs */
20
20
  onError?: (error: Error) => void;
21
+ /** Enable lazy loading (default: false for backward compatibility) */
22
+ lazy?: boolean;
23
+ /** IntersectionObserver rootMargin for lazy loading (default: "200px") */
24
+ lazyRootMargin?: string;
21
25
  }
22
26
  /**
23
27
  * Global window interface extension for reCAPTCHA
@@ -30,6 +34,15 @@ declare global {
30
34
  action: string;
31
35
  }) => Promise<string>;
32
36
  };
37
+ /** Flag to track if reCAPTCHA script has loaded */
38
+ __recaptchaLoaded?: boolean;
39
+ /** Flag to track if reCAPTCHA script is currently loading */
40
+ __recaptchaLoading?: boolean;
41
+ /** Callbacks to execute when script finishes loading */
42
+ __recaptchaCallbacks?: Array<{
43
+ onLoad: () => void;
44
+ onError: (error: Error) => void;
45
+ }>;
33
46
  }
34
47
  }
35
48
 
@@ -42,6 +55,7 @@ declare global {
42
55
  * - Refreshes token periodically (tokens expire after 2 minutes)
43
56
  * - Stores token in hidden input field for form submission
44
57
  * - Graceful fallback when not configured
58
+ * - Lazy loading support to defer script loading until visible
45
59
  *
46
60
  * @example Basic usage
47
61
  * ```tsx
@@ -69,7 +83,17 @@ declare global {
69
83
  * onError={(error) => console.error("Error:", error)}
70
84
  * />
71
85
  * ```
86
+ *
87
+ * @example Lazy loading for better performance
88
+ * ```tsx
89
+ * <RecaptchaWrapper action="contact_form" lazy />
90
+ * ```
91
+ *
92
+ * @example Lazy loading with custom root margin
93
+ * ```tsx
94
+ * <RecaptchaWrapper action="contact_form" lazy lazyRootMargin="400px" />
95
+ * ```
72
96
  */
73
- declare function RecaptchaWrapper({ action, inputName, inputId, siteKey: propSiteKey, refreshInterval, onTokenGenerated, onError, }: RecaptchaWrapperProps): react_jsx_runtime.JSX.Element | null;
97
+ declare function RecaptchaWrapper({ action, inputName, inputId, siteKey: propSiteKey, refreshInterval, onTokenGenerated, onError, lazy, lazyRootMargin, }: RecaptchaWrapperProps): react_jsx_runtime.JSX.Element | null;
74
98
 
75
99
  export { RecaptchaWrapper, RecaptchaWrapper as default };
@@ -18,6 +18,10 @@ interface RecaptchaWrapperProps {
18
18
  onTokenGenerated?: (token: string) => void;
19
19
  /** Callback when an error occurs */
20
20
  onError?: (error: Error) => void;
21
+ /** Enable lazy loading (default: false for backward compatibility) */
22
+ lazy?: boolean;
23
+ /** IntersectionObserver rootMargin for lazy loading (default: "200px") */
24
+ lazyRootMargin?: string;
21
25
  }
22
26
  /**
23
27
  * Global window interface extension for reCAPTCHA
@@ -30,6 +34,15 @@ declare global {
30
34
  action: string;
31
35
  }) => Promise<string>;
32
36
  };
37
+ /** Flag to track if reCAPTCHA script has loaded */
38
+ __recaptchaLoaded?: boolean;
39
+ /** Flag to track if reCAPTCHA script is currently loading */
40
+ __recaptchaLoading?: boolean;
41
+ /** Callbacks to execute when script finishes loading */
42
+ __recaptchaCallbacks?: Array<{
43
+ onLoad: () => void;
44
+ onError: (error: Error) => void;
45
+ }>;
33
46
  }
34
47
  }
35
48
 
@@ -42,6 +55,7 @@ declare global {
42
55
  * - Refreshes token periodically (tokens expire after 2 minutes)
43
56
  * - Stores token in hidden input field for form submission
44
57
  * - Graceful fallback when not configured
58
+ * - Lazy loading support to defer script loading until visible
45
59
  *
46
60
  * @example Basic usage
47
61
  * ```tsx
@@ -69,7 +83,17 @@ declare global {
69
83
  * onError={(error) => console.error("Error:", error)}
70
84
  * />
71
85
  * ```
86
+ *
87
+ * @example Lazy loading for better performance
88
+ * ```tsx
89
+ * <RecaptchaWrapper action="contact_form" lazy />
90
+ * ```
91
+ *
92
+ * @example Lazy loading with custom root margin
93
+ * ```tsx
94
+ * <RecaptchaWrapper action="contact_form" lazy lazyRootMargin="400px" />
95
+ * ```
72
96
  */
73
- declare function RecaptchaWrapper({ action, inputName, inputId, siteKey: propSiteKey, refreshInterval, onTokenGenerated, onError, }: RecaptchaWrapperProps): react_jsx_runtime.JSX.Element | null;
97
+ declare function RecaptchaWrapper({ action, inputName, inputId, siteKey: propSiteKey, refreshInterval, onTokenGenerated, onError, lazy, lazyRootMargin, }: RecaptchaWrapperProps): react_jsx_runtime.JSX.Element | null;
74
98
 
75
99
  export { RecaptchaWrapper, RecaptchaWrapper as default };
@@ -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,11 +57,16 @@ 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 [isVisible, setIsVisible] = react.useState(!lazy);
69
+ const [scriptLoaded, setScriptLoaded] = react.useState(false);
32
70
  const executeRecaptcha = react.useCallback(async () => {
33
71
  if (!siteKey) {
34
72
  return;
@@ -60,7 +98,49 @@ function RecaptchaWrapper({
60
98
  }
61
99
  }, [siteKey, action, onTokenGenerated, onError]);
62
100
  react.useEffect(() => {
63
- executeRecaptcha();
101
+ if (!lazy || !containerRef.current) return;
102
+ if (typeof IntersectionObserver === "undefined") {
103
+ setIsVisible(true);
104
+ return;
105
+ }
106
+ const observer = new IntersectionObserver(
107
+ ([entry]) => {
108
+ if (entry.isIntersecting) {
109
+ setIsVisible(true);
110
+ observer.disconnect();
111
+ }
112
+ },
113
+ { rootMargin: lazyRootMargin }
114
+ );
115
+ observer.observe(containerRef.current);
116
+ return () => observer.disconnect();
117
+ }, [lazy, lazyRootMargin]);
118
+ react.useEffect(() => {
119
+ if (!siteKey) return;
120
+ if (lazy) return;
121
+ if (typeof window !== "undefined" && !window.__recaptchaLoaded && !window.__recaptchaLoading) {
122
+ window.__recaptchaLoading = true;
123
+ window.__recaptchaCallbacks = window.__recaptchaCallbacks || [];
124
+ }
125
+ }, [siteKey, lazy]);
126
+ react.useEffect(() => {
127
+ if (!siteKey) return;
128
+ if (!lazy) return;
129
+ if (!isVisible) return;
130
+ const handleLoad = () => {
131
+ setScriptLoaded(true);
132
+ executeRecaptcha();
133
+ };
134
+ const handleError = (error) => {
135
+ console.error("[reCAPTCHA] Failed to load reCAPTCHA script");
136
+ if (onError) {
137
+ onError(error);
138
+ }
139
+ };
140
+ loadRecaptchaScript(siteKey, handleLoad, handleError);
141
+ }, [siteKey, lazy, isVisible, executeRecaptcha, onError]);
142
+ react.useEffect(() => {
143
+ if (!scriptLoaded) return;
64
144
  refreshIntervalRef.current = setInterval(() => {
65
145
  executeRecaptcha();
66
146
  }, refreshInterval);
@@ -69,7 +149,7 @@ function RecaptchaWrapper({
69
149
  clearInterval(refreshIntervalRef.current);
70
150
  }
71
151
  };
72
- }, [executeRecaptcha, refreshInterval]);
152
+ }, [scriptLoaded, executeRecaptcha, refreshInterval]);
73
153
  if (!siteKey) {
74
154
  if (process.env.NODE_ENV === "development") {
75
155
  console.warn(
@@ -78,7 +158,7 @@ function RecaptchaWrapper({
78
158
  }
79
159
  return null;
80
160
  }
81
- return /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
161
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { ref: containerRef, style: { display: "contents" }, children: [
82
162
  /* @__PURE__ */ jsxRuntime.jsx(
83
163
  "input",
84
164
  {
@@ -89,15 +169,28 @@ function RecaptchaWrapper({
89
169
  "data-testid": "recaptcha-token-input"
90
170
  }
91
171
  ),
92
- /* @__PURE__ */ jsxRuntime.jsx(
172
+ !lazy && /* @__PURE__ */ jsxRuntime.jsx(
93
173
  Script__default.default,
94
174
  {
95
175
  src: `https://www.google.com/recaptcha/api.js?render=${siteKey}`,
96
176
  strategy: "afterInteractive",
97
177
  onLoad: () => {
178
+ if (typeof window !== "undefined") {
179
+ window.__recaptchaLoaded = true;
180
+ window.__recaptchaLoading = false;
181
+ window.__recaptchaCallbacks?.forEach((cb) => cb.onLoad());
182
+ window.__recaptchaCallbacks = [];
183
+ }
184
+ setScriptLoaded(true);
98
185
  executeRecaptcha();
99
186
  },
100
187
  onError: () => {
188
+ if (typeof window !== "undefined") {
189
+ window.__recaptchaLoading = false;
190
+ const error = new Error("Failed to load reCAPTCHA script");
191
+ window.__recaptchaCallbacks?.forEach((cb) => cb.onError(error));
192
+ window.__recaptchaCallbacks = [];
193
+ }
101
194
  console.error("[reCAPTCHA] Failed to load reCAPTCHA script");
102
195
  if (onError) {
103
196
  onError(new Error("Failed to load reCAPTCHA script"));
@@ -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 * 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, 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 * <RecaptchaWrapper\n * action=\"payment\"\n * onTokenGenerated={(token) => console.log(\"Token:\", token)}\n * onError={(error) => console.error(\"Error:\", error)}\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 [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 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 // 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 // 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"]}