DEV Community

Cover image for Building a Polling System with Node / TypeScript using Encore.ts
Marcus Kohlberg for Encore

Posted on

Building a Polling System with Node / TypeScript using Encore.ts

In this tutorial we'll be building a polling system from scratch using Encore.ts.

Picture it: You and your friends are trying to decide on a weekend getaway and everyone has different opinions. Some are for one place and others for another.

So you came up with the idea to create a poll where everyone can vote sounds fun right?

There’s something special about creating something from nothing and sharing it with your friends.

That’s exactly what we’re going to do! We’re going to build a full-blown polling system like the ones you see on Messenger, WhatsApp, or YouTube community posts.

In this tutorial, we’ll build an end-to-end Online Polling system. We’ll focus on building the backend using REST APIs with Encore.ts and integrating the API with the front end.

I’ll walk you through step by step from scratch to deploying the backend service using the Encore Cloud platform. We’ll use PostgreSQL as the database to store our poll data.

What is Encore?

Encore

Encore is an open-source backend framework for building scalable, distributed systems. It’s a dev-friendly tool that makes building robust, type-safe applications easy with it’s high-performance API framework. Whether you like TypeScript or Go, Encore supports both.

Encore also comes with built-in tools to help with development whether you’re working on a small personal project or a large-scale backend system.

It’s a great choice for developers who value simplicity, performance, and scalability.

Check out Encore on GitHub

Prerequisites

No special expertise is needed to follow along with this tutorial. But since we’re building a REST API, it would be helpful if you have a basic understanding of how REST APIs work. Things like the common methods: GET, POST, PUT, DELETE will come in handy as we go.

If you’ve worked with Node.js or Express before, even just a little, that’s a bonus. Knowing how to create server routes or set up a basic server will help you connect the dots as you go.

Before we get started, make sure you have the latest version of Node.js installed.

Go to Node.js Website and download it if you haven’t already. Once that’s done we’re good to go.

Node.js

Before we get started, one more thing: the Encore CLI. Go to encore.dev and you’ll find all the information you need to install it.

Follow the instructions for your machine if you’re on Windows, macOS, or Linux.

You can also copy and paste the following commands to install it directly:

# Install Encore on macOS
brew install encoredev/tap/encore

# Install Encore on Windows (PowerShell)
iwr https://encore.dev/install.ps1 | iex

# Install Encore on Linux
curl -L https://encore.dev/install.sh | bash
Enter fullscreen mode Exit fullscreen mode

Now that you have everything installed, let’s check Node.js and Encore are set up on your machine. Like when you start a new project and want to make sure you have all your tools in place.

Here’s how you can check node version:

node -v
Enter fullscreen mode Exit fullscreen mode

This should show you the version of Node.js you’ve installed. If it pops up with a version number, you’re good to go.

Let’s check the encore version:

encore version
Enter fullscreen mode Exit fullscreen mode

If Encore is installed properly, it’ll display its version too.

Seeing both version numbers?

Perfect. You’re all set, and we’re ready to move forward.

If not, double-check the installation steps, and we’ll get it sorted.

Image

Setting Up the Project

Now let’s create our Encore project. Just run the command and it’ll walk you through the process to set up the encore project.

Here is the command to create an encore project:

encore app create
Enter fullscreen mode Exit fullscreen mode

When you run this, Encore will ask you to choose a language for your project. In this tutorial, we’ll go with TypeScript it’s a great choice for building robust and scalable APIs.

Use your arrow keys to select TypeScript, then hit Enter.

Image

When you run the encore app create command, Encore gives you a few templates to choose from.

These are like pre-built blueprints, each one to help you get started depending on what kind of project you’re building.

But for this tutorial, we’re going to keep it simple and start from scratch. We’ll choose the “Empty app” template.

Why?

Because building from scratch is the best way to see how everything works.

So, go ahead and select the Empty app template. Encore will give you a clean, empty project for you .

Once you do, Encore will generate a clean, minimal project structure for you. No extra code, no pre-built features just a blank canvas waiting for your ideas.

