@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/LICENSE +21 -0
- package/README.md +267 -0
- package/client.d.ts +27 -0
- package/client.js +89 -0
- package/form.d.ts +83 -0
- package/form.js +246 -0
- package/index.d.ts +6 -0
- package/index.js +20 -0
- package/messages.d.ts +25 -0
- package/messages.js +76 -0
- package/package.json +59 -0
- package/submit.d.ts +21 -0
- package/submit.js +118 -0
- package/turnstile.d.ts +38 -0
- package/turnstile.js +181 -0
- package/validate.d.ts +37 -0
- package/validate.js +91 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ishaq Sahibole
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# @qaflo/forms-client
|
|
2
|
+
|
|
3
|
+
The client half of the shared qaflo contact-form service. It owns the parts that
|
|
4
|
+
are identical on every site and easy to get subtly wrong — the endpoint, the
|
|
5
|
+
Turnstile lifecycle, the `action`/sitekey pairing, the empty-token guard, and
|
|
6
|
+
what a visitor is told for each status. It owns no markup.
|
|
7
|
+
|
|
8
|
+
> **Status: BUILT.** Written 2026-09-19 against the service at
|
|
9
|
+
> `internal/api/submit.go`. Not yet published; qaflo.com is the pilot.
|
|
10
|
+
>
|
|
11
|
+
> **Sources:** ../../internal/api/submit.go, ../../internal/api/validate.go,
|
|
12
|
+
> ../../docs/FRONTEND-SNIPPET.md, ../../docs/CLIENT-PACKAGE-PLAN.md
|
|
13
|
+
|
|
14
|
+
**Nothing here is a security control.** The server decides the recipient from the
|
|
15
|
+
verified `Origin` header, verifies the captcha against Cloudflare, and applies
|
|
16
|
+
the rate limits. Every check in this package is duplicated server-side, because a
|
|
17
|
+
browser can always be bypassed.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
yarn add @qaflo/forms-client
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
React is an optional peer dependency (`^16.8 || ^17 || ^18 || ^19`), needed only
|
|
26
|
+
for `useTurnstile`. A site that is not React imports `@qaflo/forms-client/submit`
|
|
27
|
+
instead and gets no React in its bundle.
|
|
28
|
+
|
|
29
|
+
## A complete working example
|
|
30
|
+
|
|
31
|
+
This is a whole contact form. There is no other file.
|
|
32
|
+
|
|
33
|
+
```jsx
|
|
34
|
+
import React from 'react'
|
|
35
|
+
import { createFormsClient } from '@qaflo/forms-client'
|
|
36
|
+
|
|
37
|
+
// The only per-site values. `action` MUST equal this site's `site_key` in the
|
|
38
|
+
// forms registry: the service compares it with the action Cloudflare reports,
|
|
39
|
+
// so a mismatch is a 403 on every submit even with a perfectly good token. The
|
|
40
|
+
// sitekey is public by design — it identifies the widget and authorises nothing.
|
|
41
|
+
const forms = createFormsClient({
|
|
42
|
+
action: 'newsite',
|
|
43
|
+
sitekey: '0xYOUR_SITE_KEY',
|
|
44
|
+
fallbackEmail: 'info@newsite.com',
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
export default function ContactForm() {
|
|
48
|
+
const { formProps, fieldProps, honeypotProps, containerRef, errors, state, error, reset } =
|
|
49
|
+
forms.useContactForm()
|
|
50
|
+
|
|
51
|
+
if (state === 'ok') {
|
|
52
|
+
return (
|
|
53
|
+
<div>
|
|
54
|
+
<p>Thanks — we have your message and will reply shortly.</p>
|
|
55
|
+
<button onClick={reset}>Send another</button>
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<form {...formProps}>
|
|
62
|
+
<input {...fieldProps('name')} placeholder="Your name" />
|
|
63
|
+
{errors.name && <p>{errors.name}</p>}
|
|
64
|
+
|
|
65
|
+
<input {...fieldProps('email')} type="email" placeholder="Email" />
|
|
66
|
+
{errors.email && <p>{errors.email}</p>}
|
|
67
|
+
|
|
68
|
+
<input {...fieldProps('phone')} type="tel" placeholder="Phone" />
|
|
69
|
+
|
|
70
|
+
<textarea {...fieldProps('message')} placeholder="How can we help?" />
|
|
71
|
+
{errors.message && <p>{errors.message}</p>}
|
|
72
|
+
|
|
73
|
+
<input {...honeypotProps} />
|
|
74
|
+
<div ref={containerRef} />
|
|
75
|
+
|
|
76
|
+
<button type="submit" disabled={state === 'sending'}>
|
|
77
|
+
{state === 'sending' ? 'Sending…' : 'Send'}
|
|
78
|
+
</button>
|
|
79
|
+
|
|
80
|
+
{state === 'error' && <p role="status">{error}</p>}
|
|
81
|
+
</form>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
That form already has: validation matching the service's own rules, errors that
|
|
87
|
+
appear only after the first submit and clear as you type, focus jumping to the
|
|
88
|
+
first bad field, a honeypot, the Turnstile lifecycle, the single-use token
|
|
89
|
+
reset, and a different sentence for every failure the service can return.
|
|
90
|
+
|
|
91
|
+
**Fields are uncontrolled and read by `name`.** That is the decision that makes
|
|
92
|
+
this cheap: an input needs a `name` and nothing else — no `value`, no
|
|
93
|
+
`onChange`, no state per field. Adding a question to the form is adding one
|
|
94
|
+
`<input>`, and it arrives in the notification as a labelled line without the
|
|
95
|
+
client being touched.
|
|
96
|
+
|
|
97
|
+
### Making it yours
|
|
98
|
+
|
|
99
|
+
Everything below is optional.
|
|
100
|
+
|
|
101
|
+
```jsx
|
|
102
|
+
const { formProps, fieldProps, errors, composed } = forms.useContactForm({
|
|
103
|
+
// Defaults to name, email, phone and message with the service's own limits.
|
|
104
|
+
fields: {
|
|
105
|
+
name: { required: true, min: 2, max: 100 },
|
|
106
|
+
email: { required: true, email: true, max: 254 },
|
|
107
|
+
phone: { required: true, min: 5, max: 32 }, // optional to the service
|
|
108
|
+
message: { required: true, min: 10, max: 5000 },
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
// The rules are the service's. The voice is yours.
|
|
112
|
+
messages: {
|
|
113
|
+
name: { required: 'Please tell us your name.' },
|
|
114
|
+
message: {
|
|
115
|
+
min: ({ value, limit }) => `${limit - value.length} more characters, please.`,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
// Fold extra questions into the message body instead of sending them as
|
|
120
|
+
// their own lines.
|
|
121
|
+
compose: (v) => `Enquiry type: ${v.intent}\n\n${v.message}`,
|
|
122
|
+
exclude: ['intent'], // ...and then do not ALSO send them
|
|
123
|
+
subject: (v) => `${v.intent} — ${v.name}`,
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`composed` gives you back the body that was sent, which is what a "send this on
|
|
128
|
+
WhatsApp instead" fallback needs so a failure never makes anyone retype.
|
|
129
|
+
|
|
130
|
+
## Not React?
|
|
131
|
+
|
|
132
|
+
`useContactForm` is the easy path for a React site. Under it are two layers you
|
|
133
|
+
can drop to, and neither imports React:
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
import { submitTo } from '@qaflo/forms-client/submit'
|
|
137
|
+
|
|
138
|
+
const result = await submitTo('https://forms.qaflo.com/v1/submit', body, { token })
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## API
|
|
142
|
+
|
|
143
|
+
### `createFormsClient(config)`
|
|
144
|
+
|
|
145
|
+
| Option | Default | |
|
|
146
|
+
|---|---|---|
|
|
147
|
+
| `action` | — | **Required.** Must equal the site's `site_key` in the registry. |
|
|
148
|
+
| `sitekey` | — | **Required.** The public Turnstile sitekey. |
|
|
149
|
+
| `endpoint` | `https://forms.qaflo.com/v1/submit` | |
|
|
150
|
+
| `appearance` | `interaction-only` | Passed to `turnstile.render`. |
|
|
151
|
+
| `theme` | Turnstile's default | |
|
|
152
|
+
| `messages` | see below | Override any status's wording, or `default`. |
|
|
153
|
+
| `fallbackEmail` | — | Appends "Please email … while we fix it." to the default message. |
|
|
154
|
+
|
|
155
|
+
Throws `TypeError` if `action` or `sitekey` is missing. Those are programming
|
|
156
|
+
errors that otherwise fail silently — in an inbox that never fills.
|
|
157
|
+
|
|
158
|
+
### `forms.useContactForm(config)`
|
|
159
|
+
|
|
160
|
+
The whole form except its markup. Returns `formProps`, `fieldProps(name)`,
|
|
161
|
+
`honeypotProps`, `errorId(name)`, `containerRef`, `errors`, `state`, `error`,
|
|
162
|
+
`composed`, `values`, `ready` and `reset()`.
|
|
163
|
+
|
|
164
|
+
| Option | Default | |
|
|
165
|
+
|---|---|---|
|
|
166
|
+
| `fields` | name, email, phone, message | Per-field rules, in focus order. `true` means "whatever the service applies to a field of this name". |
|
|
167
|
+
| `messages` | plain English | Per-field wording. A function receives `{ value, limit, label, field }`. |
|
|
168
|
+
| `compose` | `values.message` | Build the message body from every named field. |
|
|
169
|
+
| `subject` | none | String, or computed from the values. |
|
|
170
|
+
| `exclude` | `[]` | Fields that feed `compose` and must not also be sent on their own. |
|
|
171
|
+
| `honeypotName` | `website` | The service reads `website` or `honeypot`. |
|
|
172
|
+
| `idPrefix` | `cf-` | Must match your `<label htmlFor>`. |
|
|
173
|
+
| `onSuccess` | none | Called with the submitted values. |
|
|
174
|
+
|
|
175
|
+
Validation rules mirror `internal/api/validate.go`. **The server stays the
|
|
176
|
+
authority** — this exists so nothing it would reject costs a round trip, because
|
|
177
|
+
a rejection there arrives as one banner with no field named.
|
|
178
|
+
|
|
179
|
+
### `forms.useTurnstile()`
|
|
180
|
+
|
|
181
|
+
Renders one widget **explicitly**, with its own widget id.
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
const { containerRef, widgetProps, getToken, reset, submit, ready } = forms.useTurnstile()
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
- `containerRef` — attach to the element the widget renders into.
|
|
188
|
+
- `widgetProps` — spread on an empty `<div />` instead, if you would rather not
|
|
189
|
+
write the attributes yourself.
|
|
190
|
+
- `getToken()` — this widget's token, or `''` if it has not solved yet.
|
|
191
|
+
- `reset()` — scoped to this widget. Tokens are single-use and expire in five
|
|
192
|
+
minutes.
|
|
193
|
+
- `submit(body)` — reads the token, refuses without one, posts, then resets.
|
|
194
|
+
- `ready` — true once the widget has rendered.
|
|
195
|
+
|
|
196
|
+
Explicit rendering rather than `class="cf-turnstile"` scanning, for two reasons
|
|
197
|
+
this codebase has already met: the script never rescans after a client-side
|
|
198
|
+
navigation, so a form reached by clicking a link gets no widget at all; and two
|
|
199
|
+
forms on one page share an argument-less `turnstile.reset()`, so submitting one
|
|
200
|
+
blanks the other's token. royalshadescurtains renders a form in its footer *and*
|
|
201
|
+
on its contact page.
|
|
202
|
+
|
|
203
|
+
Everything that touches `window` is inside an effect — these are Gatsby sites
|
|
204
|
+
and they server-render the component.
|
|
205
|
+
|
|
206
|
+
### `submitTo(endpoint, body, options)` — the React-free transport
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
import { submitTo } from '@qaflo/forms-client/submit'
|
|
210
|
+
|
|
211
|
+
const result = await submitTo('https://forms.qaflo.com/v1/submit', body, { token })
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Returns `{ ok: true }`, or `{ ok: false, status, message }`. `status` is `0` when
|
|
215
|
+
the request never reached the server — an unsolved captcha or a dead connection,
|
|
216
|
+
told apart by the message.
|
|
217
|
+
|
|
218
|
+
**It does not throw for anything the server says.** A caller who forgets a `try`
|
|
219
|
+
must not lose the lead to an unhandled rejection. It throws only for a
|
|
220
|
+
programming error: no endpoint, a body that is not an object, or no `fetch`.
|
|
221
|
+
|
|
222
|
+
The body is passed through **untouched** apart from the token. `to`, `from`,
|
|
223
|
+
`recipient`, `cc` and `bcc` are sent exactly as given: the service stores them as
|
|
224
|
+
ordinary unknown fields and routes on the verified `Origin`, so an attempted
|
|
225
|
+
relay shows up as a labelled line in the operator's inbox instead of happening.
|
|
226
|
+
The client does not sanitise them, because then two places would claim to own
|
|
227
|
+
that rule and one of them would eventually be wrong.
|
|
228
|
+
|
|
229
|
+
### `messageForStatus(status, serverError, options)`
|
|
230
|
+
|
|
231
|
+
| Status | What the visitor reads |
|
|
232
|
+
|---|---|
|
|
233
|
+
| `400` | the server's own `error` — it names the field at fault |
|
|
234
|
+
| `403` | We could not verify that you are human. Please reload the page and try again. |
|
|
235
|
+
| `413` | That message is too long. Please shorten it and try again. |
|
|
236
|
+
| `429` | Too many messages have been sent from here recently. Please try again in an hour. |
|
|
237
|
+
| anything else | Our contact form is having trouble at our end. Your message has not been sent. |
|
|
238
|
+
| `0` | the security check has not finished, or the server could not be reached |
|
|
239
|
+
|
|
240
|
+
Only `400` shows the server's text. The rest describe our problems, not the
|
|
241
|
+
visitor's — the service says "temporarily unavailable" and "captcha check
|
|
242
|
+
failed", and neither is for a visitor to read.
|
|
243
|
+
|
|
244
|
+
This wording is lifted verbatim from the eight sites that were cut over by hand,
|
|
245
|
+
which had already converged on it word-for-word. **It is visitor-facing copy, so
|
|
246
|
+
changing any of it is a breaking change** — bump the major version.
|
|
247
|
+
|
|
248
|
+
## Tests
|
|
249
|
+
|
|
250
|
+
```sh
|
|
251
|
+
npm test
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
No bundler and no test framework: plain ESM and `node --test`. The suite covers
|
|
255
|
+
the status mapping, the empty-token refusal, and that a body-supplied `to`
|
|
256
|
+
survives untouched. Nothing in it touches the network.
|
|
257
|
+
|
|
258
|
+
## What this package cannot do
|
|
259
|
+
|
|
260
|
+
It does **not** propagate a fix to the live sites. Each pins a version and has to
|
|
261
|
+
be rebuilt to pick one up, so "one place to fix a bug" only pays off if somebody
|
|
262
|
+
bumps and redeploys. The reliable wins are narrower: a new site takes minutes
|
|
263
|
+
instead of half an hour, and it cannot drift on the day it ships.
|
|
264
|
+
|
|
265
|
+
## Licence
|
|
266
|
+
|
|
267
|
+
MIT — see [LICENSE](LICENSE).
|
package/client.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { MessageOptions } from './messages'
|
|
2
|
+
import type { SubmitBody, SubmitOptions, SubmitResult } from './submit'
|
|
3
|
+
import type { TurnstileHandle } from './turnstile'
|
|
4
|
+
import type { ContactFormConfig, ContactFormHandle } from './form'
|
|
5
|
+
|
|
6
|
+
export interface FormsClientConfig extends MessageOptions {
|
|
7
|
+
/** MUST equal the site's `site_key` in the forms registry. */
|
|
8
|
+
action: string
|
|
9
|
+
/** Public by design: it identifies the widget, it authorises nothing. */
|
|
10
|
+
sitekey: string
|
|
11
|
+
/** Defaults to https://forms.qaflo.com/v1/submit */
|
|
12
|
+
endpoint?: string
|
|
13
|
+
appearance?: string
|
|
14
|
+
theme?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface FormsClient {
|
|
18
|
+
readonly endpoint: string
|
|
19
|
+
readonly action: string
|
|
20
|
+
readonly sitekey: string
|
|
21
|
+
submit(body: SubmitBody, options?: SubmitOptions): Promise<SubmitResult>
|
|
22
|
+
messageForStatus(status: number, serverError?: string): string
|
|
23
|
+
useContactForm(config?: ContactFormConfig): ContactFormHandle
|
|
24
|
+
useTurnstile(): TurnstileHandle
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export declare function createFormsClient(config: FormsClientConfig): FormsClient
|
package/client.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ties the three halves together for one site.
|
|
3
|
+
*
|
|
4
|
+
* Headless on purpose. A shared <ContactForm /> would have to accept every
|
|
5
|
+
* site's fields, labels, classes and fallbacks as props and would end up harder
|
|
6
|
+
* to use than the copy it replaced. So this owns the part that is identical
|
|
7
|
+
* everywhere and easy to get subtly wrong — the endpoint, the Turnstile
|
|
8
|
+
* lifecycle, the action/sitekey pairing, the empty-token guard, the status
|
|
9
|
+
* mapping — and owns none of the markup.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here is a security control. The server decides the recipient from the
|
|
12
|
+
* verified Origin header, verifies the captcha against Cloudflare and applies
|
|
13
|
+
* the rate limits. This package is a convenience; a browser can always be
|
|
14
|
+
* bypassed, and every check in it is duplicated server-side.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { messageForStatus } from './messages.js'
|
|
18
|
+
import { submitTo, DEFAULT_ENDPOINT } from './submit.js'
|
|
19
|
+
import { useTurnstile } from './turnstile.js'
|
|
20
|
+
import { useContactForm } from './form.js'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{
|
|
24
|
+
* action: string,
|
|
25
|
+
* sitekey: string,
|
|
26
|
+
* endpoint?: string,
|
|
27
|
+
* appearance?: string,
|
|
28
|
+
* theme?: string,
|
|
29
|
+
* messages?: Record<string|number, string>,
|
|
30
|
+
* fallbackEmail?: string,
|
|
31
|
+
* }} config
|
|
32
|
+
*/
|
|
33
|
+
export function createFormsClient(config) {
|
|
34
|
+
// A programming error, and the expensive kind: the widget renders, the visitor
|
|
35
|
+
// solves it, and every submit is a 403 because the action does not match the
|
|
36
|
+
// registry row. Fail at import instead of in the inbox that never fills.
|
|
37
|
+
if (!config || typeof config.action !== 'string' || config.action === '') {
|
|
38
|
+
throw new TypeError('createFormsClient: action is required and must equal the site_key in the forms registry')
|
|
39
|
+
}
|
|
40
|
+
if (typeof config.sitekey !== 'string' || config.sitekey === '') {
|
|
41
|
+
throw new TypeError('createFormsClient: sitekey is required')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const endpoint = config.endpoint || DEFAULT_ENDPOINT
|
|
45
|
+
const shared = { messages: config.messages, fallbackEmail: config.fallbackEmail }
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
endpoint,
|
|
49
|
+
action: config.action,
|
|
50
|
+
sitekey: config.sitekey,
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Post a body. The token comes from `options.token`, or from
|
|
54
|
+
* `captcha_token` / `cf-turnstile-response` / `altcha` in the body itself.
|
|
55
|
+
* Resets nothing — it owns no widget; use the hook's `submit` for that.
|
|
56
|
+
*
|
|
57
|
+
* @param {Record<string, unknown>} body
|
|
58
|
+
* @param {{ token?: string, fetch?: typeof fetch, signal?: AbortSignal }} [options]
|
|
59
|
+
*/
|
|
60
|
+
submit(body, options = {}) {
|
|
61
|
+
return submitTo(endpoint, body, { ...shared, ...options })
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
/** @param {string} [serverError] */
|
|
65
|
+
messageForStatus(status, serverError) {
|
|
66
|
+
return messageForStatus(status, serverError, shared)
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The whole form except its markup: validation, state, honeypot, Turnstile
|
|
71
|
+
* and submit. A site writes `<form {...formProps}>` and its own fields.
|
|
72
|
+
*/
|
|
73
|
+
useContactForm(form = {}) {
|
|
74
|
+
return useContactForm({ ...config, endpoint }, form)
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/** The widget on its own, for a form that wants to drive its own submit. */
|
|
78
|
+
useTurnstile() {
|
|
79
|
+
return useTurnstile({
|
|
80
|
+
sitekey: config.sitekey,
|
|
81
|
+
action: config.action,
|
|
82
|
+
endpoint,
|
|
83
|
+
appearance: config.appearance,
|
|
84
|
+
theme: config.theme,
|
|
85
|
+
...shared,
|
|
86
|
+
})
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
package/form.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { CSSProperties, FormEvent } from 'react'
|
|
2
|
+
import type { FieldSpec, MessageSpec } from './validate'
|
|
3
|
+
import type { SubmitBody, SubmitResult } from './submit'
|
|
4
|
+
|
|
5
|
+
export interface ContactFormConfig {
|
|
6
|
+
/** Defaults to name, email, phone and message with the service's own rules. */
|
|
7
|
+
fields?: FieldSpec
|
|
8
|
+
messages?: MessageSpec
|
|
9
|
+
/** Build the message body from every named field. Defaults to `values.message`. */
|
|
10
|
+
compose?: (values: Record<string, string>) => string
|
|
11
|
+
subject?: string | ((values: Record<string, string>) => string)
|
|
12
|
+
/** The hidden field's name. The service accepts `website` or `honeypot`. */
|
|
13
|
+
honeypotName?: string
|
|
14
|
+
/** Prefix for generated ids; must match what your <label htmlFor> uses. */
|
|
15
|
+
idPrefix?: string
|
|
16
|
+
/**
|
|
17
|
+
* Fields that feed `compose` and must NOT also be sent on their own.
|
|
18
|
+
* Without this they arrive twice: inside the message body, and again as a
|
|
19
|
+
* labelled line in the notification.
|
|
20
|
+
*/
|
|
21
|
+
exclude?: string[]
|
|
22
|
+
onSuccess?: (values: Record<string, string>) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ContactFormHandle {
|
|
26
|
+
formProps: {
|
|
27
|
+
onSubmit: (e: FormEvent<HTMLFormElement>) => void
|
|
28
|
+
onInput: (e: FormEvent<HTMLFormElement>) => void
|
|
29
|
+
onChange: (e: FormEvent<HTMLFormElement>) => void
|
|
30
|
+
noValidate: true
|
|
31
|
+
}
|
|
32
|
+
fieldProps: (name: string) => {
|
|
33
|
+
id: string
|
|
34
|
+
name: string
|
|
35
|
+
'aria-invalid': true | undefined
|
|
36
|
+
'aria-describedby': string | undefined
|
|
37
|
+
}
|
|
38
|
+
honeypotProps: {
|
|
39
|
+
type: string
|
|
40
|
+
name: string
|
|
41
|
+
tabIndex: number
|
|
42
|
+
autoComplete: string
|
|
43
|
+
'aria-hidden': true
|
|
44
|
+
style: CSSProperties
|
|
45
|
+
}
|
|
46
|
+
errorId: (name: string) => string
|
|
47
|
+
containerRef: { current: any }
|
|
48
|
+
ready: boolean
|
|
49
|
+
/** Empty until the visitor has tried to send once. */
|
|
50
|
+
errors: Record<string, string>
|
|
51
|
+
state: 'idle' | 'sending' | 'ok' | 'error'
|
|
52
|
+
/** The banner message when `state` is 'error'. */
|
|
53
|
+
error: string
|
|
54
|
+
/** The composed body, for a fallback that must not make them retype. */
|
|
55
|
+
composed: string
|
|
56
|
+
values: Record<string, string>
|
|
57
|
+
reset: () => void
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export declare function useContactForm(
|
|
61
|
+
clientConfig: { sitekey: string; action: string; endpoint?: string; [k: string]: any },
|
|
62
|
+
config?: ContactFormConfig,
|
|
63
|
+
): ContactFormHandle
|
|
64
|
+
|
|
65
|
+
export declare function useContactFormWith(
|
|
66
|
+
widget: {
|
|
67
|
+
getToken: () => string
|
|
68
|
+
submit: (body: SubmitBody) => Promise<SubmitResult>
|
|
69
|
+
containerRef: { current: any }
|
|
70
|
+
ready: boolean
|
|
71
|
+
},
|
|
72
|
+
config?: ContactFormConfig,
|
|
73
|
+
): ContactFormHandle
|
|
74
|
+
|
|
75
|
+
export declare function buildBody(
|
|
76
|
+
values: Record<string, string>,
|
|
77
|
+
options: {
|
|
78
|
+
message: string
|
|
79
|
+
subject?: string | ((values: Record<string, string>) => string)
|
|
80
|
+
honeypotName: string
|
|
81
|
+
exclude?: string[]
|
|
82
|
+
},
|
|
83
|
+
): Record<string, unknown>
|