@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 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>