Image

Now comes the fun part of naming your project. For this tutorial, I’ve named my project “polling-system”, but feel free to choose something that resonates with you.

Image

As soon as you hit Enter, Encore will spring into action. It’ll start downloading the “Empty app” template and setting up your project behind the scenes.

You’ll see a flurry of activity in your terminal files being created, dependencies being installed, and everything neatly organized into a clean project structure.

Once the setup is complete, Encore will likely prompt you to run the project right away. It’ll suggest a command like encore run to fire things up.

But hold on we’re not going to run it just yet. Instead, we’ll switch over to our code editor, where we’ll open the project, explore the structure, and run it from there.

It’s a better way to stay in sync with the code as we build and tweak things.

Image

When you open the project in your code editor, it’s refreshingly simple. No clutter, no overwhelming list of dependencies.

If you peek into the package.json, you’ll see just one main dependency: encore.dev. That’s it.

Alongside it, TypeScript sits quietly as a dev dependency, keeping things clean and focused.

Open your terminal in your code editor and run the following command:

encore run
Enter fullscreen mode Exit fullscreen mode

As soon as you hit Enter, something magical happens. Encore doesn’t just start your project it automatically opens your browser and takes you straight to the developer dashboard.

As we start building our APIs, we’ll explore it in detail tracking requests, debugging errors, and much more.

But for now, just take a moment to appreciate how seamless this is. Encore is doing the heavy lifting so you can focus on what really matters: writing great code.

Let’s keep going there’s so much more to uncover!

Image

Implementing the API Endpoints

Alright, let’s create our first API endpoint. We’re going to build a simple /hello endpoint that responds with “Hello, world!” when you hit it.

Let's explore how we can accomplish this. Now, let's return to the code editor.

Image description

I’ve created a directory called hello right at the root of the project, and inside it, I added a file named hello.ts.

This is how we can keep our project organized structuring folders and files in a way that makes sense as the project grows.

Inside the hello.ts file, I imported the api function from Encore. This function is the heart of defining endpoints.

It takes two main things:

  1. An object as the first parameter, where we define the method (like GET, POST, etc) the path (like /hello), and a key called expose: true. Setting expose: true means this endpoint is publicly accessible anyone can call it.

  2. An asynchronous function, where we write the logic for what the endpoint should return. In our case, it’s a simple “Hello, world!” message.

I hope this gives you a basic idea of how endpoints are structured and what to keep in mind when working with them.

Now that we’ve got the fundamentals down, let’s shift gears and start building the REST APIs for our online polling system.

This is where things get exciting!

Create Poll:

Let’s start by creating a new directory called polls at the root of our project. Inside this directory, we’ll add a file named polls.ts.

This is where we’ll define everything related to our polls functionality keeping things neat and organized as we build.

Next, let’s define the interfaces for our polls.

// Data Models
interface Poll {
  id: number;
  question: string;
  created_at: string;
}

interface Option {
  id: number;
  poll_id: number;
  option_text: string;
  vote_count: number;
}

interface CreatePollParams {
  question: string;
  options: string[];
}

interface VoteParams {
  pollId: number;
  optionId: number;
}
Enter fullscreen mode Exit fullscreen mode

Here made things simple easy to understand and efficient. While createing poll in polls table we will only keep question nothing more than that.

Then, in the options table we keep poll_id along with option *text and vote_*count.

Setup your local database

The first thing you need is Docker running on your machine. Encore uses this to automatically setup and manage your local databases.

Don’t worry, if you do not have Docker, just go to the website and download the version that is suitable for your operating system. The installation is simple so you will be able to create a PostgreSQL instance very quickly.

Once Docker is started, you don’t need to do anything else with it for now.

Now, let’s focus on creating a database schema for our polls API. To do this, we’ll use migration files.

Migrations are like a version control for your db they help you define and update your db over time.

