# FormCapture

> Render any form from Storefront Forms by its key, with validation, inline errors, consent, and first-party capture

Canonical URL: https://3c5b211cae60:3005/islands/engagement/form-capture

> Renders a merchant form (phone, name, select, consent, and more) from its `formKey`, validates it, and submits to the form-capture backend. Field errors from the server show inline next to the right input.

**Category**: Engagement

## Props

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| formKey | string | yes | - | Key of the form in <AppLink to="forms">Storefront Forms</AppLink>, e.g. `customer_phone_number` |
| fields | array | no | - | Field definitions rendered instead of the live schema. Keys must match the form's fields |
| heading | string | no | - | Heading above the form |
| description | string | no | - | Supporting text under the heading |
| submitLabel | string | no | "Submit" | Submit button text |
| successMessage | string | no | "Thanks! We got your details." | Message shown after a successful submit |
| consentText | string | no | form's consent text | Consent copy. When set here or on the form, a required checkbox is shown |
| layout | enum | no | stacked | `stacked` or `inline` (fields and button on one row from the `sm` breakpoint) |
| variant | enum | no | default | `default`, `compact` (tighter padding) or `minimal` (underline inputs) |
| fieldOverrides | object | no | - | Per-field `{label, placeholder, width}` keyed by field key. `width: "half"` puts two fields on one row |
| messages | object | no | - | Custom or translated error copy keyed by error code (see below) |
| resetOnSuccess | boolean | no | false | Clear the inputs and keep the form visible after success |

Field types map to native inputs: `text`, `email` (`type="email"`), `phone` (`type="tel"`, `inputMode="tel"`, `autoComplete="tel"`), `number`, `date`, `textarea`, `select`, `radio` (a fieldset of options) and `checkbox`.

## Embed Example

```html
<lx-island name="FormCapture">
  <script type="application/json">
{"formKey":"customer_phone_number","heading":"Get order updates by SMS","submitLabel":"Text me","successMessage":"Thanks! You are on the list."}
  </script>
</lx-island>
```

## Usage Patterns

**Phone pop-up** (nested in Modal):

```html
<lx-island name="Modal">
  <script type="application/json">
{"trigger":"delay","triggerValue":5000,"position":"center","size":"md","showOnce":true,"storageKey":"phone_popup","headline":"Get 10% off by SMS","body":"Drop your number and we will text you a code.","children":[{"island":"FormCapture","props":{"formKey":"customer_phone_number","variant":"compact","submitLabel":"Send my code","successMessage":"Check your texts!"}}]}
  </script>
</lx-island>
```

**Two fields on one row**:

```html
<lx-island name="FormCapture">
  <script type="application/json">
{"formKey":"wholesale_inquiry","fieldOverrides":{"first_name":{"width":"half"},"last_name":{"width":"half"}},"submitLabel":"Send inquiry"}
  </script>
</lx-island>
```

**Translated errors**:

```html
<lx-island name="FormCapture">
  <script type="application/json">
{"formKey":"customer_phone_number","layout":"inline","variant":"compact","submitLabel":"Enviar","messages":{"invalid_phone":"Introduce un número válido","required":"Campo obligatorio"}}
  </script>
</lx-island>
```

## How Submissions Are Captured

FormCapture loads the form's live field list when it mounts and shows a skeleton while loading. If the form doesn't exist, it renders nothing on the live page. In the editor preview it shows "Form `<key>` not found" so the problem is visible.

On submit it checks required fields, emails and phones on the client. Phones must be 10 digits, or `+` followed by 8–15 digits, ignoring spaces, dashes and parentheses. The server is authoritative: it normalises the phone for the form's default country and returns field-level errors, which appear inline next to the input with `aria-invalid` and `aria-describedby`. Focus moves to the first invalid field.

Consent is sent as `_consent: true`. A hidden honeypot input named `_hp` is included and must stay empty, so bots that fill it are silently dropped.

After success the island emits `form:submitted` on the island event bus. If an email field was filled, it also calls `lx("identify", {email})`.

### Error codes

`required`, `invalid_email`, `invalid_phone`, `invalid_number`, `invalid_option`, `invalid_checkbox`, `too_long`, `consent_required`, `unknown_form`, `form_archived`, `rate_limited`, `network_error`, `submit_failed`. Any of them can be overridden through `messages`.

## Stylable Parts

Every element carries both `data-part` and `data-lx-part` with the same value:

| Part | Description |
|------|-------------|
| `root` | Outermost container (dispatches the DOM events) |
| `heading` | Heading |
| `description` | Description text |
| `form` | `<form>` element |
| `field` | Wrapper around one field (also has `data-field="<key>"`) |
| `label` | Field label (`<legend>` for radio groups) |
| `input` | Input, select, textarea, radio or checkbox |
| `error` | Inline field error |
| `consent` | Consent checkbox row |
| `submit` | Submit button |
| `success` | Success message |
| `status` | Live region for form-level errors (and the preview "not found" notice) |
| `skeleton` | Loading placeholder |

## CSS Variables

| Variable | Default | Controls |
|----------|---------|----------|
| `--lx-form-gap` | `0.75rem` | Gap between fields and rows |
| `--lx-input-radius` | `0.5rem` | Input corner radius |
| `--lx-input-bg` | `#fff` | Input background |
| `--lx-input-border` | `--lx-border-color`, `#e5e7eb` | Input border colour |
| `--lx-input-color` | `--lx-text-color`, `#111827` | Input text colour |
| `--lx-label-color` | `--lx-text-color`, `#111827` | Label colour |
| `--lx-heading-color` | `--lx-text-color`, `#111827` | Heading colour |
| `--lx-submit-bg` | `--lx-accent-color`, `#5055aa` | Button background |
| `--lx-submit-color` | `--lx-accent-contrast`, `#fff` | Button text colour |
| `--lx-submit-radius` | `--lx-input-radius` | Button corner radius |
| `--lx-error-color` | `#ef4444` | Error text and invalid borders |
| `--lx-success-color` | `#22c55e` | Success check background |
| `--lx-success-text` | `#15803d` | Success message colour |

```css
/* Example: pill inputs, dark button */
[data-section-id='SECTION'] [data-lx-part='root'] {
  --lx-input-radius: 9999px;
  --lx-submit-bg: #111;
  --lx-form-gap: 1rem;
}
[data-section-id='SECTION'] [data-lx-part='label'] {
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
```

## Events

The island root dispatches bubbling, composed `CustomEvent`s that section JS can listen for:

| Event | `detail` |
|-------|----------|
| `lx:form:pending` | `{formKey}` |
| `lx:form:success` | `{formKey}` |
| `lx:form:error` | `{formKey, code, field?}` |

```js
root.addEventListener("lx:form:success", (e) => {
  if (e.detail.formKey === "customer_phone_number") root.classList.add("is-done");
});
```

## Fully Custom Forms

If you need markup FormCapture can't produce, skip the island and post from section JS instead:

```js
const schema = await lifecycle.forms.schema("customer_phone_number");
const result = await lifecycle.forms.submit("customer_phone_number", { customer_phone: value }, { consent: true });
```

## Tips

- Create the form in Storefront Forms first. A wrong `formKey` renders nothing on the live page.
- Keep pop-up forms to 1–3 fields, and set `showOnce: true` on the Modal.
- If a form has consent text, don't hide the checkbox: submissions fail with `consent_required`.
- For a single email newsletter field, use [EmailCapture](/islands/engagement/email-capture).

## Related Islands

- [EmailCapture](/islands/engagement/email-capture): single-field newsletter signup
- [Modal](/islands/engagement/modal): pop-up and exit-intent container
