The Poll App We Built in a Day: A Story of Deadlines, Details, and Vue.js

You know those moments when an idea hits, and it's so good you just have to build it right now? That's exactly what happened with this app.

We were in the middle of planning our Staff Recognition for 2025 at Vakkaru Maldives, and we had a challenge. The surprise of the night was going to be crowning our "Mr. and Miss Vakkaru," and we wanted the entire audience to vote, live. We needed something fast, reliable, and engaging.

While the initial idea came from the resort's management, the vision for how to actually bring it to life sparked from my friend and colleague of over a decade, our incredible Assistant HR Manager, Hassan Thalhath - Solah. He imagined us creating the poll in real-time, right in front of everyone. Attendees would just scan a QR code, vote on their phones, and watch the results pour in. No fuss, no delays.

Here’s the crazy part: from the moment Solah and I started planning the execution to having a fully working application, less than a day had passed.

This is where having a partner-in-crime who shares your obsessive eye for detail (we both have a touch of that productive OCD) makes all the difference. We knew we couldn't settle for some clunky, ad-riddled online tool. It had to be elegant, seamless, and perfect for the occasion. We're incredibly proud of what we can create when we put our minds to it, and this was no exception.

Almost immediately, we realized this wasn't just a one-off solution for our party. Anyone running a workshop, a live presentation, or a community event could use a tool this simple and powerful. And because we believe in that, we've put the app online for everyone. It's completely free of charge—no strings attached.

Go ahead and try it out for yourself: https://pollapp.eyaadh.net/

So, fueled by a tight deadline and a passion for getting it right, we built it with a few core principles:

  • Instant Creation: Build and launch a poll in seconds.
  • Effortless Voting: No sign-ups, no app downloads. Just scan and vote.
  • Live, Animated Results: Make watching the results as engaging as voting.
  • Host Control: Simple, secure tools for the poll creator.


And that's the story of how, in less than 24 hours, a real-world need at a resort in the Maldives turned into a fully-fledged real-time polling application.

The final workflow is designed to be as intuitive and seamless as the idea that sparked it. Before we dive into the code, here’s a quick visual breakdown of the entire journey from creation to the final results:

The Technical Deep Dive: How It All Works

Now that you've seen the high-level flow, let's get into the nuts and bolts of how we brought this app to life in less than a day.

To pull this off, I reached for my go-to toolkit. Anyone who follows my work knows I live and breathe Vue, so that was the obvious choice for the frontend. We paired it with the real-time power of Firebase Firestore to handle all the database magic without needing a traditional backend. It's the perfect stack for building something powerful, fast.

I've already done the heavy lifting of the initial project setup, so you can jump right into the code. The best way to follow along is to clone the project directly from GitHub and open it in your favorite code editor.

You can get the full source code here: https://github.com/eyaadh/pollApp

Once you have the project open, we'll take a tour of its core components. We're going to break down:

  • The Foundation: How the app starts up and connects to all its core services.
  • The Views: A look at each page of the application (Home, Manage, Vote, and Results).
  • The Router: How Vue Router seamlessly connects these views.
  • The Pinia Store: The central "brain" that manages all our data and logic.

Ready? Let's dive in.

The Foundation: App Setup (main.ts & firebase.ts)

Before we explore the individual pages, let's quickly look at the two files that bootstrap the entire application. This is where we connect all the pieces.

firebase.ts

This file has one simple but vital job: to connect to our Firebase project. It reads the project credentials from our secure environment variables (.env.local) and initializes the connection.

// In src/firebase.ts
import { initializeApp } from "firebase/app";

// Load config from environment variables
const firebaseConfig = {  
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,  
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,  
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,  
  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,  
  messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,  
  appId: import.meta.env.VITE_FIREBASE_APP_ID,  
};

// Initialize Firebase and export the main app instance
export const firebaseApp = initializeApp(firebaseConfig);

We export the firebaseApp instance, which acts as the gateway to all Firebase services.

main.ts

This is the main entry point for our Vue application. Here, we create our app instance and then "plug in" all the essential services it needs to run.

// In src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { VueFire } from 'vuefire'

import App from './App.vue'
import router from './router'
import { firebaseApp } from './firebase'

const app = createApp(App)

app.use(createPinia()) // Plug in our state management library
app.use(router)        // Plug in our router for page navigation

// Plug in VueFire, connecting our Vue app to our Firebase instance
app.use(VueFire, {
  firebaseApp,
})

app.mount('#app')

The most important part here is app.use(VueFire). This is the magic that links Vue to Firebase. It gives us access to powerful composables like useDocument and useCollection throughout our app, which are the key to our real-time data features.

With that foundation in place, let's see how it all comes together, starting with our landing page.

Kicking Things Off: The Home View

First impressions matter, and HomeView.vue is the first thing our users see. Its job is simple but crucial: welcome the user, give them a clear path to create a new poll, and show them any polls they might have created in the past. It's the central hub of the application.

