diff --git a/README.md b/README.md index 249b2f6..66fcd7d 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,22 @@ const onLoad = () => { return ; ``` +#### Custom challenge containers + +Pass `challenge-container` with a container ID or DOM element to render the puzzle inline instead of using hCaptcha's popup/shim. Mount the container before hCaptcha renders. Outside clicks leave the inline puzzle open; `execute({ async: true })` keeps waiting for its result. + +```jsx +
+ +``` + +You control placement and any surrounding modal/backdrop. See [`demo_cc`](./demo_cc) for a dimmed modal with configurable dismissal. + ### References #### Props @@ -183,6 +199,7 @@ return { + const res = await fetch('/api/protected-action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + if (!res.ok) throw new Error('Submission failed'); + }} +/> +``` + +The demo only obtains a token. Your backend must verify it through [siteverify](https://docs.hcaptcha.com/#verify-the-response-token-server-side) before performing the protected action. Keep the secret key on the server. diff --git a/demo_cc/package.json b/demo_cc/package.json new file mode 100644 index 0000000..bd4a455 --- /dev/null +++ b/demo_cc/package.json @@ -0,0 +1,26 @@ +{ + "name": "@hcaptcha-react/demo-cc", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "webpack serve -c webpack.config.js", + "build": "webpack -c webpack.config.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@hcaptcha-react/lib": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@swc/core": "^1.15.8", + "@types/react": "^18.0.0", + "html-webpack-plugin": "^5.6.5", + "swc-loader": "^0.2.6", + "typescript": "^4.9.5", + "webpack": "^5.104.1", + "webpack-cli": "^6.0.1", + "webpack-dev-server": "^5.2.2" + } +} diff --git a/demo_cc/src/ModalCaptchaAction.tsx b/demo_cc/src/ModalCaptchaAction.tsx new file mode 100644 index 0000000..3dba664 --- /dev/null +++ b/demo_cc/src/ModalCaptchaAction.tsx @@ -0,0 +1,83 @@ +import { useId, useRef, useState } from 'react'; +import HCaptcha from '@hcaptcha-react/lib'; + +interface Props { + sitekey: string; + dismissOnBackdrop?: boolean; + onVerified?: (token: string) => Promise; +} + +export default function ModalCaptchaAction({ + sitekey, + dismissOnBackdrop = false, + onVerified, +}: Props) { + const captchaRef = useRef(null); + const dialogRef = useRef(null); + const challengeId = useId(); + const titleId = `${challengeId}-title`; + const [ready, setReady] = useState(false); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(''); + + async function handleSubmit() { + const captcha = captchaRef.current; + const dialog = dialogRef.current; + if (!captcha || !dialog || busy) return; + + setBusy(true); + setMessage(''); + + try { + captcha.resetCaptcha(); + dialog.showModal(); + const { response: token } = await captcha.execute({ async: true }); + dialog.close(); + await onVerified?.(token); + setMessage('Verification completed.'); + } catch { + setMessage('Verification cancelled or failed. Please retry.'); + } finally { + dialog.close(); + setBusy(false); + } + } + + return ( + <> + + + { + if (dismissOnBackdrop && event.target === event.currentTarget) { + captchaRef.current?.resetCaptcha(); + } + }} + onCancel={(event) => { + event.preventDefault(); + if (dismissOnBackdrop) captchaRef.current?.resetCaptcha(); + }} + > +
+

Complete verification

+
+
+
+ + setReady(true)} + /> + +

{message}

+ + ); +} diff --git a/demo_cc/src/index.html b/demo_cc/src/index.html new file mode 100644 index 0000000..343be4f --- /dev/null +++ b/demo_cc/src/index.html @@ -0,0 +1,25 @@ + + + + + + hCaptcha custom challenge container + + + +
+ + diff --git a/demo_cc/src/index.jsx b/demo_cc/src/index.jsx new file mode 100644 index 0000000..06cd13f --- /dev/null +++ b/demo_cc/src/index.jsx @@ -0,0 +1,29 @@ +import React, { useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import ModalCaptchaAction from './ModalCaptchaAction'; + +const sitekey = new URLSearchParams(window.location.search).get('sitekey') + || 'a5f74b19-9e45-40e0-b45d-47ff91b7a6c2'; + +function App() { + const [dismissOnBackdrop, setDismissOnBackdrop] = useState(false); + + return ( +
+

Custom challenge container

+

The puzzle renders inside a dimmed modal. Background interaction is blocked.

+ + +

This frontend demo obtains a token. Verify it server-side before performing a protected action.

+
+ ); +} + +createRoot(document.getElementById('app')).render(); diff --git a/demo_cc/tsconfig.json b/demo_cc/tsconfig.json new file mode 100644 index 0000000..4c8f1ca --- /dev/null +++ b/demo_cc/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "lib": ["ES2020", "DOM"], + "moduleResolution": "node", + "esModuleInterop": true, + "baseUrl": ".", + "paths": { + "@hcaptcha-react/lib": ["../types/index.d.ts"] + } + }, + "include": ["src/*.tsx"] +} diff --git a/demo_cc/webpack.config.js b/demo_cc/webpack.config.js new file mode 100644 index 0000000..a887e5c --- /dev/null +++ b/demo_cc/webpack.config.js @@ -0,0 +1,35 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import HtmlWebpackPlugin from 'html-webpack-plugin'; + +const root = dirname(fileURLToPath(import.meta.url)); + +export default { + mode: 'development', + entry: resolve(root, 'src/index.jsx'), + output: { path: resolve(root, 'dist'), filename: 'demo.js', clean: true }, + resolve: { + extensions: ['.js', '.jsx', '.ts', '.tsx'], + alias: { + react: resolve(root, 'node_modules/react'), + 'react-dom': resolve(root, 'node_modules/react-dom'), + }, + }, + module: { + rules: [{ + test: /\.[jt]sx?$/, + exclude: /node_modules/, + use: { + loader: 'swc-loader', + options: { + jsc: { + parser: { syntax: 'typescript', tsx: true }, + transform: { react: { runtime: 'automatic' } }, + }, + }, + }, + }], + }, + plugins: [new HtmlWebpackPlugin({ template: resolve(root, 'src/index.html') })], + devServer: { host: '127.0.0.1', port: 9001 }, +}; diff --git a/package.json b/package.json index d743277..cf3dbcb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@hcaptcha/react-hcaptcha", "description": "A React library for hCaptcha", - "version": "2.2.0", + "version": "2.2.1", "author": "hCaptcha team and contributors", "license": "MIT", "repository": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb0815a..4eb7909 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,43 @@ importers: specifier: ^5.2.2 version: 5.2.4(webpack-cli@6.0.1)(webpack@5.104.1) + demo_cc: + dependencies: + '@hcaptcha-react/lib': + specifier: workspace:^ + version: link:../lib + react: + specifier: ^19.0.0 + version: 19.2.3 + react-dom: + specifier: ^19.0.0 + version: 19.2.3(react@19.2.3) + devDependencies: + '@swc/core': + specifier: ^1.15.8 + version: 1.15.8 + '@types/react': + specifier: ^18.0.0 + version: 18.3.27 + html-webpack-plugin: + specifier: ^5.6.5 + version: 5.6.5(webpack@5.104.1) + swc-loader: + specifier: ^0.2.6 + version: 0.2.6(@swc/core@1.15.8)(webpack@5.104.1) + typescript: + specifier: ^4.9.5 + version: 4.9.5 + webpack: + specifier: ^5.104.1 + version: 5.104.1(@swc/core@1.15.8)(webpack-cli@6.0.1) + webpack-cli: + specifier: ^6.0.1 + version: 6.0.1(webpack-dev-server@5.2.4)(webpack@5.104.1) + webpack-dev-server: + specifier: ^5.2.2 + version: 5.2.4(webpack-cli@6.0.1)(webpack@5.104.1) + lib: dependencies: '@hcaptcha/loader': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6840add..8459def 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,4 @@ packages: - lib - - demo \ No newline at end of file + - demo + - demo_cc diff --git a/types/index.d.ts b/types/index.d.ts index a898e14..6963d57 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -27,6 +27,7 @@ export interface HCaptchaProps { theme?: "light" | "dark" | "contrast" | object; tabIndex?: number; id?: string; + "challenge-container"?: HTMLElement | string; reCaptchaCompat?: boolean; loadAsync?: boolean; scriptLocation?: HTMLElement | null;