diff --git a/cloudflare-worker/hubspot-proxy.js b/cloudflare-worker/hubspot-proxy.js index 15bb0c4..c43af1c 100644 --- a/cloudflare-worker/hubspot-proxy.js +++ b/cloudflare-worker/hubspot-proxy.js @@ -129,6 +129,77 @@ export default { } } + // ── action: requestVerification ────────────────────────── + // Génère un token unique, le stocke en KV avec les infos du contact, + // 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. + // Anti-bot : Turnstile vérifié d'abord. + if (action === 'requestVerification') { + if (!body.email) return jsonResponse({ error: 'email requis' }, 400); + + const turnstileOk = await verifyTurnstile(env, body.turnstile_token, request); + if (!turnstileOk) { + return jsonResponse({ ok: false, error: 'Turnstile validation failed' }, 403); + } + + try { + const verToken = crypto.randomUUID().replace(/-/g, ''); + const tokenData = { + firstname : body.firstname || '', + email : body.email.toLowerCase().trim(), + reference_client : body.reference_client || '', + createdAt : new Date().toISOString(), + }; + + if (!env.WELCOME_KV) { + return jsonResponse({ ok: false, error: 'KV not bound' }, 500); + } + // Token valide 24h + await env.WELCOME_KV.put(`verify:${verToken}`, JSON.stringify(tokenData), { + expirationTtl: 60 * 60 * 24, + }); + + await sendVerificationEmail(env, tokenData, verToken); + return jsonResponse({ ok: true }); + } catch (err) { + return jsonResponse({ ok: false, error: err.message }, 500); + } + } + + // ── action: verifyToken ────────────────────────────────── + // Appelé par confirmation.html quand l'utilisateur clique sur + // le lien dans l'email de validation. Lit le token en KV, + // envoie le welcome (avec ref + adresse Paris) via Resend, + // puis supprime le token (one-time use). + if (action === 'verifyToken') { + if (!body.token) return jsonResponse({ error: 'token requis' }, 400); + if (!env.WELCOME_KV) return jsonResponse({ ok: false, error: 'KV not bound' }, 500); + + const key = `verify:${body.token}`; + const raw = await env.WELCOME_KV.get(key); + if (!raw) { + return jsonResponse({ ok: false, error: 'Token invalide ou expiré' }, 404); + } + const tokenData = JSON.parse(raw); + + try { + await sendWelcomeViaResend(env, tokenData); + // Marque le token consommé (gardé 7j pour idempotence en cas de + // double clic du lien, mais avec flag "used") + await env.WELCOME_KV.put(key, JSON.stringify({ ...tokenData, used: true, usedAt: new Date().toISOString() }), { + expirationTtl: 60 * 60 * 24 * 7, + }); + return jsonResponse({ + ok: true, + firstname : tokenData.firstname, + reference_client : tokenData.reference_client, + }); + } catch (err) { + return jsonResponse({ ok: false, error: err.message }, 500); + } + } + // ── action: listSubscriptions (debug : trouver les IDs) ── if (action === 'listSubscriptions') { // Endpoint legacy email/public/v1 nécessite scope content au lieu de @@ -354,6 +425,153 @@ async function sendWelcomeEmail(env, params) { } } +// ============================================================= +// Resend : envoi d'emails (verification + welcome) +// ============================================================= +// Resend est utilisé pour envoyer les emails car il ne nécessite +// pas de template séparé (on construit le HTML directement dans le +// Worker). Free tier : 100 emails/jour, 3000/mois. +// +// Setup requis : +// - env.RESEND_API_KEY = clé API Resend (re_...) +// - env.RESEND_FROM = adresse expéditrice vérifiée chez Resend +// (ex: "MVA Global Fret ") +// Pour test : "onboarding@resend.dev" +// - env.SITE_URL = base URL du site (ex: "https://mva-global-fret.github.io/site-mva-global-fret") + +const RESEND_API = 'https://api.resend.com/emails'; + +async function resendSend(env, { to, subject, html }) { + if (!env.RESEND_API_KEY) { + throw new Error('RESEND_API_KEY env var not set'); + } + const from = env.RESEND_FROM || 'MVA Global Fret '; + + const res = await fetch(RESEND_API, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${env.RESEND_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ from, to: [to], subject, html }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Resend ${res.status}: ${text}`); + } + return res.json(); +} + +async function sendVerificationEmail(env, contact, verToken) { + const siteUrl = env.SITE_URL || 'https://mva-global-fret.github.io/site-mva-global-fret'; + const verifyUrl = `${siteUrl}/confirmation.html?token=${verToken}`; + const firstname = escapeHtml(contact.firstname || ''); + + const html = ` + + +
+
+
MVA GLOBAL FRET
+
Fret Aérien Paris — Antananarivo
+
+
+

