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

12 KiB
Raw Permalink Blame History

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:

{
  "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):
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.step2form.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.