form create

This commit is contained in:
Esteban Paz 2026-07-23 07:34:16 -05:00
parent 820f11a5a0
commit aab729cd4a
4 changed files with 641 additions and 1 deletions

View File

@ -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" } }
]
}
]
}

View File

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

View File

@ -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."
} }

View File

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