Here’s how we’ll set it up:

  1. Inside the polls directory (which we created earlier), we’ll add another directory called migrations. This is where all our database migration files will live.

  2. Inside the migrations folder, we’ll create a file named 1_create_tables.up.sql The name might look a bit specific, but it’s just a convention the 1 indicates it’s the first migration, and .up.sq means this file will handle creating or updating the database schema.

In this file we’ll define the schema for our polls and options table.

Here’s an example:

CREATE TABLE polls (
    id SERIAL PRIMARY KEY,
    question TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT now()
);

CREATE TABLE options (
    id SERIAL PRIMARY KEY,
    poll_id INTEGER REFERENCES polls(id) ON DELETE CASCADE,
    option_text TEXT NOT NULL,
    vote_count INTEGER DEFAULT 0
);
Enter fullscreen mode Exit fullscreen mode

This polls schema includes:

  • id column that auto-increments with each new poll.
  • question columns to store the only question text of the poll.
  • created_at column to track when the poll was created.

and in options table:

  • id column that auto-increments with each new poll.
  • poll_id column to store the poll ID as a reference.
  • options_text column to store option text. Each poll should have exactly 4 options.
  • vote_count field that updates when a user votes for an option

Once this migration file is in place, Encore will automatically apply it to the database when we run our app using encore run.

And just like that, our database will be ready to store polls!

Now, to we're going to use our database from our application. To do this, we’ll need to import the SQLDatabase from Encore’s sqldb module. This will allow us to interact with our PostgreSQL database seamlessly.

import { SQLDatabase } from "encore.dev/storage/sqldb";
Enter fullscreen mode Exit fullscreen mode

Once we’ve imported it we’ll define a database instance. Since our database is named polls, we’ll use that as the reference.

Inside the database instance, we’ll also need to define the path to our migrations folder. This tells Encore where to find the migration files we created earlier.

const db = new SQLDatabase("polls", { migrations: "./migrations" });
Enter fullscreen mode Exit fullscreen mode

With this setup, Encore knows exactly where to find the migration files and will automatically apply them to the polls database when we run the app.

It’s a clean and efficient way to manage our database schema as we build out the polling system.

Let's finally connect all the things together to test our create poll API.

import { api, APIError } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";

const db = new SQLDatabase("polls", { migrations: "./migrations" });

interface Poll {
  id: number;
  question: string;
  created_at: string;
}

interface Option {
  id: number;
  poll_id: number;
  option_text: string;
  vote_count: number;
}

interface CreatePollParams {
  question: string;
  options: string[];
}

// Create a Poll
export const createPoll = api<CreatePollParams, Poll>(
  {
    method: "POST",
    path: "/polls",
    expose: true,
  },
  async ({ question, options }: CreatePollParams): Promise<Poll> => {
    if (options.length < 2 || options.length > 4) {
      throw APIError.invalidArgument("Poll must have between 2 and 4 options.");
    }

    const poll = await db.queryRow<Poll>`
      INSERT INTO polls (question)
      VALUES (${question})
      RETURNING id, question, created_at`;

    if (!poll) {
      throw APIError.internal("Failed to create poll");
    }

    for (const optionText of options) {
      await db.exec`
        INSERT INTO options (poll_id, option_text, vote_count)
        VALUES (${poll.id}, ${optionText}, 0)`;
    }

    return poll;
  }
);
Enter fullscreen mode Exit fullscreen mode

Here we have the condition to check options are meet our expectation that has to be greater 2 or less then equal 4.

Then we have added questions to the polls table and options in the options table.

Once you re-run your app, you’ll notice that after the process completes in Docker, a container is created for your database.

Image

Cool, let’s test our endpoint on the Developer Dashboard. Head over to the dashboard, and you’ll see your endpoint listed there.

Image

Now, when we check the response, we can see the id, question, andcreated_atfields. This means our data has been successfully saved in the database.

Since we are saving only questions in polls table and options in the options table, so It’s working perfectly!

If you want to take a closer look at the tables and data, you can use the Encore DB Shell. It’s a handy tool that lets you interact directly with your database.

To open the shell, just run this command in your terminal:

encore db shell polls
Enter fullscreen mode Exit fullscreen mode

