feat: conectar frontend a Google Sheets y ajustar responsive

- Agregar API route /api/formulario/send con mapeo de 46 columnas
- Agregar appendRegistrationToSheet() para escribir en tab VOLUNTARIOS
- Reemplazar simulación de envío por fetch real al endpoint
- Corregir validación de checkboxes, email y niveles
- Ajustar stepper para pantallas de laptop (gap, círculos, labels)
- Corregir header en iPad: ocultar hamburguesa y mostrar idioma desde md
This commit is contained in:
Esteban Paz 2026-07-24 03:08:42 -05:00
parent aab729cd4a
commit 0e35531ec7
12 changed files with 1064 additions and 334 deletions

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ prisma/dev.db
# opencode - agentes y scripts locales (no subir a producción) # opencode - agentes y scripts locales (no subir a producción)
opencode/ opencode/
.opencode/

View File

@ -3,14 +3,35 @@
"submit_label": "form.submit", "submit_label": "form.submit",
"steps": [ "steps": [
{ {
"label": "form.step1", "label": "form.step1_reglamento",
"fields": [
{
"key": "aceptacion_reglamento",
"label": "form.aceptacion_reglamento",
"type": "checkbox",
"required": true,
"options": [
{ "value": "leido_acepto", "label": "form.aceptacion.leido", "required": true },
{ "value": "codigo_etica", "label": "form.aceptacion.codigo_etica", "required": true },
{ "value": "datos_personales", "label": "form.aceptacion.datos", "required": true },
{ "value": "participacion_voluntaria", "label": "form.aceptacion.voluntaria", "required": true },
{ "value": "informacion_verdadera", "label": "form.aceptacion.verdadera", "required": true },
{ "value": "firma_electronica_validez", "label": "form.aceptacion.firma_validez", "required": true }
]
}
]
},
{
"label": "form.step2",
"column": 2, "column": 2,
"fields": [ "fields": [
{ "key": "nombre", "label": "form.nombre", "type": "text", "required": true }, { "key": "nombre", "label": "form.nombre", "type": "text", "required": true },
{ "key": "apellido", "label": "form.apellido", "type": "text", "required": true }, { "key": "apellido", "label": "form.apellido", "type": "text", "required": true },
{ "key": "documentos", "label": "form.documentos", "type": "text", "required": true, "colspan": 2 },
{ "key": "correo", "label": "form.correo", "type": "email", "required": true, "colspan": 2 },
{ "key": "reglamento_firma", "label": "form.reglamento_firma", "type": "text", "required": true, "colspan": 2, "placeholder": "form.reglamento_firma_placeholder" },
{ "key": "fecha_nacimiento", "label": "form.fecha_nacimiento", "type": "date", "required": true }, { "key": "fecha_nacimiento", "label": "form.fecha_nacimiento", "type": "date", "required": true },
{ "key": "nacionalidad", "label": "form.nacionalidad", "type": "text", "required": true }, { "key": "nacionalidad", "label": "form.nacionalidad", "type": "text", "required": true },
{ "key": "documentos", "label": "form.documentos", "type": "text", "required": true, "colspan": 2 },
{ {
"key": "sexo", "key": "sexo",
"label": "form.sexo", "label": "form.sexo",
@ -29,12 +50,11 @@
{ "key": "pais", "label": "form.pais", "type": "text", "required": true }, { "key": "pais", "label": "form.pais", "type": "text", "required": true },
{ "key": "postal", "label": "form.codigo_postal", "type": "text", "required": true }, { "key": "postal", "label": "form.codigo_postal", "type": "text", "required": true },
{ "key": "telefono", "label": "form.mobile", "type": "text", "required": true }, { "key": "telefono", "label": "form.mobile", "type": "text", "required": true },
{ "key": "whatsapp", "label": "form.whatsapp", "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", "label": "form.step3",
"column": 2, "column": 2,
"fields": [ "fields": [
{ "key": "profesion", "label": "form.profesion", "type": "text", "required": true }, { "key": "profesion", "label": "form.profesion", "type": "text", "required": true },
@ -72,7 +92,7 @@
] ]
}, },
{ {
"label": "form.step3", "label": "form.step4",
"fields": [ "fields": [
{ {
"key": "areas_colaborar", "key": "areas_colaborar",
@ -101,6 +121,127 @@
}, },
{ "key": "areas_otra", "label": "form.areas_otra", "type": "text", "required": false, "showWhen": { "field": "areas_colaborar", "value": "Otra" } } { "key": "areas_otra", "label": "form.areas_otra", "type": "text", "required": false, "showWhen": { "field": "areas_colaborar", "value": "Otra" } }
] ]
},
{
"label": "form.step5",
"fields": [
{
"key": "dias_disponibles",
"label": "form.dias_disponibles",
"type": "checkbox",
"required": true,
"options": [
{ "value": "Lunes", "label": "form.dias.lunes" },
{ "value": "Martes", "label": "form.dias.martes" },
{ "value": "Miercoles", "label": "form.dias.miercoles" },
{ "value": "Jueves", "label": "form.dias.jueves" },
{ "value": "Viernes", "label": "form.dias.viernes" },
{ "value": "Sabado", "label": "form.dias.sabado" },
{ "value": "Domingo", "label": "form.dias.domingo" }
]
},
{
"key": "horario_preferido",
"label": "form.horario_preferido",
"type": "checkbox",
"required": true,
"options": [
{ "value": "Manana", "label": "form.horario.manana" },
{ "value": "Tarde", "label": "form.horario.tarde" },
{ "value": "Noche", "label": "form.horario.noche" },
{ "value": "Segun necesidades", "label": "form.horario.segun" }
]
},
{
"key": "fuera_ciudad",
"label": "form.fuera_ciudad",
"type": "radio",
"required": true,
"options": [
{ "value": "si", "label": "form.si" },
{ "value": "no", "label": "form.no" }
]
},
{
"key": "misiones_internacionales",
"label": "form.misiones_internacionales",
"type": "radio",
"required": true,
"options": [
{ "value": "si", "label": "form.si" },
{ "value": "no", "label": "form.no" }
]
}
]
},
{
"label": "form.step6",
"fields": [
{
"key": "condicion_medica",
"label": "form.condicion_medica",
"type": "radio",
"required": true,
"options": [
{ "value": "si", "label": "form.si" },
{ "value": "no", "label": "form.no" }
]
},
{
"key": "condicion_medica_cual",
"label": "form.condicion_medica_cual",
"type": "textarea",
"required": true,
"showWhen": { "field": "condicion_medica", "value": "si" }
},
{
"key": "alergias",
"label": "form.alergias",
"type": "textarea",
"required": false
},
{
"key": "medicamentos",
"label": "form.medicamentos",
"type": "textarea",
"required": false
}
]
},
{
"label": "form.step7",
"fields": [
{ "key": "emergencia_nombre", "label": "form.emergencia_nombre", "type": "text", "required": true },
{ "key": "emergencia_parentesco", "label": "form.emergencia_parentesco", "type": "text", "required": true },
{ "key": "emergencia_telefono", "label": "form.emergencia_telefono", "type": "phone", "required": true },
{ "key": "emergencia_correo", "label": "form.emergencia_correo", "type": "email", "required": false }
]
},
{
"label": "form.step8",
"column": 2,
"fields": [
{ "key": "confirmar_nombre", "label": "form.confirmar_nombre", "type": "text", "readonly": true, "valueFrom": "nombre" },
{ "key": "confirmar_firma", "label": "form.confirmar_firma", "type": "text", "readonly": true, "valueFrom": "reglamento_firma" },
{
"key": "declaracion_voluntario",
"label": "form.declaracion_voluntario",
"type": "checkbox",
"required": true,
"colspan": 2,
"options": [
{ "value": "informacion_verdadera", "label": "form.declaracion.verdadera", "required": true },
{ "value": "acepto_reglamento", "label": "form.declaracion.acepto_reglamento", "required": true },
{ "value": "compromiso_etica", "label": "form.declaracion.compromiso_etica", "required": true },
{ "value": "autorizo_datos", "label": "form.declaracion.autorizo_datos", "required": true },
{ "value": "confidencialidad", "label": "form.declaracion.confidencialidad", "required": true },
{ "value": "participacion_voluntaria", "label": "form.declaracion.voluntaria", "required": true }
]
},
{ "key": "confirmacion", "label": "form.confirmacion", "type": "checkbox", "colspan": 2, "required": true, "options": [
{ "value": "confirmado", "label": "form.confirmacion_label", "required": true }
] }
]
} }
] ]
} }

187
public/reglamento/es.html Normal file
View File

@ -0,0 +1,187 @@
<h1>Reglamento General del Programa de Voluntariado</h1>
<p>Términos, Condiciones y Código de Ética para Voluntarios</p>
<section id="introduccion">
<h2>Introducción</h2>
<p>El Centro del Reino de Paz y Justicia tiene como misión promover la paz, la justicia, el liderazgo, la educación, la ayuda humanitaria, la responsabilidad social y la cooperación entre comunidades y naciones.</p>
<p>El presente Reglamento establece el marco jurídico, ético e institucional que regula la participación de los voluntarios, definiendo sus derechos, obligaciones y principios de actuación, con el fin de garantizar que todas las actividades de voluntariado se desarrollen conforme a los valores del Centro y a la legislación aplicable.</p>
</section>
<section id="objeto-voluntariado">
<h2>1. Objeto del Voluntariado</h2>
<p>El voluntario participa libre y voluntariamente en las actividades del Centro con el propósito de contribuir al cumplimiento de su misión institucional en las áreas de educación, desarrollo comunitario, asistencia humanitaria, liderazgo, diplomacia pública, promoción de la paz, la justicia y el servicio a la sociedad.</p>
<p>Toda actividad deberá desarrollarse con espíritu de servicio, responsabilidad, integridad, respeto, cooperación y compromiso con el bienestar común.</p>
</section>
<section id="naturaleza-voluntariado">
<h2>2. Naturaleza del Voluntariado</h2>
<p>La participación del voluntario tiene carácter exclusivamente voluntario y no constituye, bajo ninguna circunstancia, una relación laboral, contractual, societaria, de representación, mandato o subordinación con el Centro.</p>
<p>La participación en las actividades no genera derecho a salario, prestaciones laborales, seguridad social, indemnizaciones ni cualquier otra remuneración económica, salvo autorización expresa y por escrito del Centro respecto del reembolso de gastos.</p>
</section>
<section id="requisitos-voluntario">
<h2>3. Requisitos para ser Voluntario</h2>
<p>El voluntario declara que:</p>
<ul>
<li>La información proporcionada es veraz, completa y actualizada.</li>
<li>Se encuentra física y mentalmente apto para participar en las actividades.</li>
<li>No existe impedimento legal para su participación.</li>
<li>Cumplirá las políticas, reglamentos y procedimientos establecidos por el Centro.</li>
</ul>
</section>
<section id="seguro">
<h2>4. Seguro</h2>
<p>Salvo comunicación expresa y por escrito del Centro, cada voluntario será responsable de contar con la cobertura médica, de accidentes y demás seguros que considere necesarios para su participación.</p>
<p>La contratación de cualquier seguro por parte del Centro no sustituye la responsabilidad personal del voluntario.</p>
</section>
<section id="responsabilidad-medica">
<h2>5. Responsabilidad Médica</h2>
<p>El voluntario manifiesta que su estado de salud le permite participar en las actividades y se compromete a informar oportunamente cualquier condición médica que pueda afectar su seguridad o la de terceros.</p>
</section>
<section id="cumplimiento-normas">
<h2>6. Cumplimiento de Normas</h2>
<p>El voluntario se compromete a cumplir las instrucciones impartidas por los responsables del Centro, respetar las normas de seguridad y actuar siempre con responsabilidad, profesionalismo y respeto hacia todas las personas.</p>
</section>
<section id="confidencialidad">
<h2>7. Confidencialidad</h2>
<p>Toda la información a la que el voluntario tenga acceso durante su participación será considerada confidencial.</p>
<p>El voluntario se obliga a no divulgar información institucional, financiera, estratégica, personal o cualquier otra información reservada, aun después de finalizar su colaboración con el Centro.</p>
</section>
<section id="proteccion-datos">
<h2>8. Protección de Datos Personales</h2>
<p>El voluntario autoriza al Centro a recopilar, almacenar y tratar sus datos personales exclusivamente para fines relacionados con:</p>
<ul>
<li>la administración del programa de voluntariado;</li>
<li>la comunicación institucional;</li>
<li>la organización de actividades;</li>
<li>el cumplimiento de obligaciones legales;</li>
<li>la seguridad de los participantes.</li>
</ul>
</section>
<section id="uso-imagen">
<h2>9. Uso de Imagen</h2>
<p>El voluntario autoriza al Centro a utilizar fotografías, grabaciones de audio, videos e imágenes obtenidas durante las actividades para fines institucionales, educativos, informativos, promocionales y de recaudación de fondos, salvo manifestación expresa y escrita en contrario.</p>
</section>
<section id="propiedad-intelectual">
<h2>10. Propiedad Intelectual</h2>
<p>Todo documento, fotografía, video, material audiovisual, presentación, publicación, contenido o trabajo desarrollado por el voluntario dentro de las actividades institucionales será propiedad exclusiva del Centro, salvo acuerdo escrito en contrario.</p>
</section>
<section id="uso-bienes">
<h2>11. Uso y Cuidado de Bienes</h2>
<p>El voluntario deberá utilizar responsablemente los bienes, equipos y materiales del Centro, comprometiéndose a conservarlos y devolverlos cuando le sean requeridos o al finalizar su participación.</p>
</section>
<section id="terminacion-voluntariado">
<h2>12. Terminación del Voluntariado</h2>
<p>El Centro podrá suspender o finalizar la participación de cualquier voluntario cuando exista incumplimiento del presente Reglamento, conducta incompatible con los valores institucionales, riesgos para la organización o cualquier otra causa justificada.</p>
<p>El voluntario podrá retirarse libremente notificándolo con una anticipación razonable.</p>
</section>
<section id="limitacion-responsabilidad">
<h2>13. Limitación de Responsabilidad</h2>
<p>El voluntario reconoce que participa libremente en las actividades del Centro y acepta los riesgos normales inherentes a las mismas.</p>
<p>En la medida permitida por la legislación aplicable, el Centro, sus directivos, empleados, representantes, colaboradores y demás voluntarios no serán responsables por daños derivados de la participación del voluntario, salvo en casos de dolo o negligencia grave.</p>
</section>
<section id="declaracion-riesgos">
<h2>14. Declaración de Asunción de Riesgos</h2>
<p>El voluntario declara conocer que determinadas actividades pueden implicar riesgos derivados de desplazamientos, trabajo comunitario, asistencia humanitaria, eventos públicos, visitas institucionales u otras actividades propias del programa.</p>
<p>El voluntario acepta participar bajo su propia responsabilidad y asume voluntariamente dichos riesgos, salvo cuando exista dolo o negligencia grave imputable al Centro.</p>
</section>
<section id="conducta-institucional">
<h2>15. Conducta Institucional</h2>
<p>El voluntario se compromete a mantener una conducta acorde con los principios y valores del Centro.</p>
<p>Queda prohibido:</p>
<ul>
<li>realizar actos de violencia física o verbal;</li>
<li>discriminar o acosar a cualquier persona;</li>
<li>consumir drogas ilegales o presentarse bajo sus efectos;</li>
<li>portar armas sin autorización legal y expresa;</li>
<li>solicitar donaciones o administrar recursos económicos en nombre del Centro sin autorización escrita;</li>
<li>utilizar el nombre del Centro para fines personales, políticos o comerciales.</li>
</ul>
</section>
<section id="proteccion-menores">
<h2>16. Protección de Menores y Personas Vulnerables</h2>
<p>Cuando las actividades involucren menores de edad o personas en situación de vulnerabilidad, el voluntario deberá cumplir estrictamente los protocolos de protección establecidos por el Centro y la legislación aplicable.</p>
</section>
<section id="conflicto-intereses">
<h2>17. Conflicto de Intereses</h2>
<p>El voluntario deberá informar cualquier situación personal, profesional, económica o familiar que pudiera generar un conflicto de interés con las actividades del Centro.</p>
</section>
<section id="cumplimiento-normativo">
<h2>18. Cumplimiento Normativo</h2>
<p>El voluntario se compromete a cumplir toda la legislación aplicable relacionada con derechos humanos, protección de datos personales, anticorrupción, prevención del lavado de dinero, igualdad, no discriminación y demás normas aplicables.</p>
</section>
<section id="uso-imagen-institucional">
<h2>19. Uso del Nombre e Imagen Institucional</h2>
<p>El nombre, logotipo, marcas, imagen corporativa y demás signos distintivos del Centro únicamente podrán utilizarse con autorización previa y escrita.</p>
</section>
<section id="redes-sociales">
<h2>20. Redes Sociales y Comunicaciones</h2>
<p>El voluntario no podrá emitir declaraciones públicas, entrevistas, comunicados o publicaciones en representación del Centro sin autorización previa.</p>
<p>Asimismo, se compromete a utilizar responsablemente las redes sociales evitando publicaciones que puedan afectar la imagen institucional.</p>
</section>
<section id="donaciones">
<h2>21. Donaciones</h2>
<p>La condición de voluntario no otorga derecho alguno sobre los bienes, recursos, proyectos o donaciones administrados por el Centro.</p>
<p>Toda campaña de recaudación de fondos deberá contar con autorización previa y escrita.</p>
</section>
<section id="fuerza-mayor">
<h2>22. Fuerza Mayor</h2>
<p>El Centro podrá suspender, modificar o cancelar cualquier actividad debido a causas de fuerza mayor, razones de seguridad, conflictos armados, emergencias sanitarias, desastres naturales o cualquier otra circunstancia fuera de su control, sin que ello genere responsabilidad alguna.</p>
</section>
<section id="legislacion-jurisdiccion">
<h2>23. Legislación Aplicable y Jurisdicción</h2>
<p>El presente Reglamento se regirá por la legislación vigente del país donde se desarrollen las actividades del voluntariado.</p>
<p>Toda controversia será sometida a la jurisdicción de los tribunales competentes de dicho país.</p>
</section>
<section id="codigo-etica">
<h2>Código de Ética del Voluntario</h2>
<p>Todo voluntario se compromete a:</p>
<ul>
<li>Actuar con honestidad, integridad y transparencia.</li>
<li>Respetar la dignidad y los derechos de todas las personas.</li>
<li>Promover una cultura de paz, justicia y solidaridad.</li>
<li>Mantener absoluta confidencialidad.</li>
<li>Evitar conflictos de interés.</li>
<li>Representar al Centro con respeto y profesionalismo.</li>
<li>Cumplir la legislación aplicable y las políticas institucionales.</li>
<li>Proteger la reputación y los valores del Centro.</li>
<li>Actuar siempre con espíritu de servicio.</li>
</ul>
</section>
<section id="politica-privacidad">
<h2>Política de Privacidad y Tratamiento de Datos Personales</h2>
<p>El Centro reconoce la importancia de proteger la privacidad de sus voluntarios y se compromete a tratar toda información personal conforme a la legislación aplicable.</p>
<p>Los datos personales serán utilizados exclusivamente para:</p>
<ul>
<li>Administración del programa de voluntariado.</li>
<li>Organización de actividades y eventos.</li>
<li>Comunicación institucional.</li>
<li>Cumplimiento de obligaciones legales.</li>
<li>Protección y seguridad de los participantes.</li>
<li>Elaboración de estadísticas e informes institucionales.</li>
</ul>
<p>El Centro adoptará medidas técnicas, administrativas y organizativas razonables para proteger la información personal contra pérdida, acceso no autorizado, alteración, divulgación o uso indebido.</p>
<p>Los voluntarios podrán ejercer los derechos que les otorgue la legislación aplicable respecto del acceso, rectificación, actualización o eliminación de sus datos personales.</p>
</section>

View File

@ -2,9 +2,10 @@
const { props } = Astro.props; const { props } = Astro.props;
import { Icon } from "astro-icon/components"; import { Icon } from "astro-icon/components";
import Button from "./ui/Button.astro"; import Button from "./ui/Button.astro";
import RegisterModal from "../components/RegisterModal.vue"; import { getLocalizedRoute } from '@/i18n';
const background = props.bgImage || props.bgColor; const background = props.bgImage || props.bgColor;
const formUrl = `/${Astro.currentLocale}/${getLocalizedRoute("formulario", Astro.currentLocale)}`;
--- ---
<div style={{ background: props.bgImage ? `url('${props.bgImage}') center no-repeat; background-size:contain;` : props.bgColor }} class={`aspect-auto xl:aspect-square py-8 px-8 xl:px-16 flex flex-col justify-evenl`}> <div style={{ background: props.bgImage ? `url('${props.bgImage}') center no-repeat; background-size:contain;` : props.bgColor }} class={`aspect-auto xl:aspect-square py-8 px-8 xl:px-16 flex flex-col justify-evenl`}>
@ -28,7 +29,7 @@ const background = props.bgImage || props.bgColor;
{props.hasInput && ( {props.hasInput && (
<div class="flex gap-2 w-full mt-5"> <div class="flex gap-2 w-full mt-5">
<RegisterModal client:load locale={Astro.currentLocale} /> <Button class="px-6 py-3 uppercase bg-[#22523F]" url={formUrl} variant="secondary" title={props.buttonLabel} />
</div> </div>
)} )}
</div> </div>

View File

@ -89,9 +89,9 @@ function translatePath(newLocale: string) {
</p> </p>
</div> </div>
<nav <nav
class="flex justify-evenly gap-10 items-center uppercase text-md text-white" class="flex justify-end md:justify-evenly gap-4 md:gap-6 lg:gap-10 items-center uppercase text-md text-white"
> >
<div class="hidden md:flex gap-8 font-primary font-bold"> <div class="hidden md:flex gap-4 lg:gap-8 font-primary font-bold">
{ {
navItems.map((item) => ( navItems.map((item) => (
<a <a
@ -105,7 +105,7 @@ function translatePath(newLocale: string) {
<!-- <a class="hover:text-colorPrimary transition" href={`/${currentLocale}/archive`}>{tl("nav.archive")}</a> <!-- <a class="hover:text-colorPrimary transition" href={`/${currentLocale}/archive`}>{tl("nav.archive")}</a>
<a class="hover:text-colorPrimary transition" href={`/${currentLocale}/nations`}>{tl("nav.nations")}</a> --> <a class="hover:text-colorPrimary transition" href={`/${currentLocale}/nations`}>{tl("nav.nations")}</a> -->
</div> </div>
<div class="drawer lg:hidden"> <div class="drawer md:hidden">
<input id="my-drawer-1" type="checkbox" class="drawer-toggle" /> <input id="my-drawer-1" type="checkbox" class="drawer-toggle" />
<div class="drawer-content"> <div class="drawer-content">
<label for="my-drawer-1" class="btn-ghost drawer-button"> <label for="my-drawer-1" class="btn-ghost drawer-button">
@ -202,7 +202,7 @@ function translatePath(newLocale: string) {
variant="primary" variant="primary"
/> />
</div> </div>
<div class="dropdown dropdown-end lg:block hidden"> <div class="dropdown dropdown-end md:block hidden">
<div <div
tabindex="0" tabindex="0"
role="button" role="button"

View File

@ -1,11 +1,11 @@
<script setup> <script setup>
import { ref, reactive, computed, onMounted } from "vue"; import { ref, reactive, computed, onMounted, watch } from "vue";
import { Icon } from "@iconify/vue"; import { Icon } from "@iconify/vue";
import { createTranslator } from "../../i18n"; import { createTranslator } from "../../i18n";
const props = defineProps({ const props = defineProps({
locale: String, locale: String,
formConfigUrl: { type: String, default: "/forms/formulario-inscripcion.json" } formConfigUrl: { type: String, required: false, default: "/forms/formulario-inscripcion.json" }
}); });
const tl = createTranslator(props.locale); const tl = createTranslator(props.locale);
@ -19,6 +19,12 @@ const isSuccess = ref(false);
const isError = ref(false); const isError = ref(false);
const errorMsg = ref(""); const errorMsg = ref("");
const reglamentoScrollRef = ref(null);
const reglamentoHTML = ref("");
const reglamentoLoading = ref(false);
const reglamentoScrolled = ref(false);
const reglamentoFetchAttempted = ref(false);
const totalSteps = computed(() => config.value?.steps?.length ?? 0); const totalSteps = computed(() => config.value?.steps?.length ?? 0);
const currentStepData = computed(() => config.value?.steps?.[currentStep.value] ?? null); const currentStepData = computed(() => config.value?.steps?.[currentStep.value] ?? null);
@ -28,6 +34,7 @@ const isLastStep = computed(() => currentStep.value === totalSteps.value - 1);
const stepErrors = reactive({}); const stepErrors = reactive({});
const fetchConfig = async () => { const fetchConfig = async () => {
isSuccess.value = false;
try { try {
const res = await fetch(props.formConfigUrl); const res = await fetch(props.formConfigUrl);
if (!res.ok) throw new Error("Failed to load form config"); if (!res.ok) throw new Error("Failed to load form config");
@ -53,17 +60,42 @@ const validateStep = (stepIndex) => {
const step = config.value?.steps?.[stepIndex]; const step = config.value?.steps?.[stepIndex];
if (!step) return true; if (!step) return true;
let valid = true; let valid = true;
if (stepIndex === 0 && !reglamentoScrolled.value) {
valid = false;
}
step.fields.forEach((field) => { step.fields.forEach((field) => {
if (field.showWhen) {
const sourceVal = formData[field.showWhen.field];
const sourceField = findField(field.showWhen.field);
const conditionMet = sourceField?.type === "checkbox"
? Array.isArray(sourceVal) && sourceVal.includes(field.showWhen.value)
: sourceVal === field.showWhen.value;
if (!conditionMet) return;
}
const val = formData[field.key]; const val = formData[field.key];
if (field.required) { if (field.type === "checkbox") {
if (field.type === "checkbox") { const requiredOptions = field.options?.filter(o => o.required)?.map(o => o.value) || [];
if (!val || val.length === 0) { if (field.required) {
stepErrors[field.key] = true; if (requiredOptions.length > 0) {
valid = false; const allRequiredSelected = requiredOptions.every(optVal => val?.includes(optVal));
if (!allRequiredSelected) {
stepErrors[field.key] = true;
valid = false;
} else {
stepErrors[field.key] = false;
}
} else { } else {
stepErrors[field.key] = false; if (!val || val.length === 0) {
stepErrors[field.key] = true;
valid = false;
} else {
stepErrors[field.key] = false;
}
} }
} else if (!val || val.trim() === "") { }
}
if (field.required && field.type !== "checkbox") {
if (!val || val.trim() === "") {
stepErrors[field.key] = true; stepErrors[field.key] = true;
valid = false; valid = false;
} else { } else {
@ -75,6 +107,8 @@ const validateStep = (stepIndex) => {
if (!emailRegex.test(val.trim())) { if (!emailRegex.test(val.trim())) {
stepErrors[field.key] = true; stepErrors[field.key] = true;
valid = false; valid = false;
} else {
stepErrors[field.key] = false;
} }
} }
if (field.type === "checkbox" && field.levels && Array.isArray(val)) { if (field.type === "checkbox" && field.levels && Array.isArray(val)) {
@ -82,6 +116,8 @@ const validateStep = (stepIndex) => {
if (!formData[`${field.key}_nivel_${optVal}`]) { if (!formData[`${field.key}_nivel_${optVal}`]) {
stepErrors[`${field.key}_nivel_${optVal}`] = true; stepErrors[`${field.key}_nivel_${optVal}`] = true;
valid = false; valid = false;
} else {
stepErrors[`${field.key}_nivel_${optVal}`] = false;
} }
}); });
} }
@ -168,18 +204,20 @@ const handleSubmit = async () => {
try { try {
const payload = buildPayload(); const payload = buildPayload();
const response = await fetch(config.value.action, { const res = await fetch("/api/formulario/send", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!response.ok) throw new Error("Error en el servidor"); const data = await res.json();
isSuccess.value = true; if (data.success) {
setTimeout(() => { isSuccess.value = false; }, 4000); isSuccess.value = true;
} else {
throw new Error(data.message || "Error al enviar el formulario");
}
} catch (error) { } catch (error) {
isError.value = true; isError.value = true;
errorMsg.value = error.message || "Error al enviar el formulario"; errorMsg.value = error.message || "Error al enviar el formulario";
setTimeout(() => { isError.value = false; }, 4000);
} finally { } finally {
isSubmitting.value = false; isSubmitting.value = false;
} }
@ -206,6 +244,77 @@ const shouldShow = (field) => {
} }
return value === field.showWhen.value; return value === field.showWhen.value;
}; };
const fetchReglamento = async () => {
if (reglamentoFetchAttempted.value) return;
reglamentoFetchAttempted.value = true;
reglamentoScrolled.value = false;
reglamentoLoading.value = true;
reglamentoHTML.value = "";
try {
const locales = [props.locale, "es"];
let html = null;
for (const l of locales) {
const res = await fetch(`/reglamento/${l}.html`);
if (res.ok) { html = await res.text(); break; }
}
reglamentoHTML.value = html || "<p>Error al cargar el reglamento</p>";
} catch {
reglamentoHTML.value = "<p>Error al cargar el reglamento</p>";
} finally {
reglamentoLoading.value = false;
}
};
const onReglamentoScroll = () => {
const el = reglamentoScrollRef.value;
if (!el) return;
const threshold = 10;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - threshold) {
reglamentoScrolled.value = true;
}
};
watch(currentStep, (step) => {
if (step === 0) {
fetchReglamento();
} else {
reglamentoFetchAttempted.value = false;
}
}, { immediate: true });
const getFieldIcon = (field) => {
const k = field.key;
if (k === "nombre" || k === "emergencia_nombre") return "ph:user";
if (k === "apellido") return "ph:identification-card";
if (k === "documentos") return "ph:cardholder";
if (k === "correo" || k === "emergencia_correo") return "ph:envelope";
if (k === "reglamento_firma") return "ph:signature";
if (k === "fecha_nacimiento") return "ph:calendar";
if (k === "nacionalidad" || k === "pais") return "ph:flag";
if (k === "direccion_completa") return "ph:house";
if (k === "ciudad") return "ph:city";
if (k === "estado" || k === "postal") return "ph:map-pin";
if (k === "telefono" || k === "whatsapp" || k === "emergencia_telefono") return "ph:phone";
if (k === "profesion") return "ph:briefcase";
if (k === "lugar_trabajo_actual") return "ph:buildings";
if (k === "nivel_academico") return "ph:graduation-cap";
if (k === "idioma_otro") return "ph:translate";
if (k === "areas_otra") return "ph:plus-circle";
if (k === "emergencia_parentesco") return "ph:users-three";
if (field.type === "phone") return "ph:phone";
if (field.type === "email") return "ph:envelope";
if (field.type === "textarea") return "ph:note-pencil";
if (field.type === "date") return "ph:calendar";
return "ph:newspaper-clip";
};
const getFieldInputType = (field) => {
if (field.type === "email") return "email";
if (field.type === "phone") return "tel";
if (field.type === "date") return "date";
return "text";
};
</script> </script>
<template> <template>
@ -214,228 +323,338 @@ const shouldShow = (field) => {
</div> </div>
<div v-else-if="!config" class="flex justify-center py-12"> <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> <span class="w-8 h-8 border-2 border-[#22523F] font-secondary border-t-transparent rounded-full animate-spin"></span>
</div> </div>
<div v-else class="w-full max-w-3xl mx-auto md:px-4 sm:px-0">
<div v-else class="w-full max-w-2xl mx-auto"> <template v-if="!isSuccess">
<div class="flex items-center justify-center gap-2 mb-8"> <div class="flex flex-wrap sm:flex-nowrap items-center justify-center gap-x-1 md:gap-x-2 gap-y-2 sm:gap-y-3 mb-4 sm:mb-6 lg:mb-8">
<template v-for="(step, index) in config.steps" :key="index"> <template v-for="(step, index) in config.steps" :key="index">
<button <button
type="button" type="button"
@click="goToStep(index)" @click="goToStep(index)"
class="flex items-center gap-1 text-sm transition-colors" class="flex flex-col lg:flex-row items-center gap-1 text-sm transition-colors text-center"
:class="index === currentStep ? 'text-[#22523F] font-bold' : 'text-gray-400 hover:text-[#22523F]'" :class="index === currentStep ? 'text-[#EBE6D2] font-bold' : 'text-[#EBE6D2]/60 hover:text-[#EBE6D2]'"
>
<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="flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold border-2 transition-colors shrink-0"
<span class="hidden sm:inline">{{ tl(step.label) }}</span> :class="index === currentStep
</button> ? 'bg-[#CBA16A] text-[#22523F] border-[#CBA16A]'
<span v-if="index < config.steps.length - 1" class="w-8 h-px bg-gray-300"></span> : index < currentStep
</template> ? 'bg-[#EBE6D2] text-[#22523F] border-[#EBE6D2]'
</div> : 'border-[#EBE6D2]/50 text-[#EBE6D2]/60'"
>
<form @submit.prevent="handleSubmit" class="bg-[#EBE6D2] p-8 rounded-none"> {{ index < currentStep ? '✓' : index + 1 }}
<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> </span>
</label> <span class="text-[10px] lg:text-xs leading-tight max-w-[60px] md:max-w-[70px] lg:max-w-none font-secondary">{{ tl(step.label) }}</span>
</button>
<input <span v-if="index < config.steps.length - 1" class="w-4 md:w-6 lg:w-8 h-px bg-[#EBE6D2]/30 shrink-0 hidden sm:inline"></span>
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> </template>
</div> </div>
<div v-if="isError && errorMsg" class="text-red-600 text-sm text-center mt-4"> <form @submit.prevent="handleSubmit" class="bg-[#EBE6D2] p-5 sm:p-8 lg:p-10 rounded-none">
{{ errorMsg }} <h3 class="text-lg sm:text-xl font-bold text-[#22523F] mb-4 sm:mb-6 text-center font-secondary">
</div> {{ tl(currentStepData.label) }}
</h3>
<div class="flex justify-between mt-8"> <div v-if="currentStep === 0" class="mb-4 sm:mb-6">
<button <div v-if="reglamentoLoading" class="flex justify-center py-8">
v-if="!isFirstStep" <span class="w-8 h-8 border-2 border-[#22523F] border-t-transparent rounded-full animate-spin"></span>
type="button" </div>
@click="prevStep" <div v-else>
class="btn rounded-none border-[#22523F] text-[#22523F] hover:bg-[#22523F] hover:text-white" <div
> ref="reglamentoScrollRef"
<Icon icon="ph:arrow-left" class="text-lg" /> class="md:max-h-[720px] max-h-[500px] overflow-y-auto border scrollbar-thin scrollbar-thumb-[#22523F] border-gray-300 p-4 bg-white reglamento-content"
{{ tl("form.back") }} @scroll="onReglamentoScroll"
</button> >
<div v-else></div> <div v-html="reglamentoHTML"></div>
</div>
<div class="flex items-center gap-2 mt-3">
<Icon
:icon="reglamentoScrolled ? 'ph:check-circle-fill' : 'ph:arrow-down'"
:class="reglamentoScrolled ? 'text-green-600' : 'text-gray-400'"
class="text-lg"
/>
<span :class="reglamentoScrolled ? 'text-green-700 font-medium' : 'text-gray-500'" class="text-sm font-secondary">
{{ reglamentoScrolled ? tl("form.reglamento_leido") : tl("form.reglamento_scroll_needed") }}
</span>
</div>
<div v-if="!reglamentoScrolled" class="bg-amber-50 border-l-4 border-amber-400 text-amber-800 p-3 mt-3 text-sm flex items-start gap-2">
<Icon icon="ph:info" class="text-lg mt-0.5 shrink-0" />
<span class="font-secondary">{{ tl("form.reglamento_aviso") }}</span>
</div>
</div>
</div>
<div class="flex gap-3"> <div class="grid gap-4 sm: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 }">
<button <template v-for="field in currentStepData.fields" :key="field.key">
v-if="!isLastStep" <div v-if="shouldShow(field)" class="flex flex-col gap-1.5" :class="{ 'md:col-span-2': field.colspan === 2 && currentStepData.column > 1, 'md:col-span-3': field.colspan === 3 && currentStepData.column > 1 }">
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 <span class="text-[#22523F] font-secondary font-medium text-sm leading-tight">
v-else {{ tl(field.label) }}
type="submit" <span v-if="field.required" class="text-red-500 ml-0.5">*</span>
:disabled="isSubmitting" </span>
class="btn rounded-none px-8 transition-colors"
:class="isSubmitting <div v-if="field.readonly" class="py-2.5 px-3 bg-gray-100 border border-gray-200 text-[#6B7280] rounded-xl min-h-[2.5rem] flex items-center text-sm">
? 'bg-gray-400 text-white cursor-not-allowed' {{ field.valueFrom ? (formData[field.valueFrom] || "—") : (formData[field.key] || "—") }}
: isSuccess </div>
? 'bg-green-600 text-white'
: isError <label
? 'bg-red-600 text-white' v-else-if="field.type === 'text' || field.type === 'email' || field.type === 'phone'"
class="input w-full bg-white rounded-xl focus-within:outline-2 focus-within:outline-[#22523F]"
:class="{ 'input-error border-red-500 focus-within:outline-red-500': getFieldError(field.key) }"
>
<Icon :icon="getFieldIcon(field)" class="shrink-0 opacity-50 text-gray-400 text-lg" />
<input
class="grow text-[#1a1a1a]"
:type="getFieldInputType(field)"
v-model="formData[field.key]"
:placeholder="tl(field.placeholder || field.label)"
@input="stepErrors[field.key] = false"
/>
</label>
<label
v-else-if="field.type === 'date'"
class="input w-full bg-white rounded-xl focus-within:outline-2 focus-within:outline-[#22523F]"
:class="{ 'input-error border-red-500 focus-within:outline-red-500': getFieldError(field.key) }"
>
<Icon icon="ph:calendar" class="shrink-0 opacity-50 text-gray-400 text-lg" />
<input
class="grow text-[#1a1a1a]"
type="date"
v-model="formData[field.key]"
@change="stepErrors[field.key] = false"
/>
</label>
<div v-else-if="field.type === 'textarea'" class="relative">
<Icon icon="ph:note-pencil" class="absolute left-3 top-3.5 text-gray-400 opacity-50 shrink-0 pointer-events-none text-lg" />
<textarea
class="textarea w-full bg-white text-[#1a1a1a] pl-10 rounded-xl resize-none min-h-[6rem] focus:outline-2 focus:outline-[#22523F] focus:outline-offset-[-1px]"
:class="{ 'border-red-500 focus:outline-red-500': getFieldError(field.key) }"
v-model="formData[field.key]"
:placeholder="tl(field.placeholder || field.label)"
rows="4"
@input="stepErrors[field.key] = false"
/>
</div>
<div v-else-if="field.type === 'radio'" class="flex flex-wrap gap-x-5 gap-y-1">
<label
v-for="opt in field.options"
:key="opt.value"
class="flex items-center gap-2 cursor-pointer p-1 -ml-1 rounded-lg hover:bg-[#22523F]/5 transition-colors"
>
<input
type="radio"
:name="field.key"
:value="opt.value"
v-model="formData[field.key]"
class="radio radio-sm border-gray-300 checked:border-[#4A8C6F] checked:bg-[#4A8C6F]"
@change="stepErrors[field.key] = false"
/>
<span class="text-[#1a1a1a] text-sm">{{ tl(opt.label) }}</span>
</label>
</div>
<div v-else-if="field.type === 'checkbox'" class="flex flex-col gap-1">
<div
v-for="opt in field.options"
:key="opt.value"
class="flex flex-col"
>
<label
class="flex items-center gap-3 cursor-pointer p-1.5 rounded-lg transition-colors"
:class="currentStep === 0 && !reglamentoScrolled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-[#22523F]/5'"
>
<input
type="checkbox"
:value="opt.value"
:disabled="currentStep === 0 && !reglamentoScrolled"
class="checkbox checkbox-sm border-gray-300 checked:border-[#4A8C6F] checked:bg-[#4A8C6F]"
:checked="formData[field.key]?.includes(opt.value)"
@change="handleCheckboxChange(field.key, opt.value, $event.target.checked)"
/>
<span class="text-[#1a1a1a] text-sm">{{ tl(opt.label) }}</span>
</label>
<div
v-if="formData[field.key]?.includes(opt.value) && field.levels"
class="flex flex-wrap gap-x-4 gap-y-1 ml-9 mt-1"
>
<label
v-for="lvl in field.levels.options"
:key="lvl.value"
class="flex items-center gap-1.5 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 border-gray-300 checked:border-[#4A8C6F] checked:bg-[#4A8C6F]"
/>
<span class="text-[#1a1a1a]">{{ 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>
<p
v-if="getFieldError(field.key) && (field.type !== 'checkbox' || currentStep > 0)"
class="text-xs text-red-500 flex items-center gap-1 mt-0.5"
>
<Icon icon="ph:warning-circle" class="text-sm shrink-0" />
{{ tl("form.required") }}
</p>
</div>
</template>
</div>
<div v-if="isError && errorMsg" class="text-red-600 text-sm text-center mt-4">
{{ errorMsg }}
</div>
<div class="border-t border-gray-300/30 mt-6 sm:mt-8 mb-4 sm:mb-6"></div>
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3">
<div class="flex justify-center sm:hidden">
<span class="text-xs text-gray-500 font-secondary font-medium">
{{ tl("form.step_counter", { current: currentStep + 1, total: totalSteps }) }}
</span>
</div>
<div class="flex justify-between items-center w-full sm:contents">
<button
v-if="!isFirstStep"
type="button"
@click="prevStep"
class="btn bg-white border border-gray-200 text-gray-500 hover:bg-gray-50 hover:text-gray-700"
>
<Icon icon="ph:arrow-left" class="text-lg" />
{{ tl("form.back") }}
</button>
<div v-else></div>
<div class="flex items-center gap-3">
<span class="hidden sm:inline text-xs text-gray-500 font-secondary font-medium whitespace-nowrap">
{{ tl("form.step_counter", { current: currentStep + 1, total: totalSteps }) }}
</span>
<div class="relative group">
<button
v-if="!isLastStep"
type="button"
@click="nextStep"
:disabled="currentStep === 0 && !reglamentoScrolled"
class="btn rounded-none text-sm sm:text-base px-3 sm:px-4"
:class="currentStep === 0 && !reglamentoScrolled
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-[#22523F] text-white hover:bg-[#1a3d2f]'"
>
{{ tl("form.next") }}
<Icon icon="ph:arrow-right" class="text-base sm:text-lg" />
</button>
<div
v-if="currentStep === 0 && !reglamentoScrolled"
class="absolute bottom-full mb-1 sm:mb-2 left-1/2 -translate-x-1/2 whitespace-nowrap bg-gray-800 text-white text-xs rounded px-3 py-1.5 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-lg"
>
{{ tl("form.reglamento_tooltip") }}
</div>
</div>
<button
v-if="isLastStep"
type="submit"
:disabled="isSubmitting"
class="btn rounded-none px-3 sm:px-5 transition-colors text-xs sm:text-sm leading-tight min-w-0"
:class="isSubmitting
? 'bg-gray-400 text-white cursor-not-allowed'
: 'bg-[#22523F] text-white hover:bg-[#1a3d2f]'" : 'bg-[#22523F] text-white hover:bg-[#1a3d2f]'"
> >
<span v-if="isSubmitting" class="flex items-center gap-2"> <span v-if="isSubmitting" class="flex items-center gap-1 sm:gap-2">
<span class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span> <span class="w-3 h-3 sm:w-4 sm:h-4 border-2 border-white border-t-transparent rounded-full animate-spin shrink-0"></span>
{{ tl("form.sending") }} <span class="truncate">{{ tl("form.sending") }}</span>
</span> </span>
<span v-else-if="isSuccess" class="flex items-center gap-2"> <span v-else class="truncate">
<Icon icon="ph:check-circle" class="text-lg" /> {{ tl(config.submit_label) }}
{{ tl("form.success") }} </span>
</span> </button>
<span v-else-if="isError" class="flex items-center gap-2"> </div>
<Icon icon="ph:x-circle" class="text-lg" /> </div>
{{ tl("form.error") }}
</span> </div>
<span v-else> </form>
{{ tl(config.submit_label) }} </template>
</span>
</button> <div v-else class="bg-[#EBE6D2] p-8 sm:p-12 rounded-none text-center">
<img
src="/img/logo-metalico.webp"
alt="Logo Centro del Reino de Paz y Justicia"
class="w-20 h-20 sm:w-28 sm:h-28 mx-auto mb-6"
/>
<h2 class="text-xl sm:text-2xl font-bold text-[#22523F] font-secondary mb-4">
{{ tl("form.success_title") }}
</h2>
<p class="text-[#1a1a1a]/80 text-sm sm:text-base max-w-lg mx-auto mb-8 leading-relaxed">
{{ tl("form.success_info") }}
</p>
<div class="bg-[#22523F]/5 border border-[#22523F]/10 rounded-xl p-5 sm:p-6 text-left text-sm text-gray-600 max-w-lg mx-auto mb-8">
<div class="flex items-start gap-3">
<Icon icon="ph:info" class="text-[#22523F] text-lg mt-0.5 shrink-0" />
<p>{{ tl("form.success_detail") }}</p>
</div> </div>
</div> </div>
</form> <a :href="`/${props.locale}`" class="btn bg-[#22523F] text-white hover:bg-[#1a3d2f] rounded-none px-8">
{{ tl("form.back_home") }}
</a>
</div>
</div> </div>
</template> </template>
<style>
.reglamento-content h2 {
color: #22523F;
font-size: 1.25rem;
font-weight: 700;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.reglamento-content h2:first-of-type {
margin-top: 0;
}
.reglamento-content p {
color: #374151;
line-height: 1.625;
margin-bottom: 0.75rem;
}
.reglamento-content ul {
list-style: disc;
padding-left: 1.5rem;
margin-bottom: 0.75rem;
}
.reglamento-content li {
color: #374151;
line-height: 1.625;
margin-bottom: 0.25rem;
}
.reglamento-content h1 {
color: #22523F;
font-size: 1.5rem;
font-weight: 800;
margin-bottom: 0.25rem;
}
.reglamento-content > p:first-of-type {
color: #6B7280;
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
</style>

View File

@ -203,11 +203,17 @@
"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_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.page_desc": "Completá tus datos para formar parte del Centro del Reino de Paz y Justicia.",
"form.step1": "Datos Personales", "form.step1_reglamento": "Reglamento y Aceptación",
"form.step2": "Formación Academica y Experiencia", "form.step2": "Datos Personales",
"form.step3": "Formación Academica y Experiencia",
"form.step4": "Áreas de Colaboración",
"form.step5": "Disponibilidad",
"form.step6": "Información Médica",
"form.step7": "Contacto de Emergencia",
"form.nombre": "Nombre", "form.nombre": "Nombre",
"form.apellido": "Apellido", "form.apellido": "Apellido",
"form.documentos": "Documento de Identidad / Pasaporte", "form.documentos": "Documento de Identidad / Pasaporte",
"form.correo": "Correo Electrónico",
"form.fecha_nacimiento": "Fecha de Nacimiento", "form.fecha_nacimiento": "Fecha de Nacimiento",
"form.nacionalidad": "Nacionalidad", "form.nacionalidad": "Nacionalidad",
"form.sexo": "Sexo", "form.sexo": "Sexo",
@ -221,7 +227,23 @@
"form.codigo_postal": "Código Postal", "form.codigo_postal": "Código Postal",
"form.mobile": "Teléfono Móvil", "form.mobile": "Teléfono Móvil",
"form.whatsapp": "WhatsApp", "form.whatsapp": "WhatsApp",
"form.correo": "Correo Electrónico", "form.aceptacion_reglamento": "Declaración de Aceptación",
"form.aceptacion.leido": "He leído y acepto el presente Reglamento",
"form.aceptacion.codigo_etica": "Acepto el Código de Ética del Centro",
"form.aceptacion.datos": "Autorizo el tratamiento de mis datos personales conforme a la Política de Privacidad",
"form.aceptacion.voluntaria": "Confirmo que mi participación es completamente voluntaria y que no existe relación laboral, contractual o de representación con el Centro",
"form.aceptacion.verdadera": "Confirmo que toda la información proporcionada es verdadera",
"form.aceptacion.firma_validez": "Acepto que mi consentimiento electrónico tendrá la misma validez jurídica que una firma manuscrita, conforme a la legislación aplicable",
"form.reglamento_firma": "Firma electrónica",
"form.reglamento_firma_placeholder": "Nombre completo como firma",
"form.leer_reglamento": "Leer Reglamento Completo",
"form.reglamento_titulo": "Reglamento General del Programa de Voluntariado",
"form.reglamento_confirmar": "He leído y comprendo el Reglamento en su totalidad",
"form.reglamento_cerrar": "Cerrar",
"form.reglamento_leido": "Has leído el Reglamento completo",
"form.reglamento_scroll_needed": "Desplázate hasta el final para confirmar que has leído el Reglamento",
"form.reglamento_aviso": "Debes leer el Reglamento completo para poder marcar las casillas de aceptación y continuar con el formulario.",
"form.reglamento_tooltip": "Debes leer y desplazarte hasta el final del Reglamento para continuar",
"form.profesion": "Profesión u ocupación", "form.profesion": "Profesión u ocupación",
"form.lugar_trabajo_actual": "Lugar de trabajo actual", "form.lugar_trabajo_actual": "Lugar de trabajo actual",
"form.nivel_academico": "Nivel académico", "form.nivel_academico": "Nivel académico",
@ -239,7 +261,6 @@
"form.nivel.basico": "Básico", "form.nivel.basico": "Básico",
"form.nivel.intermedio": "Intermedio", "form.nivel.intermedio": "Intermedio",
"form.nivel.avanzado": "Avanzado", "form.nivel.avanzado": "Avanzado",
"form.step3": "Áreas de Colaboración",
"form.areas_colaborar": "Seleccione las áreas en las que desea colaborar", "form.areas_colaborar": "Seleccione las áreas en las que desea colaborar",
"form.areas.ayuda_humanitaria": "Ayuda Humanitaria", "form.areas.ayuda_humanitaria": "Ayuda Humanitaria",
"form.areas.educacion": "Educación", "form.areas.educacion": "Educación",
@ -259,6 +280,41 @@
"form.areas.tecnologia_innovacion": "Tecnología e Innovación", "form.areas.tecnologia_innovacion": "Tecnología e Innovación",
"form.areas.otra": "Otra", "form.areas.otra": "Otra",
"form.areas_otra": "Otra (especificar)", "form.areas_otra": "Otra (especificar)",
"form.dias_disponibles": "Días disponibles",
"form.dias.lunes": "Lunes",
"form.dias.martes": "Martes",
"form.dias.miercoles": "Miércoles",
"form.dias.jueves": "Jueves",
"form.dias.viernes": "Viernes",
"form.dias.sabado": "Sábado",
"form.dias.domingo": "Domingo",
"form.horario_preferido": "Horario preferido",
"form.horario.manana": "Mañana",
"form.horario.tarde": "Tarde",
"form.horario.noche": "Noche",
"form.horario.segun": "Según las necesidades del Centro",
"form.fuera_ciudad": "¿Está dispuesto(a) a participar en actividades fuera de su ciudad?",
"form.misiones_internacionales": "¿Está dispuesto(a) a participar en actividades o misiones internacionales?",
"form.condicion_medica": "¿Padece alguna condición médica que el Centro deba conocer?",
"form.condicion_medica_cual": "En caso afirmativo, especifique",
"form.alergias": "Alergias",
"form.medicamentos": "Medicamentos de uso permanente:",
"form.emergencia_nombre": "Nombre completo",
"form.emergencia_parentesco": "Parentesco",
"form.emergencia_telefono": "Teléfono",
"form.emergencia_correo": "Correo electrónico",
"form.step8": "Declaración del Voluntario",
"form.declaracion_voluntario": "Declaro bajo protesta de decir verdad que:",
"form.declaracion.verdadera": "Toda la información proporcionada en esta solicitud es verdadera y completa.",
"form.declaracion.acepto_reglamento": "He leído y acepto el Reglamento y las Condiciones del Programa de Voluntariado del Centro del Reino de Paz y Justicia.",
"form.declaracion.compromiso_etica": "Me comprometo a cumplir el Código de Ética, las políticas institucionales y las instrucciones de los responsables del Centro.",
"form.declaracion.autorizo_datos": "Autorizo el tratamiento de mis datos personales conforme a la Política de Privacidad del Centro y la legislación aplicable.",
"form.declaracion.confidencialidad": "Me comprometo a guardar absoluta confidencialidad respecto de toda la información a la que tenga acceso durante mi participación.",
"form.declaracion.voluntaria": "Entiendo que mi participación es completamente voluntaria y que no genera relación laboral alguna con el Centro.",
"form.confirmar_nombre": "Nombre",
"form.confirmar_firma": "Firma electrónica",
"form.confirmacion": "Confirmación final",
"form.confirmacion_label": "Confirmo que los datos personales y la firma mostrados arriba son correctos y corresponden a mi persona.",
"form.submit": "Enviar Formulario", "form.submit": "Enviar Formulario",
"form.next": "Siguiente", "form.next": "Siguiente",
"form.back": "Anterior", "form.back": "Anterior",
@ -266,5 +322,12 @@
"form.success": "¡Enviado con éxito!", "form.success": "¡Enviado con éxito!",
"form.error": "Error al enviar", "form.error": "Error al enviar",
"form.required": "Este campo es obligatorio", "form.required": "Este campo es obligatorio",
"form.config_error": "Error al cargar el formulario. Intentá de nuevo más tarde." "form.config_error": "Error al cargar el formulario. Intentá de nuevo más tarde.",
"form.si": "Sí",
"form.no": "No",
"form.step_counter": "Paso {current} de {total}",
"form.success_title": "¡Registro completado con éxito!",
"form.success_info": "Tu solicitud de inscripción como voluntario ha sido recibida correctamente.",
"form.success_detail": "Para más información, puedes comunicarte con nosotros a través de los canales oficiales del Centro del Reino de Paz y Justicia. Agradecemos tu interés y compromiso.",
"form.back_home": "Volver al inicio"
} }

View File

@ -35,6 +35,17 @@ export const routeTranslations = {
ru: "редакция", ru: "редакция",
rw: "editorial", rw: "editorial",
kr: "editorial", kr: "editorial",
},
formulario: {
es: "formulario",
en: "form",
fr: "formulaire",
he: "טופס",
uk: "форма",
pt: "formulário",
ru: "форма",
rw: "formulaire",
kr: "fòm",
} }
} as const; } as const;

View File

@ -4,6 +4,7 @@ import Header from "@/components/Header.astro"
import NewsList from "@/components/cards/NewsList.astro"; import NewsList from "@/components/cards/NewsList.astro";
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
import FooterSection from "@/components/section/FooterSection.astro"; import FooterSection from "@/components/section/FooterSection.astro";
import DynamicForm from "@/components/forms/DynamicForm.vue";
import { createTranslator, getRouteKeyFromSlug } from '@/i18n'; import { createTranslator, getRouteKeyFromSlug } from '@/i18n';
const tl = createTranslator(Astro.currentLocale); const tl = createTranslator(Astro.currentLocale);
@ -11,79 +12,107 @@ const tl = createTranslator(Astro.currentLocale);
const { locale, section } = Astro.params; const { locale, section } = Astro.params;
const routeKey = getRouteKeyFromSlug(section); const routeKey = getRouteKeyFromSlug(section);
const items = await getCollection(routeKey, (post)=>{ if (routeKey === "formulario") {
const currentLocale = Astro.currentLocale; if (Astro.currentLocale !== "es") {
return post.data.locale == currentLocale return Astro.redirect(`/${Astro.currentLocale}/`);
}); }
} else {
var items = await getCollection(routeKey, (post)=>{
const currentLocale = Astro.currentLocale;
return post.data.locale == currentLocale
});
const sortedPosts = [...items] var sortedPosts = [...items]
.sort((a, b) => { .sort((a, b) => {
const dateDiff = new Date(b.data.date).getTime() - new Date(a.data.date).getTime() const dateDiff = new Date(b.data.date).getTime() - new Date(a.data.date).getTime()
if (dateDiff !== 0) return dateDiff if (dateDiff !== 0) return dateDiff
return (a.data.order ?? 0) - (b.data.order ?? 0) return (a.data.order ?? 0) - (b.data.order ?? 0)
}); });
const allTags = routeKey === "news" ? [...new Set( var allTags = routeKey === "news" ? [...new Set(
sortedPosts sortedPosts
.filter(p => p.data.tags && p.data.tags.length > 0) .filter(p => p.data.tags && p.data.tags.length > 0)
.flatMap(p => p.data.tags) .flatMap(p => p.data.tags)
.filter((tag): tag is string => tag !== undefined) .filter((tag): tag is string => tag !== undefined)
)].sort() : []; )].sort() : [];
const allYears = routeKey === "news" ? [...new Set( var allYears = routeKey === "news" ? [...new Set(
sortedPosts.map(p => new Date(p.data.date).getFullYear()) sortedPosts.map(p => new Date(p.data.date).getFullYear())
)].sort((a, b) => b - a) : []; )].sort((a, b) => b - a) : [];
}
--- ---
<MainLayout > <MainLayout title={routeKey === "formulario" ? tl("form.page_title") : undefined}>
<div class="top-16 relative mb container mx-auto"> {routeKey === "formulario" && (
<div class="pt-16 relative container mx-auto">
<Header /> <Header />
</div>
<div class="container mx-auto mt-4">
<div class="flex flex-col lg:w-1/2 items-center mx-auto py-8">
<h1 class="text-white text-2xl uppercase font-bold text-center mb-4 font-primary md:mt-20 mt-10">{tl(routeKey + ".title")}</h1>
<h2 class="text-white text-3xl lg:text-5xl font-bold text-center font-secondary mb-4 md:p-0 px-2">{tl(routeKey + ".text")}</h2>
</div> </div>
)}
{routeKey !== "formulario" && (
<div class="top-16 relative mb container mx-auto">
<Header />
</div>
)}
{routeKey === "formulario" && (
{routeKey === "news" && allTags.length > 0 && ( <main class="min-h-screen py-16 px-4">
<div class="container mx-auto mb-8 px-4 md:px-0"> <div class="md:container mx-auto">
<div class="flex flex-nowrap md:flex-wrap gap-2 md:justify-center overflow-x-auto md:overflow-visible pb-2 md:pb-0"> <h1 class="text-3xl lg:text-4xl font-bold font-secondary text-[#EBE6D2] text-center mb-4 md:mb-9">
<button class="filter-btn px-4 py-2 font-primary text-sm cursor-pointer transition-colors whitespace-nowrap" data-tag="all"> {tl("form.page_title")}
{tl("news.all")} </h1>
</button> <DynamicForm client:load locale={Astro.currentLocale} />
{allTags.map((tag) => (
<button class="filter-btn px-4 py-2 font-primary text-sm cursor-pointer transition-colors whitespace-nowrap" data-tag={tag!}>
{tag}
</button>
))}
</div>
</div> </div>
)} </main>
)}
<div class="flex flex-col md:gap-0 gap-2 lg:max-w-4xl mx-auto bg-white p-4 md:p-8"> {routeKey !== "formulario" && (
{routeKey === "news" && allYears.length > 0 && ( <div class="container mx-auto mt-4">
<div class="container mb-4"> <div class="flex flex-col lg:w-1/2 items-center mx-auto py-8">
<div class="flex justify-start md:justify-end"> <h1 class="text-white text-2xl uppercase font-bold text-center mb-4 font-primary md:mt-20 mt-10">{tl(routeKey + ".title")}</h1>
<select id="year-filter" class="bg-[#003421] text-[#EBE5D0] px-4 py-2 font-primary text-sm cursor-pointer border-none outline-none"> <h2 class="text-white text-3xl lg:text-5xl font-bold text-center font-secondary mb-4 md:p-0 px-2">{tl(routeKey + ".text")}</h2>
<option value="all">{tl("news.allYears")}</option> </div>
{allYears.map((year) => (
<option value={year}>{year}</option>
))} {routeKey === "news" && allTags.length > 0 && (
</select> <div class="container mx-auto mb-8 px-4 md:px-0">
<div class="flex flex-nowrap md:flex-wrap gap-2 md:justify-center overflow-x-auto md:overflow-visible pb-2 md:pb-0">
<button class="filter-btn px-4 py-2 font-primary text-sm cursor-pointer transition-colors whitespace-nowrap" data-tag="all">
{tl("news.all")}
</button>
{allTags.map((tag) => (
<button class="filter-btn px-4 py-2 font-primary text-sm cursor-pointer transition-colors whitespace-nowrap" data-tag={tag!}>
{tag}
</button>
))}
</div> </div>
</div> </div>
)} )}
{
sortedPosts.map((item) => ( <div class="flex flex-col md:gap-0 gap-2 lg:max-w-4xl mx-auto bg-white p-4 md:p-8">
<div class="news-item" data-tags={routeKey === "news" ? JSON.stringify(item.data.tags || []) : "[]"} data-year={routeKey === "news" ? new Date(item.data.date).getFullYear() : ""}> {routeKey === "news" && allYears.length > 0 && (
<NewsList data={item} content={{ body: item.body }} routeKey={routeKey} /> <div class="container mb-4">
</div> <div class="flex justify-start md:justify-end">
)) <select id="year-filter" class="bg-[#003421] text-[#EBE5D0] px-4 py-2 font-primary text-sm cursor-pointer border-none outline-none">
} <option value="all">{tl("news.allYears")}</option>
{allYears.map((year) => (
<option value={year}>{year}</option>
))}
</select>
</div>
</div>
)}
{
sortedPosts.map((item) => (
<div class="news-item" data-tags={routeKey === "news" ? JSON.stringify(item.data.tags || []) : "[]"} data-year={routeKey === "news" ? new Date(item.data.date).getFullYear() : ""}>
<NewsList data={item} content={{ body: item.body }} routeKey={routeKey} />
</div>
))
}
</div>
</div> </div>
</div> )}
<FooterSection />
<FooterSection />
</MainLayout> </MainLayout>

View File

@ -1,27 +0,0 @@
---
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>

View File

@ -0,0 +1,88 @@
import type { APIRoute } from "astro";
import { appendRegistrationToSheet } from "../lib/googleSheets";
export const prerender = false;
const COLUMNS = [
"aceptacion_reglamento",
"nombre",
"apellido",
"documentos",
"correo",
"reglamento_firma",
"fecha_nacimiento",
"nacionalidad",
"sexo",
"direccion_completa",
"ciudad",
"estado",
"pais",
"postal",
"telefono",
"whatsapp",
"profesion",
"lugar_trabajo_actual",
"nivel_academico",
"idioma",
"idioma_nivel_Espanol",
"idioma_nivel_Ingles",
"idioma_nivel_Hebreo",
"idioma_nivel_Portugues",
"idioma_nivel_Otro",
"idioma_otro",
"voluntariado_anterior",
"caso_si",
"areas_colaborar",
"areas_otra",
"dias_disponibles",
"horario_preferido",
"fuera_ciudad",
"misiones_internacionales",
"condicion_medica",
"condicion_medica_cual",
"alergias",
"medicamentos",
"emergencia_nombre",
"emergencia_parentesco",
"emergencia_telefono",
"emergencia_correo",
"confirmar_nombre",
"confirmar_firma",
"declaracion_voluntario",
"confirmacion",
];
function flattenValue(val: unknown): string {
if (Array.isArray(val)) {
return val.join(", ");
}
if (val === null || val === undefined) {
return "";
}
return String(val);
}
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const formData = body.formData || {};
const timestamp = new Date().toLocaleString("es-ES", {
timeZone: "America/Puerto_Rico",
});
const row = COLUMNS.map((key) => flattenValue(formData[key]));
row.push(timestamp);
const result = await appendRegistrationToSheet(row);
return Response.json(result);
} catch (error) {
return Response.json(
{
success: false,
message: error instanceof Error ? error.message : "Error al enviar el formulario",
},
{ status: 500 }
);
}
};

View File

@ -17,7 +17,7 @@ export async function emailExists(email: string): Promise<boolean> {
// Escribir fórmula en celda auxiliar // Escribir fórmula en celda auxiliar
await sheets.spreadsheets.values.update({ await sheets.spreadsheets.values.update({
spreadsheetId: SPREADSHEET_ID, spreadsheetId: SPREADSHEET_ID,
range: 'DATABASE!Z1', // usa una columna libre range: 'VOLUNTARIOS!Z1', // usa una columna libre
valueInputOption: 'USER_ENTERED', valueInputOption: 'USER_ENTERED',
requestBody: { requestBody: {
values: [[formula]], values: [[formula]],
@ -27,7 +27,7 @@ export async function emailExists(email: string): Promise<boolean> {
// Leer resultado calculado // Leer resultado calculado
const result = await sheets.spreadsheets.values.get({ const result = await sheets.spreadsheets.values.get({
spreadsheetId: SPREADSHEET_ID, spreadsheetId: SPREADSHEET_ID,
range: 'DATABASE!Z1', range: 'VOLUNTARIOS!Z1',
valueRenderOption: 'UNFORMATTED_VALUE', valueRenderOption: 'UNFORMATTED_VALUE',
}); });
@ -41,7 +41,7 @@ export async function appendToSheet(values: any[]) {
try { try {
await sheets.spreadsheets.values.append({ await sheets.spreadsheets.values.append({
spreadsheetId: SPREADSHEET_ID, spreadsheetId: SPREADSHEET_ID,
range: 'DATABASE!A2:H2', range: 'VOLUNTARIOS!A2:H2',
valueInputOption: 'RAW', valueInputOption: 'RAW',
requestBody: { requestBody: {
values: [values], values: [values],
@ -57,3 +57,20 @@ export async function appendToSheet(values: any[]) {
throw error; throw error;
} }
} }
export async function appendRegistrationToSheet(values: string[]) {
try {
await sheets.spreadsheets.values.append({
spreadsheetId: SPREADSHEET_ID,
range: 'VOLUNTARIOS!A2:AU',
valueInputOption: 'RAW',
requestBody: {
values: [values],
},
});
return { success: true, message: 'Registro guardado correctamente' };
} catch (error) {
throw error;
}
}