@prosopo/procaptcha-puzzle 2.10.8 → 2.10.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build$colon$cjs.log +53 -0
- package/.turbo/turbo-build$colon$tsc.log +56 -0
- package/.turbo/turbo-build.log +57 -57
- package/CHANGELOG.md +8 -0
- package/dist/cjs/components/ProcaptchaPuzzle.cjs +18 -0
- package/dist/cjs/components/ProcaptchaWidget.cjs +191 -0
- package/dist/cjs/components/PuzzleCanvas.cjs +275 -0
- package/dist/cjs/index.cjs +5 -0
- package/dist/cjs/services/Manager.cjs +286 -0
- package/dist/components/ProcaptchaPuzzle.js +17 -5
- package/dist/components/ProcaptchaWidget.js +181 -144
- package/dist/components/PuzzleCanvas.js +258 -189
- package/dist/index.js +5 -3
- package/dist/services/Manager.js +270 -253
- package/package.json +4 -4
- package/src/components/ProcaptchaPuzzle.tsx +38 -0
- package/src/components/ProcaptchaWidget.tsx +261 -0
- package/src/components/PuzzleCanvas.tsx +336 -0
- package/src/index.ts +15 -0
- package/src/services/Manager.ts +454 -0
- package/tsconfig.cjs.json +44 -0
- package/tsconfig.json +45 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/tsconfig.types.json +9 -0
- package/.turbo/turbo-typecheck.log +0 -4
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
import { loadI18next, useTranslation } from "@prosopo/locale";
|
|
16
|
+
import { buildUpdateState, useProcaptcha } from "@prosopo/procaptcha-common";
|
|
17
|
+
import { Checkbox, Honeypot } from "@prosopo/procaptcha-common";
|
|
18
|
+
import {
|
|
19
|
+
type GetPuzzleCaptchaResponse,
|
|
20
|
+
ModeEnum,
|
|
21
|
+
type ProcaptchaProps,
|
|
22
|
+
type PuzzleEvent,
|
|
23
|
+
} from "@prosopo/types";
|
|
24
|
+
import { darkTheme, lightTheme } from "@prosopo/widget-skeleton";
|
|
25
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
26
|
+
import { Manager } from "../services/Manager.js";
|
|
27
|
+
import { PuzzleCanvas } from "./PuzzleCanvas.js";
|
|
28
|
+
|
|
29
|
+
// Define the same event name as in the bundle for consistency
|
|
30
|
+
const PROCAPTCHA_EXECUTE_EVENT = "procaptcha:execute";
|
|
31
|
+
|
|
32
|
+
type PuzzlePhase = "checkbox" | "dragging" | "submitting";
|
|
33
|
+
|
|
34
|
+
const Procaptcha = (props: ProcaptchaProps) => {
|
|
35
|
+
const { t, ready: isTranslationReady } = useTranslation();
|
|
36
|
+
const config = props.config;
|
|
37
|
+
const i18n = props.i18n;
|
|
38
|
+
const theme = "light" === config.theme ? lightTheme : darkTheme;
|
|
39
|
+
const frictionlessState = props.frictionlessState; // Set up Session ID and Provider if they exist
|
|
40
|
+
const callbacks = props.callbacks || {};
|
|
41
|
+
const [state, _updateState] = useProcaptcha(useState, useRef);
|
|
42
|
+
const [loading, setLoading] = useState(false);
|
|
43
|
+
const [puzzlePhase, setPuzzlePhase] = useState<PuzzlePhase>("checkbox");
|
|
44
|
+
const [challengeData, setChallengeData] =
|
|
45
|
+
useState<GetPuzzleCaptchaResponse | null>(null);
|
|
46
|
+
const [showRetry, setShowRetry] = useState(false);
|
|
47
|
+
// get the state update mechanism
|
|
48
|
+
const updateState = buildUpdateState(state, _updateState);
|
|
49
|
+
const hpRef = useRef<HTMLInputElement>(null);
|
|
50
|
+
const manager = useRef(
|
|
51
|
+
Manager(
|
|
52
|
+
config,
|
|
53
|
+
state,
|
|
54
|
+
updateState,
|
|
55
|
+
callbacks,
|
|
56
|
+
frictionlessState,
|
|
57
|
+
() => hpRef.current?.value || undefined,
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (config.language) {
|
|
63
|
+
if (i18n) {
|
|
64
|
+
if (i18n.language !== config.language) {
|
|
65
|
+
i18n.changeLanguage(config.language).then((r) => r);
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
loadI18next(false).then((i18n) => {
|
|
69
|
+
if (i18n.language !== config.language)
|
|
70
|
+
i18n.changeLanguage(config.language).then((r) => r);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}, [i18n, config.language]);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!state.error) return undefined;
|
|
78
|
+
setLoading(false);
|
|
79
|
+
setPuzzlePhase("checkbox");
|
|
80
|
+
setChallengeData(null);
|
|
81
|
+
setShowRetry(false);
|
|
82
|
+
if (state.error.key === "CAPTCHA.NO_SESSION_FOUND" && frictionlessState) {
|
|
83
|
+
const timer = setTimeout(() => {
|
|
84
|
+
frictionlessState.restart();
|
|
85
|
+
}, 100);
|
|
86
|
+
return () => clearTimeout(timer);
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}, [state.error, frictionlessState]);
|
|
90
|
+
|
|
91
|
+
// Add event listener for the execute event (works for invisible mode)
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
// Only set up event listener if in invisible mode
|
|
94
|
+
if (config.mode === ModeEnum.invisible) {
|
|
95
|
+
// Event handler for when execute() is called: fetch a challenge
|
|
96
|
+
// then drive the puzzle UI through the same phase transitions as
|
|
97
|
+
// the visible checkbox flow.
|
|
98
|
+
const handleExecuteEvent = async () => {
|
|
99
|
+
if (loading) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
setLoading(true);
|
|
103
|
+
setShowRetry(false);
|
|
104
|
+
try {
|
|
105
|
+
const challenge = await manager.current.start();
|
|
106
|
+
if (challenge) {
|
|
107
|
+
setChallengeData(challenge);
|
|
108
|
+
setPuzzlePhase("dragging");
|
|
109
|
+
}
|
|
110
|
+
} catch (error) {
|
|
111
|
+
callbacks.onError?.(
|
|
112
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
113
|
+
);
|
|
114
|
+
} finally {
|
|
115
|
+
setLoading(false);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
document.addEventListener(PROCAPTCHA_EXECUTE_EVENT, handleExecuteEvent);
|
|
120
|
+
|
|
121
|
+
// Cleanup function to remove event listener
|
|
122
|
+
return () => {
|
|
123
|
+
document.removeEventListener(
|
|
124
|
+
PROCAPTCHA_EXECUTE_EVENT,
|
|
125
|
+
handleExecuteEvent,
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Return empty cleanup function when not in invisible mode
|
|
131
|
+
return () => {};
|
|
132
|
+
}, [config.mode, callbacks.onError, loading]);
|
|
133
|
+
|
|
134
|
+
const handlePuzzleComplete = useCallback(
|
|
135
|
+
async (finalX: number, finalY: number, puzzleEvents: PuzzleEvent[]) => {
|
|
136
|
+
setPuzzlePhase("submitting");
|
|
137
|
+
let verified = false;
|
|
138
|
+
try {
|
|
139
|
+
verified = await manager.current.submitSolution(
|
|
140
|
+
finalX,
|
|
141
|
+
finalY,
|
|
142
|
+
puzzleEvents,
|
|
143
|
+
);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
callbacks.onError?.(
|
|
146
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (verified) {
|
|
151
|
+
setPuzzlePhase("checkbox");
|
|
152
|
+
setChallengeData(null);
|
|
153
|
+
setShowRetry(false);
|
|
154
|
+
setLoading(false);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Failed — show retry message and fetch a new challenge
|
|
159
|
+
setShowRetry(true);
|
|
160
|
+
setPuzzlePhase("dragging");
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const newChallenge = await manager.current.start();
|
|
164
|
+
if (newChallenge) {
|
|
165
|
+
setChallengeData(newChallenge);
|
|
166
|
+
} else {
|
|
167
|
+
// Couldn't get new challenge, fall back to checkbox
|
|
168
|
+
setPuzzlePhase("checkbox");
|
|
169
|
+
setChallengeData(null);
|
|
170
|
+
setShowRetry(false);
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
setPuzzlePhase("checkbox");
|
|
174
|
+
setChallengeData(null);
|
|
175
|
+
setShowRetry(false);
|
|
176
|
+
}
|
|
177
|
+
setLoading(false);
|
|
178
|
+
},
|
|
179
|
+
[callbacks.onError],
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
const isInvisible = config.mode === ModeEnum.invisible;
|
|
183
|
+
const showPuzzleOverlay =
|
|
184
|
+
(puzzlePhase === "dragging" || puzzlePhase === "submitting") &&
|
|
185
|
+
challengeData;
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<>
|
|
189
|
+
{frictionlessState?.hp && (
|
|
190
|
+
<Honeypot ref={hpRef} encodedQuestion={frictionlessState.hp} />
|
|
191
|
+
)}
|
|
192
|
+
{/* Puzzle overlay — rendered outside the shadow DOM flow via fixed
|
|
193
|
+
positioning. Shown in both visible and invisible modes once a
|
|
194
|
+
challenge has been fetched; puzzle is inherently interactive. */}
|
|
195
|
+
{showPuzzleOverlay && (
|
|
196
|
+
<PuzzleCanvas
|
|
197
|
+
originX={challengeData.originX}
|
|
198
|
+
originY={challengeData.originY}
|
|
199
|
+
targetX={challengeData.targetX}
|
|
200
|
+
targetY={challengeData.targetY}
|
|
201
|
+
onComplete={handlePuzzleComplete}
|
|
202
|
+
showRetry={showRetry}
|
|
203
|
+
submitting={puzzlePhase === "submitting"}
|
|
204
|
+
/>
|
|
205
|
+
)}
|
|
206
|
+
|
|
207
|
+
{/* Checkbox — only in visible mode. Invisible mode is driven by
|
|
208
|
+
the host page's execute() call (e.g. on form submit). */}
|
|
209
|
+
{!isInvisible && (
|
|
210
|
+
<Checkbox
|
|
211
|
+
checked={state.isHuman}
|
|
212
|
+
theme={theme}
|
|
213
|
+
onChange={async (event: React.MouseEvent | React.TouchEvent) => {
|
|
214
|
+
if (loading) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
setLoading(true);
|
|
218
|
+
setShowRetry(false);
|
|
219
|
+
|
|
220
|
+
// Capture click coordinates (mirrors POW widget) so the
|
|
221
|
+
// puzzle solution salt records the entry-point telemetry.
|
|
222
|
+
let x = 0;
|
|
223
|
+
let y = 0;
|
|
224
|
+
const mouseOrTouchEvent = event.nativeEvent;
|
|
225
|
+
if (!mouseOrTouchEvent.isTrusted) {
|
|
226
|
+
// Don't capture coordinates for non-trusted events
|
|
227
|
+
} else if (
|
|
228
|
+
"touches" in mouseOrTouchEvent &&
|
|
229
|
+
mouseOrTouchEvent.touches.length > 0 &&
|
|
230
|
+
mouseOrTouchEvent.touches[0]
|
|
231
|
+
) {
|
|
232
|
+
x = mouseOrTouchEvent.touches[0].clientX;
|
|
233
|
+
y = mouseOrTouchEvent.touches[0].clientY;
|
|
234
|
+
} else if (
|
|
235
|
+
"clientX" in mouseOrTouchEvent &&
|
|
236
|
+
"clientY" in mouseOrTouchEvent
|
|
237
|
+
) {
|
|
238
|
+
x = mouseOrTouchEvent.clientX;
|
|
239
|
+
y = mouseOrTouchEvent.clientY;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const challenge = await manager.current.start(x, y);
|
|
243
|
+
|
|
244
|
+
if (challenge) {
|
|
245
|
+
setChallengeData(challenge);
|
|
246
|
+
setPuzzlePhase("dragging");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
setLoading(false);
|
|
250
|
+
}}
|
|
251
|
+
labelText={isTranslationReady ? t("WIDGET.I_AM_HUMAN") : ""}
|
|
252
|
+
error={state.error?.message}
|
|
253
|
+
aria-label="human checkbox"
|
|
254
|
+
loading={loading || puzzlePhase === "submitting"}
|
|
255
|
+
/>
|
|
256
|
+
)}
|
|
257
|
+
</>
|
|
258
|
+
);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export default Procaptcha;
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
import type { PuzzleEvent } from "@prosopo/types";
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
17
|
+
|
|
18
|
+
interface PuzzleCanvasProps {
|
|
19
|
+
originX: number;
|
|
20
|
+
originY: number;
|
|
21
|
+
targetX: number;
|
|
22
|
+
targetY: number;
|
|
23
|
+
onComplete: (
|
|
24
|
+
finalX: number,
|
|
25
|
+
finalY: number,
|
|
26
|
+
puzzleEvents: PuzzleEvent[],
|
|
27
|
+
) => void;
|
|
28
|
+
showRetry: boolean;
|
|
29
|
+
submitting: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const CONTAINER_WIDTH = 300;
|
|
33
|
+
const CONTAINER_HEIGHT = 200;
|
|
34
|
+
const TARGET_SIZE = 30;
|
|
35
|
+
const PIECE_SIZE = 24;
|
|
36
|
+
|
|
37
|
+
const SHAKE_KEYFRAMES = `
|
|
38
|
+
@keyframes prosopo-puzzle-shake {
|
|
39
|
+
0%, 100% { transform: translateX(0); }
|
|
40
|
+
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
|
|
41
|
+
20%, 40%, 60%, 80% { transform: translateX(4px); }
|
|
42
|
+
}
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
export const PuzzleCanvas = ({
|
|
46
|
+
originX,
|
|
47
|
+
originY,
|
|
48
|
+
targetX,
|
|
49
|
+
targetY,
|
|
50
|
+
onComplete,
|
|
51
|
+
showRetry,
|
|
52
|
+
submitting,
|
|
53
|
+
}: PuzzleCanvasProps) => {
|
|
54
|
+
const [posX, setPosX] = useState<number>(originX);
|
|
55
|
+
const [posY, setPosY] = useState<number>(originY);
|
|
56
|
+
const isDragging = useRef<boolean>(false);
|
|
57
|
+
const puzzleEvents = useRef<PuzzleEvent[]>([]);
|
|
58
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
59
|
+
const offsetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
|
60
|
+
const [visible, setVisible] = useState(false);
|
|
61
|
+
const [shaking, setShaking] = useState(false);
|
|
62
|
+
|
|
63
|
+
// Reset piece position when challenge data changes (new puzzle on retry)
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
setPosX(originX);
|
|
66
|
+
setPosY(originY);
|
|
67
|
+
}, [originX, originY]);
|
|
68
|
+
|
|
69
|
+
// Trigger entrance animation after mount
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const frame = requestAnimationFrame(() => setVisible(true));
|
|
72
|
+
return () => cancelAnimationFrame(frame);
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
// Trigger shake on retry
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (showRetry) {
|
|
78
|
+
setShaking(true);
|
|
79
|
+
const timer = setTimeout(() => setShaking(false), 500);
|
|
80
|
+
return () => clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
return () => {};
|
|
83
|
+
}, [showRetry]);
|
|
84
|
+
|
|
85
|
+
const clamp = useCallback(
|
|
86
|
+
(value: number, min: number, max: number): number => {
|
|
87
|
+
return Math.max(min, Math.min(max, value));
|
|
88
|
+
},
|
|
89
|
+
[],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const getContainerOffset = useCallback((): { x: number; y: number } => {
|
|
93
|
+
if (containerRef.current) {
|
|
94
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
95
|
+
return { x: rect.left, y: rect.top };
|
|
96
|
+
}
|
|
97
|
+
return { x: 0, y: 0 };
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
const handleMoveEvent = useCallback(
|
|
101
|
+
(clientX: number, clientY: number) => {
|
|
102
|
+
if (!isDragging.current) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const containerOffset = getContainerOffset();
|
|
107
|
+
const newX = clamp(
|
|
108
|
+
clientX - containerOffset.x - offsetRef.current.x,
|
|
109
|
+
0,
|
|
110
|
+
CONTAINER_WIDTH,
|
|
111
|
+
);
|
|
112
|
+
const newY = clamp(
|
|
113
|
+
clientY - containerOffset.y - offsetRef.current.y,
|
|
114
|
+
0,
|
|
115
|
+
CONTAINER_HEIGHT,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
setPosX(newX);
|
|
119
|
+
setPosY(newY);
|
|
120
|
+
|
|
121
|
+
puzzleEvents.current.push({ x: newX, y: newY, t: Date.now() });
|
|
122
|
+
},
|
|
123
|
+
[clamp, getContainerOffset],
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const handleEndEvent = useCallback(() => {
|
|
127
|
+
if (!isDragging.current) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
isDragging.current = false;
|
|
132
|
+
|
|
133
|
+
const currentEvents = [...puzzleEvents.current];
|
|
134
|
+
const lastEvent = currentEvents[currentEvents.length - 1];
|
|
135
|
+
const finalX = lastEvent ? lastEvent.x : originX;
|
|
136
|
+
const finalY = lastEvent ? lastEvent.y : originY;
|
|
137
|
+
|
|
138
|
+
onComplete(finalX, finalY, currentEvents);
|
|
139
|
+
}, [onComplete, originX, originY]);
|
|
140
|
+
|
|
141
|
+
const handleMouseMove = useCallback(
|
|
142
|
+
(event: MouseEvent) => {
|
|
143
|
+
handleMoveEvent(event.clientX, event.clientY);
|
|
144
|
+
},
|
|
145
|
+
[handleMoveEvent],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const handleTouchMove = useCallback(
|
|
149
|
+
(event: TouchEvent) => {
|
|
150
|
+
const touch = event.touches[0];
|
|
151
|
+
if (touch) {
|
|
152
|
+
handleMoveEvent(touch.clientX, touch.clientY);
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
[handleMoveEvent],
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const handleMouseUp = useCallback(() => {
|
|
159
|
+
handleEndEvent();
|
|
160
|
+
}, [handleEndEvent]);
|
|
161
|
+
|
|
162
|
+
const handleTouchEnd = useCallback(() => {
|
|
163
|
+
handleEndEvent();
|
|
164
|
+
}, [handleEndEvent]);
|
|
165
|
+
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
document.addEventListener("mousemove", handleMouseMove);
|
|
168
|
+
document.addEventListener("mouseup", handleMouseUp);
|
|
169
|
+
document.addEventListener("touchmove", handleTouchMove);
|
|
170
|
+
document.addEventListener("touchend", handleTouchEnd);
|
|
171
|
+
|
|
172
|
+
return () => {
|
|
173
|
+
document.removeEventListener("mousemove", handleMouseMove);
|
|
174
|
+
document.removeEventListener("mouseup", handleMouseUp);
|
|
175
|
+
document.removeEventListener("touchmove", handleTouchMove);
|
|
176
|
+
document.removeEventListener("touchend", handleTouchEnd);
|
|
177
|
+
};
|
|
178
|
+
}, [handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
|
|
179
|
+
|
|
180
|
+
const handlePieceMouseDown = useCallback(
|
|
181
|
+
(event: React.MouseEvent<HTMLDivElement>) => {
|
|
182
|
+
if (submitting) return;
|
|
183
|
+
isDragging.current = true;
|
|
184
|
+
puzzleEvents.current = [];
|
|
185
|
+
const containerOffset = getContainerOffset();
|
|
186
|
+
offsetRef.current = {
|
|
187
|
+
x: event.clientX - containerOffset.x - posX,
|
|
188
|
+
y: event.clientY - containerOffset.y - posY,
|
|
189
|
+
};
|
|
190
|
+
},
|
|
191
|
+
[getContainerOffset, posX, posY, submitting],
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const handlePieceTouchStart = useCallback(
|
|
195
|
+
(event: React.TouchEvent<HTMLDivElement>) => {
|
|
196
|
+
if (submitting) return;
|
|
197
|
+
const touch = event.touches[0];
|
|
198
|
+
if (touch) {
|
|
199
|
+
isDragging.current = true;
|
|
200
|
+
puzzleEvents.current = [];
|
|
201
|
+
const containerOffset = getContainerOffset();
|
|
202
|
+
offsetRef.current = {
|
|
203
|
+
x: touch.clientX - containerOffset.x - posX,
|
|
204
|
+
y: touch.clientY - containerOffset.y - posY,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
[getContainerOffset, posX, posY, submitting],
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
const instructionText = showRetry
|
|
212
|
+
? "Not quite \u2014 try again"
|
|
213
|
+
: "Drag the piece to the target";
|
|
214
|
+
|
|
215
|
+
const headerBorderColor = showRetry
|
|
216
|
+
? "rgba(220, 53, 69, 0.4)"
|
|
217
|
+
: "transparent";
|
|
218
|
+
|
|
219
|
+
const headerTextColor = showRetry ? "#dc3545" : "#333";
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<div
|
|
223
|
+
style={{
|
|
224
|
+
position: "fixed",
|
|
225
|
+
inset: 0,
|
|
226
|
+
zIndex: 2147483646,
|
|
227
|
+
display: "flex",
|
|
228
|
+
alignItems: "center",
|
|
229
|
+
justifyContent: "center",
|
|
230
|
+
backgroundColor: visible ? "rgba(0, 0, 0, 0.4)" : "rgba(0, 0, 0, 0)",
|
|
231
|
+
transition: "background-color 0.3s ease",
|
|
232
|
+
}}
|
|
233
|
+
>
|
|
234
|
+
{/* Inject shake keyframes */}
|
|
235
|
+
<style>{SHAKE_KEYFRAMES}</style>
|
|
236
|
+
|
|
237
|
+
<div
|
|
238
|
+
style={{
|
|
239
|
+
display: "flex",
|
|
240
|
+
flexDirection: "column",
|
|
241
|
+
alignItems: "center",
|
|
242
|
+
gap: "0",
|
|
243
|
+
zIndex: 2147483647,
|
|
244
|
+
opacity: visible ? 1 : 0,
|
|
245
|
+
transform: visible ? "scale(1)" : "scale(0.9)",
|
|
246
|
+
transition: "opacity 0.3s ease, transform 0.3s ease",
|
|
247
|
+
animation: shaking ? "prosopo-puzzle-shake 0.5s ease" : "none",
|
|
248
|
+
}}
|
|
249
|
+
>
|
|
250
|
+
{/* Instruction text */}
|
|
251
|
+
<div
|
|
252
|
+
style={{
|
|
253
|
+
backgroundColor: "#fff",
|
|
254
|
+
borderRadius: "8px 8px 0 0",
|
|
255
|
+
padding: "12px 20px",
|
|
256
|
+
width: `${CONTAINER_WIDTH}px`,
|
|
257
|
+
boxSizing: "border-box",
|
|
258
|
+
textAlign: "center",
|
|
259
|
+
fontFamily:
|
|
260
|
+
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
261
|
+
fontSize: "14px",
|
|
262
|
+
fontWeight: 500,
|
|
263
|
+
color: headerTextColor,
|
|
264
|
+
borderBottom: `2px solid ${headerBorderColor}`,
|
|
265
|
+
boxShadow:
|
|
266
|
+
"0px 11px 15px -7px rgba(0,0,0,0.2), 0px 24px 38px 3px rgba(0,0,0,0.14), 0px 9px 46px 8px rgba(0,0,0,0.12)",
|
|
267
|
+
transition: "color 0.3s ease, border-color 0.3s ease",
|
|
268
|
+
}}
|
|
269
|
+
>
|
|
270
|
+
{instructionText}
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
{/* Puzzle area */}
|
|
274
|
+
<div
|
|
275
|
+
ref={containerRef}
|
|
276
|
+
style={{
|
|
277
|
+
position: "relative",
|
|
278
|
+
width: `${CONTAINER_WIDTH}px`,
|
|
279
|
+
height: `${CONTAINER_HEIGHT}px`,
|
|
280
|
+
background:
|
|
281
|
+
"linear-gradient(135deg, #e8eaf6 0%, #c5cae9 50%, #e8eaf6 100%)",
|
|
282
|
+
borderRadius: "0 0 8px 8px",
|
|
283
|
+
overflow: "hidden",
|
|
284
|
+
userSelect: "none",
|
|
285
|
+
boxShadow:
|
|
286
|
+
"0px 11px 15px -7px rgba(0,0,0,0.2), 0px 24px 38px 3px rgba(0,0,0,0.14), 0px 9px 46px 8px rgba(0,0,0,0.12)",
|
|
287
|
+
opacity: submitting ? 0.6 : 1,
|
|
288
|
+
pointerEvents: submitting ? "none" : "auto",
|
|
289
|
+
transition: "opacity 0.2s ease",
|
|
290
|
+
}}
|
|
291
|
+
>
|
|
292
|
+
{/* Target zone */}
|
|
293
|
+
<div
|
|
294
|
+
style={{
|
|
295
|
+
position: "absolute",
|
|
296
|
+
left: `${targetX - TARGET_SIZE / 2}px`,
|
|
297
|
+
top: `${targetY - TARGET_SIZE / 2}px`,
|
|
298
|
+
width: `${TARGET_SIZE}px`,
|
|
299
|
+
height: `${TARGET_SIZE}px`,
|
|
300
|
+
borderRadius: "50%",
|
|
301
|
+
border: "2px dashed rgba(74, 144, 217, 0.6)",
|
|
302
|
+
backgroundColor: "rgba(74, 144, 217, 0.08)",
|
|
303
|
+
boxSizing: "border-box",
|
|
304
|
+
}}
|
|
305
|
+
/>
|
|
306
|
+
{/* Puzzle piece */}
|
|
307
|
+
<div
|
|
308
|
+
onMouseDown={handlePieceMouseDown}
|
|
309
|
+
onTouchStart={handlePieceTouchStart}
|
|
310
|
+
style={{
|
|
311
|
+
position: "absolute",
|
|
312
|
+
left: `${posX - PIECE_SIZE / 2}px`,
|
|
313
|
+
top: `${posY - PIECE_SIZE / 2}px`,
|
|
314
|
+
width: `${PIECE_SIZE}px`,
|
|
315
|
+
height: `${PIECE_SIZE}px`,
|
|
316
|
+
borderRadius: "50%",
|
|
317
|
+
background:
|
|
318
|
+
"radial-gradient(circle at 40% 40%, #6ab0ff, #4a90d9)",
|
|
319
|
+
cursor: submitting
|
|
320
|
+
? "default"
|
|
321
|
+
: isDragging.current
|
|
322
|
+
? "grabbing"
|
|
323
|
+
: "grab",
|
|
324
|
+
boxShadow: isDragging.current
|
|
325
|
+
? "0 4px 12px rgba(74, 144, 217, 0.5)"
|
|
326
|
+
: "0 2px 6px rgba(74, 144, 217, 0.3)",
|
|
327
|
+
transition: isDragging.current
|
|
328
|
+
? "none"
|
|
329
|
+
: "box-shadow 0.2s ease, left 0.3s ease, top 0.3s ease",
|
|
330
|
+
}}
|
|
331
|
+
/>
|
|
332
|
+
</div>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
);
|
|
336
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Copyright 2021-2026 Prosopo (UK) Ltd.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
export * from "./components/ProcaptchaWidget.js";
|
|
15
|
+
export * from "./components/ProcaptchaPuzzle.js";
|