Here, polls is the name of our database. Once you run this, you’ll be inside the database shell, where you can explore tables, run SQL queries, and see exactly what’s stored in your database.

Let’s give it a try and see what’s under the hood!

Image

Now we can query from our table.

Image

I have created some polls and we can see all the options here with vote_count. The data is being saved correctly, and we’ve verified it.

Read All Polls:

Now, let’s create our second endpoint this time to retrieve all the polls from our database.

Just like we did for the createPoll endpoint, we’ll follow a similar process. We’ll define the endpoint, write the logic to fetch the data from the database and return it as a response.

It’s all about building on what we’ve already learned. Let’s dive in and get this done!

// Get All Polls
export const getPolls = api(
  {
    method: "GET",
    path: "/polls",
    expose: true,
  },
  async (): Promise<{ polls: Poll[] }> => {
    const rows = db.query`
      SELECT id, question, created_at
      FROM polls
      ORDER BY created_at DESC`;

    const polls: Poll[] = [];
    for await (const row of rows) {
      polls.push({
        id: row.id,
        question: row.question,
        created_at: row.created_at,
      });
    }

    return { polls };
  }
);
Enter fullscreen mode Exit fullscreen mode

Let’s test our new endpoint and see if all the polls are showing up correctly.

Head over to the Developer Dashboard, find the getPolls endpoint in the dropdown, and hit send. If everything’s set up right, you should see a list of all the polls stored in your database.

Image

Get poll by ID:

Now, let’s create an endpoint to get a single poll by its ID. For this, we’ll take the id as a parameter in the function.

Then, we’ll write a simple SQL query to fetch the poll from the database that matches the given id. It’s straightforward just a small tweak to what we’ve already done.

Let’s build it and see how it works!

// Get a Poll by ID
export const getPollById = api<
  { id: number },
  { poll: Poll; options: Option[] }
>(
  {
    method: "GET",
    path: "/polls/:id",
    expose: true,
  },
  async ({
    id,
  }: {
    id: number;
  }): Promise<{ poll: Poll; options: Option[] }> => {
    const poll = await db.queryRow<Poll>`
      SELECT id, question, created_at FROM polls WHERE id = ${id}`;
    if (!poll) {
      throw APIError.notFound("Poll not found");
    }

    const optionsRows = db.query`
      SELECT id, option_text, vote_count FROM options WHERE poll_id = ${id}`;

    const options: Option[] = [];
    for await (const row of optionsRows) {
      options.push({
        id: row.id,
        poll_id: id,
        option_text: row.option_text,
        vote_count: row.vote_count,
      });
    }

    return { poll, options };
  }
);
Enter fullscreen mode Exit fullscreen mode

Let’s test this new endpoint using the Developer Dashboard. Head over to the dashboard, find the getPollById endpoint, and pass 1 as the ID. Hit send, and you should see the details of that single poll in the response. 

Image

Vote Poll by ID:

Now, let’s create an endpoint to vote poll by its ID. For this, we’ll take the id as a parameter in the function and optionId from the body.

Then, we’ll write a simple SQL query to vote the poll. We will update the vote_count value based on the optionId. Let’s build it and see how it works!

// Vote for an Option
export const votePoll = api<VoteParams, { message: string }>(
  {
    method: "POST",
    path: "/polls/:pollId/vote",
    expose: true,
  },
  async ({ pollId, optionId }: VoteParams): Promise<{ message: string }> => {
    const option = await db.queryRow<Option>`
      SELECT * FROM options WHERE id = ${optionId} AND poll_id = ${pollId}`;

    if (!option) {
      throw APIError.notFound("Invalid option.");
    }

    await db.exec`
      UPDATE options SET vote_count = vote_count + 1 WHERE id = ${optionId}`;

    return { message: "Vote recorded successfully" };
  }
);
Enter fullscreen mode Exit fullscreen mode

Let's test our Vote Poll endpoint:

Image

Get Poll Result:

Now, let’s create an endpoint to get result by poll ID. For this, we’ll take the id as a parameter in the function.