<template>
  <div class="relative isolate min-h-screen overflow-hidden bg-gray-900">
    <svg
      class="absolute inset-0 -z-10 w-full h-full mask-[radial-gradient(100%_100%_at_top_right,white,transparent)] stroke-white/10"
      aria-hidden="true"
      xmlns="http://www.w3.org/2000/svg"
    >
      <defs>
        <pattern
          id="pattern-lines"
          width="200"
          height="200"
          x="50%"
          y="-1"
          patternUnits="userSpaceOnUse"
        >
          <path d="M0.5 200V0.5H200" fill="none" />
        </pattern>
      </defs>

      <svg x="50%" y="-1" class="overflow-visible fill-gray-800/20" xmlns="http://www.w3.org/2000/svg">
        <path
          d="M-200 0h201v201h-201Z M600 0h201v201h-201Z M-400 600h201v201h-201Z M200 800h201v201h-201Z"
          stroke-width="0"
        />
      </svg>

      <rect
        width="100%"
        height="100%"
        stroke-width="0"
        fill="url(#pattern-lines)"
      />
    </svg>

    <div
      class="absolute top-10 left-[calc(50%-4rem)] -z-10 transform-gpu blur-3xl
             sm:left-[calc(50%-18rem)] lg:top-[calc(50%-30rem)] lg:left-48
             xl:left-[calc(50%-24rem)]"
      aria-hidden="true"
    >
      <div
        class="aspect-[1108/632] w-[277px] bg-gradient-to-r from-[#80caff] to-[#4f46e5] opacity-20"
        style="
          clip-path: polygon(
            73.6% 51.7%,
            91.7% 11.8%,
            100% 46.4%,
            97.4% 82.2%,
            92.5% 84.9%,
            75.7% 64%,
            55.3% 47.5%,
            46.5% 49.4%,
            45% 62.9%,
            50.3% 87.2%,
            21.3% 64.1%,
            0.1% 100%,
            5.4% 51.1%,
            21.4% 63.9%,
            58.9% 0.2%,
            73.6% 51.7%
          );
        "
      />
    </div>

    <div
      class="mx-auto max-w-7xl px-6 pt-10 pb-24 sm:pb-32
             lg:flex lg:h-screen lg:items-center lg:px-8 lg:py-0"
    >
      <div
        class="mx-auto flex h-full max-h-full max-w-2xl shrink-0 flex-col overflow-y-auto
               pr-4 pb-10 pl-2 lg:mx-0 lg:pt-8"
      >
        <h1
          class="animate-fade-down mt-10 text-5xl font-semibold tracking-tight
                 text-pretty text-white sm:text-7xl"
        >
          Live Polls, Instantly.
        </h1>

        <p
          class="animate-fade-right mt-8 text-lg font-medium text-pretty text-gray-400 sm:text-xl/8"
        >
          Give your poll a name to get started. Create and share with anyone, anywhere.
        </p>

        <form
          @submit.prevent="handleCreatePoll"
          class="animate-fade-up mt-10 flex items-center gap-x-4"
        >
          <input
            v-model="newPollName"
            type="text"
            required
            placeholder="e.g., Team Lunch Options"
            class="flex-grow rounded-md border-0 bg-white/5 px-3 py-2.5 text-white
                   shadow-sm ring-1 ring-white/10 ring-inset placeholder:text-gray-400
                   focus:ring-2 focus:ring-indigo-500 focus:ring-inset sm:text-sm sm:leading-6"
          />
          <button
            type="submit"
            class="rounded-md bg-indigo-500 px-3.5 py-2.5 text-sm font-semibold
                   text-white shadow-xs hover:bg-indigo-400
                   focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
          >
            Create Poll
          </button>
        </form>

        <div class="animate-fade-up mt-16 w-full">
          <h2 class="text-xl font-bold tracking-tight text-white sm:text-2xl">
            Or manage an existing poll
          </h2>

          <div v-if="isLoading" class="mt-6 text-left text-gray-500">
            <p>Loading existing polls...</p>
          </div>

          <div v-else-if="pollsList.length > 0" class="mt-6 space-y-3">
            <RouterLink
              v-for="poll in pollsList"
              :key="poll.id"
              :to="{ name: 'manage-poll', params: { id: poll.id } }"
              class="block rounded-lg border border-white/10 bg-white/5 p-4 shadow-lg
                     transition-all hover:bg-white/10 hover:ring-2 hover:ring-indigo-400"
            >
              <div class="flex items-center justify-between">
                <div>
                  <p class="font-semibold text-white">{{ poll.name }}</p>
                  <p class="text-xs text-gray-400">{{ poll.options.length }} options</p>
                </div>
                <span
                  :class="{
                    'bg-yellow-400/10 text-yellow-400 ring-yellow-400/20': poll.status === 'configuring',
                    'bg-green-400/10 text-green-400 ring-green-400/20': poll.status === 'voting',
                    'bg-gray-400/10 text-gray-400 ring-gray-400/20': poll.status === 'closed',
                  }"
                  class="rounded-full px-3 py-1 text-xs font-medium capitalize ring-1 ring-inset"
                >
                  {{ poll.status }}
                </span>
              </div>
            </RouterLink>
          </div>

          <div v-else class="mt-6 text-left text-gray-500">
            <p>No polls found. Create one to get started!</p>
          </div>
        </div>
      </div>

      <div
        class="mx-auto mt-16 flex max-w-2xl sm:mt-24 lg:mt-0 lg:mr-0 lg:ml-10 lg:max-w-none lg:flex-none xl:ml-32"
      >
        <div
          class="h-[50px] max-w-md flex-none md:max-w-3xl lg:h-auto lg:max-w-none"
        >
          <img
            src="@/assets/vote.jpg"
            alt="App screenshot"
            class="w-full rounded-md bg-white/5 shadow-2xl ring-1 ring-white/10"
          />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, watch } from "vue";
import { RouterLink } from "vue-router";
import { usePollStore } from "@/stores/poll";
import { storeToRefs } from "pinia";

const pollStore = usePollStore();
const { pollsList } = storeToRefs(pollStore);

const newPollName = ref("");
const isLoading = ref(true);

watch(
  pollsList,
  (newList) => {
    if (newList) {
      isLoading.value = false;
    }
  },
  { immediate: true },
);

function handleCreatePoll() {
  pollStore.createPoll(newPollName.value);
  newPollName.value = "";
}
</script>

The User Interface (<template>)

The UI is split into two main columns; on the left are all the interactive elements, while a static image on the right sets the mood. The most important part is the creation form, which captures the poll name and sets its visibility using a Switch component from Headless UI.

<!-- In HomeView.vue -->
<form @submit.prevent="handleCreatePoll" class="mt-10 flex items-center gap-x-4">
  <input
    v-model="newPollName"
    type="text"
    required
    placeholder="e.g., Team Lunch Options"
    class="..."
  />
  <button type="submit" class="...">
    Create Poll
  </button>
