Compare commits

..

No commits in common. "a4ba8095f5f6ae7be9a19b769434ced504a2b7e0" and "affbc47bfdccf2c7f3a5e4272c6bf1407564fd58" have entirely different histories.

11 changed files with 207 additions and 1457 deletions

7
.env
View File

@ -22,10 +22,3 @@ DATABASE_URL="file:./dev.db"
N8N_WEBHOOK_URL="https://flows2.carpa.com/webhook/news-summary"
N8N_API_KEY="sk_live_JwjpTdZrLVvijCKuWylWZbUuhBm6zPDH"
# Cloudflare Turnstile — public key (visible in frontend)
TURNSTILE_SITE_KEY=0x4AAAAAAD9IYWC6HPflpneO
# Cloudflare Turnstile — secret key (server-side, keep safe!)
# In production, set this as a Gitea Actions secret named TURNSTILE_SECRET_KEY
TURNSTILE_SECRET_KEY=0x4AAAAAAD9IYXASjQSpmo2iS902SDoXnT4

View File

@ -1,6 +0,0 @@
# Cloudflare Turnstile — public key (visible in frontend)
TURNSTILE_SITE_KEY=0x4AA...
# Cloudflare Turnstile — secret key (server-side, keep safe!)
# In production, set this as a Gitea Actions secret named TURNSTILE_SECRET_KEY
TURNSTILE_SECRET_KEY=0x4AA...

View File

@ -54,7 +54,5 @@ jobs:
corepack prepare pnpm@11 --activate
cd ${{ env.TARGET_DIR }}
pnpm install --prod 2>&1
sed -i "s|TURNSTILE_SITE_KEY: \"\"|TURNSTILE_SITE_KEY: \"${{ secrets.TURNSTILE_SITE_KEY }}\"|" ecosystem.config.cjs
sed -i "s|TURNSTILE_SECRET_KEY: \"\"|TURNSTILE_SECRET_KEY: \"${{ secrets.TURNSTILE_SECRET_KEY }}\"|" ecosystem.config.cjs
pm2 reload ecosystem.config.cjs --env ${{ env.PM2_ENV }} --update-env || \
pm2 start ecosystem.config.cjs --env ${{ env.PM2_ENV }} --update-env

View File

@ -5,14 +5,10 @@ module.exports = {
env: {
NODE_ENV: "staging",
PORT: 3310,
TURNSTILE_SITE_KEY: "",
TURNSTILE_SECRET_KEY: "",
},
env_production: {
NODE_ENV: "production",
PORT: 3310,
TURNSTILE_SITE_KEY: "",
TURNSTILE_SECRET_KEY: "",
}
}]
};

View File

@ -40,7 +40,8 @@
"required": true,
"options": [
{ "value": "M", "label": "form.sexo.m" },
{ "value": "F", "label": "form.sexo.f" }
{ "value": "F", "label": "form.sexo.f" },
{ "value": "Otro", "label": "form.sexo.o" }
]
},
{ "key": "direccion_completa", "label": "form.direccion", "type": "text", "required": true, "colspan": 2 },
@ -48,8 +49,8 @@
{ "key": "estado", "label": "form.estado", "type": "text" },
{ "key": "pais", "label": "form.pais", "type": "select", "required": true, "source": "/forms/paises.json" },
{ "key": "postal", "label": "form.codigo_postal", "type": "text", "required": true },
{ "key": "telefono", "label": "form.mobile", "type": "phone", "required": true },
{ "key": "whatsapp", "label": "form.whatsapp", "type": "phone_country", "source": "/forms/paises.json" }
{ "key": "telefono", "label": "form.mobile", "type": "text", "required": true },
{ "key": "whatsapp", "label": "form.whatsapp", "type": "text" }
]
},
{
@ -77,7 +78,6 @@
{ "value": "Ingles", "label": "form.idioma.ing" },
{ "value": "Hebreo", "label": "form.idioma.heb" },
{ "value": "Portugues", "label": "form.idioma.port" },
{ "value": "Frances", "label": "form.idioma.franc" },
{ "value": "Otro", "label": "form.idioma.otro" }
]
},

File diff suppressed because it is too large Load Diff

View File

