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