setPreviewData({ [pid]: n })}
/>
)
}
// Registered field types draw their own control.
if (typeDef && typeDef.Canvas) {
const RegisteredCanvas = typeDef.Canvas
return (
)
}
switch (field.type) {
case 'textarea': {
// The storefront textarea has no icon (the reference builder embed): same here, so icons never shift the text.
return (
)
}
case 'select':
case 'dropdown': {
const isMultiSelect = Boolean(field.isMulti ?? field.multiSelect)
return (
{renderFieldIcon('left')}
setPreviewData({ [pid]: isMultiSelect ? Array.from(e.target.selectedOptions).map((o) => o.value).filter(Boolean) : e.target.value }) : undefined}
className={`w-full py-2 border rounded-lg text-gray-900 text-sm focus:outline-none transition-colors cursor-pointer`}
style={inputCommonStyle}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onMouseEnter={() => setIsInputHovered(true)}
onMouseLeave={() => setIsInputHovered(false)}
>
{placeholderText}
{field.options?.map((opt, idx) => {
const optVal = getOptionValue(opt)
const optLabel = getOptionLabel(opt)
return (
{optLabel}
)
})}
{viewMode === 'edit' && isEditingIcon && (
setIsEditingIcon(false)}
onUpdate={(updates) => updateField(field.id, updates)}
anchorRef={iconRef}
setIsDirty={useFormStore.getState().setIsDirty}
/>
)}
)
}
case 'checkbox': {
const rawCols = field.checkboxRowColumns || field.choiceColumns || 1
const cols = rawCols === 'inline' ? 'inline' : (parseInt(rawCols) || 1)
const style = field.choiceStyle || fp.checkboxStyle || 'standard'
const containerClass =
cols === 'inline' || style === 'pill'
? 'flex flex-wrap gap-2'
: cols === 2
? 'grid grid-cols-2 gap-2'
: cols === 3
? 'grid grid-cols-3 gap-2'
: cols === 4
? 'grid grid-cols-4 gap-2'
: cols >= 5
? 'grid grid-cols-5 gap-2'
: 'space-y-1.5'
// Standard options: the storefront's grid (N columns, 8px gap; one column on phones).
const groupStyle = cols === 'inline' || style === 'pill'
? undefined
: { display: 'grid', gridTemplateColumns: isMobile ? '1fr' : `repeat(${cols}, minmax(0, 1fr))`, gap: 8 }
const { selected: choiceSelected, unselected: choiceUnselected } = choiceColors(fp, gs)
const choiceOptions = (field.options && field.options.length > 0)
? field.options
: [
{ label: 'First Choice', value: 'first_choice' },
{ label: 'Second Choice', value: 'second_choice' },
{ label: 'Third Choice', value: 'third_choice' }
]
return (
{choiceOptions.map((opt, idx) => {
const optVal = getOptionValue(opt)
const optLabel = getOptionLabel(opt)
const optPrice = typeof opt === 'object' && opt?.price !== undefined ? opt.price : null
const isChecked = viewMode === 'preview' ? (previewData[pid] || []).includes(optVal) : (Array.isArray(fp.defaultValue) ? fp.defaultValue : []).map(String).includes(String(optVal))
if (style === 'pill') {
return (
{
const current = previewData[pid] || []
setPreviewData({
[pid]: isChecked ? current.filter((x) => x !== optVal) : [...current, optVal],
})
}
: undefined
}
className={`px-3.5 py-1.5 rounded-full text-xs font-medium border transition-all ${
isChecked
? 'bg-gray-900 border-gray-900 text-white shadow-xs'
: 'bg-white border-gray-200 text-gray-700 hover:border-gray-400'
}`}
>
{optLabel}
{optPrice !== null && (+${optPrice}) }
{viewMode === 'edit' && choiceOptions.length > 1 && (
{
e.stopPropagation()
updateField(field.id, removeOptionUpdates(field, choiceOptions, idx))
}}
className="opacity-0 group-hover/opt:opacity-100 p-0.5 text-gray-400 hover:text-red-600 rounded transition-opacity cursor-pointer ml-1"
title="Remove choice"
data-canvas-chrome="true"
>
)}
)
}
if (style === 'card') {
// Storefront checkbox card (render_checkbox_field, card branch).
const toggle = viewMode === 'preview' ? () => {
const current = previewData[pid] || []
setPreviewData({ [pid]: isChecked ? current.filter((x) => x !== optVal) : [...current, optVal] })
} : undefined
const o = typeof opt === 'object' && opt ? opt : {}
const os = choiceOptionStyle(fp, gs, opt)
return (
{viewMode === 'edit' && choiceOptions.length > 1 && (
{ e.stopPropagation(); updateField(field.id, removeOptionUpdates(field, choiceOptions, idx)) }}
className="absolute -top-2 -right-2 z-10 p-0.5 bg-white border border-gray-200 rounded-full text-gray-400 hover:text-red-600 opacity-0 group-hover/opt:opacity-100 transition-opacity cursor-pointer">
)}
{}} className="wpvfb-choice-box"
style={{ '--choice-selected': choiceSelected, '--choice-unselected': choiceUnselected }} />
{viewMode === 'edit'
? optionEditor(opt, optLabel, idx, choiceOptions, { ...os, lineHeight: 1.3, fontWeight: os.fontWeight || 700, cursor: undefined, display: 'block' }, String(optLabel).replace(/\s*[—–-]\s*[+]?\$[\d,.]+$/, ''))
:
{String(optLabel).replace(/\s*[—–-]\s*[+]?\$[\d,.]+$/, '')}
}
{o.description &&
{o.description}
}
{optPrice !== null &&
+${Number(optPrice).toLocaleString()} }
)
}
return (
{
const current = previewData[pid] || []
setPreviewData({
[pid]: e.target.checked
? [...current, optVal]
: current.filter((x) => x !== optVal),
})
}
: undefined
}
className="wpvfb-choice-box"
style={{ '--choice-selected': choiceSelected, '--choice-unselected': choiceUnselected }}
/>
{viewMode === 'edit' ? (
e.stopPropagation()}>
{
const updated = [...(field.options || [])]
if (typeof updated[idx] === 'object') {
updated[idx] = { ...updated[idx], label: newLabel, ...formatting }
} else {
updated[idx] = { label: newLabel, value: newLabel, ...formatting }
}
updateField(field.id, { options: updated })
}}
/>
) : (
{optLabel}
)}
{viewMode === 'edit' && field.options?.length > 1 && (
{
e.stopPropagation()
updateField(field.id, removeOptionUpdates(field, field.options, idx))
}}
className="absolute right-0 top-1/2 -translate-y-1/2 z-40 opacity-0 group-hover/opt:opacity-100 p-1 text-gray-400 hover:text-red-600 rounded transition-opacity cursor-pointer"
title="Remove choice"
data-canvas-chrome="true"
>
)}
)
})}
{/* Inline Add Option & Column Layout Controls (on hover, like the reference builder) */}
{viewMode === 'edit' && (isHovered || isActive) && (
{
e.stopPropagation()
const nextIdx = (field.options?.length || 0) + 1
updateField(field.id, {
options: [...(field.options || []), { label: `Option ${nextIdx}`, value: `option_${nextIdx}` }]
})
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 hover:border-gray-900 transition-colors shadow-2xs cursor-pointer"
>
Add Option
{
e.stopPropagation()
// the reference builder key (checkboxRowColumns, 1-5) is what the storefront reads; "Inline" stays a canvas-only layout.
updateField(field.id, e.target.value === 'inline'
? { choiceColumns: 'inline', checkboxRowColumns: undefined }
: { checkboxRowColumns: parseInt(e.target.value) || 1, choiceColumns: undefined })
}}
className="pl-2 pr-7 py-1 min-w-[104px] text-xs bg-white border border-gray-200 rounded-lg text-gray-700 focus:outline-none focus:border-gray-900 cursor-pointer"
>
1 Column
2 Columns
3 Columns
4 Columns
5 Columns
Inline
)}
)
}
case 'radio': {
const rawCols = field.radioRowColumns || field.choiceColumns || 1
const cols = rawCols === 'inline' ? 'inline' : (parseInt(rawCols) || 1)
const style = field.choiceStyle || (fp.radioStyle === 'pills' ? 'pill' : fp.radioStyle) || 'standard'
const containerClass =
cols === 'inline' || style === 'pill'
? 'flex flex-wrap gap-2'
: cols === 2
? 'grid grid-cols-2 gap-2'
: cols === 3
? 'grid grid-cols-3 gap-2'
: cols === 4
? 'grid grid-cols-4 gap-2'
: cols >= 5
? 'grid grid-cols-5 gap-2'
: 'space-y-1.5'
// Standard options: the storefront's grid (N columns, 8px gap; one column on phones).
const groupStyle = cols === 'inline' || style === 'pill'
? undefined
: { display: 'grid', gridTemplateColumns: isMobile ? '1fr' : `repeat(${cols}, minmax(0, 1fr))`, gap: 8 }
const { selected: choiceSelected, unselected: choiceUnselected } = choiceColors(fp, gs)
const choiceOptions = (field.options && field.options.length > 0)
? field.options
: [
{ label: 'First Choice', value: 'first_choice' },
{ label: 'Second Choice', value: 'second_choice' },
{ label: 'Third Choice', value: 'third_choice' }
]
return (
{choiceOptions.map((opt, idx) => {
const optVal = getOptionValue(opt)
const optLabel = getOptionLabel(opt)
const optPrice = typeof opt === 'object' && opt?.price !== undefined ? opt.price : null
const isSelected = viewMode === 'preview' ? previewData[pid] === optVal : (typeof fp.defaultValue === 'string' && fp.defaultValue !== '' && fp.defaultValue === String(optVal))
if (style === 'pill') {
return (
setPreviewData({ [pid]: optVal }) : undefined}
className={`px-3.5 py-1.5 rounded-full text-xs font-medium border transition-all ${
isSelected
? 'bg-gray-900 border-gray-900 text-white shadow-xs'
: 'bg-white border-gray-200 text-gray-700 hover:border-gray-400'
}`}
>
{optLabel}
{optPrice !== null && (+${optPrice}) }
{viewMode === 'edit' && field.options.length > 1 && (
{
e.stopPropagation()
updateField(field.id, removeOptionUpdates(field, field.options, idx))
}}
className="opacity-0 group-hover/opt:opacity-100 p-0.5 text-gray-400 hover:text-red-600 rounded transition-opacity cursor-pointer ml-1"
title="Remove choice"
data-canvas-chrome="true"
>
)}
)
}
if (style === 'card') {
// Storefront radio card (render_radio_field, card branch; vertical-center layout too).
const select = viewMode === 'preview' ? () => setPreviewData({ [pid]: optVal }) : undefined
const o = typeof opt === 'object' && opt ? opt : {}
const title = String(optLabel).replace(/\s*[—–-]\s*\$[\d,.]+$/, '')
const os = choiceOptionStyle(fp, gs, opt)
const hasPrice = o.price !== undefined && o.price !== null
if (fp.radioCardLayout === 'vertical-center') {
return (
{viewMode === 'edit' && choiceOptions.length > 1 && (
{ e.stopPropagation(); updateField(field.id, removeOptionUpdates(field, choiceOptions, idx)) }}
className="absolute -top-2 -right-2 z-10 p-0.5 bg-white border border-gray-200 rounded-full text-gray-400 hover:text-red-600 opacity-0 group-hover/opt:opacity-100 transition-opacity cursor-pointer">
)}
{o.badge &&
{o.badge} }
{viewMode === 'edit' ? optionEditor(opt, optLabel, idx, choiceOptions, { fontSize: 12, fontWeight: 700, color: '#18181B', marginBottom: 2, display: 'block' }, title) :
{title}
}
{hasPrice &&
${Number(o.price).toLocaleString()}
}
)
}
const fsPx = parseFloat(os.fontSize) || 14
return (
{viewMode === 'edit' && choiceOptions.length > 1 && (
{ e.stopPropagation(); updateField(field.id, removeOptionUpdates(field, choiceOptions, idx)) }}
className="absolute -top-2 -right-2 z-10 p-0.5 bg-white border border-gray-200 rounded-full text-gray-400 hover:text-red-600 opacity-0 group-hover/opt:opacity-100 transition-opacity cursor-pointer">
)}
{viewMode === 'edit' ? optionEditor(opt, optLabel, idx, choiceOptions, { ...os, cursor: undefined }, title) : {title} }
{}} className="wpvfb-choice-box" style={{ ...RADIO_INPUT_VARS(gs) }} />
{hasPrice &&
${Number(o.price).toLocaleString(undefined, Number.isInteger(Number(o.price)) ? {} : { minimumFractionDigits: 2, maximumFractionDigits: 2 })} }
)
}
return (
setPreviewData({ [pid]: optVal }) : undefined}
className="wpvfb-choice-box"
style={{ ...RADIO_INPUT_VARS(gs) }}
/>
{viewMode === 'edit' ? (
e.stopPropagation()}>
{
const updated = [...(field.options || [])]
if (typeof updated[idx] === 'object') {
updated[idx] = { ...updated[idx], label: newLabel, ...formatting }
} else {
updated[idx] = { label: newLabel, value: newLabel, ...formatting }
}
updateField(field.id, { options: updated })
}}
/>
) : (
{optLabel}
)}
{viewMode === 'edit' && field.options.length > 1 && (
{
e.stopPropagation()
updateField(field.id, removeOptionUpdates(field, field.options, idx))
}}
className="absolute right-0 top-1/2 -translate-y-1/2 z-40 opacity-0 group-hover/opt:opacity-100 p-1 text-gray-400 hover:text-red-600 rounded transition-opacity cursor-pointer"
title="Remove choice"
data-canvas-chrome="true"
>
)}
)
})}
{/* Inline Add Option & Column Layout Controls (on hover, like the reference builder) */}
{viewMode === 'edit' && (isHovered || isActive) && (
{
e.stopPropagation()
const nextIdx = (field.options?.length || 0) + 1
updateField(field.id, {
options: [...(field.options || []), { label: `Option ${nextIdx}`, value: `option_${nextIdx}` }]
})
}}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-semibold text-gray-700 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 hover:border-gray-900 transition-colors shadow-2xs cursor-pointer"
>
Add Option
{
e.stopPropagation()
// the reference builder key (radioRowColumns, 1-5) is what the storefront reads; "Inline" stays a canvas-only layout.
updateField(field.id, e.target.value === 'inline'
? { choiceColumns: 'inline', radioRowColumns: undefined }
: { radioRowColumns: parseInt(e.target.value) || 1, choiceColumns: undefined })
}}
className="pl-2 pr-7 py-1 min-w-[104px] text-xs bg-white border border-gray-200 rounded-lg text-gray-700 focus:outline-none focus:border-gray-900 cursor-pointer"
>
1 Column
2 Columns
3 Columns
4 Columns
5 Columns
Inline
)}
)
}
case 'hidden':
return (
Hidden Field:
{field.placeholder || field.defaultValue || '(no default value)'}
)
case 'time': {
// render_time_field: HH : MM (: AM/PM in 12-hour format) in a 1fr auto 1fr (1fr) grid, centred text, the
// field icon 24px to the left of the boxes, subfield placeholders and default values.
const is12 = or(fp.timeFormat, '12hour') === '12hour'
const dv = fp.defaultValues || {}
const width = String(or(fp.width, '100%'))
const tStyle = {
...inputCommonStyle, borderRadius: `${or(gs.inputSettings.borderRadius, 4)}px`, fontFamily: 'inherit',
color: or(fp.textColor, gs.inputTextColor, gs.primaryTextColor, '#000000'), lineHeight: `${gs.lineHeight}px`,
padding: '8px', textAlign: 'center', width: '100%',
}
const handlers = {
onFocus: () => setIsInputFocused(true), onBlur: () => setIsInputFocused(false),
onMouseEnter: () => setIsInputHovered(true), onMouseLeave: () => setIsInputHovered(false),
}
return (
)
}
case 'date': {
// Storefront date (render_date_field): MM/DD/YYYY boxes by default (dateInputType date-field), a single
// picker input (date-picker) or month/day/year selects (date-dropdown). Icon sits left of the boxes.
const dType = fp.dateInputType || 'date-field'
const fmt = String(fp.dateFormat || 'mm/dd/yyyy')
const parts = fmt.replace('mm', 'MM').replace('dd', 'DD').replace('yyyy', 'YYYY').split(/[-/.]/)
const keyOf = (part) => ({ mm: 'month', dd: 'day' }[part.toLowerCase()] || 'year')
const dv = fp.defaultValues || {}
const boxStyle = { ...inputCommonStyle, padding: '8px', width: '100%', borderRadius: `${gs.inputSettings.borderRadius}px`, lineHeight: `${gs.lineHeight}px` }
const icon = hasIcon ? (
) : null
if (dType === 'date-picker') {
return (
{hasIcon && }
setPreviewData({ [pid]: e.target.value }) : undefined} />
{fp.datePickerIcon !== 'no-icon' && }
)
}
if (dType === 'date-dropdown') {
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
return (
{icon}
{parts.map((part) => {
const k = keyOf(part)
return (
{part}
{k === 'month' && months.map((m, i) => {m} )}
{k === 'day' && Array.from({ length: 31 }, (_, i) => {i + 1} )}
)
})}
)
}
return (
)
}
case 'captcha':
return (
)
case 'switch': {
// Storefront switch (render_switch_field): 44×24 track, 20px thumb; "card" style puts the label, description
// and price inside a bordered card (no field label above it).
const onValue = fp.switchOnValue || 'on'
const dvRaw = Array.isArray(fp.defaultValue) ? fp.defaultValue[0] : fp.defaultValue
const isDefaultOn = dvRaw === 'true' || dvRaw === true || dvRaw === 'on' || (dvRaw !== undefined && dvRaw !== '' && String(dvRaw) === String(onValue))
const isChecked = viewMode === 'preview' ? Boolean(previewData[pid]) : isDefaultOn
const onColor = fp.switchOnColor || gs.checkboxSelectedColor || '#2563EB'
const offColor = fp.switchOffColor || gs.checkboxUnselectedColor || '#D1D5DB'
const switchToggle = (
setPreviewData({ [pid]: e.target.checked }) : () => {}}
className="sr-only"
/>
)
if (fp.switchStyle === 'card') {
const lab = labelStyle(fp, gs)
return (
updateField(field.id, updates)} setIsDirty={useFormStore.getState().setIsDirty}>
{String(fp.name || 'Switch').replace(/\s*\([+]?\$[\d,.]+\)\s*$/, '')}
{fp.required && <>{' '}* >}
{field.description &&
{field.description}
}
{fp.switchPrice !== undefined && fp.switchPrice !== null && fp.switchPrice !== '' && +${Number(fp.switchPrice).toLocaleString()} }
{switchToggle}
)
}
return (
updateField(field.id, updates)} setIsDirty={useFormStore.getState().setIsDirty}>
{switchToggle}
)
}
case 'section': {
// Storefront section: heading (headingFontSizes[hN], weight 400, heading line-height/letter-spacing), a 1px
// divider, description above/below. headingLevel (panel) is read when headingFormat is missing.
const sec = sectionStyle(fp, gs)
const desc = field.description ? (
{field.description}
) : null
return (
{gs.descriptionPlacement === 'above' && desc}
{field.labelVisibility !== 'hidden' && (
updateField(field.id, updates)}
setIsDirty={useFormStore.getState().setIsDirty}
/>
)}
{gs.descriptionPlacement === 'below' && desc}
)
}
case 'submit': {
// Storefront submit button (render_submit_button): alignment wrapper, full width by default, padding
// 10px 20px, line-height 1.5, weight labelFontWeight (else normal), radius fieldBorderRadius → input radius.
const sb = submitStyle(fp, gs)
const submitIcon = field.fieldIcon && field.fieldIcon !== 'none' ? field.fieldIcon : null
const submitIconPos = field.iconPosition || 'right'
return (
{
e.preventDefault()
e.stopPropagation()
if (viewMode === 'preview') {
setIsSubmittedInPreview(true)
} else {
setIsEditingSubmitButton(true)
}
}}
className="inline-submit-btn-trigger cursor-pointer hover:opacity-90"
style={sb.button}
title={viewMode !== 'preview' ? 'Click to edit submit button style' : 'Click to submit preview'}
>
{submitIcon && submitIconPos === 'left' && }
{sb.text}
{submitIcon && submitIconPos === 'right' && }
{viewMode !== 'preview' && (
setIsEditingSubmitButton(false)}
field={field}
onUpdateField={(updates) => updateField(field.id, updates)}
/>
)}
)
}
case 'static-content': {
// Storefront static content (render_static_content_field): outer margin (on the field wrapper), a padded
// box (background/border/radius, pill badges shrink to their text), then the text element.
const sc = staticContentStyle(fp, gs)
// Block-level HTML escapes the storefront's /: it inherits the form text instead of the content style.
// The escaped blocks become items of the storefront's .flex.flex-col wrapper (margins do not collapse).
const textStyle = sc.blockContent
? { display: 'flex', flexDirection: 'column', margin: 0, padding: 0, overflowWrap: 'normal', fontFamily: sc.text.fontFamily, ...(sc.customFont ? { '--wpvfb-font': sc.text['--wpvfb-font'] } : {}) }
: sc.text
if (fp.contentType === 'social-network-icons') {
const size = fp.socialIconsSize || 24
return (
{(fp.selectedSocialIcons || []).filter((id) => SOCIAL_ICONS[id]).map((id) => (
))}
)
}
// What the site prints: the author HTML after WordPress's kses/safecss filtering (WPVFB_FP_Renderer::h()).
const html = ksesPost(field.content || field.placeholder || (viewMode === 'edit' ? 'Click to edit content...' : 'Enter your content here'))
const textEl = viewMode === 'edit' ? (
{
e.stopPropagation()
setIsEditingContent(true)
}}
html={html}
onCommit={(next) => {
updateField(field.id, { content: next })
useFormStore.getState().setIsDirty?.(true)
}}
className="outline-none cursor-text focus:ring-2 focus:ring-gray-900/20 rounded"
style={textStyle}
/>
) : (
)
return (
{sc.isListItem ? (
{sc.listIcon.position === 'left' && }
{textEl}
{sc.listIcon.position === 'right' && }
) : textEl}
{viewMode === 'edit' && isEditingContent && (
setIsEditingContent(false)}
field={field}
onUpdate={(updates) => updateField(field.id, updates)}
anchorRef={contentRef}
/>
)}
)
}
case 'html': {
// Rendered like the reference builder: the HTML itself, or the field label + a hint while it is still empty.
const htmlSource = field.htmlContent || field.content || ''
return (
{!htmlSource && field.labelVisibility !== 'hidden' && (
{field.label || field.name || 'HTML Field'}
)}
Enter your HTML content')) }}
/>
)
}
case 'image': {
// Storefront image (render_image_field): size preset (medium by default), per-corner radius, border, shadow,
// background, caption; 16px below; a dashed placeholder when there is no image yet.
const sizeMap = { small: [50, 50], medium: [200, 200], large: [400, 400], custom: [fp.imageCustomWidth || fp.imageMaxWidth || 200, fp.imageCustomHeight || fp.imageMaxHeight || 200] }
const isFull = fp.imageSize === 'full'
const [mw, mh] = isFull ? ['100%', 'none'] : (sizeMap[fp.imageSize || 'medium'] || [undefined, undefined])
const shadow = { none: 'none', subtle: '0 1px 3px rgba(0, 0, 0, 0.1)', medium: '0 4px 6px rgba(0, 0, 0, 0.1)', strong: '0 10px 15px rgba(0, 0, 0, 0.1)' }[fp.imageBoxShadow || 'none']
const radius = `${fp.imageBorderRadiusTopLeft || 0}px ${fp.imageBorderRadiusTopRight || 0}px ${fp.imageBorderRadiusBottomRight || 0}px ${fp.imageBorderRadiusBottomLeft || 0}px`
const bgH = isFull && fp.enableBackgroundHeight && fp.backgroundImageHeight
const common = { borderRadius: radius, border: `${fp.imageBorderWidth || 0}px solid ${fp.imageBorderColor || 'transparent'}`, boxShadow: shadow, backgroundColor: fp.imageBackgroundColor || 'transparent', display: 'inline-block', verticalAlign: 'baseline' }
const imgStyle = isFull
? { ...common, maxWidth: '100%', maxHeight: bgH ? `${fp.backgroundImageHeight}px` : 'none', width: '100%', height: bgH ? `${fp.backgroundImageHeight}px` : 'auto', objectFit: bgH ? 'cover' : 'contain' }
: { ...common, maxWidth: `${mw}px`, maxHeight: `${mh}px`, width: 'auto', height: 'auto' }
const pad = fp.padding && typeof fp.padding === 'object' ? fp.padding : {}
const src = field.imageUrl || field.url
return (
updateField(field.id, updates)}
setIsDirty={useFormStore.getState().setIsDirty}
>
{src ? (
) : (
No image uploaded
)}
{fp.imageCaption && (
{fp.imageCaption}
)}
)
}
case 'name': {
// Storefront name field: one grid row of the visible subfields (≥ 2 columns), 8px gap, sub-labels below.
// It stacks on phones (the storefront's max-width:768px rule) — driven by the canvas preview mode, not the
// admin's viewport.
const sub = fp.subfields && typeof fp.subfields === 'object' ? fp.subfields : {}
const visible = (k) => Boolean(sub[k]?.isVisible ?? false)
const count = Math.max(['prefix', 'first', 'middle', 'last', 'suffix'].filter(visible).length, 2)
const nameInput = { ...inputCommonStyle, padding: hasIcon ? (iconSide === 'left' ? '8px 8px 8px 32px' : '8px 32px 8px 8px') : '8px', width: '100%', borderRadius: `${gs.inputSettings.borderRadius}px`, lineHeight: `${gs.lineHeight}px` }
const subLabel = { fontSize: 12, lineHeight: '16px', color: '#6B7280', display: 'block', marginTop: 4 }
const cell = (key, ph, lbl) => (visible(key) ? (
{renderFieldIcon(iconSide, '2px')}
{sub[key]?.label || lbl}
) : null)
return (
{visible('prefix') && (
{sub.prefix?.placeholder || 'Select Prefix'}
{(fp.prefixOptions || []).map((o, i) => {o.label} )}
{sub.prefix?.label || 'Prefix'}
)}
{cell('first', 'First Name', 'First')}
{cell('middle', 'Middle', 'Middle')}
{cell('last', 'Last Name', 'Last')}
{cell('suffix', 'Suffix', 'Suffix')}
)
}
case 'address': {
// render_address_field: inputs with the sub-label below (12px gray-600), city/state and zip/country rows,
// `subfields.*` visibility/label/placeholder, the full country list, the field icon in every input.
const sf = fp.subfields || {}
const dvs = fp.defaultValues || {}
const visible = (k) => !(sf[k] && sf[k].isVisible === false)
const addrInput = {
...inputCommonStyle, width: '100%', lineHeight: `${gs.lineHeight}px`, color: or(fp.textColor, gs.inputTextColor, gs.primaryTextColor, '#000000'),
borderRadius: `${or(gs.inputSettings.borderRadius, 4)}px`, fontFamily: 'inherit',
padding: hasIcon ? (iconSide === 'left' ? '8px 8px 8px 32px' : '8px 32px 8px 8px') : '8px',
}
const addrSelect = { ...addrInput, appearance: 'auto', WebkitAppearance: 'menulist', backgroundImage: 'none', maxWidth: 'none' }
const icon = hasIcon ? (
) : null
const subLabel = (text) =>
{text}
const sub = (k, def) => (visible(k) ? (
) : null)
return (
{sub('street', 'Street Address')}
{sub('address2', 'Address Line 2')}
{sub('city', 'City')}
{sub('state', 'State / Province / Region')}
{sub('zip', 'ZIP / Postal Code')}
{visible('country') && (
{icon}
Select Country
{COUNTRY_LIST.map((c) => {c} )}
{subLabel(or(sf.country?.label, 'Country'))}
)}
)
}
case 'consent':
return (
);
case 'promo-banner':
return
case 'trust-badges':
return
case 'faq-accordion':
return (
{(field.faqItems || [
{ question: 'What is your return policy?', answer: 'You can return items within 30 days.' },
{ question: 'How long does shipping take?', answer: 'Standard shipping takes 5-7 business days.' },
]).map((item, idx) => (
viewMode === 'preview' && setActiveAccordion(activeAccordion === idx ? -1 : idx)}
className="w-full px-4 py-3 text-left text-sm font-medium text-slate-700 flex items-center justify-between hover:bg-slate-50 transition-colors"
>
{item.question}
{activeAccordion === idx &&
{item.answer}
}
))}
);
case 'countdown-banner':
return
default: {
const val = previewData[pid] || ''
if (!TEXT_INPUT_TYPES.includes(field.type)) return null
const charLength = String(val).length
const isPassword = field.type === 'password'
const isNumber = field.type === 'number'
return (
{/* Prefix Icon */}
{renderFieldIcon('left')}
{/* Number Prefix Adornment (e.g. $) */}
{isNumber && field.prefix && (
{field.prefix}
)}
setPreviewData({ [pid]: e.target.value }) : undefined}
maxLength={field.maxLength || undefined}
className={`w-full py-2.5 border rounded-lg text-sm focus:outline-none transition-colors ${
hasIcon && iconSide === 'left'
? 'pl-9 pr-3'
: isNumber && field.prefix
? 'pl-7 pr-3'
: 'px-3'
} ${
(hasIcon && iconSide === 'right') || isPassword || (isNumber && field.suffix)
? 'pr-9'
: ''
}`}
{...(inputCommonStyle['--wpvfb-font'] ? { 'data-font': '' } : {})}
style={inputCommonStyle}
placeholder={placeholderText}
readOnly={viewMode !== 'preview'}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onMouseEnter={() => setIsInputHovered(true)}
onMouseLeave={() => setIsInputHovered(false)}
/>
{/* Suffix Icon */}
{renderFieldIcon('right')}
{/* Inline submit arrow inside an email field (newsletter templates), as on the storefront */}
{field.type === 'email' && (field.buttonPlacement === 'inside' || field.inlineSubmitArrow === true) && (
)}
{viewMode === 'edit' && isEditingIcon && (
setIsEditingIcon(false)}
onUpdate={(updates) => updateField(field.id, updates)}
anchorRef={iconRef}
setIsDirty={useFormStore.getState().setIsDirty}
/>
)}
{/* Number Suffix Adornment (e.g. kg) */}
{isNumber && field.suffix && (
{field.suffix}
)}
{/* Password Eye Toggle */}
{isPassword && field.showPasswordToggle !== false && (
setShowPassword(!showPassword)}
data-canvas-chrome="true"
className="absolute right-3 text-slate-400 hover:text-slate-600 cursor-pointer"
>
{showPassword ? : }
)}
{/* Character Counter */}
{field.showCharCount && (
{charLength} {field.maxLength ? `/ ${field.maxLength}` : 'chars'}
)}
)
}
}
}
// A field type this builder doesn't know (and nothing registered): a neutral placeholder. The field and all its
// settings stay in the form untouched; it can be removed.
if (!field.isPlaceholder && !typeDef && !BUILTIN_FIELD_TYPES.includes(field.type)) {
if (viewMode === 'preview') return null
return (
{
e.stopPropagation()
setActiveFieldId(field.id)
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className="group relative cursor-grab active:cursor-grabbing select-none"
>
{field.label || field.name || 'Field'}
Unsupported field type: {String(field.type || 'unknown')}
e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
removeField(field.id)
}}
className="inline-flex items-center gap-1 px-2 py-1 rounded-md border border-gray-200 bg-white text-gray-600 hover:text-red-600 hover:border-red-200 cursor-pointer"
>
Remove
)
}
if (field.isPlaceholder) {
return (
Drop {field.label} Here
)
}
// --- RECURSIVE CONTAINER LAYOUT RENDERING ---
// Rendered like the storefront (render_grid_field) in both modes; Edit only adds chrome that does not move
// anything (dashed outline, action bar, empty-column drop hints). A registered container type (containerDef) wraps
// the grid in its own body (`Body`, receiving renderGrid(instanceIndex)).
const isRepeated = Boolean(containerDef)
if (field.type === 'grid' || isRepeated) {
const colKeys = Object.keys(layoutColumns || { 'col-0': [] })
const gst = gridStyles(fp, previewMode, (containerDef && containerDef.gridStyleOptions) || {})
const withUrl = (st) => (st && st.backgroundImage ? { ...st, backgroundImage: st.backgroundImage.replace(/^url\((.*)\)$/, (m, u) => `url("${resolveImageUrl(u.replace(/^["']|["']$/g, ''))}")`) } : st)
const colStyleAt = (i) => ({ position: 'relative', ...withUrl(gst.columns[i]?.style || {}) })
const renderGrid = (instIdx) => (
{gst.overlay &&
}
{colKeys.map((colKey, colIdx) => {
if (viewMode === 'edit') {
return (
)
}
return (
{gst.columns[colIdx]?.overlay &&
}
{(layoutColumns?.[colKey] || []).map((nestedField) => {
if (!shouldFieldBeVisible(nestedField, previewData, fields)) return null
return (
)
})}
)
})}
)
let body
if (isRepeated && containerDef.Body) {
const ContainerBody = containerDef.Body
body = (
)
} else {
body = renderGrid(0)
}
if (viewMode === 'preview') {
return
{body}
}
const outlineColor = isActive || hoveredFieldId === field.id || isHovered ? '#3b82f6' : '#cbd5e1'
return (
{
e.stopPropagation()
setActiveFieldId(field.id)
}}
// The grid's own action bar only while the pointer is on the grid itself, not on one of its fields.
onMouseMove={(e) => {
const owner = e.target.closest('[data-field-id]')
const own = !owner || owner.getAttribute('data-field-id') === field.id
if (own !== isHovered) setIsHovered(own)
}}
onMouseLeave={() => setIsHovered(false)}
className="group relative cursor-grab active:cursor-grabbing"
>
{/* Dashed outline: an overlay, so the grid keeps exactly its storefront layout */}
setActiveFieldId(field.id)}
onSpacing={() => setIsEditingSpacing(!isEditingSpacing)}
onDuplicate={() => duplicateField(field.id)}
onDelete={() => removeField(field.id)}
spacingBtnRef={spacingBtnRef}
/>
{isEditingSpacing && (
setIsEditingSpacing(false)}
onUpdate={(spacingData) => updateField(field.id, spacingData)}
anchorRef={spacingBtnRef}
/>
)}
{body}
)
}
// --- UNIFIED FORMFIELD RENDERING (100% PARITY BETWEEN EDIT & PREVIEW) ---
return (
{
e.stopPropagation()
setActiveFieldId(field.id)
} : undefined}
onMouseEnter={viewMode === 'edit' ? () => setIsHovered(true) : undefined}
onMouseLeave={viewMode === 'edit' ? () => setIsHovered(false) : undefined}
onMouseMove={viewMode === 'edit' ? (e) => {
if (e.currentTarget.contains(e.target)) setIsHovered(true)
} : undefined}
className={`group relative ${
viewMode === 'edit' ? 'cursor-grab active:cursor-grabbing select-none' : ''
} transition-all`}
>
{/* the reference builder Blue Hover/Active Inset Border */}
{viewMode === 'edit' && (isHovered || isActive || hoveredFieldId === field.id) && (
)}
{/* the reference builder Field Actions Bar (Inside Top-Right Frame) */}
{viewMode === 'edit' && (
setActiveFieldId(field.id)}
onSpacing={() => setIsEditingSpacing(!isEditingSpacing)}
onDuplicate={() => duplicateField(field.id)}
onDelete={() => removeField(field.id)}
spacingBtnRef={spacingBtnRef}
/>
)}
{/* Inline Spacing Editor Popover */}
{isEditingSpacing && viewMode === 'edit' && (
setIsEditingSpacing(false)}
onUpdate={(spacingData) => updateField(field.id, spacingData)}
anchorRef={spacingBtnRef}
/>
)}
{/* Resizable Right Handle (the reference builder style) */}
{(isHovered || isActive) &&
viewMode === 'edit' &&
field.type !== 'submit' &&
field.type !== 'grid' &&
!containerDef && (
e.stopPropagation()}
title="Drag to resize field width (12-col grid)"
>
)}
{/* Label + description + control, laid out like the storefront: label on top (or in a 130px column when
labelPlacement is "left", none when "none"), description above or below the control. */}
{(() => {
const lab = labelStyle(fp, gs)
// The address label falls back to the input text colour (render_address_field), not the theme label colour.
if (field.type === 'address') lab.style = { ...lab.style, color: or(fp.labelTextColor, fp.textColor, gs.inputTextColor, gs.primaryTextColor, '#000000') }
// Switch cards, steppers and registered types with `ownLabel` draw their own label (and description) like the storefront.
const isSwitchCard = (field.type === 'switch' && fp.switchStyle === 'card') || (field.type === 'number' && fp.numberStyle === 'stepper-slider') || Boolean(typeDef && typeDef.ownLabel)
const showLabel = !noLabel && !isSwitchCard && lab.visible && field.showLabel !== false
const desc = fieldDescriptionStyle(fp, gs)
const hasDesc = !NO_DESCRIPTION_TYPES.includes(field.type) && !(typeDef && typeDef.noDescription) && !isSwitchCard && Boolean(field.description)
const descEl = (
updateField(field.id, updates)}
setIsDirty={useFormStore.getState().setIsDirty}
/>
)
return (
// Fields without a label column (image, static content, submit…) are plain blocks on the storefront, so their
// inner margins collapse with the neighbours; labelled fields sit in flex columns there too.
{showLabel && (() => {
const labelEl = (
updateField(field.id, updates)}
setIsDirty={useFormStore.getState().setIsDirty}
/>
)
// Radio label helper text ("1-click select") sits at the right end of the label row (render_radio_field).
if (field.type !== 'radio' || !fp.labelHelperText) return labelEl
const ls = radioLabelStyle(fp, gs, lab.style)
return (
{React.cloneElement(labelEl, { displayStyle: { ...ls, margin: 0 } })}
{fp.labelHelperText}
)
})()}
{hasDesc && desc.placement === 'above' && descEl}
{/* Input container (Flush, Zero pl-7 indent, Dynamic Theme Styling) */}
e.stopPropagation() : undefined}
onTouchStart={viewMode === 'edit' ? (e) => e.stopPropagation() : undefined}
>
{/* Placeholder: one click on the placeholder edits it in place (Enter saves, Escape reverts) */}
updateField(field.id, { placeholder })}>
{renderInputPreview(viewMode !== 'preview')}
{hasDesc && desc.placement === 'below' && descEl}
)
})()}
)
}