@usefillo/react 0.8.0 → 0.9.0

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/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { ComponentType, ReactNode, ReactElement } from 'react';
3
3
  import * as _usefillo_core from '@usefillo/core';
4
- import { FormSchema, FilloClient, ResponseData, FieldValue, FormPage, Block, FormStatus, FieldKind, Field, CustomField, FormTheme, FilloAppearance, FilloRendererStrings, FilloRespondent, FilloError, CodeForm, FormSettings, Condition, TextField, PhoneField, NumberField, ChoiceField, SelectOption, CheckboxField, RatingField, LinearScaleField, RankingField, SignatureField, DateField, FileUploadField as FileUploadField$1, HiddenField } from '@usefillo/core';
4
+ import { FormSchema, FilloClient, ResponseData, FieldValue, FormPage, Block, FormStatus, FieldKind, Field, CustomField, FormTheme, FilloAppearance, FilloRendererStrings, FilloRespondent, ChallengeConfig, FilloError, CodeForm, FormSettings, Condition, TextField, PhoneField, NumberField, ChoiceField, SelectOption, CheckboxField, RatingField, LinearScaleField, RankingField, SignatureField, DateField, FileUploadField as FileUploadField$1, HiddenField } from '@usefillo/core';
5
5
  export { CodeForm, Field, FieldValue, FileValue, FilloAppearance, FilloClient, FilloError, FilloJsxError, FilloRendererStrings, FilloSlot, FilloStrings, FormSchema, FormStatus, FormTheme, ProvisionWorkspaceResult, PublishedForm, ResponseData, SlotState, createClient, defineForm, provisionWorkspace, when } from '@usefillo/core';
6
6
 
7
7
  /**
@@ -136,6 +136,13 @@ interface FilloFormBaseProps {
136
136
  * loads).
137
137
  */
138
138
  respondent?: FilloRespondent;
139
+ /**
140
+ * Human-verification challenge config (public site key + provider). Normally
141
+ * the SDK reads this from the form fetch automatically; pass it explicitly
142
+ * only when you render an inline `form` schema and still want the widget (the
143
+ * hosted page does this). The SECRET key stays server-side — never passed here.
144
+ */
145
+ challenge?: ChallengeConfig;
139
146
  onChange?: (data: ResponseData) => void;
140
147
  onSubmitted?: (responseId: string | undefined, data: ResponseData) => void;
141
148
  /** Observe load and code-form sync failures (otherwise only logged). */
@@ -286,6 +293,12 @@ interface ControllerOptions {
286
293
  * dashboard, webhooks, and integrations can say who answered.
287
294
  */
288
295
  respondent?: FilloRespondent;
296
+ /** True when the form requires a human-verification challenge (Turnstile). */
297
+ challengeRequired?: boolean;
298
+ /** Read the current challenge token from the rendered widget (lazy). */
299
+ getChallengeToken?: () => string | undefined;
300
+ /** The server rejected the challenge — reset the widget for a fresh token. */
301
+ onChallengeFailed?: () => void;
289
302
  }
