# Lead Gen Forms UX Standards

**Last Updated:** 2026-03-26

Standards for lead generation forms, modals, and gated content across the Ordio site. Ensures consistent spacing, accessibility, and exit behavior.

## Spacing

### Consent Checkbox to Primary Button

**Requirement:** 20px (1.25rem) minimum gap between the marketing consent checkbox and the primary CTA button.

**Implementation:**

- Use `mb-5` (Tailwind) on the consent label, or
- Use CSS variable: `margin-bottom: var(--lead-gen-consent-button-gap);`

**Design token:** `--lead-gen-consent-button-gap: 1.25rem` (defined in `src/input.css`)

```html
<label class="contact-consent-checkbox contact-consent-visible mb-5">
  <input type="checkbox" id="hubspot_form_consent" name="hubspot_form_consent" value="1">
  <span class="contact-consent-text">Ich stimme zu, dass meine Kontaktdaten an Ordio übermittelt und für Marketingzwecke genutzt werden können.</span>
</label>
<button type="submit" class="...">Submit</button>
```

### Primary Button to Disclaimer

**Requirement:** 16px (1rem) minimum gap between the primary button and the "Kostenlos • Keine Spam • Jederzeit abbestellbar" disclaimer.

**Implementation:**

- Use `mt-4` (Tailwind) on the disclaimer container

**Design token:** `--lead-gen-button-disclaimer-gap: 1rem`

```html
<button type="submit" class="...">Submit</button>
<div class="mt-4 text-center">
  <p class="text-xs text-gray-400">Kostenlos • Keine Spam • Jederzeit abbestellbar</p>
</div>
```

## Modal Exit Behavior

All modals must support three exit mechanisms:

1. **ESC key** – Required for accessibility (keyboard users)
2. **Click outside** – Expected UX pattern (backdrop/overlay click)
3. **Close button** – Explicit X or "Schließen" button

### Alpine.js Pattern

```html
<div x-show="showModal"
     @click.away="showModal = false"
     @keydown.escape.window="showModal = false"
     class="fixed inset-0 z-50 overflow-y-auto bg-black/50 flex items-center justify-center p-4"
     role="dialog"
     aria-modal="true"
     aria-labelledby="modal-title">
  <div @click.stop class="modal-content ...">
    <!-- Form content -->
  </div>
</div>
```

### Vanilla JavaScript Pattern

```javascript
modal.addEventListener('click', (e) => {
  if (e.target === modal) closeModal();
});
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && modal.classList.contains('active')) closeModal();
});
```

## Validation and programmatic submit (HubSpot demo / `form-hs`)

HubSpot Forms API submissions **do not enforce** portal “required” field settings. Any client that posts to [`html/form-hs.php`](../../html/form-hs.php) must validate **in the browser and on the server**.

### Demo modal: `fetch` + inline errors (no full-page error screen)

The header demo form ([`v2/base/include_form-hs.php`](../../v2/base/include_form-hs.php)) must **not** navigate away on validation or HubSpot errors:

1. **`submit`:** always `preventDefault`, run UTM/GTM/consent, then validate in JS.
2. **Submit to server:** `fetch(form.action, { method: 'POST', body: FormData, credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' } })`.
3. **400** from `form-hs.php`: JSON `{ success: false, invalidFields: ['email','phone'] }` — map to inline messages; focus first invalid field.
4. **200** success: JSON `{ success: true, redirectUrl: '...' }` — `window.location.href = redirectUrl` (same URLs as the former `Location` redirect).
5. **`novalidate`** on the `<form>` so the browser does not show English-only HTML5 bubbles; use custom messages instead.

### Locale for validation copy

Use **`document.documentElement.lang`** first, then **`navigator.language`**, with fallbacks (`de` / `en` / `fr` bundles in the demo form script). This keeps messages aligned with the page language instead of the browser’s generic English strings.

### Phone (`fullNumber`)

The modal combines **country code** (Alpine) and **national digits** into a hidden `fullNumber` field. Validation must ensure the result is not only a prefix (e.g. `+49`):

- Strip non-digits and require **at least 8 digits** total (lenient international minimum), or equivalent subscriber-length check.
- Keep existing `getFullNumber()` behavior when fixing validation; do not change normalization unless product asks.

Server-side mirror: `form_hs_validate_submission_fields()` in `html/form-hs.php` (returns invalid field keys or `null`).

### Inline errors

Per-field messages with `aria-invalid` and `role="alert"`; optional `#demo-form-error-general` for server/network errors. Clear on `input`.

### Automated check

Run (no HubSpot HTTP calls): `php v2/scripts/dev-helpers/test-form-hs-validation.php`

### Legacy note

Avoid **`form.submit()`** after `preventDefault` (skips validation). The demo modal no longer uses that pattern for the HubSpot POST.

## Component Checklist

| Component | Spacing | ESC | Click-outside |
|-----------|---------|-----|---------------|
| include_form-gated-content.php | mb-5 | N/A (inline) | N/A |
| templates_template.php modal | mb-5 | Yes | Yes |
| Tools email modals (15+ pages) | mb-5 | Yes | Yes |
| Demo modal (header.php) | N/A | Yes | Yes |
| Lead capture popup | Different | Yes | Yes |
| Enterprise modal | N/A | Yes | Yes |
| Pricing addon modal | N/A | Yes | Yes |

## Related Files

- **Cursor rule:** `.cursor/rules/lead-gen-forms-ux.mdc`
- **Design tokens:** `src/input.css` (`:root`)
- **Form tracking:** `docs/forms/FORM_TRACKING_DEVELOPER_GUIDE.md`
- **GDPR consent:** `forms-gdpr-consent.mdc`
