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.
{
"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.
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.
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.
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()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.