</form>

Below the form, we display the list of existing polls. This is where the real-time nature of Firebase starts to shine. We use a v-for loop to iterate over a reactive list called pollsList from our central store. Each item is wrapped in a <RouterLink>, which dynamically links to the manage page for that specific poll's ID.

<!-- In HomeView.vue -->
<div v-if="pollsList.length > 0" class="mt-6 space-y-3">
  <RouterLink
    v-for="poll in pollsList"
    :key="poll.id"
    :to="{ name: 'manage-poll', params: { id: poll.id } }"
    class="..."
  >
    <!-- Display poll.name and poll.status -->
  </RouterLink>
</div>

The Logic (<script setup>)

The script for this view is incredibly lean because all the heavy lifting is handled by our Pinia store. We simply connect to the store, pull in the pollsList data, and create a handleCreatePoll function. This function's only job is to take the user's input and pass it along to the createPoll action in our store.

// In HomeView.vue
function handleCreatePoll() {
  // Pass the name and visibility to the store.
  // The store handles the database logic and navigation.
  pollStore.createPoll(newPollName.value, newPollVisibility.value);
  newPollName.value = ""; // Clear the input
}

This component is a perfect example of a "dumb" view: it displays data from the store and sends user events to the store, but contains no complex business logic itself. With a single function call, the user has created a new poll and is automatically navigated to the manage page. Let's follow them there.

The Control Center: ManagePollView.vue

This view is the heart of the host's experience. It's a dynamic component that changes its appearance and functionality based on the poll's current status.

