MarketingForm.tsx427 lines · main
1'use client'
2
3import { useRef, useState } from 'react'
4import ReactMarkdown from 'react-markdown'
5import {
6 Button,
7 Checkbox,
8 Input,
9 Select,
10 SelectContent,
11 SelectItem,
12 SelectTrigger,
13 SelectValue,
14 TextArea,
15} from 'ui'
16import type { z } from 'zod'
17
18import { submitFormAction } from '../go/actions/submitForm'
19import { formCrmConfigSchema, formFieldSchema, type GoFormFieldShowWhen } from '../go/schemas'
20
21/** Input-shape field type — fields with Zod defaults (`half`, `required`) are optional here. */
22export type MarketingFormField = z.input<typeof formFieldSchema>
23export type MarketingFormCrmConfig = z.input<typeof formCrmConfigSchema>
24
25/**
26 * Evaluate a `showWhen` rule against the current form values. All supplied
27 * criteria must pass (AND). Missing values are treated as the empty string.
28 */
29function evaluateShowWhen(showWhen: GoFormFieldShowWhen, values: Record<string, string>): boolean {
30 const value = values[showWhen.field] ?? ''
31 if (showWhen.equals !== undefined && value !== showWhen.equals) return false
32 if (showWhen.notEquals !== undefined && value === showWhen.notEquals) return false
33 if (showWhen.in !== undefined && !showWhen.in.includes(value)) return false
34 if (showWhen.notIn !== undefined && showWhen.notIn.includes(value)) return false
35 if (showWhen.truthy === true && value === '') return false
36 if (showWhen.truthy === false && value !== '') return false
37 return true
38}
39
40export interface MarketingFormProps {
41 /** Form fields. The submit handler builds the payload from these by `name`. */
42 fields: MarketingFormField[]
43 /** Submit button label. */
44 submitLabel: string
45 /** Optional title shown above the form. */
46 title?: string
47 /** Optional description shown above the form. */
48 description?: string
49 /** Optional markdown disclaimer shown beneath the submit button. */
50 disclaimer?: string
51 /** Message shown after a successful submission. Ignored when `successRedirect` is set. */
52 successMessage?: string
53 /** URL to redirect the user to after a successful submission. Overrides `successMessage`. */
54 successRedirect?: string
55 /** CRM fan-out config — submits to HubSpot, Customer.io, and/or Notion in parallel. */
56 crm?: MarketingFormCrmConfig
57 /** Wraps the form in a styled card (border + padding). Defaults to `true`. */
58 card?: boolean
59 /** Extra class names applied to the outer wrapper. */
60 className?: string
61}
62
63type SubmitState = 'idle' | 'loading' | 'success' | 'error'
64
65/** Build the sessionStorage key used to block double-submits of the same email to the same form. */
66function dedupeKey(crm: MarketingFormCrmConfig | undefined, email: string): string | null {
67 const formId = crm?.hubspot?.formGuid ?? crm?.notion?.database_id
68 if (!formId || !email) return null
69 return `marketing-form-submitted:${formId}:${email.trim().toLowerCase()}`
70}
71
72function FieldInput({
73 field,
74 value,
75 onChange,
76}: {
77 field: MarketingFormField
78 value: string
79 onChange: (value: string) => void
80}) {
81 switch (field.type) {
82 case 'text':
83 case 'email':
84 case 'url':
85 return (
86 <Input
87 type={field.type}
88 placeholder={field.placeholder}
89 required={field.required}
90 value={value}
91 onChange={(e) => onChange(e.target.value)}
92 />
93 )
94 case 'textarea':
95 return (
96 <TextArea
97 placeholder={field.placeholder}
98 required={field.required}
99 rows={field.rows}
100 value={value}
101 onChange={(e) => onChange(e.target.value)}
102 />
103 )
104 case 'select':
105 return (
106 <Select value={value} onValueChange={onChange} required={field.required}>
107 <SelectTrigger>
108 <SelectValue placeholder={field.placeholder} />
109 </SelectTrigger>
110 <SelectContent>
111 {field.options.map((opt) => (
112 <SelectItem key={opt.value} value={opt.value}>
113 {opt.label}
114 </SelectItem>
115 ))}
116 </SelectContent>
117 </Select>
118 )
119 case 'checkbox':
120 return null
121 default: {
122 const _exhaustive: never = field
123 return null
124 }
125 }
126}
127
128function Field({
129 field,
130 value,
131 onChange,
132}: {
133 field: MarketingFormField
134 value: string
135 onChange: (value: string) => void
136}) {
137 if (field.type === 'checkbox') {
138 return (
139 <label className="flex items-start gap-3 cursor-pointer text-sm text-foreground-light leading-relaxed">
140 <Checkbox
141 className="mt-0.5"
142 checked={value === 'true'}
143 onCheckedChange={(checked) => onChange(checked === true ? 'true' : 'false')}
144 />
145 <span>{field.label}</span>
146 </label>
147 )
148 }
149
150 return (
151 <div className="flex flex-col gap-2">
152 <label className="text-sm text-foreground font-medium">{field.label}</label>
153 <FieldInput field={field} value={value} onChange={onChange} />
154 {field.description && (
155 <p className="text-xs text-foreground-lighter leading-relaxed">{field.description}</p>
156 )}
157 </div>
158 )
159}
160
161export default function MarketingForm({
162 fields,
163 submitLabel,
164 title,
165 description,
166 disclaimer,
167 successMessage,
168 successRedirect,
169 crm,
170 card = true,
171 className,
172}: MarketingFormProps) {
173 const [values, setValues] = useState<Record<string, string>>(() =>
174 Object.fromEntries(fields.map((f) => [f.name, '']))
175 )
176 const [submitState, setSubmitState] = useState<SubmitState>('idle')
177 const [errorMessages, setErrorMessages] = useState<string[]>([])
178 // Anti-spam: tracks when the form was first rendered. Submissions that come
179 // back faster than MIN_FORM_RENDER_MS on the server are rejected as bot-like.
180 const formMountedAtRef = useRef<number>(Date.now())
181 // Anti-spam: honeypot. Real users never see or focus this field. The value
182 // is kept out of `values` so it never reaches the CRM payload.
183 const honeypotRef = useRef<HTMLInputElement>(null)
184
185 const handleChange = (name: string, value: string) => {
186 setValues((prev) => ({ ...prev, [name]: value }))
187 }
188
189 // Only fields whose `showWhen` (if any) currently passes are rendered or submitted.
190 const visibleFields = fields.filter((f) => !f.showWhen || evaluateShowWhen(f.showWhen, values))
191 const visibleFieldNames = new Set(visibleFields.map((f) => f.name))
192
193 const handleSubmit = async (e: React.FormEvent) => {
194 e.preventDefault()
195
196 // Required checkboxes aren't covered by HTML5 validation; check them manually.
197 const uncheckedRequired = visibleFields.filter(
198 (f) => f.type === 'checkbox' && f.required && values[f.name] !== 'true'
199 )
200 if (uncheckedRequired.length > 0) {
201 setSubmitState('error')
202 setErrorMessages(
203 uncheckedRequired.map((f) => `Please confirm: ${f.label.replace(/\*$/, '').trim()}`)
204 )
205 return
206 }
207
208 // Strip values for fields that are currently hidden so stale data doesn't leak.
209 const submittedValues = Object.fromEntries(
210 Object.entries(values).filter(([name]) => visibleFieldNames.has(name))
211 )
212
213 if (!crm) {
214 if (process.env.NODE_ENV === 'development') {
215 console.log('[marketing/form] No CRM configured — form values:', submittedValues)
216 }
217 return
218 }
219
220 // Block repeat submissions of the same email to the same form within this
221 // browser session. Render the success state instead of re-hitting the CRM.
222 const emailValue =
223 submittedValues['email'] ??
224 submittedValues['workEmail'] ??
225 submittedValues['work_email'] ??
226 submittedValues['emailAddress'] ??
227 submittedValues['email_address'] ??
228 ''
229 const sessionKey = dedupeKey(crm, emailValue)
230 if (sessionKey && typeof window !== 'undefined') {
231 try {
232 if (window.sessionStorage.getItem(sessionKey)) {
233 if (successRedirect) {
234 window.location.href = successRedirect
235 } else {
236 setSubmitState('success')
237 }
238 return
239 }
240 } catch {
241 // sessionStorage can throw in private mode / disabled storage — ignore.
242 }
243 }
244
245 setSubmitState('loading')
246 setErrorMessages([])
247
248 const pageUri = typeof window !== 'undefined' ? window.location.href : undefined
249 const pageName = typeof document !== 'undefined' ? document.title : undefined
250 const honeypot = honeypotRef.current?.value ?? ''
251
252 try {
253 const result = await submitFormAction(crm, submittedValues, {
254 pageUri,
255 pageName,
256 honeypot,
257 formMountedAt: formMountedAtRef.current,
258 })
259
260 if (result.success) {
261 if (sessionKey && typeof window !== 'undefined') {
262 try {
263 window.sessionStorage.setItem(sessionKey, '1')
264 } catch {
265 // Storage write can fail in private mode — non-fatal.
266 }
267 }
268 if (successRedirect) {
269 window.location.href = successRedirect
270 } else {
271 setSubmitState('success')
272 }
273 } else {
274 setSubmitState('error')
275 setErrorMessages(result.errors)
276 }
277 } catch (err: any) {
278 console.error('[marketing/form] Form submission failed:', err)
279 setSubmitState('error')
280 setErrorMessages(['Something went wrong. Please try again.'])
281 }
282 }
283
284 // Group fields into rows: half-width fields pair up, full-width fields get their own row.
285 // Checkbox fields always take a full row regardless of their `half` flag.
286 const rows: MarketingFormField[][] = []
287 let pendingHalf: MarketingFormField | null = null
288
289 for (const field of visibleFields) {
290 const isHalf = field.half && field.type !== 'checkbox'
291 if (isHalf) {
292 if (pendingHalf) {
293 rows.push([pendingHalf, field])
294 pendingHalf = null
295 } else {
296 pendingHalf = field
297 }
298 } else {
299 if (pendingHalf) {
300 rows.push([pendingHalf])
301 pendingHalf = null
302 }
303 rows.push([field])
304 }
305 }
306 if (pendingHalf) {
307 rows.push([pendingHalf])
308 }
309
310 if (submitState === 'success') {
311 return (
312 <div className={className}>
313 <div
314 className={
315 card
316 ? 'border border-muted rounded-2xl p-6 sm:p-8 flex flex-col items-center gap-4 text-center'
317 : 'flex flex-col items-center gap-4 text-center'
318 }
319 >
320 <p className="text-lg font-medium">Thank you!</p>
321 <p className="text-foreground-light">
322 {successMessage ?? "We've received your submission and will be in touch soon."}
323 </p>
324 </div>
325 </div>
326 )
327 }
328
329 return (
330 <div className={className}>
331 {(title || description) && (
332 <div className="flex flex-col items-center gap-4 text-center text-balance mb-10">
333 {title && (
334 <h2 className="text-2xl md:text-3xl lg:text-4xl leading-tight tracking-tight">
335 {title}
336 </h2>
337 )}
338 {description && <p className="text-foreground-light text-lg max-w-xl">{description}</p>}
339 </div>
340 )}
341 <form
342 onSubmit={handleSubmit}
343 className={
344 card
345 ? 'border border-muted rounded-2xl p-6 sm:p-8 flex flex-col gap-6'
346 : 'flex flex-col gap-6'
347 }
348 >
349 {/*
350 Honeypot: positioned off-screen rather than display:none so that
351 headless bots scraping the form still see and (often) fill it.
352 aria-hidden + tabIndex=-1 keep it out of the user journey.
353 */}
354 <div
355 aria-hidden="true"
356 className="absolute -left-[9999px] top-auto h-px w-px overflow-hidden"
357 >
358 <label>
359 Website
360 <input
361 ref={honeypotRef}
362 type="text"
363 name="website"
364 tabIndex={-1}
365 autoComplete="off"
366 defaultValue=""
367 />
368 </label>
369 </div>
370 {rows.map((row, rowIndex) => (
371 <div
372 key={rowIndex}
373 className={row.length > 1 ? 'grid grid-cols-1 sm:grid-cols-2 gap-4' : undefined}
374 >
375 {row.map((field) => (
376 <Field
377 key={field.name}
378 field={field}
379 value={values[field.name] ?? ''}
380 onChange={(v) => handleChange(field.name, v)}
381 />
382 ))}
383 </div>
384 ))}
385
386 {submitState === 'error' && errorMessages.length > 0 && (
387 <div className="flex flex-col gap-1">
388 {errorMessages.map((msg, i) => (
389 <p key={i} className="text-sm text-destructive">
390 {msg}
391 </p>
392 ))}
393 </div>
394 )}
395
396 <hr className="border-muted" />
397
398 <Button
399 htmlType="submit"
400 type="primary"
401 size="large"
402 block
403 loading={submitState === 'loading'}
404 >
405 {submitLabel}
406 </Button>
407
408 {disclaimer && (
409 <div className="text-xs text-foreground-lighter leading-relaxed [&_a]:text-brand-link [&_a]:decoration-brand-link">
410 <ReactMarkdown
411 components={{
412 p: ({ children }) => <p>{children}</p>,
413 a: ({ href, children }) => (
414 <a href={href} target="_blank" rel="noopener noreferrer">
415 {children}
416 </a>
417 ),
418 }}
419 >
420 {disclaimer}
421 </ReactMarkdown>
422 </div>
423 )}
424 </form>
425 </div>
426 )
427}