Features

Deep dive into BrewHoard's core features - Collection Management, Beer Scanner, Marketplace, and Ratings.

BrewHoard provides a comprehensive set of features for beer enthusiasts. This guide covers the implementation and usage of each major feature.

Feature Overview

FeatureDescriptionKey Components
CollectionTrack your beer inventoryInventory, History, Statistics
ScannerAI-powered beer recognitionCamera, Vision API, Matching
MarketplaceBuy and sell beersListings, Transactions, Payments
RatingsReview and rate beersTasting Notes, Scores, Reviews

Beer Collection

The collection system is the heart of BrewHoard, allowing users to track every beer they own.

Data Model

Text
user_collection (main inventory)

    ├── beer_id → beers table
    ├── quantity, container_type, storage_location

    └── collection_acquisitions (purchase records)
            ├── purchase_price, purchase_date
            ├── purchase_location, batch_number
            └── best_before

Adding Beers to Collection

Svelte
<!-- src/components/collection/AddBeerForm.svelte -->
<script>
  import { enhance } from '$app/forms';
  import { Button } from '$lib/components/ui/button';
  import { Input } from '$lib/components/ui/input';
  
  let { beer, onSuccess } = $props();
  
  let quantity = $state(1);
  let containerType = $state('bottle');
  let purchasePrice = $state('');
  let storageLocation = $state('');
  let isSubmitting = $state(false);
</script>

<form
  method="POST"
  action="/api/v1/collection"
  use:enhance={() => {
    isSubmitting = true;
    return async ({ result }) => {
      isSubmitting = false;
      if (result.type === 'success') {
        onSuccess?.();
      }
    };
  }}
>
  <input type="hidden" name="beerId" value={beer.id} />
  
  <div class="space-y-4">
    <div>
      <label for="quantity">Quantity</label>
      <Input type="number" name="quantity" bind:value={quantity} min="1" />
    </div>
    
    <div>
      <label for="containerType">Container Type</label>
      <select name="containerType" bind:value={containerType}>
        <option value="bottle">Bottle</option>
        <option value="can">Can</option>
        <option value="keg">Keg</option>
        <option value="growler">Growler</option>
      </select>
    </div>
    
    <div>
      <label for="purchasePrice">Purchase Price</label>
      <Input type="number" name="purchasePrice" bind:value={purchasePrice} step="0.01" />
    </div>
    
    <div>
      <label for="storageLocation">Storage Location</label>
      <Input name="storageLocation" bind:value={storageLocation} placeholder="e.g., Beer Fridge" />
    </div>
    
    <Button type="submit" disabled={isSubmitting}>
      {isSubmitting ? 'Adding...' : 'Add to Collection'}
    </Button>
  </div>
</form>

Collection Display with Grouping

Svelte
<!-- src/routes/(app)/collection/+page.svelte -->
<script>
  let { data } = $props();
  
  let groupBy = $state('style');
  
  let groupedCollection = $derived(() => {
    const groups = {};
    
    for (const item of data.collection) {
      const key = groupBy === 'style' 
        ? item.beer.style 
        : item.beer.brewery?.name || 'Unknown';
      
      if (!groups[key]) {
        groups[key] = [];
      }
      groups[key].push(item);
    }
    
    return Object.entries(groups).sort(([a], [b]) => a.localeCompare(b));
  });
</script>

