@prosopo/procaptcha-bundle 4.3.0 → 4.4.1

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.
Files changed (43) hide show
  1. package/.turbo/turbo-build$colon$cjs.log +6 -6
  2. package/.turbo/turbo-build$colon$tsc.log +26 -26
  3. package/.turbo/turbo-build.log +15 -15
  4. package/CHANGELOG.md +33 -0
  5. package/dist/cjs/index.cjs +56 -11
  6. package/dist/cjs/util/captcha/captchaRenderer.cjs +18 -1
  7. package/dist/cjs/util/configCreator.cjs +4 -2
  8. package/dist/cjs/util/widgetFactory.cjs +8 -5
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +56 -11
  12. package/dist/index.js.map +1 -1
  13. package/dist/tests/bind.unit.test.d.ts +2 -0
  14. package/dist/tests/bind.unit.test.d.ts.map +1 -0
  15. package/dist/tests/bind.unit.test.js +166 -0
  16. package/dist/tests/bind.unit.test.js.map +1 -0
  17. package/dist/tests/detectorPrefetch.unit.test.js.map +1 -1
  18. package/dist/tests/reset.unit.test.js +6 -2
  19. package/dist/tests/reset.unit.test.js.map +1 -1
  20. package/dist/tests/start.unit.test.js +6 -3
  21. package/dist/tests/start.unit.test.js.map +1 -1
  22. package/dist/util/captcha/captchaRenderer.d.ts +1 -1
  23. package/dist/util/captcha/captchaRenderer.d.ts.map +1 -1
  24. package/dist/util/captcha/captchaRenderer.js +18 -1
  25. package/dist/util/captcha/captchaRenderer.js.map +1 -1
  26. package/dist/util/configCreator.d.ts +14 -2
  27. package/dist/util/configCreator.d.ts.map +1 -1
  28. package/dist/util/configCreator.js +5 -3
  29. package/dist/util/configCreator.js.map +1 -1
  30. package/dist/util/widgetFactory.d.ts +7 -2
  31. package/dist/util/widgetFactory.d.ts.map +1 -1
  32. package/dist/util/widgetFactory.js +8 -5
  33. package/dist/util/widgetFactory.js.map +1 -1
  34. package/package.json +7 -7
  35. package/src/index.ts +92 -18
  36. package/src/tests/bind.unit.test.ts +227 -0
  37. package/src/tests/detectorPrefetch.unit.test.ts +2 -2
  38. package/src/tests/reset.unit.test.ts +9 -3
  39. package/src/tests/start.unit.test.ts +7 -4
  40. package/src/util/captcha/captchaRenderer.tsx +31 -13
  41. package/src/util/configCreator.ts +29 -11
  42. package/src/util/widgetFactory.ts +10 -3
  43. package/tsconfig.tsbuildinfo +1 -1
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@ import { at } from "@prosopo/util";
24
24
  import type { Root } from "react-dom/client";
25
25
  import { extractParams, getProcaptchaScript } from "./util/config.js";
26
26
  import { resolveStartMode } from "./util/startMode.js";
27
- import { WidgetFactory } from "./util/widgetFactory.js";
27
+ import { type CreatedWidget, WidgetFactory } from "./util/widgetFactory.js";
28
28
  import { WidgetThemeResolver } from "./util/widgetThemeResolver.js";
29
29
 
30
30
  const BUNDLE_NAMES = ["procaptcha.bundle.iife.js", "procaptcha.bundle.js"];
@@ -43,9 +43,12 @@ const BUNDLE_NAMES = ["procaptcha.bundle.iife.js", "procaptcha.bundle.js"];
43
43
  interface WidgetEntry {
44
44
  root: Root;
45
45
  element: Element;
46
+ /** The element the widget listens on; a targeted execute() is dispatched here. */
47
+ target: HTMLElement;
46
48
  renderOptions: ProcaptchaRenderOptions;
47
49
  isWeb2: boolean;
48
50
  invisible: boolean;
51
+ unbindTrigger?: () => void;
49
52
  }
50
53
 
51
54
  const procaptchaWidgets = new Map<string, WidgetEntry>();
@@ -54,17 +57,18 @@ let widgetIdCounter = 0;
54
57
  const nextWidgetId = (): string => `procaptcha-widget-${widgetIdCounter++}`;
55
58
 
