arrow_backDocs

Step 5

Render the ballots.

After voter login, CrownVote returns the full ballot payload. You can render ballot categories and candidates immediately without making another request.

One ballot section per category

Each ballot object becomes a section on the page.

One radio group per category

Use categoryId as the radio input name so the voter can pick one candidate per category.

Store encrypted vote values

When a candidate is selected, save candidate.vote under the matching categoryId.

Do not decode votes

The vote value is encrypted. Treat it as an opaque string and submit it unchanged.

Ballots come from the login response.

Each ballot category contains candidates. Each candidate has a vote value. That value is what you submit when the voter selects the candidate.

code Ballot payload
{
  "ballots": [
    {
      "categoryId": "70f74ae6-fb73-41e9-87e0-ece419585ed5",
      "categoryPosition": 1,
      "title": "PRESIDENT",
      "description": null,
      "instructions": "Select one candidate.",
      "candidates": [
        {
          "name": "Kofi Angel",
          "image": "https://cdn-f.crownvote.com/elections/kofi-angel.jpg",
          "bio": "Level 300 Computer Science",
          "manifesto": null,
          "vote": "encrypted_vote_value"
        },
        {
          "name": "Ama Serwaa",
          "image": "https://cdn-f.crownvote.com/elections/ama-serwaa.jpg",
          "bio": "Level 300 Business Administration",
          "manifesto": null,
          "vote": "encrypted_vote_value"
        }
      ]
    }
  ]
}

Store selections by category.

The app displays each candidate with name, image, and short detail, but only stores the encrypted vote value for submission.

code Selection state
state.selections = {
  "70f74ae6-fb73-41e9-87e0-ece419585ed5": "encrypted_vote_value"
}

Replace the empty renderBallots() function.

This function loops through the ballot categories, creates radio inputs for the candidates, and stores the selected encrypted vote value.

code Add to index.js
function renderBallots() {
  elements.ballots.innerHTML = ''
  state.selections = {}

  state.ballots.forEach((ballot) => {
    const ballotElement = document.createElement('section')
    ballotElement.className = 'ballot'

    const header = document.createElement('div')
    header.className = 'ballot-header'

    const title = document.createElement('h2')
    title.textContent = ballot.title

    const instruction = document.createElement('p')
    instruction.className = 'muted'
    instruction.textContent = ballot.instructions || 'Select one candidate.'

    header.appendChild(title)
    header.appendChild(instruction)

    const candidates = document.createElement('div')
    candidates.className = 'candidates'

    ballot.candidates.forEach((candidate) => {
      const label = document.createElement('label')
      label.className = 'candidate'

      const input = document.createElement('input')
      input.type = 'radio'
      input.name = ballot.categoryId
      input.value = candidate.vote

      const imageWrap = document.createElement('div')
      imageWrap.className = 'candidate-image'

      if (candidate.image) {
        const image = document.createElement('img')
        image.src = candidate.image
        image.alt = candidate.name
        image.loading = 'lazy'
        imageWrap.appendChild(image)
      } else {
        const fallback = document.createElement('span')
        fallback.textContent = candidate.name.charAt(0).toUpperCase()
        imageWrap.appendChild(fallback)
      }

      const content = document.createElement('div')
      content.className = 'candidate-content'

      const name = document.createElement('strong')
      name.textContent = candidate.name

      const meta = document.createElement('small')
      meta.textContent = candidate.bio || candidate.manifesto || 'Candidate'

      content.appendChild(name)
      content.appendChild(meta)

      const check = document.createElement('span')
      check.className = 'candidate-check'
      check.textContent = '✓'

      input.addEventListener('change', () => {
        state.selections[ballot.categoryId] = candidate.vote

        const siblings = candidates.querySelectorAll('.candidate')
        siblings.forEach((item) => item.classList.remove('selected'))

        label.classList.add('selected')
      })

      label.appendChild(input)
      label.appendChild(imageWrap)
      label.appendChild(content)
      label.appendChild(check)

      candidates.appendChild(label)
    })

    ballotElement.appendChild(header)
    ballotElement.appendChild(candidates)

    elements.ballots.appendChild(ballotElement)
  })
}

Your index.js should now look like this.

The app can now load the election, login the voter, and display selectable ballot categories.

