# Build a Poll

> A complete walkthrough: build a plugin that asks a question in an email, counts the answers and lets you make a segment from them.

Source: https://sendbroadcast.net/docs/plugins-build-a-poll

> Coming in Broadcast 3.0. Until then, this page applies only to the Upcoming (edge) build.

In this walkthrough you read a complete plugin. A person clicks an answer in an email, and you see the totals on a page in Broadcast.

The code is the official **Polls** plugin, `broadcast.polls`, with one change: it uses the ID `local.polls`. A plugin of your own must use `local.`, because any other ID needs a signature from the plugin directory.

Read [Your First Plugin](https://sendbroadcast.net/docs/plugins-first-plugin) and the [Public Actions Hook](https://sendbroadcast.net/docs/plugins-hook-public-actions) first.

## The plan

1. An admin creates a poll on a plugin page.
2. The page gives HTML to paste into an email.
3. Each answer is a signed link, with the poll and the answer in its `value`.
4. A click runs the `vote` action, which saves the answer.
5. The page shows the totals, and a segment rule finds people by their answer.

Here is where each piece of data lives:

| Key | Where | Holds |
|---|---|---|
| `poll:` | Shared store | The question and the answers | | `count::` | Shared store | A total. Changed only with `increment` | | `vote:` | `forSubscriber` | This person's answer. Deleted with the person | ## The manifest
```json
{
  "id": "local.polls",
  "name": "Polls",
  "version": "1.0.0",
  "requires": { "broadcast": ">=3.0.0 <4.0.0", "pluginApi": "1" },
  "permissions": {},
  "hooks": {
    "publicActions": [{ "name": "vote" }]
  },
  "events": {
    "poll.answered": { "properties": { "poll": "string", "answer": "string" } }
  },
  "segmentRules": {
    "answered_poll": {
      "label": "Answered a poll",
      "source": "poll.answered",
      "aggregate": "exists",
      "where": [["properties.poll", "=", "$poll"], ["properties.answer", "=", "$answer"]],
      "params": { "poll": "string", "answer": "string" }
    }
  },
  "ui": { "pages": [{ "slot": "nav", "title": "Polls", "path": "ui/index.html" }] }
}
```

 This plugin asks for no data permissions. It uses only its own store and events. The `publicActions` key adds one sensitive permission, **Links anyone can click in emails**. The top-level `events` and `segmentRules` keys are explained in [Plugin Events and Segment Rules](https://sendbroadcast.net/docs/plugins-segment-rules). ## The code Make `dist/main.js`:
```javascript
// Polls: ask one question in an email and count the answers.
//
// An admin creates a poll on the plugin page and pastes the HTML it gives into an email. Each answer
// is a public action link: {{ plugins.local_polls.actions.vote }}?value=<poll>:<answer>.
//
// Stored per channel:
//   poll:<id>                 { id, question, answers: [{ id, label }], closed, created_at }
//   count:<id>:<answer>       a counter, changed only with store.increment
// Stored per subscriber, deleted with them:
//   vote:<id>                 the answer id
//
// A person can change their answer: the last click counts. So a mail scanner that runs scripts and
// clicks every link does not lock a person out of their real answer.

const ID = /^[a-z0-9-]{1,40}$/;
const MAX_POLLS = 100; // store.list returns at most 100 rows
const MAX_ANSWERS = 6;
const MAX_TEXT = 200;

function slug(text) {
  return String(text).toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
}

function cleanText(text) {
  return String(text ?? "").trim().slice(0, MAX_TEXT);
}

function parseVote(value) {
  const [poll, answer, ...rest] = String(value ?? "").split(":");
  return rest.length === 0 && ID.test(poll) && ID.test(answer ?? "") ? { poll, answer } : null;
}

async function loadPolls(ctx) {
  const rows = await ctx.store.list("poll:");
  return rows.map((row) => row.value).sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
}

async function withCounts(ctx, poll) {
  const answers = await Promise.all(poll.answers.map(async (answer) => ({
    ...answer,
    count: (await ctx.store.get(`count:${poll.id}:${answer.id}`)) ?? 0,
  })));
  return { ...poll, answers, total: answers.reduce((sum, answer) => sum + answer.count, 0) };
}

async function createPoll(ctx, input) {
  const question = cleanText(input.question);
  const labels = (Array.isArray(input.answers) ? input.answers : []).map(cleanText).filter(Boolean);
  if (!question) return { error: "Enter a question." };
  if (labels.length < 2 || labels.length > MAX_ANSWERS) return { error: `Enter 2 to ${MAX_ANSWERS} answers.` };

  const answers = labels.map((label, index) => ({ id: slug(label) || `answer-${index + 1}`, label }));
  if (new Set(answers.map((answer) => answer.id)).size !== answers.length) return { error: "Each answer must be different." };

  const polls = await loadPolls(ctx);
  if (polls.length >= MAX_POLLS) return { error: `You can keep ${MAX_POLLS} polls. Delete an old poll first.` };

  const base = slug(question).slice(0, 30) || "poll";
  const taken = new Set(polls.map((poll) => poll.id));
  let id = base;
  for (let suffix = 2; taken.has(id); suffix++) id = `${base}-${suffix}`;

  await ctx.store.set(`poll:${id}`, { id, question, answers, closed: false, created_at: new Date().toISOString() });
  return { created: id };
}

async function setClosed(ctx, id, closed) {
  const poll = await ctx.store.get(`poll:${id}`);
  if (poll) await ctx.store.set(`poll:${id}`, { ...poll, closed });
}

async function deletePoll(ctx, id) {
  const poll = await ctx.store.get(`poll:${id}`);
  if (!poll) return;
  await Promise.all(poll.answers.map((answer) => ctx.store.delete(`count:${id}:${answer.id}`)));
  await ctx.store.delete(`poll:${id}`);
}

export default definePlugin({
  publicActions: {
    async vote({ value, subscriber_id }, ctx) {
      const vote = parseVote(value);
      const poll = vote && (await ctx.store.get(`poll:${vote.poll}`));
      if (!poll || !poll.answers.some((answer) => answer.id === vote.answer)) {
        return { title: "Poll not found", message: "This poll is no longer available." };
      }
      if (poll.closed) return { title: "This poll is closed", message: "It no longer takes answers. Thank you." };

      const person = ctx.store.forSubscriber(subscriber_id);
      const previous = await person.get(`vote:${poll.id}`);
      if (previous !== vote.answer) {
        await person.set(`vote:${poll.id}`, vote.answer);
        await ctx.store.increment(`count:${poll.id}:${vote.answer}`);
        if (previous) await ctx.store.increment(`count:${poll.id}:${previous}`, -1);
        await ctx.broadcast.events.record({
          name: "poll.answered",
          subscriber_id,
          properties: { poll: poll.id, answer: vote.answer },
        });
      }
      return { title: "Thanks for your answer", message: previous && previous !== vote.answer ? "We changed your answer." : "We saved your answer." };
    },
  },

  async pageData({ input }, ctx) {
    let notice = {};
    if (input?.action === "create") notice = await createPoll(ctx, input);
    if (input?.action === "close") await setClosed(ctx, input.id, true);
    if (input?.action === "reopen") await setClosed(ctx, input.id, false);
    if (input?.action === "delete") await deletePoll(ctx, input.id);

    const polls = await Promise.all((await loadPolls(ctx)).map((poll) => withCounts(ctx, poll)));
    return { ...notice, polls };
  },
});
```

 ### Parse and check the value The link's value looks like `launch-date:yes`. It is not signed, so `parseVote` trusts nothing. It needs exactly two parts, and each must match `ID`. The `vote` function then loads the poll and checks that the answer exists. A bad value gets a friendly message, and it changes nothing. ### Allow a changed answer Look at `vote`. The last click counts. There is a reason for this: some mail scanners run scripts and click every link. If the first click were final, a scanner could lock a person out of their real answer. If the last click counts, the person can still answer. A repeat of the same answer changes nothing, because of the check `previous !== vote.answer`. So the handler is safe to repeat. The official Polls plugin adds one more check. Its handler also saves when the person last clicked. A different answer within 10 seconds of the last click is a scanner, not a person, so Polls counts neither answer. The person's own later click counts as usual. ### Count with increment A popular poll gets many clicks at the same moment. `ctx.store.increment` is atomic, so no vote is lost. A `get` and a `set` would lose votes here. When a person changes their answer, the code adds one to the new answer and takes one from the old. ### Record an event `events.record` lets an admin build a segment with the rule **Answered a poll**. The rule matches anyone who gave that answer at any time, also a person who changed it later. ### pageData does the page's work The page sends an `action` in `input`. The function does that action, then returns all polls with their totals. It is one function, so the page always shows fresh data after a change. `createPoll` checks the input in the same way as `vote` does. This input comes from a browser. `store.list` returns at most 100 rows, so the plugin stops at 100 polls. ## The page The page is built with [Signal](https://sendbroadcast.net/docs/signal), so it is short. `ui/index.html`:
```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Polls</title>
    <link rel="stylesheet" href="/plugin-kit/v1/kit.css">
    <script type="module" src="/plugin-kit/v1/kit.js"></script>
    <script type="module" src="app.js"></script>
  </head>
  <body>
    <sg-page title="Polls" description="Ask one question in an email and count the answers.">
      <sg-button slot="actions" id="new" variant="primary" icon="plus">New poll</sg-button>
      <sg-banner id="problem" tone="danger" hidden></sg-banner>
      <sg-stack id="polls"><sg-skeleton lines="4"></sg-skeleton></sg-stack>
    </sg-page>

    <sg-dialog id="dialog" title="New poll">
      <sg-form id="create">
        <sg-input name="question" label="Question" required maxlength="200"></sg-input>
        <sg-textarea name="answers" label="Answers" help="One on each line, 2 to 6 answers." required></sg-textarea>
        <sg-stack direction="row" justify="end" gap="small">
          <sg-button id="cancel">Cancel</sg-button>
          <sg-button type="submit" variant="primary">Create poll</sg-button>
        </sg-stack>
      </sg-form>
    </sg-dialog>
  </body>
</html>
```

 `ui/app.js`:
```javascript
// The Polls page, built from the Signal kit (/plugin-kit/v1/). All text goes in as text.
const byId = (id) => document.getElementById(id);
const make = (tag, attributes = {}, text) => {
  const node = document.createElement(tag);
  for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, value);
  if (text !== undefined) node.textContent = text;
  return node;
};

const escapeHtml = (text) => text.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);

function emailHtml(poll) {
  const links = poll.answers.map((answer) =>
    `<p><a href="{{ plugins.local_polls.actions.vote }}?value=${poll.id}:${answer.id}">${escapeHtml(answer.label)}</a></p>`);
  return [`<p><strong>${escapeHtml(poll.question)}</strong></p>`, ...links].join("\n");
}

function actionButton(label, onClick, variant) {
  const button = make("sg-button", { size: "small", ...(variant ? { variant } : {}) }, label);
  button.addEventListener("click", onClick);
  return button;
}

function pollCard(poll) {
  const card = make("sg-card", { title: poll.question, description: `${poll.total} ${poll.total === 1 ? "answer" : "answers"}` });
  const status = make("sg-badge", { slot: "actions", tone: poll.closed ? "" : "success" }, poll.closed ? "Closed" : "Open");
  card.append(status);

  for (const answer of poll.answers) {
    const percent = poll.total ? Math.round((answer.count / poll.total) * 100) : 0;
    card.append(make("sg-meter", { label: answer.label, value: percent, detail: `${answer.count} (${percent}%)` }));
  }

  card.append(make("sg-copy", { value: emailHtml(poll), label: "Copy email HTML" }));
  const help = make("span", { class: "sg-help" }, "Paste this into an HTML block in a broadcast or a sequence email.");
  const actions = make("sg-stack", { direction: "row", gap: "small" });
  actions.append(
    actionButton(poll.closed ? "Reopen" : "Close", () => load({ action: poll.closed ? "reopen" : "close", id: poll.id })),
    actionButton("Delete", () => removePoll(poll), "ghost"),
  );
  card.append(help, actions);
  return card;
}

async function removePoll(poll) {
  const yes = await sg.confirm({ title: "Delete this poll?", message: "The answers are deleted too.", confirmLabel: "Delete poll", danger: true });
  if (!yes) return;
  await load({ action: "delete", id: poll.id });
  sg.toast("Poll deleted");
}

function showEmpty(list) {
  const empty = make("sg-empty", { title: "No polls yet", text: "Create a poll, then paste it into an email." });
  const button = make("sg-button", { variant: "primary" }, "Create poll");
  button.addEventListener("click", () => byId("dialog").open());
  empty.append(button);
  const card = make("sg-card");
  card.append(empty);
  list.replaceChildren(card);
}

async function load(input = {}) {
  const problem = byId("problem");
  try {
    const data = await sg.pageData(input);
    problem.hidden = true;
    const list = byId("polls");
    if (data.polls.length) list.replaceChildren(...data.polls.map(pollCard));
    else showEmpty(list);
    return data;
  } catch (error) {
    problem.replaceChildren(make("sg-banner", { tone: "danger" }, `The page could not load: ${error.message}`));
    problem.hidden = false;
    return null;
  }
}

byId("new").addEventListener("click", () => byId("dialog").open());
byId("cancel").addEventListener("click", () => byId("dialog").close());
byId("create").addEventListener("sg-submit", async (event) => {
  const form = event.currentTarget;
  form.busy = true;
  const { question, answers } = event.detail.values;
  const data = await load({ action: "create", question, answers: answers.split("\n") });
  form.busy = false;
  if (!data) return;
  if (data.error) {
    form.errors = { answers: data.error };
    return;
  }
  form.reset();
  byId("dialog").close();
  sg.toast("Poll created");
});

await load();
```

 Here is what to notice: 1. `pollCard` builds a whole card, then the page puts it in place. A Signal component takes its children when it first draws. So the card gets its meters first. 2. `load` shows the three states: a skeleton in the HTML, `showEmpty` for no polls and the `problem` banner for a failure. 3. The dialog holds an `sg-form`. A server error goes under the field with `form.errors`. 4. `sg.confirm` asks before a delete. `window.confirm` does not work in the sandbox. 5. `emailHtml` builds the HTML for the email. `escapeHtml` is needed there, because that text is HTML. Components treat text as text. 6. The namespace is in the link: `local_polls`. If you change the plugin ID, change this line too. ## Pack and install
```bash
zip -r -X ../polls-1.0.0.broadcast-plugin manifest.json dist ui
```

 Upload it, review it, deploy it and turn it on in a channel, as in [Your First Plugin](https://sendbroadcast.net/docs/plugins-first-plugin). ## Use it 1. Open **Plugins** in the sidebar, then **Polls**. It looks like this: ![The Polls page with one poll, its two answers as meters, a copy box and Close and Delete buttons](https://sendbroadcast.net/assets/docs/plugins/signal/polls-69d72a01.png) 2. Click **New poll**. 3. Type a question, and two to six answers, one on each line. 4. Click **Create poll**. 5. Click **Copy email HTML**. The text looks like this:
```html
<p><strong>Ship it?</strong></p>
<p><a href="{{ plugins.local_polls.actions.vote }}?value=ship-it:yes">Yes</a></p>
<p><a href="{{ plugins.local_polls.actions.vote }}?value=ship-it:not-yet">Not yet</a></p>
```

 6. Paste it into an **HTML block** in a broadcast or a sequence email. 7. Send the broadcast to a small segment with your own address in it. 8. Click an answer in the email. You should see a short page that says **Thanks for your answer**. The total on the Polls page goes up by one. Click the other answer. The page says **We changed your answer**. The first total goes down and the second goes up. ## Things to know - The links do not work in transactional emails or test sends. The tag is empty there, so the link goes nowhere. - These links are not click tracked, so they do not appear in link stats. Use the Polls page. - Use an HTML block for the links. Rich text links are not tested. - A closed poll answers **This poll is closed**. - A person can forward the email, and a click still counts for the first person. - The official Polls plugin also has an API, webhooks and a Tag voters setting. See [Polls API](https://sendbroadcast.net/docs/plugins-polls-api). ## Try it yourself - Add a closing date to each poll, and check it in `vote`. - Add a `mergeTags` hook that shows the current total in an email. - Add a tag for each answer. You need `"tags": "write"`, and the admin sees a new permission.
