Skip to main content
Uma checklist é o painel de “primeiros passos” dentro do seu produto: alguns passos, cada um feito ou não, com um funil de conclusão atrás dizendo onde as pessoas param. O que ela tem de incomum é o que você não pode fazer: não existe endpoint que marque um passo como feito.

Um passo é satisfeito, nunca marcado

Três tipos, e nenhum deles aceita escrita. O que cada um é, e o que cada um garante, está mais abaixo — esta seção é sobre a regra que os três compartilham. Não existe caminho de escrita contra o progresso, e essa ausência é o desenho. Uma marcação que o cliente de alguém pode enviar é uma checklist que diz “você conectou seu domínio” para quem não conectou, porque uma requisição foi repetida ou um botão foi clicado por otimismo. Um passo satisfeito pelo fato que o satisfaz é um passo que não pode estar errado. Vale para o clique também. Um passo pode carregar um destino e alguém pode apertá-lo — e apertar não satisfaz nada. O link é a porta, não a prova de que alguém atravessou. O corolário: o que você quer que apareça numa checklist precisa ser um fato antes. Um passo nomeia um fato sobre uma PESSOA — um contato ou um vínculo, ou um evento seu. Um fato sobre um time ou uma assinatura teria que ser espalhado pelo quadro inteiro para ser atribuído, e “todo mundo completou configurar cobrança porque uma pessoa completou” não é como um funil é lido.

Ela conta a partir do momento em que é ligada

Um fato publicado antes de a checklist existir não conta. O barramento não é um event store, então não há o que reproduzir — e uma regra que preenchesse alguns passos retroativamente e outros não produziria um funil cujos números não podem ser comparados entre si. Na prática: crie a checklist, ligue, e leia o funil contra a coorte que chegou depois. Uma checklist ligada hoje não conta o que os usuários do ano passado fizeram. active é um interruptor reversível e não um arquivamento, porque nada fora desta API nomeia uma checklist por id.

Renderizando

position aceita inline (padrão), center, bottom-right, bottom-center ou bottom-left — as mesmas cinco que a pesquisa e o quadro de sugestões aceitam. Dois painéis flutuantes nunca dividem um canto. Um guia e um <HelpWidget /> no mesmo canto se cobriam, e o único conselho era pôr um à esquerda — uma decisão de layout tomada no seu lugar para contornar um bug nosso. Agora o canto comporta um painel: abrir a ajuda pede ao guia que saia de lado, e fechar a ajuda o devolve. O guia sai de lado saindo mesmo, e o que torna isso seguro é a volta: um guia que sumisse quando alguém abre a ajuda leria como a ajuda ter fechado o onboarding da pessoa, enquanto um que volta no instante em que a ajuda fecha lê como o canto sendo compartilhado. Recolher era a alternativa óbvia e não resolve — um card no mesmo canto continua no canto. A ajuda não volta no sentido contrário: alguém a abriu, algo aberto depois tomou o canto, e uma janela de suporte que reaparecesse sozinha seria uma janela que a pessoa fechou. Flutuando, o cabeçalho carrega dois gestos, e eles são de fato diferentes. Recolher encolhe o guia para um card — o título, a barra de progresso e a única coisa a fazer em seguida. Não é um widget menor no canto; é o mesmo widget dizendo menos, e ele volta a abrir pelo próprio controle. Fechar tira da tela e não deixa nada flutuando. Um painel que responde “fechar” com um painel menor no mesmo canto não fechou. O caminho de volta é um controle que você posiciona:
O <OnboardingLauncher /> desenha o mesmo progresso do guia e o abre. Ele não desenha nada até um <OnboardingChecklist /> na página dizer que há o que abrir, então nunca vira um botão que não abre nada — e nada enquanto o guia flutuante está aberto, porque um painel sobre a página e um pill no header dizendo a mesma coisa é um a mais. Passe whileOpen para mantê-lo nos dois estados, numa barra que reflowaria de forma visível a cada aparição. Um guia inline nunca o esconde: inline vive no fluxo da página e pode estar três telas abaixo, então um pill que sumisse por causa dele não deixaria caminho de volta. Do seu código, onboardingGuide.show() faz o mesmo.

