Widget
Installer le widget de feedback, comprendre quand il s'affiche, et le piloter depuis votre app.
Le widget, c'est la partie que vos clients voient : un bouton flottant qui leur permet de dessiner un rectangle sur la page — ou, sur téléphone, de toucher un élément —, de taper un commentaire et de l'envoyer, épinglé à l'élément exact.
npm i @siteping/widgetimport { initSiteping } from "@siteping/widget";
const siteping = initSiteping({
endpoint: "/api/siteping",
projectName: "mon-projet",
});initSiteping est l'export runtime principal du package, aux côtés des deux helpers i18n (registerLocale, loadLocale) et des types TypeScript. Il pèse environ 34 KB gzip (ESM), charge ses parties lourdes à la demande (panneau, locales, moteur de capture) et s'affiche dans un Shadow DOM fermé — les styles de votre page et ceux du widget ne peuvent jamais se marcher dessus.
Quand le widget s'affiche — et quand il ne s'affiche pas
initSiteping exécute une série de garde-fous, dans cet ordre. Quand l'un d'eux s'active, vous récupérez une instance no-op (toutes les méthodes existent, rien ne s'affiche) :
- Rendu côté serveur — pas de
window? Le widget passe son tour aveconSkip("ssr"). Jamais contournable. - Déjà initialisé — un second appel à
initSiteping()renvoie l'instance existante (un seul widget par page).destroy()remet le compteur à zéro. - Production — quand
process.env.NODE_ENV === "production", le widget passe son tour aveconSkip("production"). C'est le comportement « les clients le voient pendant la relecture, les visiteurs jamais ». Contournez-le avecforceShow: truepour les environnements de staging ou de preview. À noter : seulprocess.env.NODE_ENVest lu — pasimport.meta.env. - Largeur minimale (optionnelle) — en dessous de
minViewportWidth, il passe son tour aveconSkip("mobile"). La valeur par défaut,0, l'affiche à toutes les largeurs : les téléphones ont droit à la mise en page compacte. Également contourné parforceShow. - Validation de la config — pas d'
endpoint/store, ou pas deprojectName? Le widget affiche unconsole.erroret ne fait rien. Ce cas-là n'appelle pasonSkip— regardez la console, pas le callback.
Modifié en 0.11 :
minViewportWidthvalait768par défaut, ce qui tenait le widget à l'écart des téléphones. Un site qui passe à la 0.11 sans l'avoir défini affiche désormais le widget sur téléphone aussi, etonSkip("mobile")ne se déclenche plus. MettezminViewportWidth: 768pour garder l'ancien comportement. AvecenableRightClickCommentactivé, un appui long sur un téléphone Android ouvre aussi le composeur.
Sur téléphone et tablette
Le même widget s'adapte à l'écran sur lequel il arrive — il n'y a rien à configurer.
Écrans tactiles (toutes tailles) :
- Touchez un élément pour le commenter, ou faites glisser pour sélectionner une zone. La barre d'instructions le dit, au lieu de parler de la touche Entrée.
- Le menu flottant affiche ses libellés (il n'y a pas de survol pour les révéler), et chaque contrôle est dimensionné pour un doigt.
- Les champs de formulaire utilisent un texte de 16 px : iOS Safari ne zoome donc pas la page quand l'un d'eux prend le focus.
Téléphones (fenêtre jusqu'à 640 px de large, ou écran tactile jusqu'à 500 px de haut — un téléphone tenu à l'horizontale) :
- Le formulaire de feedback est une feuille en bas d'écran qui reste au-dessus du clavier virtuel. S'il devait recouvrir ce que le client vient de sélectionner, la page défile pour garder la sélection visible.
- Le panneau des feedbacks est une feuille par-dessus la page assombrie — fermez-le avec le ✕, en touchant la page, ou en faisant glisser son en-tête vers le bas. Les filtres tiennent sur une seule ligne défilante, et les menus de type et de tri s'ouvrent en feuilles d'actions.
- Toucher une épingle sur la page ouvre directement le détail de ce feedback. Aller à l'annotation ferme la feuille et fait défiler jusqu'à l'épingle, où que la mise en page de cet écran l'ait placée.
- La demande de nom et d'e-mail est elle aussi une feuille en bas d'écran.
La mise en page respecte l'encoche et l'indicateur d'accueil quand votre page déclare viewport-fit=cover dans sa balise meta viewport. Pour tenir le widget à l'écart des petits écrans, définissez minViewportWidth (par exemple 768).
L'instance
const siteping = initSiteping({ ... });
siteping.open(); // ouvrir le panneau de feedback
siteping.close(); // le fermer
siteping.refresh(); // recharger les feedbacks de la page courante (ne lève jamais)
siteping.focusFeedback(id); // faire défiler jusqu'à un marqueur et le mettre en évidence ; false si inconnu
const off = siteping.on("feedback:sent", (feedback) => { ... });
siteping.destroy(); // démontage complet, restaure tous les globals patchéson() renvoie une fonction de désabonnement. Huit événements publics sont disponibles :
| Événement | Charge utile | Déclenché quand |
|---|---|---|
feedback:sent | le feedback créé | Un envoi a réussi |
feedback:deleted | l'id du feedback | Un feedback a été supprimé |
comment:added | le commentaire enregistré | Une réponse a été postée depuis le fil de discussion d'un feedback |
feedback:error | l'Error | Un appel à l'API de feedback a échoué — même charge utile que le callback de config onError, qui reçoit en plus les échecs des actions du panneau |
panel:open / panel:close | — | Le panneau s'est ouvert / fermé |
annotation:start / annotation:end | — | Une session d'annotation a commencé / s'est terminée. end se déclenche que l'utilisateur ait envoyé ou annulé, et les deux points d'entrée — le bouton flottant et les commentaires au clic droit — émettent la paire symétrique : de quoi mettre en pause vos bulles de chat ou vos overlays d'analytics entre les deux |
React
Utilisez le hook dédié — il survit aux doubles montages du StrictMode et laisse les callbacks changer d'un rendu à l'autre sans réinitialiser :
"use client";
import { useSiteping } from "@siteping/widget/react";
export function Feedback() {
const siteping = useSiteping({
endpoint: "/api/siteping",
projectName: "mon-projet",
onFeedbackSent: (f) => console.log("nouveau feedback", f.id),
});
return null; // le widget se rend tout seul
}Le hook renvoie null jusqu'à son montage, puis l'instance. Changer endpoint ou une autre option structurelle demande un remontage ; changer un callback, non — tous les callbacks acceptés par la config (onFeedbackSent, onCommentAdded, onError, onOpen, onClose, onAnnotationStart, onAnnotationEnd, onSkip) sont lus au moment de l'appel, donc un handler qui capture un état frais se déclenche avec cet état — tout comme les callbacks des actions du panneau. Ils se taisent également après le démontage : une réponse tardive ne peut pas rappeler dans un arbre déjà démonté.
Gérer les erreurs
onError reçoit des erreurs avec deux champs utiles — code ("NETWORK" | "VALIDATION" | "AUTH" | "SERVER" | "TIMEOUT", ce dernier uniquement en mode store) et retryable (booléen). Une erreur AUTH porte aussi le status HTTP : 401 quand les identifiants manquent ou sont morts, 403 quand ils fonctionnent et que la politique du serveur refuse la requête :
initSiteping({
endpoint: "/api/siteping",
projectName: "mon-projet",
onError: (error) => {
const code = (error as { code?: string }).code;
if (code === "AUTH") console.warn("vérifiez votre apiKey");
},
});Branchez sur error.code, pas sur instanceof — les classes d'erreur elles-mêmes ne sont pas exportées par le package. Quand l'utilisateur annule la demande d'identité, aucune erreur n'est émise : c'est une annulation, pas un échec.
Les statuts, côté widget
Un feedback a quatre statuts (open, in_progress, resolved, wont_fix). Le widget affiche les quatre mais ses propres actions sont volontairement binaires : résoudre et rouvrir. Les états intermédiaires appartiennent à votre flux de tri dans le dashboard.
Filtrer le panneau
Les filtres du panneau se combinent et ne restreignent que la liste — les marqueurs de la page continuent d'afficher les feedbacks de la page :
- Recherche : cherche dans le texte du message.
- Type : garde un seul type de feedback.
- Statut : Ouvert couvre
openetin_progress, Résolu couvreresolvedetwont_fix. - Portée : Cette page (par défaut), Ce type — toutes les pages qui partagent le modèle de route courant, proposé quand
getPageScoperenvoie unurlPattern— ou Toutes les pages. - Mes feedbacks : garde les feedbacks envoyés depuis ce navigateur.
« Mes feedbacks » ne demande ni identité ni support côté serveur, en mode HTTP comme en mode store. Le widget retient l'identifiant de chaque feedback qu'il envoie — les 500 plus récents par projet et par endpoint, dans le localStorage, y compris ceux que la file d'attente hors ligne envoie plus tard — et filtre de son côté : il parcourt les pages de la liste jusqu'à les avoir tous trouvés, puis les affiche d'un coup. Le serveur ne peut pas le faire à sa place : son GET public masque les emails des auteurs, et un filtre par email permettrait à n'importe qui de vérifier si une adresse a envoyé des feedbacks. « Mes feedbacks » veut donc dire ce navigateur : les feedbacks envoyés depuis un autre navigateur ou appareil, ou avant l'effacement des données du site, n'y figurent pas.
Par-dessus vos modales
Vos clients ont souvent besoin de signaler un problème dans une boîte de dialogue. Le widget reste utilisable par-dessus une modale ouverte — testé avec le Dialog de Radix / shadcn/ui, et conçu pour toute modale qui fonctionne de la même façon (fermeture au clic extérieur, piège à focus, blocage du défilement, masquage du reste de la page) :
- son bouton, son calque d'annotation, sa popup de commentaire et son panneau restent cliquables, même quand la modale rend le reste de la page insensible aux clics (
body { pointer-events: none }) ; - dessiner, taper, cliquer ou faire défiler dans le widget ne ferme pas la modale et ne la laisse pas reprendre le focus ;
- Échap ferme d'abord la couche ouverte du widget (menu, panneau, popup de commentaire, mode annotation) ; quand rien n'est ouvert dans le widget, il ferme votre modale comme d'habitude ;
- le widget reste exposé aux lecteurs d'écran quand la modale masque le reste de la page (
aria-hidden,inert).
Les événements pointerdown, mousedown, touchstart, touchend, click, focusin, focusout, wheel et touchmove du widget s'arrêtent au widget, modale ou non : les écouteurs en phase de bouillonnement posés sur le document ou la window de votre page ne les voient jamais. Il en va de même des keydown, keypress et keyup des touches tapées dans un champ du widget, Tab et Échap exceptées : un raccourci à une touche de votre page (/ pour la recherche, s…) ne se déclenche plus, en avalant le caractère, pendant la saisie dans le widget. Les écouteurs en phase de capture voient toujours tous ces événements, et les autres (pointerup, mouseup, touches pressées hors d'un champ…) continuent de se propager.
Au-delà, le widget n'intervient que pendant qu'une modale est ouverte — un élément aria-modal="true" visible, ou un <body> insensible aux clics —, donc sur une page sans modale, les gestionnaires de focus et de blur de vos champs voient tous les événements. Pendant qu'une modale est ouverte, les écouteurs en phase de capture ne voient plus non plus les pointerdown et focusin du widget, et passer le focus d'un des champs de la modale au widget ne déclenche pas le focusout de ce champ, donc pas non plus le onBlur de React ; son blur natif, lui, se déclenche toujours. C'est ce qui empêche le piège à focus de la modale de reprendre le focus.
Limites : un <dialog> natif ouvert avec showModal() rend inerte tout ce qui est en dehors de lui, et aucun script ne peut l'annuler — le widget est inutilisable tant qu'un tel dialogue est ouvert. Et une modale qui intercepte les mousedown, click ou touches Tab extérieurs avant qu'ils n'atteignent la page (écouteurs en phase de capture, comme focus-trap par défaut) peut encore gêner le widget.
La fiabilité, sans rien faire
- Réessais avec backoff — les envois échoués sont retentés 3 fois (timeout de 10 s, backoff exponentiel avec jitter). Les erreurs serveur et les pannes réseau sont retentées, les erreurs de validation non.
- File d'attente hors ligne — quand un envoi échoue encore après les réessais, la charge utile est mise en file dans le localStorage (jusqu'à 20 entrées) et vidée au chargement suivant. Seuls les échecs transitoires sont mis en file (pannes réseau, 5xx) : un 4xx est le verdict du serveur sur la charge utile et échouerait à l'identique au rejeu, il est donc signalé via
onErroret jamais mis en file. Chaque renvoi depuis la même popup garde l'identifiant du feedback : il remplace sa tentative en file, et le serveur ne l'enregistre jamais deux fois. La file ne stocke que les charges utiles — jamais de tokens ni d'en-têtes, qui sont recalculés au moment de la purge. Si le localStorage manque de place, les captures en file sont abandonnées d'abord (les plus anciennes en premier), puis les entrées les plus anciennes, et un avertissement dans la console indique ce qui a été abandonné. - Rien ne bloque un envoi — le corps de sa réponse a lui aussi 10 s pour arriver (un serveur qui envoie ses en-têtes puis se fige compte comme un timeout), et une fabrique
headersdoit répondre en 10 s. Les réessais et la file s'appliquent au mode HTTP (endpoint). En modestorecôté client, rien n'est réessayé ni mis en file : le widget cesse d'attendre lecreateFeedbackdu store au bout de 30 s et signale une erreur réessayable (code"TIMEOUT"), mais ne peut pas annuler l'appel — un store personnalisé qui passe par le réseau doit borner lui-même ses appels.