---
name: dynamic-form
description: Use when working with the volunteer registration dynamic multi-step form. Relevant files: DynamicForm.vue, formulario-inscripcion.json, [section]/index.astro, i18n system, reglamento HTML.
---

# Dynamic Multi-Step Volunteer Registration Form

## Stack

- **Framework:** Astro (SSR, `output: "server"`, Node.js standalone)
- **UI:** Vue 3 + Tailwind CSS v4 + daisyUI v5
- **i18n:** Astro built-in i18n with `prefixDefaultLocale: true`, custom translator via `createTranslator(locale)`

## Architecture

### Form Config (JSON-driven)

The form structure is defined in `public/forms/formulario-inscripcion.json`:

```json
{
  "action": "/api/formulario/send",
  "submit_label": "form.submit",
  "steps": [
    {
      "label": "form.step1_reglamento",
      "fields": [ ... ]
    }
  ]
}
```

Each field supports:
- `type`: `text`, `email`, `phone`, `date`, `textarea`, `radio`, `checkbox`, `select`, `autocomplete`
- `required`: boolean (for checkbox, individual options can also have `required: true`)
- `colspan`: `2` or `3` for grid layout when step has `column: 2` or `column: 3`
- `readonly`: displays value from `formData` (shared across steps). Supports `valueFrom` to reference another field's key.
- `showWhen`: `{ field: "other_key", value: "expected_value" }` — conditional display; for checkboxes checks array inclusion
- `levels`: proficiency radios for each selected checkbox option (e.g., Básico/Intermedio/Avanzado)
- `placeholder`: translation key for placeholder text
- `source`: URL to JSON file for `select`/`autocomplete` options (e.g., `/forms/paises.json`)

### Key Files

| File | Purpose |
|------|---------|
| `src/components/forms/DynamicForm.vue` | Core form component (751 lines) |
| `public/forms/formulario-inscripcion.json` | Form structure config (8 steps) |
| `public/forms/paises.json` | Country list for `select`/`autocomplete` |
| `src/pages/[locale]/[section]/index.astro` | Routes news, editorial, AND formulario |
| `src/i18n/index.ts` | Translator, `routeTranslations`, `I18nKey` type |
| `src/i18n/es.json` | Spanish translations (all form keys) |
| `public/reglamento/es.html` | Reglamento content (section-based HTML) |

### Field Types

| Type | Rendering | Behavior |
|------|-----------|----------|
| `text` | `<input>` inside daisyUI `<label class="input">` | Phosphor icon via `getFieldIcon()` |
| `email` | Same as text, `type="email"` | Regex validation on non-empty |
| `phone` | Same as text, `type="tel"` | Regex `/^\d+$/` — strips non-digit chars on input |
| `phone_country` | daisyUI `join` with autocomplete input (left) + `<input type="tel">` (right) | Input filters countries by name or code as you type. Dropdown shows only `🇨🇴 +57`. On select, stores `dial_code` in `phoneParts[key].code` and combines with number in `formData[key]`. Selected display shows `🇨🇴 +57`. |
| `date` | `<input type="date">` | Calendar icon |
| `textarea` | `<textarea>` with absolute icon | 4 rows, `resize-none` |
| `radio` | Inline `<input type="radio">` | `flex flex-wrap gap-x-5 gap-y-1` |
| `checkbox` | Stacked checkboxes with optional `levels` radios | Array in `formData` |
| `select` | `<select>` populated from `field.source` JSON | `getSelectOptions(field)` loads & caches |
| `autocomplete` | `<input>` with dropdown suggestions | `filteredSuggestions()` filters by input |

### DynamicForm.vue Features