O que dá para tirar do pill

count é para um header já denso, ou um onde o pill fica ao lado de outros números e um segundo compete com eles. O número continua indo para o leitor de tela — quem não vê o anel não fica sem a informação. border={false} é para uma barra que já tem a própria moldura, onde mais um contorno é uma caixa em volta de algo que ninguém pediu para separar. O padding lateral sai junto, porque ele existia para afastar o texto de uma borda que não é mais desenhada. label é para quando o dicionário viraria dois dicionários: o mesmo produto chamando de “Configuração” num header e “Primeiros passos” numa página de ajustes. Se for o nome em toda parte, o lugar é localization no provider — ele alcança todas as superfícies de uma vez.

Os gestos, e até onde eles vão

Minimizar e fechar são lembrados pela visita. Os dois ficam em sessionStorage, com a chave do contato: um F5 não desfaz — é a mesma visita — e fechar a aba desfaz, porque quem volta na semana seguinte merece o guia e não um card que minimizou e esqueceu. A chave leva o contato porque sessionStorage é por origem e não por pessoa; sem isso, num computador compartilhado o gesto de um vira o estado do outro. São um gesto só em duas profundidades, e por um tempo só o menor sobreviveu ao F5 — que é a metade mais estranha de explicar: um reload devolvia por cima da página o card que a pessoa acabara de tirar dali. Reabrir pelo seu próprio botão esquece o fechar na hora; a decisão mais recente é a que vale. Ceder o canto não conta como fechar. Quando o painel de ajuda ocupa o mesmo canto, o guia sai de cena e volta quando a ajuda fecha — um recuo nosso, não uma resposta de quem está lendo, e um F5 no meio disso não custa o guia pelo resto da visita. Nenhum dos dois conta nada para a API. O fechar que persiste de verdade é outro, e está logo abaixo. O título do card é o próprio toggle — é onde a mão vai antes do chevron. A linha do card faz o passo, não abre o guia. O card existe para nomear a única coisa que falta; uma linha que nomeia e depois responde com uma lista está pedindo que a pessoa procure ali o que acabou de ler. Com destino ela é um link de verdade (clique do meio, copiar endereço); sem destino e sem onStepAction, aí sim ela abre o guia, que é o que sobra a oferecer. O fechar que persiste mora no menu de objetivos, atrás do Editar — “fechar tudo por um tempo”. Ele dispensa cada lista do guia, cada uma pelo próprio snooze_days, e elas voltam sozinhas. Está ali e não no cabeçalho porque é um tipo de ato diferente dos dois ao lado, e porque aquele menu já é a tela sobre o que a pessoa quer no guia dela. Do seu código é o userkit.dismissChecklist(id), uma lista por vez.

O que satisfaz um passo

Três tipos, e a diferença entre os dois de evento é quem publica.

Da pessoa ou do time

