Marketplace
Learn how to buy and sell beers through the BrewHoard marketplace with secure payments and shipping management.
The BrewHoard Marketplace allows users to buy and sell beers from their collections. Built with security and trust in mind, it features Stripe-powered payments, comprehensive transaction tracking, and integrated shipping management.
Transaction Flow
The marketplace handles the complete lifecycle of beer transactions, from listing creation to final delivery.
Creating Listings
Users can create marketplace listings for beers from their collection, specifying price, condition, and shipping details.
Create Listing Form
<!-- src/components/marketplace/CreateListingForm.svelte -->
<script>
let { beer, onListingCreated } = $props();
let listingData = $state({
price: 0,
currency: 'USD',
condition: 'excellent',
quantity: 1,
description: '',
shippingMethod: 'standard',
shippingCost: 0,
location: '',
allowPickup: false
});
let isCreating = $state(false);
async function createListing() {
isCreating = true;
try {
const response = await fetch('/api/marketplace/listings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
beerId: beer.id,
...listingData
})
});
if (response.ok) {
const newListing = await response.json();
onListingCreated?.(newListing);
}
} finally {
isCreating = false;
}
}
</script>
<div class="create-listing">
<h3>List "{beer.name}" for Sale</h3>
<form onsubmit|preventDefault={createListing}>
<div class="form-grid">
<div>
<label for="price">Price</label>
<div class="price-input">
<input id="price" type="number" step="0.01" bind:value={listingData.price} required />
<select bind:value={listingData.currency}>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
</select>
</div>
</div>
<div>
<label for="condition">Condition</label>
<select id="condition" bind:value={listingData.condition}>
<option value="excellent">Excellent</option>
<option value="good">Good</option>
<option value="fair">Fair</option>
<option value="poor">Poor</option>
</select>
</div>
<div>
<label for="quantity">Quantity Available</label>
<input id="quantity" type="number" bind:value={listingData.quantity} min="1" max={beer.quantity} />
</div>
<div>
<label for="shippingMethod">Shipping Method</label>
<select id="shippingMethod" bind:value={listingData.shippingMethod}>
<option value="standard">Standard Shipping</option>
<option value="express">Express Shipping</option>
<option value="pickup">Local Pickup Only</option>
</select>
</div>
{#if listingData.shippingMethod !== 'pickup'}
<div>
<label for="shippingCost">Shipping Cost</label>
<input id="shippingCost" type="number" step="0.01" bind:value={listingData.shippingCost} />
</div>
{/if}
<div>
<label for="location">Your Location</label>
<input id="location" bind:value={listingData.location} placeholder="City, Country" />
</div>
</div>
<div class="full-width">
<label for="description">Description</label>
<textarea id="description" bind:value={listingData.description} rows="4"
placeholder="Describe the beer's condition, any special notes, etc."></textarea>
</div>
<div class="checkbox-group">
<label>
<input type="checkbox" bind:checked={listingData.allowPickup} />
Allow local pickup
</label>
</div>
<button type="submit" disabled={isCreating}>
{#if isCreating}
Creating Listing...
{:else}
Create Listing
{/if}
</button>
</form>
</div>
<style>
.create-listing {
background: var(--card-bg);
padding: 1.5rem;
border-radius: 8px;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.price-input {
display: flex;
gap: 0.5rem;
}
.price-input input {
flex: 1;
}
.full-width {
grid-column: 1 / -1;
}
.checkbox-group {
margin: 1rem 0;
}
</style>Browsing Listings
Users can browse available beer listings with filtering and search capabilities.
Marketplace Browser
<!-- src/components/marketplace/MarketplaceBrowser.svelte -->
<script>
let listings = $state([]);
let filters = $state({
search: '',
style: '',
minPrice: 0,
maxPrice: 1000,
condition: '',
shippingMethod: '',
location: ''
});
let isLoading = $state(true);
// Filtered listings
let filteredListings = $derived(() => {
return listings.filter(listing => {
if (filters.search && !listing.beer.name.toLowerCase().includes(filters.search.toLowerCase()) &&
!listing.beer.brewery.toLowerCase().includes(filters.search.toLowerCase())) {
return false;
}
if (filters.style && listing.beer.style !== filters.style) {
return false;
}
if (listing.price < filters.minPrice || listing.price > filters.maxPrice) {
return false;
}
if (filters.condition && listing.condition !== filters.condition) {
return false;
}
if (filters.shippingMethod && listing.shippingMethod !== filters.shippingMethod) {
return false;
}
if (filters.location && !listing.location.toLowerCase().includes(filters.location.toLowerCase())) {
return false;
}
return true;
});
});
async function loadListings() {
isLoading = true;
try {
const response = await fetch('/api/marketplace/listings');
if (response.ok) {
listings = await response.json();
}
} finally {
isLoading = false;
}
}
// Load listings on mount
$effect(() => {
loadListings();
});
</script>
<div class="marketplace-browser">
<div class="filters">
<div class="filter-grid">
<div>
<label for="search">Search</label>
<input id="search" bind:value={filters.search} placeholder="Beer name or brewery" />
</div>
<div>
<label for="style">Style</label>
<select id="style" bind:value={filters.style}>
<option value="">All Styles</option>
<option value="IPA">IPA</option>
<option value="Stout">Stout</option>
<option value="Lager">Lager</option>
<!-- More options -->
</select>
</div>
<div>
<label for="minPrice">Min Price</label>
<input id="minPrice" type="number" bind:value={filters.minPrice} />
</div>
<div>
<label for="maxPrice">Max Price</label>
<input id="maxPrice" type="number" bind:value={filters.maxPrice} />
</div>
<div>
<label for="condition">Condition</label>
<select id="condition" bind:value={filters.condition}>
<option value="">Any Condition</option>
<option value="excellent">Excellent</option>
<option value="good">Good</option>
<option value="fair">Fair</option>
</select>
</div>
<div>
<label for="location">Location</label>
<input id="location" bind:value={filters.location} placeholder="City or country" />
</div>
</div>
</div>
<div class="listings">
{#if isLoading}
<div class="loading">Loading listings...</div>
{:else if filteredListings.length === 0}
<div class="no-listings">No listings match your filters</div>
{:else}
<div class="listings-grid">
{#each filteredListings as listing}
<div class="listing-card">
<div class="beer-info">
<h4>{listing.beer.name}</h4>
<p>{listing.beer.brewery}</p>
{#if listing.beer.style}
<span class="style">{listing.beer.style}</span>
{/if}
</div>
<div class="listing-details">
<div class="price">${listing.price} {listing.currency}</div>
<div class="condition">Condition: {listing.condition}</div>
<div class="shipping">{listing.shippingMethod}</div>
{#if listing.location}
<div class="location">📍 {listing.location}</div>
{/if}
</div>
<button class="buy-btn" onclick={() => handlePurchase(listing)}>
Buy Now
</button>
</div>
{/each}
</div>
{/if}
</div>
</div>
<style>
.marketplace-browser {
padding: 1rem;
}
.filters {
background: var(--card-bg);
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.listings-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.listing-card {
background: var(--card-bg);
padding: 1rem;
border-radius: 8px;
display: flex;
flex-direction: column;
}
.beer-info {
margin-bottom: 1rem;
}
.style {
background: var(--primary-bg);
color: var(--primary);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
}
.listing-details {
margin-bottom: 1rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.price {
font-size: 1.2rem;
font-weight: bold;
color: var(--primary);
}
.buy-btn {
background: var(--primary);
color: white;
border: none;
padding: 0.75rem;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.buy-btn:hover {
background: var(--primary-hover);
}
.loading, .no-listings {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
</style>Purchasing Flow
The purchase process integrates with Stripe for secure payment processing.
Purchase Component
<!-- src/components/marketplace/PurchaseFlow.svelte -->
<script>
import { loadStripe } from '@stripe/stripe-js';
let { listing, onPurchaseComplete } = $props();
let stripe = $state(null);
let isProcessing = $state(false);
let paymentIntent = $state(null);
// Initialize Stripe
$effect(() => {
loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY).then(s => {
stripe = s;
});
});
async function initiatePurchase() {
isProcessing = true;
try {
// Create payment intent on server
const response = await fetch('/api/marketplace/purchase', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
listingId: listing.id,
quantity: 1 // For simplicity
})
});
if (response.ok) {
const { clientSecret } = await response.json();
// Confirm payment with Stripe
const result = await stripe.confirmCardPayment(clientSecret, {
payment_method: {
card: cardElement,
billing_details: {
name: 'Buyer Name', // Would come from form
},
},
});
if (result.error) {
console.error('Payment failed:', result.error);
} else if (result.paymentIntent.status === 'succeeded') {
onPurchaseComplete?.(result.paymentIntent);
}
}
} finally {
isProcessing = false;
}
}
</script>
<div class="purchase-flow">
<h3>Complete Your Purchase</h3>
<div class="order-summary">
<div class="item">
<strong>{listing.beer.name}</strong> by {listing.beer.brewery}
<div class="price-breakdown">
<div>Item: ${listing.price}</div>
{#if listing.shippingCost}
<div>Shipping: ${listing.shippingCost}</div>
{/if}
<div class="total">Total: ${(listing.price + (listing.shippingCost || 0)).toFixed(2)}</div>
</div>
</div>
</div>
<div class="payment-form">
<div id="card-element"><!-- Stripe Elements will be mounted here --></div>
<button onclick={initiatePurchase} disabled={isProcessing || !stripe}>
{#if isProcessing}
Processing Payment...
{:else}
Pay ${(listing.price + (listing.shippingCost || 0)).toFixed(2)}
{/if}
</button>
</div>
</div>
<style>
.purchase-flow {
background: var(--card-bg);
padding: 1.5rem;
border-radius: 8px;
max-width: 500px;
}
.order-summary {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.price-breakdown {
margin-top: 0.5rem;
font-size: 0.9rem;
}
.total {
font-weight: bold;
font-size: 1.1rem;
color: var(--primary);
margin-top: 0.5rem;
}
.payment-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
#card-element {
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
background: white;
}
button {
background: var(--primary);
color: white;
border: none;
padding: 0.75rem;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
}
button:hover:not(:disabled) {
background: var(--primary-hover);
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>Shipping and Delivery
Sellers can update shipping status and buyers can track their orders.
Shipping Tracker
<!-- src/components/marketplace/ShippingTracker.svelte -->
<script>
let { transaction } = $props();
let shippingUpdate = $state({
carrier: '',
trackingNumber: '',
estimatedDelivery: ''
});
let isUpdating = $state(false);
async function updateShipping() {
isUpdating = true;
try {
const response = await fetch(`/api/marketplace/transactions/${transaction.id}/shipping`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(shippingUpdate)
});
if (response.ok) {
// Refresh transaction data
transaction = await response.json();
}
} finally {
isUpdating = false;
}
}
async function markAsDelivered() {
const response = await fetch(`/api/marketplace/transactions/${transaction.id}/deliver`, {
method: 'POST'
});
if (response.ok) {
transaction = await response.json();
}
}
</script>
<div class="shipping-tracker">
<h4>Shipping Status: {transaction.status}</h4>
{#if transaction.status === 'paid'}
<div class="shipping-form">
<h5>Update Shipping Information</h5>
<div class="form-group">
<label for="carrier">Carrier</label>
<select id="carrier" bind:value={shippingUpdate.carrier}>
<option value="">Select Carrier</option>
<option value="usps">USPS</option>
<option value="ups">UPS</option>
<option value="fedex">FedEx</option>
<option value="dhl">DHL</option>
</select>
</div>
<div class="form-group">
<label for="trackingNumber">Tracking Number</label>
<input id="trackingNumber" bind:value={shippingUpdate.trackingNumber} />
</div>
<div class="form-group">
<label for="estimatedDelivery">Estimated Delivery</label>
<input id="estimatedDelivery" type="date" bind:value={shippingUpdate.estimatedDelivery} />
</div>
<button onclick={updateShipping} disabled={isUpdating}>
{#if isUpdating}
Updating...
{:else}
Update Shipping
{/if}
</button>
</div>
{:else if transaction.status === 'shipped'}
<div class="tracking-info">
<p><strong>Carrier:</strong> {transaction.shippingInfo?.carrier}</p>
<p><strong>Tracking:</strong> {transaction.shippingInfo?.trackingNumber}</p>
{#if transaction.shippingInfo?.estimatedDelivery}
<p><strong>Estimated Delivery:</strong> {new Date(transaction.shippingInfo.estimatedDelivery).toLocaleDateString()}</p>
{/if}
{#if transaction.isBuyer}
<button onclick={markAsDelivered}>Mark as Delivered</button>
{/if}
</div>
{:else if transaction.status === 'delivered'}
<div class="delivered">
<p>✅ Package delivered on {new Date(transaction.deliveredAt).toLocaleDateString()}</p>
</div>
{/if}
</div>
<style>
.shipping-tracker {
background: var(--card-bg);
padding: 1rem;
border-radius: 8px;
}
.shipping-form {
margin-top: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.tracking-info {
margin-top: 1rem;
}
.delivered {
color: var(--success);
font-weight: 500;
}
</style>Transaction States
The marketplace tracks transactions through various states to ensure smooth completion.
Transaction Status Component
<!-- src/components/marketplace/TransactionStatus.svelte -->
<script>
let { transaction } = $props();
// Status configuration
const statusConfig = {
pending_payment: {
label: 'Pending Payment',
color: 'warning',
icon: '⏳',
description: 'Waiting for payment to be processed'
},
paid: {
label: 'Paid',
color: 'success',
icon: '💰',
description: 'Payment received, awaiting shipment'
},
shipped: {
label: 'Shipped',
color: 'info',
icon: '📦',
description: 'Package has been shipped'
},
delivered: {
label: 'Delivered',
color: 'success',
icon: '✅',
description: 'Package delivered successfully'
},
completed: {
label: 'Completed',
color: 'success',
icon: '🎉',
description: 'Transaction completed'
},
cancelled: {
label: 'Cancelled',
color: 'error',
icon: '❌',
description: 'Transaction was cancelled'
},
dispute: {
label: 'Dispute',
color: 'error',
icon: '⚠️',
description: 'Issue reported, under review'
}
};
let currentStatus = $derived(statusConfig[transaction.status] || statusConfig.pending_payment);
</script>
<div class="transaction-status status-{currentStatus.color}">
<div class="status-header">
<span class="status-icon">{currentStatus.icon}</span>
<h4>{currentStatus.label}</h4>
</div>
<p class="status-description">{currentStatus.description}</p>
{#if transaction.status === 'shipped' && transaction.shippingInfo?.trackingNumber}
<div class="tracking-link">
<a href={getTrackingUrl(transaction.shippingInfo)} target="_blank">
Track Package
</a>
</div>
{/if}
<div class="timeline">
{#each Object.entries(statusConfig) as [status, config]}
<div class="timeline-item" class:active={status === transaction.status} class:completed={isStatusCompleted(status, transaction.status)}>
<div class="timeline-marker">{config.icon}</div>
<div class="timeline-content">
<div class="timeline-title">{config.label}</div>
<div class="timeline-desc">{config.description}</div>
</div>
</div>
{/each}
</div>
</div>
<style>
.transaction-status {
background: var(--card-bg);
padding: 1.5rem;
border-radius: 8px;
border-left: 4px solid var(--border);
}
.status-warning {
border-left-color: var(--warning);
}
.status-success {
border-left-color: var(--success);
}
.status-info {
border-left-color: var(--info);
}
.status-error {
border-left-color: var(--error);
}
.status-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.status-icon {
font-size: 1.5rem;
}
.status-description {
color: var(--text-secondary);
margin-bottom: 1rem;
}
.tracking-link {
margin-bottom: 1rem;
}
.timeline {
position: relative;
padding-left: 2rem;
}
.timeline::before {
content: '';
position: absolute;
left: 1rem;
top: 0;
bottom: 0;
width: 2px;
background: var(--border);
}
.timeline-item {
position: relative;
margin-bottom: 1rem;
opacity: 0.5;
}
.timeline-item.completed {
opacity: 1;
}
.timeline-item.active {
opacity: 1;
}
.timeline-item.active .timeline-marker {
background: var(--primary);
color: white;
}
.timeline-marker {
position: absolute;
left: -2rem;
top: 0;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
}
.timeline-content {
background: var(--bg-secondary);
padding: 0.75rem;
border-radius: 4px;
}
.timeline-title {
font-weight: 500;
margin-bottom: 0.25rem;
}
.timeline-desc {
font-size: 0.8rem;
color: var(--text-secondary);
}
</style>Next Steps
- Collection Guide - Manage your beer inventory
- Ratings Guide - Rate marketplace purchases
- Analytics Dashboard - Track marketplace performance