- **Multi-step navigation:** stepper with numbered circles + step labels, clickable to revisit completed steps. Gold (`#CBA16A`) for active, cream (`#EBE6D2`) with checkmark for completed.
- **Validation:** per-step via `validateStep()`; checks required fields (with trim), email format, checkbox required options, level selection. Errors show `ph:warning-circle` icon + "Este campo es obligatorio".
- **Step counter:** "Paso X de Y" centered above buttons on mobile, inline with "Siguiente" on desktop. Divider line separates fields from navigation.
- **Reglamento scroll requirement:** fetches HTML from `/reglamento/{locale}.html` with fallback to `es`, renders in `max-h-[500px] md:max-h-[720px] overflow-y-auto`. User must scroll to bottom (`scrollTop + clientHeight >= scrollHeight - 10`) before checkboxes (step 1) and "Siguiente" button become enabled. Info banner + tooltip shown until requirement met.
- **Conditional fields:** `shouldShow()` evaluates `showWhen`; for checkboxes source, checks array inclusion; for others, strict equality.
- **Levels system:** when checkbox option is selected, level radios appear inline (`ml-9 mt-1`). Levels also validated.
- **Readonly fields:** display data from other steps via `valueFrom` cross-reference.
- **Select/autocomplete:** lazy-loads options from `field.source` URL, cached in `suggestionsCache`. Autocomplete filters by input value, shows dropdown on focus/input.
- **Icons:** Phosphor icons mapped via `getFieldIcon()` by field key or type.
- **Submit:** POSTs `{ formData, responses, turnstileToken, honeypot }` to config's `action` URL (`/api/formulario/send`). Success screen shows logo (`/img/logo-metalico.webp`), success title, info detail, and "Volver al inicio" link. Handles loading/spinner state during submit.
- **Bot protection:** Cloudflare Turnstile widget renders on the last step. `turnstileToken` ref updated via callback. Honeypot hidden field (`name="website"`, `class="hidden"`, `tabindex="-1"`) traps basic bots. Both values included in payload.
- **Payload shape:** `responses` array with `{ key, value, section_id: "step_N" }`. For levels, each level is a separate response entry. Does NOT include empty fields. Extra fields: `turnstileToken` (string), `honeypot` (string).
- **Mount reset:** `isSuccess.value = false` on fetch to prevent stale success state on re-navigation.

### Validation Logic (validateStep)

- **Checkbox required:** if individual options have `required: true`, all must be selected; if no option is individually required but field is `required`, at least 1 selection is needed
- **Text/email/phone/date/textarea/radio required:** checks `!val || val.trim() === ""`
- **Phone format:** validates `/^\d+$/` when non-empty — `formatErrors` tracks format issues separately from `stepErrors`; shows "Solo se permiten números" vs "Este campo es obligatorio"
- **Phone country (`phone_country`):** two-piece component (select + input). `phoneParts` reactive tracks code and number separately; combined value stored in `formData[key]`. Input sanitizes non-digits via `event.target.value = cleaned` + `phoneParts` tracking. Validates: required (code + number filled), min 7 digits, max 15 total (code + number).

### i18n System

- `createTranslator(locale)` returns `tl(key, vars?)` function — supports `{name}` interpolation (used in `form.select_placeholder`)
- `I18nKey = keyof typeof es` — all keys must exist in `es.json`
- Fallback chain: requested locale → `es` → raw key string
- `routeTranslations` now includes `formulario` key (was missing initially):

```ts
formulario: {
  es: "formulario", en: "form", fr: "formulaire",
  he: "טופס", uk: "форма", pt: "formulário",
  ru: "форма", rw: "formulaire", kr: "fòm"
}
```

- `getRouteKeyFromSlug(slug)`: resolves URL segment to route key (returns `"news"` default for unknown)
- `getLocalizedRoute(route, locale)`: builds locale-aware URL segment

### Routing

The `[section]/index.astro` page handles three route keys:
- `"formulario"` + locale `es` → renders DynamicForm with MainLayout
- `"formulario"` + other locale → `Astro.redirect` to that locale's home page
- `"news"` / `"editorial"` → existing content list behavior

Form URLs:
- `/es/formulario` (active)
- `/en/form`, `/fr/formulaire`, etc. (redirect to home)

### Color Palette

| Token | Hex | Usage |
|-------|-----|-------|
| Primary green | `#22523F` | Headings, stepper active, buttons, reglamento headings, focus outlines, hover backgrounds |
| Cream | `#EBE6D2` | Form background, stepper completed circles |
| Gold | `#CBA16A` | Active step circles |
| Input text | `#1a1a1a` | Input/textarea values, checkbox/radio labels |
| Readonly text | `#6B7280` | Readonly field values |
| Checked accent | `#4A8C6F` | Checkbox/radio checked background and border |

