12 KiB
12 KiB
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.
API Endpoint:
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 viacreateTranslator(locale)
Architecture
Form Config (JSON-driven)
The form structure is defined in public/forms/formulario-inscripcion.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,autocompleterequired: boolean (for checkbox, individual options can also haverequired: true)colspan:2or3for grid layout when step hascolumn: 2orcolumn: 3readonly: displays value fromformData(shared across steps). SupportsvalueFromto reference another field's key.showWhen:{ field: "other_key", value: "expected_value" }— conditional display; for checkboxes checks array inclusionlevels: proficiency radios for each selected checkbox option (e.g., Básico/Intermedio/Avanzado)placeholder: translation key for placeholder textsource: URL to JSON file forselect/autocompleteoptions (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 showph:warning-circleicon + "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}.htmlwith fallback toes, renders inmax-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()evaluatesshowWhen; 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
valueFromcross-reference. - Select/autocomplete: lazy-loads options from
field.sourceURL, cached insuggestionsCache. 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'sactionURL (/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.
turnstileTokenref updated via callback. Honeypot hidden field (name="website",class="hidden",tabindex="-1") traps basic bots. Both values included in payload. - Payload shape:
responsesarray 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 = falseon 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 isrequired, at least 1 selection is needed - Text/email/phone/date/textarea/radio required: checks
!val || val.trim() === "" - Phone format: validates
/^\d+$/when non-empty —formatErrorstracks format issues separately fromstepErrors; shows "Solo se permiten números" vs "Este campo es obligatorio" - Phone country (
phone_country): two-piece component (select + input).phonePartsreactive tracks code and number separately; combined value stored informData[key]. Input sanitizes non-digits viaevent.target.value = cleaned+phonePartstracking. Validates: required (code + number filled), min 7 digits, max 15 total (code + number).
i18n System
createTranslator(locale)returnstl(key, vars?)function — supports{name}interpolation (used inform.select_placeholder)I18nKey = keyof typeof es— all keys must exist ines.json- Fallback chain: requested locale →
es→ raw key string routeTranslationsnow includesformulariokey (was missing initially):
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"+ localees→ renders DynamicForm with MainLayout"formulario"+ other locale →Astro.redirectto 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):
- Honeypot — if
body.honeypotis non-empty →403 Solicitud rechazada - Turnstile — verifies
body.turnstileTokenagainst Cloudflare API →403 Captcha inválidoif fail - If
TURNSTILE_SECRET_KEYenv var is empty/unset, Turnstile check is skipped (dev mode)
Env vars needed at runtime:
TURNSTILE_SITE_KEY— public key (inecosystem.config.cjs, injected viasedin CI)TURNSTILE_SECRET_KEY— secret key (injected fromsecrets.TURNSTILE_SECRET_KEYin 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 buildas a verification step — the user will handle builds manually or via CI. - Translation keys in
es.jsonuse dot notation:"form.field_name": "Label" - Checkbox
formDatais initialized as[], other fields as"" - Step labels:
form.step1_reglamento,form.step2–form.step8 - The
RegisterModalon the home page was replaced with a direct link to/{locale}/{formSlug} - All i18n keys used in DynamicForm.vue must exist in
es.json(strictI18nKeytyping) - Input icons are mapped via
getFieldIcon()by key or type — add new entries when adding fields (usesph:prefix Phosphor icons) valueFromonreadonlyfields allows displaying cross-step data (e.g., shownombrefrom step 2 on step 8)selectandautocompletefields require asourceURL pointing to a JSON array of{ label, value }objectssuggestionsCacheis a shared ref — data loads once persourceURL 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 viareglamentoFetchAttemptedguard - Turnstile widget renders only on the last step (
isLastStep) and only ifprops.turnstileSiteKeyis non-empty. The token is obtained viawindow.turnstile.render()callback. Script loads fromhttps://challenges.cloudflare.com/turnstile/v0/api.jsononMounted. - Honeypot field (
name="website") is hidden via daisyUIhiddenclass andtabindex="-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.jsonfield labels and option labels can keep their existing text — only new text generated for_helptooltips or translations must follow this rule.