@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/turnstile.js ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The Turnstile lifecycle, as a React hook.
3
+ *
4
+ * EXPLICIT rendering, with a widget id per hook instance. Implicit rendering
5
+ * (letting the script scan the DOM for `.cf-turnstile`) breaks in two ways this
6
+ * codebase has already met: the script never rescans after a client-side
7
+ * navigation, so a form reached by clicking a link gets no widget at all; and
8
+ * two forms on one page share `turnstile.reset()` with no argument, so
9
+ * submitting one blanks the other's token. royalshadescurtains renders a form
10
+ * in its footer AND on its contact page, on the same page.
11
+ *
12
+ * Everything that touches `window` is inside an effect. These are Gatsby sites
13
+ * and they server-render this component.
14
+ */
15
+
16
+ import { useCallback, useEffect, useRef, useState } from 'react'
17
+
18
+ import { submitTo, DEFAULT_ENDPOINT } from './submit.js'
19
+ import { SECURITY_CHECK_PENDING } from './messages.js'
20
+
21
+ /**
22
+ * `render=explicit` is what stops the script auto-scanning the DOM. The markup
23
+ * below may still carry `class="cf-turnstile"` — inert decoration once the
24
+ * script is in explicit mode, and worth keeping so the widget is recognisable.
25
+ */
26
+ export const TURNSTILE_SCRIPT =
27
+ 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
28
+
29
+ /**
30
+ * Hidden from people, visible to bots. Styled away rather than `type="hidden"`:
31
+ * a bot reads the markup either way, but a password manager will not autofill a
32
+ * positioned-off-screen text input, and an autofilled honeypot is a real
33
+ * visitor's enquiry silently dropped.
34
+ */
35
+ export const honeypotStyle = {
36
+ position: 'absolute',
37
+ left: '-9999px',
38
+ width: 1,
39
+ height: 1,
40
+ overflow: 'hidden',
41
+ }
42
+
43
+ // One script tag for the whole page, however many forms mount.
44
+ /** @type {Promise<any> | null} */
45
+ let scriptPromise = null
46
+
47
+ /** @returns {Promise<any>} */
48
+ export function loadTurnstile() {
49
+ if (typeof window === 'undefined') return Promise.reject(new Error('turnstile: no window'))
50
+ if (window.turnstile) return Promise.resolve(window.turnstile)
51
+ if (!scriptPromise) {
52
+ scriptPromise = new Promise((resolve, reject) => {
53
+ const existing = document.querySelector(`script[src="${TURNSTILE_SCRIPT}"]`)
54
+ const script = existing || document.createElement('script')
55
+ script.addEventListener('load', () => resolve(window.turnstile))
56
+ script.addEventListener('error', () => {
57
+ scriptPromise = null // let the next mount try again
58
+ reject(new Error('turnstile failed to load'))
59
+ })
60
+ if (!existing) {
61
+ script.src = TURNSTILE_SCRIPT
62
+ script.async = true
63
+ script.defer = true
64
+ document.head.appendChild(script)
65
+ }
66
+ })
67
+ }
68
+ return scriptPromise
69
+ }
70
+
71
+ /**
72
+ * One widget per call.
73
+ *
74
+ * @param {{ sitekey: string, action: string, endpoint?: string, appearance?: string, messages?: Record<string|number, string>, fallbackEmail?: string, theme?: string }} config
75
+ */
76
+ export function useTurnstile(config) {
77
+ const { sitekey, action, endpoint = DEFAULT_ENDPOINT, appearance = 'interaction-only' } = config
78
+
79
+ const containerRef = useRef(/** @type {HTMLElement | null} */ (null))
80
+ const widgetId = useRef(/** @type {string | null} */ (null))
81
+ const [ready, setReady] = useState(false)
82
+
83
+ useEffect(() => {
84
+ let cancelled = false
85
+ loadTurnstile()
86
+ .then((turnstile) => {
87
+ if (cancelled || !containerRef.current || widgetId.current !== null) return
88
+ widgetId.current = turnstile.render(containerRef.current, {
89
+ sitekey,
90
+ // MUST equal the site's site_key in the registry. The service compares
91
+ // it with the action Cloudflare reports, which is what stops a token
92
+ // solved on one of the thirty sites being spent on another.
93
+ action,
94
+ appearance,
95
+ ...(config.theme ? { theme: config.theme } : {}),
96
+ })
97
+ if (!cancelled) setReady(true)
98
+ })
99
+ .catch(() => {
100
+ // Blocked script, or an ad blocker. Submitting will say the check has
101
+ // not finished rather than posting a request that can only 403.
102
+ })
103
+ return () => {
104
+ cancelled = true
105
+ if (widgetId.current !== null && typeof window !== 'undefined' && window.turnstile) {
106
+ try {
107
+ window.turnstile.remove(widgetId.current)
108
+ } catch {
109
+ /* already gone */
110
+ }
111
+ }
112
+ widgetId.current = null
113
+ }
114
+ // sitekey/action/appearance are per-site constants; re-rendering the widget
115
+ // because an object literal was rebuilt would throw the token away.
116
+ }, [sitekey, action, appearance, config.theme])
117
+
118
+ /** Read this widget's token. `''` when it has not solved yet. */
119
+ const getToken = useCallback(() => {
120
+ if (widgetId.current === null || typeof window === 'undefined' || !window.turnstile) return ''
121
+ try {
122
+ return window.turnstile.getResponse(widgetId.current) || ''
123
+ } catch {
124
+ return ''
125
+ }
126
+ }, [])
127
+
128
+ /**
129
+ * Tokens are single-use and expire in five minutes, so reset whichever way a
130
+ * submit went; a second attempt on the old token is a 403 that looks like a
131
+ * broken captcha. Scoped to THIS widget's id.
132
+ */
133
+ const reset = useCallback(() => {
134
+ if (widgetId.current === null || typeof window === 'undefined' || !window.turnstile) return
135
+ try {
136
+ window.turnstile.reset(widgetId.current)
137
+ } catch {
138
+ /* widget not mounted; nothing to reset */
139
+ }
140
+ }, [])
141
+
142
+ /**
143
+ * Spread on an empty <div /> for a site that wants the markup handed to it.
144
+ * A site that owns its own markup uses `containerRef` directly instead.
145
+ */
146
+ const widgetProps = {
147
+ ref: containerRef,
148
+ className: 'cf-turnstile',
149
+ 'data-sitekey': sitekey,
150
+ 'data-action': action,
151
+ 'data-appearance': appearance,
152
+ }
153
+
154
+ /**
155
+ * Submit through THIS widget: read its token, refuse without one, post, and
156
+ * reset it afterwards. The refusal deliberately does not reset — the widget is
157
+ * probably still solving, and resetting would discard that.
158
+ *
159
+ * @param {Record<string, unknown>} body
160
+ */
161
+ const submit = useCallback(
162
+ async (body) => {
163
+ const token = getToken()
164
+ if (!token) {
165
+ return { ok: false, status: 0, message: SECURITY_CHECK_PENDING }
166
+ }
167
+ try {
168
+ return await submitTo(endpoint, body, {
169
+ token,
170
+ messages: config.messages,
171
+ fallbackEmail: config.fallbackEmail,
172
+ })
173
+ } finally {
174
+ reset()
175
+ }
176
+ },
177
+ [endpoint, getToken, reset, config.messages, config.fallbackEmail],
178
+ )
179
+
180
+ return { containerRef, widgetProps, getToken, reset, submit, ready }
181
+ }
package/validate.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ export interface FieldRule {
2
+ required?: boolean
3
+ min?: number
4
+ max?: number
5
+ email?: boolean
6
+ /** Used in the default messages; defaults to the field's own name. */
7
+ label?: string
8
+ }
9
+
10
+ export type FieldMessage =
11
+ | string
12
+ | ((ctx: { value: string; limit: number; label: string; field: string }) => string)
13
+
14
+ export interface FieldMessages {
15
+ required?: FieldMessage
16
+ email?: FieldMessage
17
+ min?: FieldMessage
18
+ max?: FieldMessage
19
+ }
20
+
21
+ /** `true` means "the rule the service applies to a field of this name". */
22
+ export type FieldSpec = Record<string, FieldRule | true>
23
+
24
+ /** A bare string overrides the `required` message. */
25
+ export type MessageSpec = Record<string, FieldMessages | string>
26
+
27
+ export declare const SERVER_RULES: Record<
28
+ 'name' | 'email' | 'phone' | 'subject' | 'message',
29
+ FieldRule
30
+ >
31
+ export declare const DEFAULT_FIELDS: FieldSpec
32
+
33
+ export declare function validateFields(
34
+ values: Record<string, string>,
35
+ fields?: FieldSpec,
36
+ messages?: MessageSpec,
37
+ ): Record<string, string>
package/validate.js ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Client-side validation, mirroring the service's own rules.
3
+ *
4
+ * **The server is the authority** (`internal/api/validate.go`) and every rule
5
+ * here is duplicated there. This exists so that nothing the service would
6
+ * reject costs a network round trip — because a rejection there comes back as
7
+ * one banner at the bottom of the form with no indication of which field caused
8
+ * it. Nothing here is a security control; a browser can always be bypassed.
9
+ */
10
+
11
+ /**
12
+ * The service's own limits, copied from internal/api/validate.go. A site that
13
+ * names no fields gets exactly these.
14
+ */
15
+ export const SERVER_RULES = {
16
+ name: { required: true, min: 2, max: 100 },
17
+ email: { required: true, email: true, max: 254 },
18
+ phone: { max: 32 },
19
+ subject: { max: 150 },
20
+ message: { required: true, min: 10, max: 5000 },
21
+ }
22
+
23
+ /** The four a contact form almost always has. */
24
+ export const DEFAULT_FIELDS = {
25
+ name: SERVER_RULES.name,
26
+ email: SERVER_RULES.email,
27
+ phone: SERVER_RULES.phone,
28
+ message: SERVER_RULES.message,
29
+ }
30
+
31
+ // Deliberately loose. Anything stricter starts rejecting addresses that exist,
32
+ // and the service parses the address properly anyway.
33
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
34
+
35
+ const DEFAULTS = {
36
+ required: (label) => `Please enter your ${label}.`,
37
+ email: () => 'That does not look like a valid email address.',
38
+ min: (label, n) => `That looks too short — please enter at least ${n} characters.`,
39
+ max: (label, n) => `That is too long — ${n} characters at most.`,
40
+ }
41
+
42
+ /**
43
+ * Resolve one message. A site may override with a string or a function, so the
44
+ * dynamic ones ("three more characters") stay possible.
45
+ *
46
+ * @param {Record<string, any>|undefined} overrides
47
+ * @param {string} field
48
+ * @param {'required'|'email'|'min'|'max'} rule
49
+ */
50
+ function message(overrides, field, rule, label, limit, value) {
51
+ const own = overrides && overrides[field]
52
+ const candidate = own && typeof own === 'object' ? own[rule] : rule === 'required' ? own : undefined
53
+ if (typeof candidate === 'function') return candidate({ value, limit, label, field })
54
+ if (typeof candidate === 'string') return candidate
55
+ return DEFAULTS[rule](label, limit)
56
+ }
57
+
58
+ /**
59
+ * @param {Record<string, string>} values
60
+ * @param {Record<string, any>} fields
61
+ * @param {Record<string, any>} [messages]
62
+ * @returns {Record<string, string>} one entry per INVALID field
63
+ */
64
+ export function validateFields(values, fields = DEFAULT_FIELDS, messages) {
65
+ /** @type {Record<string, string>} */
66
+ const errors = {}
67
+
68
+ for (const [field, raw] of Object.entries(fields)) {
69
+ // `true` means "use the service's own rule for a field of this name".
70
+ const rule = raw === true ? SERVER_RULES[field] || { required: true } : raw
71
+ if (!rule) continue
72
+
73
+ const label = rule.label || field
74
+ const value = (values[field] ?? '').trim()
75
+
76
+ if (!value) {
77
+ if (rule.required) errors[field] = message(messages, field, 'required', label, 0, value)
78
+ // An empty optional field is not short, and not a bad address. Saying so
79
+ // is how a form ends up scolding someone for leaving Company blank.
80
+ continue
81
+ }
82
+ if (rule.email && !EMAIL_RE.test(value)) {
83
+ errors[field] = message(messages, field, 'email', label, 0, value)
84
+ } else if (rule.min && value.length < rule.min) {
85
+ errors[field] = message(messages, field, 'min', label, rule.min, value)
86
+ } else if (rule.max && value.length > rule.max) {
87
+ errors[field] = message(messages, field, 'max', label, rule.max, value)
88
+ }
89
+ }
90
+ return errors
91
+ }