### Responsive Behavior

| Element | Mobile (< sm) | Desktop (sm+) |
|---------|---------------|---------------|
| Container | `max-w-3xl px-4` | `max-w-3xl mx-auto` |
| Form padding | `p-5` | `sm:p-8 lg:p-10` |
| Grid gap | `gap-4` | `sm:gap-5` |
| Title size | `text-lg` | `sm:text-xl` |
| Button font | `text-xs` | `sm:text-sm` |
| Step counter | Centered above buttons | Next to "Siguiente" button |
| Nav layout | Column (counter, then row of buttons) | Row with `justify-between` via `sm:contents` |
| Reglamento height | `max-h-[500px]` | `md:max-h-[720px]` |

### API Endpoint: `POST /api/formulario/send`

| File | Purpose |
|------|---------|
| `src/pages/api/formulario/send.ts` | Receives form data, validates Turnstile, writes to Google Sheets |

**Security checks (in order):**
1. **Honeypot** — if `body.honeypot` is non-empty → `403 Solicitud rechazada`
2. **Turnstile** — verifies `body.turnstileToken` against Cloudflare API → `403 Captcha inválido` if fail
3. If `TURNSTILE_SECRET_KEY` env var is empty/unset, Turnstile check is skipped (dev mode)

**Env vars needed at runtime:**
- `TURNSTILE_SITE_KEY` — public key (in `ecosystem.config.cjs`, injected via `sed` in CI)
- `TURNSTILE_SECRET_KEY` — secret key (injected from `secrets.TURNSTILE_SECRET_KEY` in CI)

### Reglamento HTML

Stored in `public/reglamento/{locale}.html`. Must NOT contain `<html>`, `<head>`, `<body>`, or `<style>` tags — only `<section>`, `<h1>`, `<h2>`, `<p>`, `<ul>`, `<li>` elements. Loaded via `fetch()` and injected with `v-html` inside a `.reglamento-content` div with scoped CSS styles in the component's `<style>` block.

## Important Conventions

- NEVER run `pnpm run build` as a verification step — the user will handle builds manually or via CI.
- Translation keys in `es.json` use dot notation: `"form.field_name": "Label"`
- Checkbox `formData` is initialized as `[]`, other fields as `""`
- Step labels: `form.step1_reglamento`, `form.step2`–`form.step8`
- The `RegisterModal` on the home page was replaced with a direct link to `/{locale}/{formSlug}`
- All i18n keys used in DynamicForm.vue must exist in `es.json` (strict `I18nKey` typing)
- Input icons are mapped via `getFieldIcon()` by key or type — add new entries when adding fields (uses `ph:` prefix Phosphor icons)
- `valueFrom` on `readonly` fields allows displaying cross-step data (e.g., show `nombre` from step 2 on step 8)
- `select` and `autocomplete` fields require a `source` URL pointing to a JSON array of `{ label, value }` objects
- `suggestionsCache` is a shared ref — data loads once per `source` URL and is reused across all fields pointing to the same source
- Reglamento is fetched via locale fallback chain: `props.locale` → `"es"` — only fetched once per step 0 visit via `reglamentoFetchAttempted` guard
- Turnstile widget renders only on the last step (`isLastStep`) and only if `props.turnstileSiteKey` is non-empty. The token is obtained via `window.turnstile.render()` callback. Script loads from `https://challenges.cloudflare.com/turnstile/v0/api.js` on `onMounted`.
- Honeypot field (`name="website"`) is hidden via daisyUI `hidden` class and `tabindex="-1"` — browsers/autofill may still fill it but human users never see it.

## Language Style

- All Spanish text must use **international Spanish (neutral/formal)**, NOT Argentine voseo.
- Use **usted** forms: "Seleccione", "Indique", "Complete", "Ingrese", "Inténtelo", "Desplácese", "Debe leer", etc.
- Use **su** instead of **tu**: "su país", "su interés", "sus datos".
- Exception: the `formulario-inscripcion.json` field labels and option labels can keep their existing text — only new text generated for `_help` tooltips or translations must follow this rule.