<template>
  <div class="relative isolate min-h-screen overflow-hidden bg-gray-900 p-4 sm:p-8">
    <svg
      class="absolute inset-0 -z-10 w-full h-full mask-[radial-gradient(100%_100%_at_top_right,white,transparent)] stroke-white/10"
      aria-hidden="true"
      xmlns="http://www.w3.org/2000/svg"
    >
      <defs>
        <pattern
          id="pattern-lines"
          width="200"
          height="200"
          x="50%"
          y="-1"
          patternUnits="userSpaceOnUse"
        >
          <path d="M0.5 200V0.5H200" fill="none" />
        </pattern>
      </defs>
      <rect
        width="100%"
        height="100%"
        fill="url(#pattern-lines)"
        stroke-width="0"
      />
    </svg>

    <div class="mx-auto max-w-3xl">
      <header class="animate-fade-down mb-10 text-center">
        <h1 class="text-4xl font-bold text-white sm:text-5xl">
          {{ pollData?.name || "Manage Your Poll" }}
        </h1>
        <p v-if="pollData" class="mt-4 font-mono text-sm text-gray-500">
          Poll ID: {{ route.params.id }}
        </p>
      </header>

      <div v-if="!pollData" class="animate-fade-right text-center text-gray-400">
        <p>Loading poll data...</p>
      </div>

      <div v-else-if="pollData.status === 'configuring'" class="animate-fade-right">
        <section class="mb-8 rounded-lg border border-white/10 bg-white/5 p-6 shadow-xl">
          <h2 class="mb-4 text-2xl font-semibold text-white">Poll Details</h2>

          <div class="mb-6">
            <label for="poll-name" class="block text-sm leading-6 font-medium text-gray-300">
              Poll Name
            </label>
            <div class="mt-2">
              <input
                v-model="editablePollName"
                id="poll-name"
                type="text"
                class="block w-full rounded-md border-0 bg-white/5 px-3 py-1.5 text-white shadow-sm ring-1 ring-white/10 ring-inset placeholder:text-gray-400 focus:ring-2 focus:ring-indigo-500 focus:ring-inset sm:text-sm sm:leading-6"
              />
            </div>
          </div>

          <SwitchGroup as="div" class="mb-6 flex items-center justify-between">
            <div>
              <SwitchLabel as="span" class="text-sm leading-6 font-medium text-gray-300" passive>
                Results Visibility
              </SwitchLabel>
              <p class="mt-1 text-xs text-gray-500">
                <span v-if="editableVisibility">Anyone can view live results.</span>
                <span v-else>Results are private until the poll is closed.</span>
              </p>
            </div>
            <Switch
              v-model="editableVisibility"
              :class="[
                editableVisibility ? 'bg-indigo-600' : 'bg-gray-700',
                'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2 focus:ring-offset-gray-900 focus:outline-none'
              ]"
            >
              <span
                aria-hidden="true"
                :class="[
                  editableVisibility ? 'translate-x-5' : 'translate-x-0',
                  'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out'
                ]"
              />
            </Switch>
          </SwitchGroup>

          <div>
            <label for="close-code" class="block text-sm leading-6 font-medium text-gray-300">
              Closing Code
            </label>
            <p class="mt-1 text-xs text-gray-500">
              Save this code. You'll need it to close the poll later.
            </p>
            <div class="mt-2 flex gap-2">
              <input
                :type="closeCodeInputType"
                :value="pollData.closeCode"
                readonly
                class="block w-full rounded-md border-0 bg-black/20 px-3 py-1.5 font-mono text-gray-300 shadow-sm ring-1 ring-white/10 ring-inset"
              />
              <button
                @click="toggleCloseCodeVisibility"
                class="rounded-md p-2 text-sm font-semibold text-gray-300 ring-1 ring-white/20 ring-inset hover:bg-white/10"
              >
                <EyeIcon v-if="closeCodeInputType === 'password'" class="h-5 w-5" />
                <EyeSlashIcon v-else class="h-5 w-5" />
              </button>
              <button
                @click="copy(pollData.closeCode)"
                class="rounded-md bg-indigo-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-400"
              >
                <ClipboardDocumentCheckIcon v-if="copied" class="h-5 w-5" />
                <ClipboardDocumentIcon v-else class="h-5 w-5" />
              </button>
            </div>
          </div>
        </section>

        <section class="mb-8 rounded-lg border border-white/10 bg-white/5 p-6 shadow-xl">
          <h2 class="mb-4 text-2xl font-semibold text-white">Add & Arrange Options</h2>
          <form @submit.prevent="handleAddOption" class="mb-4 flex gap-3">
            <input
              v-model="newOptionText"
              type="text"
              placeholder="e.g., Project Alpha"
              class="flex-grow rounded-md border-white/10 bg-white/5 px-3 py-2 text-white shadow-sm ring-1 ring-white/10 ring-inset focus:ring-2 focus:ring-indigo-500 focus:ring-inset"
            />
            <button
              type="submit"
              class="rounded-md bg-indigo-500 px-4 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-400 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-indigo-500"
            >
              Add
            </button>
          </form>

          <div ref="listEl" class="space-y-2">
            <div
              v-for="option in editableOptions"
              :key="option.id"
              class="flex cursor-grab items-center justify-between rounded-md bg-white/10 p-3"
            >
              <span class="font-medium text-gray-200">{{ option.text }}</span>
              <div class="space-x-4">
                <button
                  @click="startEditing(option)"
                  class="text-sm font-semibold text-indigo-400 hover:text-indigo-300"
                >
                  Edit
                </button>
                <button
                  @click="pollStore.deleteOption(option.id)"
                  class="text-sm font-semibold text-red-400 hover:text-red-300"
                >
                  Delete
                </button>
              </div>
            </div>
          </div>
          <p v-if="editableOptions.length === 0" class="mt-4 text-center text-sm text-gray-500">
            Add some options to get started.
          </p>
        </section>

        <section class="text-center">
          <button
            @click="pollStore.startVoting()"
            :disabled="pollData.options.length < 2"
            class="rounded-md bg-green-500 px-5 py-3 text-base font-semibold text-white shadow-sm hover:bg-green-400 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-green-500 disabled:cursor-not-allowed disabled:bg-gray-600"
          >
            Finalize & Start Voting
          </button>
          <p v-if="pollData.options.length < 2" class="mt-2 text-center text-sm text-gray-500">
            Add at least two options to start voting.
          </p>
        </section>
      </div>

      <div v-else-if="pollData.status === 'voting'" class="animate-fade-left text-center">
        <h2 class="mb-4 text-3xl font-bold text-green-400">Voting is Live!</h2>
        <p class="mb-6 text-gray-300">Share this QR code or link with your participants.</p>
        <div class="mb-8 inline-block rounded-lg bg-white p-4 shadow-2xl">
          <QrcodeVue :value="`${siteUrl}/poll/${route.params.id}/vote`" :size="250" level="H" />
        </div>

        <section class="rounded-lg border border-white/10 bg-white/5 p-6 shadow-xl">
          <h2 class="mb-4 text-xl font-semibold text-white">Poll Controls</h2>

          <p class="mb-4 text-sm text-gray-400">
            Enter the closing code to enable poll controls.
            <span v-if="pollData.visibility === 'private'">For private polls, this also unlocks the 'View Results' button.</span>
          </p>

          <form @submit.prevent="handleClosePoll" class="flex flex-col items-center gap-4">
            <input
              v-model="enteredCloseCode"
              type="text"
              placeholder="Enter closing code..."
              class="w-full max-w-xs rounded-md border-white/10 bg-white/5 px-3 py-2 text-center text-white shadow-sm ring-1 ring-white/10 ring-inset focus:ring-2 focus:ring-indigo-500 focus:ring-inset"
            />

            <span class="isolate inline-flex rounded-md shadow-sm">
              <button
                type="button"
                @click="handleViewResultsWithCode"
                :disabled="isViewResultsDisabled"
                class="relative inline-flex items-center rounded-l-md bg-indigo-500 px-4 py-2 text-sm font-semibold text-white ring-1 ring-indigo-600/30 ring-inset hover:bg-indigo-400 focus:z-10 disabled:cursor-not-allowed disabled:bg-gray-700 disabled:text-gray-400"
              >
                View Results
              </button>
              <button
                type="submit"
                :disabled="isClosePollDisabled"
                class="relative -ml-px inline-flex items-center rounded-r-md bg-red-600 px-4 py-2 text-sm font-semibold text-white ring-1 ring-red-700/30 ring-inset hover:bg-red-500 focus:z-10 disabled:cursor-not-allowed disabled:bg-gray-700 disabled:text-gray-400"
              >
                Close Poll
              </button>
            </span>
          </form>
        </section>
      </div>
    </div>

    <EditOptionDialog
      :open="isEditDialogOpen"
      :initial-value="optionToEdit?.text || ''"
      @close="isEditDialogOpen = false"
      @save="handleSave"
    />
  </div>
</template>

