Deux routeurs, deux emplacements, un seul principe : le widget est un script tiers, il se charge après l'hydratation et n'entre jamais dans le rendu React.
Prérequis : le site est créé dans CONTROL, ses domaines sont déclarés, sa clé publique est copiée. Voir le README.
NEXT_PUBLIC_
# .env.local # Clé PUBLIQUE : elle finit dans le bundle JavaScript, et c'est normal. NEXT_PUBLIC_INODESK_KEY=INODSK_PUB_votre_cle_publique NEXT_PUBLIC_INODESK_ENDPOINT=https://messagerie.inovertix.com/api/inodesk # Clé SECRÈTE : PAS de préfixe NEXT_PUBLIC_. Jamais. INODESK_SECRET_KEY=INODSK_SEC_votre_cle_secrete
// app/layout.jsx import Script from "next/script"; export default function RootLayout({ children }) { return ( <html lang="fr"> <body> {children} <Script src="https://messagerie.inovertix.com/inodesk.js" data-site={process.env.NEXT_PUBLIC_INODESK_KEY} data-endpoint={process.env.NEXT_PUBLIC_INODESK_ENDPOINT} strategy="lazyOnload" /> </body> </html> ); }
strategy="lazyOnload" : le widget se charge quand le navigateur n'a plus rien d'urgent à faire. Il n'entre dans aucun calcul de Core Web Vitals et ne retarde pas la page. afterInteractive fonctionne aussi si vous voulez la bulle plus tôt.
strategy="lazyOnload"
afterInteractive
// pages/_app.jsx import Script from "next/script"; export default function MyApp({ Component, pageProps }) { return ( <> <Component {...pageProps} /> <Script src="https://messagerie.inovertix.com/inodesk.js" data-site={process.env.NEXT_PUBLIC_INODESK_KEY} data-endpoint={process.env.NEXT_PUBLIC_INODESK_ENDPOINT} strategy="lazyOnload" /> </> ); }
Le widget expose une API minimale une fois chargé :
<button type="button" onClick={() => window.inodesk?.("open", "support")}> Nous contacter </button>
window.inodesk("open") ouvre l'onglet par défaut, ("open", "support") l'onglet « Ma demande », ("close") referme. L'appel est sans effet si le widget n'a pas chargé — il ne lèvera jamais d'exception.
window.inodesk("open")
("open", "support")
("close")
Si votre site pose une Content-Security-Policy (recommandé), deux directives doivent connaître le domaine de CONTROL :
// next.config.js const CONTROL = "https://messagerie.inovertix.com"; const csp = [ `default-src 'self'`, // Le fichier du widget est chargé depuis CONTROL. `script-src 'self' ${CONTROL}`, // Le widget appelle /config et /submissions. `connect-src 'self' ${CONTROL}`, // Le widget injecte ses styles dans un shadow DOM. `style-src 'self' 'unsafe-inline'`, // Le logo de votre marque, si vous en configurez un. `img-src 'self' data: https:`, ].join("; "); module.exports = { async headers() { return [{ source: "/:path*", headers: [{ key: "Content-Security-Policy", value: csp }] }]; }, };
Symptôme d'une CSP incomplète : la console affiche Refused to load the script ou Refused to connect, et la bulle n'apparaît jamais.
Refused to load the script
Refused to connect
Cas typique : une commande échoue, un paiement est refusé, un utilisateur signale un problème depuis un espace connecté — et vous voulez ouvrir la demande avec le contexte que vous avez déjà, sans le faire ressaisir.
// app/actions/support.js "use server"; /** * Ouvre une demande INODESK depuis le serveur. * * NON BLOQUANT par construction : si CONTROL est injoignable ou lent, l'utilisateur * ne doit pas en subir les conséquences. On journalise et on rend la main. */ export async function ouvrirDemande({ sujet, message, contact }) { try { const res = await fetch(`${process.env.INODESK_ENDPOINT}/tickets`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.INODESK_SECRET_KEY}`, }, body: JSON.stringify({ subject: sujet, message, contact, // { name, email, phone, city } priority: "haute", }), // Timeout court : ouvrir une demande ne doit jamais faire attendre une page. signal: AbortSignal.timeout(4000), cache: "no-store", }); if (!res.ok) { console.error("INODESK:", res.status, await res.text()); return { ok: false }; } const data = await res.json(); // `track_url` n'est renvoyé QU'À la clé secrète : côté navigateur, le lien part // par email. Ne le réexposez pas dans une page publique. return { ok: true, reference: data.reference, trackUrl: data.track_url }; } catch (error) { console.error("INODESK injoignable:", error); return { ok: false }; } }
Le contact est dédupliqué automatiquement par (site, email) puis (site, téléphone) : rappeler la même personne ne crée pas une deuxième fiche, et l'agent voit son historique complet.
// app/api/support/route.js export async function POST(request) { const body = await request.json(); await fetch(`${process.env.INODESK_ENDPOINT}/tickets`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.INODESK_SECRET_KEY}`, }, body: JSON.stringify(body), }); return Response.json({ ok: true }); }
⚠️ Un relais ouvert est un formulaire ouvert : ajoutez-y votre propre limitation de débit et votre propre validation, sans quoi vous offrez au premier venu la possibilité d'ouvrir mille demandes.
Si vous avez déjà un formulaire React et voulez seulement en envoyer le contenu :
const endpoint = process.env.NEXT_PUBLIC_INODESK_ENDPOINT; const key = process.env.NEXT_PUBLIC_INODESK_KEY; await fetch(`${endpoint}/submissions?k=${encodeURIComponent(key)}`, { method: "POST", // `text/plain` : la requête reste « simple » au sens CORS, donc AUCUN préflight // OPTIONS avant l'envoi. C'est le même transport que le traqueur INOTRACK. headers: { "Content-Type": "text/plain;charset=UTF-8" }, body: JSON.stringify({ form_slug: "contact", data: { name, email, phone, message }, source_url: window.location.href, rendered_at: renderedAt, // horodatage d'affichage du formulaire (anti-robot) }), });
La réponse est 202 dans tous les cas exploitables, y compris quand la soumission est classée en spam : c'est voulu. Un robot qui reçoit une erreur recommence ; un robot satisfait s'en va. Les suspectes sont rangées dans le dossier « Spam » de l'inbox et restaurables d'un clic.
Ces trois briques sont des onglets du widget déjà installé : rien de neuf à charger, rien de neuf à autoriser dans la CSP. On les active dans CONTROL → INODESK → Sites & clés → Widget, et le panneau les affiche au prochain rechargement.
Un onglet coché mais impossible ne s'affiche pas — « Devis » exige un formulaire de type devis, « Avis » la collecte ouverte, « Ma commande » une boutique INOSHOP liée. Vous n'aurez jamais un onglet mort en production.
Deux façons, et le choix se fait sur une seule question : voulez-vous être référencé sur vos avis ?
Non — le script suffit, une balise et c'est réglé :
// app/avis/page.tsx "use client"; import Script from "next/script"; export default function Avis() { return ( <> <div data-inodesk-reviews data-site={process.env.NEXT_PUBLIC_INODESK_KEY} data-endpoint={process.env.NEXT_PUBLIC_INODESK_ENDPOINT} data-mode="wall" /> <Script src="https://messagerie.inovertix.com/inodesk-reviews.js" strategy="afterInteractive" /> </> ); }
Fichier séparé du widget (~4 Ko) : cette page n'a aucune raison de télécharger le panneau de contact complet. data-mode="badge" n'affiche que la note, data-limit borne le nombre d'avis. Zéro avis publié → le bloc n'affiche rien.
data-mode="badge"
data-limit
Oui — alors le rendu doit être serveur, avec le JSON-LD dans le HTML. Un balisage injecté en JavaScript est lu de façon inégale par les moteurs, et c'est précisément pourquoi inodesk-reviews.js n'en pose aucun. Le composant serveur complet est dans avis-seo.md.
inodesk-reviews.js
La solution sans travail : lier /care/{votre-site}/avis, déjà brandée, balisée et indexable.
/care/{votre-site}/avis
const res = await fetch( `${process.env.INODESK_ENDPOINT}/reviews?k=${process.env.INODESK_PUBLIC_KEY}`, { next: { revalidate: 300 } } // aligné sur le Cache-Control de l'API );
Clé publique, pas secrète : cet endpoint ne rend que du contenu déjà public. Depuis le serveur il n'y a pas d'en-tête Origin, donc pas de contrôle de domaine à craindre ; depuis le navigateur, votre domaine doit être déclaré dans CONTROL.
Origin
Si vous préférez votre propre formulaire :
// app/api/suivi/route.ts — relais serveur, pour ne pas exposer l'endpoint export async function POST(request: Request) { const { order_number, phone } = await request.json(); const res = await fetch( `${process.env.INODESK_ENDPOINT}/order-lookup?k=${process.env.INODESK_PUBLIC_KEY}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ order_number, phone }), } ); // ⚠️ On renvoie la réponse TELLE QUELLE. Ne l'enrichissez pas, ne distinguez pas // « numéro inconnu » de « téléphone faux » : le message unique est ce qui empêche // cette route de confirmer l'existence d'une commande à qui en devine le numéro. return new Response(await res.text(), { status: res.status, headers: { "Content-Type": "application/json" }, }); }
Deux réflexes à garder dans votre interface :
Afficher le message du serveur tel quel. Il est identique pour toutes les causes d'échec, y compris le verrouillage d'IP.
Ne pas ajouter de latence différenciée. Le serveur applique déjà un plancher de temps de réponse ; un setTimeout conditionnel côté client le réduirait à néant.
setTimeout
grep -r "INODSK_SEC_" .next/static/
script-src
connect-src
La Server Action d'ouverture de demande n'a aucun effet visible quand CONTROL est arrêté (test : couper le backend, refaire le parcours).
Sur mobile réel (pas seulement en émulation) : panneau plein écran, clavier qui ne masque pas le bouton d'envoi.
Les onglets activés dans CONTROL apparaissent après un simple rechargement, sans redéploiement du site.
data-inodesk-reviews
Si le SEO est visé : view-source: de la page d'avis contient bien le application/ld+json, et la note balisée est celle affichée.
view-source:
application/ld+json
Suivi de commande : un couple faux renvoie exactement le même message et le même délai qu'un couple inconnu.
_app.jsx
layout.jsx
/config
origin_not_allowed
www
NEXT_PUBLIC_INODESK_KEY
401 secret_key_in_url
?k=
Authorization
_app
_document
Les autres codes d'erreur sont dans le tableau du README.