@ -5,8 +5,7 @@ import { createTranslator } from "../../i18n";
const props = defineProps({
locale: String,
formConfigUrl: { type: String, required: false, default: "/forms/formulario-inscripcion.json" },
turnstileSiteKey: { type: String, required: false, default: "" }
formConfigUrl: { type: String, required: false, default: "/forms/formulario-inscripcion.json" }
});
const tl = createTranslator(props.locale);
@ -26,12 +25,6 @@ const reglamentoLoading = ref(false);
const reglamentoScrolled = ref(false);
const reglamentoFetchAttempted = ref(false);
const turnstileToken = ref("");
const turnstileContainer = ref(null);
const honeypot = ref("");
const turnstileReady = ref(false);
const totalSteps = computed(() => config.value?.steps?.length ?? 0);
const currentStepData = computed(() => config.value?.steps?.[currentStep.value] ?? null);
@ -39,8 +32,6 @@ const isFirstStep = computed(() => currentStep.value === 0);
const isLastStep = computed(() => currentStep.value === totalSteps.value - 1);
const stepErrors = reactive({});
const formatErrors = reactive({});
const phoneParts = reactive({});
const fetchConfig = async () => {
isSuccess.value = false;
@ -53,9 +44,6 @@ const fetchConfig = async () => {
step.fields.forEach((field) => {
if (field.type === "checkbox") {
formData[field.key] = [];
} else if (field.type === "phone_country") {
formData[field.key] = "";
phoneParts[field.key] = { code: "", number: "", flag: "" };
} else {
formData[field.key] = "";
}
@ -66,37 +54,7 @@ const fetchConfig = async () => {
}
};
const initTurnstile = () => {
if (!props.turnstileSiteKey || !turnstileContainer.value) return;
if (window.turnstile) {
window.turnstile.render(turnstileContainer.value, {
sitekey: props.turnstileSiteKey,
callback: (token) => { turnstileToken.value = token; },
});
turnstileReady.value = true;
}
};
onMounted(() => {
fetchConfig();
if (props.turnstileSiteKey) {
if (document.querySelector('script[src*="turnstile"]')) {
const check = setInterval(() => {
if (window.turnstile) {
clearInterval(check);
initTurnstile();
}
}, 100);
} else {
const script = document.createElement("script");
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
script.async = true;
script.defer = true;
script.onload = initTurnstile;
document.head.appendChild(script);
}
}
});
onMounted(fetchConfig);
const validateStep = (stepIndex) => {
const step = config.value?.steps?.[stepIndex];
@ -153,37 +111,6 @@ const validateStep = (stepIndex) => {
stepErrors[field.key] = false;
}
}
if (field.type === "phone" && val && val.trim() !== "") {
const phoneRegex = /^\d+$/;
if (!phoneRegex.test(val.trim())) {
formatErrors[field.key] = true;
stepErrors[field.key] = true;
valid = false;
} else {
formatErrors[field.key] = false;
}
}
if (field.type === "phone_country") {
const parts = phoneParts[field.key];
if (field.required && (!parts || !parts.code || !parts.number)) {
stepErrors[field.key] = true;
valid = false;
} else if (parts && parts.number) {
const full = parts.code + parts.number;
if (full.length > 15) {
formatErrors[field.key] = true;
stepErrors[field.key] = true;
valid = false;
} else if (parts.number.length < 7) {
formatErrors[field.key] = true;
stepErrors[field.key] = true;
valid = false;
} else {
stepErrors[field.key] = false;
formatErrors[field.key] = false;
}
}
}
if (field.type === "checkbox" && field.levels && Array.isArray(val)) {
val.forEach((optVal) => {
if (!formData[`${field.key}_nivel_${optVal}`]) {
@ -259,7 +186,7 @@ const buildPayload = () => {
}
});
});
return { formData: { ...formData }, responses, turnstileToken: turnstileToken.value, honeypot: honeypot.value };
return { formData: { ...formData }, responses };
};
const handleSubmit = async () => {
@ -298,72 +225,8 @@ const handleSubmit = async () => {
const getFieldError = (key) => stepErrors[key] || false;
const onPhoneInput = (key, event) => {
const cleaned = event.target.value.replace(/\D/g, "");
formData[key] = cleaned;
stepErrors[key] = false;
formatErrors[key] = false;
};
const onPhoneCountryNumberInput = (key, event) => {
if (!phoneParts[key]) return;
const cleaned = event.target.value.replace(/\D/g, "");
event.target.value = cleaned;
phoneParts[key].number = cleaned;
formData[key] = phoneParts[key].code + cleaned;
stepErrors[key] = false;
};
const getPhoneCountryDisplay = (key) => {
if (phoneCountrySearchText[key] !== undefined && phoneCountrySearchText[key] !== null) {
return phoneCountrySearchText[key];
}
if (!phoneParts[key] || !phoneParts[key].code) return "";
return phoneParts[key].flag
? `${phoneParts[key].code}`
: phoneParts[key].code;
};
const filteredPhoneCountries = (field, key) => {
const text = (phoneCountrySearchText[key] || "").toLowerCase().trim();
const list = suggestionsCache.value[field.source] || [];
if (!text) return list;
return list.filter(
item => item.label.toLowerCase().includes(text) || item.dial_code.includes(text)
);
};
const onPhoneCountrySearchFocus = (field, key) => {
loadSuggestions(field.source);
showCountrySuggestions[key] = true;
};
const onPhoneCountrySearchBlur = (key) => {
setTimeout(() => {
showCountrySuggestions[key] = false;
phoneCountrySearchText[key] = undefined;
}, 200);
};
const onPhoneCountrySearchInput = (key, event) => {
phoneCountrySearchText[key] = event.target.value;
showCountrySuggestions[key] = true;
};
const onPhoneCountrySelect = (key, item) => {
phoneParts[key].code = item.dial_code;
phoneParts[key].flag = item.flag;
phoneParts[key].number = phoneParts[key].number || "";
formData[key] = item.dial_code + phoneParts[key].number;
phoneCountrySearchText[key] = undefined;
showCountrySuggestions[key] = false;
stepErrors[key] = false;
};
const suggestionsCache = ref({});
const showSuggestions = reactive({});
const phoneCountrySearchText = reactive({});
const showCountrySuggestions = reactive({});
const loadSuggestions = async (source) => {
if (suggestionsCache.value[source]) return;
@ -585,7 +448,7 @@ const getFieldInputType = (field) => {
:type="getFieldInputType(field)"
v-model="formData[field.key]"
:placeholder="tl(field.placeholder || field.label)"
@input="field.type === 'phone' ? onPhoneInput(field.key, $event) : (stepErrors[field.key] = false)"
@input="stepErrors[field.key] = false"
/>
</label>
@ -622,53 +485,6 @@ const getFieldInputType = (field) => {
</label>
</div>
<div v-else-if="field.type === 'phone_country'" class="relative">
<div class="join w-full">
<div class="join-item relative w-28">
<label
class="input border-gray-200 rounded-l-xl bg-white w-full focus-within:outline-2 focus-within:outline-[#22523F]"
:class="{ 'border-red-500': getFieldError(field.key) }"
>
<input
class="w-full text-[#1a1a1a]"
type="text"
autocomplete="off"
:value="getPhoneCountryDisplay(field.key)"
:placeholder="tl('form.phone_code_select')"
@input="onPhoneCountrySearchInput(field.key, $event)"
@focus="onPhoneCountrySearchFocus(field, field.key)"
@blur="onPhoneCountrySearchBlur(field.key)"
/>
</label>
<ul
v-if="showCountrySuggestions[field.key] && filteredPhoneCountries(field, field.key).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 filteredPhoneCountries(field, field.key)"
:key="item.value"
class="px-3 py-2 cursor-pointer text-sm text-[#1a1a1a] hover:bg-[#22523F]/10 transition-colors flex items-center gap-2"
@mousedown.prevent="onPhoneCountrySelect(field.key, item)"
>
<span>{{ item.flag }}</span>
<span class="font-mono">{{ item.dial_code }}</span>
</li>
</ul>
</div>
<label
class="input join-item grow bg-white rounded-r-xl border-gray-200 focus-within:outline-2 focus-within:outline-[#22523F]"
:class="{ 'border-red-500': getFieldError(field.key) }"
>
<input
class="grow text-[#1a1a1a]"
type="tel"
:placeholder="tl(field.placeholder || field.label)"
@input="onPhoneCountryNumberInput(field.key, $event)"
/>
</label>
</div>
</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]"
@ -724,7 +540,7 @@ const getFieldInputType = (field) => {
:name="field.key"
:value="opt.value"
v-model="formData[field.key]"
class="radio radio-sm border-gray-300 bg-white checked:border-[#4A8C6F] checked:bg-[#4A8C6F]"
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>
@ -745,7 +561,7 @@ const getFieldInputType = (field) => {
type="checkbox"
:value="opt.value"
:disabled="currentStep === 0 && !reglamentoScrolled"
class="checkbox checkbox-sm border-gray-300 bg-white checked:border-[#4A8C6F] checked:bg-[#4A8C6F]"
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)"
/>
@ -765,7 +581,7 @@ const getFieldInputType = (field) => {
:name="`${field.key}_nivel_${opt.value}`"
:value="lvl.value"
v-model="formData[`${field.key}_nivel_${opt.value}`]"
class="radio radio-sm border-gray-300 bg-white checked:border-[#4A8C6F] checked:bg-[#4A8C6F]"
class="radio radio-sm border-gray-300 checked:border-[#4A8C6F] checked:bg-[#4A8C6F]"
/>
<span class="text-[#1a1a1a]">{{ tl(lvl.label) }}</span>
</label>
@ -784,7 +600,7 @@ const getFieldInputType = (field) => {
class="text-xs text-red-500 flex items-center gap-1 mt-0.5"
>
<Icon icon="ph:warning-circle" class="text-sm shrink-0" />
{{ formatErrors[field.key] ? (field.type === "phone_country" ? tl("form.phone_number_invalid") : tl("form.phone_invalid")) : tl("form.required") }}
{{ tl("form.required") }}
</p>
</div>
</template>
@ -794,19 +610,6 @@ const getFieldInputType = (field) => {
{{ errorMsg }}
</div>
<div v-if="isLastStep && props.turnstileSiteKey" class="flex justify-center my-4">
<div ref="turnstileContainer"></div>
</div>
<input
v-if="props.turnstileSiteKey"
type="text"
name="website"
v-model="honeypot"
class="hidden"
autocomplete="off"
tabindex="-1"
/>
<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">

View File

@ -252,7 +252,6 @@
"form.idioma.ing": "Inglés",
"form.idioma.heb": "Hebreo",
"form.idioma.port": "Portugués",
"form.idioma.franc": "Francés",
"form.idioma.otro": "Otro",
"form.idioma_otro": "Otro (especificar)",
"form.voluntariado_anterior": "¿Ha realizado voluntariado anteriormente?",
@ -323,9 +322,6 @@
"form.success": "¡Enviado con éxito!",
"form.error": "Error al enviar",
"form.required": "Este campo es obligatorio",
"form.phone_invalid": "Solo se permiten números",
"form.phone_number_invalid": "Número inválido (mín. 7 dígitos, máx. 15)",
"form.phone_code_select": "Código",
"form.config_error": "Error al cargar el formulario. Intentá de nuevo más tarde.",
"form.si": "Sí",
"form.no": "No",

View File

@ -60,7 +60,7 @@ if (routeKey === "formulario") {
<h1 class="text-3xl lg:text-4xl font-bold font-secondary text-[#EBE6D2] text-center mb-4 md:mb-9">
{tl("form.page_title")}
</h1>
<DynamicForm client:load locale={Astro.currentLocale} turnstileSiteKey={import.meta.env.TURNSTILE_SITE_KEY} />
<DynamicForm client:load locale={Astro.currentLocale} />
</div>
</main>
)}

View File

@ -1,6 +1,5 @@
import type { APIRoute } from "astro";
import { appendRegistrationToSheet } from "../lib/googleSheets";
import { sendEmail } from "../lib/email";
export const prerender = false;
const COLUMNS = [
@ -67,34 +66,6 @@ export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
const formData = body.formData || {};
const honeypot = body.honeypot || "";
if (honeypot) {
return Response.json(
{ success: false, message: "Solicitud rechazada" },
{ status: 403 }
);
}
const turnstileToken = body.turnstileToken || "";
const turnstileSecret = import.meta.env.TURNSTILE_SECRET_KEY;
if (turnstileSecret && turnstileToken) {
const verify = await fetch(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `secret=${turnstileSecret}&response=${turnstileToken}`,
}
);
const outcome = await verify.json();
if (!outcome.success) {
return Response.json(
{ success: false, message: "Captcha inválido" },
{ status: 403 }
);
}
}
const timestamp = new Date().toLocaleString("es-ES", {
timeZone: "America/Puerto_Rico",
});
@ -104,14 +75,8 @@ export const POST: APIRoute = async ({ request }) => {
const result = await appendRegistrationToSheet(row);
await sendEmail({
nombres: formData.nombre,
apellidos: formData.apellido,
}, formData.correo, "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION");
return Response.json(result);
} catch (error) {
console.error("Error en /api/formulario/send:", error);
return Response.json(
{
success: false,

View File

@ -1,30 +0,0 @@
type TypeEmailData = "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_INFO" | "CENTRO_DEL_REINO_PAZ_Y_JUSTICIA_POSTULACION";
export async function sendEmail(data: object, to: string, type: TypeEmailData) {
const emailApiKey = import.meta.env.EMAIL_API_KEY;
const dataFinal = {
...data,
email: to,
senderName: "Centro del Reino de Paz y Justicia",
};
const emailResponse = await fetch("https://flows2.carpa.com/webhook/email/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": emailApiKey!,
},
body: JSON.stringify({
type: type,
data: dataFinal,
}),
});
const responseBody = await emailResponse.text();
if (!emailResponse.ok) {
console.error(`Error enviando correo (status ${emailResponse.status}): ${responseBody}`);
throw new Error(`Error en servicio de correo: ${emailResponse.status}`);
}
}