@qaflo/forms-client 1.0.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/form.js ADDED
@@ -0,0 +1,246 @@
1
+ /**
2
+ * The whole contact form except its markup.
3
+ *
4
+ * This is the layer that exists because eight sites each hand-wrote the same
5
+ * two hundred lines around the same fetch: a state machine, a validator
6
+ * mirroring the service, the honeypot, the Turnstile plumbing, and
7
+ * focus-the-first-error. None of that differs between sites. All of the markup
8
+ * does, so none of it is here.
9
+ *
10
+ * Inputs are UNCONTROLLED and read by `name`, which is the design decision that
11
+ * makes integration cheap: a field needs a `name` attribute and nothing else —
12
+ * no `value`, no `onChange`, no state per input. Adding a question to a form is
13
+ * adding one `<input>`.
14
+ */
15
+
16
+ import { useCallback, useMemo, useRef, useState } from 'react'
17
+
18
+ import { DEFAULT_FIELDS, validateFields } from './validate.js'
19
+ import { useTurnstile } from './turnstile.js'
20
+ import { DEFAULT_ENDPOINT } from './submit.js'
21
+ import { SECURITY_CHECK_PENDING } from './messages.js'
22
+
23
+ /**
24
+ * Hidden from people, visible to bots. Styled away rather than `type="hidden"`:
25
+ * a bot reads the markup either way, but a password manager will not autofill a
26
+ * positioned-off-screen text input — and an autofilled honeypot is a real
27
+ * visitor's enquiry silently dropped.
28
+ */
29
+ const HONEYPOT_STYLE = {
30
+ position: 'absolute',
31
+ left: '-9999px',
32
+ width: 1,
33
+ height: 1,
34
+ overflow: 'hidden',
35
+ opacity: 0,
36
+ }
37
+
38
+ /** Read every named input in the form, trimmed. */
39
+ function readForm(form) {
40
+ const data = new FormData(form)
41
+ /** @type {Record<string, string>} */
42
+ const values = {}
43
+ data.forEach((value, key) => {
44
+ if (typeof value === 'string') values[key] = value.trim()
45
+ })
46
+ return values
47
+ }
48
+
49
+ /**
50
+ * What actually goes on the wire.
51
+ *
52
+ * Pure, and exported, because the shape of this object IS the contact between
53
+ * the form and the inbox. It regressed once already: reading every named input
54
+ * meant `intent`, `company` and the widget's own hidden field were sent as
55
+ * extra keys, and the service renders an unknown key as a labelled line — so
56
+ * every enquiry arrived saying everything twice.
57
+ *
58
+ * @param {Record<string, string>} values every named field, trimmed
59
+ * @param {{ message: string, subject?: any, honeypotName: string, exclude?: string[] }} options
60
+ */
61
+ export function buildBody(values, { message, subject, honeypotName, exclude = [] }) {
62
+ /** @type {Record<string, unknown>} */
63
+ const body = { message }
64
+
65
+ // Every OTHER named field is sent as-is. The service stores it and renders it
66
+ // as a labelled line, so a new question on the form reaches the inbox with
67
+ // nothing else changed. `exclude` is for the fields that feed `compose` and
68
+ // would otherwise arrive twice.
69
+ for (const [key, value] of Object.entries(values)) {
70
+ if (key === 'message' || key === honeypotName) continue
71
+ // The widget's own hidden input. The token is added by the transport from
72
+ // the widget id, which is the only reading that is right when two forms
73
+ // share a page.
74
+ if (key === 'cf-turnstile-response') continue
75
+ if (exclude.includes(key)) continue
76
+ body[key] = value
77
+ }
78
+
79
+ if (subject) body.subject = typeof subject === 'function' ? subject(values) : subject
80
+ // The honeypot travels under the name the service reads.
81
+ body.honeypot = values[honeypotName] || ''
82
+ return body
83
+ }
84
+
85
+ /**
86
+ * @param {{ getToken: () => string, submit: (body: any) => Promise<any>, containerRef: any, ready: boolean }} widget
87
+ * @param {any} config
88
+ */
89
+ export function useContactFormWith(widget, config = {}) {
90
+ const {
91
+ fields = DEFAULT_FIELDS,
92
+ messages,
93
+ compose,
94
+ subject,
95
+ honeypotName = 'website',
96
+ idPrefix = 'cf-',
97
+ exclude = [],
98
+ onSuccess,
99
+ } = config
100
+
101
+ const [state, setState] = useState(/** @type {'idle'|'sending'|'ok'|'error'} */ ('idle'))
102
+ const [errors, setErrors] = useState(/** @type {Record<string, string>} */ ({}))
103
+ // Nothing is marked invalid until the visitor has tried to send once. Nothing
104
+ // should scold you for a field you have not reached yet.
105
+ const [showErrors, setShowErrors] = useState(false)
106
+ const [error, setError] = useState('')
107
+ const [composed, setComposed] = useState('')
108
+ const lastValues = useRef(/** @type {Record<string, string>} */ ({}))
109
+
110
+ const fieldOrder = useMemo(() => Object.keys(fields), [fields])
111
+
112
+ const revalidate = useCallback(
113
+ (event) => {
114
+ // Only once errors are on screen: the message under a field then clears
115
+ // the moment it stops being true, rather than at the next submit.
116
+ if (!showErrors) return
117
+ setErrors(validateFields(readForm(event.currentTarget), fields, messages))
118
+ },
119
+ [showErrors, fields, messages],
120
+ )
121
+
122
+ const onSubmit = useCallback(
123
+ async (event) => {
124
+ event.preventDefault()
125
+ const form = event.currentTarget
126
+ const values = readForm(form)
127
+ lastValues.current = values
128
+
129
+ const found = validateFields(values, fields, messages)
130
+ setErrors(found)
131
+ if (Object.keys(found).length > 0) {
132
+ setShowErrors(true)
133
+ setState('idle')
134
+ setError('')
135
+ // "First error" means the first one down the page, not whichever key
136
+ // Object.keys happened to yield first.
137
+ const first = fieldOrder.find((f) => found[f])
138
+ const el = first ? form.querySelector(`#${idPrefix}${first}`) : null
139
+ if (el) {
140
+ el.focus()
141
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' })
142
+ }
143
+ return
144
+ }
145
+
146
+ // A real person never fills a field they cannot see. Report success, so a
147
+ // bot learns nothing about why it failed.
148
+ if (values[honeypotName]) {
149
+ setState('ok')
150
+ return
151
+ }
152
+
153
+ // Before anything is composed or stored: an unsolved captcha is not a
154
+ // failure to fall back from, it is "press Submit again in a moment".
155
+ if (!widget.getToken()) {
156
+ setState('error')
157
+ setError(SECURITY_CHECK_PENDING)
158
+ return
159
+ }
160
+
161
+ const message = compose ? compose(values) : values.message
162
+ setComposed(message)
163
+ setState('sending')
164
+ setError('')
165
+
166
+ const body = buildBody(values, { message, subject, honeypotName, exclude })
167
+
168
+ const result = await widget.submit(body)
169
+
170
+ if (result.ok) {
171
+ setState('ok')
172
+ form.reset()
173
+ setErrors({})
174
+ setShowErrors(false)
175
+ if (onSuccess) onSuccess(values)
176
+ return
177
+ }
178
+ setState('error')
179
+ setError(result.message)
180
+ },
181
+ [fields, messages, compose, subject, honeypotName, idPrefix, exclude, onSuccess, widget, fieldOrder],
182
+ )
183
+
184
+ const invalid = useCallback((name) => (showErrors ? errors[name] : undefined), [showErrors, errors])
185
+
186
+ return {
187
+ /** Spread on the <form>. */
188
+ formProps: { onSubmit, onInput: revalidate, onChange: revalidate, noValidate: true },
189
+
190
+ /** Spread on an <input>/<textarea>/<select>. Your classes and labels stay yours. */
191
+ fieldProps: (name) => ({
192
+ id: `${idPrefix}${name}`,
193
+ name,
194
+ 'aria-invalid': invalid(name) ? true : undefined,
195
+ 'aria-describedby': invalid(name) ? `${idPrefix}${name}-error` : undefined,
196
+ }),
197
+
198
+ /** Spread on one <input>. That is the entire honeypot. */
199
+ honeypotProps: {
200
+ type: 'text',
201
+ name: honeypotName,
202
+ tabIndex: -1,
203
+ autoComplete: 'off',
204
+ 'aria-hidden': true,
205
+ style: HONEYPOT_STYLE,
206
+ },
207
+
208
+ /** The id to put on your error element, so the field can point at it. */
209
+ errorId: (name) => `${idPrefix}${name}-error`,
210
+
211
+ containerRef: widget.containerRef,
212
+ ready: widget.ready,
213
+
214
+ errors: showErrors ? errors : {},
215
+ state,
216
+ error,
217
+ /** The composed message body, for a fallback that must not make them retype. */
218
+ composed,
219
+ values: lastValues.current,
220
+ reset: useCallback(() => {
221
+ setState('idle')
222
+ setError('')
223
+ setErrors({})
224
+ setShowErrors(false)
225
+ }, []),
226
+ }
227
+ }
228
+
229
+ /**
230
+ * The one-call form. Renders its own Turnstile widget.
231
+ *
232
+ * @param {any} clientConfig sitekey/action/endpoint, from createFormsClient
233
+ * @param {any} config the form's own shape
234
+ */
235
+ export function useContactForm(clientConfig, config = {}) {
236
+ const widget = useTurnstile({
237
+ sitekey: clientConfig.sitekey,
238
+ action: clientConfig.action,
239
+ endpoint: clientConfig.endpoint || DEFAULT_ENDPOINT,
240
+ appearance: clientConfig.appearance,
241
+ theme: clientConfig.theme,
242
+ messages: clientConfig.messages,
243
+ fallbackEmail: clientConfig.fallbackEmail,
244
+ })
245
+ return useContactFormWith(widget, config)
246
+ }
package/index.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './client'
2
+ export * from './submit'
3
+ export * from './turnstile'
4
+ export * from './messages'
5
+ export * from './form'
6
+ export * from './validate'
package/index.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * @qaflo/forms-client — the client half of the shared contact-form service.
3
+ *
4
+ * The service is qaflo/forms; its contract is documented in
5
+ * docs/FRONTEND-SNIPPET.md and enforced by internal/api/submit.go.
6
+ *
7
+ * `./submit` is the React-free entry point for a site that is not React.
8
+ */
9
+
10
+ export { createFormsClient } from './client.js'
11
+ export { submitTo, DEFAULT_ENDPOINT } from './submit.js'
12
+ export { useTurnstile, loadTurnstile, honeypotStyle, TURNSTILE_SCRIPT } from './turnstile.js'
13
+ export { useContactForm, useContactFormWith, buildBody } from './form.js'
14
+ export { validateFields, SERVER_RULES, DEFAULT_FIELDS } from './validate.js'
15
+ export {
16
+ messageForStatus,
17
+ DEFAULT_MESSAGES,
18
+ SECURITY_CHECK_PENDING,
19
+ NETWORK_ERROR,
20
+ } from './messages.js'
package/messages.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ export type MessageOverrides = Record<string | number, string>
2
+
3
+ export interface MessageOptions {
4
+ /** Override any entry by status, or `default`. Visitor-facing copy. */
5
+ messages?: MessageOverrides
6
+ /** Appends "Please email <address> while we fix it." to the default message. */
7
+ fallbackEmail?: string
8
+ }
9
+
10
+ export declare const DEFAULT_MESSAGES: {
11
+ 400: string
12
+ 403: string
13
+ 413: string
14
+ 429: string
15
+ default: string
16
+ }
17
+
18
+ export declare const SECURITY_CHECK_PENDING: string
19
+ export declare const NETWORK_ERROR: string
20
+
21
+ export declare function messageForStatus(
22
+ status: number,
23
+ serverError?: string,
24
+ options?: MessageOptions,
25
+ ): string
package/messages.js ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * What a visitor is told, per status.
3
+ *
4
+ * Deliberately not one message. "Something went wrong" for every case is
5
+ * precisely what let the endpoint this service replaces return 500 for weeks
6
+ * without anyone realising the form was dead — every visitor saw the same
7
+ * banner whether they had mistyped an email or hit a dead mailbox.
8
+ *
9
+ * The wording is lifted verbatim from the eight sites that were cut over by
10
+ * hand, which had already converged on it word-for-word. It is visitor-facing
11
+ * copy, so a change here is a BREAKING change: bump the major.
12
+ */
13
+
14
+ /** Statuses the service actually returns. See ../../internal/api/submit.go. */
15
+
16
+ // Only a 400 carries the server's own text, because that is the endpoint
17
+ // naming a field ("message is required"), which the visitor can act on. Every
18
+ // other status describes OUR problem, not theirs, so it gets our words.
19
+ export const DEFAULT_MESSAGES = {
20
+ 400: 'Please check the form and try again.',
21
+ 403: 'We could not verify that you are human. Please reload the page and try again.',
22
+ // The service caps the body; a 413 is reachable with a long enough message,
23
+ // and "having trouble at our end" would be a lie about whose problem it is.
24
+ 413: 'That message is too long. Please shorten it and try again.',
25
+ 429: 'Too many messages have been sent from here recently. Please try again in an hour.',
26
+ // 503, 415, and anything unexpected. NOT the visitor's fault, and worth
27
+ // saying so, so they reach us another way instead of retyping it.
28
+ default: 'Our contact form is having trouble at our end. Your message has not been sent.',
29
+ }
30
+
31
+ /**
32
+ * Shown when the widget has not produced a token yet.
33
+ *
34
+ * Turnstile is interaction-only, so most visitors never see it and get a token
35
+ * silently. Posting without one earns a 403 and a "we could not verify that you
36
+ * are human" pointing at a widget that is invisible — which reads as the form
37
+ * being broken. Say what is actually happening and make no call.
38
+ */
39
+ export const SECURITY_CHECK_PENDING =
40
+ 'The security check has not finished yet. Please wait a moment and press Submit again.'
41
+
42
+ /** Shown when nothing came back at all — `fetch` only rejects for that. */
43
+ export const NETWORK_ERROR =
44
+ 'We could not reach the server. Please check your connection and try again.'
45
+
46
+ /**
47
+ * Status → what the visitor reads.
48
+ *
49
+ * @param {number} status HTTP status, or 0 when the request never happened.
50
+ * @param {string} [serverError] The `error` field of the response body. Used
51
+ * for 400 ONLY: that one is the endpoint naming a field.
52
+ * @param {{ messages?: Record<string|number, string>, fallbackEmail?: string }} [options]
53
+ * `messages` overrides any entry by status, or `default`. `fallbackEmail`
54
+ * appends the "please email us meanwhile" sentence to the default message —
55
+ * the one site-specific token inside otherwise identical copy.
56
+ * @returns {string}
57
+ */
58
+ export function messageForStatus(status, serverError, options = {}) {
59
+ const messages = { ...DEFAULT_MESSAGES, ...(options.messages || {}) }
60
+
61
+ if (status === 400) {
62
+ return serverError || messages[400]
63
+ }
64
+
65
+ const own = messages[status]
66
+ if (own) return own
67
+
68
+ const fallback = messages.default
69
+ // Only appended to the UNCHANGED default. A site that supplied its own
70
+ // wording has already said what it wants; bolting a sentence onto the end of
71
+ // it would be the package overruling the site.
72
+ if (options.fallbackEmail && fallback === DEFAULT_MESSAGES.default) {
73
+ return `${fallback} Please email ${options.fallbackEmail} while we fix it.`
74
+ }
75
+ return fallback
76
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@qaflo/forms-client",
3
+ "version": "1.0.0",
4
+ "description": "Client half of the shared qaflo contact-form service: transport, Turnstile lifecycle and visitor-facing status messages.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "main": "./index.js",
12
+ "types": "./index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./index.d.ts",
16
+ "default": "./index.js"
17
+ },
18
+ "./submit": {
19
+ "types": "./submit.d.ts",
20
+ "default": "./submit.js"
21
+ },
22
+ "./messages": {
23
+ "types": "./messages.d.ts",
24
+ "default": "./messages.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "*.js",
29
+ "*.d.ts",
30
+ "README.md",
31
+ "LICENSE"
32
+ ],
33
+ "scripts": {
34
+ "test": "node --test test/*.test.js"
35
+ },
36
+ "peerDependencies": {
37
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "react": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "devDependencies": {
45
+ "react": "^18.2.0"
46
+ },
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "git+ssh://git@github.com/qaflo/forms.git",
50
+ "directory": "clients/js"
51
+ },
52
+ "keywords": [
53
+ "contact-form",
54
+ "turnstile"
55
+ ],
56
+ "engines": {
57
+ "node": ">=18"
58
+ }
59
+ }
package/submit.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { MessageOptions } from './messages'
2
+
3
+ /** `status` is 0 when the request never reached the server. */
4
+ export type SubmitResult = { ok: true } | { ok: false; status: number; message: string }
5
+
6
+ export type SubmitBody = Record<string, unknown>
7
+
8
+ export interface SubmitOptions extends MessageOptions {
9
+ /** Captcha token. Falls back to `captcha_token` in the body. */
10
+ token?: string
11
+ fetch?: typeof fetch
12
+ signal?: AbortSignal
13
+ }
14
+
15
+ export declare const DEFAULT_ENDPOINT: string
16
+
17
+ export declare function submitTo(
18
+ endpoint: string,
19
+ body: SubmitBody,
20
+ options?: SubmitOptions,
21
+ ): Promise<SubmitResult>
package/submit.js ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * The transport. No React, no DOM beyond `fetch` — a plain <script> site, a Vue
3
+ * site or a Node test can use this half on its own.
4
+ */
5
+
6
+ import { messageForStatus, NETWORK_ERROR, SECURITY_CHECK_PENDING } from './messages.js'
7
+
8
+ /** The one endpoint, for all thirty sites. */
9
+ export const DEFAULT_ENDPOINT = 'https://forms.qaflo.com/v1/submit'
10
+
11
+ /**
12
+ * Keys the service reads as the captcha token, in the order it reads them.
13
+ * See ../../internal/api/validate.go — `parseForm` takes the first non-empty.
14
+ */
15
+ const TOKEN_KEYS = ['captcha_token', 'cf-turnstile-response', 'altcha']
16
+
17
+ /**
18
+ * POST one submission.
19
+ *
20
+ * It does NOT throw for anything the server says. A caller who forgets a `try`
21
+ * must not lose the lead to an unhandled rejection — the whole point of this
22
+ * service is that an enquiry survives our mistakes. It throws only for a
23
+ * programming error: no endpoint, or a body that is not an object.
24
+ *
25
+ * The body is passed through UNTOUCHED apart from the token. In particular `to`,
26
+ * `from`, `recipient`, `cc` and `bcc` are sent exactly as given. The service
27
+ * stores them as ordinary unknown fields and decides the recipient from the
28
+ * verified Origin header, so an attempted relay shows up as a labelled line in
29
+ * the operator's inbox instead of happening. Sanitising them here would put a
30
+ * second claimant on a rule the server already owns, and one of the two would
31
+ * eventually be wrong.
32
+ *
33
+ * @param {string} endpoint
34
+ * @param {Record<string, unknown>} body
35
+ * @param {{ token?: string, messages?: Record<string|number, string>, fallbackEmail?: string, fetch?: typeof fetch, signal?: AbortSignal }} [options]
36
+ * @returns {Promise<{ ok: true } | { ok: false, status: number, message: string }>}
37
+ */
38
+ export async function submitTo(endpoint, body, options = {}) {
39
+ if (typeof endpoint !== 'string' || endpoint === '') {
40
+ throw new TypeError('submitTo: endpoint is required')
41
+ }
42
+ if (body === null || typeof body !== 'object') {
43
+ throw new TypeError('submitTo: body must be an object')
44
+ }
45
+
46
+ const token = resolveToken(body, options.token)
47
+ if (!token) {
48
+ // Refused before any network call, and WITHOUT resetting anything: the
49
+ // widget is most likely still solving, and resetting it here would discard
50
+ // the solve in flight, so "press Submit again" would never start working.
51
+ return { ok: false, status: 0, message: SECURITY_CHECK_PENDING }
52
+ }
53
+
54
+ const doFetch = options.fetch || globalThis.fetch
55
+ if (typeof doFetch !== 'function') {
56
+ throw new TypeError('submitTo: no fetch available; pass options.fetch')
57
+ }
58
+
59
+ let response
60
+ try {
61
+ response = await doFetch(endpoint, {
62
+ method: 'POST',
63
+ // application/json is what the service requires, and it is also what
64
+ // makes the browser preflight — which is what makes the Origin header
65
+ // non-optional, which is what decides where the mail goes. No
66
+ // Authorization header: this endpoint is not gated by a shared secret.
67
+ headers: { 'Content-Type': 'application/json' },
68
+ body: JSON.stringify({ ...body, captcha_token: token }),
69
+ signal: options.signal,
70
+ })
71
+ } catch {
72
+ // fetch rejects only when nothing came back at all.
73
+ return { ok: false, status: 0, message: NETWORK_ERROR }
74
+ }
75
+
76
+ // 202 is the only success. It means "we have stored your message", not "we
77
+ // managed to email it" — the service writes the lead down before it tries to
78
+ // send, which is the distinction the outage this replaces turned on.
79
+ if (response.status === 202) {
80
+ return { ok: true }
81
+ }
82
+
83
+ const payload = await readJSON(response)
84
+ return {
85
+ ok: false,
86
+ status: response.status,
87
+ message: messageForStatus(response.status, payload.error, options),
88
+ }
89
+ }
90
+
91
+ /**
92
+ * @param {Record<string, unknown>} body
93
+ * @param {string} [explicit]
94
+ * @returns {string}
95
+ */
96
+ function resolveToken(body, explicit) {
97
+ if (typeof explicit === 'string' && explicit.trim() !== '') return explicit.trim()
98
+ for (const key of TOKEN_KEYS) {
99
+ const value = body[key]
100
+ if (typeof value === 'string' && value.trim() !== '') return value.trim()
101
+ }
102
+ return ''
103
+ }
104
+
105
+ /**
106
+ * @param {{ json: () => Promise<unknown> }} response
107
+ * @returns {Promise<{ error?: string }>}
108
+ */
109
+ async function readJSON(response) {
110
+ try {
111
+ const parsed = await response.json()
112
+ if (parsed && typeof parsed === 'object') return /** @type {{ error?: string }} */ (parsed)
113
+ } catch {
114
+ // An HTML error page from something in front of the service, or an empty
115
+ // body. The status still tells the visitor what happened.
116
+ }
117
+ return {}
118
+ }
package/turnstile.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { CSSProperties, Ref } from 'react'
2
+ import type { MessageOptions } from './messages'
3
+ import type { SubmitBody, SubmitResult } from './submit'
4
+
5
+ export interface TurnstileConfig extends MessageOptions {
6
+ sitekey: string
7
+ /** MUST equal the site's `site_key` in the forms registry. */
8
+ action: string
9
+ endpoint?: string
10
+ appearance?: string
11
+ theme?: string
12
+ }
13
+
14
+ export interface TurnstileWidgetProps {
15
+ ref: Ref<any>
16
+ className: string
17
+ 'data-sitekey': string
18
+ 'data-action': string
19
+ 'data-appearance': string
20
+ }
21
+
22
+ export interface TurnstileHandle {
23
+ /** Attach to the element the widget renders into. */
24
+ containerRef: { current: any }
25
+ /** Ready-made props for a site that does not own its widget markup. */
26
+ widgetProps: TurnstileWidgetProps
27
+ /** This widget's token, or `''` if it has not solved yet. */
28
+ getToken: () => string
29
+ reset: () => void
30
+ /** Reads the token, refuses without one, posts, then resets this widget. */
31
+ submit: (body: SubmitBody) => Promise<SubmitResult>
32
+ ready: boolean
33
+ }
34
+
35
+ export declare const TURNSTILE_SCRIPT: string
36
+ export declare const honeypotStyle: CSSProperties
37
+ export declare function loadTurnstile(): Promise<any>
38
+ export declare function useTurnstile(config: TurnstileConfig): TurnstileHandle