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:
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.
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.
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.
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.
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.
// 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": { /* … */ }
}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_lockeddo 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"earia-disabled="true". Os cliques não fazem nada. - Um único
console.warnaparece em desenvolvimento; em produção, silêncio. Ele sai uma vez por pulse, nunca a cada clique. - O
onErrordo<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" }pelopostReaction). <OpiniPulse requireEmail>(ourequireEmailherdado 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).
// 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 é 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.
<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.