187 lines
12 KiB
Markdown
187 lines
12 KiB
Markdown
---
|
||
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.
|