<script setup lang="ts">
import { ref, watch, onMounted, nextTick, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import {
  usePollStore,
  type PollOption,
  type PollVisibility,
} from "@/stores/poll";
import QrcodeVue from "qrcode.vue";
import { useSortable } from "@vueuse/integrations/useSortable";
import { watchDebounced, useClipboard } from "@vueuse/core";
import EditOptionDialog from "@/components/EditOptionDialog.vue";
import { Switch, SwitchGroup, SwitchLabel } from "@headlessui/vue";
import {
  EyeIcon,
  EyeSlashIcon,
  ClipboardDocumentIcon,
  ClipboardDocumentCheckIcon,
} from "@heroicons/vue/24/outline";

const pollStore = usePollStore();
const { pollData } = storeToRefs(pollStore);
const route = useRoute();
const router = useRouter();

const editablePollName = ref("");
const closeCodeInputType = ref<"password" | "text">("password");
const enteredCloseCode = ref("");
const { copy, copied } = useClipboard();

const editableVisibility = computed({
  get: () => pollData.value?.visibility === "open",
  set: (value: boolean) => {
    const newVisibility: PollVisibility = value ? "open" : "private";
    if (pollData.value && newVisibility !== pollData.value.visibility) {
      pollStore.updatePollVisibility(newVisibility);
    }
  },
});

const isViewResultsDisabled = computed(() => {
  if (pollData.value?.visibility === "private") {
    return (
      enteredCloseCode.value.trim().toUpperCase() !== pollData.value.closeCode
    );
  }
  return false;
});

const isClosePollDisabled = computed(() => {
  return (
    enteredCloseCode.value.trim().toUpperCase() !== pollData.value?.closeCode
  );
});

const newOptionText = ref("");
const editableOptions = ref<PollOption[]>([]);
const siteUrl = window.location.origin;

const listEl = ref<HTMLElement | null>(null);
const isSortableInitialized = ref(false);

const isEditDialogOpen = ref(false);
const optionToEdit = ref<PollOption | null>(null);

onMounted(() => {
  pollStore.bindToPoll(route.params.id as string);
});

watch(
  pollData,
  (newData) => {
    if (newData) {
      if (newData.status === "closed") {
        router.push({ name: "results", params: { id: route.params.id } });
        return;
      }

      editablePollName.value = newData.name;
      const newOptions = JSON.parse(JSON.stringify(newData.options));
      editableOptions.value.splice(0, editableOptions.value.length, ...newOptions);

      if (!isSortableInitialized.value && newOptions.length > 0) {
        nextTick(() => {
          if (listEl.value) {
            useSortable(listEl, editableOptions, {
              animation: 150,
              onUpdate: () => {
                pollStore.updateOptionsOrder(editableOptions.value);
              },
            });
            isSortableInitialized.value = true;
          }
        });
      }
    }
  },
  { deep: true, immediate: true }
);

watchDebounced(
  editablePollName,
  (newName) => {
    if (newName && newName !== pollData.value?.name) {
      pollStore.updatePollName(newName);
    }
  },
  { debounce: 500, maxWait: 2000 }
);

function handleViewResultsWithCode() {
  router.push({
    name: "results",
    params: { id: route.params.id },
    query: { code: enteredCloseCode.value.trim() },
  });
}

function toggleCloseCodeVisibility() {
  closeCodeInputType.value =
    closeCodeInputType.value === "password" ? "text" : "password";
}

async function handleClosePoll() {
  await pollStore.closePollWithCode(enteredCloseCode.value);
  enteredCloseCode.value = "";
}

async function handleAddOption() {
  await pollStore.addOption(newOptionText.value);
  newOptionText.value = "";
}

function startEditing(option: PollOption) {
  optionToEdit.value = option;
  isEditDialogOpen.value = true;
}

function handleSave(newText: string) {
  if (optionToEdit.value) {
    pollStore.updateOptionText(optionToEdit.value.id, newText);
  }
  isEditDialogOpen.value = false;
}
</script>

The Logic (<script setup>)

When this component mounts, it immediately calls the bindToPoll action in our store, passing the poll ID from the URL. This tells VueFire to create a live, real-time connection to that specific poll document in Firestore.

// In ManagePollView.vue
onMounted(() => {
  pollStore.bindToPoll(route.params.id as string);
});

From this point on, the pollData ref in our store will always reflect the latest state of our poll. We also have a watch effect that automatically redirects the user to the results page if the poll's status ever changes to 'closed'.

// In ManagePollView.vue
watch(pollData, (newData) => {
    if (newData) {
      // If the poll is closed, don't stay here. Go to the results.
      if (newData.status === 'closed') {
        router.push({ name: 'results', params: { id: route.params.id } });
        return;
      }
      // ... rest of the watch logic ...
    }
  },
  { deep: true, immediate: true }
);

We use watchDebounced from VueUse for a seamless auto-save experience on the poll name. For re-ordering options, the useSortable composable makes drag-and-drop incredibly simple.

The two key functions for the 'voting' state are handleViewResultsWithCode() and handleClosePoll(). The first navigates the host to the results page, passing the secret code in the URL so the results page can validate access. The second calls the closePollWithCode action in our store, which handles the validation and updates the poll's status in Firestore.

The User Interface (<template>)

The template is divided into sections using v-if and v-else-if based on pollData.status.

1. 'Configuring' State The host has full control to edit the poll name, set its visibility, view/copy the secret closing code, and manage options (add, edit, delete, and re-order). A "Finalize & Start Voting" button is disabled until at least two options are present.

2. 'Voting' State Once voting starts, the UI changes completely. The configuration options disappear, a large QR code is displayed for easy sharing, and the Poll Controls section appears. This section contains the input for the closing code and a button group to view results or close the poll, with buttons enabled only after the correct code is entered.

Casting a Vote: The VoteView.vue

When voters scan the QR code, they land on the VoteView.vue page. The design philosophy here is all about speed and simplicity.

<template>
  <div
    class="relative isolate flex min-h-screen items-center justify-center overflow-hidden bg-gray-900 p-4 sm:p-8"
  >
    <svg
      class="absolute inset-0 -z-10 w-full h-full mask-[radial-gradient(100%_100%_at_top_right,white,transparent)] stroke-white/10"
      aria-hidden="true"
      xmlns="http://www.w3.org/2000/svg"
    >
      <defs>
        <pattern
          id="pattern-lines"
          width="200"
          height="200"
          x="50%"
          y="-1"
          patternUnits="userSpaceOnUse"
        >
          <path d="M0.5 200V0.5H200" fill="none" />
        </pattern>
      </defs>
      <rect
        width="100%"
        height="100%"
        stroke-width="0"
        fill="url(#pattern-lines)"
      />
    </svg>

    <div class="mx-auto w-full max-w-lg text-center">
      <header class="animate-fade-down mb-10">
        <h1 class="text-4xl font-bold text-white sm:text-5xl">
          {{ pollData?.name || "Cast Your Vote" }}
        </h1>
      </header>

      <div v-if="!pollData" class="animate-fade-up text-gray-400">
        <p>Loading poll...</p>
      </div>

      <div v-else-if="pollData.status !== 'voting'" class="animate-fade-up">
        <p class="text-xl text-yellow-400">
          Voting for this poll is not currently active.
        </p>
      </div>

      <div v-else-if="hasVoted" class="animate-fade-up">
        <p class="mt-2 text-2xl font-semibold text-green-400">
          Thank you for your vote!
        </p>
      </div>

      <div v-else class="animate-fade-left space-y-4">
        <button
          v-for="option in pollData.options"
          :key="option.id"
          @click="handleVote(option.id)"
          :disabled="isSubmitting"
          :class="[
            'flex w-full transform items-center justify-center rounded-lg bg-indigo-500 py-4 text-xl font-semibold text-white shadow-lg transition-transform focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-indigo-500',
            {
              'pointer-events-none opacity-50': isSubmitting,
              'hover:scale-105 hover:bg-indigo-400': !isSubmitting,
            }
          ]"
        >
          <ArrowPathIcon
            v-if="isSubmitting && option.id === submittingOptionId"
            class="h-6 w-6 animate-spin"
          />
          <span v-else>{{ option.text }}</span>
        </button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { usePollStore } from "@/stores/poll";
