Ratings & Reviews

Learn how to rate and review beers with detailed tasting notes and flavor descriptors.

BrewHoard’s rating system allows you to record detailed impressions of beers you’ve tried, including numerical ratings across multiple categories and descriptive tasting notes.

Rating Categories

Each beer rating includes five core categories that capture different aspects of the beer experience:

  • Overall (1-5): Your overall impression of the beer
  • Aroma (1-5): The beer’s smell and nose characteristics
  • Appearance (1-5): Visual aspects like color, clarity, and head retention
  • Taste (1-5): Flavor profile and balance
  • Mouthfeel (1-5): Texture, body, and carbonation

Rating Scale Guide

JavaScript
// Rating scale definitions
const RATING_SCALE = {
  1: 'Poor',
  2: 'Below Average', 
  3: 'Average',
  4: 'Good',
  5: 'Excellent'
};

// Category descriptions
const CATEGORY_DESCRIPTIONS = {
  overall: 'Overall quality and drinking experience',
  aroma: 'Smell, nose, and aromatic qualities',
  appearance: 'Visual appeal, color, clarity, head',
  taste: 'Flavor balance, taste profile, finish',
  mouthfeel: 'Body, texture, carbonation, mouth coating'
};

Tasting Notes System

Complement your numerical ratings with detailed tasting notes using predefined flavor descriptors.

JavaScript
// Predefined flavor descriptors
export const FLAVOR_CATEGORIES = {
  hoppy: ['citrus', 'pine', 'floral', 'tropical', 'herbal', 'resinous'],
  malty: ['caramel', 'toffee', 'biscuit', 'chocolate', 'coffee', 'roasted'],
  fruity: ['apple', 'pear', 'banana', 'berry', 'stone fruit', 'dried fruit'],
  spicy: ['pepper', 'clove', 'cinnamon', 'coriander', 'ginger'],
  other: ['bread', 'honey', 'vanilla', 'smoke', 'oak', 'earthy']
};

Flavor Selector Component

Svelte
<!-- src/components/ratings/FlavorSelector.svelte -->
<script>
  import { FLAVOR_CATEGORIES } from './constants.js';
  
  let { selected = $bindable([]) } = $props();
  
  let activeCategory = $state('hoppy');
  
  function toggleFlavor(flavor) {
    if (selected.includes(flavor)) {
      selected = selected.filter(f => f !== flavor);
    } else {
      selected = [...selected, flavor];
    }
  }
  
  function isSelected(flavor) {
    return selected.includes(flavor);
  }
</script>

<div class="flavor-selector">
  <div class="category-tabs">
    {#each Object.keys(FLAVOR_CATEGORIES) as category}
      <button 
        class="tab" 
        class:active={activeCategory === category}
        onclick={() => activeCategory = category}
      >
        {category.charAt(0).toUpperCase() + category.slice(1)}
      </button>
    {/each}
  </div>
  
  <div class="flavor-grid">
    {#each FLAVOR_CATEGORIES[activeCategory] as flavor}
      <button 
        class="flavor-chip" 
        class:selected={isSelected(flavor)}
        onclick={() => toggleFlavor(flavor)}
      >
        {flavor.charAt(0).toUpperCase() + flavor.slice(1)}
      </button>
    {/each}
  </div>
  
  {#if selected.length > 0}
    <div class="selected-flavors">
      <h5>Selected Flavors:</h5>
      <div class="selected-chips">
        {#each selected as flavor}
          <span class="selected-chip">
            {flavor}
            <button onclick={() => toggleFlavor(flavor)}>×</button>
          </span>
        {/each}
      </div>
    </div>
  {/if}
</div>

<style>
  .flavor-selector {
    background: var(--card-bg);
    padding: 1rem;
    border-radius: 8px;
  }
  
  .category-tabs {
    display: flex;
    gap: 0.5rem;
    margin-bottom: 1rem;
    border-bottom: 1px solid var(--border);
  }
  
  .tab {
    background: none;
    border: none;
    padding: 0.5rem 1rem;
    cursor: pointer;
    border-radius: 4px 4px 0 0;
    color: var(--text-secondary);
  }
  
  .tab.active {
    background: var(--primary-bg);
    color: var(--primary);
    border-bottom: 2px solid var(--primary);
  }
  
  .flavor-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
    gap: 0.5rem;
    margin-bottom: 1rem;
  }
  
  .flavor-chip {
    background: var(--bg-secondary);
    border: 1px solid var(--border);
    padding: 0.5rem;
    border-radius: 20px;
    cursor: pointer;
    text-align: center;
    transition: all 0.2s;
  }
  
  .flavor-chip:hover {
    background: var(--primary-bg);
    border-color: var(--primary);
  }
  
  .flavor-chip.selected {
    background: var(--primary);
    color: white;
    border-color: var(--primary);
  }
  
  .selected-flavors {
    border-top: 1px solid var(--border);
    padding-top: 1rem;
  }
  
  .selected-chips {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin-top: 0.5rem;
  }
  
  .selected-chip {
    background: var(--primary-bg);
    color: var(--primary);
    padding: 0.25rem 0.5rem;
    border-radius: 12px;
    display: flex;
    align-items: center;
    gap: 0.25rem;
    font-size: 0.9rem;
  }
  
  .selected-chip button {
    background: none;
    border: none;
    color: var(--primary);
    cursor: pointer;
    font-size: 1.2rem;
    line-height: 1;
    padding: 0;
    margin-left: 0.25rem;
  }
</style>

Review Form Component

The complete review form combines ratings, tasting notes, and written feedback.

Svelte
<!-- src/components/ratings/BeerReviewForm.svelte -->
<script>
  import StarRating from './StarRating.svelte';
  import FlavorSelector from './FlavorSelector.svelte';
  
  let { beer, onSubmit } = $props();
  
  let ratings = $state({
    overall: 0,
    aroma: 0,
    appearance: 0,
    taste: 0,
    mouthfeel: 0
  });
  
  let reviewText = $state('');
  let tastingNotes = $state({ flavors: [], aromas: [] });
  let servingType = $state('bottle');
  
  let isSubmitting = $state(false);
  
  async function handleSubmit() {
    isSubmitting = true;
    try {
      const response = await fetch('/api/v1/ratings', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          beerId: beer.id,
          ...ratings,
          reviewText,
          tastingNotes,
          servingType
        })
      });
      
      if (response.ok) {
        onSubmit?.();
      }
    } finally {
      isSubmitting = false;
    }
  }
  
  // Check if form is complete
  let isFormValid = $derived(() => {
    return ratings.overall > 0 && reviewText.trim().length > 0;
  });
