Pulses
Reaktionen pro Funktion (👍 / 👎) und gezielte Kommentare, als JSX um jeden Bereich deiner Oberfläche gelegt. Reaktionen ergeben im Dashboard eine Stimmungszeile („Checkout-Button: 78 % positiv bei 412 Reaktionen“); Kommentare landen in derselben Triage, demselben Board und derselben Roadmap wie dein übriges Feedback.
Schnellstart
Installiere @opini-dev/react 0.1.1 oder neuer (Pulses kommen mit dem React-Paket; derselbe Installationsbefehl wie beim Widget). Fünf Zeilen JSX bringen Reaktionen und ein Kommentarfeld an jede Komponente:
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>Die erste Reaktion mit einem unbekannten name registriert den Pulse automatisch mit einem lesbaren Namen; er erscheint auf der Seite /pulses im Dashboard, sobald ein Admin sie das nächste Mal lädt. Vor dem Ausliefern ist nichts einzurichten.
Stufe 1: fertig zum Einbauen
Die Standard-Styles sind aktiv. Zeigt die Buttons 👍 / 👎 in deinem Inhalt, dazu ein optionales Kommentarfeld.
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>
)
}Gib unstyled am umschließenden <OpiniPulse> an, um auf das Standard-Aussehen ganz zu verzichten.
Stufe 2: Klassen pro Bereich
Gib den strukturellen Bereichen Klassen aus Tailwind, CSS Modules oder normalem CSS. Bereiche des Button-Paars: reactions, reactionUp, reactionDown, reactionCount, reactionUpActive, reactionDownActive, reactionDisabled. Für das Kommentarformular: 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>
)
}Jeder Button trägt data-state="idle | submitting | reacted-up | reacted-down | error | locked", data-active="true|false" und am aktiven Button aria-pressed. Mit Tailwind steuerst du den aktiven Zustand über data-[active=true]:…, ganz ohne JS.
Stufe 3: kombinierbare Teile
Nimm <OpiniReactionUp /> und <OpiniReactionDown />, wenn du die Buttons selbst platzieren willst: je einer an jeder Seite einer Karte, nur Daumen hoch, eigene Buttons per asChild. Mit Children als Funktion bekommst du Aktiv-Zustand und Zähler für eigene Texte.
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>
)
}Das Kommentarformular folgt demselben Vertrag für normale Felder wie <OpiniForm>: name="message" ist Pflicht; name="email" und name="name" sind optional. Alles andere wird vor dem POST verworfen.
Stufe 4: useOpiniPulse (ohne Oberfläche)
Der Hook ohne Oberfläche liefert react, comment und den ganzen beobachtbaren Zustand: 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>
)
}Innerhalb eines <OpiniPulse>-Providers nutzt der Hook den Kontext (zwei Nutzer im selben Pulse teilen sich einen Zustand). Außerhalb legt er für das Paar (projectKey, name) einen eigenen Zustand an.
Datenformat
Der Reaktions-Endpunkt ist die einzige neue öffentliche Route. Der bestehende Endpunkt /api/v1/ingest bekommt genau ein neues optionales Feld, pulse_key, ein Kommentar zu einem Pulse nutzt also denselben Weg, den du schon eingerichtet hast.
// 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": { /* … */ }
}Der Endpunkt ist pro IP, Projekt und Pulse-Schlüssel begrenzt (Burst 60, Nachfüllen 300/min, das 5-Fache des Feedback-Limits, weil Reagieren nur ein Tipp ist). Versuche mit unbekannten Schlüsseln verbrauchen das Limit auch, ein Bot auf der Suche nach gültigen Schlüsseln kommt mit 404-Schleifen also nicht vorbei.
Die Option undo sorgt dafür, dass ein zweiter Klick auf denselben Button aufhebt: Ein erneuter Klick auf 👍 sendet kind: "up", undo: true, und der Server löscht die Reaktion; die Antwort enthält you_reacted: null. Ein Klick auf den anderen Button wechselt direkt: undo: false mit dem neuen kind.
Gesperrter Modus
Ein Pulse geht in den gesperrten Modus, wenn auf dem Server eins von zwei Dingen passiert:
- Die Projekteinstellung
pulses_lockedist an (der Admin hat die automatische Registrierung abgeschaltet), und das JSX nutzt einen Schlüssel, den der Server nicht kennt. - Das Projekt hat sein Tageslimit für automatische Registrierungen erreicht (200 neue Schlüssel pro Tag), und ein unbekannter Schlüssel kommt an.
Die React-Bibliothek behandelt beides leise:
- Beide Buttons erscheinen mit
data-state="locked"undaria-disabled="true". Klicks tun nichts. - Ein einziges
console.warnin der Entwicklung; in Produktion still. Einmal pro Pulse, nie bei jedem Klick. onErroran<OpiniPulse>feuert trotzdem, deine Telemetrie erfasst die Sperre also.- Es wird keine Exception in den React-Baum geworfen. Die Sperre gilt, solange der Baum besteht, wir versuchen es also nicht bei jedem Klick neu.
Öffne /pulses/settings im Dashboard, um pulses_locked abzuschalten, den Schlüssel von Hand zu registrieren oder das Tageslimit anzuheben.
E-Mail verlangen
Die Pulse-Einstellungen haben die Option require_email (pro Projekt oder pro Pulse). Wenn sie an ist:
- Der POST für eine Reaktion muss eine
emailenthalten; ohne gibt es 400 (vonpostReactionals{ ok:false, kind:"bad" }zurückgegeben). <OpiniPulse requireEmail>(oderrequireEmailaus den Projekteinstellungen) zeigt das E-Mail-Feld vor der ersten Reaktion. Danach wird die E-Mail gemerkt, und weitere Klicks fragen nicht erneut.- Auf dem Server sind Reaktionen pro
(pulse_id, email)eindeutig: eine Reaktion pro E-Mail und Pulse, egal wie oft geklickt wird. Anonyme Reaktionen (ohne E-Mail) werden so gut es geht nach IP und Browser gebündelt.
Webhook-Events
Drei neue Event-Arten. Sie nutzen dieselbe Webhook-Konfiguration (Discord-Format über 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 wird gebündelt: Ein Button, der in einer Minute 1000-mal geklickt wird, löst einen Webhook mit der Summe aus, nicht 1000. Das Bündelungsfenster liegt im Speicher (60 s), bei einem Absturz gehen die Summen dieses Webhooks also verloren (die Übersichtstabellen bleiben erhalten). Das preview von pulse.commented sind die ersten 160 Zeichen des Texts ohne Zeilenumbrüche; den vollen Text holst du über die API.
Selbst hosten
Pulses sind immer aktiv. Keine Umgebungsvariable, kein Freischalten, keine Migration. Richte dein Widget oder @opini-dev/react auf deine eigene baseUrl, und der Reaktions-Endpunkt funktioniert mit jeder aktuellen Version.
<OpiniPulse
name="checkout-button"
projectKey="pk_live_xxx"
baseUrl="https://feedback.your-corp.com"
>
<button>Buy now</button>
<OpiniReactions />
</OpiniPulse>Einstellungen pro Projekt (automatische Registrierung sperren, Tageslimit anheben, E-Mail verlangen, Kommentare aus der Triage heraushalten) findest du auf der Seite /pulses/settings im Dashboard.
Roadmap: v1.1
Kommt mit v1.1:
- Pulses ohne Code. Markiere im Dashboard einen CSS-Selektor, ohne JSX. Praktisch für Teams, deren Produkt einen anderen Sprint-Rhythmus hat als die Opini-Integration.
- Mini-Diagramme in der Pulse-Liste. Der 14-Tage-Trend auf einen Blick. v1.0 zeigt nur Summen und Stimmung in %.
- Öffentliche Stimmungs-Badges. Ein optionaler Baustein
<OpiniSentiment />, der Besuchern den Prozentwert zeigt. Abhängig von der Projekteinstellung für öffentliche Sichtbarkeit. - Pulses per Script-Tag. v1.0 setzt auf React. Ein Attribut
data-opini-pulse="…"für das Script-Tag-Widget steht auf der Liste, sobald es Nachfrage gibt.