Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/app/components/FormInput/DatePicker.jsx
Original file line number Diff line number Diff line change
@@ -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 = ({
Expand All @@ -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 (
<div className="mb-4">
Expand Down
142 changes: 142 additions & 0 deletions src/app/components/FormInput/ReCaptcha.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`inline-block ${className}`} style={{ minHeight: 78 }} aria-live={error ? 'polite' : undefined}>
{/* Loading / error UI sits outside the grecaptcha target */}
{error ? (
<div className="text-red-500 text-sm">Unable to load reCAPTCHA. Please try again later.</div>
) : !isScriptLoaded || !isRendered ? (
<div className="min-h-[78px] min-w-[302px] flex items-center justify-center">
<div className="text-gray-500 dark:text-gray-400 text-sm">Loading reCAPTCHA...</div>
</div>
) : null}

{/* Empty div required by grecaptcha.render. It must be empty when render is called. */}
<div ref={containerRef} />
</div>
);
};

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;
72 changes: 72 additions & 0 deletions src/app/components/FormInput/ReCaptcha.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<ReCaptcha
siteKey={mockSiteKey}
onVerify={mockOnVerify}
/>
);

const container = screen.getByTestId('recaptcha-container');
expect(container).toBeInTheDocument();
});

it('initializes reCAPTCHA with correct props', () => {
render(
<ReCaptcha
siteKey={mockSiteKey}
onVerify={mockOnVerify}
theme="dark"
size="compact"
/>
);

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(
<ReCaptcha
siteKey={mockSiteKey}
onVerify={mockOnVerify}
className={customClass}
/>
);

const container = screen.getByTestId('recaptcha-container');
expect(container).toHaveClass(customClass);
});
});
50 changes: 50 additions & 0 deletions src/app/components/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -170,6 +172,7 @@ export default function Page() {

// All components with search data
const allComponents = {

buttons: [
{
name: t('buttons.primary.name'),
Expand Down Expand Up @@ -773,6 +776,28 @@ export default function Page() {
</section>
)}

{/* Form Helpers Section */}
{filteredComponents.formHelpers && (
<section
id="formHelpers"
className="bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 dark:from-blue-900/20 dark:via-indigo-900/20 dark:to-purple-900/20 border border-blue-100 dark:border-blue-900 shadow-xl rounded-2xl p-10"
>
<h2 className="relative text-2xl font-semibold mb-6 flex justify-center items-center gap-2 text-blue-600 dark:text-blue-300">
<span className="whitespace-nowrap text-[1.3rem] sm:text-2xl md:text-3xl lg:text-3xl">
Form Helpers ({filteredComponents.formHelpers.length})
</span>
<span className="absolute top-10 h-1 w-full bg-gradient-to-r from-blue-300 to-purple-300 rounded-full block" />
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{filteredComponents.formHelpers.map((item, index) => (
<div key={index} title={item.name} className="flex justify-center">
{item.component}
</div>
))}
</div>
</section>
)}

{/* Inputs Section */}
{filteredComponents.inputs && (
<section
Expand Down Expand Up @@ -976,6 +1001,31 @@ export default function Page() {
<FormValidation minLength={5} />
</div>

{/* ReCaptcha Card */}
<div className="w-full p-6 bg-gradient-to-r from-indigo-50 to-indigo-100/80 dark:from-indigo-900 dark:to-indigo-700 text-indigo-900 dark:text-indigo-100 rounded-xl font-medium shadow-sm border border-indigo-200 dark:border-indigo-800">
<h3 className="text-lg font-semibold mb-2">๐Ÿ”’ reCAPTCHA</h3>
<div className="flex justify-center">
{(() => {
const DynamicReCaptcha = dynamic(() => import('./FormInput/ReCaptcha'), {
ssr: false,
loading: () => (
<div className="min-h-[78px] flex items-center justify-center">
<div className="text-indigo-700 dark:text-indigo-300 text-sm">
Loading reCAPTCHA...
</div>
</div>
),
});
return (
<DynamicReCaptcha
siteKey="6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI"
onVerify={(token) => console.log('Verified:', token)}
/>
);
})()}
</div>
</div>

{/* Login Form Card */}
<div className="w-full p-6 bg-gradient-to-r from-purple-50 to-purple-100/80 dark:from-purple-900 dark:to-purple-700 text-purple-900 dark:text-purple-100 rounded-xl font-medium shadow-sm border border-purple-200 dark:border-purple-800">
<h3 className="text-lg font-semibold mb-2">
Expand Down