code index.js
const API_URL = 'https://api.crownvote.com/api/v1'
const PUBLIC_KEY = 'cv_pub_xxxxx'

const state = {
  election: null,
  accessToken: null,
  ballots: [],
  selections: {},
}

const elements = {
  title: document.querySelector('#electionTitle'),
  description: document.querySelector('#electionDescription'),
  notice: document.querySelector('#notice'),
  loginForm: document.querySelector('#loginForm'),
  voterId: document.querySelector('#voterId'),
  password: document.querySelector('#password'),
  ballotScreen: document.querySelector('#ballotScreen'),
  voterInfo: document.querySelector('#voterInfo'),
  ballots: document.querySelector('#ballots'),
  submitVote: document.querySelector('#submitVote'),
}

function showNotice(message) {
  elements.notice.textContent = message
  elements.notice.classList.remove('hidden')
}

function hideNotice() {
  elements.notice.textContent = ''
  elements.notice.classList.add('hidden')
}

async function getElection() {
  hideNotice()

  const response = await fetch(`${API_URL}/election`, {
    headers: {
      'X-PUBLIC-KEY': PUBLIC_KEY,
    },
  })

  const result = await response.json()

  if (!result.status) {
    showNotice(result.message || 'Unable to load election.')
    return
  }

  state.election = result.data.election

  elements.title.textContent = state.election.title
  elements.description.textContent =
    state.election.description || state.election.instructions || 'Login to cast your vote.'

  if (state.election.votingMode !== 'remote') {
    showNotice('This starter guide is for remote voting only.')
    return
  }

  if (state.election.accessMode !== 'api') {
    showNotice('This election is not configured for API voting.')
    return
  }

  if (state.election.status !== 'live') {
    showNotice('This election is not currently live.')
    return
  }

  elements.loginForm.classList.remove('hidden')
}

async function loginVoter(event) {
  event.preventDefault()
  hideNotice()

  const voterId = elements.voterId.value.trim()
  const password = elements.password.value.trim()

  if (!voterId || !password) {
    showNotice('Enter your voter ID and password.')
    return
  }

  const response = await fetch(`${API_URL}/voter/login`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-PUBLIC-KEY': PUBLIC_KEY,
    },
    body: JSON.stringify({
      voterId,
      password,
    }),
  })

  const result = await response.json()

  if (!result.status) {
    showNotice(result.message || 'Voter login failed.')
    return
  }

  state.accessToken = result.data.accessToken.value
  state.ballots = result.data.ballots
  state.selections = {}

  sessionStorage.setItem('crownvote_voter_token', state.accessToken)

  elements.loginForm.classList.add('hidden')
  elements.ballotScreen.classList.remove('hidden')

  elements.voterInfo.textContent = `Voting as ${result.data.voter.displayName}`

  renderBallots()
}

function renderBallots() {
  elements.ballots.innerHTML = ''
  state.selections = {}

  state.ballots.forEach((ballot) => {
    const ballotElement = document.createElement('section')
    ballotElement.className = 'ballot'

    const title = document.createElement('h2')
    title.textContent = ballot.title

    const instruction = document.createElement('p')
    instruction.className = 'muted'
    instruction.textContent = ballot.instructions || 'Select one candidate.'

    const candidates = document.createElement('div')
    candidates.className = 'candidates'

    ballot.candidates.forEach((candidate) => {
      const label = document.createElement('label')
      label.className = 'candidate'

      const input = document.createElement('input')
      input.type = 'radio'
      input.name = ballot.categoryId
      input.value = candidate.vote

      input.addEventListener('change', () => {
        state.selections[ballot.categoryId] = candidate.vote
      })

      const name = document.createElement('span')
      name.textContent = candidate.name

      label.appendChild(input)
      label.appendChild(name)

      candidates.appendChild(label)
    })

    ballotElement.appendChild(title)
    ballotElement.appendChild(instruction)
    ballotElement.appendChild(candidates)

    elements.ballots.appendChild(ballotElement)
  })
}

elements.loginForm.addEventListener('submit', loginVoter)

getElection()
ballot

What you have now

Your app now displays ballot categories and lets the voter choose candidates. Next, you will convert those selections into the submission payload.