@elsikora/x-captcha-react
Version:
React components for X-Captcha service
218 lines (215 loc) • 13.7 kB
JavaScript
import { jsx, jsxs } from 'react/jsx-runtime';
import { XCaptchaApiClient } from '@elsikora/x-captcha-client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { EChallengeType } from '../../infrastructure/enum/challenge-type.enum.js';
import { PowSolver } from '../../infrastructure/utility/pow-solver.utility.js';
import CAPTCHA_WIDGET_CONSTANT from '../constant/captcha-widget.constant.js';
import { detectLanguage, createTranslator } from '../i18n/i18n.js';
import '../i18n/translations/index.js';
import { GenerateThemeVariables } from '../utility/generate-theme-variables.utility.js';
import styles from '../styles/captcha-widget.module.css.js';
/**
* Captcha widget component with modern styling and animations
* @param {ICaptchaWidgetProperties} props - The properties
* @returns {React.ReactElement} The captcha widget
*/
const CaptchaWidget = ({ apiUrl, backgroundColor, brandNameColor, challengeType, checkmarkColor, errorTextColor, height = CAPTCHA_WIDGET_CONSTANT.BOX_HEIGHT, language, onError, onVerify, powSolver, publicKey, shouldShowBrandName = true, themeColor = "#4285F4", tryAgainButtonBackgroundColor, tryAgainButtonTextColor, width = CAPTCHA_WIDGET_CONSTANT.BOX_WIDTH }) => {
// Check if publicKey is provided
const isMissingPublicKey = !publicKey;
// eslint-disable-next-line @elsikora/react/1/naming-convention/use-state
const [client] = useState(() => {
return new XCaptchaApiClient({ apiKey: publicKey, baseUrl: apiUrl, secretKey: "" });
});
const [challenge, setChallenge] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [isVerifying, setIsVerifying] = useState(false);
const [isVerified, setIsVerified] = useState(false);
const [error, setError] = useState(null);
const [animation, setAnimation] = useState("none");
const [hasFakeDelay, setHasFakeDelay] = useState(false);
// Determine which language to use - either from props or auto-detect
// eslint-disable-next-line @elsikora/react/1/naming-convention/use-state
const [translate] = useState(() => {
const detectedLanguage = language ?? detectLanguage();
return createTranslator(detectedLanguage);
});
const themeVariables = useMemo(() => GenerateThemeVariables({
backgroundColor,
brandNameColor,
checkmarkColor,
errorTextColor,
themeColor,
tryAgainButtonBackgroundColor,
tryAgainButtonTextColor,
}), [backgroundColor, brandNameColor, checkmarkColor, errorTextColor, themeColor, tryAgainButtonBackgroundColor, tryAgainButtonTextColor]);
const loadChallenge = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const startTime = Date.now();
const newChallenge = await client.challenge.create({ type: challengeType });
const elapsedTime = Date.now() - startTime;
const remainingTime = Math.max(0, CAPTCHA_WIDGET_CONSTANT.LOADING_FAKE_DELAY - elapsedTime);
if (remainingTime > 0) {
await new Promise((resolve) => setTimeout(resolve, remainingTime));
}
setChallenge(newChallenge);
}
catch (error_) {
console.log("PIDOR", error_);
setError(translate("failedToLoadChallenge"));
if (onError)
onError(translate("failedToLoadChallenge"));
}
finally {
setIsLoading(false);
}
}, [client, onError, translate]);
const validateCaptcha = async (challenge) => {
try {
if (challenge.data.type === EChallengeType.POW) {
const solution = await PowSolver.solve({
difficulty: challenge.data.difficulty,
prefix: challenge.data.challenge,
}, powSolver);
const result = await client.challenge.solve(challenge.id, {
solution: {
hash: solution.hash,
nonce: solution.nonce,
type: EChallengeType.POW,
},
});
setAnimation("success");
setHasFakeDelay(false);
setIsVerified(true);
setTimeout(() => {
if (onVerify)
onVerify(result.token);
}, CAPTCHA_WIDGET_CONSTANT.ON_VERIFY_DELAY);
// eslint-disable-next-line @elsikora/typescript/no-unsafe-enum-comparison
}
else if (challenge.data.type === EChallengeType.CLICK) {
const result = await client.challenge.solve(challenge.id, {
solution: {
// eslint-disable-next-line @elsikora/typescript/naming-convention
data: true,
type: EChallengeType.CLICK,
},
});
setAnimation("success");
setHasFakeDelay(false);
setIsVerified(true);
setTimeout(() => {
if (onVerify)
onVerify(result.token);
}, CAPTCHA_WIDGET_CONSTANT.ON_VERIFY_DELAY);
}
else {
throw new Error("Invalid challenge type or missing data");
}
}
catch (error) {
console.error("Verification error:", error);
setAnimation("error");
setTimeout(() => {
setAnimation("none");
setHasFakeDelay(false);
setError(translate("errorDuringVerification"));
if (onError)
onError(translate("errorDuringVerification"));
void loadChallenge();
}, CAPTCHA_WIDGET_CONSTANT.RETRY_DELAY);
}
finally {
setTimeout(() => {
setIsVerifying(false);
}, CAPTCHA_WIDGET_CONSTANT.RETRY_DELAY);
}
};
// Handle click for the click captcha
const handleClick = useCallback(() => {
if (!challenge || isVerified || isVerifying)
return;
try {
setIsVerifying(true);
setAnimation("verifying");
setHasFakeDelay(true);
setTimeout(() => {
void validateCaptcha(challenge).catch((error) => {
console.error("Unexpected error:", error);
});
}, CAPTCHA_WIDGET_CONSTANT.VERIFY_FAKE_DELAY);
}
catch {
setAnimation("error");
setHasFakeDelay(false);
setTimeout(() => {
setAnimation("none");
setError(translate("errorDuringVerification"));
if (onError)
onError(translate("errorDuringVerification"));
void loadChallenge();
}, CAPTCHA_WIDGET_CONSTANT.RETRY_DELAY);
setTimeout(() => {
setIsVerifying(false);
}, CAPTCHA_WIDGET_CONSTANT.RETRY_DELAY);
}
}, [challenge, client, loadChallenge, onError, onVerify, isVerified, isVerifying, translate]);
useEffect(() => {
if (client) {
void loadChallenge();
}
}, [loadChallenge, client]);
const renderCaptcha = () => {
if (isMissingPublicKey) {
return (jsx("div", { className: styles["x-captcha-error"], children: jsx("div", { children: translate("missingPublicKey") }) }));
}
if (isLoading) {
return (jsxs("div", { className: styles["x-captcha-loading"], children: [jsx("div", { className: styles["x-captcha-loading-spinner"] }), jsx("span", { children: translate("loading") })] }));
}
if (error) {
return (jsxs("div", { className: styles["x-captcha-error"], children: [jsx("div", { children: error }), jsxs("button", { className: styles["x-captcha-error-button"], onClick: () => {
setIsLoading(true);
setError(null);
setTimeout(() => {
void loadChallenge();
}, CAPTCHA_WIDGET_CONSTANT.RETRY_DELAY);
}, type: "button", children: [jsx("span", { className: styles["x-captcha-error-button-icon"], children: jsxs("svg", { fill: "none", height: "14", stroke: "currentColor", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "2", viewBox: "0 0 24 24", width: "14", xmlns: "http://www.w3.org/2000/svg", children: [jsx("path", { d: "M21 12a9 9 0 0 1-9 9c-4.95 0-9-4.05-9-9s4.05-9 9-9c2.4 0 4.65.9 6.3 2.55" }), jsx("polyline", { points: "21 3 21 9 15 9" })] }) }), translate("tryAgain")] })] }));
}
if (isVerified) {
return (jsxs("div", { className: styles["x-captcha-verified"], children: [jsx("div", { className: `${styles["x-captcha-checkbox"]} ${styles["x-captcha-checkbox-verified"]}`, children: jsx("svg", { className: `${styles["x-captcha-checkmark"]} ${styles["x-captcha-checkmark-visible"]}`, fill: "none", height: "16", stroke: "currentColor", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "3", viewBox: "0 0 24 24", width: "16", xmlns: "http://www.w3.org/2000/svg", children: jsx("polyline", { points: "20 6 9 17 4 12" }) }) }), jsx("span", { children: translate("verified") })] }));
}
if (!challenge) {
return jsx("div", { className: styles["x-captcha-error"], children: translate("noChallenge") });
}
switch (challenge.type) {
case EChallengeType.CLICK: {
return (
// eslint-disable-next-line @elsikora/jsx/click-events-have-key-events,@elsikora/jsx/no-static-element-interactions
jsxs("div", { className: styles["x-captcha-container"], onClick: handleClick, children: [jsxs("div", { className: `${styles["x-captcha-checkbox"]} ${isVerifying ? styles["x-captcha-checkbox-verifying"] : ""} ${animation === "error" ? styles["x-captcha-checkbox-error"] : ""}`, children: [isVerified && (jsx("svg", { className: `${styles["x-captcha-checkmark"]} ${isVerified ? styles["x-captcha-checkmark-visible"] : ""}`, fill: "none", height: "16", stroke: "currentColor", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "3", viewBox: "0 0 24 24", width: "16", xmlns: "http://www.w3.org/2000/svg", children: jsx("polyline", { points: "20 6 9 17 4 12" }) })), jsx("div", { className: `${styles["x-captcha-pulse"]} ${isVerifying ? styles["x-captcha-pulse-active"] : ""}`, style: {
animation: animation === "verifying" ? "x-captcha-pulse 0.8s ease-out" : "none",
} })] }), jsx("div", { className: styles["x-captcha-text"], children: translate("notRobot") }), shouldShowBrandName && jsx("div", { className: styles["x-captcha-brand"], children: translate("brandName") }), jsx("div", { className: `${styles["x-captcha-verifying-overlay"]} ${hasFakeDelay ? styles["x-captcha-verifying-overlay-visible"] : ""}`, children: jsxs("div", { className: styles["x-captcha-loading"], children: [jsx("div", { className: styles["x-captcha-loading-spinner"] }), jsx("span", { children: translate("verifying") })] }) })] }));
}
// eslint-disable-next-line @elsikora/sonar/no-duplicated-branches
case EChallengeType.POW: {
return (
// eslint-disable-next-line @elsikora/jsx/click-events-have-key-events,@elsikora/jsx/no-static-element-interactions
jsxs("div", { className: styles["x-captcha-container"], onClick: handleClick, children: [jsxs("div", { className: `${styles["x-captcha-checkbox"]} ${isVerifying ? styles["x-captcha-checkbox-verifying"] : ""} ${animation === "error" ? styles["x-captcha-checkbox-error"] : ""}`, children: [isVerified && (jsx("svg", { className: `${styles["x-captcha-checkmark"]} ${isVerified ? styles["x-captcha-checkmark-visible"] : ""}`, fill: "none", height: "16", stroke: "currentColor", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "3", viewBox: "0 0 24 24", width: "16", xmlns: "http://www.w3.org/2000/svg", children: jsx("polyline", { points: "20 6 9 17 4 12" }) })), jsx("div", { className: `${styles["x-captcha-pulse"]} ${isVerifying ? styles["x-captcha-pulse-active"] : ""}`, style: {
animation: animation === "verifying" ? "x-captcha-pulse 0.8s ease-out" : "none",
} })] }), jsx("div", { className: styles["x-captcha-text"], children: translate("notRobot") }), shouldShowBrandName && jsx("div", { className: styles["x-captcha-brand"], children: translate("brandName") }), jsx("div", { className: `${styles["x-captcha-verifying-overlay"]} ${hasFakeDelay ? styles["x-captcha-verifying-overlay-visible"] : ""}`, children: jsxs("div", { className: styles["x-captcha-loading"], children: [jsx("div", { className: styles["x-captcha-loading-spinner"] }), jsx("span", { children: translate("verifying") })] }) })] }));
}
default: {
return jsx("div", { className: styles["x-captcha-error"], children: translate("unsupportedCaptchaType") });
}
}
};
// Set dimensions as inline styles as they're specific to the instance, not part of theming
const widgetStyle = {
...themeVariables,
height,
width,
};
return (jsx("div", { className: styles["x-captcha-widget"], style: widgetStyle, children: renderCaptcha() }));
};
export { CaptchaWidget };
//# sourceMappingURL=CaptchaWidget.js.map