290
303
  /**
291
304
  * React binding for the framework-agnostic engine in @usefillo/core
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  // src/FilloForm.tsx
4
- import { useEffect as useEffect7, useId as useId2, useMemo as useMemo5, useRef as useRef6, useState as useState6 } from "react";
4
+ import { useCallback, useEffect as useEffect8, useId as useId2, useMemo as useMemo5, useRef as useRef7, useState as useState6 } from "react";
5
5
  import {
6
6
  FilloError as FilloError2,
7
7
  createClient,
@@ -10,7 +10,8 @@ import {
10
10
  needsExplicitSubmit,
11
11
  normalizeFormSchema,
12
12
  normalizeFormTheme,
13
- visibleFields as visibleFields2
13
+ reachableFields,
14
+ reachablePageSequence
14
15
  } from "@usefillo/core";
15
16
  import { resolveStrings, slotClass as slotClass3 } from "@usefillo/core";
16
17
 
@@ -127,7 +128,12 @@ function useFilloController(options) {
127
128
  surface: options.surface ?? "headless",
128
129
  // Wrapped so the engine (created once) always calls the latest resolver.
129
130
  resolveFormId: options.resolveFormId ? () => optsRef.current.resolveFormId() : void 0,
130
- respondent: options.respondent
131
+ respondent: options.respondent,
132
+ // Challenge wiring read through optsRef so the engine (created once) sees
133
+ // the latest token/reset each submit — the widget solves after mount.
134
+ challengeRequired: options.challengeRequired,
135
+ getChallengeToken: () => optsRef.current.getChallengeToken?.(),
136
+ onChallengeFailed: () => optsRef.current.onChallengeFailed?.()
131
137
  });
132
138
  }
133
139
  const store = storeRef.current;
@@ -276,6 +282,7 @@ function useCodeFormSync(form, client, onError, options) {
276
282
  formId: r.formId,
277
283
  status: r.status ?? null,
278
284
  branding: r.branding,
285
+ challenge: r.challenge,
279
286
  error: null,
280
287
  noticeError,
281
288
  staged: Boolean(r.staged),
@@ -325,6 +332,7 @@ function useCodeFormSync(form, client, onError, options) {
325
332
  formId: synced.formId,
326
333
  status: synced.status,
327
334
  branding: synced.branding,
335
+ challenge: synced.challenge,
328
336
  error: synced.error,
329
337
  noticeError: synced.noticeError,
330
338
  syncing: Boolean(syncKey) && !synced.settled,
@@ -1899,8 +1907,127 @@ function FormField({
1899
1907
  );
1900
1908
  }
1901
1909
 
1910
+ // src/turnstile.tsx
1911
+ import { useEffect as useEffect7, useRef as useRef6 } from "react";
1912
+ import { jsx as jsx5 } from "react/jsx-runtime";
1913
+ var SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
1914
+ var SCRIPT_ID = "fillo-turnstile-script";
1915
+ var scriptPromise = null;
1916
+ function turnstileGlobal() {
1917
+ return globalThis.turnstile;
1918
+ }
1919
+ function loadTurnstileScript() {
1920
+ if (typeof window === "undefined" || typeof document === "undefined") {
1921
+ return Promise.reject(new Error("Turnstile needs a browser"));
1922
+ }
1923
+ if (turnstileGlobal()) return Promise.resolve();
1924
+ if (scriptPromise) return scriptPromise;
1925
+ scriptPromise = new Promise((resolve, reject) => {
1926
+ const onReady = () => {
1927
+ if (turnstileGlobal()) resolve();
1928
+ else reject(new Error("Turnstile loaded without its global"));
1929
+ };
1930
+ const existing = document.getElementById(SCRIPT_ID);
1931
+ if (existing) {
1932
+ if (turnstileGlobal()) {
1933
+ resolve();
1934
+ return;
1935
+ }
1936
+ existing.addEventListener("load", onReady, { once: true });
1937
+ existing.addEventListener(
1938
+ "error",
1939
+ () => reject(new Error("Turnstile script failed to load")),
1940
+ { once: true }
1941
+ );
1942
+ return;
1943
+ }
1944
+ const script = document.createElement("script");
1945
+ script.id = SCRIPT_ID;
1946
+ script.src = SCRIPT_SRC;
1947
+ script.async = true;
1948
+ script.defer = true;
1949
+ script.addEventListener("load", onReady, { once: true });
1950
+ script.addEventListener(
1951
+ "error",
1952
+ () => {
1953
+ script.remove();
1954
+ scriptPromise = null;
1955
+ reject(new Error("Turnstile script failed to load"));
1956
+ },
1957
+ { once: true }
1958
+ );
1959
+ document.head.appendChild(script);
1960
+ });
1961
+ return scriptPromise;
1962
+ }
1963
+ function TurnstileWidget({
1964
+ siteKey,
1965
+ onToken,
1966
+ onError,
1967
+ registerReset,
1968
+ className
1969
+ }) {
1970
+ const containerRef = useRef6(null);
1971
+ const widgetIdRef = useRef6(null);
1972
+ const onTokenRef = useRef6(onToken);
1973
+ onTokenRef.current = onToken;
1974
+ const onErrorRef = useRef6(onError);
1975
+ onErrorRef.current = onError;
1976
+ const registerResetRef = useRef6(registerReset);
1977
+ registerResetRef.current = registerReset;
1978
+ useEffect7(() => {
1979
+ let cancelled = false;
1980
+ loadTurnstileScript().then(() => {
1981
+ if (cancelled || !containerRef.current) return;
1982
+ const api = turnstileGlobal();
1983
+ if (!api) {
1984
+ onErrorRef.current?.();
1985
+ return;
1986
+ }
1987
+ const clearAndReset = () => {
1988
+ onTokenRef.current(void 0);
1989
+ if (widgetIdRef.current) api.reset(widgetIdRef.current);
1990
+ };
1991
+ widgetIdRef.current = api.render(containerRef.current, {
1992
+ sitekey: siteKey,
1993
+ theme: "auto",
1994
+ callback: (token) => onTokenRef.current(token),
1995
+ "error-callback": () => {
1996
+ onTokenRef.current(void 0);
1997
+ onErrorRef.current?.();
1998
+ },
1999
+ "expired-callback": clearAndReset,
2000
+ "timeout-callback": clearAndReset
2001
+ });
2002
+ registerResetRef.current?.(clearAndReset);
2003
+ }).catch(() => {
2004
+ if (!cancelled) onErrorRef.current?.();
2005
+ });
2006
+ return () => {
2007
+ cancelled = true;
2008
+ const api = turnstileGlobal();
2009
+ if (widgetIdRef.current && api) {
2010
+ try {
2011
+ api.remove(widgetIdRef.current);
2012
+ } catch {
2013
+ }
2014
+ }
2015
+ widgetIdRef.current = null;
2016
+ onTokenRef.current(void 0);
2017
+ };
2018
+ }, [siteKey]);
2019
+ return /* @__PURE__ */ jsx5(
2020
+ "div",
2021
+ {
2022
+ ref: containerRef,
2023
+ className,
2024
+ "data-fillo": "turnstile"
2025
+ }
2026
+ );
2027
+ }
2028
+
1902
2029
  // src/FilloForm.tsx