</script>

<form onsubmit|preventDefault={handleSubmit}>
  <div class="review-form">
    <!-- Overall Rating (prominent) -->
    <div class="overall-rating">
      <h3>Overall Rating</h3>
      <StarRating bind:value={ratings.overall} size="lg" />
      <p class="rating-hint">
        {#if ratings.overall === 0}
          How would you rate this beer overall?
        {:else}
          {ratings.overall} star{rating.overall !== 1 ? 's' : ''} - {
            ratings.overall >= 4 ? 'Excellent' : 
            ratings.overall >= 3 ? 'Good' : 
            ratings.overall >= 2 ? 'Fair' : 'Poor'
          }
        {/if}
      </p>
    </div>
    
    <!-- Category Ratings -->
    <div class="category-ratings">
      <h4>Detailed Ratings</h4>
      <div class="ratings-grid">
        <div class="rating-item">
          <label>Aroma</label>
          <StarRating bind:value={ratings.aroma} />
          <span class="rating-value">{ratings.aroma}/5</span>
        </div>
        
        <div class="rating-item">
          <label>Appearance</label>
          <StarRating bind:value={ratings.appearance} />
          <span class="rating-value">{ratings.appearance}/5</span>
        </div>
        
        <div class="rating-item">
          <label>Taste</label>
          <StarRating bind:value={ratings.taste} />
          <span class="rating-value">{ratings.taste}/5</span>
        </div>
        
        <div class="rating-item">
          <label>Mouthfeel</label>
          <StarRating bind:value={ratings.mouthfeel} />
          <span class="rating-value">{ratings.mouthfeel}/5</span>
        </div>
      </div>
    </div>
    
    <!-- Tasting Notes -->
    <div class="tasting-notes">
      <h4>Tasting Notes</h4>
      <p>Describe the flavors and aromas you detected:</p>
      
      <div class="notes-section">
        <label>Flavors</label>
        <FlavorSelector bind:selected={tastingNotes.flavors} />
      </div>
      
      <div class="notes-section">
        <label>Aromas</label>
        <FlavorSelector bind:selected={tastingNotes.aromas} />
      </div>
    </div>
    
    <!-- Written Review -->
    <div class="written-review">
      <h4>Your Review</h4>
      <textarea 
        bind:value={reviewText} 
        rows="4" 
        placeholder="Share your thoughts about this beer. What did you like or dislike? How does it compare to similar beers?"
        maxlength="1000"
      ></textarea>
      <div class="char-count">
        {reviewText.length}/1000 characters
      </div>
    </div>
    
    <!-- Serving Type -->
    <div class="serving-type">
      <h4>Serving Type</h4>
      <div class="serving-options">
        {#each ['bottle', 'can', 'draft', 'cask'] as type}
          <label class="serving-option">
            <input 
              type="radio" 
              bind:group={servingType} 
              value={type} 
            />
            <span>{type.charAt(0).toUpperCase() + type.slice(1)}</span>
          </label>
        {/each}
      </div>
    </div>
    
    <!-- Submit Button -->
    <div class="form-actions">
      <button type="submit" disabled={!isFormValid || isSubmitting}>
        {#if isSubmitting}
          Submitting Review...
        {:else}
          Submit Review
        {/if}
      </button>
    </div>
  </div>
</form>

<style>
  .review-form {
    max-width: 600px;
    margin: 0 auto;
  }
  
  .overall-rating {
    text-align: center;
    margin-bottom: 2rem;
    padding: 1.5rem;
    background: var(--card-bg);
    border-radius: 8px;
  }
  
  .rating-hint {
    margin-top: 0.5rem;
    color: var(--text-secondary);
    font-size: 0.9rem;
  }
  
  .category-ratings {
    margin-bottom: 2rem;
  }
  
  .ratings-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: 1rem;
    margin-top: 1rem;
  }
  
  .rating-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0.75rem;
    background: var(--bg-secondary);
    border-radius: 6px;
  }
  
  .rating-item label {
    font-weight: 500;
    min-width: 80px;
  }
  
  .rating-value {
    font-size: 0.9rem;
    color: var(--text-secondary);
    min-width: 30px;
    text-align: right;
  }
  
  .tasting-notes {
    margin-bottom: 2rem;
  }
  
  .notes-section {
    margin-bottom: 1.5rem;
  }
  
  .written-review {
    margin-bottom: 2rem;
  }
  
  textarea {
    width: 100%;
    padding: 0.75rem;
    border: 1px solid var(--border);
    border-radius: 4px;
    resize: vertical;
    font-family: inherit;
  }
  
  .char-count {
    text-align: right;
    font-size: 0.8rem;
    color: var(--text-secondary);
    margin-top: 0.25rem;
  }
  
  .serving-type {
    margin-bottom: 2rem;
  }
  
  .serving-options {
    display: flex;
    gap: 1rem;
    margin-top: 0.5rem;
  }
  
  .serving-option {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    cursor: pointer;
  }
  
  .form-actions {
    text-align: center;
  }
  
  button[type="submit"] {
    background: var(--primary);
    color: white;
    border: none;
    padding: 0.75rem 2rem;
    border-radius: 4px;
    font-size: 1rem;
    font-weight: 500;
    cursor: pointer;
    transition: background 0.2s;
  }
  
  button[type="submit"]:hover:not(:disabled) {
    background: var(--primary-hover);
  }
  
  button[type="submit"]:disabled {
    background: var(--text-secondary);
    cursor: not-allowed;
  }
</style>

Star Rating Component

A reusable component for collecting star ratings.

Svelte
<!-- src/components/ratings/StarRating.svelte -->
<script>
  let { value = $bindable(0), max = 5, size = 'md', readonly = false } = $props();
  
  function setRating(rating) {
    if (!readonly) {
      value = rating;
    }
  }
  
  function getSizeClass() {
    switch (size) {
      case 'sm': return 'star-sm';
      case 'lg': return 'star-lg';
      default: return 'star-md';
    }
  }
</script>

<div class="star-rating {getSizeClass()}" class:readonly>
  {#each Array(max) as _, i}
    {@const starValue = i + 1}
    <button 
      type="button"
      class="star" 
      class:filled={starValue <= value}
      onclick={() => setRating(starValue)}
      disabled={readonly}
      aria-label="Rate {starValue} star{starValue !== 1 ? 's' : ''}"
    >

    </button>
  {/each}
  
  {#if !readonly}
    <span class="rating-text">
      {value > 0 ? `${value}/${max}` : 'Not rated'}
    </span>
  {/if}
</div>

<style>
  .star-rating {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
  }
  
  .star {
    background: none;
    border: none;
    cursor: pointer;
    color: var(--text-secondary);
    transition: color 0.2s;
    padding: 0;
  }
  
  .star:not(.readonly):hover {
    color: var(--primary);
  }
  
  .star.filled {
    color: var(--primary);
  }
  
  .star.readonly {
    cursor: default;
  }
  
  .star-sm .star {
    font-size: 1rem;
  }
  
  .star-md .star {
    font-size: 1.25rem;
  }
  
  .star-lg .star {
    font-size: 1.5rem;
  }
  
  .rating-text {
    margin-left: 0.5rem;
    font-size: 0.9rem;
    color: var(--text-secondary);
  }
</style>

Review Display

Display submitted reviews with ratings and tasting notes.

Svelte
<!-- src/components/ratings/BeerReviewDisplay.svelte -->
<script>
  import StarRating from './StarRating.svelte';
  
  let { review } = $props();
  
  function formatDate(dateString) {
    return new Date(dateString).toLocaleDateString('en-US', {
      year: 'numeric',
      month: 'short',
      day: 'numeric'
    });
  }
  
  function getRatingDescription(rating) {
    if (rating >= 4.5) return 'Outstanding';
    if (rating >= 4) return 'Excellent';
    if (rating >= 3.5) return 'Very Good';
    if (rating >= 3) return 'Good';
    if (rating >= 2.5) return 'Fair';
    if (rating >= 2) return 'Poor';
    return 'Very Poor';
  }
</script>

<div class="beer-review">
  <div class="review-header">
    <div class="user-info">
      <strong>{review.user?.name || 'Anonymous'}</strong>
      <span class="review-date">{formatDate(review.createdAt)}</span>
    </div>
    
    <div class="overall-rating">
      <StarRating value={review.overall} readonly={true} />
      <span class="rating-desc">{getRatingDescription(review.overall)}</span>
    </div>
  </div>
  
  <div class="detailed-ratings">
    <div class="rating-breakdown">
      <div class="rating-item">
        <span>Aroma:</span>
        <StarRating value={review.aroma} size="sm" readonly={true} />
      </div>
      
      <div class="rating-item">
        <span>Appearance:</span>
        <StarRating value={review.appearance} size="sm" readonly={true} />
      </div>
      
      <div class="rating-item">
        <span>Taste:</span>
        <StarRating value={review.taste} size="sm" readonly={true} />
      </div>
      
      <div class="rating-item">
        <span>Mouthfeel:</span>
        <StarRating value={review.mouthfeel} size="sm" readonly={true} />
      </div>
    </div>
    
    {#if review.servingType}
      <div class="serving-info">
        Served from: <strong>{review.servingType}</strong>
      </div>
    {/if}
  </div>
  
  {#if review.tastingNotes && (review.tastingNotes.flavors.length > 0 || review.tastingNotes.aromas.length > 0)}
    <div class="tasting-notes">
      <h5>Tasting Notes</h5>
      
      {#if review.tastingNotes.flavors.length > 0}
        <div class="flavor-tags">
          <strong>Flavors:</strong>
          {#each review.tastingNotes.flavors as flavor}
            <span class="tag flavor">{flavor}</span>
          {/each}
        </div>
      {/if}
      
      {#if review.tastingNotes.aromas.length > 0}
        <div class="flavor-tags">
          <strong>Aromas:</strong>
          {#each review.tastingNotes.aromas as aroma}
            <span class="tag aroma">{aroma}</span>
          {/each}
        </div>
      {/if}
    </div>
  {/if}
  
  {#if review.reviewText}
    <div class="review-text">
      <p>{review.reviewText}</p>
    </div>
  {/if}
</div>

<style>
  .beer-review {
    background: var(--card-bg);
    padding: 1.5rem;
    border-radius: 8px;
    margin-bottom: 1rem;
  }
  
  .review-header {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    margin-bottom: 1rem;
  }
  
  .user-info {
    display: flex;
    flex-direction: column;
  }
  
  .review-date {
    font-size: 0.8rem;
    color: var(--text-secondary);
  }
  
  .overall-rating {
    text-align: right;
  }
  
  .rating-desc {
    display: block;
    font-size: 0.9rem;
    color: var(--text-secondary);
    margin-top: 0.25rem;
  }
  
  .detailed-ratings {
    margin-bottom: 1rem;
  }
  
  .rating-breakdown {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
    gap: 0.5rem;
    margin-bottom: 0.5rem;
  }
  
  .rating-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    font-size: 0.9rem;
  }
  
  .serving-info {
    font-size: 0.9rem;
    color: var(--text-secondary);
  }
  
  .tasting-notes {
    margin-bottom: 1rem;
  }
  
  .flavor-tags {
    margin-bottom: 0.5rem;
  }
  
  .tag {
    display: inline-block;
    padding: 0.25rem 0.5rem;
    margin: 0.125rem;
    border-radius: 12px;
    font-size: 0.8rem;
    font-weight: 500;
  }
  
  .tag.flavor {
    background: var(--primary-bg);
    color: var(--primary);
  }
  
  .tag.aroma {
    background: var(--secondary-bg);
    color: var(--secondary);
  }
  
  .review-text {
    border-top: 1px solid var(--border);
    padding-top: 1rem;
  }
  
  .review-text p {
    line-height: 1.6;
  }
</style>

Next Steps