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 ? ( +
+
Loading reCAPTCHA...
+
+ ) : 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 && ( +
+

+ + Form Helpers ({filteredComponents.formHelpers.length}) + + +

+
+ {filteredComponents.formHelpers.map((item, index) => ( +
+ {item.component} +
+ ))} +
+
+ )} + {/* Inputs Section */} {filteredComponents.inputs && (
+ {/* ReCaptcha Card */} +
+

🔒 reCAPTCHA

+
+ {(() => { + const DynamicReCaptcha = dynamic(() => import('./FormInput/ReCaptcha'), { + ssr: false, + loading: () => ( +
+
+ Loading reCAPTCHA... +
+
+ ), + }); + return ( + console.log('Verified:', token)} + /> + ); + })()} +
+
+ {/* Login Form Card */}