Bonjour ${firstname},

+

+ Merci pour votre inscription chez MVA Global Fret ! +

+

+ Pour finaliser votre inscription et recevoir votre numéro de référence client + ainsi que l'adresse de notre dépôt à Paris, cliquez sur le bouton ci-dessous : +

+
+ + ✓ Confirmer mon email + +
+

+ Ce lien est valable 24 heures. Si vous n'êtes pas à l'origine de cette inscription, ignorez simplement cet email. +

+

+ Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :
+ ${verifyUrl} +

+
+
+ © 2026 MVA Global Fret — Tous droits réservés +
+
+ +`; + + return resendSend(env, { + to: contact.email, + subject: 'Confirmez votre inscription chez MVA Global Fret', + html, + }); +} + +async function sendWelcomeViaResend(env, contact) { + const firstname = escapeHtml(contact.firstname || ''); + const ref = escapeHtml(contact.reference_client || ''); + const parisAddrRaw = env.PARIS_DEPOT_ADDRESS || ''; + const parisAddr = escapeHtml(parisAddrRaw).replace(/\n/g, '
'); + + const html = ` + + +
+
+
MVA GLOBAL FRET
+
Fret Aérien Paris — Antananarivo
+
+
+

Bonjour ${firstname},

+

+ Bienvenu(e) chez MVA Global Fret ! Votre email est confirmé, + votre inscription est désormais active. +

+
+

Votre numéro de référence client :

+

${ref}

+

Conservez ce numéro précieusement.

+
+

L'adresse à Paris pour l'envoi de vos colis :

+
+ ${parisAddr} +
+
+

+ 📌 Sur le colis, indiquez votre numéro de référence ${ref} + juste après le nom du destinataire entre parenthèses. +

+
+

+ Pour toute question, contactez-nous :
+ 📧 mvaglobalfret@gmail.com
+ 📞 +33 7 80 97 08 25 (France) — +261 38 49 737 51 (Madagascar) +