<div class="collection-page">
  <header class="flex justify-between items-center mb-6">
    <h1>My Collection</h1>
    
    <select bind:value={groupBy}>
      <option value="style">Group by Style</option>
      <option value="brewery">Group by Brewery</option>
    </select>
  </header>
  
  {#each groupedCollection as [group, items]}
    <section class="mb-8">
      <h2 class="text-xl font-semibold mb-4">{group}</h2>
      
      <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {#each items as item}
          <CollectionCard {item} />
        {/each}
      </div>
    </section>
  {/each}
</div>

Consumption Tracking

JavaScript
// src/lib/collection/collection.js

/**
 * Record consumption of a beer
 * @param {string} collectionItemId
 * @param {number} quantity
 * @param {Object} options
 */
export async function consumeBeer(collectionItemId, quantity, options = {}) {
  const { notes, rating } = options;
  
  return await sql.begin(async (sql) => {
    // 1. Get collection item
    const [item] = await sql`
      SELECT * FROM user_collection WHERE id = ${collectionItemId}
    `;
    
    if (!item || item.quantity < quantity) {
      throw new Error('Insufficient quantity');
    }
    
    // 2. Update quantity
    if (item.quantity === quantity) {
      // Remove item entirely
      await sql`DELETE FROM user_collection WHERE id = ${collectionItemId}`;
    } else {
      await sql`
        UPDATE user_collection 
        SET quantity = quantity - ${quantity}
        WHERE id = ${collectionItemId}
      `;
    }
    
    // 3. Record in history
    await sql`
      INSERT INTO collection_history (
        user_id, collection_item_id, beer_id, action, quantity, notes
      ) VALUES (
        ${item.user_id}, ${collectionItemId}, ${item.beer_id}, 
        'consumed', ${quantity}, ${notes}
      )
    `;
    
    // 4. Create rating if provided
    if (rating) {
      await sql`
        INSERT INTO ratings (user_id, beer_id, overall_rating, review_text)
        VALUES (${item.user_id}, ${item.beer_id}, ${rating.overall}, ${rating.text})
        ON CONFLICT (user_id, beer_id) 
        DO UPDATE SET overall_rating = ${rating.overall}, review_text = ${rating.text}
      `;
    }
    
    return { success: true };
  });
}

Beer Scanner

The scanner uses AI-powered image recognition to identify beers from photos.

Scanner Architecture

Loading diagram...

Camera Component

Svelte
<!-- src/routes/(app)/scan/+page.svelte -->
<script>
  import { onMount } from 'svelte';
  
  let videoElement = $state(null);
  let canvasElement = $state(null);
  let isScanning = $state(false);
  let results = $state([]);
  let error = $state('');
  
  onMount(async () => {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        video: { facingMode: 'environment' }
      });
      videoElement.srcObject = stream;
    } catch (err) {
      error = 'Camera access denied';
    }
    
    return () => {
      // Cleanup camera on unmount
      const tracks = videoElement?.srcObject?.getTracks();
      tracks?.forEach(track => track.stop());
    };
  });
  
  async function captureAndScan() {
    isScanning = true;
    
    // Capture frame from video
    const ctx = canvasElement.getContext('2d');
    ctx.drawImage(videoElement, 0, 0, 640, 480);
    
    // Convert to blob
    const blob = await new Promise(resolve => {
      canvasElement.toBlob(resolve, 'image/jpeg', 0.8);
    });
    
    // Send to API
    const formData = new FormData();
    formData.append('image', blob);
    
    try {
      const response = await fetch('/api/v1/scanner/recognize', {
        method: 'POST',
        body: formData
      });
      
      const data = await response.json();
      results = data.data?.matches || [];
    } catch (err) {
      error = 'Recognition failed';
    } finally {
      isScanning = false;
    }
  }
</script>