1903
- import { Fragment as Fragment2, jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
2030
+ import { Fragment as Fragment2, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
1904
2031
  function themeProps(theme) {
1905
2032
  const safeTheme = normalizeFormTheme(theme);
1906
2033
  if (!safeTheme) return {};
@@ -1973,6 +2100,7 @@ function FilloForm(props) {
1973
2100
  formId: syncedFormId,
1974
2101
  status: syncedStatus,
1975
2102
  branding: syncedBranding,
2103
+ challenge: syncedChallenge,
1976
2104
  error: syncError,
1977
2105
  noticeError: syncNoticeError,
1978
2106
  syncing: codeFormSyncing,
@@ -1986,12 +2114,12 @@ function FilloForm(props) {
1986
2114
  });
1987
2115
  const renderResolvedSnapshot = Boolean(resolvedSchema && !isDevEnv());
1988
2116
  const inlineSchema = codeForm ? renderResolvedSnapshot ? resolvedSchema : codeForm.schema : form;
1989
- const onErrorRef = useRef6(onError);
1990
- useEffect7(() => {
2117
+ const onErrorRef = useRef7(onError);
2118
+ useEffect8(() => {
1991
2119
  onErrorRef.current = onError;
1992
2120
  });
1993
2121
  const needsFetch = !inlineSchema && Boolean(client && formId);
1994
- useEffect7(() => {
2122
+ useEffect8(() => {
1995
2123
  if (!needsFetch || !client || !formId) {
1996
2124
  setLoaded(null);
1997
2125
  setLoadFailure(null);
@@ -2007,7 +2135,8 @@ function FilloForm(props) {
2007
2135
  schema: published.schema,
2008
2136
  theme: published.theme,
2009
2137
  closed: Boolean(published.closed),
2010
- branding: published.branding ?? null
2138
+ branding: published.branding ?? null,
2139
+ challenge: published.challenge ?? null
2011
2140
  });
2012
2141
  }).catch((err) => {
2013
2142
  if (cancelled) return;
@@ -2029,25 +2158,25 @@ function FilloForm(props) {
2029
2158
  const theme = props.appearance?.theme ?? props.theme ?? resolvedCodeTheme ?? currentLoaded?.theme ?? void 0;
2030
2159
  if (normalized && !normalized.ok) {
2031
2160
  const error = new FilloError2(`This form could not be rendered: ${normalized.error}`, 422);
2032
- if (props.renderError) return /* @__PURE__ */ jsx5(Fragment2, { children: props.renderError(error) });
2033
- return /* @__PURE__ */ jsx5("div", { className: `fillo-form fillo-form--error ${props.className ?? ""}`, children: strings.renderFailed });
2161
+ if (props.renderError) return /* @__PURE__ */ jsx6(Fragment2, { children: props.renderError(error) });
2162
+ return /* @__PURE__ */ jsx6("div", { className: `fillo-form fillo-form--error ${props.className ?? ""}`, children: strings.renderFailed });
2034
2163
  }
2035
2164
  if (loadError) {
2036
2165
  if (props.renderError) {
2037
- return /* @__PURE__ */ jsx5(Fragment2, { children: props.renderError(loadError) });
2166
+ return /* @__PURE__ */ jsx6(Fragment2, { children: props.renderError(loadError) });
2038
2167
  }
2039
2168
  const message = loadError.status === 404 ? strings.loadFailedNotFound : loadError.status === 0 ? strings.loadFailedNetwork : strings.loadFailed;
2040
- return /* @__PURE__ */ jsx5("div", { className: `fillo-form fillo-form--error ${props.className ?? ""}`, children: message });
2169
+ return /* @__PURE__ */ jsx6("div", { className: `fillo-form fillo-form--error ${props.className ?? ""}`, children: message });
2041
2170
  }
2042
2171
  if (currentLoaded?.closed) {
2043
- return /* @__PURE__ */ jsx5("div", { className: `fillo-form fillo-form--closed ${props.className ?? ""}`, ...themeProps(theme), children: /* @__PURE__ */ jsx5("p", { className: "fillo-closed", children: strings.closed }) });
2172
+ return /* @__PURE__ */ jsx6("div", { className: `fillo-form fillo-form--closed ${props.className ?? ""}`, ...themeProps(theme), children: /* @__PURE__ */ jsx6("p", { className: "fillo-closed", children: strings.closed }) });
2044
2173
  }
2045
2174
  const canonicalSyncError = codeForm && syncError && !props.skipValidation ? syncError : null;
2046
2175
  if (canonicalSyncError && props.renderError) {
2047
- return /* @__PURE__ */ jsx5(Fragment2, { children: props.renderError(canonicalSyncError) });
2176
+ return /* @__PURE__ */ jsx6(Fragment2, { children: props.renderError(canonicalSyncError) });
2048
2177
  }
2049
2178
  if (canonicalSyncError && !isDevEnv()) {
2050
- return /* @__PURE__ */ jsx5(
2179
+ return /* @__PURE__ */ jsx6(
2051
2180
  "div",
2052
2181
  {
2053
2182
  className: `fillo-form fillo-form--error ${props.className ?? ""}`,
@@ -2062,8 +2191,8 @@ function FilloForm(props) {
2062
2191
  const notPublished = Boolean(codeForm) && syncedStatus === "draft" && !props.skipValidation;
2063
2192
  if (notPublished && !isDevEnv()) {
2064
2193
  const error = new FilloError2("This form isn't published yet.", 403);
2065
- if (props.renderError) return /* @__PURE__ */ jsx5(Fragment2, { children: props.renderError(error) });
2066
- return /* @__PURE__ */ jsx5("div", { className: `fillo-form fillo-form--closed ${props.className ?? ""}`, ...themeProps(theme), children: /* @__PURE__ */ jsx5("p", { className: "fillo-closed", children: strings.notLive }) });
2194
+ if (props.renderError) return /* @__PURE__ */ jsx6(Fragment2, { children: props.renderError(error) });
2195
+ return /* @__PURE__ */ jsx6("div", { className: `fillo-form fillo-form--closed ${props.className ?? ""}`, ...themeProps(theme), children: /* @__PURE__ */ jsx6("p", { className: "fillo-closed", children: strings.notLive }) });
2067
2196
  }
2068
2197
  const awaitingCanonical = Boolean(codeForm && codeFormSyncing && !props.skipValidation);
2069
2198
  if (!isDevEnv() && awaitingCanonical || !schema) {
@@ -2074,16 +2203,17 @@ function FilloForm(props) {
2074
2203
  "aria-busy": "true",
2075
2204
  ...themeProps(theme),
2076
2205
  children: [
2077
- /* @__PURE__ */ jsx5("div", { className: "fillo-skeleton" }),
2078
- /* @__PURE__ */ jsx5("div", { className: "fillo-skeleton" }),
2079
- /* @__PURE__ */ jsx5("div", { className: "fillo-skeleton fillo-skeleton--short" })
2206
+ /* @__PURE__ */ jsx6("div", { className: "fillo-skeleton" }),
2207
+ /* @__PURE__ */ jsx6("div", { className: "fillo-skeleton" }),
2208
+ /* @__PURE__ */ jsx6("div", { className: "fillo-skeleton fillo-skeleton--short" })
2080
2209
  ]
2081
2210
  }
2082
2211
  );
2083
2212
  }
2084
2213
  const branding = codeForm ? syncedBranding : currentLoaded?.branding;
2085
2214
  const poweredBy = branding?.poweredBy ?? true;
2086
- return /* @__PURE__ */ jsx5(
2215
+ const challenge = props.challenge ?? (codeForm ? syncedChallenge : currentLoaded?.challenge) ?? void 0;
2216
+ return /* @__PURE__ */ jsx6(
2087
2217
  ResolvedForm,
2088
2218
  {
2089
2219
  ...props,
@@ -2096,33 +2226,34 @@ function FilloForm(props) {
2096
2226
  brandHref: branding?.href,
2097
2227
  draftNotice: notPublished,
2098
2228
  stagedNotice: codeChangesStaged,
2099
- syncErrorNotice: syncError ?? syncNoticeError ?? void 0
2229
+ syncErrorNotice: syncError ?? syncNoticeError ?? void 0,
2230
+ challenge
2100
2231
  }
2101
2232
  );
2102
2233
  }
2103
2234
  function PoweredBy({ label, href }) {
2104
- return /* @__PURE__ */ jsx5("a", { className: "fillo-powered", href: badgeUrl(href), target: "_blank", rel: "noopener noreferrer", children: label ?? "Powered by Fillo" });
2235
+ return /* @__PURE__ */ jsx6("a", { className: "fillo-powered", href: badgeUrl(href), target: "_blank", rel: "noopener noreferrer", children: label ?? "Powered by Fillo" });
2105
2236
  }
2106
2237
  function isDevEnv() {
2107
2238
  return typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
2108
2239
  }
2109
2240
  function DevNoClientNotice() {
2110
2241
  return /* @__PURE__ */ jsxs5("div", { className: "fillo-devwarning", role: "alert", children: [
2111
- /* @__PURE__ */ jsx5("strong", { children: "No Fillo client connected." }),
2242
+ /* @__PURE__ */ jsx6("strong", { children: "No Fillo client connected." }),
2112
2243
  " This form is render-only and won\u2019t save responses. Pass a ",
2113
- /* @__PURE__ */ jsx5("code", { children: "client" }),
2244
+ /* @__PURE__ */ jsx6("code", { children: "client" }),
2114
2245
  " to collect them in Fillo, or forward them with webhooks. (This notice only appears in development.)"
2115
2246
  ] });
2116
2247
  }
2117
2248
  function DevDraftNotice() {
2118
2249
  return /* @__PURE__ */ jsxs5("div", { className: "fillo-devwarning", role: "alert", children: [
2119
- /* @__PURE__ */ jsx5("strong", { children: "Draft form." }),
2250
+ /* @__PURE__ */ jsx6("strong", { children: "Draft form." }),
2120
2251
  " It renders here, but it won\u2019t accept responses until you publish it in your Fillo dashboard. (This notice only appears in development.)"
2121
2252
  ] });
2122
2253
  }
2123
2254
  function DevStagedNotice() {
2124
2255
  return /* @__PURE__ */ jsxs5("div", { className: "fillo-devwarning", role: "alert", children: [
2125
- /* @__PURE__ */ jsx5("strong", { children: "Code changes are staged." }),
2256
+ /* @__PURE__ */ jsx6("strong", { children: "Code changes are staged." }),
2126
2257
  " This local preview is not the live form yet. Publish the changes in Fillo before deploying them for respondents."
2127
2258
  ] });
2128
2259
  }
@@ -2141,9 +2272,23 @@ function DevSyncErrorNotice({ error }) {
2141
2272
  function ResolvedForm(props) {
2142
2273
  const { schema, theme } = props;
2143
2274
  const instanceId = useId2().replace(/:/g, "");
2144
- const hpRef = useRef6(null);
2275
+ const hpRef = useRef7(null);
2145
2276
  const codeForm = isCodeForm2(props.form) ? props.form : null;
2146
2277
  const client = props.client;
2278
+ const challenge = props.challenge;
2279
+ const challengeRequired = Boolean(challenge);
2280
+ const challengeTokenRef = useRef7(void 0);
2281
+ const [challengeToken, setChallengeToken] = useState6(void 0);
2282
+ const [challengeError, setChallengeError] = useState6(false);
2283
+ const resetChallengeRef = useRef7(void 0);
2284
+ const handleChallengeToken = useCallback((token) => {
2285
+ challengeTokenRef.current = token;
2286
+ setChallengeToken(token);
2287
+ if (token) setChallengeError(false);
2288
+ }, []);
2289
+ const registerChallengeReset = useCallback((reset) => {
2290
+ resetChallengeRef.current = reset;
2291
+ }, []);
2147
2292
  const resolveFormId = useMemo5(
2148
2293
  () => codeForm && client?.key ? (
2149
2294
  // bypassCache: a stale cached formId must not be trusted at submit time.
@@ -2213,25 +2358,31 @@ function ResolvedForm(props) {
2213
2358
  // "default", never the bare-engine "headless".
2214
2359
  surface: "default",
2215
2360
  resolveFormId,
2216
- respondent: props.respondent
2361
+ respondent: props.respondent,
2362
+ challengeRequired,
2363
+ getChallengeToken: () => challengeTokenRef.current,
2364
+ onChallengeFailed: () => {
2365
+ handleChallengeToken(void 0);
2366
+ resetChallengeRef.current?.();
2367
+ }
2217
2368
  });
2218
2369
  useDraftFlush(api);
2219
2370
  const themed = useMemo5(() => themeProps(theme), [theme]);
2220
2371
  const settings = schema.settings;
2221
2372
  const submitted = api.status === "submitted";
2222
2373
  const liveSubmit = submitted && !api.restoredSubmission;
2223
- useEffect7(() => {
2374
+ useEffect8(() => {
2224
2375
  if (!liveSubmit || !settings.redirectUrl || typeof window === "undefined") return;
2225
2376
  const url = safeHttpUrl(settings.redirectUrl);
2226
2377
  if (url) window.location.assign(url);
2227
2378
  }, [liveSubmit, settings.redirectUrl]);
2228
- const successRef = useRef6(null);
2229
- useEffect7(() => {
2379
+ const successRef = useRef7(null);
2380
+ useEffect8(() => {
2230
2381
  if (liveSubmit) successRef.current?.focus();
2231
2382
  }, [liveSubmit]);
2232
- const blocksRef = useRef6(null);
2233
- const prevPageIndex = useRef6(api.pageIndex);
2234
- useEffect7(() => {
2383
+ const blocksRef = useRef7(null);
2384
+ const prevPageIndex = useRef7(api.pageIndex);
2385
+ useEffect8(() => {
2235
2386
  if (prevPageIndex.current === api.pageIndex) return;
2236
2387
  prevPageIndex.current = api.pageIndex;
2237
2388
  blocksRef.current?.focus();
@@ -2261,22 +2412,29 @@ function ResolvedForm(props) {
2261
2412
  className: slotClass3("fillo-success", appearance, { slot: "success", status: "submitted" }),
2262
2413
  "data-fillo": "success",
2263
2414
  children: [
2264
- /* @__PURE__ */ jsx5("div", { className: "fillo-success-mark", "aria-hidden": "true", children: "\u2713" }),
2265
- /* @__PURE__ */ jsx5("h2", { className: "fillo-success-title", children: settings.successTitle ?? strings.successTitle }),
2266
- /* @__PURE__ */ jsx5("p", { className: "fillo-success-message", children: api.duplicateSubmission ? strings.alreadyAnswered : settings.successMessage ?? strings.successMessage })
2415
+ /* @__PURE__ */ jsx6("div", { className: "fillo-success-mark", "aria-hidden": "true", children: "\u2713" }),
2416
+ /* @__PURE__ */ jsx6("h2", { className: "fillo-success-title", children: settings.successTitle ?? strings.successTitle }),
2417
+ /* @__PURE__ */ jsx6("p", { className: "fillo-success-message", children: api.duplicateSubmission ? strings.alreadyAnswered : settings.successMessage ?? strings.successMessage })
2267
2418
  ]
2268
2419
  }
2269
2420
  ),
2270
- props.poweredBy && /* @__PURE__ */ jsx5(PoweredBy, { label: props.brandLabel, href: props.brandHref })
2421
+ props.poweredBy && /* @__PURE__ */ jsx6(PoweredBy, { label: props.brandLabel, href: props.brandHref })
2271
2422
  ]
2272
2423
  }
2273
2424
  );
2274
2425
  }
2275
2426
  const multiPage = api.pageCount > 1;
2276
2427
  const autoSubmit = settings.submitMode === "auto";
2277
- const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields2(api.form, api.data));
2428
+ const showChallenge = challengeRequired && api.isLastPage;
2429
+ const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(reachableFields(api.form, api.data)) || // A challenge needs an explicit Submit even on an otherwise button-less
2430
+ // auto-submit form — you can't one-tap past a human check.
2431
+ showChallenge;
2278
2432
  const showFooter = showPrimaryButton || multiPage && !api.isFirstPage;
2279
- return /* @__PURE__ */ jsx5(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsx5(FilloStringsContext.Provider, { value: strings, children: /* @__PURE__ */ jsx5(FilloAppearanceContext.Provider, { value: appearance, children: /* @__PURE__ */ jsx5(FilloInstanceIdContext.Provider, { value: instanceId, children: /* @__PURE__ */ jsxs5(
2433
+ const progressSeq = reachablePageSequence(api.form, api.data);
2434
+ const progressTotal = Math.max(progressSeq.length, 1);
2435
+ const progressPos = progressSeq.indexOf(api.page.id);
2436
+ const progressStep = progressPos >= 0 ? progressPos + 1 : Math.min(api.pageIndex + 1, progressTotal);
2437
+ return /* @__PURE__ */ jsx6(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsx6(FilloStringsContext.Provider, { value: strings, children: /* @__PURE__ */ jsx6(FilloAppearanceContext.Provider, { value: appearance, children: /* @__PURE__ */ jsx6(FilloInstanceIdContext.Provider, { value: instanceId, children: /* @__PURE__ */ jsxs5(
2280
2438
  "form",
2281
2439
  {
2282
2440
  className: slotClass3(`fillo-form ${props.className ?? ""}`, appearance, {
@@ -2301,10 +2459,10 @@ function ResolvedForm(props) {
2301
2459
  }
2302
2460
  },
2303
2461
  children: [
2304
- !props.client && !props.skipValidation && isDevEnv() && /* @__PURE__ */ jsx5(DevNoClientNotice, {}),
2305
- props.draftNotice && isDevEnv() && /* @__PURE__ */ jsx5(DevDraftNotice, {}),
2306
- props.stagedNotice && isDevEnv() && /* @__PURE__ */ jsx5(DevStagedNotice, {}),
2307
- props.syncErrorNotice && isDevEnv() && /* @__PURE__ */ jsx5(DevSyncErrorNotice, { error: props.syncErrorNotice }),
2462
+ !props.client && !props.skipValidation && isDevEnv() && /* @__PURE__ */ jsx6(DevNoClientNotice, {}),
2463
+ props.draftNotice && isDevEnv() && /* @__PURE__ */ jsx6(DevDraftNotice, {}),
2464
+ props.stagedNotice && isDevEnv() && /* @__PURE__ */ jsx6(DevStagedNotice, {}),
2465
+ props.syncErrorNotice && isDevEnv() && /* @__PURE__ */ jsx6(DevSyncErrorNotice, { error: props.syncErrorNotice }),
2308
2466
  (api.resumedDraft || api.editingPrevious) && /* @__PURE__ */ jsxs5(
2309
2467
  "div",
2310
2468
  {
@@ -2312,21 +2470,21 @@ function ResolvedForm(props) {
2312
2470
  "data-fillo": "resume",
2313
2471
  role: "status",
2314
2472
  children: [
2315
- /* @__PURE__ */ jsx5("span", { className: "fillo-resume-text", children: api.editingPrevious ? strings.editNotice : strings.resumeNotice }),
2316
- /* @__PURE__ */ jsx5("button", { type: "button", className: "fillo-resume-clear", onClick: api.resetDraft, children: strings.resumeStartOver })
2473
+ /* @__PURE__ */ jsx6("span", { className: "fillo-resume-text", children: api.editingPrevious ? strings.editNotice : strings.resumeNotice }),
2474
+ /* @__PURE__ */ jsx6("button", { type: "button", className: "fillo-resume-clear", onClick: api.resetDraft, children: strings.resumeStartOver })
2317
2475
  ]
2318
2476
  }
2319
2477
  ),
2320
- api.resumeLinkFailed && !api.resumedDraft && !api.editingPrevious && /* @__PURE__ */ jsx5(
2478
+ api.resumeLinkFailed && !api.resumedDraft && !api.editingPrevious && /* @__PURE__ */ jsx6(
2321
2479
  "div",
2322
2480
  {
2323
2481
  className: slotClass3("fillo-resume fillo-resume--expired", appearance, { slot: "resume" }),
2324
2482
  "data-fillo": "resume",
2325
2483
  role: "status",
2326
- children: /* @__PURE__ */ jsx5("span", { className: "fillo-resume-text", children: strings.resumeLinkExpired })
2484
+ children: /* @__PURE__ */ jsx6("span", { className: "fillo-resume-text", children: strings.resumeLinkExpired })
2327
2485
  }
2328
2486
  ),
2329
- multiPage && settings.showProgress !== false && /* @__PURE__ */ jsx5(
2487
+ multiPage && settings.showProgress !== false && /* @__PURE__ */ jsx6(
2330
2488
  "div",
2331
2489
  {
2332
2490
  className: slotClass3("fillo-progress-track", appearance, { slot: "progress" }),
@@ -2334,21 +2492,21 @@ function ResolvedForm(props) {
2334
2492
  role: "progressbar",
2335
2493
  "aria-label": schema.title || "Form progress",
2336
2494
  "aria-valuemin": 1,
2337
- "aria-valuemax": api.pageCount,
2338
- "aria-valuenow": api.pageIndex + 1,
2339
- children: /* @__PURE__ */ jsx5(
2495
+ "aria-valuemax": progressTotal,
2496
+ "aria-valuenow": progressStep,
2497
+ children: /* @__PURE__ */ jsx6(
2340
2498
  "div",
2341
2499
  {
2342
2500
  className: slotClass3("fillo-progress-fill", appearance, { slot: "progressFill" }),
2343
2501
  "data-fillo": "progressFill",
2344
- style: { "--fillo-progress-value": `${(api.pageIndex + 1) / api.pageCount * 100}%` }
2502
+ style: { "--fillo-progress-value": `${progressStep / progressTotal * 100}%` }
2345
2503
  }
2346
2504
  )
2347
2505
  }
2348
2506
  ),
2349
2507
  api.pageIndex === 0 && props.showTitle !== false && /* @__PURE__ */ jsxs5("header", { className: slotClass3("fillo-header", appearance, { slot: "header" }), "data-fillo": "header", children: [
2350
- schema.title && /* @__PURE__ */ jsx5("h1", { className: slotClass3("fillo-title", appearance, { slot: "title" }), "data-fillo": "title", children: schema.title }),
2351
- schema.description && /* @__PURE__ */ jsx5(
2508
+ schema.title && /* @__PURE__ */ jsx6("h1", { className: slotClass3("fillo-title", appearance, { slot: "title" }), "data-fillo": "title", children: schema.title }),
2509
+ schema.description && /* @__PURE__ */ jsx6(
2352
2510
  "p",
2353
2511
  {
2354
2512
  className: slotClass3("fillo-form-description", appearance, { slot: "description" }),
@@ -2357,8 +2515,8 @@ function ResolvedForm(props) {
2357
2515
  }
2358
2516
  )
2359
2517
  ] }),
2360
- multiPage && api.page.title && api.pageIndex > 0 && /* @__PURE__ */ jsx5("h2", { className: slotClass3("fillo-page-title", appearance, { slot: "pageTitle" }), "data-fillo": "pageTitle", children: api.page.title }),
2361
- /* @__PURE__ */ jsx5("div", { className: slotClass3("fillo-blocks", appearance, { slot: "blocks" }), "data-fillo": "blocks", ref: blocksRef, tabIndex: -1, children: api.blocks.map((block) => /* @__PURE__ */ jsx5(
2518
+ multiPage && api.page.title && api.pageIndex > 0 && /* @__PURE__ */ jsx6("h2", { className: slotClass3("fillo-page-title", appearance, { slot: "pageTitle" }), "data-fillo": "pageTitle", children: api.page.title }),
2519
+ /* @__PURE__ */ jsx6("div", { className: slotClass3("fillo-blocks", appearance, { slot: "blocks" }), "data-fillo": "blocks", ref: blocksRef, tabIndex: -1, children: api.blocks.map((block) => /* @__PURE__ */ jsx6(
2362
2520
  BlockRenderer,
2363
2521
  {
2364
2522
  block,
@@ -2368,7 +2526,7 @@ function ResolvedForm(props) {
2368
2526
  },
2369
2527
  block.id
2370
2528
  )) }),
2371
- /* @__PURE__ */ jsx5(
2529
+ /* @__PURE__ */ jsx6(
2372
2530
  "input",
2373
2531
  {
2374
2532
  type: "text",
@@ -2385,9 +2543,22 @@ function ResolvedForm(props) {
2385
2543
  }
2386
2544
  }
2387
2545
  ),
2388
- (api.status === "error" || api.submitError) && /* @__PURE__ */ jsx5("p", { className: "fillo-submit-error", role: "alert", children: api.submitError ?? strings.submitFailed }),
2546
+ challenge && showChallenge && /* @__PURE__ */ jsxs5("div", { className: "fillo-turnstile-slot", "data-fillo": "turnstile-slot", children: [
2547
+ /* @__PURE__ */ jsx6(
2548
+ TurnstileWidget,
2549
+ {
2550
+ siteKey: challenge.siteKey,
2551
+ className: slotClass3("fillo-turnstile", appearance, { slot: "turnstile" }),
2552
+ onToken: handleChallengeToken,
2553
+ onError: () => setChallengeError(true),
2554
+ registerReset: registerChallengeReset
2555
+ }
2556
+ ),
2557
+ challengeError && /* @__PURE__ */ jsx6("p", { className: "fillo-turnstile-error", role: "alert", children: strings.challengeUnavailable })
2558
+ ] }),
2559
+ (api.status === "error" || api.submitError) && /* @__PURE__ */ jsx6("p", { className: "fillo-submit-error", role: "alert", children: api.submitError ?? strings.submitFailed }),
2389
2560
  showFooter && /* @__PURE__ */ jsxs5("footer", { className: slotClass3("fillo-footer", appearance, { slot: "footer" }), "data-fillo": "footer", children: [
2390
- multiPage && !api.isFirstPage && /* @__PURE__ */ jsx5(
2561
+ multiPage && !api.isFirstPage && /* @__PURE__ */ jsx6(
2391
2562
  "button",
2392
2563
  {
2393
2564
  type: "button",
@@ -2397,38 +2568,41 @@ function ResolvedForm(props) {
2397
2568
  children: strings.back
2398
2569
  }
2399
2570
  ),
2400
- showPrimaryButton && /* @__PURE__ */ jsx5(
2571
+ showPrimaryButton && /* @__PURE__ */ jsx6(
2401
2572
  "button",
2402
2573
  {
2403
2574
  type: "submit",
2404
2575
  className: slotClass3("fillo-button fillo-button--primary", appearance, { slot: "button", variant: "primary" }),
2405
2576
  "data-fillo": "button",
2406
- disabled: api.status === "submitting" || api.uploading,
2577
+ disabled: api.status === "submitting" || api.uploading || // Wait for the human check before enabling submit (never
2578
+ // "Next"). The server rejects a tokenless submit regardless —
2579
+ // this is UX only.
2580
+ showChallenge && !challengeToken,
2407
2581
  children: api.uploading ? strings.uploading : api.status === "submitting" ? strings.submitting : api.isLastPage ? settings.submitLabel ?? strings.submit : strings.next
2408
2582
  }
2409
2583
  )
2410
2584
  ] }),
2411
- props.poweredBy && /* @__PURE__ */ jsx5(PoweredBy, { label: props.brandLabel, href: props.brandHref })
2585
+ props.poweredBy && /* @__PURE__ */ jsx6(PoweredBy, { label: props.brandLabel, href: props.brandHref })
2412
2586
  ]
2413
2587
  }
2414
2588
  ) }) }) }) });
2415
2589
  }
2416
2590
 
2417
2591
  // src/jsx.tsx
2418
- import { useRef as useRef7 } from "react";
2592
+ import { useRef as useRef8 } from "react";
2419
2593
  import {
2420
2594
  codeFormFromJsx,
2421
2595
  FilloJsxError,
2422
2596
  JSX_BLOCK_COMPONENTS
2423
2597
  } from "@usefillo/core";
2424
- import { jsx as jsx6 } from "react/jsx-runtime";
2598
+ import { jsx as jsx7 } from "react/jsx-runtime";
2425
2599
  var B = JSX_BLOCK_COMPONENTS;
2426
2600
  var isDevEnv2 = () => typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
2427
2601
  function JsxForm(props) {
2428
2602
  const { id, title, description, settings, theme, client, children, ...rest } = props;
2429
2603
  const compiled = codeFormFromJsx({ id, title, description, settings, theme }, children);
2430
2604
  const json = JSON.stringify(compiled.schema) + JSON.stringify(compiled.theme ?? null);
2431
- const memo2 = useRef7(null);
2605
+ const memo2 = useRef8(null);
2432
2606
  if (memo2.current && memo2.current.json !== json && isDevEnv2()) {
2433
2607
  console.warn(
2434
2608
  `[fillo] "${id}": form structure changed between renders. If you just edited the form, ignore this. Otherwise: conditional questions are visibleIf={when(\u2026)\u2026}, never {cond && <Fillo.\u2026/>} \u2014 a per-visitor schema churns drafts in your workspace.`
@@ -2437,7 +2611,7 @@ function JsxForm(props) {
2437
2611
  if (!memo2.current || memo2.current.json !== json) {
2438
2612
  memo2.current = { json, form: compiled };
2439
2613
  }
2440
- return /* @__PURE__ */ jsx6(FilloForm, { form: memo2.current.form, client, ...rest });
2614
+ return /* @__PURE__ */ jsx7(FilloForm, { form: memo2.current.form, client, ...rest });
2441
2615
  }
2442
2616
  function defineFormFromJsx(element) {
2443
2617
  if (!element || element.type !== JsxForm) {
@@ -2490,7 +2664,7 @@ import {
2490
2664
  resolveStrings as resolveStrings2,
2491
2665
  syncCodeForm as syncCodeForm3
2492
2666
  } from "@usefillo/core";
2493
- import { Fragment as Fragment3, jsx as jsx7 } from "react/jsx-runtime";
2667
+ import { Fragment as Fragment3, jsx as jsx8 } from "react/jsx-runtime";
2494
2668
  function FilloProvider({
2495
2669
  children,
2496
2670
  form,
@@ -2585,15 +2759,15 @@ function FilloProvider({
2585
2759
  useDraftFlush(api);
2586
2760
  const resolvedStrings = resolveStrings2(strings);
2587
2761
  const canonicalSyncError = syncError && !options.skipValidation ? syncError : null;
2588
- if (canonicalSyncError && renderError) return /* @__PURE__ */ jsx7(Fragment3, { children: renderError(canonicalSyncError) });
2762
+ if (canonicalSyncError && renderError) return /* @__PURE__ */ jsx8(Fragment3, { children: renderError(canonicalSyncError) });
2589
2763
  if (canonicalSyncError && !isDevEnv3()) return null;
2590
2764
  if (codeFormSyncing && !options.skipValidation && !isDevEnv3()) return null;
2591
2765
  const notPublished = Boolean(codeForm) && syncedStatus === "draft" && !options.skipValidation;
2592
2766
  if (notPublished && !isDevEnv3()) {
2593
2767
  const error = new FilloError3("This form isn't published yet.", 403, void 0, "form_not_published");
2594
- return renderError ? /* @__PURE__ */ jsx7(Fragment3, { children: renderError(error) }) : null;
2768
+ return renderError ? /* @__PURE__ */ jsx8(Fragment3, { children: renderError(error) }) : null;
2595
2769
  }
2596
- return /* @__PURE__ */ jsx7(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsx7(FilloStringsContext.Provider, { value: resolvedStrings, children: /* @__PURE__ */ jsx7(FilloAppearanceContext.Provider, { value: appearance, children: /* @__PURE__ */ jsx7(FilloInstanceIdContext.Provider, { value: instanceId, children }) }) }) });
2770
+ return /* @__PURE__ */ jsx8(FilloContext.Provider, { value: api, children: /* @__PURE__ */ jsx8(FilloStringsContext.Provider, { value: resolvedStrings, children: /* @__PURE__ */ jsx8(FilloAppearanceContext.Provider, { value: appearance, children: /* @__PURE__ */ jsx8(FilloInstanceIdContext.Provider, { value: instanceId, children }) }) }) });
2597
2771
  }
2598
2772
  function isDevEnv3() {
2599
2773
  return typeof process !== "undefined" && process.env?.NODE_ENV !== "production";
package/dist/styles.css CHANGED
@@ -735,4 +735,22 @@
735
735
  border-radius: 4px;
736
736
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--fillo-primary) 15%, transparent);
737
737
  }
738
+
739
+ /* ---------- Human-verification challenge (Turnstile) ---------- */
740
+ .fillo-turnstile-slot {
741
+ display: flex;
742
+ flex-direction: column;
743
+ gap: 0.5rem;
744
+ margin: 0.25rem 0 1rem;
745
+ }
746
+ /* Cloudflare draws a fixed-size widget inside; just give it room to breathe. */
747
+ .fillo-turnstile {
748
+ min-height: 65px;
749
+ }
750
+ .fillo-turnstile-error {
751
+ margin: 0;
752
+ color: var(--fillo-error);
753
+ font-size: 0.82rem;
754
+ line-height: 1.45;
755
+ }
738
756
  }
@@ -735,3 +735,21 @@
735
735
  border-radius: 4px;
736
736
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--fillo-primary) 15%, transparent);
737
737
  }
738
+
739
+ /* ---------- Human-verification challenge (Turnstile) ---------- */
740
+ .fillo-turnstile-slot {
741
+ display: flex;
742
+ flex-direction: column;
743
+ gap: 0.5rem;
744
+ margin: 0.25rem 0 1rem;
745
+ }
746
+ /* Cloudflare draws a fixed-size widget inside; just give it room to breathe. */
747
+ .fillo-turnstile {
748
+ min-height: 65px;
749
+ }
750
+ .fillo-turnstile-error {
751
+ margin: 0;
752
+ color: var(--fillo-error);
753
+ font-size: 0.82rem;
754
+ line-height: 1.45;
755
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usefillo/react",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Headless React components for embedding Fillo forms natively in your product.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@usefillo/core": "^0.8.0"
37
+ "@usefillo/core": "^0.9.0"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "react": "^18.0.0 || ^19.0.0",