Pular para o conteúdo

Pulses

Reações por recurso (👍 / 👎) e comentários direcionados, em volta de qualquer parte da sua interface em JSX. As reações viram um resumo de sentimento no painel ("Botão de checkout: 78% positivo em 412 reações"); os comentários entram no mesmo fluxo de triagem, quadro e roadmap onde seu feedback em texto já vive.

Início rápido

Instale @opini-dev/react 0.1.1 ou mais recente (os Pulses vêm com o pacote React; mesmo comando de instalação do widget). Cinco linhas de JSX dão reações e um campo de comentário em qualquer componente:

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>

A primeira reação com um name nunca visto registra o pulse automaticamente com um nome legível; ele aparece na página /pulses do painel na próxima vez que um admin abrir. Nada para configurar no painel antes de publicar.

Nível 1: pronto para usar

Os estilos padrão vêm ligados. Mostra os botões 👍 / 👎 junto do seu conteúdo, mais um campo de comentário opcional.

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>
  )
}

Passe unstyled no <OpiniPulse> pai para dispensar totalmente o visual padrão.

Nível 2: classes por parte

Aplique classes do Tailwind, CSS Modules ou CSS puro às partes estruturais. Partes do par de botões: reactions, reactionUp, reactionDown, reactionCount, reactionUpActive, reactionDownActive, reactionDisabled. Para o formulário de comentário: 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>
  )
}

Cada botão tem data-state="idle | submitting | reacted-up | reacted-down | error | locked", data-active="true|false" e aria-pressed no botão ativo. Quem usa Tailwind controla o visual ativo com data-[active=true]:… sem escrever JS.

Nível 3: peças combináveis

Use <OpiniReactionUp /> e <OpiniReactionDown /> quando quiser posicionar os botões você mesmo: um de cada lado de um cartão, só o positivo, botões da sua marca com asChild. Com children como função você recebe o estado ativo e a contagem para seus próprios textos.

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>
  )
}

O formulário de comentário segue o mesmo contrato de campos simples do <OpiniForm>: name="message" é obrigatório; name="email" e name="name" são opcionais. Qualquer outro campo é descartado antes do POST.

Nível 4: useOpiniPulse (sem interface)

O hook sem interface entrega react, comment e todo o estado observável: 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>
  )
}

Chamado dentro de um provider <OpiniPulse>, o hook usa o contexto (dois consumidores no mesmo pulse compartilham um único estado). Fora dele, cria seu próprio estado para o par (projectKey, name).

Formato na rede

O endpoint de reações é a única rota pública nova. O endpoint /api/v1/ingest existente ganha exatamente um campo opcional novo, pulse_key, então enviar um comentário ligado a um pulse usa o mesmo caminho que você já configurou.

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": { /* … */ }
}

O endpoint tem limite por IP, projeto e chave do pulse (rajada de 60, recarga de 300/min, 5× o limite de feedback, já que reagir é um toque só). Tentativas com chaves desconhecidas também consomem o limite, então um bot procurando chaves válidas não escapa com loops de 404.

A opção undo faz o clique no mesmo botão cancelar: clicar 👍 de novo envia kind: "up", undo: true e o servidor apaga a reação; a resposta traz you_reacted: null. Clicar no botão oposto troca direto: undo: false com o novo kind.

Modo bloqueado

Um pulse entra em modo bloqueado quando uma de duas coisas acontece no servidor:

  • A opção pulses_locked do projeto está ligada (o admin desativou o registro automático) e o JSX usa uma chave que o servidor não conhece.
  • O projeto atingiu o limite diário de registros automáticos (200 chaves novas por dia) e chega uma chave nunca vista.

A biblioteca React trata os dois casos sem alarde:

  • Os dois botões aparecem com data-state="locked" e aria-disabled="true". Os cliques não fazem nada.
  • Um único console.warn aparece em desenvolvimento; em produção, silêncio. Ele sai uma vez por pulse, nunca a cada clique.
  • O onError do <OpiniPulse> continua disparando, então sua telemetria registra o bloqueio.
  • Nenhuma exceção é lançada na árvore React. O bloqueio vale enquanto a árvore existir, então não tentamos de novo a cada clique.

Abra /pulses/settings no painel para desligar pulses_locked, registrar a chave à mão ou aumentar o limite diário.

Exigir e-mail

As configurações dos Pulses têm a opção require_email (por projeto, ou por pulse). Quando ligada:

  • O POST da reação precisa incluir um email; sem ele, a resposta é 400 (mapeada para { ok:false, kind:"bad" } pelo postReaction).
  • <OpiniPulse requireEmail> (ou requireEmail herdado das configurações do projeto) mostra o campo de e-mail antes da primeira reação. Depois, o e-mail fica guardado e os próximos cliques não perguntam de novo.
  • No servidor, as reações são únicas por (pulse_id, email): uma reação por e-mail por pulse, não importa quantas vezes a pessoa clique. Reações anônimas (sem e-mail) usam um agrupamento aproximado por IP e navegador.

Eventos de webhook

Três novos tipos de evento. Eles usam a mesma configuração de webhooks (com o formato do Discord via 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 é agrupado: um botão que viraliza e recebe 1000 cliques em um minuto dispara um webhook com o total, não 1000. A janela de agrupamento fica em memória (60 s), então numa queda do servidor os totais daquele webhook se perdem (as tabelas de resumo são duráveis). O preview de pulse.commented são os primeiros 160 caracteres do texto, sem quebras de linha; quem precisa do texto completo busca pela API.

Hospedagem própria

Os Pulses vêm sempre ligados. Não há variável de ambiente, nem passo de ativação, nem migração. Aponte seu widget ou o @opini-dev/react para o seu baseUrl próprio e o endpoint de reações funciona em qualquer versão atual.

tsx
<OpiniPulse
  name="checkout-button"
  projectKey="pk_live_xxx"
  baseUrl="https://feedback.your-corp.com"
>
  <button>Buy now</button>
  <OpiniReactions />
</OpiniPulse>

Os ajustes por projeto (bloquear registro automático, aumentar o limite diário, exigir e-mail, tirar comentários da triagem) ficam na página /pulses/settings do painel.

Roadmap: v1.1

Chegando na v1.1:

  • Pulses sem código. Marque um seletor CSS no painel, sem JSX. Útil para times cujo produto tem um ciclo de sprint separado da integração com o Opini.
  • Minigráficos na lista de pulses. Tendência de 14 dias num relance. A v1.0 mostra só totais e % de sentimento.
  • Selos públicos de sentimento. Uma peça opcional <OpiniSentiment /> para mostrar o percentual a visitantes. Depende da opção do projeto de deixar páginas públicas visíveis.
  • Pulses pela tag de script. A v1.0 é React primeiro. Um atributo data-opini-pulse="…" no widget por script está na lista assim que houver demanda.