Todo passo tem um scope, e o padrão é contact: uma pessoa, um passo. É o que todo passo significava antes deste campo existir. scope: "customer" é para o trabalho que é da conta, não de ninguém em particular — conectar a plataforma de pagamento, importar um catálogo, configurar um domínio. Ele fica satisfeito para todo mundo do time assim que qualquer membro faz:
Sem isso, o segundo sócio de um clube vê “conectar a plataforma” em cinza — sendo cobrado, por nós, de um trabalho que o colega já terminou. Nada é gravado para o time. A linha de progresso continua sendo da pessoa que fez; isso é a mesma linha respondendo uma pergunta diferente, na leitura, dentro de uma requisição que já resolveu de qual time se trata — que é exatamente o arranjo do entitlement, um tipo ao lado. É por isso que o argumento contra espalhar um fato por um quadro inteiro continua de pé: nada é espalhado. Duas consequências que valem saber:
  • O funil conta TIMES num passo de escopo de time, e pessoas nos outros. O scope volta junto do número na leitura de conclusão, porque 40 e 40 em unidades diferentes é uma tabela onde duas respostas parecem concordar.
  • A lista inteira passa a ter completed: null, como já acontece com um passo entitlement — e pela mesma razão. Quem escreve a marca de “terminou a lista” é um consumidor, que não tem requisição e portanto não tem time; materializar uma significaria escolher um time pela pessoa e estar errado em todos os outros de que ela participa. O passo continua com o número dele; a lista responde “não dá para dizer” em vez de contradizer o widget.
  • Sem time resolvido, cai para a própria pessoa. Um contato sem time não é um contato cujo time não fez nada — não há time. É também o estado de todo mundo antes de espelhar as contas.
scope é congelado depois de criado, como kind: virá-lo com gente segurando progresso mudaria o que as linhas delas significam. Num passo entitlement ele é recusado — esse tipo já é uma pergunta sobre o time por construção. O passo product_event é como se expressa aquilo de que um onboarding realmente é feito — “criou o primeiro projeto”, “publicou algo”, “convidou alguém pelo seu próprio fluxo”. Você manda o evento que já manda:
Nada nessa chamada menciona checklist. Se existir um passo ativo esperando esse nome naquele ambiente, o evento é encaminhado para o nosso barramento e o passo é satisfeito para aquela pessoa; se não existir, custa uma consulta a um mapa e segue sendo um evento de analytics comum.
A garantia dele é mais fraca que a dos outros dois, e vale saber qual. O analytics descarta eventos quando o buffer enche, sob carga; o nosso barramento não. Depois que o evento é gravado, o resto do caminho é do barramento e a garantia é a mesma de todo o resto — mas o começo desse caminho pode perder um, e um evento perdido é um passo que fica cinza.
Um nome não pode ser recusado por não existir. O seu vocabulário de eventos é seu e é aberto, então a API confere o formato e mais nada — o que também quer dizer que dá para escrever o guia antes de instrumentar o produto, nessa ordem. O que o painel faz no lugar é dizer o que sabe: visto 1.284×, por último hoje, ou este ambiente nunca recebeu esse evento. Um zero ali é quase sempre erro de digitação, e é o único aviso que vai existir. Visitantes anônimos ficam de fora: o progresso é de um contato identificado, e o widget cunha um visitante por carregamento de página. E o fato pode nascer no seu backend. Metade do que um onboarding mede não acontece num browser — um pagamento confirmado por webhook, um contrato assinado, uma exportação que um worker terminou. Esses vão por POST /v1/track com a chave secreta do ambiente, e cada linha nomeia o contato a que pertence:
É a mesma máquina daqui em diante: o nome bate com o passo, o passo é satisfeito para aquela pessoa. A diferença é só quem manda. POST /v1/events — a porta que o track() do browser usa — não consegue nomear ninguém, e um evento sem contato não satisfaz passo de ninguém: se o seu passo depende de um fato do servidor, é esta a porta.

Para onde um passo leva

Um guia que nomeia o trabalho e esconde a porta faz o leitor navegar duas vezes. Então um passo carrega o próprio destino:
O action_url aceita um caminho do seu próprio app (/settings/domain) ou uma URL http(s) absoluta — o mesmo par que uma notificação e um banner aceitam. A forma relativa a protocolo é recusada: //evil.example/x começa com barra, passa por caminho em qualquer revisão, e é uma URL absoluta para o host de outra pessoa. O widget desenha esse passo como um link de verdade, não como um botão que navega — clique do meio, copiar endereço e abrir em outra aba são a razão de um link ser um link. A linha inteira é o alvo e uma seta diz isso; uma URL absoluta abre em nova aba, um caminho fica no app. O action_label não é desenhado na linha. Uma palavra à direita de toda linha clicável é uma coluna de verbos competindo com os títulos, e a seta já diz que a linha abre. O que ele ainda faz é nomear a ação para um leitor de tela, e declarar que um passo sem destino tem uma — aquela que o seu próprio código trata. Um passo concluído continua com a porta aberta. Feito não é sumido: quem conectou o domínio semana passada ainda quer aquela tela.

