Deux intégrations, et elles répondent à deux besoins distincts :
le formulaire embarqué (clé publique, navigateur) — pour une page, un pied de page, un article ;
la case newsletter d'un checkout (clé secrète, serveur) — pour transformer une commande en consentement sans exposer quoi que ce soit au navigateur.
C'est le second cas qui justifie ce guide : soraparfume.com tourne sous Next.js, et la case à cocher du tunnel de commande est le plus gros gisement d'opt-in de la marque.
soraparfume.com
Prérequis : la clé publique, la clé secrète et le domaine déclaré (voir le README).
components/NewsletterForm.jsx :
components/NewsletterForm.jsx
"use client"; import Script from "next/script"; /** * Formulaire d'inscription INOCAMPAGNE. * * `next/script` avec `strategy="afterInteractive"` : le script se charge après * l'hydratation, donc il ne retarde pas le rendu. Le `<div>` doit exister dans le DOM * quand le script s'exécute — c'est pourquoi il est AVANT la balise. */ export default function NewsletterForm() { return ( <section aria-label="Inscription à la newsletter"> <div data-inocampagne-form /> <Script id="inocampagne" src="https://messagerie.inovertix.com/inocampagne.js" data-marque={process.env.NEXT_PUBLIC_INOCAMPAGNE_KEY} data-endpoint="https://messagerie.inovertix.com/api/inocampagne/public" strategy="afterInteractive" /> </section> ); }
.env.local :
.env.local
# Clé PUBLIQUE : elle finit dans le bundle, et c'est normal — elle est protégée par la # liste des domaines autorisés, pas par le secret. NEXT_PUBLIC_INOCAMPAGNE_KEY=INOCMP_PUB_votre_cle_publique
Dans une application à navigation client, une page rendue après le chargement du script n'est pas vue par lui. Le script expose donc une API minimale :
"use client"; import { useEffect } from "react"; export default function NewsletterForm() { useEffect(() => { // Le script est peut-être déjà chargé (navigation client) : on lui demande de // (re)monter les formulaires présents. Idempotent — un conteneur déjà rendu est ignoré. window.InoCampagne?.mount(); }, []); return <div data-inocampagne-form />; }
Placez la balise <Script> une seule fois, dans le layout racine, et laissez les pages poser leurs <div>.
<Script>
<div>
C'est ici que la clé secrète sert. Elle ne doit jamais atteindre le navigateur : la requête part de votre serveur, avec la clé dans l'en-tête Authorization.
Authorization
# Clé SECRÈTE : sans préfixe NEXT_PUBLIC_, donc jamais exposée au navigateur. INOCAMPAGNE_SECRET_KEY=INOCMP_SEC_votre_cle_secrete
app/api/newsletter/route.js :
app/api/newsletter/route.js
import { NextResponse } from "next/server"; const ENDPOINT = "https://messagerie.inovertix.com/api/inocampagne/public/subscribe"; export async function POST(request) { const { email, firstName, phone } = await request.json(); // Validation minimale côté serveur : on n'appelle pas CONTROL pour une saisie vide. if (!email || !email.includes("@")) { return NextResponse.json({ ok: false }, { status: 422 }); } try { await fetch(ENDPOINT, { method: "POST", headers: { // La clé SECRÈTE voyage dans l'en-tête, jamais dans l'URL : le serveur refuse // explicitement une clé secrète passée en query (elle finirait dans les journaux // d'accès et les référents). Authorization: `Bearer ${process.env.INOCAMPAGNE_SECRET_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ email, first_name: firstName || null, phone: phone || null, source_url: request.headers.get("referer") || null, }), }); } catch { // On n'échoue JAMAIS la commande à cause de la newsletter. Un opt-in perdu se // rattrape ; une commande perdue, non. } // Réponse constante, comme celle de CONTROL : le checkout n'apprend rien sur la base. return NextResponse.json({ ok: true }); }
"use client"; import { useState } from "react"; export default function CheckoutForm() { const [newsletter, setNewsletter] = useState(false); const submit = async (event) => { event.preventDefault(); const form = new FormData(event.currentTarget); // 1. La commande d'abord, TOUJOURS. await placeOrder(form); // 2. L'opt-in ensuite, et seulement si la case est cochée. if (newsletter) { // `void` : on ne bloque pas la confirmation de commande sur cet appel. void fetch("/api/newsletter", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: form.get("email"), firstName: form.get("first_name"), phone: form.get("phone"), }), }); } }; return ( <form onSubmit={submit}> {/* … les champs de la commande … */} <label> <input type="checkbox" checked={newsletter} onChange={(e) => setNewsletter(e.target.checked)} />{" "} Je souhaite recevoir les nouveautés et offres de Sora Parfume par email. </label> </form> ); }
Jamais pré-cochée. Un opt-in pré-coché n'est pas un consentement, et il se paie en plaintes pour spam — qui coûtent la réputation du domaine d'envoi, donc toutes les campagnes de toutes les marques ;
Le libellé dit ce qu'on va envoyer. « Recevoir les nouveautés et offres » se défend ; « Rester informé » ne dit rien ;
La commande ne dépend pas de l'opt-in. Si l'appel à CONTROL échoue, la commande passe quand même. C'est pour cela que l'appel est en void et que la route serveur avale ses erreurs.
void
client
Si la boutique est branchée à INOSHOP et rattachée à la marque, un achat pose automatiquement le statut client sur le profil — et un client est adressable. Vous n'avez donc rien à faire pour pouvoir écrire à vos acheteurs.
La case newsletter sert à autre chose : obtenir un opt-in explicite, qui est un consentement plus fort et qui survit à tout. Un client qui a aussi coché la case reste opt_in : le statut le plus engageant gagne.
opt_in
Ne posez donc pas la case en croyant qu'elle est nécessaire pour écrire à vos clients. Posez-la parce qu'un consentement explicite vaut mieux qu'un consentement déduit.
Le formulaire s'affiche sur la page cible, et survit à une navigation client (aller sur une autre page, revenir) ;
Une inscription depuis le navigateur apparaît dans CONTROL → Profils, provenance « Formulaire /news » ;
Une commande avec la case cochée produit un profil opt_in — et la même commande sans la case ne produit pas d'opt-in ;
La clé secrète n'est pas dans le bundle : grep -r INOCMP_SEC_ .next/ ne doit rien rendre. Si elle y est, c'est qu'elle porte le préfixe NEXT_PUBLIC_ — retirez-le ;
grep -r INOCMP_SEC_ .next/
NEXT_PUBLIC_
Le mauvais domaine échoue : /ping depuis un autre domaine rend 403 origin_not_allowed.
/ping
403 origin_not_allowed
window.InoCampagne?.mount()
useEffect
id
401 secret_key_in_url
?k=
Authorization: Bearer …
www.
/api/newsletter
INOCAMPAGNE_SECRET_KEY
await
inconnu
Le reste du dépannage vit dans le README.