diff --git a/src/app/components/FormInput/DatePicker.jsx b/src/app/components/FormInput/DatePicker.jsx
index ed1b04c..6704f30 100644
--- a/src/app/components/FormInput/DatePicker.jsx
+++ b/src/app/components/FormInput/DatePicker.jsx
@@ -1,4 +1,6 @@
-import React, { useState } from "react";
+'use client';
+
+import React, { useState, useId } from "react";
import PropTypes from 'prop-types';
export const DatePicker = ({
@@ -12,14 +14,15 @@ export const DatePicker = ({
...props
}) => {
const [date, setDate] = useState("");
+ const uniqueId = useId(); // React's stable ID generation
const handleChange = (e) => {
setDate(e.target.value);
if (onChange) onChange(e.target.value);
};
- // Generate a unique ID if not provided
- const inputId = id || `date-picker-${Math.random().toString(36).substr(2, 9)}`;
+ // Use provided ID or generate a stable one using useId
+ const inputId = id || `date-picker-${uniqueId}`;
return (
diff --git a/src/app/components/FormInput/ReCaptcha.jsx b/src/app/components/FormInput/ReCaptcha.jsx
new file mode 100644
index 0000000..a049a91
--- /dev/null
+++ b/src/app/components/FormInput/ReCaptcha.jsx
@@ -0,0 +1,142 @@
+ 'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import PropTypes from 'prop-types';
+
+const RECAPTCHA_SCRIPT_ID = 'recaptcha-script';
+
+const ReCaptcha = ({ siteKey, onVerify, theme = 'light', size = 'normal', className = '' }) => {
+ const containerRef = useRef(null);
+ const widgetId = useRef(null);
+ const [isScriptLoaded, setIsScriptLoaded] = useState(false);
+ const [isRendered, setIsRendered] = useState(false);
+ const [error, setError] = useState(null);
+
+ // Load reCAPTCHA script with onload callback
+ useEffect(() => {
+ const CALLBACK_NAME = '__reCaptchaOnload';
+ const existing = document.getElementById(RECAPTCHA_SCRIPT_ID);
+
+ if (!window[CALLBACK_NAME]) {
+ // Create a global callback the API will call when it's ready
+ // eslint-disable-next-line no-undef
+ window[CALLBACK_NAME] = () => setIsScriptLoaded(true);
+ }
+
+ if (!existing) {
+ const script = document.createElement('script');
+ script.id = RECAPTCHA_SCRIPT_ID;
+ script.src = 'https://www.google.com/recaptcha/api.js?onload=' + CALLBACK_NAME + '&render=explicit';
+ script.async = true;
+ script.defer = true;
+ script.onload = () => {
+ // debug log
+ // eslint-disable-next-line no-console
+ console.debug('[ReCaptcha] script loaded');
+ setIsScriptLoaded(true);
+ };
+ script.onerror = (e) => {
+ // eslint-disable-next-line no-console
+ console.error('[ReCaptcha] script failed to load', e);
+ setError(new Error('Failed to load reCAPTCHA script'));
+ };
+ document.head.appendChild(script);
+ } else {
+ // If script tag exists, give it a short time to initialize grecaptcha
+ const t = setTimeout(() => {
+ if (window.grecaptcha && window.grecaptcha.render) setIsScriptLoaded(true);
+ }, 500);
+ return () => clearTimeout(t);
+ }
+
+ return () => {
+ // On unmount attempt to reset widget
+ try {
+ if (widgetId.current && window.grecaptcha?.reset) {
+ window.grecaptcha.reset(widgetId.current);
+ }
+ } catch (err) {
+ // ignore
+ }
+ widgetId.current = null;
+ setIsRendered(false);
+ };
+ }, []);
+
+ // Render the widget when script is ready. Poll if necessary.
+ useEffect(() => {
+ if (!isScriptLoaded || isRendered || !containerRef.current) return undefined;
+
+ let attempts = 0;
+ const maxAttempts = 40;
+ const intervalMs = 300;
+ let pollId = null;
+
+ const tryRender = () => {
+ attempts += 1;
+ // eslint-disable-next-line no-console
+ console.debug('[ReCaptcha] tryRender attempt', attempts, 'grecaptcha?', !!window.grecaptcha);
+ if (window.grecaptcha && typeof window.grecaptcha.render === 'function') {
+ try {
+ widgetId.current = window.grecaptcha.render(containerRef.current, {
+ sitekey: siteKey,
+ theme: theme,
+ size: size,
+ callback: onVerify,
+ });
+ setIsRendered(true);
+ if (pollId) clearInterval(pollId);
+ } catch (err) {
+ // capture error and stop polling
+ // eslint-disable-next-line no-console
+ console.error('Error rendering reCAPTCHA:', err);
+ setError(err);
+ if (pollId) clearInterval(pollId);
+ }
+ } else if (attempts >= maxAttempts) {
+ setError(new Error('grecaptcha.render not available'));
+ if (pollId) clearInterval(pollId);
+ }
+ };
+
+ // immediate attempt
+ tryRender();
+
+ if (!isRendered && !error) {
+ pollId = setInterval(tryRender, intervalMs);
+ }
+
+ return () => {
+ if (pollId) clearInterval(pollId);
+ };
+ }, [isScriptLoaded, isRendered, siteKey, theme, size, onVerify, error]);
+
+ // Render a wrapper that always contains an empty target div for grecaptcha
+ // This avoids passing child nodes into the grecaptcha render target which causes
+ // the error: "reCAPTCHA placeholder element must be empty"
+ return (
+
+ {/* Loading / error UI sits outside the grecaptcha target */}
+ {error ? (
+
Unable to load reCAPTCHA. Please try again later.
+ ) : !isScriptLoaded || !isRendered ? (
+
+ ) : null}
+
+ {/* Empty div required by grecaptcha.render. It must be empty when render is called. */}
+
+
+ );
+};
+
+ReCaptcha.propTypes = {
+ siteKey: PropTypes.string.isRequired,
+ onVerify: PropTypes.func.isRequired,
+ theme: PropTypes.oneOf(['light', 'dark']),
+ size: PropTypes.oneOf(['normal', 'compact']),
+ className: PropTypes.string,
+};
+
+export default ReCaptcha;
\ No newline at end of file
diff --git a/src/app/components/FormInput/ReCaptcha.test.jsx b/src/app/components/FormInput/ReCaptcha.test.jsx
new file mode 100644
index 0000000..fb47404
--- /dev/null
+++ b/src/app/components/FormInput/ReCaptcha.test.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import ReCaptcha from './ReCaptcha';
+
+// Mock window.grecaptcha
+const mockRender = jest.fn();
+const mockReset = jest.fn();
+
+beforeAll(() => {
+ global.window.grecaptcha = {
+ render: mockRender,
+ reset: mockReset,
+ };
+});
+
+describe('ReCaptcha', () => {
+ const mockSiteKey = 'test-site-key';
+ const mockOnVerify = jest.fn();
+
+ beforeEach(() => {
+ mockRender.mockClear();
+ mockReset.mockClear();
+ mockOnVerify.mockClear();
+ });
+
+ it('renders the reCAPTCHA container', () => {
+ render(
+
+ );
+
+ const container = screen.getByTestId('recaptcha-container');
+ expect(container).toBeInTheDocument();
+ });
+
+ it('initializes reCAPTCHA with correct props', () => {
+ render(
+
+ );
+
+ expect(mockRender).toHaveBeenCalledWith(
+ expect.any(HTMLDivElement),
+ expect.objectContaining({
+ sitekey: mockSiteKey,
+ theme: 'dark',
+ size: 'compact',
+ callback: mockOnVerify,
+ })
+ );
+ });
+
+ it('applies custom className', () => {
+ const customClass = 'custom-captcha';
+ render(
+
+ );
+
+ const container = screen.getByTestId('recaptcha-container');
+ expect(container).toHaveClass(customClass);
+ });
+});
\ No newline at end of file
diff --git a/src/app/components/page.jsx b/src/app/components/page.jsx
index 2d55af9..2e280de 100644
--- a/src/app/components/page.jsx
+++ b/src/app/components/page.jsx
@@ -2,6 +2,7 @@
import { useState, useEffect, useRef } from "react";
import { Search, SparklesIcon, X } from "lucide-react";
import { useTranslations } from 'next-intl';
+import dynamic from 'next/dynamic';
// [All imports remain the same as in your original code]
import { useAnalytics } from "../context/AnalyticsContext";
@@ -34,6 +35,7 @@ import TextInput from "./inputs/TextInput";
import Select from "./inputs/Select";
import Checkbox from "./inputs/Checkbox";
import PasswordInput from "./inputs/PasswordInput";
+// ReCaptcha is loaded dynamically below to avoid SSR/render issues
// Nav
import Tabs from "./navigation/Tabs";
@@ -170,6 +172,7 @@ export default function Page() {
// All components with search data
const allComponents = {
+
buttons: [
{
name: t('buttons.primary.name'),
@@ -773,6 +776,28 @@ export default function Page() {
)}
+ {/* Form Helpers Section */}
+ {filteredComponents.formHelpers && (
+
+ )}
+
{/* Inputs Section */}
{filteredComponents.inputs && (
+ {/* ReCaptcha Card */}
+