Compare commits

..

1 Commits

Author SHA1 Message Date
Serge RAKOTO HARRY-NAIVO
dd23a46603 chore: post-review cleanup (3 Important fixes + dead code purge)
Addresses the 3 Important issues + 4 follow-ups flagged by the
final code review subagent.

## Important fixes

- **getNextRef pagination** (cloudflare-worker/hubspot-proxy.js):
  previously used HubSpot search with limit:100 without pagination,
  causing reference-number collisions once contact count exceeded
  100 (= search results don't guarantee ordering by ref). Now
  paginates through all results via paging.next.after cursor to
  find the true numeric maximum. ~10 API calls for 1000 contacts.

- **Turnstile reset after Worker calls** (js/form-handler.js):
  Cloudflare Turnstile tokens are single-use server-side. Without
  explicit reset, a re-submit would 403 silently from siteverify.
  New helper resetTurnstile() clears window.turnstileToken and
  calls window.turnstile.reset() to force a fresh challenge. Called
  after both requestVerification and sendWelcomeBack flows.

- **notifyDuplicateViaFormspree removed** (js/form-handler.js):
  was actively POSTing returning customers' name+email to Formspree
  (FORMSPREE_ID='mojrvokp' = real endpoint, sentinel guard never
  triggered). PII leak to a third-party service inconsistent with
  the new Resend-only architecture. Function + call site removed.

## Dead code purge

- form-handler.js: removed generateRefNumber (= dead, no callers),
  submitToHubSpot, submitToFormspree, notifyDuplicateViaFormspree
  functions. Removed constants HUBSPOT_PORTAL_ID, HUBSPOT_FORM_GUID,
  FORMSPREE_ID, EMAILJS_PUBLIC_KEY, EMAILJS_SERVICE_ID,
  EMAILJS_TEMPLATE_ID, EMAILJS_TEMPLATE_WELCOME_BACK. Removed
  emailjs.init() block. Net -111 lines.

- contact.html + confirmation.html: removed <script> tag loading
  EmailJS browser SDK from jsDelivr (~30KB gzipped per page).

- cloudflare-worker/hubspot-proxy.js: removed unused HUBSPOT_PORTAL_ID
  + HUBSPOT_FORM_GUID constants. Removed listSubscriptions and
  subscribe action handlers (= 0 callers in frontend, debug-only,
  reachable by unauthenticated POST without Turnstile guard).

- cloudflare-worker/DEPLOIEMENT.md: KV ID note updated to reflect
  the actual ID in wrangler.toml.

## Net diff

-116 lines (= 92 added, 208 removed across 4 files).

Refs: post-cutover polish, addresses code review 2026-05-07.
2026-05-07 17:36:25 +02:00
21 changed files with 965 additions and 465 deletions

View File

@ -27,7 +27,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -47,9 +47,8 @@
<a href="accueil.html" data-i18n="nav.home">Accueil</a> <a href="accueil.html" data-i18n="nav.home">Accueil</a>
<a href="about.html" data-i18n="nav.about">Qui sommes-nous</a> <a href="about.html" data-i18n="nav.about">Qui sommes-nous</a>
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -209,7 +208,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -235,7 +234,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js?v=20260603"></script> <script src="js/translations.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@ -31,7 +31,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -54,7 +54,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -230,7 +230,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
<li><a href="application.html" data-i18n="nav.app">Prochainement</a></li> <li><a href="application.html" data-i18n="nav.app">Prochainement</a></li>
</ul> </ul>
</div> </div>
@ -269,7 +269,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js?v=20260603"></script> <script src="js/translations.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@ -25,7 +25,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Application</a> <a href="application.html" data-i18n="nav.app">Application</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -43,9 +43,8 @@
<a href="accueil.html" data-i18n="nav.home">Accueil</a> <a href="accueil.html" data-i18n="nav.home">Accueil</a>
<a href="about.html" data-i18n="nav.about">Qui sommes-nous</a> <a href="about.html" data-i18n="nav.about">Qui sommes-nous</a>
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Application</a> <a href="application.html" data-i18n="nav.app">Application</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -279,7 +278,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -305,7 +304,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js?v=20260603"></script> <script src="js/translations.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
<script> <script>
document.getElementById('notifyForm')?.addEventListener('submit', function(e) { document.getElementById('notifyForm')?.addEventListener('submit', function(e) {

View File

@ -65,7 +65,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -83,9 +83,8 @@
<a href="accueil.html" data-i18n="nav.home">Accueil</a> <a href="accueil.html" data-i18n="nav.home">Accueil</a>
<a href="about.html" data-i18n="nav.about">Qui sommes-nous</a> <a href="about.html" data-i18n="nav.about">Qui sommes-nous</a>
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -320,7 +319,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
<li><a href="cgv.html" style="color:rgba(255,255,255,0.7);" data-i18n="footer.cgv">Conditions Générales de Vente</a></li> <li><a href="cgv.html" style="color:rgba(255,255,255,0.7);" data-i18n="footer.cgv">Conditions Générales de Vente</a></li>
</ul> </ul>
</div> </div>
@ -347,7 +346,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js?v=20260603"></script> <script src="js/translations.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>

1
cloudflare-worker/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.wrangler/

View File

@ -0,0 +1,149 @@
# Déploiement Cloudflare Worker — Double opt-in MVA via Resend
Ce Worker gère le flow double opt-in du formulaire de contact :
1. **`requestVerification`** — génère un token, stocke les données du formulaire en KV, envoie un email de validation via Resend.
2. **`verifyToken`** — appelé quand le client clique sur le lien dans l'email. Crée le contact dans HubSpot (avec une référence à la volée) et envoie le welcome email avec la référence + l'adresse du dépôt Paris.
Le Worker est aussi un proxy HubSpot pour la vérification de doublon par email et la génération du prochain numéro de référence séquentiel.
L'email de bienvenue contient le **numéro de référence client** ET **l'adresse du dépôt à Paris**. Ces infos ne sont **jamais** envoyées avant validation de l'email — protection contre les bots et les expéditions de cartons vides.
---
## Étapes de déploiement (Phase D du plan WordPress → static)
### 1. Mettre à jour le code du Worker
Préférer `wrangler` :
```bash
cd cloudflare-worker
wrangler deploy
```
Ou via le dashboard Cloudflare :
1. Aller sur https://dash.cloudflare.com/
2. Workers & Pages → cliquer sur **`mva-hubspot-proxy`**
3. Onglet **Modifier le code** (ou *Quick edit*)
4. Tout sélectionner et remplacer par le contenu de `cloudflare-worker/hubspot-proxy.js`
5. Cliquer **Déployer / Save and deploy**
### 2. Secrets
Dans **Paramètres → Variables et secrets** (ou via CLI : `wrangler secret put <name>`) :
| Nom | Valeur |
|-----|--------|
| `HUBSPOT_TOKEN` | `pat-eu1-...` (scope read+write contacts) |
| `RESEND_API_KEY` | `re_...` (compte Resend partagé avec m4s-auth) |
| `RESEND_FROM_EMAIL` | adresse expéditrice (domaine vérifié chez Resend) |
| `RESEND_FROM_NAME` | nom affiché à l'expéditeur (ex: `MVA Global Fret`) |
| `PARIS_DEPOT_ADDRESS` | **Ton adresse exacte à Paris** (rue, code postal, ville, étage, nom à indiquer sur le carton…) |
| `TURNSTILE_SECRET` | secret Cloudflare Turnstile (anti-bot) |
| `SITE_URL` | base URL du site (ex: `https://mva-globalfret.com`) |
> `PARIS_DEPOT_ADDRESS` est l'info la plus sensible — c'est l'adresse à protéger. Elle ne quitte jamais Cloudflare/Resend et n'arrive au client que dans le mail de bienvenue, qui n'est envoyé qu'aux contacts qui ont confirmé leur email.
### 3. Stockage KV (idempotence — empêche de spammer le welcome)
Dans **Paramètres → Stockage et bases de données → Bindings KV** :
1. Cliquer **Ajouter un binding**
2. Variable name : **`WELCOME_KV`**
3. Namespace : **Créer** un nouveau namespace nommé `mva-welcome-tracker`
4. Sauvegarder
Si déployé via `wrangler` : `wrangler.toml` contient déjà l'ID du namespace KV (`c02656ba22064923ab1c6db06b0f4a56` sur le compte CF `sergemind4s@gmail.com`). Pour un autre compte, recréer le namespace via `wrangler kv namespace create WELCOME_KV` puis remplacer l'ID dans `wrangler.toml`.
### 4. Vérifier le scope HubSpot
Le token HubSpot doit avoir :
- `crm.objects.contacts.read`
- `crm.objects.contacts.write`
- `crm.lists.read`
Si tu obtiens des erreurs 403, regénère le token sur https://app-eu1.hubspot.com/private-apps/148163754/
### 5. Resend — vérifier le domaine expéditeur
Le compte Resend (partagé avec m4s-auth) doit avoir le domaine de `RESEND_FROM_EMAIL` vérifié (DNS records SPF + DKIM). Voir https://resend.com/domains.
---
## Test manuel
Une fois tout déployé, tu peux tester le flow `requestVerification` (token de test, ne pas réutiliser en prod) :
```bash
curl -X POST https://mva-hubspot-proxy.<account>.workers.dev \
-H "Content-Type: application/json" \
-d '{
"action": "requestVerification",
"firstname": "Test",
"lastname": "User",
"email": "test@example.com",
"phone": "+33000000000",
"address": "Test address",
"turnstile_token": "<token from Turnstile widget>"
}'
```
Réponse attendue :
```json
{ "ok": true }
```
Le client reçoit alors un email de validation. En cliquant sur le lien, il déclenche `verifyToken`, qui crée le contact dans HubSpot et envoie le welcome email avec la référence + l'adresse Paris.
---
## Logs en production
Cloudflare → ton Worker → **Logs (en temps réel)** : tu verras chaque exécution du Worker.
---
## En cas de problème
| Symptôme | Cause probable | Fix |
|---|---|---|
| `Resend 401: API key invalid` | Mauvaise valeur dans `RESEND_API_KEY` | Re-vérifier la clé sur https://resend.com/api-keys |
| `Resend 422: domain is not verified` | Domaine de `RESEND_FROM_EMAIL` pas vérifié | Vérifier le domaine sur https://resend.com/domains |
| `HubSpot 403` | Token n'a pas le scope write | Regénérer token avec scope `contacts.write` |
| `HubSpot 401` | Token invalide / expiré | Regénérer un Private App token |
| `KV not bound` | Binding `WELCOME_KV` pas créé | Vérifier *Storage & Databases → KV bindings* |
| `Turnstile validation failed` | Secret côté Worker ne match pas la sitekey côté front | Re-vérifier `TURNSTILE_SECRET` et la sitekey HTML |
---
## Architecture
```
┌──────────────────────────────────────────────────────────────────┐
│ 1. User remplit le formulaire sur contact.html │
│ ↓ │
│ 2. form-handler.js → POST action: requestVerification │
│ ↓ │
│ 3. Worker valide Turnstile, génère un token, stocke les │
│ données du formulaire en KV (TTL 24h), envoie l'email │
│ de validation via Resend │
│ ↓ │
│ ─────── User clique sur « Confirmer » ─────── │
│ ↓ │
│ 4. confirmation.html → POST action: verifyToken │
│ ↓ │
│ 5. Worker récupère les données du KV, génère la prochaine │
│ référence client, soumet via HubSpot Forms API (= contact │
│ créé dans le CRM), envoie le welcome email via Resend │
│ (avec la référence + adresse Paris), marque le token │
│ consommé en KV (TTL 7j pour idempotence). │
│ ↓ │
│ 6. Le client reçoit son welcome email avec sa référence ET │
│ l'adresse de dépôt à Paris. │
└──────────────────────────────────────────────────────────────────┘
```
**Conclusion** : il est impossible pour un bot de récupérer la référence client ou l'adresse Paris sans avoir au préalable un email valide ET cliqué sur le lien de confirmation. Anti-spam blindé.

View File

@ -0,0 +1,675 @@
// ============================================================
// MVA Global Fret — Cloudflare Worker : Proxy HubSpot + double opt-in via Resend
// ============================================================
// Ce Worker gère le formulaire de contact via un flow double opt-in :
//
// 1) requestVerification : génère un token, stocke les données du formulaire en KV,
// envoie un email de validation (lien de confirmation) via Resend.
//
// 2) verifyToken : appelé quand le client clique sur le lien de confirmation.
// Crée le contact dans HubSpot (avec une référence générée à la volée),
// puis envoie le welcome email avec la référence + l'adresse du dépôt Paris.
// Idempotent : un 2ème clic ne re-crée pas de contact ni ne renvoie d'email.
//
// La référence client + l'adresse du dépôt à Paris ne fuitent jamais avant
// validation de l'email — protection anti-bot et anti-cartons-vides.
//
// ============================================================
// DÉPLOIEMENT (Phase D du plan WordPress → static)
// ============================================================
//
// Voir cloudflare-worker/DEPLOIEMENT.md pour la procédure complète.
//
// Secrets requis (`wrangler secret put <name>`) :
// • HUBSPOT_TOKEN = pat-eu1-... (read+write contacts)
// • RESEND_API_KEY = re_... (compte Resend partagé avec m4s-auth)
// • RESEND_FROM_EMAIL = adresse expéditrice (domaine vérifié chez Resend)
// • RESEND_FROM_NAME = nom affiché à l'expéditeur
// • PARIS_DEPOT_ADDRESS = adresse complète du dépôt Paris
// • TURNSTILE_SECRET = secret Cloudflare Turnstile (anti-bot)
// • SITE_URL = base URL du site (ex: "https://mva-globalfret.com")
//
// Bindings KV requis :
// • WELCOME_KV → namespace `mva-welcome-tracker` (idempotence verifyToken)
//
// ============================================================
const HUBSPOT_API = 'https://api.hubapi.com';
const corsHeaders = {
'Access-Control-Allow-Origin' : '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
export default {
// -----------------------------------------------------------
// 1) Handler navigateur (POST depuis le formulaire / page de confirmation)
// -----------------------------------------------------------
async fetch(request, env) {
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
const token = env.HUBSPOT_TOKEN;
if (!token) {
return jsonResponse({ error: 'HUBSPOT_TOKEN env var not set' }, 500);
}
try {
const body = await request.json();
const { email, action } = body;
// ── action: nextRef ─────────────────────────────────────
if (action === 'nextRef') {
return jsonResponse({ nextRef: await getNextRef(token) });
}
// ── action: requestVerification ──────────────────────────
// Génère un token unique, stocke TOUTES les données du formulaire en KV,
// et envoie un email de validation via Resend. Le contact n'est créé
// dans HubSpot QU'APRÈS clic sur le lien de confirmation (anti-spam :
// les inscriptions non vérifiées ne polluent pas le CRM).
// 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 || '',
lastname : body.lastname || '',
phone : body.phone || '',
email : body.email.toLowerCase().trim(),
address : body.address || '',
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. C'est ICI que le contact
// est CRÉÉ dans HubSpot (avec une référence générée à la volée),
// puis le welcome email est envoyé (ref + adresse Paris).
// Idempotent : un 2ème clic ne re-crée pas de contact.
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);
// Idempotence : si déjà consommé, retourne le résultat précédent
// sans recréer le contact ni renvoyer d'email.
if (tokenData.used) {
return jsonResponse({
ok: true,
firstname : tokenData.firstname,
reference_client : tokenData.reference_client || '',
});
}
try {
// 1) Récupère la ref existante si le contact est déjà dans HubSpot
// (réinscription après suppression d'un test, ou création via
// l'ancien flow Forms API). Sinon génère la prochaine ref.
let refNumber;
try {
const existing = await searchContactByEmail(token, tokenData.email);
const existingResult = (existing.results || [])[0];
const existingRef = existingResult?.properties?.reference_client;
refNumber = existingRef || await getNextRef(token);
} catch (_) {
// Si la search échoue (scope manquant, etc.), fallback : génère
// une nouvelle ref. Le Forms API gérera la dédup côté HubSpot.
refNumber = await getNextRef(token);
}
// 2) Création directe via CRM API (= more deterministic que Forms API
// qui peut accepter une submission sans réellement créer le contact
// à cause des filtres anti-spam ou de la config du Form HubSpot).
// Requires scope crm.objects.contacts.write.
// En cas de 409 (contact déjà existant), fallback sur PATCH par ID
// pour update les propriétés (= notamment reference_client).
const crmRes = await fetch(
`${HUBSPOT_API}/crm/v3/objects/contacts`,
{
method: 'POST',
headers: {
'Content-Type' : 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
properties: {
firstname : tokenData.firstname || '',
lastname : tokenData.lastname || '',
phone : tokenData.phone || '',
email : tokenData.email,
address : tokenData.address || '',
reference_client : refNumber,
},
}),
}
);
if (crmRes.status === 409) {
// Contact existe déjà — update via PATCH par ID
const search = await searchContactByEmail(token, tokenData.email);
const existing = (search.results || [])[0];
if (existing?.id) {
const patchRes = await fetch(
`${HUBSPOT_API}/crm/v3/objects/contacts/${existing.id}`,
{
method: 'PATCH',
headers: {
'Content-Type' : 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
properties: {
firstname : tokenData.firstname || existing.properties?.firstname || '',
lastname : tokenData.lastname || existing.properties?.lastname || '',
phone : tokenData.phone || existing.properties?.phone || '',
address : tokenData.address || '',
reference_client : refNumber,
},
}),
}
);
if (!patchRes.ok) {
const errTxt = await patchRes.text();
throw new Error(`HubSpot CRM patch failed ${patchRes.status}: ${errTxt.slice(0, 200)}`);
}
}
} else if (!crmRes.ok) {
const errTxt = await crmRes.text();
throw new Error(`HubSpot CRM create failed ${crmRes.status}: ${errTxt.slice(0, 200)}`);
}
// 3) Envoie le welcome email avec ref + adresse Paris
const welcomeContact = { ...tokenData, reference_client: refNumber };
await sendWelcomeViaResend(env, welcomeContact);
// 4) Marque le token consommé (gardé 7j pour idempotence)
await env.WELCOME_KV.put(key, JSON.stringify({
...tokenData,
used : true,
usedAt : new Date().toISOString(),
reference_client : refNumber,
}), {
expirationTtl: 60 * 60 * 24 * 7,
});
return jsonResponse({
ok: true,
firstname : tokenData.firstname,
reference_client : refNumber,
});
} catch (err) {
return jsonResponse({ ok: false, error: err.message }, 500);
}
}
// ── action: sendWelcomeBack ─────────────────────────────
// Envoie un email "Vous êtes déjà inscrit" au client qui tente
// une ré-inscription. Idempotent côté HubSpot (= aucune création
// ni update de contact). Anti-bot via Turnstile + sanity check
// que l'email existe vraiment dans HubSpot avant d'envoyer.
if (action === 'sendWelcomeBack') {
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 {
// Vérification : le contact existe bien (= prevent spam vers
// emails inconnus en passant un faux turnstile)
const search = await searchContactByEmail(token, body.email);
const existing = (search.results || [])[0];
if (!existing) {
return jsonResponse({ ok: false, error: 'Contact not found' }, 404);
}
await sendWelcomeBackViaResend(env, {
firstname : body.firstname || existing.properties?.firstname || '',
email : body.email,
reference_client : existing.properties?.reference_client || '',
});
return jsonResponse({ ok: true });
} catch (err) {
return jsonResponse({ ok: false, error: err.message }, 500);
}
}
// ── action par défaut : vérification doublon par email ──
if (!email || typeof email !== 'string') {
return jsonResponse({ error: 'Email requis' }, 400);
}
const data = await searchContactByEmail(token, email);
return jsonResponse(data);
} catch (err) {
return jsonResponse({ error: err.message }, 500);
}
},
};
// =============================================================
// HubSpot : recherches & lectures
// =============================================================
async function searchContactByEmail(token, email) {
const res = await fetch(`${HUBSPOT_API}/crm/v3/objects/contacts/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
filterGroups: [{
filters: [{ propertyName: 'email', operator: 'EQ', value: email.toLowerCase().trim() }],
}],
properties: ['firstname', 'lastname', 'email', 'reference_client'],
}),
});
if (!res.ok) {
throw new Error(`HubSpot lookup failed: ${res.status}`);
}
return res.json();
}
async function getNextRef(token) {
// Paginate through ALL HubSpot contacts with `reference_client` property to
// find the true numeric maximum. Previous version used `limit: 100` without
// pagination — produced collisions once the contact count exceeded 100
// because HubSpot search results don't guarantee ordering by ref. With
// pagination, we walk the full set: 100 per page × N pages until no more.
// For ~1000 contacts = 10 API calls. Acceptable cost given that this runs
// once per signup confirmation (= rare path).
let maxNum = 0;
let after; // undefined on first iteration
do {
const body = {
filterGroups: [{
filters: [{ propertyName: 'reference_client', operator: 'HAS_PROPERTY' }],
}],
properties: ['reference_client'],
limit: 100,
};
if (after) body.after = after;
const res = await fetch(`${HUBSPOT_API}/crm/v3/objects/contacts/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`HubSpot search failed: ${res.status}`);
}
const data = await res.json();
(data.results || []).forEach(c => {
const m = (c.properties?.reference_client || '').match(/^MVA-(\d+)$/);
if (m) {
const n = parseInt(m[1], 10);
if (n > maxNum) maxNum = n;
}
});
after = data.paging?.next?.after;
} while (after);
return 'MVA-' + String(maxNum + 1).padStart(3, '0');
}
// =============================================================
// Resend : envoi d'emails transactionnels (verification + welcome)
// =============================================================
// Aligné avec m4s-auth (Phase 2.1) qui utilise déjà Resend en production.
// Le compte Resend (et le domaine vérifié) sont partagés entre m4s-auth et
// ce Worker — un seul fournisseur SMTP pour tout Mind4Solutions.
//
// Setup requis (`wrangler secret put <name>`) :
// - env.RESEND_API_KEY = clé API Resend (re_...)
// - env.RESEND_FROM_EMAIL = adresse expéditrice (domaine vérifié chez Resend)
// - env.RESEND_FROM_NAME = nom affiché à l'expéditeur (ex: "MVA Global Fret")
// - env.SITE_URL = base URL du site (ex: "https://mva-globalfret.com")
//
// API doc : https://resend.com/docs/api-reference/emails/send-email
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 fromEmail = env.RESEND_FROM_EMAIL || 'noreply@mva-globalfret.com';
const fromName = env.RESEND_FROM_NAME || 'MVA Global Fret';
const res = await fetch(RESEND_API, {
method: 'POST',
headers: {
'Content-Type' : 'application/json',
'Authorization': `Bearer ${env.RESEND_API_KEY}`,
},
body: JSON.stringify({
from : `${fromName} <${fromEmail}>`,
to : [to],
subject: subject,
html : 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-globalfret.com';
const verifyUrl = `${siteUrl}/confirmation.html?token=${verToken}`;
const logoUrl = `${siteUrl}/PNG%20MVA%20GLOBAL%20FRET.png`;
const firstname = escapeHtml(contact.firstname || '');
const html = `<!DOCTYPE html>
<html lang="fr">
<body style="margin:0;padding:0;font-family:Arial,sans-serif;background:#f5f5f5;">
<div style="max-width:600px;margin:0 auto;background:#fff;">
<div style="background:#1a1a3e;padding:24px 30px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="width:100%;border-collapse:collapse;">
<tr>
<td style="width:80px;vertical-align:middle;">
<img src="${logoUrl}" alt="MVA Global Fret" style="display:block;width:70px;height:auto;border:0;">
</td>
<td style="vertical-align:middle;text-align:center;padding-right:80px;">
<div style="color:#c5a55a;font-size:24px;font-weight:700;letter-spacing:2px;">MVA GLOBAL FRET</div>
<div style="color:#fff;font-size:13px;margin-top:6px;">Fret Aérien Paris Antananarivo</div>
</td>
</tr>
</table>
</div>
<div style="padding:40px;">
<p style="font-size:18px;color:#1a1a3e;font-weight:bold;">Bonjour ${firstname},</p>
<p style="color:#333;line-height:1.6;">
Merci pour votre inscription chez <strong>MVA Global Fret</strong> !
</p>
<p style="color:#333;line-height:1.6;">
Pour finaliser votre inscription et recevoir votre <strong>numéro de référence client</strong>
ainsi que <strong>l'adresse de notre dépôt à Paris</strong>, cliquez sur le bouton ci-dessous :
</p>
<div style="text-align:center;margin:32px 0;">
<a href="${verifyUrl}" style="display:inline-block;background:#c5a55a;color:#1a1a3e;padding:16px 40px;border-radius:50px;text-decoration:none;font-weight:700;font-size:16px;letter-spacing:0.5px;">
Confirmer mon email
</a>
</div>
<p style="color:#666;font-size:13px;line-height:1.6;">
Ce lien est valable <strong>24 heures</strong>. Si vous n'êtes pas à l'origine de cette inscription, ignorez simplement cet email.
</p>
<p style="color:#666;font-size:12px;line-height:1.6;border-top:1px solid #eee;padding-top:18px;margin-top:30px;">
Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br>
<span style="color:#c5a55a;word-break:break-all;">${verifyUrl}</span>
</p>
</div>
<div style="background:#1a1a3e;padding:18px;text-align:center;color:#c5a55a;font-size:12px;">
© 2026 MVA Global Fret Tous droits réservés
</div>
</div>
</body>
</html>`;
return resendSend(env, {
to: contact.email,
subject: 'Confirmez votre inscription chez MVA Global Fret',
html,
});
}
async function sendWelcomeViaResend(env, contact) {
const siteUrl = env.SITE_URL || 'https://mva-globalfret.com';
const logoUrl = `${siteUrl}/PNG%20MVA%20GLOBAL%20FRET.png`;
const firstname = escapeHtml(contact.firstname || '');
const ref = escapeHtml(contact.reference_client || '');
const refRaw = contact.reference_client || '';
// Format adresse Paris : la 1ère ligne (nom du destinataire) reçoit
// automatiquement la référence client entre parenthèses, comme ça
// le client a directement la bonne forme à recopier sur son colis.
// Support aussi un placeholder {{ref}} si présent dans l'env var.
let parisAddrRaw = env.PARIS_DEPOT_ADDRESS || '';
if (parisAddrRaw.includes('{{ref}}')) {
parisAddrRaw = parisAddrRaw.replace(/\{\{ref\}\}/g, refRaw);
} else if (refRaw && parisAddrRaw) {
const lines = parisAddrRaw.split('\n');
lines[0] = `${lines[0]} (${refRaw})`;
parisAddrRaw = lines.join('\n');
}
const parisAddr = escapeHtml(parisAddrRaw).replace(/\n/g, '<br>');
const html = `<!DOCTYPE html>
<html lang="fr">
<body style="margin:0;padding:0;font-family:Arial,sans-serif;background:#f5f5f5;">
<div style="max-width:600px;margin:0 auto;background:#fff;">
<div style="background:#1a1a3e;padding:24px 30px;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="width:100%;border-collapse:collapse;">
<tr>
<td style="width:80px;vertical-align:middle;">
<img src="${logoUrl}" alt="MVA Global Fret" style="display:block;width:70px;height:auto;border:0;">
</td>
<td style="vertical-align:middle;text-align:center;padding-right:80px;">
<div style="color:#c5a55a;font-size:24px;font-weight:700;letter-spacing:2px;">MVA GLOBAL FRET</div>
<div style="color:#fff;font-size:13px;margin-top:6px;">Fret Aérien Paris Antananarivo</div>
</td>
</tr>
</table>
</div>
<div style="padding:40px;">
<p style="font-size:18px;color:#1a1a3e;font-weight:bold;">Bonjour ${firstname},</p>
<p style="color:#333;line-height:1.6;">
Bienvenu(e) chez <strong>MVA Global Fret</strong> ! Votre email est confirmé,
votre inscription est désormais active.
</p>
<div style="background:#f0ead8;border-left:4px solid #c5a55a;padding:16px 20px;margin:24px 0;border-radius:4px;">
<p style="margin:0;color:#1a1a3e;font-size:14px;">Votre numéro de référence client :</p>
<p style="margin:8px 0 0;color:#1a1a3e;font-size:22px;font-weight:bold;letter-spacing:2px;">${ref}</p>
<p style="margin:6px 0 0;color:#666;font-size:12px;">Conservez ce numéro précieusement.</p>
</div>
<p style="color:#333;margin-top:28px;"><strong>L'adresse à Paris pour l'envoi de vos colis :</strong></p>
<div style="background:#f9f9f9;border:1px solid #ddd;padding:20px 24px;border-radius:6px;margin:12px 0;font-family:monospace;font-size:15px;line-height:1.8;color:#1a1a3e;">
${parisAddr}
</div>
<div style="background:#fff3cd;border:1px solid #ffc107;padding:14px 18px;border-radius:6px;margin:16px 0;">
<p style="margin:0 0 8px;color:#856404;font-size:14px;font-weight:bold;">
Important : ne modifiez rien à ces informations.
</p>
<p style="margin:0;color:#856404;font-size:14px;line-height:1.5;">
Recopiez l'adresse <strong>exactement telle qu'elle est indiquée ci-dessus</strong>,
sans rien retirer ni ajouter. Votre numéro de référence <strong>${ref}</strong>
fait partie intégrante de l'adresse — c'est ce qui garantit que votre colis nous arrive bien.
</p>
</div>
<p style="color:#333;line-height:1.6;">
Pour toute question, contactez-nous :<br>
📧 mvaglobalfret@gmail.com<br>
📞 +33 7 80 97 08 25 (France) +261 38 49 737 51 (Madagascar)
</p>
</div>
<div style="background:#1a1a3e;padding:18px;text-align:center;color:#c5a55a;font-size:12px;">
© 2026 MVA Global Fret Tous droits réservés
</div>
</div>
</body>
</html>`;
return resendSend(env, {
to: contact.email,
subject: `Bienvenue chez MVA Global Fret — Votre référence ${ref}`,
html,
});
}
// Email "Ravis de vous revoir" pour les clients déjà inscrits qui retentent
// le formulaire de contact. Reprend EXACTEMENT le template original (= avant
// migration EmailJS \xe2\x86\x92 Resend) car son contenu est strat\xc3\xa9gique \xe2\x80\x94 rappel
// adresse Paris + warning anti-modification + r\xe9f\xe9rence client. Seules
// modifications : footer (c) 2025 \xe2\x86\x92 \xc2\xa9 2026, suppression du tag
// "Email sent via EmailJS.com" (obsol\xe8te depuis Resend), URL logo
// pointe vers le nouveau domaine, et adresse Paris injecte la ref via
// le placeholder {{ref}} de PARIS_DEPOT_ADDRESS.
//
// Idempotent c\xf4t\xe9 HubSpot (= z\xe9ro write).
async function sendWelcomeBackViaResend(env, contact) {
const siteUrl = env.SITE_URL || 'https://mva-globalfret.com';
const logoUrl = `${siteUrl}/PNG%20MVA%20GLOBAL%20FRET.png`;
const firstnameRaw = contact.firstname || '';
const firstname = escapeHtml(firstnameRaw);
const refRaw = contact.reference_client || '';
const ref = escapeHtml(refRaw);
// Construction adresse Paris (= m\xeame logique que sendWelcomeViaResend) :
// injecte la ref client soit via placeholder {{ref}}, soit en l'ajoutant
// entre parenth\xe8ses sur la 1\xe8re ligne (= pattern original "VASTA Mélissa (MVA-XXX)").
let parisAddrRaw = env.PARIS_DEPOT_ADDRESS || '';
if (parisAddrRaw.includes('{{ref}}')) {
parisAddrRaw = parisAddrRaw.replace(/\{\{ref\}\}/g, refRaw);
} else if (refRaw && parisAddrRaw) {
const lines = parisAddrRaw.split('\n');
lines[0] = `${lines[0]} (${refRaw})`;
parisAddrRaw = lines.join('\n');
}
// 1\xe8re ligne en gras (= match original `<strong>VASTA Melissa (MVA-XXX)</strong>`)
const addrLines = escapeHtml(parisAddrRaw).split('\n');
const parisAddrHtml = addrLines.length > 1
? `<strong>${addrLines[0]}</strong><br>${addrLines.slice(1).join('<br>')}`
: escapeHtml(parisAddrRaw);
const greetingTitle = firstnameRaw
? `Ravis de vous revoir, ${firstname} !`
: 'Ravis de vous revoir !';
const html = `<html lang=""><body><div style="font-family:Arial,sans-serif;font-size:16px;background-color:#f5f5f5;padding:20px">
<div style="max-width:600px;margin:auto;background-color:#ffffff;border-radius:8px;overflow:hidden">
<div style="background-color:#1a1a3e;padding:30px 40px;text-align:center">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"><tr><td width="145" style="padding:15px 0 15px 20px;vertical-align:middle"><img src="${logoUrl}" width="130" height="130" alt="MVA" style="display:block;"></td><td style="text-align:center;padding:15px 75px 15px 0;vertical-align:middle"><div style="color:#c5a55a;font-size:22px;font-weight:700;letter-spacing:2px;font-family:Arial,sans-serif">MVA GLOBAL FRET</div><div style="color:#ffffff;font-size:12px;margin-top:4px;font-family:Arial,sans-serif">Fret Aerien Paris - Antananarivo</div></td></tr></table>
</div>
<div style="padding:40px">
<p style="color:#1a1a3e;font-size:22px;font-weight:bold;margin-top:0">${greetingTitle}</p>
<p style="color:#333333">Nous avons bien recu votre nouvelle tentative d&#39;inscription. Pas d&#39;inquietude : vous etes <strong>deja client</strong> chez MVA Global Fret !</p>
<p style="color:#333333">Voici un rappel de votre numero de reference client :</p>
<div style="background-color:#f0ead8;border-left:4px solid #c5a55a;padding:16px 20px;margin:24px 0;border-radius:4px;text-align:center">
<p style="margin:0;color:#1a1a3e;font-size:14px;letter-spacing:1px">VOTRE NUMERO DE REFERENCE CLIENT</p>
<p style="margin:8px 0 0 0;color:#1a1a3e;font-size:28px;font-weight:bold;letter-spacing:2px">${ref}</p>
<p style="margin:6px 0 0 0;color:#666666;font-size:12px">Conservez ce numero precieusement.</p>
</div>
<p style="color:#333333;margin-top:28px"><strong>L&#39;adresse a Paris pour l&#39;envoi de vos colis est :</strong></p>
<div style="background-color:#f9f9f9;border:1px solid #dddddd;padding:20px 24px;border-radius:6px;margin:12px 0 24px 0;font-family:monospace;font-size:15px;line-height:1.8;color:#1a1a3e">
${parisAddrHtml}
</div>
<div style="background-color:#fff3cd;border:1px solid #ffc107;padding:16px 20px;border-radius:6px;margin:24px 0">
<p style="margin:0;color:#856404;font-size:14px"><strong>IMPORTANT :</strong> Cette adresse ne doit etre changee sous aucun pretexte. Toute modification empecherait la bonne transmission de votre colis a notre depot a Paris.</p>
</div>
<p style="color:#333333">Pour toute question, n&#39;hesitez pas a nous contacter :</p>
<ul style="color:#333333;line-height:2">
<li><a href="mailto:mvaglobalfret@gmail.com" style="color:#c5a55a">mvaglobalfret@gmail.com</a></li>
<li><a href="tel:+33780970825" style="color:#c5a55a">+33 7 80 97 08 25</a> (France)</li>
<li><a href="tel:+261384973751" style="color:#c5a55a">+261 38 49 737 51</a> (Madagascar)</li>
</ul>
<p style="color:#333333;margin-top:32px">A tres bientot pour votre prochain envoi,<br><strong>L&#39;equipe MVA Global Fret</strong></p>
</div>
<div style="background-color:#1a1a3e;color:rgba(255,255,255,0.6);padding:16px;text-align:center;font-size:12px">
(c) 2026 MVA Global Fret - Antananarivo 101, Madagascar
</div>
</div>
</div></body></html>`;
// Subject : reprend strictement le sujet original "Ravis de vous revoir, [firstname] !"
// (= en cas de firstname vide, fallback sans virgule).
const subjectFirstname = firstnameRaw.replace(/[\r\n]/g, '').trim();
return resendSend(env, {
to: contact.email,
subject: subjectFirstname
? `Ravis de vous revoir, ${subjectFirstname} !`
: 'Ravis de vous revoir !',
html,
});
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
// =============================================================
// Cloudflare Turnstile : validation anti-bot
// =============================================================
// Reçoit le token généré côté client (window.turnstileToken) et
// l'envoie à l'API Cloudflare avec le secret pour validation.
// Renvoie true uniquement si Cloudflare confirme que c'est un
// utilisateur humain.
async function verifyTurnstile(env, token, request) {
if (!token) return false;
if (!env.TURNSTILE_SECRET) {
// En dev / si pas configuré, on laisse passer (à durcir en prod)
console.warn('TURNSTILE_SECRET not set, skipping validation');
return true;
}
const ip = request.headers.get('CF-Connecting-IP') || '';
const formData = new FormData();
formData.append('secret', env.TURNSTILE_SECRET);
formData.append('response', token);
if (ip) formData.append('remoteip', ip);
try {
const res = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
body: formData,
});
const data = await res.json();
return data.success === true;
} catch (err) {
console.warn('Turnstile verification error:', err);
return false;
}
}
// =============================================================
// Helpers
// =============================================================
function jsonResponse(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}

View File

@ -0,0 +1,19 @@
name = "mva-hubspot-proxy"
main = "hubspot-proxy.js"
compatibility_date = "2026-04-01"
workers_dev = true
# KV namespace — placeholder ID, populated at deploy time (Phase D3)
[[kv_namespaces]]
binding = "WELCOME_KV"
id = "c02656ba22064923ab1c6db06b0f4a56"
# Required secrets to configure via `wrangler secret put` :
# - HUBSPOT_TOKEN
# - RESEND_API_KEY
# - RESEND_FROM_EMAIL
# - RESEND_FROM_NAME
# - PARIS_DEPOT_ADDRESS
# - TURNSTILE_SECRET
# - SITE_URL
# (BREVO_* and EMAILJS_* deprecated — removed in this version)

View File

@ -172,7 +172,7 @@
© 2026 MVA Global Fret. Tous droits réservés. © 2026 MVA Global Fret. Tous droits réservés.
</footer> </footer>
<script src="js/translations.js?v=20260603"></script> <script src="js/translations.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
<script src="js/confirmation.js"></script> <script src="js/confirmation.js"></script>
</body> </body>

View File

@ -25,7 +25,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -43,9 +43,8 @@
<a href="accueil.html" data-i18n="nav.home">Accueil</a> <a href="accueil.html" data-i18n="nav.home">Accueil</a>
<a href="about.html" data-i18n="nav.about">Qui sommes-nous</a> <a href="about.html" data-i18n="nav.about">Qui sommes-nous</a>
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -55,8 +54,8 @@
<!-- HERO --> <!-- HERO -->
<section class="hero hero-sub" style="background-image: url('images/hero/contact-hero.jpg');"> <section class="hero hero-sub" style="background-image: url('images/hero/contact-hero.jpg');">
<div class="hero-content animate-on-scroll"> <div class="hero-content animate-on-scroll">
<h1 data-i18n="contact.heroTitle">Inscrivez-vous</h1> <h1 data-i18n="contact.heroTitle">Contactez-Nous</h1>
<p data-i18n="contact.heroSubtitle">Commencez à envoyer vos colis dès aujourd'hui</p> <p data-i18n="contact.heroSubtitle">Inscrivez-vous et commencez à envoyer vos colis dès aujourd'hui</p>
</div> </div>
</section> </section>
@ -280,7 +279,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -306,7 +305,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js?v=20260603"></script> <script src="js/translations.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
<script src="js/form-handler.js"></script> <script src="js/form-handler.js"></script>

View File

@ -25,7 +25,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -43,9 +43,8 @@
<a href="accueil.html" data-i18n="nav.home">Accueil</a> <a href="accueil.html" data-i18n="nav.home">Accueil</a>
<a href="about.html" data-i18n="nav.about">Qui sommes-nous</a> <a href="about.html" data-i18n="nav.about">Qui sommes-nous</a>
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -73,7 +72,7 @@
<div class="prohibited-item"> <div class="prohibited-item">
<i class="fa-solid fa-bomb" aria-hidden="true"></i> <i class="fa-solid fa-bomb" aria-hidden="true"></i>
<h4 data-i18n="guide.cat1Title">Explosifs</h4> <h4 data-i18n="guide.cat1Title">Explosifs</h4>
<p data-i18n="guide.cat1Desc">Dynamite, munitions, feux d'artifice, pétards, armes à feu</p> <p data-i18n="guide.cat1Desc">Dynamite, munitions, feux d'artifice, pétards</p>
</div> </div>
<div class="prohibited-item"> <div class="prohibited-item">
<i class="fa-solid fa-fire-flame-curved" aria-hidden="true"></i> <i class="fa-solid fa-fire-flame-curved" aria-hidden="true"></i>
@ -98,7 +97,7 @@
<div class="prohibited-item"> <div class="prohibited-item">
<i class="fa-solid fa-skull-crossbones" aria-hidden="true"></i> <i class="fa-solid fa-skull-crossbones" aria-hidden="true"></i>
<h4 data-i18n="guide.cat6Title">Substances toxiques</h4> <h4 data-i18n="guide.cat6Title">Substances toxiques</h4>
<p data-i18n="guide.cat6Desc">Poisons, pesticides, substances infectieuses, substances illégales</p> <p data-i18n="guide.cat6Desc">Poisons, pesticides, substances infectieuses</p>
</div> </div>
<div class="prohibited-item"> <div class="prohibited-item">
<i class="fa-solid fa-flask" aria-hidden="true"></i> <i class="fa-solid fa-flask" aria-hidden="true"></i>
@ -261,7 +260,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -287,7 +286,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js?v=20260603"></script> <script src="js/translations.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@ -62,7 +62,7 @@
</main> </main>
<script src="js/translations.js?v=20260603"></script> <script src="js/translations.js"></script>
<script> <script>
/* i18n minimal ------------------------------------------------------- */ /* i18n minimal ------------------------------------------------------- */
(function () { (function () {

View File

@ -1,27 +1,23 @@
// ============================================================ // ============================================================
// MVA Global Fret — Page de confirmation post-validation email // MVA Global Fret — Page de confirmation post-validation email
// ============================================================ // ============================================================
// Cette page est la cible du lien dans l'email de validation envoyé // Cette page est la cible du lien dans l'email de validation
// par mva-api (= Resend) lors d'une inscription via contact.html. // (envoyé par Brevo après soumission du formulaire).
// //
// URL : https://mva-globalfret.com/confirmation.html?token=XXX // URL : https://mva-global-fret.github.io/site-mva-global-fret/confirmation.html?token=XXX
// //
// Étapes : // Étapes :
// 1. Lire le token depuis l'URL // 1. Lire le token depuis l'URL
// 2. POST mva-api /leads/verify-token avec { token } // 2. POST au Worker avec action 'verifyToken'
// 3. mva-api INSERT le lead en DB, génère la ref MVA-NNN, envoie le // 3. Worker valide le token, envoie le welcome email (avec ref +
// welcome email (= ref + adresse Paris) via Resend, puis renvoie // adresse Paris) via Brevo, puis renvoie OK
// { ok: true, firstname, reference_client } // 4. Page affiche "Inscription confirmée !"
// 4. Page affiche "Inscription confirmée !" + la ref
// //
// Si le token est invalide / expiré / déjà consommé : affichage d'un // Si le token est invalide / expiré : affichage d'un message d'erreur
// message d'erreur avec invitation à contacter le support. // avec invitation à contacter le support.
//
// Migration 2026-05-10 : remplace l'ancien Cloudflare Worker
// `mva-hubspot-proxy.sergemind4s.workers.dev` (= décommissionné).
// ============================================================ // ============================================================
const API_BASE_URL = 'https://api.mva.mind4solutions.com'; const WORKER_PROXY_URL = 'https://mva-hubspot-proxy.sergemind4s.workers.dev';
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
const token = new URLSearchParams(window.location.search).get('token'); const token = new URLSearchParams(window.location.search).get('token');
@ -32,20 +28,18 @@ document.addEventListener('DOMContentLoaded', async () => {
} }
try { try {
const res = await fetch(`${API_BASE_URL}/leads/verify-token`, { const res = await fetch(WORKER_PROXY_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }), body: JSON.stringify({ action: 'verifyToken', token }),
}); });
const data = await res.json(); const data = await res.json();
if (data.ok) { if (data.ok) {
showSuccess(data.reference_client || null); showSuccess(data.reference_client || null);
} else { } else {
// Token invalide, expiré, ou inconnu // Token expiré, déjà utilisé, ou inconnu
const isInvalid = data.code === 'INVALID_OR_EXPIRED' showError(data.error === 'Token invalide ou expiré'
|| data.message === 'Token invalide ou expiré';
showError(isInvalid
? 'Ce lien de confirmation a expiré ou a déjà été utilisé.' ? 'Ce lien de confirmation a expiré ou a déjà été utilisé.'
: 'Une erreur est survenue lors de la confirmation.'); : 'Une erreur est survenue lors de la confirmation.');
} }

View File

@ -3,21 +3,23 @@
// ============================================ // ============================================
// Frontend logic for contact.html (= inscription form): // Frontend logic for contact.html (= inscription form):
// - validate inputs + Cloudflare Turnstile token // - validate inputs + Cloudflare Turnstile token
// - call mva-api /leads/* routes for dedup check + double opt-in flow // - call Cloudflare Worker mva-hubspot-proxy for HubSpot dedup +
// (= verification email + welcome / welcome-back emails via Resend) // sending verification email (= action requestVerification) or
// - reset Turnstile widget after each API call (= tokens are // "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 // single-use server-side; without reset, a re-submit silently
// 403s from Cloudflare's siteverify endpoint) // 403s from Cloudflare's siteverify endpoint)
// //
// Migration 2026-05-10 : remplace l'ancien Cloudflare Worker // All HubSpot/Resend transactions go through the Worker. No direct
// `mva-hubspot-proxy.sergemind4s.workers.dev` (= décommissionné) par // EmailJS / Formspree / HubSpot Forms API calls from the browser.
// les routes mva-api Fastify. La DB Postgres remplace HubSpot Contacts.
// ============================================ // ============================================
// ── MVA API BASE URL ───────────────────────────────────────────── // ── PROXY CLOUDFLARE WORKER ──────────────────────────────────────
// Routes leads servies par mva-api derrière Caddy. CORS strict : // Worker URL (= deployed via wrangler from cloudflare-worker/).
// le serveur whitelist explicitement https://mva-globalfret.com. // CORS Access-Control-Allow-Origin: * so the browser can call it
const API_BASE_URL = 'https://api.mva.mind4solutions.com'; // directly.
const WORKER_PROXY_URL = 'https://mva-hubspot-proxy.sergemind4s.workers.dev';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('contactForm'); const form = document.getElementById('contactForm');
@ -37,24 +39,20 @@ function resetTurnstile() {
} }
} }
// Vérifie si l'email existe déjà dans la table leads via mva-api. // Vérifie si l'email existe déjà dans HubSpot via le proxy Worker.
// Retourne les propriétés du lead existant, ou null si nouveau // Retourne les propriétés du contact existant, ou null si nouveau
// client / API indisponible. // client / Worker indisponible.
async function checkExistingContact(email) { async function checkExistingContact(email) {
if (!WORKER_PROXY_URL) return null;
try { try {
const res = await fetch(`${API_BASE_URL}/leads/check-email`, { const res = await fetch(WORKER_PROXY_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.toLowerCase().trim() }), body: JSON.stringify({ email: email.toLowerCase().trim() }),
}); });
if (!res.ok) return null; if (!res.ok) return null;
const data = await res.json(); const data = await res.json();
if (!data.exists) return null; return data.total > 0 ? data.results[0].properties : null;
// Forme attendue par showAlreadyRegistered : { firstname, reference_client }
return {
firstname: data.firstname || '',
reference_client: data.reference_client || '',
};
} catch { } catch {
return null; return null;
} }
@ -97,17 +95,18 @@ function setupContactForm(form) {
address: form.address.value.trim(), address: form.address.value.trim(),
}; };
// ── ENVOI VERS MVA-API ──────────────────────────────────────── // ── ENVOI VERS LE WORKER ─────────────────────────────────────
// L'API stocke les données en `leads_pending` (24h TTL) et envoie un // Le Worker stocke les données en KV (24h) et envoie un email de
// email de validation via Resend. Le lead n'est INSERT en `leads` QUE // validation via Resend. Le contact n'est créé dans HubSpot QUE
// quand l'utilisateur clique sur le lien de confirmation // quand l'utilisateur clique sur le lien de confirmation
// (anti-pollution DB + anti-bot complémentaire à Turnstile). // (anti-pollution du CRM).
let ok = false; let ok = false;
try { try {
const res = await fetch(`${API_BASE_URL}/leads/request-verification`, { const res = await fetch(WORKER_PROXY_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
action: 'requestVerification',
...data, ...data,
turnstile_token: window.turnstileToken || '', turnstile_token: window.turnstileToken || '',
}), }),
@ -222,26 +221,29 @@ function showSuccess(_refNumber, _clientData) {
} }
// ── EMAIL "RAVIS DE VOUS REVOIR" (client déjà inscrit) ─────────── // ── EMAIL "RAVIS DE VOUS REVOIR" (client déjà inscrit) ───────────
// Rappelle au client son numéro de référence existant — zéro write DB. // Rappelle au client son numéro de référence existant — n'écrit
// Passe par mva-api /leads/welcome-back qui délègue à Resend. // RIEN dans HubSpot. Passe par le Cloudflare Worker (action:
// Anti-bot via Turnstile : transmet le token déjà validé au moment du // sendWelcomeBack) qui délègue à Resend. Anti-bot via Turnstile :
// submit du formulaire. // transmet le token déjà validé au moment du submit du formulaire.
async function sendWelcomeBackEmail(contact) { async function sendWelcomeBackEmail(contact) {
if (!WORKER_PROXY_URL) return;
if (!contact || !contact.email) return; if (!contact || !contact.email) return;
if (!window.turnstileToken) return; if (!window.turnstileToken) return;
try { try {
await fetch(`${API_BASE_URL}/leads/welcome-back`, { await fetch(WORKER_PROXY_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
action : 'sendWelcomeBack',
email : contact.email, email : contact.email,
firstname : contact.firstname || '',
turnstile_token : window.turnstileToken, turnstile_token : window.turnstileToken,
}), }),
}); });
} catch (err) { } catch (err) {
// Erreur réseau : on n'interrompt pas l'UX (le client voit // Erreur réseau : on n'interrompt pas l'UX (le client voit
// déjà sa référence dans le UI). // déjà sa référence dans le UI).
console.warn('welcome-back failed:', err); console.warn('Worker sendWelcomeBack failed:', err);
} }
} }

