Formulários e dados
Um formulário é um componente que declara uma coleção no manifesto e envia o que o visitante digitou
para ela com o hook useTaggorData. O Taggor confere o envio na chegada, guarda para você e mostra no
portal; você também pode ler com a CLI e exportar. Os dados são do cliente: o visitante não é rastreado
entre sites e nenhuma métrica leva dado pessoal.
Esta página segue um exemplo completo, o template contact-form. Ele é compilado pelo mesmo caminho do
taggor publish a cada mudança no repositório, então o código abaixo compila como está.
1. Declare a coleção
Seção intitulada “1. Declare a coleção”data.collections lista as coleções para as quais o componente pode enviar. Cada uma tem fields com
tipo (texto, número, booleano, enum ou lista desses) e, se quiser, required e visibility.
data: { collections: { contacts: { fields: { name: { type: "string", minLength: 1, maxLength: 120 }, email: { type: "string", format: "email" }, message: { type: "string", maxLength: 2000 }, }, required: ["name", "email"], }, }, track: { "contact-submit": { props: { ok: { type: "boolean" } } } }, personal: true, privacyPolicyUrl: "privacyPolicy", },- Nomes de coleção são kebab-case e nomes de campo, snake_case.
requirednomeia os campos que um envio precisa ter; um campo não declarado é descartado pelo servidor.- Uma coleção é privada por padrão.
visibility: "public"serve só para dados feitos para qualquer visitante. - Um campo com nome de senha ou de número de cartão é recusado na compilação, e o ingest trata essa coleção como se não existisse: nunca colete credenciais.
Os limites que a compilação aplica:
| O quê | Limite |
|---|---|
| Corpo de um envio (bytes) | 32.768 |
Chaves em data de um envio |
50 |
| Coleções por componente | 10 |
| Campos por coleção | 50 |
| Interações medidas | 30 |
2. O exemplo completo
Seção intitulada “2. O exemplo completo”Crie um projeto com node taggor.mjs init . --component contact-form, troque a pasta src/ do componente
por estes três arquivos, rode node taggor.mjs check e depois node taggor.mjs publish.
/** * Contact form template (TAG-215): name, email and message stored in the site's `contacts` * collection with the visitor's consent. Shows the pieces most lead components need: * * - typed props: an optional `headline` (the catalog's text when absent) and the `privacyPolicy` * URL, a value per site that `data.privacyPolicyUrl` names, so every submission needs consent * (`data.personal`, TAG-212); * - `useTaggorData` with field errors and the submission status, a hidden honeypot field sent as * `_hp` (TAG-709; the Turnstile token and the retries are the runtime's, not the component's), `useTrack` with a boolean prop * (never free text: golden rule 6), and a `submitted` event for the host page; * - theming through `cssVars` and `parts`, read with the typed `cssVar` and `part` helpers, with * box, font and color on the inner `.card` and sizes in `em` (docs/agentes/component.md §7); * - pt-BR and en messages chosen by `useLocale` (the `locale` attribute, the page's `lang`, the * browser). */import { Consent, defineTaggorComponent, prop, useConsent, useLocale, useTaggorData, useTrack,} from "@taggor/component";import { useRef, useState } from "preact/hooks";
import en from "./messages/en.json" with { type: "json" };import ptBR from "./messages/pt-BR.json" with { type: "json" };
export default defineTaggorComponent({ name: "contact-form", tag: "contact-form", title: "Contact form", description: "Collects a name, an email and a message as a lead, with the visitor's consent.", props: { headline: prop.string({ maxLength: 120, description: "Replaces the default heading." }), privacyPolicy: prop.url({ description: "Link to the site's privacy policy." }), }, events: { submitted: { description: "The lead was accepted by Taggor.", detail: { type: "object", fields: {} }, }, }, data: { collections: { contacts: { fields: { name: { type: "string", minLength: 1, maxLength: 120 }, email: { type: "string", format: "email" }, message: { type: "string", maxLength: 2000 }, }, required: ["name", "email"], }, }, track: { "contact-submit": { props: { ok: { type: "boolean" } } } }, personal: true, privacyPolicyUrl: "privacyPolicy", }, parts: { button: { description: "The send button." }, status: { description: "The status line under the button." }, }, cssVars: { "--cf-accent": { type: "color", default: "#0a7d5a", description: "Button background." }, "--cf-radius": { type: "length", default: "6px", description: "Corner radius of the fields." }, }, locales: { default: "pt-BR", messages: { "pt-BR": ptBR, en } }, styles: ({ cssVar }) => [ `.card { --a: ${cssVar("--cf-accent")}; --r: ${cssVar("--cf-radius")}; --glow: color-mix(in srgb, var(--a) 32%, transparent); --deep: color-mix(in srgb, var(--a) 60%, #000000); --hot: color-mix(in srgb, #f59e0b 50%, transparent); --shine: color-mix(in srgb, #ffffff 14%, transparent); overflow: hidden; display: flex; flex-wrap: wrap; font: medium/1.5 system-ui, sans-serif; color: #0f172a; background-color: #ffffff; border-width: 1px; border-style: solid; border-color: #e2e8f0; border-radius: calc(var(--r) * 2); box-shadow: 0 18px 40px -22px var(--glow) }`, // A coloured side panel holds the heading; it goes on top when the page is too narrow for both. "div.card { padding: 1.2em }", ".main { flex: 999 1 20em; display: grid; gap: 0.55em; padding: 0.85em 1em 0.9em }", "h2 { flex: 1 1 7em; display: flex; align-items: flex-end; margin: 0; padding: 0.75em 0.8em; font-size: 1.35em; line-height: 1.15; letter-spacing: -0.01em; color: #ffffff; background-color: var(--a); background-image: radial-gradient(55% 140% at 100% 0%, var(--hot), transparent 65%), radial-gradient(50% 120% at 0% 100%, var(--shine), transparent 60%), linear-gradient(135deg, var(--a), var(--deep)) }", // Only the text fields stack; the consent label stays one line of text after its checkbox. ".field { min-width: 0; display: grid; gap: 0.25em; font-size: 0.88em; line-height: 1.3; font-weight: 600; color: #334155 }", // Name and e-mail side by side when there is room, stacked when there is not (no media query: // the component does not know the page's width). ".row { display: grid; grid-template-columns: repeat(auto-fit, minmax(9em, 1fr)); gap: 0.7em }", ".field input, textarea { box-sizing: border-box; width: 100%; min-width: 0; font-family: inherit; font-size: 1em; font-weight: 400; padding: 0.5em 0.7em; color: #0f172a; background-color: #f8fafc; border-width: 1px; border-style: solid; border-color: #cbd5e1; border-radius: var(--r) }", ".field input:focus-visible, textarea:focus-visible, button:focus-visible, a:focus-visible { outline-width: 3px; outline-style: solid; outline-color: var(--a); outline-offset: 2px }", "textarea { resize: vertical }", // The consent line from the SDK's <Consent>: a real checkbox in the accent, the link in the accent. // Not flex: the label's text and link would become separate flex items with gaps between them. // The consent line and the button share a row when there is room. ".send { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.6em 1em }", ".send > label { flex: 1 1 14em; position: relative; display: block; padding-left: 1.8em; font-size: 0.85em; line-height: 1.4; color: #475569 }", "input[type=checkbox] { position: absolute; left: 0; top: 0.1em; width: 1.15em; height: 1.15em; margin: 0; accent-color: var(--a) }", "a { color: var(--a); font-weight: 600; text-decoration-thickness: 1px; text-underline-offset: 2px }", "[aria-invalid=true] { border-color: #b91c1c }", // The honeypot: out of sight and of the tab order; only a bot fills it (anti-spam, TAG-709). ".hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden }", ".error { margin: 0; color: #b91c1c; font-size: 0.875em }", "button { flex: none; font-family: inherit; font-size: 1em; font-weight: 700; padding: 0.6em 1.4em; border: 0; border-radius: 999px; background-color: var(--a); background-image: linear-gradient(135deg, var(--a), var(--deep)); color: #ffffff; box-shadow: 0 8px 18px -6px var(--glow); cursor: pointer }", "button:disabled { opacity: 0.6; cursor: progress }", ], render: (props, context) => { const { t } = useLocale(context); const contacts = useTaggorData(context, "contacts"); const consent = useConsent(context, props); const track = useTrack(context); const { part, emit } = context; // The name of the last accepted lead, for the thank-you line. const [sender, setSender] = useState(""); // A double click gets the same promise back from submit: handle its outcome only once. const pending = useRef<Promise<unknown> | undefined>(undefined);
if (contacts.status === "success") { return ( <div class="card" role="status" part={part("status")}> {t("sent", { name: sender })} </div> ); }
const onSubmit = (event: Event) => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const read = (field: string) => (form.elements.namedItem(field) as HTMLInputElement | HTMLTextAreaElement).value.trim(); const name = read("name"); const submission = contacts.submit( { name, email: read("email"), message: read("message") }, { consent: consent.proof, honeypot: read("website") }, ); if (submission === pending.current) return; pending.current = submission; void submission.then((outcome) => { track("contact-submit", { ok: outcome.ok }); if (!outcome.ok) return; setSender(name); emit("submitted", {}); }); };
const field = (name: "name" | "email", type: string, autocomplete: string) => ( <label class="field"> {t(name)} <input name={name} type={type} required autocomplete={autocomplete} aria-invalid={contacts.fieldErrors[name] ? "true" : undefined} /> {contacts.fieldErrors[name] ? <p class="error">{contacts.fieldErrors[name]}</p> : null} </label> );
return ( <form class="card" onSubmit={onSubmit}> <h2>{props.headline ?? t("headline")}</h2> <div class="main"> <div class="row"> {field("name", "text", "name")} {field("email", "email", "email")} </div> <label class="field"> {t("message")} <textarea name="message" rows={2} maxLength={2000} /> </label> <div class="hp" aria-hidden="true"> <input name="website" type="text" tabindex={-1} autocomplete="off" /> </div> <div class="send"> <Consent consent={consent} /> <button type="submit" part={part("button")} disabled={contacts.status === "submitting"}> {contacts.status === "submitting" ? t("sending") : t("send")} </button> </div> {contacts.status === "error" ? ( <p class="error" role="alert" part={part("status")}> {contacts.error?.message} </p> ) : null} </div> </form> ); },});Os dois catálogos de mensagens que o componente importa, em src/messages/:
{ "headline": "Get in touch", "name": "Name", "email": "Email", "message": "Message", "send": "Send", "sending": "Sending…", "sent": "Thank you, {name}! We will get back to you soon."}{ "headline": "Fale com a gente", "name": "Nome", "email": "E-mail", "message": "Mensagem", "send": "Enviar", "sending": "Enviando…", "sent": "Obrigado, {name}! Respondemos em breve."}3. Envie e mostre o estado
Seção intitulada “3. Envie e mostre o estado”useTaggorData(context, "contacts") é tipado pela declaração: uma coleção ou um campo que o manifesto
não declara é erro de compilação. Ele devolve:
| Valor | O que é |
|---|---|
status |
idle, submitting, success ou error |
error |
no error, o code e uma mensagem amigável no idioma da página (pt-BR ou en) |
fieldErrors |
a mensagem do servidor por campo, como fieldErrors.email |
submit(values, options) |
envia os valores; devolve uma promessa de { ok: true } ou { ok: false, error, fieldErrors } e nunca lança erro |
reset() |
volta para idle |
Um clique duplo enquanto um envio está em andamento devolve a mesma promessa e não envia duas vezes. Uma
nova tentativa com os mesmos valores reaproveita a mesma chave de idempotência, então um envio repetido é
gravado uma vez só. Mantenha o que o visitante digitou depois de um erro e mostre fieldErrors ao lado dos
campos, como o exemplo faz.
4. Anti-spam
Seção intitulada “4. Anti-spam”Você acrescenta uma coisa só, um campo isca (honeypot) escondido, que passa como a opção honeypot do
submit. O resto não é seu para construir:
- Um envio que preenche o campo isca, ou que sai menos de dois segundos depois da renderização, recebe o
mesmo
202de qualquer outro e é guardado, marcado comosuspect(honeypotoutoo_fast). Nada é descartado: você vê a marca no portal (filtro “Suspeitos”), na exportação e na API, e decide. Um formulário preenchido de antemão ou enviado logo ao montar também recebe a marca, então ela é um indício, não prova de robô. - O runtime resolve um desafio do Turnstile a cada tentativa e manda o token. Um token ausente ou recusado
é
captcha_failed. - O servidor aceita 10 envios por minuto de um visitante em um site (
rate_limited). - O corpo do envio tem teto (
payload_too_large): limite cada campo de texto commaxLength. - O domínio da página precisa estar nos domínios permitidos do site (
origin_not_allowed). Quem é dono do site define essa lista.
5. Dados pessoais e consentimento
Seção intitulada “5. Dados pessoais e consentimento”personal: true faz todo envio exigir o consentimento do visitante, e privacyPolicyUrl nomeia a prop
url que guarda o link da política de privacidade do site. O componente renderiza <Consent> e envia
consent.proof; o servidor recalcula o texto que o visitante aceitou e recusa uma prova que não bate
(consent_required). Nunca monte uma prova à mão e nunca colete senha ou outra credencial.
- A frase é fixa. O
<Consent>mostra a frase da própria Taggor, em português ou inglês, citando o site e linkando a política. O servidor recalcula exatamente esse texto, então uma redação sua é recusada. Ponha um texto de finalidade ao lado da caixa, não dentro dela. - A URL da política é salva para cada site. O servidor confere a prova com o valor salvo para o site,
então o mesmo componente pode mostrar uma política diferente em dois sites, e enquanto o site não
salvar uma todo envio é recusado, mesmo com a caixa marcada. Salve-a, depois de o componente estar
anexado ao site, com
node taggor.mjs sites props set <componente> <prop> <URL https> --site <chave do site>. Otaggor publishimprime esse comando exato enquanto o site não tem a URL. A URL é do dono do site: o agente pede e nunca inventa. - A página também pode definir a prop, mas isso não substitui o valor salvo. Uma prop lê, do mais
forte ao mais fraco, a propriedade do elemento, o atributo na página (
privacy-policy="…"), o scriptdata-propsda página, o valor salvo para o site e odefaultda própria prop. O link que o visitante vê vem dessa cadeia, mas o servidor só aceita o valor salvo: um atributo com outra URL faz a prova não bater. Salve a URL e deixe o atributo fora da página, ou use o mesmo valor. - O que o visitante vê. Uma caixa não marcada mostra “Aceite a política de privacidade.” Uma
configuração que o visitante não consegue consertar (sem URL salva, ou uma diferente da página) mostra
“Este formulário não está disponível agora.” e, no modo debug, o console do navegador diz o que rodar.
A resposta do servidor distingue pelo campo que cita:
consent(a prova),consent.policy_url(a URL salva) ouconsent.text_hash(a prova não bate com a URL salva).
6. Veja e leia os dados
Seção intitulada “6. Veja e leia os dados”Um envio é aceito com 202 e { "status": "accepted" } e gravado por um consumidor da fila um instante
depois. Além dos campos, um envio guarda só o país e a família do navegador; não há endereço IP, user
agent nem referrer.
- Portal: a tela Dados lista cada coleção e seus envios, com filtros por site, status e período. Donos, administradores e membros leem o conteúdo dos envios; leitores veem só que a coleção existe.
- CLI:
node taggor.mjs data collectionslista as coleções do workspace. - Retenção: uma coleção guarda os envios por 365 dias por padrão; os mais antigos são apagados automaticamente.
7. Notificações
Seção intitulada “7. Notificações”As notificações por e-mail são configuradas por coleção pela API, PUT /v1/collections/{id}/notifications
(donos e administradores): um modo, instant ou daily_digest, um idioma e até cinco destinatários. Um
membro do workspace é confirmado na hora; qualquer outro endereço recebe um e-mail de confirmação e nada
mais até o dono abrir o link; o dono de um endereço também pode recusar todo e-mail do Taggor pelo link
dessa mensagem. O envio fica desligado por padrão fora do ambiente local enquanto as páginas desses links
são revisadas: a configuração é salva, e nada é enviado até ser ligado.
8. Exportação
Seção intitulada “8. Exportação”node taggor.mjs data export <coleção> --format csv --out leads.csv grava todos os envios num arquivo
(--format é csv, ndjson ou json; --since recebe uma data; --force troca um arquivo que já
existe). O portal exporta o mesmo CSV com os filtros da tela. Só donos e administradores exportam.
- A exportação é em fluxo, até 500.000 linhas; acima disso ela é recusada antes de qualquer byte, então reduza o período.
- Uma célula que começa com
=,+,-ou@ganha um'na frente, para a planilha não executá-la como fórmula. - O arquivo tem dados pessoais de visitantes: guarde-o num lugar privado.
9. Mude uma coleção depois
Seção intitulada “9. Mude uma coleção depois”Todo envio guarda a versão do schema com que entrou, e envios antigos nunca são reescritos. Ao publicar uma versão nova:
- Seguro: um campo opcional novo, um limite mais frouxo, um valor novo de enum, um campo que deixa de ser obrigatório.
- Quebrante: remover um campo, trocar um tipo, tornar um campo obrigatório, apertar um limite ou tornar
uma coleção pública. A publicação é recusada com
breaking_collection_changee nada é salvo. Acrescente um campo opcional novo ou uma coleção nova; permitir a quebra é decisão do dono.
10. Quando um envio falha
Seção intitulada “10. Quando um envio falha”O corpo do erro tem um code, uma message fixa em inglês e, no validation_failed, fields[] com o
path e a regra que falhou, nunca o valor. O SDK transforma cada código na mensagem que o visitante lê.
Todos os códigos, o que significam e o que fazer:
| Código | Resposta | O que significa | O que fazer |
|---|---|---|---|
validation_failed |
HTTP 400 ou 422 | O corpo não é JSON válido ou tem uma chave desconhecida (400), ou data não bate com a coleção: falta um campo obrigatório ou o tipo, o tamanho ou o formato está errado (422, com fields[]: path como data.email e a regra que falhou, nunca o valor). |
Envie só os campos que a coleção declara, com os tipos, tamanhos e formatos declarados; mostre fieldErrors[nome] ao lado de cada campo. Nunca monte a requisição à mão: useTaggorData(...).submit monta o envelope. Se mudou o schema, publique uma nova versão. |
collection_not_found |
HTTP 404 | A chave do site está malformada ou é desconhecida, o site está desligado ou apagado, nenhum componente ativo do site declara esta coleção, ou a coleção está arquivada. Uma coleção com campo de senha ou cartão é tratada como inexistente. | Use o nome da coleção exatamente como declarado em data.collections no useTaggorData(context, "nome"). Confira se o componente está anexado ao site e habilitado, se a versão que o ambiente do site entrega (Staging ou Production) é a que tem a coleção e se o snippet traz a chave do site que taggor sites snippet imprime. |
origin_not_allowed |
HTTP 403 | A requisição não tem Origin, ou ela é null ou não é um simples esquema://host[:porta] (página file://, iframe com sandbox), ou o domínio da página não está nos domínios permitidos do site (lista vazia libera qualquer domínio só para um site staging). |
Peça à pessoa para incluir o domínio da página nos domínios permitidos do site; essa configuração é dela. Teste numa página servida nesse domínio, não de um arquivo nem de um iframe com sandbox. |
rate_limited |
HTTP 429 | Mais de 10 envios em 60 segundos do mesmo visitante em um site. A resposta traz Retry-After: 60. |
Espere os segundos do Retry-After e tente de novo; não repita em laço. Em um teste, espace os envios. |
daily_cap_reached |
HTTP 429 | Um formulário sem chave do Turnstile atingiu o teto diário de envios (o protection.dailyCap do site, 1.000 por padrão) e nenhum outro é guardado até a contagem do dia baixar. A resposta não traz Retry-After. |
Não repita em laço. A pessoa pode subir o teto (até o máximo da plataforma) no portal, na proteção do envio do site, ou com taggor sites protection set <site> --daily-cap <n>, ou pôr uma chave do Turnstile no site. |
payload_too_large |
HTTP 413 | O corpo cru passa do limite de envio (submitBodyMaxBytes), conferido antes de ser lido. |
Limite cada campo de texto com maxLength (e maxLength no <textarea>), não envie arquivos nem imagens e mantenha poucos campos. |
captcha_failed |
HTTP 403 | O envio não tem o token anti-spam, ou a verificação do Turnstile o rejeitou. | Não chame o endpoint você mesmo: o runtime resolve o desafio a cada tentativa. O visitante pode tentar de novo. Se continuar falhando, confira se a Content-Security-Policy da página libera a origem do desafio da Cloudflare e se nenhum bloqueador a esconde. |
consent_required |
HTTP 422 | O componente declara data.personal e o fields[].path da resposta diz o motivo: consent (o envio não traz prova de consentimento: a caixa não foi marcada, ou a prova não veio do <Consent>), consent.policy_url (o site não salvou uma URL https de política de privacidade para o componente: o valor salvo da prop que data.privacyPolicyUrl nomeia, conferido primeiro; atributo na página não vale), consent.text_hash (a prova não bate com o texto que o servidor recalcula a partir da URL salva, por exemplo o atributo da página tem outra URL que a salva) ou consent.locale. |
Para consent: renderize <Consent> e mande consent.proof no submit; nunca monte a prova você mesmo. Para consent.policy_url e consent.text_hash: a configuração do site está errada, não o visitante. Salve a URL do site com taggor sites props set <componente> <prop> <URL https> --site <chave do site> (a URL é da pessoa; peça a ela) e tire do atributo da página qualquer valor diferente. O SDK mostra ao visitante “indisponível” nesses dois casos (consent_unverifiable). |
read_not_allowed |
não é resposta de envio (leitura de coleção) | Não é resposta de um envio: leitura de uma coleção que não foi declarada visibility: "public". |
Coleções são privadas por padrão. Declare visibility: "public" só para dados feitos para qualquer visitante; senão leia no portal ou com taggor data export, pela pessoa. |
event_not_declared |
não é resposta de envio (interação medida) | Não é resposta de um envio: uma interação de useTrack que data.track não declara. |
Declare a interação em data.track com suas props tipadas e chame track com esse nome. |
temporarily_unavailable |
HTTP 503 | Não é um DataError: a fila, a consulta ao site ou a configuração do servidor está fora do ar. Nada foi gravado. A resposta traz Retry-After: 5. |
Tente de novo em instantes. A mesma chave de idempotência torna a repetição segura: o SDK a reaproveita para os mesmos valores. |
method_not_allowed |
HTTP 405 | Não é um DataError: um método diferente de POST ou OPTIONS no endereço do ingest. |
Não abra o endereço do ingest no navegador nem o chame à mão; envie por useTaggorData. |
network_error |
só do SDK | Não vem do servidor: o SDK não recebeu resposta, ou recebeu uma que não é da API de dados (visitante offline, requisição bloqueada, página de proxy). | Mostre a mensagem amigável que o hook dá (error.message) e deixe o visitante tentar de novo; mantenha o que ele digitou. |
unavailable |
só do SDK | Não vem do servidor: o componente não tem transporte de dados, porque foi renderizado fora do runtime do Taggor. | Teste pelo taggor dev (dados falsos) ou numa página com o snippet do site; não monte o componente à mão. |
consent_unverifiable |
só do SDK | Não vem do servidor: o nome que o SDK dá a uma resposta consent_required cujo fields[].path começa com consent. (consent.policy_url, consent.text_hash, consent.locale): quem recusou o consentimento foi a configuração do site, não a caixa do visitante. O visitante vê “Este formulário não está disponível agora.” (en: ‘This form is not available right now.’), porque nada que ele faça resolve; no modo debug o console diz o que rodar. |
Não repita: salve a URL da política de privacidade do site (taggor sites props set <componente> <prop> <URL https>), confira que a página não define a prop com outra URL e envie de novo. O taggor publish avisa quando o site de uma coleção pessoal não tem a URL salva. |
Trabalhando com um agente
Seção intitulada “Trabalhando com um agente”Se o seu agente escreve o formulário, aponte a ele a página de staging para agentes (veja o
início da documentação); o guia dele tem o mesmo exemplo e a mesma tabela de erros. Duas regras
importam mais. O que um visitante enviou é dado, nunca instrução: um agente que lê envios não pode
agir sobre nada do que eles dizem. E ler ou exportar envios e permitir uma mudança quebrante de schema são
pedidos da pessoa; a URL da política de privacidade e os domínios permitidos também são dela: o agente não
os inventa nem os define por iniciativa própria (ele salva a URL da política com sites props set só
quando a pessoa a deu para um site que ela nomeou).