<div class="scanner-page">
  {#if error}
    <div class="error-banner">{error}</div>
  {/if}
  
  <div class="camera-container">
    <video bind:this={videoElement} autoplay playsinline></video>
    <canvas bind:this={canvasElement} width="640" height="480" class="hidden"></canvas>
    
    <button 
      onclick={captureAndScan} 
      disabled={isScanning}
      class="capture-button"
    >
      {isScanning ? 'Scanning...' : 'Capture'}
    </button>
  </div>
  
  {#if results.length > 0}
    <div class="results">
      <h2>Matches Found</h2>
      {#each results as match}
        <div class="match-card">
          <img src={match.beer.imageUrl} alt={match.beer.name} />
          <div>
            <h3>{match.beer.name}</h3>
            <p>{match.beer.brewery?.name}</p>
            <p class="confidence">
              {Math.round(match.confidence * 100)}% match
            </p>
          </div>
          <button onclick={() => selectBeer(match.beer)}>
            Add to Collection
          </button>
        </div>
      {/each}
    </div>
  {/if}
</div>

Vision API Integration

JavaScript
// src/lib/scanner/vision/recognize.js

/**
 * Analyze image using Vision API
 * @param {Buffer} imageBuffer
 */
export async function recognizeImage(imageBuffer) {
  const base64Image = imageBuffer.toString('base64');
  
  // Call Vision API (example using Google Cloud Vision)
  const response = await fetch(
    `https://vision.googleapis.com/v1/images:annotate?key=${process.env.VISION_API_KEY}`,
    {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        requests: [{
          image: { content: base64Image },
          features: [
            { type: 'TEXT_DETECTION' },
            { type: 'LOGO_DETECTION' },
            { type: 'LABEL_DETECTION', maxResults: 10 }
          ]
        }]
      })
    }
  );
  
  const data = await response.json();
  return parseVisionResponse(data);
}

function parseVisionResponse(data) {
  const result = data.responses?.[0];
  
  return {
    text: result?.fullTextAnnotation?.text || '',
    logos: result?.logoAnnotations?.map(l => l.description) || [],
    labels: result?.labelAnnotations?.map(l => l.description) || []
  };
}

Matching Algorithm

JavaScript
// src/lib/scanner/matcher.js

/**
 * Find matching beers based on vision results
 * @param {Object} visionResult
 */
export async function findMatches(visionResult) {
  const { text, logos, labels } = visionResult;
  
  // Extract potential beer/brewery names
  const searchTerms = extractSearchTerms(text, logos, labels);
  
  // Query database with fuzzy matching
  const matches = await sql`
    SELECT 
      b.*,
      br.name as brewery_name,
      similarity(b.name, ${searchTerms.join(' ')}) as name_score,
      similarity(br.name, ${searchTerms.join(' ')}) as brewery_score
    FROM beers b
    LEFT JOIN breweries br ON b.brewery_id = br.id
    WHERE 
      b.name % ANY(${searchTerms})
      OR br.name % ANY(${searchTerms})
    ORDER BY 
      GREATEST(
        similarity(b.name, ${searchTerms.join(' ')}),
        similarity(br.name, ${searchTerms.join(' ')})
      ) DESC
    LIMIT 5
  `;
  
  return matches.map(beer => ({
    beer,
    confidence: Math.max(beer.name_score, beer.brewery_score),
    matchedOn: determineMatchType(beer, searchTerms)
  }));
}

Marketplace

The peer-to-peer marketplace enables secure beer trading.

Listing Creation Flow

Loading diagram...

Create Listing Component

Svelte
<!-- src/components/marketplace/CreateListingForm.svelte -->
<script>
  import { enhance } from '$app/forms';
  import PhotoUpload from '$components/storage/PhotoUpload.svelte';
  
  let { collectionItem } = $props();
  
  let price = $state('');
  let shippingCost = $state('');
  let description = $state('');
  let photos = $state([]);
  let shipsTo = $state(['US']);
</script>

<form method="POST" action="/api/v1/marketplace/listings" use:enhance>
  <input type="hidden" name="collectionItemId" value={collectionItem.id} />
  
  <div class="space-y-6">
    <!-- Beer info (read-only) -->
    <div class="beer-info">
      <img src={collectionItem.beer.imageUrl} alt="" />
      <div>
        <h3>{collectionItem.beer.name}</h3>
        <p>{collectionItem.beer.brewery?.name}</p>
      </div>
    </div>
    
    <!-- Pricing -->
    <div class="grid grid-cols-2 gap-4">
      <div>
        <label>Price</label>
        <input type="number" name="price" bind:value={price} step="0.01" required />
      </div>
      <div>
        <label>Shipping Cost</label>
        <input type="number" name="shippingCost" bind:value={shippingCost} step="0.01" />
      </div>
    </div>
    
    <!-- Description -->
    <div>
      <label>Description</label>
      <textarea name="description" bind:value={description} rows="4"></textarea>
    </div>
    
    <!-- Photos -->
    <div>
      <label>Photos (up to 5)</label>
      <PhotoUpload bind:photos maxFiles={5} />
    </div>
    
    <!-- Shipping destinations -->
    <div>
      <label>Ships To</label>
      <select multiple name="shipsTo" bind:value={shipsTo}>
        <option value="US">United States</option>
        <option value="CA">Canada</option>
        <option value="GB">United Kingdom</option>
        <option value="DE">Germany</option>
        <option value="NL">Netherlands</option>
      </select>
    </div>
    
    <button type="submit" class="w-full">Create Listing</button>
  </div>
</form>

Stripe Payment Integration

JavaScript
// src/routes/api/v1/payments/checkout/+server.js
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export async function POST({ request, locals }) {
  const { listingId, quantity, shippingAddress } = await request.json();
  
  // Get listing details
  const [listing] = await sql`
    SELECT ml.*, u.stripe_account_id as seller_stripe_id
    FROM marketplace_listings ml
    JOIN users u ON ml.seller_id = u.id
    WHERE ml.id = ${listingId} AND ml.status = 'active'
  `;
  
  if (!listing) {
    return json({ error: 'Listing not found' }, { status: 404 });
  }
  
  const totalAmount = (listing.price + (listing.shipping_cost || 0)) * quantity;
  
  // Create Stripe Payment Intent with Connect (for marketplace)
  const paymentIntent = await stripe.paymentIntents.create({
    amount: Math.round(totalAmount * 100), // Cents
    currency: listing.currency.toLowerCase(),
    metadata: {
      listingId,
      buyerId: locals.user.id,
      sellerId: listing.seller_id
    },
    // Transfer to seller (minus platform fee)
    transfer_data: {
      destination: listing.seller_stripe_id,
      amount: Math.round(totalAmount * 0.9 * 100) // 10% platform fee
    }
  });
  
  // Create pending transaction
  await sql`
    INSERT INTO transactions (
      listing_id, buyer_id, seller_id, quantity,
      item_price, shipping_cost, total_price, currency,
      stripe_payment_intent_id, shipping_address, status
    ) VALUES (
      ${listingId}, ${locals.user.id}, ${listing.seller_id}, ${quantity},
      ${listing.price}, ${listing.shipping_cost}, ${totalAmount}, ${listing.currency},
      ${paymentIntent.id}, ${JSON.stringify(shippingAddress)}, 'pending'
    )
  `;
  
  return json({
    success: true,
    data: {
      clientSecret: paymentIntent.client_secret,
      amount: totalAmount
    }
  });
}

Ratings & Reviews

The rating system allows detailed tasting notes and scores.

Rating Categories

  • Overall (1-5): General impression
  • Aroma (1-5): Smell characteristics
  • Appearance (1-5): Visual appeal
  • Taste (1-5): Flavor profile
  • Mouthfeel (1-5): Texture and body

Tasting Notes System

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']
};