View File

@ -21,7 +21,7 @@ const translations = {
pricing: "Tarifs", pricing: "Tarifs",
serviceCommande: "Service Commande", serviceCommande: "Service Commande",
guide: "Guide d'envoi", guide: "Guide d'envoi",
contact: "Inscription", contact: "Contact",
app: "Prochainement" app: "Prochainement"
}, },
home: { home: {
@ -134,8 +134,8 @@ const translations = {
delivery2Note: "Retrait au bureau Cotisse de votre ville" delivery2Note: "Retrait au bureau Cotisse de votre ville"
}, },
contact: { contact: {
heroTitle: "Inscrivez-vous", heroTitle: "Contactez-Nous",
heroSubtitle: "Commencez à envoyer vos colis dès aujourd'hui", heroSubtitle: "Inscrivez-vous et commencez à envoyer vos colis dès aujourd'hui",
formTitle: "Formulaire d'inscription", formTitle: "Formulaire d'inscription",
formSubtitle: "Remplissez ce formulaire pour recevoir votre numéro de référence client et l'adresse de dépôt à Paris.", formSubtitle: "Remplissez ce formulaire pour recevoir votre numéro de référence client et l'adresse de dépôt à Paris.",
labelNom: "Nom", labelNom: "Nom",
@ -189,7 +189,7 @@ const translations = {
prohibitedTitle: "Articles Interdits", prohibitedTitle: "Articles Interdits",
prohibitedSubtitle: "Ces articles ne peuvent pas être transportés par fret aérien (réglementation IATA)", prohibitedSubtitle: "Ces articles ne peuvent pas être transportés par fret aérien (réglementation IATA)",
cat1Title: "Explosifs", cat1Title: "Explosifs",
cat1Desc: "Dynamite, munitions, feux d'artifice, pétards, armes à feu", cat1Desc: "Dynamite, munitions, feux d'artifice, pétards",
cat2Title: "Gaz comprimés", cat2Title: "Gaz comprimés",
cat2Desc: "Bouteilles de gaz, aérosols pressurisés, butane, propane", cat2Desc: "Bouteilles de gaz, aérosols pressurisés, butane, propane",
cat3Title: "Liquides inflammables", cat3Title: "Liquides inflammables",
@ -199,7 +199,7 @@ const translations = {
cat5Title: "Batteries lithium", cat5Title: "Batteries lithium",
cat5Desc: "Piles lithium seules, hoverboards, certains appareils", cat5Desc: "Piles lithium seules, hoverboards, certains appareils",
cat6Title: "Substances toxiques", cat6Title: "Substances toxiques",
cat6Desc: "Poisons, pesticides, substances infectieuses, substances illégales", cat6Desc: "Poisons, pesticides, substances infectieuses",
cat7Title: "Matières corrosives", cat7Title: "Matières corrosives",
cat7Desc: "Acides, mercure, soude caustique", cat7Desc: "Acides, mercure, soude caustique",
cat8Title: "Matières radioactives", cat8Title: "Matières radioactives",
@ -383,7 +383,7 @@ const translations = {
pricing: "Pricing", pricing: "Pricing",
serviceCommande: "Order Service", serviceCommande: "Order Service",
guide: "Shipping Guide", guide: "Shipping Guide",
contact: "Sign Up", contact: "Contact",
app: "Coming Soon" app: "Coming Soon"
}, },
home: { home: {
@ -496,8 +496,8 @@ const translations = {
ctaBtn: "Contact Us" ctaBtn: "Contact Us"
}, },
contact: { contact: {
heroTitle: "Sign Up", heroTitle: "Contact Us",
heroSubtitle: "Start sending your parcels today", heroSubtitle: "Register and start sending your parcels today",
formTitle: "Registration Form", formTitle: "Registration Form",
formSubtitle: "Fill out this form to receive your client reference number and the Paris drop-off address.", formSubtitle: "Fill out this form to receive your client reference number and the Paris drop-off address.",
labelNom: "Last Name", labelNom: "Last Name",
@ -551,7 +551,7 @@ const translations = {
prohibitedTitle: "Prohibited Items", prohibitedTitle: "Prohibited Items",
prohibitedSubtitle: "These items cannot be transported by air freight (IATA regulations)", prohibitedSubtitle: "These items cannot be transported by air freight (IATA regulations)",
cat1Title: "Explosives", cat1Title: "Explosives",
cat1Desc: "Dynamite, ammunition, fireworks, firecrackers, firearms", cat1Desc: "Dynamite, ammunition, fireworks, firecrackers",
cat2Title: "Compressed Gases", cat2Title: "Compressed Gases",
cat2Desc: "Gas cylinders, pressurized aerosols, butane, propane", cat2Desc: "Gas cylinders, pressurized aerosols, butane, propane",
cat3Title: "Flammable Liquids", cat3Title: "Flammable Liquids",
@ -561,7 +561,7 @@ const translations = {
cat5Title: "Lithium Batteries", cat5Title: "Lithium Batteries",
cat5Desc: "Loose lithium batteries, hoverboards, certain devices", cat5Desc: "Loose lithium batteries, hoverboards, certain devices",
cat6Title: "Toxic Substances", cat6Title: "Toxic Substances",
cat6Desc: "Poisons, pesticides, infectious substances, illegal substances", cat6Desc: "Poisons, pesticides, infectious substances",
cat7Title: "Corrosive Materials", cat7Title: "Corrosive Materials",
cat7Desc: "Acids, mercury, caustic soda", cat7Desc: "Acids, mercury, caustic soda",
cat8Title: "Radioactive Materials", cat8Title: "Radioactive Materials",
@ -745,7 +745,7 @@ const translations = {
pricing: "Sarany", pricing: "Sarany",
serviceCommande: "Tolotra Fividianana", serviceCommande: "Tolotra Fividianana",
guide: "Toromarika fandefasana", guide: "Toromarika fandefasana",
contact: "Fisoratana anarana", contact: "Fifandraisana",
app: "Avy tsy ho ela" app: "Avy tsy ho ela"
}, },
home: { home: {
@ -858,8 +858,8 @@ const translations = {
ctaBtn: "Mifandraisa aminay" ctaBtn: "Mifandraisa aminay"
}, },
contact: { contact: {
heroTitle: "Misoratra anarana", heroTitle: "Mifandraisa Aminay",
heroSubtitle: "Manomboha mandefa ny entanareo anio", heroSubtitle: "Misoratra anarana ary manomboha mandefa ny entanareo anio",
formTitle: "Taratasy fisoratana anarana", formTitle: "Taratasy fisoratana anarana",
formSubtitle: "Fenoy ity taratasy ity mba handraisana ny laharan'ny mpanjifa sy ny adiresy fametrahana any Paris.", formSubtitle: "Fenoy ity taratasy ity mba handraisana ny laharan'ny mpanjifa sy ny adiresy fametrahana any Paris.",
labelNom: "Anarana", labelNom: "Anarana",
@ -913,7 +913,7 @@ const translations = {
prohibitedTitle: "Entana Voarara", prohibitedTitle: "Entana Voarara",
prohibitedSubtitle: "Ireto entana ireto dia tsy azo alefa amin'ny fandefasana entana an'habakabaka (fitsipika IATA)", prohibitedSubtitle: "Ireto entana ireto dia tsy azo alefa amin'ny fandefasana entana an'habakabaka (fitsipika IATA)",
cat1Title: "Zavatra mipoaka", cat1Title: "Zavatra mipoaka",
cat1Desc: "Dynamita, bala, afo artifisialy, petarada, basy", cat1Desc: "Dynamita, bala, afo artifisialy, petarada",
cat2Title: "Gazy voatery", cat2Title: "Gazy voatery",
cat2Desc: "Tavoahangy gazy, aérosol misy tsindry, butane, propane", cat2Desc: "Tavoahangy gazy, aérosol misy tsindry, butane, propane",
cat3Title: "Ranoka mampirehitra", cat3Title: "Ranoka mampirehitra",
@ -923,7 +923,7 @@ const translations = {
cat5Title: "Bateria lithium", cat5Title: "Bateria lithium",
cat5Desc: "Bateria lithium irery, hoverboards, fitaovana sasany", cat5Desc: "Bateria lithium irery, hoverboards, fitaovana sasany",
cat6Title: "Zava-mahapoizina", cat6Title: "Zava-mahapoizina",
cat6Desc: "Poizina, fanafody bibikely, zava-mifindra, zavatra tsy ara-dalàna", cat6Desc: "Poizina, fanafody bibikely, zava-mifindra",
cat7Title: "Zava-mandevona", cat7Title: "Zava-mandevona",
cat7Desc: "Asida, mercure, soude caustique", cat7Desc: "Asida, mercure, soude caustique",
cat8Title: "Zava-misy radioactivité", cat8Title: "Zava-misy radioactivité",

View File

@ -47,7 +47,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -67,7 +67,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -273,7 +273,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
<li><a href="cgv.html" style="color:rgba(255,255,255,0.7);" data-i18n="footer.cgv">Conditions Générales de Vente</a></li> <li><a href="cgv.html" style="color:rgba(255,255,255,0.7);" data-i18n="footer.cgv">Conditions Générales de Vente</a></li>
</ul> </ul>
</div> </div>
@ -300,7 +300,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js?v=20260603"></script> <script src="js/translations.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@ -56,7 +56,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -76,7 +76,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -444,7 +444,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
<li><a href="cgv.html" style="color:rgba(255,255,255,0.7);" data-i18n="footer.cgv">Conditions Générales de Vente</a></li> <li><a href="cgv.html" style="color:rgba(255,255,255,0.7);" data-i18n="footer.cgv">Conditions Générales de Vente</a></li>
</ul> </ul>
</div> </div>
@ -471,7 +471,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js?v=20260603"></script> <script src="js/translations.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@ -4,78 +4,30 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<title>Réinitialisation du mot de passe — MVA Global Fret</title> <title>Redirection — MVA Global Fret</title>
<link rel="icon" type="image/png" href="PNG MVA GLOBAL FRET.png"> <link rel="icon" type="image/png" href="PNG MVA GLOBAL FRET.png">
<script> <script>
// Bridge mobile deep link MVA : redirect vers le custom scheme natif // Bridge mobile deep link MVA Expo : redirect vers auth.mind4solutions.com
// mvaglobalfret://reset-password pour ouvrir le flow in-app de l'app Expo. // qui héberge le UI reset-password Phase 2.2 m4s-auth.
// Le lien email porte token_hash + type (recovery) ; l'app fait verifyOtp. // Conserve le query param ?token=... pour que GoTrue PKCE flow continue.
(function() { (function() {
var params = new URLSearchParams(window.location.search); var params = window.location.search || '';
var tokenHash = params.get('token_hash'); var hash = window.location.hash || '';
var type = params.get('type') || 'recovery'; window.location.replace('https://auth.mind4solutions.com/reset-password' + params + hash);
if (tokenHash) {
window.location.replace(
'mvaglobalfret://reset-password?token_hash=' +
encodeURIComponent(tokenHash) + '&type=' + encodeURIComponent(type)
);
}
})(); })();
</script> </script>
<style> <style>
body { body { font-family: system-ui, -apple-system, sans-serif; text-align: center; padding: 2rem; color: #333; }
font-family: system-ui, -apple-system, sans-serif; a { color: #c5a55a; font-weight: 600; }
text-align: center;
padding: 2rem;
color: #333;
max-width: 480px;
margin: 0 auto;
}
h1 { color: #2d3748; font-size: 1.5rem; }
p { line-height: 1.6; }
a.cta {
display: inline-block;
background: #c5a55a;
color: #fff;
padding: 0.75rem 1.5rem;
border-radius: 6px;
text-decoration: none;
font-weight: 600;
margin-top: 1rem;
}
a.cta:hover { background: #b3954a; }
.hint { color: #666; font-size: 0.9rem; margin-top: 2rem; }
.error { color: #b91c1c; font-size: 0.95rem; line-height: 1.6; margin-top: 1rem; }
</style> </style>
</head> </head>
<body> <body>
<h1>Ouvrir l'app MVA Global Fret</h1> <h1>Redirection en cours...</h1>
<p id="intro">Pour réinitialiser votre mot de passe, ouvrez le lien ci-dessous dans l'application MVA Global Fret installée sur votre téléphone.</p> <p>Si la redirection automatique ne fonctionne pas,
<p id="link-wrap"> <a id="manual-link" href="https://auth.mind4solutions.com/reset-password">cliquez ici</a>.
<a id="manual-link" class="cta" href="#">Réinitialiser mon mot de passe</a>
</p> </p>
<p id="hint" class="hint">Si rien ne se passe, vérifiez que l'application MVA Global Fret est bien installée sur votre appareil.</p>
<p id="error" class="error" style="display:none;">Ce lien de réinitialisation est invalide ou incomplet. Veuillez relancer la procédure « Mot de passe oublié » depuis l'application MVA Global Fret.</p>
<script> <script>
(function() { document.getElementById('manual-link').href = 'https://auth.mind4solutions.com/reset-password' + (window.location.search || '');
var params = new URLSearchParams(window.location.search);
var tokenHash = params.get('token_hash');
var type = params.get('type') || 'recovery';
if (!tokenHash) {
// Lien invalide : pas de token_hash → on masque le bouton et on prévient.
document.getElementById('intro').style.display = 'none';
document.getElementById('link-wrap').style.display = 'none';
document.getElementById('hint').style.display = 'none';
document.getElementById('error').style.display = 'block';
return;
}
// Deep link vers l'app : l'app lit token_hash + type et fait verifyOtp.
document.getElementById('manual-link').href =
'mvaglobalfret://reset-password?token_hash=' +
encodeURIComponent(tokenHash) + '&type=' + encodeURIComponent(type);
})();
</script> </script>
</body> </body>
</html> </html>

View File

@ -25,7 +25,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -45,7 +45,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -242,7 +242,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -268,7 +268,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js?v=20260603"></script> <script src="js/translations.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>

View File

@ -1,286 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title>Créez votre compte — MVA Global Fret</title>
<link rel="icon" type="image/png" href="PNG MVA GLOBAL FRET.png">
<style>
* { box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
background: linear-gradient(135deg, #1a1a3e 0%, #2d2d5e 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.card {
background: #fff;
border-radius: 16px;
max-width: 480px;
width: 100%;
padding: 2.5rem 2rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.header {
text-align: center;
margin-bottom: 1.5rem;
}
.header .brand {
color: #c5a55a;
font-size: 22px;
font-weight: 700;
letter-spacing: 2px;
margin-bottom: 0.25rem;
}
.header .subtitle {
color: #7a7a8a;
font-size: 13px;
}
h1 {
color: #1a1a3e;
font-size: 1.5rem;
margin: 1.5rem 0 0.5rem;
text-align: center;
}
.ref-badge {
background: #f0ead8;
border-left: 4px solid #c5a55a;
padding: 12px 16px;
border-radius: 4px;
margin: 1rem 0 1.5rem;
font-size: 14px;
color: #1a1a3e;
}
.ref-badge strong { letter-spacing: 1px; }
label {
display: block;
color: #1a1a3e;
font-size: 13px;
font-weight: 600;
margin: 1rem 0 0.4rem;
}
input[type="email"], input[type="password"] {
width: 100%;
padding: 0.7rem 0.9rem;
border: 1px solid #d4d4d8;
border-radius: 8px;
font-size: 15px;
font-family: inherit;
}
input[disabled] { background: #f7f7f9; color: #6b6b75; cursor: not-allowed; }
input:focus { outline: none; border-color: #c5a55a; box-shadow: 0 0 0 3px rgba(197, 165, 90, 0.18); }
.hint {
color: #7a7a8a;
font-size: 12px;
margin-top: 0.35rem;
line-height: 1.4;
}
button {
width: 100%;
margin-top: 1.5rem;
padding: 0.85rem;
background: #c5a55a;
color: #1a1a3e;
border: 0;
border-radius: 50px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
}
button:hover:not(:disabled) { background: #b3954a; }
button:disabled { opacity: 0.6; cursor: wait; }
.alert {
padding: 0.85rem 1rem;
border-radius: 8px;
font-size: 14px;
margin: 1rem 0;
line-height: 1.5;
}
.alert-error { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; }
.alert-success { background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534; }
.footer {
margin-top: 1.75rem;
text-align: center;
font-size: 12px;
color: #9ca3af;
}
.footer a { color: #c5a55a; text-decoration: none; }
.loading {
text-align: center;
padding: 2rem;
color: #7a7a8a;
}
.hidden { display: none; }
</style>
</head>
<body>
<div class="card">
<div class="header">
<div class="brand">MVA GLOBAL FRET</div>
<div class="subtitle">Fret Aérien Paris — Antananarivo</div>
</div>
<div id="loading" class="loading">Chargement du lien…</div>
<div id="error-state" class="hidden">
<h1>Lien invalide</h1>
<div id="error-message" class="alert alert-error"></div>
<p class="footer">
Si le problème persiste, contactez-nous via <a href="contact.html">la page contact</a>.
</p>
</div>
<div id="success-state" class="hidden">
<h1>Compte créé ✓</h1>
<div class="alert alert-success">
Votre compte MVA Global Fret a été créé avec succès.
Téléchargez l'application sur votre téléphone pour commander :
</div>
<p style="text-align:center;margin:1rem 0;">
<a href="application.html" style="color:#c5a55a;font-weight:600;text-decoration:none;">
📱 Télécharger l'application
</a>
</p>
</div>
<form id="setup-form" class="hidden" novalidate>
<h1>Créez votre compte</h1>
<p id="welcome-text" style="color:#1a1a3e;text-align:center;font-size:14px;margin-bottom:0;"></p>
<div id="ref-display" class="ref-badge hidden">
Référence client : <strong id="ref-value"></strong>
</div>
<label for="email-display">Email</label>
<input id="email-display" type="email" disabled>
<label for="password">Mot de passe</label>
<input id="password" type="password" autocomplete="new-password" required minlength="8" maxlength="72">
<div class="hint">Minimum 8 caractères, avec au moins une majuscule et un chiffre.</div>
<label for="password-confirm">Confirmer le mot de passe</label>
<input id="password-confirm" type="password" autocomplete="new-password" required>
<div id="form-error" class="alert alert-error hidden"></div>
<button type="submit" id="submit-btn">Créer mon compte</button>
<p class="footer">
En créant votre compte, vous acceptez nos <a href="cgv.html">CGV</a>
et notre <a href="politique-confidentialite.html">politique de confidentialité</a>.
</p>
</form>
</div>
<script>
(function() {
const API_BASE = "https://api.mva.mind4solutions.com";
const token = new URLSearchParams(window.location.search).get('token');
const $ = (id) => document.getElementById(id);
const show = (id) => $(id).classList.remove('hidden');
const hide = (id) => $(id).classList.add('hidden');
function showError(message) {
hide('loading');
hide('setup-form');
$('error-message').textContent = message;
show('error-state');
}
function showFormError(message) {
$('form-error').textContent = message;
show('form-error');
}
async function init() {
if (!token || token.length !== 96 || !/^[a-f0-9]+$/i.test(token)) {
showError("Lien invalide ou incomplet.");
return;
}
try {
const res = await fetch(API_BASE + "/auth/lookup-setup-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
const data = await res.json();
if (!res.ok) {
showError(data.message || "Lien invalide.");
return;
}
$('email-display').value = data.email || '';
$('welcome-text').textContent = data.firstname
? `Bonjour ${data.firstname}, choisissez votre mot de passe pour activer votre compte.`
: "Choisissez votre mot de passe pour activer votre compte.";
if (data.reference_client) {
$('ref-value').textContent = data.reference_client;
show('ref-display');
}
hide('loading');
show('setup-form');
} catch (err) {
showError("Impossible de joindre le serveur. Vérifiez votre connexion.");
}
}
$('setup-form').addEventListener('submit', async (e) => {
e.preventDefault();
hide('form-error');
const password = $('password').value;
const confirm = $('password-confirm').value;
if (password.length < 8) {
showFormError("Le mot de passe doit contenir au moins 8 caractères.");
return;
}
if (!/[A-Z]/.test(password) || !/[0-9]/.test(password)) {
showFormError("Le mot de passe doit contenir au moins une majuscule et un chiffre.");
return;
}
if (password !== confirm) {
showFormError("Les deux mots de passe ne correspondent pas.");
return;
}
const btn = $('submit-btn');
btn.disabled = true;
btn.textContent = "Création en cours…";
try {
const res = await fetch(API_BASE + "/auth/setup-from-lead", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, password }),
});
const data = await res.json();
if (!res.ok) {
btn.disabled = false;
btn.textContent = "Créer mon compte";
showFormError(data.message || "Erreur lors de la création du compte.");
return;
}
hide('setup-form');
show('success-state');
} catch (err) {
btn.disabled = false;
btn.textContent = "Créer mon compte";
showFormError("Impossible de joindre le serveur. Réessayez dans un instant.");
}
});
init();
})();
</script>
</body>
</html>

View File

@ -25,7 +25,7 @@
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a> <a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="header-right"> <div class="header-right">
@ -43,9 +43,8 @@
<a href="accueil.html" data-i18n="nav.home">Accueil</a> <a href="accueil.html" data-i18n="nav.home">Accueil</a>
<a href="about.html" data-i18n="nav.about">Qui sommes-nous</a> <a href="about.html" data-i18n="nav.about">Qui sommes-nous</a>
<a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a> <a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a>
<a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a>
<a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a> <a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a>
<a href="contact.html" data-i18n="nav.contact">Inscription</a> <a href="contact.html" data-i18n="nav.contact">Contact</a>
<a href="application.html" data-i18n="nav.app">Prochainement</a> <a href="application.html" data-i18n="nav.app">Prochainement</a>
</nav> </nav>
<div class="overlay" id="overlay"></div> <div class="overlay" id="overlay"></div>
@ -258,7 +257,7 @@
<li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li> <li><a href="tarifs.html" data-i18n="nav.pricing">Tarifs</a></li>
<li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li> <li><a href="service-commande.html" data-i18n="nav.serviceCommande">Service Commande</a></li>
<li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li> <li><a href="guide-envoi.html" data-i18n="nav.guide">Guide d'envoi</a></li>
<li><a href="contact.html" data-i18n="nav.contact">Inscription</a></li> <li><a href="contact.html" data-i18n="nav.contact">Contact</a></li>
</ul> </ul>
</div> </div>
<div> <div>
@ -284,7 +283,7 @@
</div> </div>
</footer> </footer>
<script src="js/translations.js?v=20260603"></script> <script src="js/translations.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>
</html> </html>