Widget

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/widget
import { 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) :

  1. Rendu côté serveur — pas de window ? Le widget passe son tour avec onSkip("ssr"). Jamais contournable.
  2. Déjà initialisé — un second appel à initSiteping() renvoie l'instance existante (un seul widget par page). destroy() remet le compteur à zéro.
  3. Production — quand process.env.NODE_ENV === "production", le widget passe son tour avec onSkip("production"). C'est le comportement « les clients le voient pendant la relecture, les visiteurs jamais ». Contournez-le avec forceShow: true pour les environnements de staging ou de preview. À noter : seul process.env.NODE_ENV est lu — pas import.meta.env.
  4. Largeur minimale (optionnelle) — en dessous de minViewportWidth, il passe son tour avec onSkip("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é par forceShow.
  5. Validation de la config — pas d'endpoint/store, ou pas de projectName ? Le widget affiche un console.error et ne fait rien. Ce cas-là n'appelle pas onSkip — regardez la console, pas le callback.

Modifié en 0.11 : minViewportWidth valait 768 par 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, et onSkip("mobile") ne se déclenche plus. Mettez minViewportWidth: 768 pour garder l'ancien comportement. Avec enableRightClickComment activé, 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és

on() renvoie une fonction de désabonnement. Huit événements publics sont disponibles :

ÉvénementCharge utileDéclenché quand
feedback:sentle feedback crééUn envoi a réussi
feedback:deletedl'id du feedbackUn feedback a été supprimé
comment:addedle commentaire enregistréUne réponse a été postée depuis le fil de discussion d'un feedback
feedback:errorl'ErrorUn 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 open et in_progress, Résolu couvre resolved et wont_fix.
  • Portée : Cette page (par défaut), Ce type — toutes les pages qui partagent le modèle de route courant, proposé quand getPageScope renvoie un urlPattern — 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 onError et 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 headers doit répondre en 10 s. Les réessais et la file s'appliquent au mode HTTP (endpoint). En mode store côté client, rien n'est réessayé ni mis en file : le widget cesse d'attendre le createFeedback du 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.
Modifier sur GitHub

Sur cette page