A frase que não cabe na linha

Um passo é uma linha, e isso é o ponto — um guia que se lê de relance é um guia que as pessoas leem. Mas alguns passos precisam de uma segunda frase: por que este está travado, o que a coisa é de fato, o que ela comprou. Essa frase vai no hover, depois de um segundo:
Dois textos, e o par é a funcionalidade. A mesma linha quer dizer coisas diferentes antes e depois. Pendente, a frase útil é um motivo ou uma instrução; concluído, uma instrução é ruído e o útil é o que aquilo comprou. Um campo só obrigaria você a escrever uma frase errada na metade das vezes — e uma frase errada na metade das vezes é uma frase que ninguém escreve. Os dois são opcionais e independentes. Só o hint_pending é o caso comum e lê certo: nada no hover depois de feito, que é o tanto honesto a dizer sobre uma coisa que a pessoa já fez. Uma checklist aceita um terceiro, o hint_locked, porque um título tem um estado que uma linha não tem. O widget já desenha um cadeado e o nome do que o grupo espera, e um nome é uma porta, não um motivo: Um passo tem só dois, e a ausência é deliberada: um passo não tem estado travado. O que parece passo travado em outros produtos é passo dentro de um grupo travado, e um grupo travado é desenhado fechado — então um terceiro campo ali seria configuração que nunca consegue aparecer. O caso mais próximo é um passo de entitlement de um recurso que o time não tem, e esse é honestamente pendente: “disponível no Pro” é hint_pending. No máximo 200 caracteres. Mais que isso é um parágrafo no hover, que some no instante em que a pessoa move o mouse para ler.

Ele fica verde enquanto a pessoa está olhando

Um passo espera um fato chegar no nosso barramento, o que leva alguns segundos depois do track(). O widget não faz polling — um poll correria com o dreno em vez de ler um número vivo — então, sem ajuda, o check só apareceria no próximo carregamento, muito depois do momento em que ele significava alguma coisa. Por isso o track() também avisa localmente:
Nada disso é escrita. O evento viaja do jeito de sempre, a API grava, e quem satisfaz o passo continua sendo o barramento — isto só conta para o que está na tela que alguém fez a coisa, segundos antes da verdade chegar. O guia relê duas vezes para trocar o palpite pela resposta, e se o fato não chegar, o check é retirado: um evento pode ser descartado sob carga, recusado por formato, ou ser de um visitante para quem nada grava progresso — e um check que ficasse seria o widget mentindo pelo resto da sessão. A marca otimista também não decide nada. O completed continua sendo a resposta do servidor, então nenhuma lista parabeniza ninguém, se esconde, nem grava conclusão em cima de um palpite. Para o app que termina algo sem chamar track():

Quando a ação não é uma navegação

Metade do que um passo quer fazer no clique é abrir a sua própria modal, começar um tour, ou focar um campo na página em que a pessoa já está. Nada disso tem endereço, então não vive no banco — o seu código é que vive:
O handler roda antes do link, e devolver true cancela a navegação. É isso que permite um único handler interceptar os dois passos que o seu app conhece e deixar o resto navegar. Um passo cuja ação é só uma função tem action_label e nenhum action_url — ele declara que existe uma porta e que a sua página é quem abre. Numa página que nunca ligou o onStepAction, esse passo desenha um controle que não faz nada, e é por isso que o painel avisa ao lado do campo.
Clicar nunca satisfaz um passo, em nenhuma das duas formas. Se o clique é o que você está medindo, a composição é um passo product_event cujo track() está no mesmo handler:

