Production-ready Framer forms

Build Framer Forms, ship production-ready.

Design, preview, and export polished Framer forms with real validation, Google Sheets support, webhook delivery, and cleaner code from one builder.

Form BuilderTotally free
One system. Structure to delivery.

One source of truth for fields, styling, behavior, and submissions.

Build the field layer

Add text, email, phone, dropdowns, radios, checkboxes. Control widths, layout, and responsive structure with system-level logic.

Ship data to the right places

Connect directly to Google Sheets and webhooks, no need to rebuild payload logic or submission handling separately.

Enable OTP verification
Verify mobile submissions before form send.
Enable Google Sheets
Sync submissions into a connected sheet automatically.
Enable data webhook
Send structured payloads to your backend endpoint.
Enable redirect on submit
Take visitors to a destination after successful submit.

Ship to destinations

Export production-ready Framer JSX. Route submissions to Google Sheets and webhooks without rebuilding payload logic.

SubmitApplicationForm.tsx
import * 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 },
});
Design Bold.
Launch Fast.
© 2026 WHIM
By Shubh