Voting

Learn how to implement upvoting and downvoting on feedback using the Feedhog SDK.

Voting

The SDK provides methods to toggle votes on feedback and check vote status. Voting helps prioritize features based on user interest.

vote(feedbackId)

Toggles a vote on a feedback item. If the user has already voted, their vote is removed. If they haven't voted, a vote is added.

async vote(feedbackId: string): Promise<VoteResult>

Returns

interface VoteResult {
  voted: boolean;    // true if vote was added, false if removed
  voteCount: number; // Total votes after the operation
}

Example

import Feedhog from '@feedhog/js';

const feedhog = new Feedhog({ apiKey: 'fhpk_your_public_key' });

// Identify user first (recommended for persistent voting)
await feedhog.identify({
  externalId: 'user-123',
  email: 'john@example.com'
});

// Toggle vote
const result = await feedhog.vote('fb_abc123');

if (result.voted) {
  console.log('Vote added! Total votes:', result.voteCount);
} else {
  console.log('Vote removed. Total votes:', result.voteCount);
}

hasVoted(feedbackId)

Checks if the current user has voted on a feedback item.

async hasVoted(feedbackId: string): Promise<VoteResult>

Example

const { voted, voteCount } = await feedhog.hasVoted('fb_abc123');

console.log('Has voted:', voted);
console.log('Total votes:', voteCount);

Vote Button Component

A complete voting component for React:

'use client';

import { useState, useEffect } from 'react';
import Feedhog from '@feedhog/js';

const feedhog = new Feedhog({
  apiKey: process.env.NEXT_PUBLIC_FEEDHOG_API_KEY!
});

interface VoteButtonProps {
  feedbackId: string;
  initialVoteCount?: number;
  userId?: string;
  userEmail?: string;
}

export function VoteButton({
  feedbackId,
  initialVoteCount = 0,
  userId,
  userEmail
}: VoteButtonProps) {
  const [voteCount, setVoteCount] = useState(initialVoteCount);
  const [hasVoted, setHasVoted] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const [isChecking, setIsChecking] = useState(true);

  // Check initial vote status
  useEffect(() => {
    async function checkVote() {
      if (userId) {
        await feedhog.identify({ externalId: userId, email: userEmail });
      }

      try {
        const result = await feedhog.hasVoted(feedbackId);
        setHasVoted(result.voted);
        setVoteCount(result.voteCount);
      } catch (error) {
        console.error('Failed to check vote:', error);
      } finally {
        setIsChecking(false);
      }
    }

    checkVote();
  }, [feedbackId, userId, userEmail]);

  async function handleVote() {
    if (isLoading) return;
    setIsLoading(true);

    try {
      const result = await feedhog.vote(feedbackId);
      setHasVoted(result.voted);
      setVoteCount(result.voteCount);
    } catch (error) {
      console.error('Failed to vote:', error);
    } finally {
      setIsLoading(false);
    }
  }

  if (isChecking) {
    return (
      <button disabled className="vote-button loading">
        <span className="vote-icon">△</span>
        <span className="vote-count">...</span>
      </button>
    );
  }

  return (
    <button
      onClick={handleVote}
      disabled={isLoading}
      className={`vote-button ${hasVoted ? 'voted' : ''}`}
      aria-pressed={hasVoted}
      aria-label={hasVoted ? 'Remove vote' : 'Add vote'}
    >
      <span className="vote-icon">{hasVoted ? '▲' : '△'}</span>
      <span className="vote-count">{voteCount}</span>
    </button>
  );
}

CSS Styles

.vote-button {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 8px 12px;
  border: 1px solid #e5e7eb;
  border-radius: 8px;
  background: white;
  cursor: pointer;
  transition: all 0.2s;
}

.vote-button:hover:not(:disabled) {
  border-color: #3b82f6;
}

.vote-button.voted {
  background: #eff6ff;
  border-color: #3b82f6;
  color: #3b82f6;
}

.vote-button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.vote-icon {
  font-size: 16px;
}

.vote-count {
  font-size: 14px;
  font-weight: 600;
}

Voting in a List

Display a list of feedback with voting:

'use client';

import { useEffect, useState } from 'react';
import Feedhog from '@feedhog/js';
import type { FeedbackListItem } from '@feedhog/js';
import { VoteButton } from './vote-button';

