Zum Inhalt springen

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:

tsx
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.

tsx
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.

tsx
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.

tsx
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.

tsx
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.

jsonc
// 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 */ }
}
jsonc
// 200 response
{
  "pulse_public_id": "pl_2A6Q…",
  "count_up": 412,
  "count_down": 116,
  "you_reacted": "up"           // null when no de-dup hint available
}
jsonc
// 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_locked ist 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" und aria-disabled="true". Klicks tun nichts.
  • Ein einziges console.warn in der Entwicklung; in Produktion still. Einmal pro Pulse, nie bei jedem Klick.
  • onError an <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 email enthalten; ohne gibt es 400 (von postReaction als { ok:false, kind:"bad" } zurückgegeben).
  • <OpiniPulse requireEmail> (oder requireEmail aus 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).

jsonc
// 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"
}
jsonc
// 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
}
jsonc
// 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.

tsx
<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.