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
| Feature | Description | Key Components |
|---|---|---|
| Collection | Track your beer inventory | Inventory, History, Statistics |
| Scanner | AI-powered beer recognition | Camera, Vision API, Matching |
| Marketplace | Buy and sell beers | Listings, Transactions, Payments |
| Ratings | Review and rate beers | Tasting 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_beforeAdding 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
- Collection Guide - Advanced collection features
- Scanner Guide - Scanner configuration
- Marketplace Guide - Trading best practices
- Ratings Guide - Detailed rating system