Review Form Component

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');
  
  async function handleSubmit() {
    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?.();
    }
  }
</script>

<form onsubmit|preventDefault={handleSubmit}>
  <div class="space-y-6">
    <!-- Overall Rating (prominent) -->
    <div class="text-center">
      <h3>Overall Rating</h3>
      <StarRating bind:value={ratings.overall} size="lg" />
    </div>
    
    <!-- Category Ratings -->
    <div class="grid grid-cols-2 gap-4">
      <div>
        <label>Aroma</label>
        <StarRating bind:value={ratings.aroma} />
      </div>
      <div>
        <label>Appearance</label>
        <StarRating bind:value={ratings.appearance} />
      </div>
      <div>
        <label>Taste</label>
        <StarRating bind:value={ratings.taste} />
      </div>
      <div>
        <label>Mouthfeel</label>
        <StarRating bind:value={ratings.mouthfeel} />
      </div>
    </div>
    
    <!-- Tasting Notes -->
    <div>
      <label>Tasting Notes</label>
      <FlavorSelector bind:selected={tastingNotes.flavors} />
    </div>
    
    <!-- Written Review -->
    <div>
      <label>Your Review</label>
      <textarea bind:value={reviewText} rows="4" placeholder="Share your thoughts..."></textarea>
    </div>
    
    <!-- Serving Type -->
    <div>
      <label>Serving Type</label>
      <select bind:value={servingType}>
        <option value="bottle">Bottle</option>
        <option value="can">Can</option>
        <option value="draft">Draft</option>
        <option value="cask">Cask</option>
      </select>
    </div>
    
    <button type="submit">Submit Review</button>
  </div>
</form>

Next Steps