752 lines
28 KiB
Vue
752 lines
28 KiB
Vue
<script setup>
|
|
import { ref, reactive, computed, onMounted, watch } from "vue";
|
|
import { Icon } from "@iconify/vue";
|
|
import { createTranslator } from "../../i18n";
|
|
|
|
const props = defineProps({
|
|
locale: String,
|
|
formConfigUrl: { type: String, required: false, 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 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 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 () => {
|
|
isSuccess.value = false;
|
|
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;
|
|
if (stepIndex === 0 && !reglamentoScrolled.value) {
|
|
valid = false;
|
|
}
|
|
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];
|
|
if (field.type === "checkbox") {
|
|
const requiredOptions = field.options?.filter(o => o.required)?.map(o => o.value) || [];
|
|
if (field.required) {
|
|
if (requiredOptions.length > 0) {
|
|
const allRequiredSelected = requiredOptions.every(optVal => val?.includes(optVal));
|
|
if (!allRequiredSelected) {
|
|
stepErrors[field.key] = true;
|
|
valid = false;
|
|
} else {
|
|
stepErrors[field.key] = false;
|
|
}
|
|
} else {
|
|
if (!val || val.length === 0) {
|
|
stepErrors[field.key] = true;
|
|
valid = false;
|
|
} else {
|
|
stepErrors[field.key] = false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (field.required && field.type !== "checkbox") {
|
|
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;
|
|
} else {
|
|
stepErrors[field.key] = 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;
|
|
} else {
|
|
stepErrors[`${field.key}_nivel_${optVal}`] = 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 res = await fetch("/api/formulario/send", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
isSuccess.value = true;
|
|
} else {
|
|
throw new Error(data.message || "Error al enviar el formulario");
|
|
}
|
|
} catch (error) {
|
|
isError.value = true;
|
|
errorMsg.value = error.message || "Error al enviar el formulario";
|
|
} finally {
|
|
isSubmitting.value = false;
|
|
}
|
|
};
|
|
|
|
const getFieldError = (key) => stepErrors[key] || false;
|
|
|
|
const suggestionsCache = ref({});
|
|
const showSuggestions = reactive({});
|
|
|
|
const loadSuggestions = async (source) => {
|
|
if (suggestionsCache.value[source]) return;
|
|
try {
|
|
const res = await fetch(source);
|
|
suggestionsCache.value[source] = await res.json();
|
|
} catch {}
|
|
};
|
|
|
|
const filteredSuggestions = (field) => {
|
|
const val = formData[field.key]?.toLowerCase().trim() || "";
|
|
if (!val) return [];
|
|
const list = suggestionsCache.value[field.source] || [];
|
|
return list.filter(item => item.label.toLowerCase().includes(val));
|
|
};
|
|
|
|
const selectSuggestion = (field, item) => {
|
|
formData[field.key] = item.value;
|
|
showSuggestions[field.key] = false;
|
|
stepErrors[field.key] = false;
|
|
};
|
|
|
|
const onAutocompleteFocus = (field) => {
|
|
loadSuggestions(field.source);
|
|
showSuggestions[field.key] = true;
|
|
};
|
|
|
|
const onAutocompleteBlur = (field) => {
|
|
setTimeout(() => { showSuggestions[field.key] = false; }, 200);
|
|
};
|
|
|
|
const getSelectOptions = (field) => {
|
|
loadSuggestions(field.source);
|
|
return suggestionsCache.value[field.source] || [];
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
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>
|
|
|
|
<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] font-secondary border-t-transparent rounded-full animate-spin"></span>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<div v-if="!isSuccess" class="w-full max-w-3xl mx-auto md:px-4 sm:px-0">
|
|
<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">
|
|
<button
|
|
type="button"
|
|
@click="goToStep(index)"
|
|
class="flex flex-col lg:flex-row items-center gap-1 text-sm transition-colors text-center"
|
|
:class="index === currentStep ? 'text-[#EBE6D2] font-bold' : 'text-[#EBE6D2]/60 hover:text-[#EBE6D2]'"
|
|
>
|
|
<span
|
|
class="flex items-center justify-center w-7 h-7 rounded-full text-xs font-bold border-2 transition-colors shrink-0"
|
|
:class="index === currentStep
|
|
? 'bg-[#CBA16A] text-[#22523F] border-[#CBA16A]'
|
|
: index < currentStep
|
|
? 'bg-[#EBE6D2] text-[#22523F] border-[#EBE6D2]'
|
|
: 'border-[#EBE6D2]/50 text-[#EBE6D2]/60'"
|
|
>
|
|
{{ index < currentStep ? '✓' : index + 1 }}
|
|
</span>
|
|
<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>
|
|
<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>
|
|
</template>
|
|
</div>
|
|
|
|
<form @submit.prevent="handleSubmit" class="bg-[#EBE6D2] p-5 sm:p-8 lg:p-10 rounded-none">
|
|
<h3 class="text-lg sm:text-xl font-bold text-[#22523F] mb-4 sm:mb-6 text-center font-secondary">
|
|
{{ tl(currentStepData.label) }}
|
|
</h3>
|
|
|
|
<div v-if="currentStep === 0" class="mb-4 sm:mb-6">
|
|
<div v-if="reglamentoLoading" class="flex justify-center py-8">
|
|
<span class="w-8 h-8 border-2 border-[#22523F] border-t-transparent rounded-full animate-spin"></span>
|
|
</div>
|
|
<div v-else>
|
|
<div
|
|
ref="reglamentoScrollRef"
|
|
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"
|
|
@scroll="onReglamentoScroll"
|
|
>
|
|
<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="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 }">
|
|
<template v-for="field in currentStepData.fields" :key="field.key">
|
|
<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 }">
|
|
|
|
<span class="text-[#22523F] font-secondary font-medium text-sm leading-tight">
|
|
{{ tl(field.label) }}
|
|
<span v-if="field.required" class="text-red-500 ml-0.5">*</span>
|
|
</span>
|
|
|
|
<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">
|
|
{{ field.valueFrom ? (formData[field.valueFrom] || "—") : (formData[field.key] || "—") }}
|
|
</div>
|
|
|
|
<label
|
|
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 === 'select'" class="relative">
|
|
<label
|
|
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" />
|
|
<select
|
|
class="grow text-[#1a1a1a] bg-transparent"
|
|
v-model="formData[field.key]"
|
|
@change="stepErrors[field.key] = false"
|
|
>
|
|
<option value="" disabled>{{ tl("form.select_placeholder", { label: tl(field.label) }) }}</option>
|
|
<option v-for="item in getSelectOptions(field)" :key="item.value" :value="item.value">
|
|
{{ item.label }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
|
|
<div v-else-if="field.type === 'autocomplete'" class="relative">
|
|
<label
|
|
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="text"
|
|
v-model="formData[field.key]"
|
|
:placeholder="tl(field.placeholder || field.label)"
|
|
autocomplete="off"
|
|
@input="stepErrors[field.key] = false; showSuggestions[field.key] = true"
|
|
@focus="onAutocompleteFocus(field)"
|
|
@blur="onAutocompleteBlur(field)"
|
|
/>
|
|
</label>
|
|
<ul
|
|
v-if="showSuggestions[field.key] && filteredSuggestions(field).length"
|
|
class="absolute left-0 right-0 z-50 bg-white shadow-lg rounded-b-xl border border-gray-200 border-t-0 max-h-[200px] overflow-y-auto"
|
|
>
|
|
<li
|
|
v-for="item in filteredSuggestions(field)"
|
|
:key="item.value"
|
|
class="px-4 py-2.5 cursor-pointer text-sm text-[#1a1a1a] hover:bg-[#22523F]/10 transition-colors"
|
|
@mousedown.prevent="selectSuggestion(field, item)"
|
|
>
|
|
{{ item.label }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<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]'"
|
|
>
|
|
<span v-if="isSubmitting" class="flex items-center gap-1 sm:gap-2">
|
|
<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>
|
|
<span class="truncate">{{ tl("form.sending") }}</span>
|
|
</span>
|
|
<span v-else class="truncate">
|
|
{{ tl(config.submit_label) }}
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<div v-else class="w-full max-w-3xl mx-auto md:px-4 sm:px-0">
|
|
<div 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>
|
|
<a :href="`/${props.locale}`" class="btn bg-[#22523F] text-white hover:bg-[#1a3d2f] rounded-none px-8">
|
|
{{ tl("form.back_home") }}
|
|
</a>
|
|
</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>
|