Then, we’ll write a simple SQL query to get the vote result by the poll ID. Let’s build it and see how it works!

// Get Poll Results
export const getPollResults = api<{ id: number }, { results: Option[] }>(
  {
    method: "GET",
    path: "/polls/:id/results",
    expose: true,
  },
  async ({ id }: { id: number }): Promise<{ results: Option[] }> => {
    const optionsRows = db.query`
      SELECT id, option_text, vote_count FROM options WHERE poll_id = ${id}
      ORDER BY vote_count DESC`;

    const options: Option[] = [];
    for await (const row of optionsRows) {
      options.push({
        id: row.id,
        poll_id: id,
        option_text: row.option_text,
        vote_count: row.vote_count,
      });
    }

    if (options.length === 0) {
      throw APIError.notFound("Poll not found.");
    }

    return { results: options };
  }
);
Enter fullscreen mode Exit fullscreen mode

Let's test the endpoint on the developer dashboard:

Image

Now that we’ve walked through all the operations for our online polling system API.

Create Poll, Get poll by ID, Vote poll, and Get Poll result. You’ve got the foundation to build a fully functional application.

I hope this gives you a solid understanding of how to create and manage an application using Encore.

Deploy the backend server:

Deployment in Encore is one of those things that feels almost magical it’s incredibly interesting and super easy.

With just 3 commands, you can deploy your entire backend code, including the database.

Here’s how it works:

  1. Stage your changes:

    git add .
    
  2. Commit your changes with a message:

    git commit -m "Your commit message here"
    
  3. Push to Encore:

    git push encore
    

And that’s it! Encore takes care of the rest building your application, setting up the database, and deploying everything to the cloud.

Image

Now open your app from app.encore.dev

Image description

Here’s your live hosted URL!

You can now use this URL to integrate with frontend that we will do in a bit.

And guess what? It’s already hosted in a staging environment.

How cool is that? In just a minute, your application is up and running, ready to be tested and used.

Encore makes deployment feel effortless.

API Integration with Frontend

We have all the endpoints, they’re in the Encore environment. Now it’s time to bring it all to life by adding a frontend.

For this, I’m creating a React app using shadcn UI—a sleek and modern UI library that’ll make our online polling platform look polished and professional.

Now that the backend is set up, let’s connect it to the frontend!

// App.tsx

import { useEffect, useState } from "react";
import CreatePollDialog from "./components/CreatePollDialog";
import PollCard from "./components/PollCard";
import { CreatePollData, Poll } from "./types";

const base_url = "http://127.0.0.1:4000";