import { ArrowPathIcon } from "@heroicons/vue/20/solid";

const pollStore = usePollStore();
const { pollData } = storeToRefs(pollStore);
const route = useRoute();
const router = useRouter();

const hasVoted = ref(false);
const isSubmitting = ref(false);
const submittingOptionId = ref<string | null>(null);

const pollId = route.params.id as string;
const storageKey = `voted_poll_${pollId}`;

// Redirect if poll closed while on page
watch(pollData, (newData) => {
  if (newData?.status === "closed") {
    router.push({ name: "results", params: { id: pollId } });
  }
});

onMounted(() => {
  if (localStorage.getItem(storageKey)) {
    hasVoted.value = true;
  }
  pollStore.bindToPoll(pollId);
});

async function handleVote(optionId: string) {
  if (isSubmitting.value) return;

  isSubmitting.value = true;
  submittingOptionId.value = optionId;

  try {
    await pollStore.castVote(optionId);
    localStorage.setItem(storageKey, "true");
    hasVoted.value = true;
  } catch (error) {
    console.error(error);
    alert(
      "Sorry, your vote could not be cast. The poll may have been deleted or closed."
    );
  } finally {
    isSubmitting.value = false;
    submittingOptionId.value = null;
  }
}
</script>

The Logic (<script setup>)

The cleverest part of this component is how it prevents a device from voting multiple times on the same poll. When the component mounts, it checks the browser's localStorage for a "receipt" using a key unique to the poll ID (voted_poll_${pollId}). If the receipt exists, the user has already voted.

// In VoteView.vue
const pollId = route.params.id as string;
const storageKey = `voted_poll_${pollId}`;

onMounted(() => {
  if (localStorage.getItem(storageKey)) {
    hasVoted.value = true;
  }
  pollStore.bindToPoll(pollId);
});

The handleVote function sets a loading state to disable all buttons, preventing double-clicks. It then calls the store to cast the vote and, on success, sets the receipt in localStorage before showing the "Thank you" message.

The User Interface (<template>)

The template uses a simple v-if/v-else-if chain to show the correct state: "Loading," "Voting has not started," "Thank you for your vote," or the list of voting buttons.

The Grand Finale: ResultsView.vue

This is where everyone comes to see the outcome. This view is designed to be a clear, dynamic, and engaging display of the results.

<template>
  <div
    class="relative isolate min-h-screen overflow-hidden bg-gray-900 p-4 sm:p-8"
  >
    <svg
      class="absolute inset-0 -z-10 w-full h-full mask-[radial-gradient(100%_100%_at_top_right,white,transparent)] stroke-white/10"
      aria-hidden="true"
      xmlns="http://www.w3.org/2000/svg"
    >
      <defs>
        <pattern
          id="pattern-lines"
          width="200"
          height="200"
          x="50%"
          y="-1"
          patternUnits="userSpaceOnUse"
        >
          <path d="M0.5 200V0.5H200" fill="none" />
        </pattern>
      </defs>
      <rect
        width="100%"
        height="100%"
        stroke-width="0"
        fill="url(#pattern-lines)"
      />
    </svg>

    <div class="mx-auto max-w-4xl">
      <header class="mb-10 text-center">
        <h1 class="text-4xl font-bold text-white sm:text-5xl">
          {{ pollData?.name || "Poll Results" }}
        </h1>
        <p class="mt-4 text-xl text-gray-400">
          Total Votes:
          <span class="font-bold text-white">{{ totalVotes }}</span>
        </p>
      </header>

      <div>
        <div v-if="!pollData" class="text-center text-gray-400">
          <p>Loading results...</p>
        </div>

        <div v-else class="space-y-5">
          <div
            v-for="option in sortedOptions"
            :key="option.id"
            class="rounded-lg border border-white/10 bg-white/5 p-4 shadow-xl"
          >
            <div class="mb-2 flex items-center justify-between">
              <span class="text-xl font-semibold text-gray-200">{{ option.text }}</span>
              <span class="text-lg font-bold text-indigo-400">{{ option.votes }} votes</span>
            </div>

            <div class="h-8 w-full rounded-full bg-black/20">
              <div
                :class="[
                  'flex h-8 items-center justify-end rounded-full bg-indigo-500 text-white transition-all duration-500 ease-out',
                  { 'pr-2': getVotePercentage(option.votes) > 0 }
                ]"
                :style="{ width: getVotePercentage(option.votes) + '%' }"
              >
                <span
                  v-if="getVotePercentage(option.votes) > 0"
                  class="text-sm font-semibold whitespace-nowrap"
                >
                  {{ getVotePercentage(option.votes).toFixed(0) }}%
                </span>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, onMounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { storeToRefs } from "pinia";
