site-mva-global-fret/js/form-handler.js
serge 2774c25a61
Some checks are pending
Deploy site to GitHub Pages / deploy (push) Waiting to run
chore: post-review cleanup (3 Important + dead code purge -116 lines) (#10)
2026-05-07 18:37:10 +03:00

289 lines
10 KiB
JavaScript

// ============================================
// MVA Global Fret — Form Handler
// ============================================
// Frontend logic for contact.html (= inscription form):
// - validate inputs + Cloudflare Turnstile token
// - call Cloudflare Worker mva-hubspot-proxy for HubSpot dedup +
// sending verification email (= action requestVerification) or
// "Ravis de vous revoir" email for returning customers (=
// action sendWelcomeBack)
// - reset Turnstile widget after each Worker call (= tokens are
// single-use server-side; without reset, a re-submit silently
// 403s from Cloudflare's siteverify endpoint)
//
// All HubSpot/Resend transactions go through the Worker. No direct
// EmailJS / Formspree / HubSpot Forms API calls from the browser.
// ============================================
// ── PROXY CLOUDFLARE WORKER ──────────────────────────────────────
// Worker URL (= deployed via wrangler from cloudflare-worker/).
// CORS Access-Control-Allow-Origin: * so the browser can call it
// directly.
const WORKER_PROXY_URL = 'https://mva-hubspot-proxy.sergemind4s.workers.dev';
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('contactForm');
if (form) setupContactForm(form);
});
// ── TURNSTILE TOKEN MANAGEMENT ───────────────────────────────────
// Reset the Turnstile widget + global token after each Worker call.
// Cloudflare Turnstile tokens are single-use server-side: a token
// already submitted to siteverify cannot be re-used. Without an
// explicit reset, a re-submit (= same form, same widget) would send
// the now-consumed token and Cloudflare would 403 silently.
function resetTurnstile() {
window.turnstileToken = null;
if (window.turnstile && typeof window.turnstile.reset === 'function') {
try { window.turnstile.reset(); } catch (_) { /* widget absent */ }
}
}
// Vérifie si l'email existe déjà dans HubSpot via le proxy Worker.
// Retourne les propriétés du contact existant, ou null si nouveau
// client / Worker indisponible.
async function checkExistingContact(email) {
if (!WORKER_PROXY_URL) return null;
try {
const res = await fetch(WORKER_PROXY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.toLowerCase().trim() }),
});
if (!res.ok) return null;
const data = await res.json();
return data.total > 0 ? data.results[0].properties : null;
} catch {
return null;
}
}
function setupContactForm(form) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!validateForm(form)) return;
// ── VÉRIFICATION TURNSTILE (CAPTCHA anti-bot) ────────────────
if (!window.turnstileToken) {
const errEl = document.getElementById('formErrorGlobal');
if (errEl) {
errEl.style.display = 'block';
errEl.textContent = '🤖 Veuillez compléter le contrôle anti-robot ci-dessus avant d\'envoyer le formulaire.';
}
return;
}
setLoading(true);
const email = form.email.value.trim();
// ── VÉRIFICATION DOUBLON ─────────────────────────────────────
// Comme les contacts ne sont créés QU'APRÈS confirmation email,
// ce check ne retourne que les vrais clients déjà inscrits.
const existing = await checkExistingContact(email);
if (existing) {
setLoading(false);
showAlreadyRegistered(existing);
return;
}
const data = {
firstname: form.firstname.value.trim(),
lastname: form.lastname.value.trim(),
phone: form.phone.value.trim(),
email: email,
address: form.address.value.trim(),
};
// ── ENVOI VERS LE WORKER ─────────────────────────────────────
// Le Worker stocke les données en KV (24h) et envoie un email de
// validation via Resend. Le contact n'est créé dans HubSpot QUE
// quand l'utilisateur clique sur le lien de confirmation
// (anti-pollution du CRM).
let ok = false;
try {
const res = await fetch(WORKER_PROXY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'requestVerification',
...data,
turnstile_token: window.turnstileToken || '',
}),
});
const result = await res.json().catch(() => ({}));
ok = res.ok && result.ok === true;
} catch (err) {
console.warn('[requestVerification]', err);
}
// Reset Turnstile after the Worker call (= regardless of result)
resetTurnstile();
setLoading(false);
if (ok) {
showSuccess(null, data);
} else {
showError();
}
});
}
// ── VALIDATION ───────────────────────────────────────────────────
function validateForm(form) {
let valid = true;
const lang = localStorage.getItem('mva-lang') || 'fr';
const t = translations?.[lang]?.contact || {};
const requiredMsg = t.required || 'Ce champ est obligatoire';
const invalidEmail = t.invalidEmail || 'Adresse email invalide';
const invalidPhone = t.invalidPhone || 'Numéro de téléphone invalide';
const fields = ['firstname', 'lastname', 'phone', 'email', 'address'];
fields.forEach(name => clearError(name));
clearError('cgv');
fields.forEach(name => {
const el = form[name];
if (!el.value.trim()) {
showFieldError(name, requiredMsg);
valid = false;
}
});
const cgvBox = form['cgv'];
if (cgvBox && !cgvBox.checked) {
showFieldError('cgv', t.cgvRequired || 'Vous devez accepter les Conditions Générales de Vente.');
valid = false;
}
if (form.email.value.trim() && !isValidEmail(form.email.value.trim())) {
showFieldError('email', invalidEmail);
valid = false;
}
if (form.phone.value.trim() && !isValidPhone(form.phone.value.trim())) {
showFieldError('phone', invalidPhone);
valid = false;
}
return valid;
}
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function isValidPhone(phone) {
return /^[+\d][\d\s\-().]{6,20}$/.test(phone);
}
// ── AFFICHAGE ────────────────────────────────────────────────────
function showFieldError(name, msg) {
const el = document.getElementById(`error-${name}`);
const input = document.getElementById(name) || document.querySelector(`[name="${name}"]`);
if (el) el.textContent = msg;
if (input) input.classList.add('error');
}
function clearError(name) {
const el = document.getElementById(`error-${name}`);
const input = document.getElementById(name) || document.querySelector(`[name="${name}"]`);
if (el) el.textContent = '';
if (input) input.classList.remove('error');
}
function setLoading(isLoading) {
const btn = document.getElementById('submitBtn');
const txt = document.getElementById('submitText');
const form = document.getElementById('contactForm');
const lang = localStorage.getItem('mva-lang') || 'fr';
const t = translations?.[lang]?.contact || {};
if (!btn) return;
btn.disabled = isLoading;
if (txt) {
txt.textContent = isLoading
? (t.submitLoading || 'Envoi en cours...')
: (t.submitBtn || "S'inscrire");
}
form?.classList.toggle('form-loading', isLoading);
}
function showSuccess(_refNumber, _clientData) {
const successEl = document.getElementById('formSuccess');
const form = document.getElementById('contactForm');
if (successEl) {
successEl.classList.add('show');
successEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
if (form) form.style.display = 'none';
}
// ── EMAIL "RAVIS DE VOUS REVOIR" (client déjà inscrit) ───────────
// Rappelle au client son numéro de référence existant — n'écrit
// RIEN dans HubSpot. Passe par le Cloudflare Worker (action:
// sendWelcomeBack) qui délègue à Resend. Anti-bot via Turnstile :
// transmet le token déjà validé au moment du submit du formulaire.
async function sendWelcomeBackEmail(contact) {
if (!WORKER_PROXY_URL) return;
if (!contact || !contact.email) return;
if (!window.turnstileToken) return;
try {
await fetch(WORKER_PROXY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action : 'sendWelcomeBack',
email : contact.email,
firstname : contact.firstname || '',
turnstile_token : window.turnstileToken,
}),
});
} catch (err) {
// Erreur réseau : on n'interrompt pas l'UX (le client voit
// déjà sa référence dans le UI).
console.warn('Worker sendWelcomeBack failed:', err);
}
}
// Affiche le message "déjà client" — ne modifie AUCUNE donnée HubSpot
async function showAlreadyRegistered(contact) {
const lang = localStorage.getItem('mva-lang') || 'fr';
const t = translations?.[lang]?.contact || {};
const alreadyEl = document.getElementById('alreadyRegistered');
const form = document.getElementById('contactForm');
const refDisplay = document.getElementById('existingRefDisplay');
// Affiche la référence existante si disponible, sinon message de contact
if (refDisplay) {
refDisplay.textContent = contact.reference_client
? contact.reference_client
: (t.alreadyRefUnknown || 'Contactez-nous pour retrouver votre référence.');
}
if (alreadyEl) {
alreadyEl.classList.add('show');
alreadyEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
if (form) form.style.display = 'none';
// Email "Ravis de vous revoir" via Worker + Resend (= footer 2026
// cohérent avec les autres emails transactionnels).
await sendWelcomeBackEmail(contact);
// Reset Turnstile after the Worker call (= prevent reuse).
resetTurnstile();
}
function showError() {
const errEl = document.getElementById('formErrorGlobal');
const lang = localStorage.getItem('mva-lang') || 'fr';
const t = translations?.[lang]?.contact || {};
if (errEl) {
errEl.style.display = 'block';
errEl.textContent = t.errorMsg || 'Une erreur est survenue. Veuillez réessayer ou nous contacter directement.';
}
}