cdrdpyj/.opencode/skills/dynamic-form/SKILL.md

187 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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.