const feedhog = new Feedhog({
  apiKey: process.env.NEXT_PUBLIC_FEEDHOG_API_KEY!
});

interface FeedbackListProps {
  userId?: string;
  userEmail?: string;
}

export function FeedbackList({ userId, userEmail }: FeedbackListProps) {
  const [items, setItems] = useState<FeedbackListItem[]>([]);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    async function load() {
      if (userId) {
        await feedhog.identify({ externalId: userId, email: userEmail });
      }

      const result = await feedhog.list({
        sortBy: 'votes',
        limit: 20
      });

      setItems(result.items);
      setIsLoading(false);
    }

    load();
  }, [userId, userEmail]);

  if (isLoading) return <div>Loading...</div>;

  return (
    <ul className="feedback-list">
      {items.map(item => (
        <li key={item.id} className="feedback-item">
          <VoteButton
            feedbackId={item.id}
            initialVoteCount={item.voteCount}
            userId={userId}
            userEmail={userEmail}
          />
          <div className="feedback-content">
            <h3>{item.title}</h3>
            {item.description && <p>{item.description}</p>}
            <span className="badge">{item.type}</span>
            <span className="badge">{item.status}</span>
          </div>
        </li>
      ))}
    </ul>
  );
}

Anonymous vs Identified Voting

Anonymous Voting

When no user is identified, votes are tracked by IP address:

const feedhog = new Feedhog({ apiKey: 'fhpk_your_public_key' });

// No identify() call - votes are anonymous
const result = await feedhog.vote('fb_abc123');

Anonymous voting limitations:

  • Votes are tied to IP address
  • Users cannot vote from different devices
  • Votes may be lost if IP changes

Identified Voting

When a user is identified, votes are tied to their account:

const feedhog = new Feedhog({ apiKey: 'fhpk_your_public_key' });

await feedhog.identify({
  externalId: 'user-123',
  email: 'john@example.com'
});

// Votes are tied to user-123
const result = await feedhog.vote('fb_abc123');

Benefits of identified voting:

  • Votes persist across devices
  • Users can see their voting history
  • More accurate vote counts

Optimistic Updates

For better UX, update the UI immediately before the server responds:

'use client';

import { useState, useCallback } from 'react';
import Feedhog from '@feedhog/js';

const feedhog = new Feedhog({
  apiKey: process.env.NEXT_PUBLIC_FEEDHOG_API_KEY!
});

export function OptimisticVoteButton({
  feedbackId,
  initialVoteCount,
  initialHasVoted
}: {
  feedbackId: string;
  initialVoteCount: number;
  initialHasVoted: boolean;
}) {
  const [voteCount, setVoteCount] = useState(initialVoteCount);
  const [hasVoted, setHasVoted] = useState(initialHasVoted);
  const [isPending, setIsPending] = useState(false);

  const handleVote = useCallback(async () => {
    if (isPending) return;

    // Optimistic update
    const newVoted = !hasVoted;
    const newCount = hasVoted ? voteCount - 1 : voteCount + 1;

    setHasVoted(newVoted);
    setVoteCount(newCount);
    setIsPending(true);

    try {
      const result = await feedhog.vote(feedbackId);

      // Sync with server response
      setHasVoted(result.voted);
      setVoteCount(result.voteCount);
    } catch (error) {
      // Revert on error
      setHasVoted(hasVoted);
      setVoteCount(voteCount);
      console.error('Vote failed:', error);
    } finally {
      setIsPending(false);
    }
  }, [feedbackId, hasVoted, voteCount, isPending]);

  return (
    <button
      onClick={handleVote}
      className={hasVoted ? 'voted' : ''}
      disabled={isPending}
    >
      {hasVoted ? '▲' : '△'} {voteCount}
    </button>
  );
}

Error Handling

Handle common voting errors:

import Feedhog, { FeedhogApiError } from '@feedhog/js';

const feedhog = new Feedhog({ apiKey: 'fhpk_your_public_key' });

try {
  const result = await feedhog.vote('fb_abc123');
} catch (error) {
  if (error instanceof FeedhogApiError) {
    switch (error.status) {
      case 401:
        console.error('Invalid API key');
        break;
      case 404:
        console.error('Feedback not found');
        break;
      case 429:
        console.error('Too many requests, please slow down');
        break;
      default:
        console.error('Vote failed:', error.message);
    }
  }
}