import { usePollStore } from "@/stores/poll";

const pollStore = usePollStore();
const { pollData, sortedOptions } = storeToRefs(pollStore);
const route = useRoute();
const router = useRouter();

onMounted(() => {
  pollStore.bindToPoll(route.params.id as string);
});

// Handle authorization and redirect if necessary
watch(pollData, (newData) => {
  if (newData) {
    if (newData.status === "closed") return;
    if (newData.visibility === "open") return;

    if (newData.status === "voting" && newData.visibility === "private") {
      const codeFromQuery = (route.query.code as string) || "";
      if (codeFromQuery.toUpperCase() === newData.closeCode) return;
    }

    router.push({ name: "manage-poll", params: { id: route.params.id } });
  }
});

const totalVotes = computed(() => pollData.value?.totalVotes || 0);

function getVotePercentage(votes: number) {
  if (totalVotes.value === 0) return 0;
  return (votes / totalVotes.value) * 100;
}
</script>

The Logic (<script setup>)

The most critical logic here is access control, handled by a watch effect. It checks a series of rules:

  1. Is the poll status 'closed'? If yes, show the results.
  2. Is the poll visibility 'open'? If yes, show the results.
  3. Is it a 'private' poll that's still 'voting'? If yes, check for a valid closeCode in the URL. If it matches, show the results.

If none of these rules pass, the user is redirected away, ensuring private poll results remain confidential.

The User Interface (<template>)

The UI is centered around a clean, animated bar chart. We use a v-for loop to render each option from the sortedOptions getter in our store, which always keeps the highest-voted option at the top. The progress bar width is dynamically bound to the vote percentage, and the entire list is wrapped in a <TransitionGroup> to animate the re-ordering of options as votes come in.

Connecting the Dots: The Vue Router

If the views are the rooms in our house, Vue Router is the hallway that connects them. Our router configuration in src/router/index.ts is a clear map of the application, using dynamic segments like /poll/:id/manage to tell our components which poll document to fetch from Firestore.

import { createRouter, createWebHistory } from "vue-router";  
  
const router = createRouter({  
  history: createWebHistory(import.meta.env.BASE_URL),  
  routes: [  
    {  
      path: "/",  
      name: "home",  
      component: () => import("../views/HomeView.vue"),  
    },  
    {  
      path: "/poll/:id/manage",  
      name: "manage-poll",  
      component: () => import("../views/ManagePollView.vue"),  
    },  
    {  
      path: "/poll/:id/vote",  
      name: "vote",  
      component: () => import("../views/VoteView.vue"),  
    },  
    {  
      path: "/poll/:id/results",  
      name: "results",  
      component: () => import("../views/ResultsView.vue"),  
    },  
  ],  
});  
  
export default router;

The Brain of the Operation: The Pinia Store

Finally, we arrive at the most critical part of our application's architecture: the poll.ts store. If the views are the "face" of our app, the Pinia store is the "brain." It acts as the single source of truth and handles all our business logic.

This approach is fantastic because it keeps our view components "dumb." Their only job is to display data from the store and tell the store when a user does something. They don't know how to talk to a database; they just know how to talk to the store.

Our store is organized into three key parts:

1. State This is where our live data resides. We use composables from vuefire to create a real-time, two-way binding between our state and our Firestore documents.

// In src/stores/poll.ts
const pollRef = ref<DocumentReference<Poll> | null>(null);
const pollData = useDocument<Poll>(pollRef);

const pollsCollection = collection(db, "polls");
const pollsList = useCollection(pollsCollection);

When we call bindToPoll(id), we set the pollRef, and vuefire's useDocument automatically fetches the data and keeps it in sync. It's incredibly powerful.

2. Getters Getters are like computed properties for your store. Our sortedOptions getter, for example, always provides a version of the poll options sorted by vote count, without us ever having to manually sort the array in our components.

3. Actions Actions are where all the work happens. These are the functions our components call, containing all the logic for interacting with Firestore.

  • createPoll(name, visibility): Generates a unique closeCode, creates a new poll document in Firestore, and navigates the user.
  • bindToPoll(id): A simple but crucial function that tells vuefire which document to listen to for real-time updates.
  • updatePollName, updatePollVisibility, addOption, etc.: A family of functions that handle all configuration changes.
  • startVoting() & closePollWithCode(): These actions manage the poll's lifecycle, changing its status and adding timestamps.
  • castVote(optionId): This is the most critical action for data integrity. It uses a Firestore Transaction to guarantee that every single vote is counted safely, even if multiple people vote at the exact same moment.

By centralizing all this logic in the store, we make our application incredibly easy to manage, debug, and expand in the future.

import { ref, computed } from "vue";  
import { defineStore } from "pinia";  
import { useRouter } from "vue-router";  
import { useDocument, useFirestore, useCollection } from "vuefire";  
import {  
  collection,  
  doc,  
  setDoc,  
  updateDoc,  
  runTransaction,  
  type DocumentReference,  
  serverTimestamp,  
} from "firebase/firestore";  
  
export interface PollOption {  
  id: string;  
  text: string;  
  votes: number;  
}  
  
export type PollVisibility = "open" | "private";  
  
export interface Poll {  
  name: string;  
  options: PollOption[];  
  status: "configuring" | "voting" | "closed";  
  visibility: PollVisibility;  
  totalVotes: number;  
  closeCode: string;  
  createdAt: any;  
  closedAt: any | null;  
}  
  