+
+
+ © 2026 MVA Global Fret — Tous droits réservés +
+
+ +`; + + return resendSend(env, { + to: contact.email, + subject: `Bienvenue chez MVA Global Fret — Votre référence ${ref}`, + html, + }); +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + // ============================================================= // Cloudflare Turnstile : validation anti-bot // ============================================================= diff --git a/contact.html b/contact.html index daedb2a..99b05a8 100644 --- a/contact.html +++ b/contact.html @@ -76,10 +76,10 @@

Remplissez ce formulaire pour recevoir votre numéro de référence client et l'adresse de dépôt à Paris.

diff --git a/js/confirmation.js b/js/confirmation.js index 0aeba36..dcf78d7 100644 --- a/js/confirmation.js +++ b/js/confirmation.js @@ -1,110 +1,76 @@ // ============================================================ -// MVA Global Fret — Page de confirmation post-double-opt-in +// MVA Global Fret — Page de confirmation post-validation email // ============================================================ -// Cette page est la cible de redirection après que l'utilisateur -// a cliqué sur "Confirmer" dans l'email de validation HubSpot. +// Cette page est la cible du lien dans l'email de validation +// (envoyé par Resend après soumission du formulaire). // -// HubSpot redirige vers : -// https://mva-global-fret.github.io/site-mva-global-fret/confirmation.html?email={contact.email} +// URL : https://mva-global-fret.github.io/site-mva-global-fret/confirmation.html?token=XXX // // Étapes : -// 1. Lire l'email depuis l'URL -// 2. Demander au Cloudflare Worker la fiche du contact (incluant reference_client) -// 3. Envoyer un email de bienvenue via EmailJS contenant la référence -// 4. Afficher la référence à l'écran +// 1. Lire le token depuis l'URL +// 2. POST au Worker avec action 'verifyToken' +// 3. Worker valide le token, envoie le welcome email (avec ref + +// adresse Paris) via Resend, puis renvoie OK +// 4. Page affiche "Inscription confirmée !" // -// Si une étape échoue, on affiche un fallback poli (l'inscription -// reste valide côté HubSpot, c'est juste l'email qui ne part pas). +// Si le token est invalide / expiré : affichage d'un message d'erreur +// avec invitation à contacter le support. // ============================================================ -const WORKER_PROXY_URL = 'https://mva-hubspot-proxy.mvaglobalfret.workers.dev'; -const EMAILJS_PUBLIC_KEY = '8KUlaQ7BDVIbkZRyP'; -const EMAILJS_SERVICE_ID = 'service_aeamo3x'; -const EMAILJS_TEMPLATE_ID = 'template_s1kr2et'; - -// Marqueur localStorage : empêche de relancer l'envoi si l'utilisateur -// recharge la page après confirmation (par sécurité ET pour ne pas -// renvoyer 3 fois le même email). -const STORAGE_KEY_PREFIX = 'mva-confirm-sent-'; +const WORKER_PROXY_URL = 'https://mva-hubspot-proxy.mvaglobalfret.workers.dev'; document.addEventListener('DOMContentLoaded', async () => { - if (typeof emailjs !== 'undefined') { - emailjs.init({ publicKey: EMAILJS_PUBLIC_KEY }); - } + const token = new URLSearchParams(window.location.search).get('token'); - const email = getEmailFromUrl(); - - if (!email) { - // Pas d'email dans l'URL : on affiche quand même un succès générique - // (l'utilisateur a bien confirmé puisqu'il atterrit ici depuis HubSpot) - showSuccess(null); + if (!token) { + showError('Lien de confirmation invalide. Veuillez vérifier votre email ou nous contacter.'); return; } try { - const contact = await fetchContact(email); + const res = await fetch(WORKER_PROXY_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'verifyToken', token }), + }); + const data = await res.json(); - if (!contact) { - // Contact non trouvé via Worker — on affiche succès quand même - showSuccess(null); - return; + if (data.ok) { + showSuccess(data.reference_client || null); + } else { + // Token expiré, déjà utilisé, ou inconnu + showError(data.error === 'Token invalide ou expiré' + ? 'Ce lien de confirmation a expiré ou a déjà été utilisé.' + : 'Une erreur est survenue lors de la confirmation.'); } - - const ref = contact.reference_client || null; - - // Idempotence : un seul email par confirmation - const storageKey = STORAGE_KEY_PREFIX + email.toLowerCase(); - if (!localStorage.getItem(storageKey)) { - await sendWelcomeEmail({ - firstname: contact.firstname || '', - email: email, - reference_client: ref || '', - }); - localStorage.setItem(storageKey, String(Date.now())); - } - - showSuccess(ref); } catch (err) { - console.warn('[confirmation] flow failed:', err); - showError(); + console.warn('[confirmation]', err); + showError('Impossible de joindre le serveur. Vérifiez votre connexion et réessayez.'); } }); -function getEmailFromUrl() { - const params = new URLSearchParams(window.location.search); - const raw = params.get('email'); - if (!raw) return null; - const email = raw.trim().toLowerCase(); - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email : null; -} - -async function fetchContact(email) { - const res = await fetch(WORKER_PROXY_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), - }); - if (!res.ok) throw new Error('Worker error: ' + res.status); - const data = await res.json(); - if (!data.results || data.results.length === 0) return null; - return data.results[0].properties; -} - -async function sendWelcomeEmail(payload) { - if (typeof emailjs === 'undefined') return; - await emailjs.send(EMAILJS_SERVICE_ID, EMAILJS_TEMPLATE_ID, payload); -} - function showSuccess(ref) { - document.getElementById('cardLoading').style.display = 'none'; - document.getElementById('cardSuccess').style.display = ''; - if (ref) { - document.getElementById('refDisplay').textContent = ref; - document.getElementById('refBlock').style.display = ''; + const loading = document.getElementById('cardLoading'); + const success = document.getElementById('cardSuccess'); + if (loading) loading.style.display = 'none'; + if (success) { + success.style.display = ''; + if (ref) { + const refDisplay = document.getElementById('refDisplay'); + const refBlock = document.getElementById('refBlock'); + if (refDisplay) refDisplay.textContent = ref; + if (refBlock) refBlock.style.display = ''; + } } } -function showError() { - document.getElementById('cardLoading').style.display = 'none'; - document.getElementById('cardError').style.display = ''; +function showError(msg) { + const loading = document.getElementById('cardLoading'); + const error = document.getElementById('cardError'); + if (loading) loading.style.display = 'none'; + if (error) { + error.style.display = ''; + const desc = error.querySelector('p'); + if (desc && msg) desc.textContent = msg; + } } diff --git a/js/form-handler.js b/js/form-handler.js index 1d216c9..ffded90 100644 --- a/js/form-handler.js +++ b/js/form-handler.js @@ -302,11 +302,10 @@ function showSuccess(refNumber, clientData) { if (clientData) sendWelcomeEmail(clientData); } -// ── EMAIL DE BIENVENUE ──────────────────────────────────────────────────────── -// Envoyé via le Cloudflare Worker pour que l'adresse Paris ne soit JAMAIS -// présente dans le JS public. Le Worker valide d'abord le token Turnstile -// (anti-bot) puis fait le call EmailJS REST avec le PARIS_DEPOT_ADDRESS -// depuis ses variables d'environnement. +// ── 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 { @@ -314,7 +313,7 @@ async function sendWelcomeEmail(data) { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - action: 'sendWelcomeNow', + action: 'requestVerification', firstname: data.firstname, email: data.email, reference_client: data.reference_client, @@ -322,8 +321,7 @@ async function sendWelcomeEmail(data) { }), }); } catch (err) { - // L'email de bienvenue est un bonus — on ne bloque pas l'inscription si ça échoue - console.warn('Welcome email failed:', err); + console.warn('Verification email failed:', err); } } diff --git a/js/translations.js b/js/translations.js index dcad552..f7b9971 100644 --- a/js/translations.js +++ b/js/translations.js @@ -150,11 +150,11 @@ const translations = { placeholderAdresse: "Adresse complète...", submitBtn: "S'inscrire", submitLoading: "Envoi en cours...", - successTitle: "Inscription réussie !", - successMsg: "Merci ! Votre inscription a bien été enregistrée. Un email de bienvenue avec votre numéro de référence client et l'adresse de dépôt à Paris vient de vous être envoyé.", + successTitle: "Vérifiez votre boîte mail !", + successMsg: "Pour finaliser votre inscription, cliquez sur le lien de confirmation que nous venons de vous envoyer par email. Vous recevrez ensuite votre numéro de référence client et l'adresse de dépôt à Paris.", refLabel: "VOTRE NUMÉRO DE RÉFÉRENCE CLIENT", successNote: "Conservez ce numéro précieusement — il vous sera utile pour suivre vos colis.", - emailSent: "📧 Vérifiez votre boîte mail (et vos spams).", + emailSent: "📧 Email envoyé — pensez à vérifier vos spams. Le lien expire dans 24h.", alreadyTitle: "Vous êtes déjà client !", alreadyMsg: "Votre adresse email est déjà enregistrée dans notre système.", alreadyRefLabel: "VOTRE NUMÉRO DE RÉFÉRENCE EXISTANT",