Várias listas, um guia

Cada checklist em que a pessoa está é desenhada como um grupo de um guia só: o primeiro não concluído e desbloqueado fica aberto, os outros dobrados. Um guia que abrisse tudo seria uma parede de vinte passos, e a pergunta de quem chega é “e agora”, não “o que existe”. Ordene com unlocks_after. Uma lista que espera outra é desenhada fechada, com um cadeado e o nome do que ela espera — ela continua NO guia em vez de sumir, porque ver o que vem a seguir é a diferença entre um guia e uma fila. Só no mesmo ambiente, e um ciclo é 400: duas listas esperando uma pela outra é um par que nunca abriria. Deixe a pessoa escolher com selectable. Uma lista selecionável fica fora do guia até ela colocá-la lá, pelo modal “do que você precisa para começar?” atrás do Editar do guia. O público continua decidindo o que é oferecido; isto entrega o último passo a ela. O menu viaja junto com o guia numa requisição só, então o modal abre com as escolhas dela já marcadas. Salvar envia a resposta inteira, nunca um diff — senão “desmarquei isto” e “isto foi lançado enquanto meu modal estava aberto” seriam a mesma requisição, e a segunda desfaria o seu próprio lançamento sem avisar. Nada disso mexe no que satisfaz um passo. Escolher decide quais listas estão no guia; um passo continua sendo satisfeito por um fato que chega no bus ou por um direito do plano, e nenhuma tela em lugar nenhum marca um. Inline é o lugar de um dashboard, num espaço que você abriu para ele. As posições flutuantes são para o produto que não tem esse espaço: um cartão no canto que a pessoa olha entre uma tarefa e outra. Essas trazem um fechar, que o inline não precisa — um painel sobre a página do qual não se consegue livrar é um painel que as pessoas aprendem a contornar. Fechar é por checklist, via POST /v1/contact/checklist/{id}/dismiss, que o <OnboardingChecklist /> chama por você. Um ambiente tem várias listas ativas, então uma marca na pessoa faria a que você lançar no mês que vem nascer escondida para todo mundo que fechou a anterior — dispensar uma não diz nada sobre as outras, nem sobre as que ainda não existem. E é um adiamento, não uma resposta. O snooze_days da checklist decide quanto tempo: sete dias por padrão, e depois o cartão volta. Quem aperta o X num guia de setup quase nunca quer dizer “nunca mais me ajude” — quer dizer “agora não”, e gravar a leitura mais forte possível do gesto mais fraco possível é como um guia some da conta de alguém que nunca terminou de configurar nada. Use snooze_days: 0 para a checklist que é mesmo uma oferta única; aí o fechar vale para sempre. Entre os dois, períodos maiores são para a lista que a pessoa pode legitimamente não querer por um tempo — quem nunca vai comprar o recurso de um passo de entitlement veria o mesmo cartão voltar pelo resto da vida da conta, e uma cobrança com agenda continua sendo uma cobrança. Mudar o número nunca mexe num adiamento em curso. O fim é calculado quando a pessoa fecha o cartão e fica gravado, então uma promessa feita a alguém não é encurtada por uma edição que essa pessoa nunca viu. A página dedicada de setup pede as fechadas. Fechar diz “no meu dashboard não”, e quem navega até /getting-started acabou de dizer o contrário sobre aquela tela:
Isso lê com ?include_dismissed=true, e cada checklist volta carregando dismissed, para uma página que desenha a própria UI poder dizer isso. O botão de fechar sai junto — um controle que esconde algo que volta no render seguinte é um controle que não funciona. Ele passa por cima da dispensa e de mais nada: uma checklist desligada continua desligada, o público continua decidindo, e uma escolhível ainda precisa ter sido escolhida. Essas são coisas sobre a checklist; fechar é uma preferência sobre uma tela. Escolher a lista de novo no menu de objetivos traz ela de volta na hora, sobrando o que sobrar do adiamento. Aquela tela é onde alguém diz “quero isto no meu guia”, então uma dispensa que sobrevivesse a ela seria uma recusa que ninguém falou em voz alta. Não é uma recusa e não mexe no progresso: fechar diz “aqui não”, e os passos continuam sendo satisfeitos por fatos que chegam no bus. O painel lê dois números ao lado do funil de conclusão — dismissals, quantas pessoas já fecharam, e hidden, para quantas está escondida agora. A diferença entre os dois é o adiamento funcionando. Os dois iguais é a cara de um cartão que as pessoas estão empurrando para longe. Num app Next, importe do @userkit/nextjs — é o mesmo componente, e em modo proxy a leitura é encaminhada pelos handlers, com a sessão num cookie httpOnly da sua própria origem. Só a leitura: não há metade de escrita em lugar nenhum desta superfície.
Uma visitante anônima recebe 200 e uma lista vazia, não um 401. O widget roda na sua landing page, e um erro de console é a forma errada de dizer “não há nada para mostrar aqui”. satisfied_at é sempre null num passo de entitlement: nada foi armazenado, então não há momento a relatar. completed é derivado dos passos em vez de guardado ao lado deles, o que é o que o mantém correto também numa lista que carrega um passo de entitlement. O id do cliente viaja com a leitura, porque é ele que decide os passos de entitlement. Ele identifica e nunca autoriza — um time a que a sessão não pertence não casa com vínculo nenhum.