export const usePollStore = defineStore("poll", () => {  
  const db = useFirestore();  
  const router = useRouter();  
  const pollRef = ref<DocumentReference<Poll> | null>(null);  
  const pollData = useDocument<Poll>(pollRef);  
  const pollsCollection = collection(db, "polls");  
  const pollsList = useCollection(pollsCollection);  
  
  const sortedOptions = computed(() => {  
    if (!pollData.value) return [];  
    return [...pollData.value.options].sort((a, b) => b.votes - a.votes);  
  });  
  
  async function createPoll(name: string) {  
    if (!name.trim()) {  
      alert("Please provide a name for the poll.");  
      return;  
    }  
  
    const newCloseCode = Math.random()  
      .toString(36)  
      .substring(2, 8)  
      .toUpperCase();  
  
    const newPollRef = doc(collection(db, "polls"));  
    await setDoc(newPollRef, {  
      name: name.trim(),  
      visibility: "private", // Default to private for security  
      options: [],  
      status: "configuring",  
      totalVotes: 0,  
      closeCode: newCloseCode,  
      createdAt: serverTimestamp(),  
      closedAt: null,  
    });  
  
    router.push({ name: "manage-poll", params: { id: newPollRef.id } });  
  }  
  
  async function closePollWithCode(providedCode: string): Promise<boolean> {  
    if (!pollRef.value || !pollData.value || !providedCode) {  
      alert("Invalid request.");  
      return false;  
    }  
    if (pollData.value.status !== "voting") {  
      alert("This poll is not currently active for voting.");  
      return false;  
    }  
    if (providedCode.trim().toUpperCase() === pollData.value.closeCode) {  
      await updateDoc(pollRef.value, {  
        status: "closed",  
        closedAt: serverTimestamp(), // <-- Add this line  
      });  
      alert("Poll has been successfully closed.");  
      return true;  
    } else {  
      alert("Incorrect closing code.");  
      return false;  
    }  
  }  
  
  function bindToPoll(id: string) {  
    pollRef.value = doc(db, "polls", id) as DocumentReference<Poll>;  
  }  
  
  async function updatePollName(newName: string) {  
    if (!pollRef.value || !newName.trim()) return;  
    await updateDoc(pollRef.value, { name: newName.trim() });  
  }  
  
  async function updatePollVisibility(visibility: PollVisibility) {  
    if (!pollRef.value) return;  
    await updateDoc(pollRef.value, { visibility });  
  }  
  
  async function addOption(text: string) {  
    if (  
      !pollRef.value ||  
      !text.trim() ||  
      pollData.value?.status !== "configuring"  
    )  
      return;  
    const newOption: PollOption = {  
      id: crypto.randomUUID(),  
      text: text.trim(),  
      votes: 0,  
    };  
    const newOptions = [...(pollData.value.options || []), newOption];  
    await updateDoc(pollRef.value, { options: newOptions });  
  }  
  
  async function deleteOption(optionId: string) {  
    if (!pollRef.value || pollData.value?.status !== "configuring") return;  
    const newOptions = pollData.value.options.filter((o) => o.id !== optionId);  
    await updateDoc(pollRef.value, { options: newOptions });  
  }  
  
  async function updateOptionText(optionId: string, newText: string) {  
    if (!pollRef.value || pollData.value?.status !== "configuring") return;  
    const newOptions = pollData.value.options.map((o) =>  
      o.id === optionId ? { ...o, text: newText } : o,  
    );  
    await updateDoc(pollRef.value, { options: newOptions });  
  }  
  
  async function updateOptionsOrder(newOptions: PollOption[]) {  
    if (!pollRef.value || pollData.value?.status !== "configuring") return;  
    await updateDoc(pollRef.value, { options: newOptions });  
  }  
  
  async function startVoting() {  
    if (!pollRef.value) return;  
    await updateDoc(pollRef.value, { status: "voting" });  
  }  
  
  async function castVote(optionId: string) {  
    if (!pollRef.value) return;  
  
    const firestoreInstance = pollRef.value.firestore;  
    await runTransaction(firestoreInstance, async (transaction) => {  
      const pollDoc = await transaction.get(pollRef.value!);  
      // This check now correctly prevents votes on 'configuring' or 'closed' polls  
      if (!pollDoc.exists() || pollDoc.data().status !== "voting") {  
        throw "Poll is not active for voting.";  
      }  
  
      const poll = pollDoc.data() as Poll;  
      const newOptions = poll.options.map((o) =>  
        o.id === optionId ? { ...o, votes: o.votes + 1 } : o,  
      );  
      const newTotalVotes = poll.totalVotes + 1;  
  
      transaction.update(pollRef.value!, {  
        options: newOptions,  
        totalVotes: newTotalVotes,  
      });  
    });  
  }  
  
  return {  
    pollData,  
    pollsList,  
    sortedOptions,  
    createPoll,  
    bindToPoll,  
    addOption,  
    deleteOption,  
    updatePollName,  
    updatePollVisibility,  
    updateOptionText,  
    updateOptionsOrder,  
    startVoting,  
    closePollWithCode,  
    castVote,  
  };  
});

Conclusion: From a Simple Idea to a Powerful Tool

And just like that, we were live. The night of our Staff Recognition 2025 arrived, and when the time came to vote for Mr. and Miss Vakkaru, the app performed flawlessly. The QR code went up on the big screen, phones came out, and the results started pouring in. The energy in the room was electric! It was exactly the seamless, engaging experience Solah and I had envisioned.

Looking back, it’s still a bit surreal that we went from a simple idea to a fully-featured, live-fire application in less than 24 hours. It’s a testament to what you can achieve with the right tools, the right mindset and a partner who shares that same obsessive drive for perfection.

What started as a solution for our own party has become a project we're incredibly proud of. It solved our problem perfectly, and we believe it’s a tool that can help anyone create more interactive and engaging events.

Popular posts from this blog

Zapping Through Multicast Madness: A Fun Python Script to Keep Your IPTV Streams Rocking!

Turning a Joke into Innovation: AI Integration in our Daily Task Manager