function App() {
  const [polls, setPolls] = useState<Poll[]>([]);

  useEffect(() => {
    fetchPolls();
  }, []);

  const fetchPolls = async () => {
    try {
      const response = await fetch(`${base_url}/polls`);
      const data = await response.json();
      console.log(data);
      const pollsWithOptions = await Promise.all(
        data.polls.map(async (poll: Poll) => {
          const detailResponse = await fetch(`${base_url}/polls/${poll.id}`);
          const detailData = await detailResponse.json();
          return {
            ...poll,
            options: detailData.options,
          };
        })
      );

      console.log(pollsWithOptions);
      setPolls(pollsWithOptions);
    } catch (error) {
      console.error("Failed to fetch polls:", error);
    }
  };

  const handleCreatePoll = async (data: CreatePollData) => {
    try {
      const response = await fetch(`${base_url}/polls`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(data),
      });
      if (response.ok) {
        fetchPolls();
      }
    } catch (error) {
      console.error("Failed to create poll:", error);
    }
  };

  const handleVote = async (pollId: number, optionId: number) => {
    try {
      const response = await fetch(`${base_url}/polls/${pollId}/vote`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ pollId, optionId }),
      });
      if (response.ok) {
        const resultsResponse = await fetch(
          `${base_url}/polls/${pollId}/results`
        );
        const { results } = await resultsResponse.json();
        setPolls((prev) =>
          prev.map((poll) =>
            poll.id === pollId ? { ...poll, options: results } : poll
          )
        );
      }
    } catch (error) {
      console.error("Failed to submit vote:", error);
    }
  };

  return (
    <div className="w-screen min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
      <div className="w-full px-4 py-8">
        <div className="max-w-7xl mx-auto mb-8">
          <div className="flex items-center justify-between flex-wrap gap-4">
            <div>
              <h1 className="text-4xl font-bold text-gray-900 dark:text-white">
                Community Polls
              </h1>
              <p className="mt-2 text-gray-600 dark:text-gray-300">
                Create and vote on community polls
              </p>
            </div>
            <CreatePollDialog onCreatePoll={handleCreatePoll} />
          </div>
        </div>

        <div className="max-w-7xl mx-auto space-y-6">
          {polls.map((poll) => (
            <PollCard key={poll.id} poll={poll} onVote={handleVote} />
          ))}
          {polls.length === 0 && (
            <div className="text-center py-12">
              <p className="text-gray-500 dark:text-gray-400">
                No polls yet. Be the first to create one!
              </p>
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

export default App;

Enter fullscreen mode Exit fullscreen mode

This CreatePollDialog is a nice little dialog box for creating polls. We have 2 input boxes to define the poll question and add multiple options.

It’s simple and easy for users to interact and participate. Let’s see how it works and integrate it into our app!

// CreatePollDialog.tsx

import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { CreatePollData } from "@/types";
import { Cross2Icon } from "@radix-ui/react-icons";
import { PlusCircle } from "lucide-react";
import { useState } from "react";

interface CreatePollDialogProps {
  onCreatePoll: (data: CreatePollData) => Promise<void>;
}

export default function CreatePollDialog({
  onCreatePoll,
}: CreatePollDialogProps) {
  const [question, setQuestion] = useState("");
  const [options, setOptions] = useState(["", ""]);
  const [isOpen, setIsOpen] = useState(false);

  const handleAddOption = () => {
    if (options.length < 4) {
      setOptions([...options, ""]);
    }
  };

  const handleRemoveOption = (index: number) => {
    if (options.length > 2) {
      setOptions(options.filter((_, i) => i !== index));
    }
  };

  const handleOptionChange = (index: number, value: string) => {
    const newOptions = [...options];
    newOptions[index] = value;
    setOptions(newOptions);
  };

  const handleSubmit = async () => {
    if (question.trim() && options.every((opt) => opt.trim())) {
      await onCreatePoll({
        question: question.trim(),
        options: options.map((opt) => opt.trim()),
      });
      setQuestion("");
      setOptions(["", ""]);
      setIsOpen(false);
    }
  };

  return (
    <Dialog open={isOpen} onOpenChange={setIsOpen}>
      <DialogTrigger asChild>
        <Button className="gap-2">
          <PlusCircle className="h-5 w-5" />
          Create Poll
        </Button>
      </DialogTrigger>
      <DialogContent className="sm:max-w-[425px]">
        <div className="flex justify-between items-center">
          <h2 className="text-lg font-semibold"> Create a New Poll</h2>
          <Cross2Icon
            onClick={() => setIsOpen(false)}
            className="h-4 w-4 cursor-pointer"
          />
        </div>

        <div className="grid gap-4 py-4">
          <div className="space-y-2">
            <Label htmlFor="question">Question</Label>
            <Input
              id="question"
              value={question}
              onChange={(e) => setQuestion(e.target.value)}
              placeholder="What's your question?"
            />
          </div>
          <div className="space-y-4">
            <Label>Options</Label>
            {options.map((option, index) => (
              <div key={index} className="flex items-center  gap-2">
                <Input
                  value={option}
                  onChange={(e) => handleOptionChange(index, e.target.value)}
                  placeholder={`Option ${index + 1}`}
                />
                {options.length > 2 && (
                  <Cross2Icon
                    onClick={() => handleRemoveOption(index)}
                    className="h-4 w-4 cursor-pointer"
                  />
                )}
              </div>
            ))}
            {options.length < 4 && (
              <Button
                variant="outline"
                className="w-full"
                onClick={handleAddOption}
              >
                Add Option
              </Button>
            )}
          </div>
        </div>
        <Button onClick={handleSubmit} className="w-full">
          Create Poll
        </Button>
      </DialogContent>
    </Dialog>
  );
}

Enter fullscreen mode Exit fullscreen mode

And the PollCard is where you vote on polls. It will also display a smooth animation, similar to YouTube community polls. Check out the demo video below.

// PollCard.tsx

import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { Poll } from "@/types";
import { motion } from "framer-motion";
import { useState } from "react";

interface PollCardProps {
  poll: Poll;
  onVote: (pollId: number, optionId: number) => Promise<void>;
}

export default function PollCard({ poll, onVote }: PollCardProps) {
  const [hasVoted, setHasVoted] = useState(false);
  const [selectedOption, setSelectedOption] = useState<number | null>(null);
  const totalVotes =
    poll.options?.reduce((sum, opt) => sum + opt.vote_count, 0) || 0;

  const handleVote = async (optionId: number) => {
    if (!hasVoted) {
      setSelectedOption(optionId);
      await onVote(poll.id, optionId);
      setHasVoted(true);
    }
  };

  const getPercentage = (votes: number) => {
    return totalVotes === 0 ? 0 : Math.round((votes / totalVotes) * 100);
  };

  return (
    <Card className="w-full">
      <CardHeader>
        <h3 className="text-xl font-semibold">{poll.question}</h3>
        <p className="text-sm text-muted-foreground">
          Created {new Date(poll.created_at).toLocaleDateString()}
        </p>
      </CardHeader>
      <CardContent className="space-y-4">
        {poll.options?.map((option) => (
          <div key={option.id} className="relative">
            <Button
              variant={
                hasVoted && selectedOption !== option.id
                  ? "secondary"
                  : "outline"
              }
              className={cn(
                "w-full justify-start h-auto py-3 px-4 relative overflow-hidden",
                selectedOption === option.id && "border-primary",
                hasVoted &&
                  selectedOption !== option.id &&
                  "opacity-70 text-black"
              )}
              onClick={() => handleVote(option.id)}
              disabled={hasVoted && selectedOption !== option.id}
            >
              {hasVoted && selectedOption === option.id && (
                <motion.div
                  className="absolute left-0 top-0 bottom-0 bg-primary/20"
                  initial={{ width: 0 }}
                  animate={{ width: `${getPercentage(option.vote_count)}%` }}
                  transition={{ duration: 0.5, ease: "easeOut" }}
                />
              )}
              <div className="relative z-10 flex justify-between w-full">
                <span>{option.option_text}</span>
                {hasVoted && (
                  <span className="ml-2 font-medium">
                    {getPercentage(option.vote_count)}%
                  </span>
                )}
              </div>
            </Button>
          </div>
        ))}
      </CardContent>
      <CardFooter>
        <p className="text-sm text-muted-foreground">
          {totalVotes} {totalVotes === 1 ? "vote" : "votes"}
        </p>
      </CardFooter>
    </Card>
  );
}

Enter fullscreen mode Exit fullscreen mode

Here’s the final product. Everything is live when you vote, it saves to the backend database and you get a smooth animation like YouTube community polls and other online polls.

Wrapping up

Wow, what a ride from setting up the project and creating the first API to connecting to a PostgreSQL DB and deploying to Encore. Seeing it all come together has been awesome.

Encore makes it so easy to build robust, type-safe apps that scale.

If you want a closer look, don't fail to check out the Documentation.

If you like the project please Star Encore on GitHub.

Thanks for reading till the end!

Top comments (4)

Collapse
 
arindam_1729 profile image
Arindam Majumder

Great Article!

Collapse
 
marcuskohlberg profile image
Marcus Kohlberg

Thanks!

Collapse
 
astrodevil profile image
Astrodevil

Helpful Guide!

Collapse
 
marcuskohlberg profile image
Marcus Kohlberg

Thanks!