Quem vê

segment_id mira a checklist numa audiência, ou null para todo mundo. Ele decide quem vê a lista, não quem é registrado contra ela: o progresso é escrito para quem produziu o fato, então alguém que entra no segmento depois chega com os passos já cumpridos já marcados.

O funil

Um passo de entitlement reporta null, não um zero que ele não ganhou. Nada é armazenado para esses passos, então não há contagem a dar — e 0 seria um número sobre o qual alguém agiria. materialized diz qual dos dois você está olhando, para que um gráfico desenhe uma lacuna em vez de uma barra. completed é null pelo mesmo motivo sempre que a checklist carrega um passo de entitlement: um número de “terminou a lista inteira” calculado sobre os passos que ele enxerga seria um piso apresentado como total. Uma tranquilização é exatamente o que faz alguém parar de conferir. audience é quem está no segmento, ou todo contato identificado do ambiente quando a checklist é para todo mundo.

O que ela publica

checklist.step_completed e checklist.completed são entregáveis como webhooks — o segundo é o que vale assinar, porque “essa pessoa terminou o onboarding” é o momento de que uma sequência de boas-vindas ou um alerta de vendas pendura. checklist.completed dispara no máximo uma vez por pessoa por checklist, sustentado por uma linha marcadora escrita na mesma transação. O barramento é at-least-once, então um consumidor que rodasse de novo sem ela anunciaria uma formatura duas vezes. Ele dispara apenas para uma checklist feita inteiramente de passos de evento. Um passo de entitlement é decidido na leitura, contra um time, então o caminho que anuncia a conclusão — que roda quando um fato chega, e conhece só a pessoa — não consegue dizer se a lista terminou. Uma checklist da qual você quer pendurar uma automação deve ser feita só de passos de evento. Os payloads levam ids, chaves e títulos: o passo que alguém satisfez e a lista a que ele pertence. Nunca a regra por trás — qual fato ou qual funcionalidade um passo espera é configuração sua, e quem assina não precisa disso para agir sobre alguém ter chegado lá. Editar ou excluir uma checklist não é entregue.

Permissões

Toda a superfície de staff é engagement:manage — owner e admin por padrão. Um nome cobre checklists, changelog e pesquisas: é o mesmo ato, que é operar o que seus usuários veem dentro do seu produto.