Pulses
Des réactions par fonctionnalité (👍 / 👎) et des commentaires ciblés, autour de n'importe quelle zone de votre interface en JSX. Les réactions forment un résumé du ressenti dans le tableau de bord (« Bouton de paiement : 78 % positifs sur 412 réactions ») ; les commentaires rejoignent le même circuit tri, tableau et roadmap que vos retours texte.
Démarrage rapide
Installez @opini-dev/react 0.1.1 ou plus récent (les Pulses sont livrés avec le package React ; même commande d'installation que le widget). Cinq lignes de JSX suffisent pour avoir des réactions et un champ de commentaire sur n'importe quel composant :
import { OpiniPulse, OpiniReactions, OpiniComment } from "@opini-dev/react"
<OpiniPulse name="checkout-button" projectKey="pk_live_xxx">
<button>Buy now</button>
<OpiniReactions />
<OpiniComment placeholder="Tell us why" />
</OpiniPulse>La première réaction avec un name inconnu enregistre automatiquement le pulse avec un nom lisible ; il apparaît sur la page /pulses du tableau de bord au prochain chargement. Rien à configurer avant de livrer.
Niveau 1 : prêt à l'emploi
Les styles par défaut sont actifs. Affiche les boutons 👍 / 👎 dans votre contenu, plus un champ de commentaire facultatif.
import { OpiniPulse, OpiniReactions, OpiniComment, DefaultStyles } from "@opini-dev/react"
export function ChartCard() {
return (
<article>
<DefaultStyles />
<h2>Engagement over time</h2>
<ChartPNG />
<OpiniPulse name="dashboard-engagement-chart" projectKey="pk_live_xxx">
<OpiniReactions />
<OpiniComment placeholder="What's missing here?" />
</OpiniPulse>
</article>
)
}Passez unstyled sur le <OpiniPulse> parent pour vous passer complètement de l'apparence par défaut.
Niveau 2 : classes par partie
Appliquez des classes Tailwind, CSS Modules ou CSS classique aux parties structurelles. Parties de la paire de boutons : reactions, reactionUp, reactionDown, reactionCount, reactionUpActive, reactionDownActive, reactionDisabled. Pour le formulaire de commentaire : comment, commentTextarea, commentEmailInput, commentNameInput, commentActions, commentSubmit, commentSuccess, commentStatus.
import { OpiniPulse, OpiniReactions, OpiniComment } from "@opini-dev/react"
export function PricingTable() {
return (
<OpiniPulse
name="pricing-table"
projectKey="pk_live_xxx"
className="mt-4 flex items-center gap-3 text-sm"
classNames={{
reactions: "inline-flex items-center gap-2",
reactionUp: "rounded-full border px-2 py-1 hover:bg-emerald-50 data-[active=true]:bg-emerald-100",
reactionDown: "rounded-full border px-2 py-1 hover:bg-rose-50 data-[active=true]:bg-rose-100",
reactionCount: "text-xs text-zinc-500",
comment: "ml-3 flex items-center gap-2",
commentTextarea: "rounded border px-2 py-1 text-xs",
commentSubmit: "rounded bg-zinc-900 px-2 py-1 text-xs text-white",
}}
>
<span className="text-zinc-500">Was this clear?</span>
<OpiniReactions />
<OpiniComment placeholder="What was unclear?" />
</OpiniPulse>
)
}Chaque bouton porte data-state="idle | submitting | reacted-up | reacted-down | error | locked", data-active="true|false" et aria-pressed sur le bouton actif. Avec Tailwind, gérez l'état actif avec data-[active=true]:… sans écrire de JS.
Niveau 3 : composants combinables
Utilisez <OpiniReactionUp /> et <OpiniReactionDown /> pour placer les boutons vous-même : un de chaque côté d'une carte, seulement le positif, des boutons à votre marque avec asChild. Avec des children en fonction, vous obtenez l'état actif et le compteur pour vos propres textes.
import {
OpiniPulse,
OpiniReactionUp,
OpiniReactionDown,
OpiniComment,
} from "@opini-dev/react"
import { ThumbsUp, ThumbsDown } from "lucide-react"
import { Button } from "@/components/ui/button"
export function ReleaseNotesEntry({ slug, title, body }: Props) {
return (
<OpiniPulse name={`release-${slug}`} projectKey="pk_live_xxx">
<header className="flex items-center justify-between">
<h3>{title}</h3>
<div className="flex gap-2">
<OpiniReactionUp asChild>
<Button variant="ghost" size="sm">
{({ active, count }) => (
<>
<ThumbsUp data-active={active} />
<span>{count}</span>
</>
)}
</Button>
</OpiniReactionUp>
<OpiniReactionDown asChild>
<Button variant="ghost" size="sm">
{({ active, count }) => (
<>
<ThumbsDown data-active={active} />
<span>{count}</span>
</>
)}
</Button>
</OpiniReactionDown>
</div>
</header>
<p>{body}</p>
<OpiniComment placeholder="Reply to the team">
<textarea name="message" rows={2} placeholder="Reply…" />
<input name="email" type="email" placeholder="email (optional)" />
<button type="submit">Send</button>
</OpiniComment>
</OpiniPulse>
)
}Le formulaire de commentaire suit le même contrat de champs simples que <OpiniForm> : name="message" est obligatoire ; name="email" et name="name" sont facultatifs. Tout autre champ est ignoré avant le POST.
Niveau 4 : useOpiniPulse (sans interface)
Le hook sans interface fournit react, comment et tout l'état observable : status, countUp, countDown, youReacted, sentiment, locked, error.
import { useOpiniPulse } from "@opini-dev/react"
export function MyCustomChip({ pulseKey }: { pulseKey: string }) {
const { react, status, countUp, countDown, youReacted, sentiment, locked } =
useOpiniPulse({ name: pulseKey, projectKey: "pk_live_xxx" })
if (locked) return <span className="text-zinc-400">Reactions disabled</span>
const total = countUp + countDown
return (
<div className="flex items-center gap-3">
<button
onClick={() => react("up")}
disabled={status === "submitting"}
aria-pressed={youReacted === "up"}
>
👍 {countUp}
</button>
<button
onClick={() => react("down")}
disabled={status === "submitting"}
aria-pressed={youReacted === "down"}
>
👎 {countDown}
</button>
<span>
{total > 0 ? `${Math.round((sentiment ?? 0) * 100)}% positive` : "no reactions yet"}
</span>
</div>
)
}Appelé dans un provider <OpiniPulse>, le hook utilise le contexte (deux consommateurs du même pulse partagent un seul état). Appelé en dehors, il crée son propre état pour la paire (projectKey, name).
Format des requêtes
L'endpoint des réactions est la seule nouvelle route publique. L'endpoint existant /api/v1/ingest gagne un seul champ facultatif, pulse_key : envoyer un commentaire lié à un pulse passe par le même chemin que vous utilisez déjà.
// POST /api/v1/ingest/reactions
{
"project_key": "pk_live_xxx",
"pulse_key": "checkout-button",
"kind": "up", // "up" | "down"
"undo": false, // true cancels the user's prior reaction of the same kind
"email": "[email protected]", // optional; required when pulse.require_email
"context": { /* WidgetContext, optional */ }
}// 200 response
{
"pulse_public_id": "pl_2A6Q…",
"count_up": 412,
"count_down": 116,
"you_reacted": "up" // null when no de-dup hint available
}// POST /api/v1/ingest (existing endpoint, with one new optional field)
{
"project_key": "pk_live_xxx",
"text": "the new layout is great but the export button is hidden",
"email": "[email protected]",
"pulse_key": "dashboard-v2", // NEW, optional; when set, scopes the feedback to a pulse
"context": { /* … */ }
}L'endpoint est limité par IP, projet et clé de pulse (rafale de 60, recharge de 300/min, 5× la limite des retours, puisque réagir se fait d'un geste). Les tentatives avec des clés inconnues consomment aussi la limite : un bot qui cherche des clés valides ne la contourne pas en boucle de 404.
L'option undo fait qu'un second clic sur le même bouton annule : recliquer 👍 envoie kind: "up", undo: true et le serveur efface la réaction ; la réponse contient you_reacted: null. Cliquer sur le bouton opposé bascule directement : undo: false avec le nouveau kind.
Mode verrouillé
Un pulse passe en mode verrouillé quand l'une de ces deux choses arrive côté serveur :
- L'option
pulses_lockeddu projet est activée (l'admin a désactivé l'enregistrement automatique) et le JSX utilise une clé que le serveur ne connaît pas. - Le projet a atteint son plafond quotidien d'enregistrements automatiques (200 nouvelles clés par jour) et une clé inconnue arrive.
La bibliothèque React traite les deux cas en douceur :
- Les deux boutons s'affichent avec
data-state="locked"etaria-disabled="true". Les clics ne font rien. - Un seul
console.warnapparaît en développement ; rien en production. Une fois par pulse, jamais à chaque clic. - Le
onErrorde<OpiniPulse>se déclenche toujours : votre télémétrie enregistre le verrouillage. - Aucune exception n'est levée dans l'arbre React. Le verrouillage dure tant que l'arbre existe : on ne réessaie pas à chaque clic.
Ouvrez /pulses/settings dans le tableau de bord pour désactiver pulses_locked, enregistrer la clé à la main ou relever le plafond quotidien.
Exiger l'e-mail
Les réglages des Pulses proposent l'option require_email (par projet, ou par pulse). Quand elle est active :
- Le POST de réaction doit inclure un
email; sans lui, la réponse est une 400 (traduite en{ ok:false, kind:"bad" }parpostReaction). <OpiniPulse requireEmail>(ourequireEmailhérité des réglages du projet) affiche le champ e-mail avant la première réaction. Ensuite, l'e-mail est mémorisé et les clics suivants ne le redemandent pas.- Côté serveur, les réactions sont uniques par
(pulse_id, email): une réaction par e-mail et par pulse, quel que soit le nombre de clics. Les réactions anonymes (sans e-mail) sont regroupées au mieux par IP et navigateur.
Événements webhook
Trois nouveaux types d'événements. Ils utilisent la même configuration de webhooks (format Discord via is_discord_compat).
// pulse.created — fires once on auto-registration (or admin create)
{
"pulse_id": "01J…",
"pulse_public_id": "pl_…",
"project_id": "01J…",
"key": "checkout-button",
"name": "Checkout button",
"auto_registered": true,
"created_at": "2026-05-04T14:31:18Z"
}// pulse.reacted — debounced 60 s, then emitted with the delta over that window
{
"pulse_id": "01J…",
"pulse_public_id": "pl_…",
"project_id": "01J…",
"key": "checkout-button",
"name": "Checkout button",
"window_started_at": "2026-05-04T14:30:18Z",
"window_ended_at": "2026-05-04T14:31:18Z",
"delta_up": 47,
"delta_down": 3,
"total_up": 412,
"total_down": 116
}// pulse.commented — fires per comment
{
"pulse_id": "01J…",
"pulse_public_id": "pl_…",
"project_id": "01J…",
"feedback_id": "01J…",
"feedback_public_id": "fb_…",
"submitted_in_triage": true,
"preview": "the new layout is great but the export button is hidden",
"submitter_email": "[email protected]",
"created_at": "2026-05-04T14:31:18Z"
}pulse.reacted est regroupé : un bouton cliqué 1000 fois en une minute déclenche un seul webhook avec le total, pas 1000. La fenêtre de regroupement est en mémoire (60 s) : en cas de plantage, les totaux de ce webhook sont perdus (les tables de synthèse, elles, sont durables). Le preview de pulse.commented contient les 160 premiers caractères du texte, sans retours à la ligne ; pour le texte complet, passez par l'API.
Auto-hébergement
Les Pulses sont toujours actifs. Pas de variable d'environnement, pas d'étape d'activation, pas de migration. Pointez votre widget ou @opini-dev/react vers votre baseUrl auto-hébergé et l'endpoint des réactions fonctionne sur toute version récente.
<OpiniPulse
name="checkout-button"
projectKey="pk_live_xxx"
baseUrl="https://feedback.your-corp.com"
>
<button>Buy now</button>
<OpiniReactions />
</OpiniPulse>Les réglages par projet (verrouiller l'enregistrement automatique, relever le plafond, exiger l'e-mail, exclure les commentaires du tri) se trouvent sur la page /pulses/settings du tableau de bord.
Roadmap : v1.1
Prévu pour la v1.1 :
- Pulses sans code. Ciblez un sélecteur CSS depuis le tableau de bord, sans JSX. Utile quand le produit suit un cycle de sprint différent de l'intégration Opini.
- Mini-graphiques dans la liste des pulses. La tendance sur 14 jours d'un coup d'œil. La v1.0 n'affiche que les totaux et le % de ressenti.
- Badges publics de ressenti. Un composant facultatif
<OpiniSentiment />pour montrer le pourcentage aux visiteurs. Soumis au réglage du projet sur la visibilité publique. - Pulses via la balise script. La v1.0 privilégie React. Un attribut
data-opini-pulse="…"sur le widget en balise script est prévu dès qu'il y aura de la demande.