feat(forms): add FormBlock component for form-builder rendering

This commit is contained in:
Vadym Samoilenko 2026-05-18 12:05:17 +01:00
parent 0925ab2052
commit 6e5e624345

View file

@ -0,0 +1,258 @@
'use client'
import { useState, useTransition } from 'react'
const FONT = 'var(--font-montserrat, Montserrat), sans-serif'
const INPUT_CLS =
'w-full rounded-[12px] border-2 border-white/20 bg-white/10 px-4 py-3 text-[15px] text-white placeholder-white/40 focus:border-[#f28b4a] focus:outline-none'
interface FieldOption {
label: string
value: string
}
interface FormField {
blockType: string
id?: string
name: string
label?: string
required?: boolean
placeholder?: string
defaultValue?: string
options?: FieldOption[]
message?: string
width?: number
}
interface FormData {
id: string
title?: string
fields?: FormField[]
confirmationType?: 'message' | 'redirect'
confirmationMessage?: unknown
redirect?: { url: string }
submitButtonLabel?: string
}
interface FormBlockProps {
form: FormData
submitLabel?: string
}
export function FormBlock({ form, submitLabel }: FormBlockProps) {
const [values, setValues] = useState<Record<string, string>>({})
const [success, setSuccess] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
function setValue(name: string, value: string) {
setValues((prev) => ({ ...prev, [name]: value }))
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
startTransition(async () => {
try {
const submissionData = Object.entries(values).map(([field, value]) => ({ field, value }))
const res = await fetch('/api/form-submissions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ form: form.id, submissionData }),
})
if (!res.ok) {
setError('Щось пішло не так. Спробуйте ще раз.')
return
}
if (form.confirmationType === 'redirect' && form.redirect?.url) {
window.location.href = form.redirect.url
return
}
setSuccess(true)
} catch {
setError("Помилка мережі. Перевірте з'єднання та спробуйте ще раз.")
}
})
}
if (success) {
return (
<div className="flex flex-col items-center gap-4 py-10 text-center">
<div className="text-[48px]"></div>
<h3 className="text-[24px] font-bold text-white" style={{ fontFamily: FONT }}>
Заявку отримано!
</h3>
<p className="text-[16px] text-white/70" style={{ fontFamily: FONT }}>
Менеджер зв&apos;яжеться з вами найближчим часом.
</p>
</div>
)
}
const fields = form.fields ?? []
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
{fields.map((field) => {
if (field.blockType === 'message') {
return (
<div
key={field.id ?? field.name}
className="text-[14px] text-white/60 md:col-span-2"
style={{ fontFamily: FONT }}
dangerouslySetInnerHTML={{ __html: field.message ?? '' }}
/>
)
}
const isFullWidth =
field.blockType === 'textarea' || (field.width != null && field.width > 50)
return (
<div
key={field.id ?? field.name}
className={`flex flex-col gap-2${isFullWidth ? 'md:col-span-2' : ''}`}
>
{field.label && (
<label
htmlFor={field.name}
className="text-[14px] font-medium text-white/80"
style={{ fontFamily: FONT }}
>
{field.label}
{field.required && ' *'}
</label>
)}
{renderInput(field, values[field.name] ?? '', setValue)}
</div>
)
})}
</div>
{error && (
<div className="rounded-xl border border-red-400 bg-red-900/30 px-4 py-3 text-[14px] text-red-300">
{error}
</div>
)}
<button
type="submit"
disabled={isPending}
className="mt-2 inline-flex items-center justify-center rounded-[64px] bg-[#f28b4a] px-10 py-4 text-[16px] font-bold text-white transition-shadow hover:shadow-[0_0_20px_0_#f28b4a] disabled:opacity-60"
style={{ fontFamily: FONT }}
>
{isPending
? 'Надсилаємо...'
: (form.submitButtonLabel ?? submitLabel ?? 'Надіслати заявку')}
</button>
</form>
)
}
function renderInput(
field: FormField,
value: string,
setValue: (name: string, val: string) => void
) {
const cls = INPUT_CLS
const font = { fontFamily: FONT }
const { name, required, placeholder } = field
switch (field.blockType) {
case 'textarea':
return (
<textarea
id={name}
rows={4}
required={required}
value={value}
onChange={(e) => setValue(name, e.target.value)}
placeholder={placeholder}
className={cls + ' resize-none'}
style={font}
/>
)
case 'select':
return (
<select
id={name}
required={required}
value={value}
onChange={(e) => setValue(name, e.target.value)}
className={cls}
style={font}
>
<option value="">Оберіть...</option>
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
)
case 'email':
return (
<input
id={name}
type="email"
required={required}
value={value}
onChange={(e) => setValue(name, e.target.value)}
placeholder={placeholder ?? 'your@email.com'}
className={cls}
style={font}
/>
)
case 'number':
return (
<input
id={name}
type="number"
required={required}
value={value}
onChange={(e) => setValue(name, e.target.value)}
placeholder={placeholder}
className={cls}
style={font}
/>
)
case 'date':
return (
<input
id={name}
type="date"
required={required}
value={value}
onChange={(e) => setValue(name, e.target.value)}
className={cls}
style={font}
/>
)
case 'checkbox':
return (
<input
id={name}
type="checkbox"
required={required}
checked={value === 'true'}
onChange={(e) => setValue(name, String(e.target.checked))}
className="h-5 w-5 rounded accent-[#f28b4a]"
/>
)
default:
return (
<input
id={name}
type="text"
required={required}
value={value}
onChange={(e) => setValue(name, e.target.value)}
placeholder={placeholder}
className={cls}
style={font}
/>
)
}
}