Build the field layer
Add text, email, phone, dropdowns, radios, checkboxes. Control widths, layout, and responsive structure with system-level logic.
Design, preview, and export polished Framer forms with real validation, Google Sheets support, webhook delivery, and cleaner code from one builder.
One source of truth for fields, styling, behavior, and submissions.
Add text, email, phone, dropdowns, radios, checkboxes. Control widths, layout, and responsive structure with system-level logic.
Connect directly to Google Sheets and webhooks, no need to rebuild payload logic or submission handling separately.
Export production-ready Framer JSX. Route submissions to Google Sheets and webhooks without rebuilding payload logic.
SubmitApplicationForm.tsximport * as React from "react";
import { addPropertyControls, ControlType } from "framer";
const fields = [
{
"id": "field-first-name",
"type": "text",
"label": "First Name",
"placeholder": "Enter first name",
"required": false,
"isLabelVisible": true,
"isRequiredVisible": true,
"isHelperTextVisible": false,
"width": "half",
"validationMessage": "First name is required."
},
{
"id": "field-email",
"type": "email",
"label": "Email",
"placeholder": "Enter your work email",
"required": false,
"isLabelVisible": true,
"isRequiredVisible": true,
"isHelperTextVisible": false,
"width": "half",
"validationMessage": "Please enter a valid email address."
},
{
"id": "field-phone",
"type": "phone",
"label": "Phone Number",
"placeholder": "Enter phone number",
"required": false,
"isLabelVisible": true,
"isRequiredVisible": true,
"isHelperTextVisible": false,
"width": "half",
"phoneCountryCodeMode": "fixed",
"phoneCountryCode": "+91",
"phoneCountryCodeOptions": [
"+1",
"+44",
"+61",
"+91"
],
"validationMessage": "Please enter a valid phone number."
},
{
"id": "field-program",
"type": "select",
"label": "Program",
"placeholder": "Select an option",
"required": false,
"isLabelVisible": true,
"isRequiredVisible": true,
"isHelperTextVisible": false,
"width": "half",
"options": [
"Growth Sprint",
"Performance Creative",
"Product Advisory"
],
"validationMessage": "Please select a program."
}
];
const buttons = [
{
"id": "button-submit-primary",
"type": "submit",
"label": "Submit Application",
"width": "full",
"isLabelVisible": true,
"isLeftIconVisible": false,
"isRightIconVisible": false,
"leftIcon": "send",
"rightIcon": "arrowForward"
}
];
const layout = {
desktop: "2-col",
tablet: "2-col",
mobile: "2-col",
};
const styleOverrides = {
"tablet": {},
"mobile": {}
};
async function postToSheet(url, data, label) {
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: JSON.stringify(data),
})
const text = await response.text()
try {
return { ok: true, data: JSON.parse(text) }
} catch {
return { ok: true, data: text }
}
} catch (error) {
console.error(`[Form Debug] ${label} request failed`, error)
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
}
async function postToWebhook(url, params, label) {
try {
await fetch(url, {
method: "POST",
mode: "no-cors",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
})
return { ok: true }
} catch (error) {
console.error(`[Form Debug] ${label} request failed`, error)
return { ok: false, error: error instanceof Error ? error.message : String(error) }
}
}
function buildWebhookParams(payload) {
const params = new URLSearchParams()
params.append("submittedAt", String(payload.submittedAt || ""))
Object.entries(payload.values || {}).forEach(([key, value]) => {
if (Array.isArray(value)) {
params.append(key, value.join(", "))
return
}
params.append(key, String(value ?? ""))
})
return params
}
function getPhoneCountryCodeKey(fieldId) {
return fieldId + "__countryCode"
}
function getPhoneLocalNumberKey(fieldId) {
return fieldId + "__local"
}
function getPhoneFieldValue(countryCode, localNumber) {
const sanitizedCountryCode = String(countryCode || "").trim()
const sanitizedLocalNumber = String(localNumber || "").trim()
if (!sanitizedLocalNumber) {
return ""
}
return [sanitizedCountryCode, sanitizedLocalNumber].filter(Boolean).join(" ")
}
function createInitialValues(fields) {
const initialValues = {}
fields.forEach((field) => {
initialValues[field.id] = field.type === "checkbox" ? [] : ""
if (field.type === "phone") {
initialValues[getPhoneCountryCodeKey(field.id)] = field.phoneCountryCode || "+91"
initialValues[getPhoneLocalNumberKey(field.id)] = ""
}
})
return initialValues
}
function getRequiredFieldMessage(field) {
if (field.type === "select") {
return field.validationMessage || `Please select ${String(field.label || "an option").toLowerCase()}.`
}
if (field.type === "radio") {
return field.validationMessage || `Please select one ${String(field.label || "option").toLowerCase()}.`
}
if (field.type === "checkbox") {
return field.validationMessage || `Please select at least one ${String(field.label || "option").toLowerCase()}.`
}
return `${field.label || "This field"} is required.`
}
function validateField(field, values) {
const value = values[field.id]
if (field.type === "checkbox") {
const selectedValues = Array.isArray(value) ? value : []
if (field.required && selectedValues.length === 0) {
return getRequiredFieldMessage(field)
}
return ""
}
if (field.type === "phone") {
const localNumber = String(values[getPhoneLocalNumberKey(field.id)] || "").trim()
if (field.required && !localNumber) {
return getRequiredFieldMessage(field)
}
if (localNumber && localNumber.length !== 10) {
return field.validationMessage || "Please enter a valid phone number."
}
return ""
}
const normalizedValue = typeof value === "string" ? value.trim() : value
if (field.required) {
const isEmpty =
normalizedValue === "" ||
normalizedValue === null ||
normalizedValue === undefined
if (isEmpty) {
return getRequiredFieldMessage(field)
}
}
if (field.type === "email" && normalizedValue) {
if (!/^[^s@]+@[^s@]+.[^s@]+$/.test(String(normalizedValue))) {
return field.validationMessage || "Please enter a valid email address."
}
}
return ""
}
function getFormPadding(props) {
if (props.formPaddingMode === "individual") {
return `${props.formPaddingTop}px ${props.formPaddingRight}px ${props.formPaddingBottom}px ${props.formPaddingLeft}px`
}
return props.formPadding
}
function getInputPadding(props) {
if (props.inputPaddingMode === "individual") {
return {
top: props.inputPaddingTop,
right: props.inputPaddingRight,
bottom: props.inputPaddingBottom,
left: props.inputPaddingLeft,
}
}
return {
top: props.inputPadding,
right: props.inputPadding,
bottom: props.inputPadding,
left: props.inputPadding,
}
}
function getButtonPadding(button, props) {
if (button.paddingMode === "individual") {
return {
top: button.paddingTop ?? props.buttonPaddingTop,
right: button.paddingRight ?? props.buttonPaddingRight,
bottom: button.paddingBottom ?? props.buttonPaddingBottom,
left: button.paddingLeft ?? props.buttonPaddingLeft,
}
}
if (typeof button.padding === "number") {
return {
top: button.padding,
right: button.padding,
bottom: button.padding,
left: button.padding,
}
}
return {
top: props.buttonPadding,
right: props.buttonPadding,
bottom: props.buttonPadding,
left: props.buttonPadding,
}
}
function getInputSurfaceStyle(props, options = {}) {
const inputPadding = getInputPadding(props)
const topPadding = options.isTextarea ? inputPadding.top + 3 : inputPadding.top
const borderWidth = options.isFocused ? props.fieldFocusWidth : props.fieldBorderWidth
const controlIconSize = Math.max(props.inputTextSize * 0.9, 10)
const controlContentHeight = Math.max(props.inputTextSize * 1.2, controlIconSize)
return {
position: "relative",
display: "flex",
alignItems: options.isTextarea ? "flex-start" : "center",
width: "100%",
...(options.isTextarea
? { minHeight: 132 }
: { minHeight: inputPadding.top + inputPadding.bottom + controlContentHeight }),
padding: `${topPadding}px ${inputPadding.right}px ${inputPadding.bottom}px ${inputPadding.left}px`,
...(options.hasTrailing ? { paddingRight: inputPadding.right + 28 } : {}),
borderRadius: props.fieldRadius,
border: `${borderWidth}px solid ${options.isFocused ? props.fieldFocusColor : props.fieldBorderColor}`,
background: props.fieldSurfaceColor,
color: options.isPlaceholder ? props.fieldPlaceholderColor : props.fieldTextColor,
boxSizing: "border-box",
}
}
function getNativeInputStyle(props, options = {}) {
const inputPadding = getInputPadding(props)
const topPadding = options.isTextarea ? inputPadding.top + 3 : inputPadding.top
return {
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
padding: `${topPadding}px ${inputPadding.right}px ${inputPadding.bottom}px ${inputPadding.left}px`,
border: "none",
background: "transparent",
color: "transparent",
caretColor: props.fieldTextColor,
fontFamily: "inherit",
fontSize: props.inputTextSize,
fontWeight: props.inputTextWeight,
lineHeight: 1.2,
outline: "none",
resize: options.isTextarea ? "vertical" : "none",
WebkitTextFillColor: "transparent",
...(options.isSelect ? { opacity: 0, cursor: "pointer", appearance: "none" } : {}),
}
}
function getPhoneFieldLayoutStyle() {
return {
display: "grid",
gridTemplateColumns: "max-content minmax(0, 1fr)",
gap: 0,
width: "100%",
}
}
function getDirectInputStyle(props, options = {}) {
const inputPadding = getInputPadding(props)
const borderWidth = options.isFocused ? props.fieldFocusWidth : props.fieldBorderWidth
const controlIconSize = Math.max(props.inputTextSize * 0.9, 10)
const controlContentHeight = Math.max(props.inputTextSize * 1.2, controlIconSize)
return {
width: "100%",
minHeight: inputPadding.top + inputPadding.bottom + controlContentHeight,
borderRadius: props.fieldRadius,
border: `${borderWidth}px solid ${options.isFocused ? props.fieldFocusColor : props.fieldBorderColor}`,
background: props.fieldSurfaceColor,
padding: `${inputPadding.top}px ${inputPadding.right}px ${inputPadding.bottom}px ${inputPadding.left}px`,
color: props.fieldTextColor,
fontFamily: "inherit",
fontSize: props.inputTextSize,
fontWeight: props.inputTextWeight,
lineHeight: 1.2,
outline: "none",
boxSizing: "border-box",
}
}
function getPhoneCodeStaticStyle(props, options = {}) {
const inputPadding = getInputPadding(props)
return {
...getInputSurfaceStyle(props, { isFocused: options.isFocused }),
display: "inline-flex",
width: "max-content",
paddingLeft: inputPadding.left,
paddingRight: inputPadding.right,
justifyContent: "center",
whiteSpace: "nowrap",
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
}
}
function getPhoneNumberSurfaceStyle(props, options = {}) {
return {
...getInputSurfaceStyle(props, options),
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderLeft: "0",
}
}
function getPhoneNumberInputStyle(props) {
return {
...getNativeInputStyle(props),
paddingLeft: `calc(${getInputPadding(props).left}px + 1px)`,
}
}
function getPhoneCodeTriggerStyle() {
return {
display: "block",
width: "100%",
padding: 0,
border: 0,
background: "transparent",
cursor: "pointer",
fontFamily: "inherit",
textAlign: "left",
}
}
function getPhoneCodeMenuStyle(props) {
return {
position: "absolute",
top: "calc(100% - 1px)",
left: 0,
zIndex: 40,
display: "grid",
width: "max-content",
minWidth: "max-content",
padding: 4,
border: `1px solid ${props.fieldBorderColor}`,
borderRadius: props.fieldRadius,
background: props.fieldSurfaceColor,
boxShadow: "0 12px 28px rgb(0 0 0 / 0.28)",
}
}
function getPhoneCodeOptionStyle(props, isActive) {
const inputPadding = getInputPadding(props)
return {
padding: `${inputPadding.top}px ${inputPadding.right}px ${inputPadding.bottom}px ${inputPadding.left}px`,
border: 0,
borderRadius: Math.max(props.fieldRadius - 4, 0),
background: isActive ? "rgb(255 255 255 / 0.06)" : "transparent",
color: props.fieldTextColor,
fontFamily: "inherit",
fontSize: props.inputTextSize,
fontWeight: props.inputTextWeight,
lineHeight: 1.2,
textAlign: "left",
whiteSpace: "nowrap",
cursor: "pointer",
}
}
function getDropdownMenuStyle(props) {
return {
position: "absolute",
top: "calc(100% + 1px)",
left: 0,
right: 0,
zIndex: 40,
display: "grid",
padding: 4,
border: `1px solid ${props.fieldBorderColor}`,
borderRadius: props.fieldRadius,
background: props.fieldSurfaceColor,
fontFamily: "inherit",
fontSize: props.inputTextSize,
fontWeight: props.inputTextWeight,
lineHeight: 1.2,
boxShadow: "0 12px 28px rgb(0 0 0 / 0.28)",
}
}
function getDropdownOptionStyle(props, isActive) {
const inputPadding = getInputPadding(props)
return {
padding: `${inputPadding.top}px ${inputPadding.right}px ${inputPadding.bottom}px ${inputPadding.left}px`,
border: 0,
borderRadius: Math.max(props.fieldRadius - 4, 0),
background: isActive ? "rgb(255 255 255 / 0.06)" : "transparent",
color: props.fieldTextColor,
fontFamily: "inherit",
fontSize: props.inputTextSize,
fontWeight: props.inputTextWeight,
lineHeight: 1.2,
textAlign: "left",
cursor: "pointer",
}
}
function getChoiceStyle(props) {
const inputPadding = getInputPadding(props)
return {
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: `${inputPadding.top}px ${inputPadding.right}px ${inputPadding.bottom}px ${inputPadding.left}px`,
borderRadius: props.fieldRadius,
border: `${props.fieldBorderWidth}px solid ${props.fieldBorderColor}`,
background: props.fieldSurfaceColor,
fontSize: props.inputTextSize,
fontWeight: props.inputTextWeight,
color: props.fieldTextColor,
}
}
function getButtonRootStyle() {
return {
width: "100%",
padding: 0,
border: "none",
background: "transparent",
appearance: "none",
cursor: "pointer",
}
}
function getButtonSurfaceStyle(button, props) {
const buttonPadding = getButtonPadding(button, props)
const buttonVariant = button.variant || props.buttonVariant
const buttonRadius =
button.radiusMode === "individual"
? [
String(button.radiusTopLeft ?? props.buttonRadius) + "px",
String(button.radiusTopRight ?? props.buttonRadius) + "px",
String(button.radiusBottomRight ?? props.buttonRadius) + "px",
String(button.radiusBottomLeft ?? props.buttonRadius) + "px",
].join(" ")
: String(button.radius ?? props.buttonRadius) + "px"
const buttonTextSize = button.textSize ?? props.buttonTextSize
const buttonTextWeight = button.textWeight ?? props.buttonTextWeight
const buttonBorderWidth = button.borderWidth ?? props.buttonBorderWidth
const buttonBorderColor = button.borderColor ?? props.buttonBorderColor
const buttonTextColor = button.textColor ?? props.buttonTextColor
const buttonFillColor = button.fillColor ?? props.primaryColor
const shared = {
width: "100%",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
borderRadius: buttonRadius,
fontSize: buttonTextSize,
fontWeight: buttonTextWeight,
lineHeight: 1.2,
padding: `${buttonPadding.top}px ${buttonPadding.right}px ${buttonPadding.bottom}px ${buttonPadding.left}px`,
boxSizing: "border-box",
}
return buttonVariant === "outline"
? {
...shared,
border: `${buttonBorderWidth}px solid ${buttonBorderColor}`,
color: buttonTextColor,
background: "transparent",
}
: {
...shared,
border: `${buttonBorderWidth}px solid ${buttonBorderColor}`,
color: buttonTextColor,
background: buttonFillColor,
}
}
function ButtonIcon({ icon, size = 18 }) {
if (icon === "arrowBack") {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M19 12H5M5 12L11 6M5 12L11 18"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
if (icon === "send") {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M3 20.01L20 3M3 20.01L9 20M3 20.01L3 14"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
if (icon === "call") {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M5 4.5C5 14.165 9.835 19 19.5 19L21 15.5L16.75 13.5L14.75 15.5C11.785 14.02 9.98 12.215 8.5 9.25L10.5 7.25L8.5 3L5 4.5Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
if (icon === "sms") {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 6.5C4 5.12 5.12 4 6.5 4H17.5C18.88 4 20 5.12 20 6.5V14.5C20 15.88 18.88 17 17.5 17H9L4 20V6.5Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
if (icon === "lock") {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect
x="5"
y="11"
width="14"
height="9"
rx="2"
stroke="currentColor"
strokeWidth="2"
/>
<path
d="M8 11V8.5C8 6.01 10.01 4 12.5 4C14.99 4 17 6.01 17 8.5V11"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
)
}
if (icon === "check") {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M5 12L10 17L19 8"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M5 12H19M19 12L13 6M19 12L13 18"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
function ChevronIcon() {
return (
<svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M7 10L12 15L17 10"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
function getButtonContent(button, label, props) {
const leftIcon = button.leftIcon || (button.type === "back" ? "arrowBack" : button.type === "otp" ? "lock" : "send")
const rightIcon = button.rightIcon || "arrowForward"
const iconSize = (button.textSize ?? props.buttonTextSize) + 5
return (
<span style={{ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 8, fontSize: button.textSize ?? props.buttonTextSize, lineHeight: 1.2 }}>
{button.isLeftIconVisible === true ? <ButtonIcon icon={leftIcon} size={iconSize} /> : null}
{button.isLabelVisible !== false ? <span>{label}</span> : null}
{button.isRightIconVisible === true ? <ButtonIcon icon={rightIcon} size={iconSize} /> : null}
</span>
)
}
function FieldShell({ label, required, isLabelVisible, isRequiredVisible, errorMessage, children, props }) {
return (
<label style={{ display: "grid", gap: 8 }}>
{isLabelVisible !== false ? (
<span style={{ color: props.fieldLabelColor, fontWeight: props.labelWeight, fontSize: props.labelSize }}>
{label}{required && isRequiredVisible !== false ? " *" : ""}
</span>
) : null}
{children}
{errorMessage ? (
<span style={{ color: props.fieldHelperColor, fontSize: props.helperSize, fontWeight: props.helperWeight }}>{errorMessage}</span>
) : null}
</label>
)
}
function InputField({ field, value, onChange, onFocus, onBlur, isFocused, errorMessage, props, type = "text" }) {
return (
<FieldShell
label={field.label}
required={field.required}
isLabelVisible={field.isLabelVisible}
isRequiredVisible={field.isRequiredVisible}
errorMessage={errorMessage}
props={props}
>
<div style={{ position: "relative" }}>
<div aria-hidden="true" style={getInputSurfaceStyle(props, { isPlaceholder: !value, isFocused })}>
<span style={{ display: "block", width: "100%", fontSize: props.inputTextSize, fontWeight: props.inputTextWeight, lineHeight: 1.2, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{value || field.placeholder || " "}
</span>
</div>
<input
type={type}
value={value}
onChange={(event) => onChange(field.id, event.target.value)}
onFocus={onFocus}
onBlur={onBlur}
placeholder=""
style={getNativeInputStyle(props)}
/>
</div>
</FieldShell>
)
}
function TextareaField({ field, value, onChange, onFocus, onBlur, isFocused, errorMessage, props }) {
return (
<FieldShell
label={field.label}
required={field.required}
isLabelVisible={field.isLabelVisible}
isRequiredVisible={field.isRequiredVisible}
errorMessage={errorMessage}
props={props}
>
<div style={{ position: "relative" }}>
<div aria-hidden="true" style={getInputSurfaceStyle(props, { isTextarea: true, isPlaceholder: !value, isFocused })}>
<span style={{ display: "block", width: "100%", fontSize: props.inputTextSize, fontWeight: props.inputTextWeight, lineHeight: 1.2, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{value || field.placeholder || " "}
</span>
</div>
<textarea
value={value}
onChange={(event) => onChange(field.id, event.target.value)}
onFocus={onFocus}
onBlur={onBlur}
placeholder=""
style={getNativeInputStyle(props, { isTextarea: true })}
/>
</div>
</FieldShell>
)
}
function getViewportWidth() {
if (typeof window === "undefined") {
return 1200
}
return window.innerWidth
}
function getActiveMode(props, viewportWidth) {
if (props.layoutMode) {
return props.layoutMode
}
if (viewportWidth <= 414) {
return "mobile"
}
if (viewportWidth <= 820) {
return "tablet"
}
return "desktop"
}
export default function SubmitApplicationForm(props) {
const [values, setValues] = React.useState(() => createInitialValues(fields))
const [errors, setErrors] = React.useState({})
const [submitState, setSubmitState] = React.useState("idle")
const [focusedFieldId, setFocusedFieldId] = React.useState(null)
const [viewportWidth, setViewportWidth] = React.useState(() => getViewportWidth())
const otpUrl = props.otpUrl || props.otpWebhookUrl || ""
const sheetsUrl = props.sheetsUrl || props.googleSheetsUrl || ""
const webhookUrl = props.webhookUrl || props.fullDataWebhookUrl || ""
const redirectUrl = props.redirectUrl || ""
const handleChange = React.useCallback((fieldId, nextValue) => {
setValues((current) => {
const nextValues = { ...current, [fieldId]: nextValue }
const field = fields.find((candidate) => candidate.id === fieldId)
if (field) {
setErrors((currentErrors) => {
if (!currentErrors[fieldId]) {
return currentErrors
}
const nextError = validateField(field, nextValues)
if (!nextError) {
const nextErrors = { ...currentErrors }
delete nextErrors[fieldId]
return nextErrors
}
return { ...currentErrors, [fieldId]: nextError }
})
}
return nextValues
})
}, [])
React.useEffect(() => {
const handleResize = () => {
setViewportWidth(getViewportWidth())
}
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [])
const handleSubmit = React.useCallback(async (event) => {
event.preventDefault()
const nextErrors = {}
fields.forEach((field) => {
const nextError = validateField(field, values)
if (nextError) {
nextErrors[field.id] = nextError
}
})
if (Object.keys(nextErrors).length > 0) {
setErrors(nextErrors)
setSubmitState("error")
return
}
setErrors({})
setSubmitState("loading")
const payload = {
submittedAt: new Date().toISOString(),
values,
}
console.log("[Form Debug] Submit started", {
payload,
integrations: {
sheetsUrl,
webhookUrl,
redirectUrl,
hasGoogleSheetsUrl: Boolean(sheetsUrl),
hasWebhookUrl: Boolean(webhookUrl),
hasRedirectUrl: Boolean(redirectUrl),
},
})
try {
if (sheetsUrl) {
console.log("[Form Debug] Sending Google Sheets request", {
url: sheetsUrl,
payload,
})
const googleSheetsResponse = await postToSheet(sheetsUrl, payload, "GOOGLE_SHEETS");
console.log("[Form Debug] Google Sheets response", {
ok: googleSheetsResponse.ok,
data: googleSheetsResponse.data ?? null,
error: googleSheetsResponse.error ?? null,
})
}
console.log("[Form Debug] Submit succeeded")
setSubmitState("success")
} catch (error) {
console.error("[Form Debug] Submit failed", error)
setSubmitState("error")
}
}, [redirectUrl, sheetsUrl, values, webhookUrl])
const activeMode = getActiveMode(props, viewportWidth)
const resolvedProps = {
...props,
...(activeMode === "tablet"
? styleOverrides.tablet
: activeMode === "mobile"
? styleOverrides.mobile
: {}),
}
return (
<form
onSubmit={handleSubmit}
style={{
display: "grid",
gap: resolvedProps.sectionGap,
padding: getFormPadding(resolvedProps),
borderRadius: resolvedProps.sectionRadius,
background: resolvedProps.sectionSurfaceColor,
border: `${resolvedProps.sectionBorderWidth}px solid ${resolvedProps.sectionBorderColor}`,
}}
>
<div style={{ display: "grid", gap: resolvedProps.fieldGap }}>
{fields.map((field) => (
<div key={field.id}>
{field.type === "text" || field.type === "email"
? renderField(
field,
values[field.id],
handleChange,
focusedFieldId === field.id,
() => setFocusedFieldId(field.id),
() => {},
errors[field.id],
resolvedProps,
)
: null}
</div>
))}
</div>
{buttons.length > 0 ? (
<div style={{ display: "grid", gap: resolvedProps.fieldGap }}>
{buttons.map((button) => {
const label = button.type === "submit"
? submitState === "loading"
? "Submitting..."
: submitState === "success"
? "Submitted"
: submitState === "error"
? "Retry Submit"
: button.label
: button.label
return (
<div key={button.id}>
<button type="submit" style={getButtonRootStyle()}>
<span style={getButtonSurfaceStyle(button, resolvedProps)}>
{getButtonContent(button, label, resolvedProps)}
</span>
</button>
</div>
)
})}
</div>
) : null}
</form>
)
}
addPropertyControls(SubmitApplicationForm, {
sheetsUrl: { type: ControlType.String, title: "Sheets URL", defaultValue: "" },
layoutMode: {
type: ControlType.Enum,
title: "Layout Mode",
options: ["desktop", "tablet", "mobile"],
optionTitles: ["Desktop", "Tablet", "Mobile"],
displaySegmentedControl: true,
defaultValue: "desktop",
},
primaryColor: { type: ControlType.Color, title: "Button Color", defaultValue: "#0055FF" },
buttonBorderColor: { type: ControlType.Color, title: "Button Border", defaultValue: "#0055FF" },
buttonTextColor: { type: ControlType.Color, title: "Button Text", defaultValue: "#FFFFFF" },
sectionSurfaceColor: { type: ControlType.Color, title: "Section Surface", defaultValue: "#222222" },
sectionBorderColor: { type: ControlType.Color, title: "Section Border", defaultValue: "#2E2E2E" },
sectionBorderWidth: { type: ControlType.Number, title: "Section Border Width", min: 0, max: 12, step: 0.1, defaultValue: 1 },
fieldRadius: { type: ControlType.Number, title: "Field Radius", min: 0, max: 32, defaultValue: 10 },
buttonRadius: { type: ControlType.Number, title: "Button Radius", min: 0, max: 32, defaultValue: 10 },
});