56
59
  const registerWidgets = (
57
- roots: Root[],
60
+ widgets: CreatedWidget[],
58
61
  elements: Element[],
59
62
  renderOptions: ProcaptchaRenderOptions,
60
63
  isWeb2: boolean,
61
64
  invisible: boolean,
62
65
  ): string[] =>
63
- roots.map((root, index) => {
66
+ widgets.map(({ root, container }, index) => {
64
67
  const id = nextWidgetId();
65
68
  procaptchaWidgets.set(id, {
66
69
  root,
67
70
  element: at(elements, index),
71
+ target: container,
68
72
  renderOptions,
69
73
  isWeb2,
70
74
  invisible,
@@ -145,19 +149,26 @@ const implicitRender = async () => {
145
149
  startMode,
146
150
  };
147
151
 
148
- const root = await widgetFactory.createWidgets(
152
+ const widgets = await widgetFactory.createWidgets(
149
153
  elements,
150
154
  implicitRenderOptions,
151
155
  !(web3 === "true"),
152
156
  );
153
157
 
154
- registerWidgets(
155
- root,
158
+ const ids = registerWidgets(
159
+ widgets,
156
160
  elements,
157
161
  implicitRenderOptions,
158
162
  !(web3 === "true"),
159
163
  false,
160
164
  );
165
+
166
+ // `data-placement` is read per element by the renderer; `data-bind` is
167
+ // wired here because the trigger lives outside the widget.
168
+ ids.forEach((id, index) => {
169
+ const selector = at(elements, index).getAttribute("data-bind");
170
+ if (selector) bindTrigger(id, selector);
171
+ });
161
172
  }
162
173
 
163
174
  // Check for invisible mode indicators (procaptcha class on buttons)
@@ -183,19 +194,25 @@ const implicitRender = async () => {
183
194
  startMode,
184
195
  };
185
196
 
186
- const root = await widgetFactory.createWidgets(
197
+ const widgets = await widgetFactory.createWidgets(
187
198
  [button],
188
199
  buttonRenderOptions,
189
200
  true,
190
201
  true,
191
202
  );
192
203
 
193
- registerWidgets(root, [button], buttonRenderOptions, true, true);
204
+ const [id] = registerWidgets(
205
+ widgets,
206
+ [button],
207
+ buttonRenderOptions,
208
+ true,
209
+ true,
210
+ );
194
211
 
195
212
  // Add click event listener to the button
196
213
  button.addEventListener("click", async (event) => {
197
214
  event.preventDefault();
198
- execute();
215
+ execute(id);
199
216
  });
200
217
  }
201
218
  }
@@ -228,7 +245,7 @@ export const render = async (
228
245
  const invisible =
229
246
  hasInvisibleSize || element.tagName.toLowerCase() === "button";
230
247
 
231
- const roots = await widgetFactory.createWidgets(
248
+ const widgets = await widgetFactory.createWidgets(
232
249
  [element],
233
250
  renderOptions,
234
251
  isWeb2,
@@ -236,7 +253,7 @@ export const render = async (
236
253
  );
237
254
 
238
255
  const ids = registerWidgets(
239
- roots,
256
+ widgets,
240
257
  [element],
241
258
  renderOptions,
242
259
  isWeb2,
@@ -245,7 +262,10 @@ export const render = async (
245
262
 
246
263
  // Deliberately not `at()`: it throws on an empty array before it consults
247
264
  // `optional`, and zero roots is a legitimate outcome here.
248
- return ids[0];
265
+ const id = ids[0];
266
+ if (id && renderOptions.bind) bindTrigger(id, renderOptions.bind);
267
+
268
+ return id;
249
269
  };
250
270
 
251
271
  export default function ready(fn: () => void) {
@@ -262,8 +282,21 @@ export default function ready(fn: () => void) {
262
282
  }
263
283
  }
264
284
 
265
- export const execute = () => {
266
- const containers = findProcaptchaContainers();
285
+ /**
286
+ * Starts verification. With no id the event goes to `document` and every
287
+ * widget responds; with an id (as returned by `render()`) only that widget
288
+ * runs.
289
+ */
290
+ export const execute = (widgetId?: string) => {
291
+ const targeted =
292
+ undefined === widgetId ? undefined : procaptchaWidgets.get(widgetId);
293
+
294
+ if (undefined !== widgetId && !targeted) {
295
+ console.error(`No Procaptcha widget found with id ${widgetId}`);
296
+ return;
297
+ }
298
+
299
+ const containers = targeted ? [targeted.element] : findProcaptchaContainers();
267
300
 
268
301
  if (containers.length === 0) {
269
302
  console.error("No Procaptcha containers found for execution");
@@ -277,14 +310,50 @@ export const execute = () => {
277
310
  containerCount: containers.length,
278
311
  timestamp: Date.now(),
279
312
  },
280
- bubbles: true,
313
+ // A targeted event must not bubble to document, where every widget listens.
314
+ bubbles: !targeted,
281
315
  cancelable: true,
282
316
  });
283
317
 
318
+ if (targeted) {
319
+ targeted.target.dispatchEvent(executeEvent);
320
+ return;
321
+ }
322
+
284
323
  // Dispatch the event on the document
285
324
  document.dispatchEvent(executeEvent);
286
325
  };
287
326
 
327
+ /**
328
+ * The click's default is prevented so a submit button does not post the form
329
+ * before a token exists.
330
+ */
331
+ const bindTrigger = (widgetId: string, selector: string): void => {
332
+ const entry = procaptchaWidgets.get(widgetId);
333
+ if (!entry) {
334
+ console.error(`No Procaptcha widget found with id ${widgetId}`);
335
+ return;
336
+ }
337
+
338
+ const trigger = document.querySelector(selector);
339
+ if (!trigger) {
340
+ console.error(`Procaptcha: no element matches bind selector ${selector}`);
341
+ return;
342
+ }
343
+
344
+ // Binding the same widget twice would otherwise stack listeners and leave
345
+ // the earlier one behind, since only the last unbind is remembered.
346
+ entry.unbindTrigger?.();
347
+
348
+ const onClick = (event: Event) => {
349
+ event.preventDefault();
350
+ execute(widgetId);
351
+ };
352
+
353
+ trigger.addEventListener("click", onClick);
354
+ entry.unbindTrigger = () => trigger.removeEventListener("click", onClick);
355
+ };
356
+
288
357
  /** Starts a `startMode: "manual"` widget; omit the id to start all of them. */
289
358
  export const start = (widgetId?: string): void => {
290
359
  const ids: string[] =
@@ -417,15 +486,19 @@ export const reset = async (widgetId?: string): Promise<void> => {
417
486
 
418
487
  current.root.unmount();
419
488
 
420
- const [root] = await widgetFactory.createWidgets(
489
+ const [widget] = await widgetFactory.createWidgets(
421
490
  [current.element],
422
491
  current.renderOptions,
423
492
  current.isWeb2,
424
493
  current.invisible,
425
494
  );
426
495
 
427
- if (root) {
428
- procaptchaWidgets.set(id, { ...current, root });
496
+ if (widget) {
497
+ procaptchaWidgets.set(id, {
498
+ ...current,
499
+ root: widget.root,
500
+ target: widget.container,
501
+ });
429
502
  } else {
430
503
  procaptchaWidgets.delete(id);
431
504
  }
@@ -445,6 +518,7 @@ export const remove = (widgetId?: string): void => {
445
518
  for (const id of ids) {
446
519
  const entry = procaptchaWidgets.get(id);
447
520
  if (!entry) continue;
521
+ entry.unbindTrigger?.();
448
522
  entry.root.unmount();
449
523
  entry.element.innerHTML = "";
450
524
  procaptchaWidgets.delete(id);
@@ -0,0 +1,227 @@
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 { Root } from "react-dom/client";
16
+ import { beforeEach, describe, expect, it, vi } from "vitest";
17
+ import type { CreatedWidget } from "../util/widgetFactory.js";
18
+
19
+ const mocks = vi.hoisted(() => ({
20
+ prefetchDetector: vi.fn(),
21
+ createWidgets: vi.fn(),
22
+ }));
23
+
24
+ vi.mock("@prosopo/procaptcha-frictionless", () => ({
25
+ prefetchDetector: mocks.prefetchDetector,
26
+ }));
27
+
28
+ vi.mock("@prosopo/procaptcha-common", () => ({
29
+ getWindowCallback: vi.fn(),
30
+ pickIpMode: vi.fn(() => undefined),
31
+ }));
32
+
33
+ vi.mock("../util/widgetFactory.js", () => ({
34
+ WidgetFactory: vi.fn(function () {
35
+ return { createWidgets: mocks.createWidgets };
36
+ }),
37
+ }));
38
+
39
+ const { render, remove, execute } = await import("../index.js");
40
+
41
+ const SITE_KEY = "5CcNvLUdiXFpzKDMjThGLSK9rhWHA1H4EF3zrgkpkjAdqmuP";
42
+ const EXECUTE_EVENT = "procaptcha:execute";
43
+
44
+ const makeRoot = (): Root =>
45
+ ({ unmount: vi.fn(), render: vi.fn() }) as unknown as Root;
46
+
47
+ // Like the real factory, the widget lives in a child of the host element and
48
+ // listens there, so a targeted event must be dispatched on that child.
49
+ const createWidgetsLikeTheFactory = async (
50
+ hosts: Element[],
51
+ ): Promise<CreatedWidget[]> =>
52
+ hosts.map((host) => {
53
+ const container = document.createElement("div");
54
+ host.appendChild(container);
55
+ return { root: makeRoot(), container };
56
+ });
57
+
58
+ const listenLikeAWidget = (
59
+ host: Element,
60
+ ): { calls: () => number; stop: () => void } => {
61
+ const handler = vi.fn();
62
+ const target = host.firstElementChild;
63
+ if (!target) throw new Error("expected the factory to mount a container");
64
+ document.addEventListener(EXECUTE_EVENT, handler);
65
+ target.addEventListener(EXECUTE_EVENT, handler);
66
+ return {
67
+ calls: () => handler.mock.calls.length,
68
+ stop: () => {
69
+ document.removeEventListener(EXECUTE_EVENT, handler);
70
+ target.removeEventListener(EXECUTE_EVENT, handler);
71
+ },
72
+ };
73
+ };
74
+
75
+ beforeEach(async () => {
76
+ vi.clearAllMocks();
77
+ await remove();
78
+ document.body.innerHTML = "";
79
+ mocks.createWidgets.mockImplementation(createWidgetsLikeTheFactory);
80
+ });
81
+
82
+ describe("execute targeting", () => {
83
+ it("reaches every widget when called with no id", async () => {
84
+ const first = document.createElement("div");
85
+ first.className = "p-procaptcha";
86
+ const second = document.createElement("div");
87
+ second.className = "p-procaptcha";
88
+ document.body.append(first, second);
89
+ await render(first, { siteKey: SITE_KEY });
90
+ await render(second, { siteKey: SITE_KEY });
91
+
92
+ const a = listenLikeAWidget(first);
93
+ const b = listenLikeAWidget(second);
94
+ execute();
95
+
96
+ expect(a.calls()).toBe(1);
97
+ expect(b.calls()).toBe(1);
98
+ a.stop();
99
+ b.stop();
100
+ });
101
+
102
+ it("reaches only the named widget when given an id", async () => {
103
+ const first = document.createElement("div");
104
+ const second = document.createElement("div");
105
+ document.body.append(first, second);
106
+ const firstId = await render(first, { siteKey: SITE_KEY });
107
+ await render(second, { siteKey: SITE_KEY });
108
+
109
+ const a = listenLikeAWidget(first);
110
+ const b = listenLikeAWidget(second);
111
+ execute(firstId);
112
+
113
+ expect(a.calls()).toBe(1);
114
+ expect(b.calls()).toBe(0);
115
+ a.stop();
116
+ b.stop();
117
+ });
118
+
119
+ it("does not bubble a targeted event up to document", async () => {
120
+ const element = document.createElement("div");
121
+ document.body.appendChild(element);
122
+ const widgetId = await render(element, { siteKey: SITE_KEY });
123
+
124
+ const documentOnly = vi.fn();
125
+ document.addEventListener(EXECUTE_EVENT, documentOnly);
126
+ execute(widgetId);
127
+
128
+ expect(documentOnly).not.toHaveBeenCalled();
129
+ document.removeEventListener(EXECUTE_EVENT, documentOnly);
130
+ });
131
+
132
+ it("reports an unknown id rather than firing every widget", async () => {
133
+ const element = document.createElement("div");
134
+ document.body.appendChild(element);
135
+ await render(element, { siteKey: SITE_KEY });
136
+
137
+ const error = vi
138
+ .spyOn(console, "error")
139
+ .mockImplementation(() => undefined);
140
+ const listener = listenLikeAWidget(element);
141
+ execute("procaptcha-widget-does-not-exist");
142
+
143
+ expect(listener.calls()).toBe(0);
144
+ expect(error).toHaveBeenCalled();
145
+ listener.stop();
146
+ error.mockRestore();
147
+ });
148
+ });
149
+
150
+ describe("bind", () => {
151
+ it("runs only the bound widget when its button is clicked", async () => {
152
+ const button = document.createElement("button");
153
+ button.id = "pay";
154
+ const bound = document.createElement("div");
155
+ const other = document.createElement("div");
156
+ document.body.append(button, bound, other);
157
+
158
+ await render(bound, { siteKey: SITE_KEY, bind: "#pay" });
159
+ await render(other, { siteKey: SITE_KEY });
160
+
161
+ const a = listenLikeAWidget(bound);
162
+ const b = listenLikeAWidget(other);
163
+ button.click();
164
+
165
+ expect(a.calls()).toBe(1);
166
+ expect(b.calls()).toBe(0);
167
+ a.stop();
168
+ b.stop();
169
+ });
170
+
171
+ it("stops the button's default action", async () => {
172
+ const form = document.createElement("form");
173
+ const button = document.createElement("button");
174
+ button.id = "submit-it";
175
+ button.type = "submit";
176
+ form.appendChild(button);
177
+ const element = document.createElement("div");
178
+ document.body.append(form, element);
179
+
180
+ await render(element, { siteKey: SITE_KEY, bind: "#submit-it" });
181
+
182
+ const event = new MouseEvent("click", {
183
+ bubbles: true,
184
+ cancelable: true,
185
+ });
186
+ button.dispatchEvent(event);
187
+
188
+ expect(event.defaultPrevented).toBe(true);
189
+ });
190
+
191
+ it("detaches the listener when the widget is removed", async () => {
192
+ const button = document.createElement("button");
193
+ button.id = "gone";
194
+ const element = document.createElement("div");
195
+ document.body.append(button, element);
196
+
197
+ await render(element, { siteKey: SITE_KEY, bind: "#gone" });
198
+ await remove();
199
+
200
+ const error = vi
201
+ .spyOn(console, "error")
202
+ .mockImplementation(() => undefined);
203
+ button.click();
204
+
205
+ expect(error).not.toHaveBeenCalled();
206
+ error.mockRestore();
207
+ });
208
+
209
+ it("reports a selector that matches nothing and still renders", async () => {
210
+ const error = vi
211
+ .spyOn(console, "error")
212
+ .mockImplementation(() => undefined);
213
+ const element = document.createElement("div");
214
+ document.body.appendChild(element);
215
+
216
+ const widgetId = await render(element, {
217
+ siteKey: SITE_KEY,
218
+ bind: "#nothing-here",
219
+ });
220
+
221
+ expect(widgetId).toBeTruthy();
222
+ expect(error).toHaveBeenCalledWith(
223
+ expect.stringContaining("#nothing-here"),
224
+ );
225
+ error.mockRestore();
226
+ });
227
+ });
@@ -19,8 +19,8 @@
19
19
  * to assigning a detector inline on the widget's critical path.
20
20
  */
21
21
 
22
- import type { Root } from "react-dom/client";
23
22
  import { beforeEach, describe, expect, it, vi } from "vitest";
23
+ import type { CreatedWidget } from "../util/widgetFactory.js";
24
24
 
25
25
  const mocks = vi.hoisted(() => ({
26
26
  prefetchDetector: vi.fn(),
@@ -59,7 +59,7 @@ const renderAndFlush = async (
59
59
 
60
60
  beforeEach(() => {
61
61
  vi.clearAllMocks();
62
- mocks.createWidgets.mockResolvedValue([] as Root[]);
62
+ mocks.createWidgets.mockResolvedValue([] as CreatedWidget[]);
63
63
  });
64
64
 
65
65
  describe("render", () => {
@@ -22,6 +22,7 @@
22
22
 
23
23
  import type { Root } from "react-dom/client";
24
24
  import { beforeEach, describe, expect, it, vi } from "vitest";
25
+ import type { CreatedWidget } from "../util/widgetFactory.js";
25
26
 
26
27
  const mocks = vi.hoisted(() => ({
27
28
  prefetchDetector: vi.fn(),
@@ -50,10 +51,15 @@ const SITE_KEY = "5CcNvLUdiXFpzKDMjThGLSK9rhWHA1H4EF3zrgkpkjAdqmuP";
50
51
  const makeRoot = (): Root =>
51
52
  ({ unmount: vi.fn(), render: vi.fn() }) as unknown as Root;
52
53
 
54
+ const makeWidget = (root: Root = makeRoot()): CreatedWidget => ({
55
+ root,
56
+ container: document.createElement("div"),
57
+ });
58
+
53
59
  /** Each createWidgets call yields a distinct root, as the real factory does. */
54
60
  const queueRoots = (...roots: Root[]): void => {
55
61
  for (const root of roots) {
56
- mocks.createWidgets.mockResolvedValueOnce([root]);
62
+ mocks.createWidgets.mockResolvedValueOnce([makeWidget(root)]);
57
63
  }
58
64
  };
59
65
 
@@ -62,7 +68,7 @@ beforeEach(async () => {
62
68
  // Drop any widgets registered by a previous test — module state persists
63
69
  // across tests in the same file.
64
70
  await remove();
65
- mocks.createWidgets.mockResolvedValue([makeRoot()]);
71
+ mocks.createWidgets.mockResolvedValue([makeWidget()]);
66
72
  });
67
73
 
68
74
  describe("render", () => {
@@ -78,7 +84,7 @@ describe("render", () => {
78
84
  });
79
85
 
80
86
  it("returns undefined when the factory creates no widget", async () => {
81
- mocks.createWidgets.mockResolvedValueOnce([] as Root[]);
87
+ mocks.createWidgets.mockResolvedValueOnce([] as CreatedWidget[]);
82
88
 
83
89
  const widgetId = await render(document.createElement("div"), {
84
90
  siteKey: SITE_KEY,
@@ -19,6 +19,7 @@ import {
19
19
  } from "@prosopo/types";
20
20
  import type { Root } from "react-dom/client";
21
21
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
+ import type { CreatedWidget } from "../util/widgetFactory.js";
22
23
 
23
24
  const mocks = vi.hoisted(() => ({
24
25
  prefetchDetector: vi.fn(),
@@ -44,8 +45,10 @@ const { render, remove, start } = await import("../index.js");
44
45
 
45
46
  const SITE_KEY = "5CcNvLUdiXFpzKDMjThGLSK9rhWHA1H4EF3zrgkpkjAdqmuP";
46
47
 
47
- const makeRoot = (): Root =>
48
- ({ unmount: vi.fn(), render: vi.fn() }) as unknown as Root;
48
+ const makeWidget = (): CreatedWidget => ({
49
+ root: { unmount: vi.fn(), render: vi.fn() } as unknown as Root,
50
+ container: document.createElement("div"),
51
+ });
49
52
 
50
53
  const flush = (): Promise<void> =>
51
54
  new Promise((resolve) => setTimeout(resolve, 0));
@@ -53,7 +56,7 @@ const flush = (): Promise<void> =>
53
56
  const renderWidget = async (
54
57
  options: Partial<Parameters<typeof render>[1]> = {},
55
58
  ): Promise<{ element: HTMLDivElement; id: string }> => {
56
- mocks.createWidgets.mockResolvedValueOnce([makeRoot()]);
59
+ mocks.createWidgets.mockResolvedValueOnce([makeWidget()]);
57
60
  const element = document.createElement("div");
58
61
  const id = await render(element, { siteKey: SITE_KEY, ...options });
59
62
  if (!id) throw new Error("expected render to return a widget id");
@@ -141,7 +144,7 @@ describe("detector prefetch", () => {
141
144
  });
142
145
 
143
146
  it("is skipped when the element asks for manual mode", async () => {
144
- mocks.createWidgets.mockResolvedValueOnce([makeRoot()]);
147
+ mocks.createWidgets.mockResolvedValueOnce([makeWidget()]);
145
148
  const element = document.createElement("div");
146
149
  element.setAttribute("data-start-mode", "manual");
147
150
  await render(element, { siteKey: SITE_KEY });
@@ -15,10 +15,12 @@
15
15
  import createCache, { type EmotionCache } from "@emotion/cache";
16
16
  import { CacheProvider } from "@emotion/react";
17
17
  import type { Ti18n } from "@prosopo/locale";
18
- import type {
19
- Callbacks,
20
- ProcaptchaClientConfigOutput,
21
- ProcaptchaRenderOptions,
18
+ import {
19
+ type Callbacks,
20
+ Placement,
21
+ type PlacementType,
22
+ type ProcaptchaClientConfigOutput,
23
+ type ProcaptchaRenderOptions,
22
24
  } from "@prosopo/types";
23
25
  import type { ReactNode } from "react";
24
26
  import { type Root, createRoot } from "react-dom/client";
@@ -29,6 +31,18 @@ import { setStartMode } from "../startMode.js";
29
31
  import { setValidChallengeLength } from "../timeout.js";
30
32
  import { BundleCaptcha } from "./components/bundleCaptcha.js";
31
33
 
34
+ const resolveRequestedPlacement = (
35
+ element: Element,
36
+ renderOptions: ProcaptchaRenderOptions,
37
+ ): PlacementType | undefined => {
38
+ const requested =
39
+ renderOptions.placement ?? element.getAttribute("data-placement");
40
+ if (!requested) return undefined;
41
+
42
+ const parsed = Placement.safeParse(requested);
43
+ return parsed.success ? parsed.data : undefined;
44
+ };
45
+
32
46
  interface RenderSettings {
33
47
  identifierPrefix: string;
34
48
  emotionCacheKey: string;
@@ -47,16 +61,20 @@ class CaptchaRenderer {
47
61
  widgetContainer: HTMLElement,
48
62
  sourceElement?: Element,
49
63
  ): Root {
50
- const config = createConfig(
51
- renderOptions.siteKey,
52
- renderOptions.theme,
53
- renderOptions.language,
54
- isWeb2,
64
+ const config = createConfig({
65
+ siteKey: renderOptions.siteKey,
66
+ theme: renderOptions.theme,
67
+ language: renderOptions.language,
68
+ web2: isWeb2,
55
69
  invisible,
56
- renderOptions.userAccountAddress,
57
- renderOptions.ipv4,
58
- renderOptions.ipv6,
59
- );
70
+ placement: resolveRequestedPlacement(
71
+ sourceElement || container,
72
+ renderOptions,
73
+ ),
74
+ userAccountAddress: renderOptions.userAccountAddress,
75
+ ipv4: renderOptions.ipv4,
76
+ ipv6: renderOptions.ipv6,
77
+ });
60
78
  this.readAndValidateSettings(
61
79
  sourceElement || container,
62
80
  config,
@@ -15,23 +15,39 @@
15
15
  import type { Languages } from "@prosopo/locale";
16
16
  import {
17
17
  EnvironmentTypesSchema,
18
+ type PlacementType,
18
19
  type ProcaptchaClientConfigOutput,
19
20
  ProcaptchaConfigSchema,
21
+ resolvePlacement,
20
22
  } from "@prosopo/types";
21
23
 
24
+ interface CreateConfigOptions {
25
+ siteKey?: string;
26
+ theme?: "light" | "dark";
27
+ language?: (typeof Languages)[keyof typeof Languages];
28
+ web2?: boolean;
29
+ invisible?: boolean;
30
+ placement?: PlacementType;
31
+ userAccountAddress?: string;
32
+ ipv4?: boolean;
33
+ ipv6?: boolean;
34
+ }
35
+
22
36
  function createConfig(
23
- siteKey?: string,
24
- theme: "light" | "dark" = "light",
25
- language?: (typeof Languages)[keyof typeof Languages],
26
- web2 = true,
27
- invisible = false,
28
- userAccountAddress?: string,
29
- ipv4 = false,
30
- ipv6 = false,
37
+ options: CreateConfigOptions = {},
31
38
  ): ProcaptchaClientConfigOutput {
32
- if (!siteKey) {
33
- siteKey = process.env.PROSOPO_SITE_KEY || "";
34
- }
39
+ const {
40
+ theme = "light",
41
+ language,
42
+ web2 = true,
43
+ invisible = false,
44
+ placement,
45
+ userAccountAddress,
46
+ ipv4 = false,
47
+ ipv6 = false,
48
+ } = options;
49
+
50
+ const siteKey = options.siteKey || process.env.PROSOPO_SITE_KEY || "";
35
51
 
36
52
  return ProcaptchaConfigSchema.parse({
37
53
  defaultEnvironment: process.env.PROSOPO_DEFAULT_ENVIRONMENT
@@ -45,6 +61,7 @@ function createConfig(
45
61
  mongoAtlasUri: process.env.PROSOPO_MONGO_EVENTS_URI || "",
46
62
  web2,
47
63
  mode: invisible ? "invisible" : "visible",
64
+ placement: resolvePlacement(placement, invisible),
48
65
  theme,
49
66
  language,
50
67
  ipv4,
@@ -53,3 +70,4 @@ function createConfig(
53
70
  }
54
71
 
55
72
  export { createConfig };
73
+ export type { CreateConfigOptions };