multiple fixes staging #76

Closed
esteban wants to merge 32 commits from staging into main
11 changed files with 1457 additions and 207 deletions
Showing only changes of commit a4ba8095f5 - Show all commits

7
.env
View File

@ -22,3 +22,10 @@ DATABASE_URL="file:./dev.db"
N8N_WEBHOOK_URL="https://flows2.carpa.com/webhook/news-summary" N8N_WEBHOOK_URL="https://flows2.carpa.com/webhook/news-summary"
N8N_API_KEY="sk_live_JwjpTdZrLVvijCKuWylWZbUuhBm6zPDH" 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

6
.env.example Normal file
View File

@ -0,0 +1,6 @@
# 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,5 +54,7 @@ jobs:
corepack prepare pnpm@11 --activate corepack prepare pnpm@11 --activate
cd ${{ env.TARGET_DIR }} cd ${{ env.TARGET_DIR }}
pnpm install --prod 2>&1 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 reload ecosystem.config.cjs --env ${{ env.PM2_ENV }} --update-env || \
pm2 start ecosystem.config.cjs --env ${{ env.PM2_ENV }} --update-env pm2 start ecosystem.config.cjs --env ${{ env.PM2_ENV }} --update-env

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,8 @@ import { createTranslator } from "../../i18n";
const props = defineProps({ const props = defineProps({
locale: String, locale: String,
formConfigUrl: { type: String, required: false, default: "/forms/formulario-inscripcion.json" } formConfigUrl: { type: String, required: false, default: "/forms/formulario-inscripcion.json" },
turnstileSiteKey: { type: String, required: false, default: "" }
}); });
const tl = createTranslator(props.locale); const tl = createTranslator(props.locale);
@ -25,6 +26,12 @@ const reglamentoLoading = ref(false);
const reglamentoScrolled = ref(false); const reglamentoScrolled = ref(false);
const reglamentoFetchAttempted = 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 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);
@ -32,6 +39,8 @@ const isFirstStep = computed(() => currentStep.value === 0);
const isLastStep = computed(() => currentStep.value === totalSteps.value - 1); const isLastStep = computed(() => currentStep.value === totalSteps.value - 1);
const stepErrors = reactive({}); const stepErrors = reactive({});
const formatErrors = reactive({});
const phoneParts = reactive({});
const fetchConfig = async () => { const fetchConfig = async () => {
isSuccess.value = false; isSuccess.value = false;
@ -44,6 +53,9 @@ const fetchConfig = async () => {
step.fields.forEach((field) => { step.fields.forEach((field) => {
if (field.type === "checkbox") { if (field.type === "checkbox") {
formData[field.key] = []; formData[field.key] = [];
} else if (field.type === "phone_country") {
formData[field.key] = "";
phoneParts[field.key] = { code: "", number: "", flag: "" };
} else { } else {
formData[field.key] = ""; formData[field.key] = "";
} }
@ -54,7 +66,37 @@ const fetchConfig = async () => {
} }
}; };
onMounted(fetchConfig); 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);
}
}
});
const validateStep = (stepIndex) => { const validateStep = (stepIndex) => {
const step = config.value?.steps?.[stepIndex]; const step = config.value?.steps?.[stepIndex];
@ -111,6 +153,37 @@ const validateStep = (stepIndex) => {
stepErrors[field.key] = false; 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)) { if (field.type === "checkbox" && field.levels && Array.isArray(val)) {
val.forEach((optVal) => { val.forEach((optVal) => {
if (!formData[`${field.key}_nivel_${optVal}`]) { if (!formData[`${field.key}_nivel_${optVal}`]) {
@ -186,7 +259,7 @@ const buildPayload = () => {
} }
}); });
}); });
return { formData: { ...formData }, responses }; return { formData: { ...formData }, responses, turnstileToken: turnstileToken.value, honeypot: honeypot.value };
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
@ -225,8 +298,72 @@ const handleSubmit = async () => {
const getFieldError = (key) => stepErrors[key] || false; 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 suggestionsCache = ref({});
const showSuggestions = reactive({}); const showSuggestions = reactive({});
const phoneCountrySearchText = reactive({});
const showCountrySuggestions = reactive({});
const loadSuggestions = async (source) => { const loadSuggestions = async (source) => {
if (suggestionsCache.value[source]) return; if (suggestionsCache.value[source]) return;
@ -448,7 +585,7 @@ const getFieldInputType = (field) => {
:type="getFieldInputType(field)" :type="getFieldInputType(field)"
v-model="formData[field.key]" v-model="formData[field.key]"
:placeholder="tl(field.placeholder || field.label)" :placeholder="tl(field.placeholder || field.label)"
@input="stepErrors[field.key] = false" @input="field.type === 'phone' ? onPhoneInput(field.key, $event) : (stepErrors[field.key] = false)"
/> />
</label> </label>
@ -485,6 +622,53 @@ const getFieldInputType = (field) => {
</label> </label>
</div> </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"> <div v-else-if="field.type === 'autocomplete'" class="relative">
<label <label
class="input w-full bg-white rounded-xl focus-within:outline-2 focus-within:outline-[#22523F]" class="input w-full bg-white rounded-xl focus-within:outline-2 focus-within:outline-[#22523F]"
@ -540,7 +724,7 @@ const getFieldInputType = (field) => {
:name="field.key" :name="field.key"
:value="opt.value" :value="opt.value"
v-model="formData[field.key]" v-model="formData[field.key]"
class="radio radio-sm border-gray-300 checked:border-[#4A8C6F] checked:bg-[#4A8C6F]" class="radio radio-sm border-gray-300 bg-white checked:border-[#4A8C6F] checked:bg-[#4A8C6F]"
@change="stepErrors[field.key] = false" @change="stepErrors[field.key] = false"
/> />
<span class="text-[#1a1a1a] text-sm">{{ tl(opt.label) }}</span> <span class="text-[#1a1a1a] text-sm">{{ tl(opt.label) }}</span>
@ -561,7 +745,7 @@ const getFieldInputType = (field) => {
type="checkbox" type="checkbox"
:value="opt.value" :value="opt.value"
:disabled="currentStep === 0 && !reglamentoScrolled" :disabled="currentStep === 0 && !reglamentoScrolled"
class="checkbox checkbox-sm border-gray-300 checked:border-[#4A8C6F] checked:bg-[#4A8C6F]" class="checkbox checkbox-sm border-gray-300 bg-white checked:border-[#4A8C6F] checked:bg-[#4A8C6F]"
:checked="formData[field.key]?.includes(opt.value)" :checked="formData[field.key]?.includes(opt.value)"
@change="handleCheckboxChange(field.key, opt.value, $event.target.checked)" @change="handleCheckboxChange(field.key, opt.value, $event.target.checked)"
/> />
@ -581,7 +765,7 @@ const getFieldInputType = (field) => {
:name="`${field.key}_nivel_${opt.value}`" :name="`${field.key}_nivel_${opt.value}`"
:value="lvl.value" :value="lvl.value"
v-model="formData[`${field.key}_nivel_${opt.value}`]" v-model="formData[`${field.key}_nivel_${opt.value}`]"
class="radio radio-sm border-gray-300 checked:border-[#4A8C6F] checked:bg-[#4A8C6F]" class="radio radio-sm border-gray-300 bg-white checked:border-[#4A8C6F] checked:bg-[#4A8C6F]"
/> />
<span class="text-[#1a1a1a]">{{ tl(lvl.label) }}</span> <span class="text-[#1a1a1a]">{{ tl(lvl.label) }}</span>
</label> </label>
@ -600,7 +784,7 @@ const getFieldInputType = (field) => {
class="text-xs text-red-500 flex items-center gap-1 mt-0.5" class="text-xs text-red-500 flex items-center gap-1 mt-0.5"
> >
<Icon icon="ph:warning-circle" class="text-sm shrink-0" /> <Icon icon="ph:warning-circle" class="text-sm shrink-0" />
{{ tl("form.required") }} {{ formatErrors[field.key] ? (field.type === "phone_country" ? tl("form.phone_number_invalid") : tl("form.phone_invalid")) : tl("form.required") }}
</p> </p>
</div> </div>
</template> </template>
@ -610,6 +794,19 @@ const getFieldInputType = (field) => {
{{ errorMsg }} {{ errorMsg }}
</div> </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="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 flex-col sm:flex-row sm:justify-between sm:items-center gap-3">

View File

@ -252,6 +252,7 @@
"form.idioma.ing": "Inglés", "form.idioma.ing": "Inglés",
"form.idioma.heb": "Hebreo", "form.idioma.heb": "Hebreo",
"form.idioma.port": "Portugués", "form.idioma.port": "Portugués",
"form.idioma.franc": "Francés",
"form.idioma.otro": "Otro", "form.idioma.otro": "Otro",
"form.idioma_otro": "Otro (especificar)", "form.idioma_otro": "Otro (especificar)",
"form.voluntariado_anterior": "¿Ha realizado voluntariado anteriormente?", "form.voluntariado_anterior": "¿Ha realizado voluntariado anteriormente?",
@ -322,6 +323,9 @@
"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.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.config_error": "Error al cargar el formulario. Intentá de nuevo más tarde.",
"form.si": "Sí", "form.si": "Sí",
"form.no": "No", "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"> <h1 class="text-3xl lg:text-4xl font-bold font-secondary text-[#EBE6D2] text-center mb-4 md:mb-9">
{tl("form.page_title")} {tl("form.page_title")}
</h1> </h1>
<DynamicForm client:load locale={Astro.currentLocale} /> <DynamicForm client:load locale={Astro.currentLocale} turnstileSiteKey={import.meta.env.TURNSTILE_SITE_KEY} />
</div> </div>
</main> </main>
)} )}

View File

@ -1,5 +1,6 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
import { appendRegistrationToSheet } from "../lib/googleSheets"; import { appendRegistrationToSheet } from "../lib/googleSheets";
import { sendEmail } from "../lib/email";
export const prerender = false; export const prerender = false;
const COLUMNS = [ const COLUMNS = [
@ -66,6 +67,34 @@ export const POST: APIRoute = async ({ request }) => {
const body = await request.json(); const body = await request.json();
const formData = body.formData || {}; 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", { const timestamp = new Date().toLocaleString("es-ES", {
timeZone: "America/Puerto_Rico", timeZone: "America/Puerto_Rico",
}); });
@ -75,8 +104,14 @@ export const POST: APIRoute = async ({ request }) => {
const result = await appendRegistrationToSheet(row); 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); return Response.json(result);
} catch (error) { } catch (error) {
console.error("Error en /api/formulario/send:", error);
return Response.json( return Response.json(
{ {
success: false, success: false,

View File

@ -0,0 +1,30 @@
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}`);
}
}