create_form #38

Merged
esteban merged 3 commits from create_form into staging 2026-07-25 02:05:33 +00:00
10 changed files with 1358 additions and 207 deletions
Showing only changes of commit f93f9fd3ce - 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_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
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,10 +5,14 @@ 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,8 +40,7 @@
"required": true,
"options": [
{ "value": "M", "label": "form.sexo.m" },
{ "value": "F", "label": "form.sexo.f" },
{ "value": "Otro", "label": "form.sexo.o" }
{ "value": "F", "label": "form.sexo.f" }
]
},
{ "key": "direccion_completa", "label": "form.direccion", "type": "text", "required": true, "colspan": 2 },
@ -49,8 +48,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": "text", "required": true },
{ "key": "whatsapp", "label": "form.whatsapp", "type": "text" }
{ "key": "telefono", "label": "form.mobile", "type": "phone", "required": true },
{ "key": "whatsapp", "label": "form.whatsapp", "type": "phone_country", "source": "/forms/paises.json" }
]
},
{
@ -78,6 +77,7 @@
{ "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,7 +5,8 @@ import { createTranslator } from "../../i18n";
const props = defineProps({
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);
@ -25,6 +26,12 @@ 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);
@ -32,6 +39,8 @@ 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;
@ -44,6 +53,9 @@ 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: "" };
} else {
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 step = config.value?.steps?.[stepIndex];
@ -111,6 +153,37 @@ 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}`]) {
@ -186,7 +259,7 @@ const buildPayload = () => {
}
});
});
return { formData: { ...formData }, responses };
return { formData: { ...formData }, responses, turnstileToken: turnstileToken.value, honeypot: honeypot.value };
};
const handleSubmit = async () => {
@ -225,6 +298,29 @@ 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 onPhoneCountryCodeChange = (key, code) => {
if (!phoneParts[key]) return;
phoneParts[key].code = code;
formData[key] = code + phoneParts[key].number;
stepErrors[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 suggestionsCache = ref({});
const showSuggestions = reactive({});
@ -448,7 +544,7 @@ const getFieldInputType = (field) => {
:type="getFieldInputType(field)"
v-model="formData[field.key]"
:placeholder="tl(field.placeholder || field.label)"
@input="stepErrors[field.key] = false"
@input="field.type === 'phone' ? onPhoneInput(field.key, $event) : (stepErrors[field.key] = false)"
/>
</label>
@ -485,6 +581,32 @@ const getFieldInputType = (field) => {
</label>
</div>
<div v-else-if="field.type === 'phone_country'" class="relative">
<div class="join w-full">
<select
class="select join-item bg-white border-gray-200 w-[8.5rem] rounded-l-xl text-sm focus:outline-2 focus:outline-[#22523F] grow text-[#1a1a1a]"
:class="{ 'border-red-500': getFieldError(field.key) }"
@change="onPhoneCountryCodeChange(field.key, $event.target.value)"
>
<option value="" selected disabled>{{ tl("form.phone_code_select") }}</option>
<option v-for="item in getSelectOptions(field)" :key="item.value" :value="item.dial_code">
{{ item.flag }} {{ item.dial_code }}
</option>
</select>
<label
class="input join-item rounded-r-xl grow bg-white 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]"
@ -540,7 +662,7 @@ const getFieldInputType = (field) => {
:name="field.key"
:value="opt.value"
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"
/>
<span class="text-[#1a1a1a] text-sm">{{ tl(opt.label) }}</span>
@ -561,7 +683,7 @@ const getFieldInputType = (field) => {
type="checkbox"
:value="opt.value"
: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)"
@change="handleCheckboxChange(field.key, opt.value, $event.target.checked)"
/>
@ -581,7 +703,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 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>
</label>
@ -600,7 +722,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" />
{{ tl("form.required") }}
{{ formatErrors[field.key] ? (field.type === "phone_country" ? tl("form.phone_number_invalid") : tl("form.phone_invalid")) : tl("form.required") }}
</p>
</div>
</template>
@ -610,6 +732,19 @@ 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,6 +252,7 @@
"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?",
@ -322,6 +323,9 @@
"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} />
<DynamicForm client:load locale={Astro.currentLocale} turnstileSiteKey={import.meta.env.TURNSTILE_SITE_KEY} />
</div>
</main>
)}

View File

@ -67,6 +67,34 @@ 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",
});