Files
landing-builder/components/FormBuilderPanel.tsx
T

161 lines
8.4 KiB
TypeScript

"use client"
import React, { useCallback, useSyncExternalStore } from 'react'
import type { FormState, FormField } from '@/lib/state/formStore'
import { useTranslations } from 'next-intl'
type Store = {
getState: () => FormState
subscribe: (listener: () => void) => () => void
}
export default function FormBuilderPanel({ store }: { store: Store }) {
const t = useTranslations()
const state = useSyncExternalStore(store.subscribe, store.getState, store.getState)
const onAddText = useCallback(() => {
const idx = state.fields.length + 1
const name = `field${idx}`
store.getState().addField({ type: 'text', name, label: `Text ${idx}`, required: false })
}, [state.fields.length, store])
const nextName = () => `field${state.fields.length + 1}`
const onAddEmail = () => store.getState().addField({ type: 'email', name: nextName(), label: 'Email', required: false })
const onAddTel = () => store.getState().addField({ type: 'tel', name: nextName(), label: 'Tel', required: false })
const onAddDate = () => store.getState().addField({ type: 'date', name: nextName(), label: 'Date', required: false })
const onAddTextarea = () => store.getState().addField({ type: 'textarea', name: nextName(), label: 'Message', required: false })
const onAddCheckbox = () => store.getState().addField({ type: 'checkbox', name: nextName(), label: 'Agree', required: false })
const onAddSelect = () => store.getState().addField({ type: 'select', name: nextName(), label: 'Select', required: false, options: [] })
const onAddRadio = () => store.getState().addField({ type: 'radio', name: nextName(), label: 'Radio', required: false, options: [] })
const onChangeAction = (e: React.ChangeEvent<HTMLInputElement>) => {
store.getState().setActionUrl(e.target.value)
}
const onChangeHp = (e: React.ChangeEvent<HTMLInputElement>) => {
store.getState().setSpam({ honeypotFieldName: e.target.value })
}
const onChangeTs = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseInt(e.target.value || '0', 10)
store.getState().setSpam({ minSubmitSeconds: Number.isNaN(v) ? 0 : v })
}
const onChangeLabel = (i: number, e: React.ChangeEvent<HTMLInputElement>) => {
store.getState().updateField(i, { label: e.target.value })
}
const onToggleRequired = (i: number) => {
const cur = store.getState().fields[i]?.required ?? false
store.getState().updateField(i, { required: !cur })
}
const onDown = (i: number) => {
if (i < state.fields.length - 1) {
store.getState().moveField(i, i + 1)
}
}
const onRemove = (i: number) => {
store.getState().removeField(i)
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<button type="button" data-testid="btn-add-text" onClick={onAddText} className="px-2 py-1 border rounded">{t('builder.action.addText')}</button>
<button type="button" onClick={onAddEmail} className="px-2 py-1 border rounded">{t('builder.action.addEmail')}</button>
<button type="button" onClick={onAddTel} className="px-2 py-1 border rounded">{t('builder.action.addTel')}</button>
<button type="button" onClick={onAddDate} className="px-2 py-1 border rounded">{t('builder.action.addDate')}</button>
<button type="button" onClick={onAddTextarea} className="px-2 py-1 border rounded">{t('builder.action.addTextarea')}</button>
<button type="button" onClick={onAddCheckbox} className="px-2 py-1 border rounded">{t('builder.action.addCheckbox')}</button>
<button type="button" onClick={onAddSelect} className="px-2 py-1 border rounded">{t('builder.action.addSelect')}</button>
<button type="button" onClick={onAddRadio} className="px-2 py-1 border rounded">{t('builder.action.addRadio')}</button>
</div>
<div className="space-y-2">
<label className="block" htmlFor="form-action-url">
<span className="block text-sm">{t('builder.form.actionUrl')}</span>
<input id="form-action-url" aria-label="Action URL" className="border px-2 py-1 w-full" value={state.actionUrl}
onChange={onChangeAction} />
</label>
<label className="block" htmlFor="form-honeypot">
<span className="block text-sm">{t('builder.form.honeypot')}</span>
<input id="form-honeypot" aria-label="Honeypot Field" className="border px-2 py-1 w-full" value={state.spam.honeypotFieldName}
onChange={onChangeHp} />
</label>
<label className="block" htmlFor="form-min-seconds">
<span className="block text-sm">{t('builder.form.minSeconds')}</span>
<input id="form-min-seconds" aria-label="Min Submit Seconds" type="number" className="border px-2 py-1 w-full" value={state.spam.minSubmitSeconds}
onChange={onChangeTs} />
</label>
</div>
<ul className="space-y-2">
{state.fields.map((f: FormField, i: number) => (
<li key={`${f.name}-${i}`} data-testid="field-item" className="text-sm border rounded p-2 flex items-center gap-2">
<div className="flex-1">
<div className="text-xs text-gray-500">{f.type} - {f.name}</div>
<label className="block" htmlFor={`field-label-${i}`}>
<span className="sr-only">Label {i}</span>
<input id={`field-label-${i}`} aria-label={`Label ${i}`} className="border px-2 py-1 w-full" value={f.label}
onChange={(e) => onChangeLabel(i, e)} />
</label>
<label className="inline-flex items-center gap-2 mt-1" htmlFor={`field-required-${i}`}>
<input id={`field-required-${i}`} aria-label={`Required ${i}`} type="checkbox" checked={!!f.required}
onChange={() => onToggleRequired(i)} />
<span className="text-xs">required</span>
</label>
{(f.type === 'text' || f.type === 'email' || f.type === 'tel' || f.type === 'date' || f.type === 'textarea') && (
<div className="mt-1 grid grid-cols-2 gap-2">
<label className="block" htmlFor={`field-pattern-${i}`}>
<span className="sr-only">Pattern {i}</span>
<input
id={`field-pattern-${i}`}
aria-label={`Pattern ${i}`}
className="border px-2 py-1 w-full"
value={f.pattern ?? ''}
onChange={(e) => store.getState().updateField(i, { pattern: e.target.value })}
/>
</label>
<label className="block" htmlFor={`field-maxlen-${i}`}>
<span className="sr-only">MaxLength {i}</span>
<input
id={`field-maxlen-${i}`}
aria-label={`MaxLength ${i}`}
type="number"
className="border px-2 py-1 w-full"
value={f.maxLength ?? ''}
onChange={(e) => {
const v = parseInt(e.target.value || '0', 10)
store.getState().updateField(i, { maxLength: Number.isNaN(v) ? undefined : v })
}}
/>
</label>
</div>
)}
{(f.type === 'select' || f.type === 'radio') && (
<label className="block mt-1" htmlFor={`field-options-${i}`}>
<span className="sr-only">Options {i}</span>
<input id={`field-options-${i}`} aria-label={`Options ${i}`} className="border px-2 py-1 w-full"
value={(f.type === 'select' || f.type === 'radio') ? f.options.join(',') : ''}
onChange={(e) => {
const opts = e.target.value.split(',').map((s) => s.trim()).filter(Boolean)
if (f.type === 'select' || f.type === 'radio') {
store.getState().updateField(i, { options: opts } as Partial<FormField>)
}
}} />
</label>
)}
</div>
<div className="flex items-center gap-2">
<button type="button" aria-label={`Down ${i}`} onClick={() => onDown(i)} className="px-2 py-1 border rounded">Down {i}</button>
<button type="button" aria-label={`Remove ${i}`} onClick={() => onRemove(i)} className="px-2 py-1 border rounded text-red-600">Remove {i}</button>
</div>
</li>
))}
</ul>
</div>
)
}