Compare commits
2 Commits
f545c1e03f
...
9e5e01499c
| Author | SHA1 | Date |
|---|---|---|
|
|
9e5e01499c | |
|
|
aab729cd4a |
|
|
@ -0,0 +1,186 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
{
|
||||||
|
"action": "/api/formulario/send",
|
||||||
|
"submit_label": "form.submit",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"label": "form.step1",
|
||||||
|
"column": 2,
|
||||||
|
"fields": [
|
||||||
|
{ "key": "nombre", "label": "form.nombre", "type": "text", "required": true },
|
||||||
|
{ "key": "apellido", "label": "form.apellido", "type": "text", "required": true },
|
||||||
|
{ "key": "fecha_nacimiento", "label": "form.fecha_nacimiento", "type": "date", "required": true },
|
||||||
|
{ "key": "nacionalidad", "label": "form.nacionalidad", "type": "text", "required": true },
|
||||||
|
{ "key": "documentos", "label": "form.documentos", "type": "text", "required": true, "colspan": 2 },
|
||||||
|
{
|
||||||
|
"key": "sexo",
|
||||||
|
"label": "form.sexo",
|
||||||
|
"type": "radio",
|
||||||
|
"colspan": 2,
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{ "value": "M", "label": "form.sexo.m" },
|
||||||
|
{ "value": "F", "label": "form.sexo.f" },
|
||||||
|
{ "value": "Otro", "label": "form.sexo.o" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "key": "direccion_completa", "label": "form.direccion", "type": "text", "required": true, "colspan": 2 },
|
||||||
|
{ "key": "ciudad", "label": "form.ciudad", "type": "text", "required": true },
|
||||||
|
{ "key": "estado", "label": "form.estado", "type": "text", "required": true },
|
||||||
|
{ "key": "pais", "label": "form.pais", "type": "text", "required": true },
|
||||||
|
{ "key": "postal", "label": "form.codigo_postal", "type": "text", "required": true },
|
||||||
|
{ "key": "telefono", "label": "form.mobile", "type": "text", "required": true },
|
||||||
|
{ "key": "whatsapp", "label": "form.whatsapp", "type": "text", "required": true },
|
||||||
|
{ "key": "email", "label": "form.correo", "type": "email", "required": true, "colspan": 2 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "form.step2",
|
||||||
|
"column": 2,
|
||||||
|
"fields": [
|
||||||
|
{ "key": "profesion", "label": "form.profesion", "type": "text", "required": true },
|
||||||
|
{ "key": "lugar_trabajo_actual", "label": "form.lugar_trabajo_actual", "type": "text", "required": true },
|
||||||
|
{ "key": "nivel_academico", "label": "form.nivel_academico", "type": "text", "required": true, "colspan": 2 },
|
||||||
|
{
|
||||||
|
"key": "idioma",
|
||||||
|
"label": "form.idioma",
|
||||||
|
"type": "checkbox",
|
||||||
|
"required": true,
|
||||||
|
"colspan": 2,
|
||||||
|
"levels": {
|
||||||
|
"options": [
|
||||||
|
{ "value": "Basico", "label": "form.nivel.basico" },
|
||||||
|
{ "value": "Intermedio", "label": "form.nivel.intermedio" },
|
||||||
|
{ "value": "Avanzado", "label": "form.nivel.avanzado" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"options": [
|
||||||
|
{ "value": "Espanol", "label": "form.idioma.esp" },
|
||||||
|
{ "value": "Ingles", "label": "form.idioma.ing" },
|
||||||
|
{ "value": "Hebreo", "label": "form.idioma.heb" },
|
||||||
|
{ "value": "Portugues", "label": "form.idioma.port" },
|
||||||
|
{ "value": "Otro", "label": "form.idioma.otro" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "key": "idioma_otro", "label": "form.idioma_otro", "type": "text", "required": false, "colspan": 2, "showWhen": { "field": "idioma", "value": "Otro" } },
|
||||||
|
{ "key": "voluntariado_anterior", "label": "form.voluntariado_anterior", "type": "radio", "required": true, "colspan": 2, "options":
|
||||||
|
[
|
||||||
|
{ "value": "si", "label": "form.voluntariado_anterior.si" },
|
||||||
|
{ "value": "no", "label": "form.voluntariado_anterior.no" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "key": "caso_si", "label": "form.caso_si", "type": "textarea", "required": false, "colspan": 2, "showWhen": { "field": "voluntariado_anterior", "value": "si" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "form.step3",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"key": "areas_colaborar",
|
||||||
|
"label": "form.areas_colaborar",
|
||||||
|
"type": "checkbox",
|
||||||
|
"required": true,
|
||||||
|
"options": [
|
||||||
|
{ "value": "Ayuda Humanitaria", "label": "form.areas.ayuda_humanitaria" },
|
||||||
|
{ "value": "Educacion", "label": "form.areas.educacion" },
|
||||||
|
{ "value": "Desarrollo Comunitario", "label": "form.areas.desarrollo_comunitario" },
|
||||||
|
{ "value": "Liderazgo", "label": "form.areas.liderazgo" },
|
||||||
|
{ "value": "Logistica", "label": "form.areas.logistica" },
|
||||||
|
{ "value": "Organizacion de Eventos", "label": "form.areas.organizacion_eventos" },
|
||||||
|
{ "value": "Comunicacion Institucional", "label": "form.areas.comunicacion_institucional" },
|
||||||
|
{ "value": "Fotografia y Medios", "label": "form.areas.fotografia_medios" },
|
||||||
|
{ "value": "Recaudacion de Fondos", "label": "form.areas.recaudacion_fondos" },
|
||||||
|
{ "value": "Gestion de Proyectos", "label": "form.areas.gestion_proyectos" },
|
||||||
|
{ "value": "Traduccion e Interpretacion", "label": "form.areas.traduccion_interpretacion" },
|
||||||
|
{ "value": "Asesoria Juridica", "label": "form.areas.asesoria_juridica" },
|
||||||
|
{ "value": "Servicios Medicos", "label": "form.areas.servicios_medicos" },
|
||||||
|
{ "value": "Diplomacia Publica", "label": "form.areas.diplomacia_publica" },
|
||||||
|
{ "value": "Administracion", "label": "form.areas.administracion" },
|
||||||
|
{ "value": "Tecnologia e Innovacion", "label": "form.areas.tecnologia_innovacion" },
|
||||||
|
{ "value": "Otra", "label": "form.areas.otra" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "key": "areas_otra", "label": "form.areas_otra", "type": "text", "required": false, "showWhen": { "field": "areas_colaborar", "value": "Otra" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,441 @@
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted } from "vue";
|
||||||
|
import { Icon } from "@iconify/vue";
|
||||||
|
import { createTranslator } from "../../i18n";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
locale: String,
|
||||||
|
formConfigUrl: { type: String, default: "/forms/formulario-inscripcion.json" }
|
||||||
|
});
|
||||||
|
|
||||||
|
const tl = createTranslator(props.locale);
|
||||||
|
|
||||||
|
const config = ref(null);
|
||||||
|
const configError = ref(false);
|
||||||
|
const currentStep = ref(0);
|
||||||
|
const formData = reactive({});
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const isSuccess = ref(false);
|
||||||
|
const isError = ref(false);
|
||||||
|
const errorMsg = ref("");
|
||||||
|
|
||||||
|
const totalSteps = computed(() => config.value?.steps?.length ?? 0);
|
||||||
|
const currentStepData = computed(() => config.value?.steps?.[currentStep.value] ?? null);
|
||||||
|
|
||||||
|
const isFirstStep = computed(() => currentStep.value === 0);
|
||||||
|
const isLastStep = computed(() => currentStep.value === totalSteps.value - 1);
|
||||||
|
|
||||||
|
const stepErrors = reactive({});
|
||||||
|
|
||||||
|
const fetchConfig = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(props.formConfigUrl);
|
||||||
|
if (!res.ok) throw new Error("Failed to load form config");
|
||||||
|
const json = await res.json();
|
||||||
|
config.value = json;
|
||||||
|
json.steps.forEach((step, si) => {
|
||||||
|
step.fields.forEach((field) => {
|
||||||
|
if (field.type === "checkbox") {
|
||||||
|
formData[field.key] = [];
|
||||||
|
} else {
|
||||||
|
formData[field.key] = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
configError.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(fetchConfig);
|
||||||
|
|
||||||
|
const validateStep = (stepIndex) => {
|
||||||
|
const step = config.value?.steps?.[stepIndex];
|
||||||
|
if (!step) return true;
|
||||||
|
let valid = true;
|
||||||
|
step.fields.forEach((field) => {
|
||||||
|
const val = formData[field.key];
|
||||||
|
if (field.required) {
|
||||||
|
if (field.type === "checkbox") {
|
||||||
|
if (!val || val.length === 0) {
|
||||||
|
stepErrors[field.key] = true;
|
||||||
|
valid = false;
|
||||||
|
} else {
|
||||||
|
stepErrors[field.key] = false;
|
||||||
|
}
|
||||||
|
} else if (!val || val.trim() === "") {
|
||||||
|
stepErrors[field.key] = true;
|
||||||
|
valid = false;
|
||||||
|
} else {
|
||||||
|
stepErrors[field.key] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (field.type === "email" && val && val.trim() !== "") {
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(val.trim())) {
|
||||||
|
stepErrors[field.key] = true;
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (field.type === "checkbox" && field.levels && Array.isArray(val)) {
|
||||||
|
val.forEach((optVal) => {
|
||||||
|
if (!formData[`${field.key}_nivel_${optVal}`]) {
|
||||||
|
stepErrors[`${field.key}_nivel_${optVal}`] = true;
|
||||||
|
valid = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return valid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextStep = () => {
|
||||||
|
if (!validateStep(currentStep.value)) return;
|
||||||
|
if (currentStep.value < totalSteps.value - 1) {
|
||||||
|
currentStep.value++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevStep = () => {
|
||||||
|
if (currentStep.value > 0) {
|
||||||
|
currentStep.value--;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToStep = (index) => {
|
||||||
|
if (index < currentStep.value) {
|
||||||
|
currentStep.value = index;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < index; i++) {
|
||||||
|
if (!validateStep(i)) return;
|
||||||
|
}
|
||||||
|
currentStep.value = index;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckboxChange = (key, value, checked) => {
|
||||||
|
if (checked) {
|
||||||
|
formData[key].push(value);
|
||||||
|
} else {
|
||||||
|
formData[key] = formData[key].filter((v) => v !== value);
|
||||||
|
delete formData[`${key}_nivel_${value}`];
|
||||||
|
}
|
||||||
|
stepErrors[key] = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPayload = () => {
|
||||||
|
const responses = [];
|
||||||
|
config.value.steps.forEach((step, si) => {
|
||||||
|
step.fields.forEach((field) => {
|
||||||
|
const value = formData[field.key];
|
||||||
|
if (value !== "" && !(Array.isArray(value) && value.length === 0)) {
|
||||||
|
responses.push({
|
||||||
|
key: field.key,
|
||||||
|
value: value,
|
||||||
|
section_id: `step_${si}`
|
||||||
|
});
|
||||||
|
if (field.levels && Array.isArray(value)) {
|
||||||
|
value.forEach((optVal) => {
|
||||||
|
const levelKey = `${field.key}_nivel_${optVal}`;
|
||||||
|
const levelVal = formData[levelKey];
|
||||||
|
if (levelVal) {
|
||||||
|
responses.push({
|
||||||
|
key: levelKey,
|
||||||
|
value: levelVal,
|
||||||
|
section_id: `step_${si}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { formData: { ...formData }, responses };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
for (let i = 0; i < totalSteps.value; i++) {
|
||||||
|
if (!validateStep(i)) {
|
||||||
|
currentStep.value = i;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
isSuccess.value = false;
|
||||||
|
isError.value = false;
|
||||||
|
errorMsg.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = buildPayload();
|
||||||
|
const response = await fetch(config.value.action, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Error en el servidor");
|
||||||
|
isSuccess.value = true;
|
||||||
|
setTimeout(() => { isSuccess.value = false; }, 4000);
|
||||||
|
} catch (error) {
|
||||||
|
isError.value = true;
|
||||||
|
errorMsg.value = error.message || "Error al enviar el formulario";
|
||||||
|
setTimeout(() => { isError.value = false; }, 4000);
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFieldError = (key) => stepErrors[key] || false;
|
||||||
|
|
||||||
|
const findField = (key) => {
|
||||||
|
if (!config.value) return null;
|
||||||
|
for (const step of config.value.steps) {
|
||||||
|
const found = step.fields.find((f) => f.key === key);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shouldShow = (field) => {
|
||||||
|
if (!field.showWhen) return true;
|
||||||
|
const sourceField = findField(field.showWhen.field);
|
||||||
|
const value = formData[field.showWhen.field];
|
||||||
|
if (!value) return false;
|
||||||
|
if (sourceField?.type === "checkbox") {
|
||||||
|
return Array.isArray(value) && value.includes(field.showWhen.value);
|
||||||
|
}
|
||||||
|
return value === field.showWhen.value;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="configError" class="text-center py-12 text-red-500">
|
||||||
|
{{ tl("form.config_error") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!config" class="flex justify-center py-12">
|
||||||
|
<span class="w-8 h-8 border-2 border-[#22523F] border-t-transparent rounded-full animate-spin"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="w-full max-w-2xl mx-auto">
|
||||||
|
<div class="flex items-center justify-center gap-2 mb-8">
|
||||||
|
<template v-for="(step, index) in config.steps" :key="index">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="goToStep(index)"
|
||||||
|
class="flex items-center gap-1 text-sm transition-colors"
|
||||||
|
:class="index === currentStep ? 'text-[#22523F] font-bold' : 'text-gray-400 hover:text-[#22523F]'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold border-2 transition-colors"
|
||||||
|
:class="index === currentStep
|
||||||
|
? 'bg-[#22523F] text-white border-[#22523F]'
|
||||||
|
: index < currentStep
|
||||||
|
? 'bg-[#CBA16A] text-white border-[#CBA16A]'
|
||||||
|
: 'border-gray-300 text-gray-400'"
|
||||||
|
>
|
||||||
|
{{ index < currentStep ? '✓' : index + 1 }}
|
||||||
|
</span>
|
||||||
|
<span class="hidden sm:inline">{{ tl(step.label) }}</span>
|
||||||
|
</button>
|
||||||
|
<span v-if="index < config.steps.length - 1" class="w-8 h-px bg-gray-300"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleSubmit" class="bg-[#EBE6D2] p-8 rounded-none">
|
||||||
|
<h3 class="text-xl font-bold text-[#22523F] mb-6 text-center">
|
||||||
|
{{ tl(currentStepData.label) }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="grid gap-5" :class="{ 'grid-cols-1': !currentStepData.column || currentStepData.column === 1, 'md:grid-cols-2': currentStepData.column === 2, 'md:grid-cols-3': currentStepData.column === 3 }">
|
||||||
|
<template v-for="field in currentStepData.fields" :key="field.key">
|
||||||
|
<div v-if="shouldShow(field)" class="form-control" :class="{ 'md:col-span-2': field.colspan === 2 && currentStepData.column > 1, 'md:col-span-3': field.colspan === 3 && currentStepData.column > 1 }">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text text-[#22523F] font-medium">
|
||||||
|
{{ tl(field.label) }}
|
||||||
|
<span v-if="field.required" class="text-red-500 ml-1">*</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-if="field.type === 'text'"
|
||||||
|
v-model="formData[field.key]"
|
||||||
|
type="text"
|
||||||
|
:placeholder="tl(field.label)"
|
||||||
|
class="input input-bordered w-full rounded-none bg-white border-gray-300 focus:border-[#22523F]"
|
||||||
|
:class="{ 'border-red-500': getFieldError(field.key) }"
|
||||||
|
@input="stepErrors[field.key] = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-else-if="field.type === 'email'"
|
||||||
|
v-model="formData[field.key]"
|
||||||
|
type="email"
|
||||||
|
placeholder="email@ejemplo.com"
|
||||||
|
class="input input-bordered w-full rounded-none bg-white border-gray-300 focus:border-[#22523F]"
|
||||||
|
:class="{ 'border-red-500': getFieldError(field.key) }"
|
||||||
|
@input="stepErrors[field.key] = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-else-if="field.type === 'phone'"
|
||||||
|
v-model="formData[field.key]"
|
||||||
|
type="tel"
|
||||||
|
:placeholder="tl(field.label)"
|
||||||
|
class="input input-bordered w-full rounded-none bg-white border-gray-300 focus:border-[#22523F]"
|
||||||
|
:class="{ 'border-red-500': getFieldError(field.key) }"
|
||||||
|
@input="stepErrors[field.key] = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-else-if="field.type === 'date'"
|
||||||
|
v-model="formData[field.key]"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered w-full rounded-none bg-white border-gray-300 focus:border-[#22523F]"
|
||||||
|
:class="{ 'border-red-500': getFieldError(field.key) }"
|
||||||
|
@change="stepErrors[field.key] = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
v-else-if="field.type === 'textarea'"
|
||||||
|
v-model="formData[field.key]"
|
||||||
|
:placeholder="tl(field.label)"
|
||||||
|
rows="4"
|
||||||
|
class="textarea textarea-bordered w-full rounded-none bg-white border-gray-300 focus:border-[#22523F] resize-none"
|
||||||
|
:class="{ 'border-red-500': getFieldError(field.key) }"
|
||||||
|
@input="stepErrors[field.key] = false"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<div v-else-if="field.type === 'radio'" class="flex flex-col gap-2">
|
||||||
|
<label
|
||||||
|
v-for="opt in field.options"
|
||||||
|
:key="opt.value"
|
||||||
|
class="flex flex-row items-center gap-2 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:name="field.key"
|
||||||
|
:value="opt.value"
|
||||||
|
v-model="formData[field.key]"
|
||||||
|
class="radio radio-md radio-[#22523F]"
|
||||||
|
@change="stepErrors[field.key] = false"
|
||||||
|
/>
|
||||||
|
<span class="text-[#22523F]">{{ tl(opt.label) }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="field.type === 'checkbox'" class="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
v-for="opt in field.options"
|
||||||
|
:key="opt.value"
|
||||||
|
class="flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:value="opt.value"
|
||||||
|
class="checkbox checkbox-sm checkbox-[#22523F]"
|
||||||
|
:checked="formData[field.key]?.includes(opt.value)"
|
||||||
|
@change="handleCheckboxChange(field.key, opt.value, $event.target.checked)"
|
||||||
|
/>
|
||||||
|
<span class="text-[#22523F]">{{ tl(opt.label) }}</span>
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
v-if="formData[field.key]?.includes(opt.value) && field.levels"
|
||||||
|
class="flex gap-3 ml-7"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
v-for="lvl in field.levels.options"
|
||||||
|
:key="lvl.value"
|
||||||
|
class="flex items-center gap-1 cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:name="`${field.key}_nivel_${opt.value}`"
|
||||||
|
:value="lvl.value"
|
||||||
|
v-model="formData[`${field.key}_nivel_${opt.value}`]"
|
||||||
|
class="radio radio-sm radio-[#22523F]"
|
||||||
|
/>
|
||||||
|
<span class="text-[#22523F]">{{ tl(lvl.label) }}</span>
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
v-if="getFieldError(`${field.key}_nivel_${opt.value}`)"
|
||||||
|
class="text-red-500 text-xs"
|
||||||
|
>
|
||||||
|
{{ tl("form.required") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="getFieldError(field.key)"
|
||||||
|
class="text-red-500 text-xs mt-1"
|
||||||
|
>
|
||||||
|
{{ tl("form.required") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isError && errorMsg" class="text-red-600 text-sm text-center mt-4">
|
||||||
|
{{ errorMsg }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between mt-8">
|
||||||
|
<button
|
||||||
|
v-if="!isFirstStep"
|
||||||
|
type="button"
|
||||||
|
@click="prevStep"
|
||||||
|
class="btn rounded-none border-[#22523F] text-[#22523F] hover:bg-[#22523F] hover:text-white"
|
||||||
|
>
|
||||||
|
<Icon icon="ph:arrow-left" class="text-lg" />
|
||||||
|
{{ tl("form.back") }}
|
||||||
|
</button>
|
||||||
|
<div v-else></div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
v-if="!isLastStep"
|
||||||
|
type="button"
|
||||||
|
@click="nextStep"
|
||||||
|
class="btn rounded-none bg-[#22523F] text-white hover:bg-[#1a3d2f]"
|
||||||
|
>
|
||||||
|
{{ tl("form.next") }}
|
||||||
|
<Icon icon="ph:arrow-right" class="text-lg" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="submit"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
class="btn rounded-none px-8 transition-colors"
|
||||||
|
:class="isSubmitting
|
||||||
|
? 'bg-gray-400 text-white cursor-not-allowed'
|
||||||
|
: isSuccess
|
||||||
|
? 'bg-green-600 text-white'
|
||||||
|
: isError
|
||||||
|
? 'bg-red-600 text-white'
|
||||||
|
: 'bg-[#22523F] text-white hover:bg-[#1a3d2f]'"
|
||||||
|
>
|
||||||
|
<span v-if="isSubmitting" class="flex items-center gap-2">
|
||||||
|
<span class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
|
||||||
|
{{ tl("form.sending") }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="isSuccess" class="flex items-center gap-2">
|
||||||
|
<Icon icon="ph:check-circle" class="text-lg" />
|
||||||
|
{{ tl("form.success") }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="isError" class="flex items-center gap-2">
|
||||||
|
<Icon icon="ph:x-circle" class="text-lg" />
|
||||||
|
{{ tl("form.error") }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ tl(config.submit_label) }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -200,5 +200,71 @@
|
||||||
"footer.form.name": "Nombre y Apellido",
|
"footer.form.name": "Nombre y Apellido",
|
||||||
"footer.form.mesagge": "Escriba su mensaje",
|
"footer.form.mesagge": "Escriba su mensaje",
|
||||||
"footer.form.button": "Enviar",
|
"footer.form.button": "Enviar",
|
||||||
"footer.reserved": "©2026. Todos los Derechos Reservados. Centro del Reino de Paz y Justicia"
|
"footer.reserved": "©2026. Todos los Derechos Reservados. Centro del Reino de Paz y Justicia",
|
||||||
|
"form.page_title": "SOLICITUD DE INSCRIPCIÓN COMO VOLUNTARIO",
|
||||||
|
"form.page_desc": "Completá tus datos para formar parte del Centro del Reino de Paz y Justicia.",
|
||||||
|
"form.step1": "Datos Personales",
|
||||||
|
"form.step2": "Formación Academica y Experiencia",
|
||||||
|
"form.nombre": "Nombre",
|
||||||
|
"form.apellido": "Apellido",
|
||||||
|
"form.documentos": "Documento de Identidad / Pasaporte",
|
||||||
|
"form.fecha_nacimiento": "Fecha de Nacimiento",
|
||||||
|
"form.nacionalidad": "Nacionalidad",
|
||||||
|
"form.sexo": "Sexo",
|
||||||
|
"form.sexo.m": "Masculino",
|
||||||
|
"form.sexo.f": "Femenino",
|
||||||
|
"form.sexo.o": "Otro",
|
||||||
|
"form.direccion": "Dirección Completa",
|
||||||
|
"form.ciudad": "Ciudad",
|
||||||
|
"form.estado": "Estado / Provincia",
|
||||||
|
"form.pais": "País",
|
||||||
|
"form.codigo_postal": "Código Postal",
|
||||||
|
"form.mobile": "Teléfono Móvil",
|
||||||
|
"form.whatsapp": "WhatsApp",
|
||||||
|
"form.correo": "Correo Electrónico",
|
||||||
|
"form.profesion": "Profesión u ocupación",
|
||||||
|
"form.lugar_trabajo_actual": "Lugar de trabajo actual",
|
||||||
|
"form.nivel_academico": "Nivel académico",
|
||||||
|
"form.idioma": "Idiomas que domina",
|
||||||
|
"form.idioma.esp": "Español",
|
||||||
|
"form.idioma.ing": "Inglés",
|
||||||
|
"form.idioma.heb": "Hebreo",
|
||||||
|
"form.idioma.port": "Portugués",
|
||||||
|
"form.idioma.otro": "Otro",
|
||||||
|
"form.idioma_otro": "Otro (especificar)",
|
||||||
|
"form.voluntariado_anterior": "¿Ha realizado voluntariado anteriormente?",
|
||||||
|
"form.voluntariado_anterior.si": "Sí",
|
||||||
|
"form.voluntariado_anterior.no": "No",
|
||||||
|
"form.caso_si": "En caso afirmativo, indique dónde y en qué funciones",
|
||||||
|
"form.nivel.basico": "Básico",
|
||||||
|
"form.nivel.intermedio": "Intermedio",
|
||||||
|
"form.nivel.avanzado": "Avanzado",
|
||||||
|
"form.step3": "Áreas de Colaboración",
|
||||||
|
"form.areas_colaborar": "Seleccione las áreas en las que desea colaborar",
|
||||||
|
"form.areas.ayuda_humanitaria": "Ayuda Humanitaria",
|
||||||
|
"form.areas.educacion": "Educación",
|
||||||
|
"form.areas.desarrollo_comunitario": "Desarrollo Comunitario",
|
||||||
|
"form.areas.liderazgo": "Liderazgo",
|
||||||
|
"form.areas.logistica": "Logística",
|
||||||
|
"form.areas.organizacion_eventos": "Organización de Eventos",
|
||||||
|
"form.areas.comunicacion_institucional": "Comunicación Institucional",
|
||||||
|
"form.areas.fotografia_medios": "Fotografía y Medios",
|
||||||
|
"form.areas.recaudacion_fondos": "Recaudación de Fondos",
|
||||||
|
"form.areas.gestion_proyectos": "Gestión de Proyectos",
|
||||||
|
"form.areas.traduccion_interpretacion": "Traducción e Interpretación",
|
||||||
|
"form.areas.asesoria_juridica": "Asesoría Jurídica",
|
||||||
|
"form.areas.servicios_medicos": "Servicios Médicos / Primeros Auxilios",
|
||||||
|
"form.areas.diplomacia_publica": "Diplomacia Pública",
|
||||||
|
"form.areas.administracion": "Administración",
|
||||||
|
"form.areas.tecnologia_innovacion": "Tecnología e Innovación",
|
||||||
|
"form.areas.otra": "Otra",
|
||||||
|
"form.areas_otra": "Otra (especificar)",
|
||||||
|
"form.submit": "Enviar Formulario",
|
||||||
|
"form.next": "Siguiente",
|
||||||
|
"form.back": "Anterior",
|
||||||
|
"form.sending": "Enviando...",
|
||||||
|
"form.success": "¡Enviado con éxito!",
|
||||||
|
"form.error": "Error al enviar",
|
||||||
|
"form.required": "Este campo es obligatorio",
|
||||||
|
"form.config_error": "Error al cargar el formulario. Intentá de nuevo más tarde."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
---
|
||||||
|
import MainLayout from "@/layouts/MainLayout.astro";
|
||||||
|
import Header from "@/components/Header.astro";
|
||||||
|
import FooterSection from "@/components/section/FooterSection.astro";
|
||||||
|
import DynamicForm from "@/components/forms/DynamicForm.vue";
|
||||||
|
import { createTranslator } from "@/i18n";
|
||||||
|
|
||||||
|
const tl = createTranslator(Astro.currentLocale);
|
||||||
|
---
|
||||||
|
|
||||||
|
<MainLayout title={tl("form.page_title")}>
|
||||||
|
<div class="pt-16 relative container mx-auto">
|
||||||
|
<Header />
|
||||||
|
</div>
|
||||||
|
<main class="min-h-screen bg-white py-16 px-4">
|
||||||
|
<div class="container mx-auto">
|
||||||
|
<h1 class="text-3xl lg:text-4xl font-bold text-[#22523F] text-center mb-4">
|
||||||
|
{tl("form.page_title")}
|
||||||
|
</h1>
|
||||||
|
<!-- <p class="text-gray-600 text-center mb-10 max-w-lg mx-auto">
|
||||||
|
{tl("form.page_desc")}
|
||||||
|
</p> -->
|
||||||
|
<DynamicForm client:load locale={Astro.currentLocale} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<FooterSection />
|
||||||
|
</MainLayout>
|
||||||
Loading…
Reference in New Issue