Architecture finale :
1. User remplit formulaire + passe Turnstile CAPTCHA → form-handler.js
2. form-handler.js POST au Worker avec action 'requestVerification'
3. Worker valide Turnstile, génère un token UUID, le stocke en KV (TTL 24h)
avec firstname/email/reference_client, puis envoie un email via Resend
avec un lien : confirmation.html?token=XXX
4. User reçoit email, clique 'Confirmer mon email'
5. confirmation.html lit le token de l'URL, POST au Worker avec action
'verifyToken'
6. Worker valide le token, envoie le welcome email via Resend (avec ref +
adresse Paris depuis env var), marque le token comme utilisé
7. confirmation.html affiche 'Inscription confirmée !'
Ainsi : ref + adresse Paris ne sortent JAMAIS avant validation email,
et les bots sont bloqués à l'étape 1 par Turnstile.
Setup Cloudflare requis (côté user) :
- RESEND_API_KEY : clé API Resend (re_...)
- RESEND_FROM : adresse expéditrice ('onboarding@resend.dev' pour test,
ou domain vérifié pour prod)
- SITE_URL : optionnel, défaut https://mva-global-fret.github.io/site-mva-global-fret
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
384 lines
15 KiB
JavaScript
384 lines
15 KiB
JavaScript
// ============================================
|
|
// MVA Global Fret — Form Handler
|
|
// HubSpot Portal ID : 148163754
|
|
// HubSpot Form GUID : 1d9b75c9-8b60-4966-aa18-4bf503452e9a
|
|
// ============================================
|
|
|
|
const HUBSPOT_PORTAL_ID = '148163754';
|
|
const HUBSPOT_FORM_GUID = '1d9b75c9-8b60-4966-aa18-4bf503452e9a';
|
|
const FORMSPREE_ID = 'mojrvokp';
|
|
|
|
// ── EMAILJS (email de bienvenue au client) ────────────────────────────────────
|
|
const EMAILJS_PUBLIC_KEY = '8KUlaQ7BDVIbkZRyP';
|
|
const EMAILJS_SERVICE_ID = 'service_aeamo3x';
|
|
const EMAILJS_TEMPLATE_ID = 'template_s1kr2et';
|
|
// Template pour les clients déjà inscrits ("Ravis de te revoir")
|
|
// ⚠️ À créer dans EmailJS puis remplacer la valeur ci-dessous
|
|
const EMAILJS_TEMPLATE_WELCOME_BACK = 'template_welcome_back';
|
|
|
|
// Initialisation EmailJS (une seule fois au chargement)
|
|
if (typeof emailjs !== 'undefined') {
|
|
emailjs.init({ publicKey: EMAILJS_PUBLIC_KEY });
|
|
}
|
|
|
|
// ── PROXY CLOUDFLARE WORKER ───────────────────────────────────────────────────
|
|
// URL du Worker qui proxifie l'API HubSpot CRM (contourne le CORS).
|
|
// Après déploiement du Worker (voir cloudflare-worker/hubspot-proxy.js),
|
|
// remplacer la chaîne vide par l'URL obtenue, ex :
|
|
// 'https://mva-hubspot-proxy.moncompte.workers.dev'
|
|
// Tant que cette constante est vide, la vérification doublon est désactivée
|
|
// (le formulaire s'envoie normalement — aucun blocage).
|
|
const WORKER_PROXY_URL = 'https://mva-hubspot-proxy.mvaglobalfret.workers.dev';
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const form = document.getElementById('contactForm');
|
|
if (form) setupContactForm(form);
|
|
});
|
|
|
|
// Génération séquentielle via le Worker HubSpot : MVA-001, MVA-002, etc.
|
|
// Fallback sur un timestamp court si le Worker est indisponible.
|
|
async function generateRefNumber() {
|
|
if (WORKER_PROXY_URL) {
|
|
try {
|
|
const res = await fetch(WORKER_PROXY_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action: 'nextRef' }),
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.nextRef) return data.nextRef;
|
|
}
|
|
} catch { /* fallback ci-dessous */ }
|
|
}
|
|
// Fallback : numéro aléatoire court pour éviter les doublons en cas d'indisponibilité
|
|
const rand = String(Math.floor(Math.random() * 900) + 100);
|
|
return `MVA-F${rand}`;
|
|
}
|
|
|
|
// Vérifie si l'email existe déjà dans HubSpot via le proxy Cloudflare Worker.
|
|
// Retourne les propriétés du contact existant, ou null si nouveau client / proxy non configuré.
|
|
async function checkExistingContact(email) {
|
|
// Si le proxy n'est pas encore déployé, on laisse passer sans bloquer
|
|
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 {
|
|
// Erreur réseau ou Worker indisponible : on laisse passer
|
|
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 ──────────────────────────────────────────────────
|
|
// Si le client existe déjà, on affiche uniquement un message informatif.
|
|
// On ne soumet RIEN à HubSpot — la référence existante n'est JAMAIS modifiée.
|
|
const existing = await checkExistingContact(email);
|
|
if (existing) {
|
|
setLoading(false);
|
|
showAlreadyRegistered(existing);
|
|
return; // ← arrêt complet, aucune soumission
|
|
}
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
// Nouveau client : génération du numéro de référence unique
|
|
const refNumber = await generateRefNumber();
|
|
|
|
const data = {
|
|
firstname: form.firstname.value.trim(),
|
|
lastname: form.lastname.value.trim(),
|
|
phone: form.phone.value.trim(),
|
|
email: email,
|
|
address: form.address.value.trim(),
|
|
reference_client: refNumber,
|
|
};
|
|
|
|
const results = await Promise.allSettled([
|
|
submitToHubSpot(data),
|
|
submitToFormspree(data),
|
|
]);
|
|
|
|
const hubspotOk = results[0].status === 'fulfilled';
|
|
const formspreeOk = results[1].status === 'fulfilled';
|
|
|
|
setLoading(false);
|
|
|
|
if (hubspotOk || formspreeOk) {
|
|
showSuccess(refNumber, data);
|
|
} else {
|
|
showError();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── SOUMISSION HUBSPOT ────────────────────────────────────────────────────────
|
|
async function submitToHubSpot(data) {
|
|
const payload = {
|
|
fields: [
|
|
{ name: 'firstname', value: data.firstname },
|
|
{ name: 'lastname', value: data.lastname },
|
|
{ name: 'phone', value: data.phone },
|
|
{ name: 'email', value: data.email },
|
|
{ name: 'address', value: data.address },
|
|
{ name: 'reference_client', value: data.reference_client },
|
|
],
|
|
context: {
|
|
pageUri: window.location.href,
|
|
pageName: document.title,
|
|
},
|
|
};
|
|
|
|
const res = await fetch(
|
|
`https://api.hsforms.com/submissions/v3/integration/submit/${HUBSPOT_PORTAL_ID}/${HUBSPOT_FORM_GUID}`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
}
|
|
);
|
|
|
|
if (!res.ok) throw new Error(`HubSpot error: ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
// ── SOUMISSION FORMSPREE (email de backup) ────────────────────────────────────
|
|
async function submitToFormspree(data) {
|
|
if (FORMSPREE_ID === 'YOUR_FORMSPREE_ID') return;
|
|
|
|
const res = await fetch(`https://formspree.io/f/${FORMSPREE_ID}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify({
|
|
nom: data.lastname,
|
|
prenom: data.firstname,
|
|
telephone: data.phone,
|
|
email: data.email,
|
|
adresse_livraison: data.address,
|
|
reference_client: data.reference_client,
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) throw new Error(`Formspree error: ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
// ── NOTIFICATION DOUBLON (email interne seulement, sans toucher aux données) ──
|
|
async function notifyDuplicateViaFormspree(contact) {
|
|
if (FORMSPREE_ID === 'YOUR_FORMSPREE_ID') return;
|
|
try {
|
|
await fetch(`https://formspree.io/f/${FORMSPREE_ID}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
body: JSON.stringify({
|
|
_subject: `[MVA] Tentative double inscription — ${contact.firstname || ''} ${contact.lastname || ''}`,
|
|
message: `Le client ${contact.firstname || ''} ${contact.lastname || ''} (${contact.email}) a tenté de s'inscrire à nouveau. Référence existante : ${contact.reference_client || 'non définie'}.`,
|
|
}),
|
|
});
|
|
} catch { /* Ne pas bloquer l'interface si la notification échoue */ }
|
|
}
|
|
|
|
// ── 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';
|
|
|
|
// Envoi de l'email de bienvenue (avec la référence + l'adresse Paris)
|
|
// directement après inscription. Les bots qui soumettent avec un faux
|
|
// email ne reçoivent rien (boîte inexistante = bounce). Pour bloquer
|
|
// les bots qui utilisent de vrais emails, voir la protection honeypot
|
|
// dans validateForm() + le rate limit côté worker.
|
|
if (clientData) sendWelcomeEmail(clientData);
|
|
}
|
|
|
|
// ── EMAIL DE VÉRIFICATION ─────────────────────────────────────────────────────
|
|
// Le Worker génère un token unique, le stocke en KV, et envoie un email
|
|
// de validation via Resend (avec un lien de confirmation). Le contact ne
|
|
// reçoit la référence et l'adresse Paris qu'APRÈS avoir cliqué sur le lien.
|
|
async function sendWelcomeEmail(data) {
|
|
if (!WORKER_PROXY_URL) return;
|
|
try {
|
|
await fetch(WORKER_PROXY_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
action: 'requestVerification',
|
|
firstname: data.firstname,
|
|
email: data.email,
|
|
reference_client: data.reference_client,
|
|
turnstile_token: window.turnstileToken || '',
|
|
}),
|
|
});
|
|
} catch (err) {
|
|
console.warn('Verification email failed:', err);
|
|
}
|
|
}
|
|
|
|
// ── EMAIL "RAVIS DE TE REVOIR" (client déjà inscrit) ──────────────────────────
|
|
// Rappelle au client son numéro de référence existant — n'écrit RIEN dans HubSpot.
|
|
async function sendWelcomeBackEmail(contact) {
|
|
if (typeof emailjs === 'undefined') return;
|
|
if (!contact || !contact.email) return;
|
|
try {
|
|
await emailjs.send(EMAILJS_SERVICE_ID, EMAILJS_TEMPLATE_WELCOME_BACK, {
|
|
firstname: contact.firstname || '',
|
|
email: contact.email,
|
|
reference_client: contact.reference_client || '',
|
|
});
|
|
} catch (err) {
|
|
// Si le template n'existe pas encore ou erreur réseau : on n'interrompt rien
|
|
console.warn('EmailJS welcome-back email failed:', err);
|
|
}
|
|
}
|
|
|
|
// Affiche le message "déjà client" — ne modifie AUCUNE donnée HubSpot
|
|
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';
|
|
|
|
// Envoi d'une notification interne à MVA (sans modifier les données du client)
|
|
notifyDuplicateViaFormspree(contact);
|
|
|
|
// Envoi d'un email "Ravis de te revoir" au client avec son n° de référence
|
|
sendWelcomeBackEmail(contact);
|
|
}
|
|
|
|
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.';
|
|
}
|
|
}
|