Limited Edition Drop Use Case
Learn how to use Fanfare draws and auctions to distribute limited edition products fairly and create excitement.Overview
Limited edition products require careful distribution to ensure fairness while maximizing engagement. Fanfare provides draws (raffles) and auctions to give everyone a fair chance while creating memorable experiences. What you’ll learn:- Choosing between draws and auctions
- Setting up a draw-based release
- Running an auction for premium items
- Building the drop experience
- Managing winners and fulfillment
Prerequisites
- Fanfare account with draws and/or auctions enabled
- Limited edition product ready for release
- Understanding of your customer base
- Fulfillment process for winners
When to Use Each Distribution Method
| Method | Best For | Fairness Model | Revenue |
|---|---|---|---|
| Draw (Raffle) | Mass market, brand building | Equal chance | Fixed price |
| Auction | Collectors, price discovery | Highest bidder | Variable |
| Queue | First-come-first-served | Speed-based | Fixed price |
Draw-Based Release
Step 1: Configure the Draw
async function createLimitedEditionDraw() {
const draw = await createFanfareExperience({
name: "Limited Edition Sneaker Release",
slug: "ltd-sneaker-fall-2024",
// Entry period
entryStart: new Date("2024-09-01T10:00:00Z"),
entryEnd: new Date("2024-09-07T23:59:59Z"),
// Draw timing
drawTime: new Date("2024-09-08T12:00:00Z"),
// Prize configuration
config: {
totalWinners: 500,
// Entry limits
maxEntriesPerConsumer: 1,
requireAuthentication: true,
// Selection method
selectionMethod: "random", // or "weighted" for VIP bonus entries
// Winner notification
notifyWinnersVia: ["email", "sms"],
winnerClaimWindow: 48 * 60 * 60, // 48 hours to claim
// Waitlist for unclaimed prizes
enableWaitlist: true,
waitlistSize: 200,
},
// Product details
prize: {
productId: "sneaker-ltd-fall-2024",
name: "Fall 2024 Limited Edition Sneaker",
price: "250.00",
description: "Only 500 pairs available worldwide",
imageUrl: "https://your-store.com/images/ltd-sneaker.jpg",
},
// Branding
branding: {
primaryColor: "#1A1A1A",
accentColor: "#FFD700",
logoUrl: "https://your-brand.com/logo.png",
},
});
return draw;
}
Step 2: Build the Entry Experience
// pages/drops/[slug].tsx
import { FanfareProvider } from "@fanfare-io/fanfare-sdk-react";
import { DrawExperience } from "@/components/DrawExperience";
import { ProductShowcase } from "@/components/ProductShowcase";
interface DropPageProps {
drawId: string;
product: Product;
entryStart: string;
entryEnd: string;
drawTime: string;
}
export default function DropPage({ drawId, product, entryStart, entryEnd, drawTime }: DropPageProps) {
return (
<FanfareProvider
organizationId={process.env.NEXT_PUBLIC_FANFARE_ORG_ID!}
publishableKey={process.env.NEXT_PUBLIC_FANFARE_PUBLISHABLE_KEY!}
>
<div className="drop-page">
<ProductShowcase product={product} />
<DrawExperience drawId={drawId} entryStart={entryStart} entryEnd={entryEnd} drawTime={drawTime} />
<DropDetails product={product} totalWinners={500} drawTime={drawTime} />
</div>
</FanfareProvider>
);
}
function DropDetails({
product,
totalWinners,
drawTime,
}: {
product: Product;
totalWinners: number;
drawTime: string;
}) {
return (
<section className="drop-details">
<h2>How It Works</h2>
<ol className="steps">
<li>
<strong>Enter the Draw</strong>
<p>Sign up with your email during the entry period. One entry per person.</p>
</li>
<li>
<strong>Wait for the Draw</strong>
<p>Winners will be selected randomly on {new Date(drawTime).toLocaleDateString()}.</p>
</li>
<li>
<strong>Get Notified</strong>
<p>Winners receive an email and SMS with purchase instructions.</p>
</li>
<li>
<strong>Complete Purchase</strong>
<p>You have 48 hours to complete your purchase at ${product.price}.</p>
</li>
</ol>
<div className="drop-stats">
<div className="stat">
<span className="value">{totalWinners}</span>
<span className="label">Available</span>
</div>
<div className="stat">
<span className="value">${product.price}</span>
<span className="label">Price</span>
</div>
</div>
</section>
);
}
Step 3: Draw Experience Component
// components/DrawExperience.tsx
import { useExperienceJourney } from "@fanfare-io/fanfare-sdk-react";
import type { SequenceView } from "@fanfare-io/fanfare-sdk-core/experiences";
import { useState, useEffect } from "react";
interface DrawExperienceProps {
drawId: string;
entryStart: string;
entryEnd: string;
drawTime: string;
}
export function DrawExperience({ drawId, entryStart, entryEnd, drawTime }: DrawExperienceProps) {
const { view, start } = useExperienceJourney(drawId, { autoStart: true });
const stage = view?.journeyStage === "routed" ? view.sequence.phase : view?.journeyStage;
const sequence = view?.journeyStage === "routed" ? view.sequence : null;
const now = Date.now();
const entryStartTime = new Date(entryStart).getTime();
const entryEndTime = new Date(entryEnd).getTime();
const drawTimeMs = new Date(drawTime).getTime();
// Determine phase
const phase =
now < entryStartTime
? "pre_entry"
: now < entryEndTime
? "entry_open"
: now < drawTimeMs
? "entry_closed"
: "drawn";
return (
<div className="draw-experience">
{phase === "pre_entry" && <PreEntryState entryStart={entryStart} />}
{phase === "entry_open" && <EntryOpenState sequence={sequence} onEnter={start} entryEnd={entryEnd} />}
{phase === "entry_closed" && <EntryClosedState sequence={sequence} drawTime={drawTime} />}
{phase === "drawn" && <DrawnState sequence={sequence} />}
</div>
);
}
function PreEntryState({ entryStart }: { entryStart: string }) {
return (
<div className="pre-entry-state">
<h2>Entry Opens Soon</h2>
<CountdownTimer targetTime={entryStart} />
<div className="notify-me">
<p>Get notified when entry opens</p>
<NotifyMeForm />
</div>
</div>
);
}
function EntryOpenState({
sequence,
onEnter,
entryEnd,
}: {
sequence: SequenceView | null;
onEnter: () => void;
entryEnd: string;
}) {
const isEntered = sequence?.stage === "participating" || sequence?.stage === "admitted";
if (isEntered) {
return (
<div className="entered-state">
<div className="success-badge">
<span className="icon">✓</span>
<span>You're In!</span>
</div>
<p>Your entry has been recorded. Good luck!</p>
<div className="draw-countdown">
<p>Draw happens in:</p>
<CountdownTimer targetTime={entryEnd} />
</div>
<p className="reminder">We'll notify you by email if you win.</p>
</div>
);
}
return (
<div className="entry-open-state">
<h2>Enter the Draw</h2>
<div className="entry-closing">
<p>Entry closes in:</p>
<CountdownTimer targetTime={entryEnd} />
</div>
<EntryForm onSubmit={onEnter} />
<p className="terms">By entering, you agree to the draw rules and terms.</p>
</div>
);
}
function EntryForm({ onSubmit }: { onSubmit: () => void }) {
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [agreed, setAgreed] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
// SDK handles entry
onSubmit();
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="entry-form">
<div className="form-group">
<label htmlFor="email">Email Address</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="[email protected]"
/>
</div>
<div className="form-group">
<label htmlFor="phone">Phone Number (for SMS notification)</label>
<input
id="phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+1 (555) 123-4567"
/>
</div>
<div className="form-group checkbox">
<label>
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} required />I agree to
the draw rules and terms of service
</label>
</div>
<button type="submit" disabled={isSubmitting || !agreed}>
{isSubmitting ? "Entering..." : "Enter Draw"}
</button>
</form>
);
}
function EntryClosedState({ sequence, drawTime }: { sequence: SequenceView | null; drawTime: string }) {
const isEntered = sequence?.stage === "participating" || sequence?.stage === "admitted";
return (
<div className="entry-closed-state">
<h2>Entry Period Closed</h2>
{isEntered ? (
<div className="entered-confirmation">
<p className="success">You're entered! Good luck!</p>
</div>
) : (
<p className="missed">Entry is now closed. Follow us for future drops.</p>
)}
<div className="draw-info">
<p>Draw happens:</p>
<CountdownTimer targetTime={drawTime} />
</div>
</div>
);
}
function DrawnState({ sequence }: { sequence: SequenceView | null }) {
if (sequence?.phase === "granted") {
return (
<div className="winner-state">
<div className="winner-celebration">
<h2>Congratulations!</h2>
<p>You've been selected!</p>
</div>
<div className="claim-section">
<p>Complete your purchase before the access window closes.</p>
<button
onClick={() => {
sessionStorage.setItem("fanfare_admission", JSON.stringify({ admissionGrant: sequence.grant.token }));
window.location.href = "/checkout";
}}
>
Claim Your Prize
</button>
</div>
<p className="warning">Don't miss out - unclaimed prizes go to the waitlist.</p>
</div>
);
}
if (sequence?.stage === "denied") {
return (
<div className="not-selected-state">
<h2>Draw Complete</h2>
<p>Unfortunately, you weren't selected this time.</p>
<div className="follow-up">
<p>Stay tuned for future drops!</p>
<button onClick={() => (window.location.href = "/subscribe")}>Get Notified of Future Drops</button>
</div>
</div>
);
}
return <WaitingForDrawState />;
}
function WaitingForDrawState() {
return (
<div className="waiting-for-draw-state">
<h2>Draw In Progress</h2>
<p>Keep an eye on your account and email for the result.</p>
</div>
);
}
Auction-Based Release
Step 1: Configure the Auction
async function createLimitedEditionAuction() {
const auction = await createFanfareExperience({
name: "Rare Collector's Edition Artwork",
slug: "rare-artwork-auction-001",
// Timing
startTime: new Date("2024-10-01T18:00:00Z"),
endTime: new Date("2024-10-03T18:00:00Z"),
// Auction configuration
config: {
auctionType: "english", // ascending price auction
// Pricing
startingBid: 500,
reservePrice: 1000, // Minimum price to sell
bidIncrement: 50,
buyNowPrice: 5000, // Optional instant purchase
// Anti-sniping
antiSnipingEnabled: true,
antiSnipingExtension: 120, // Extend 2 minutes if bid in last 2 minutes
// Bidder requirements
requireAuthentication: true,
requirePaymentMethod: true, // Must have card on file
bidderDeposit: 100, // Refundable deposit to bid
// Limits
maxBidsPerConsumer: 50,
},
// Item details
item: {
productId: "artwork-rare-001",
name: "Original Digital Artwork #001",
description: "One-of-a-kind digital artwork with physical print",
imageUrl: "https://your-store.com/images/artwork-001.jpg",
attributes: {
artist: "Famous Artist",
medium: "Digital + Physical Print",
size: "24x36 inches",
certificate: "Includes Certificate of Authenticity",
},
},
// Branding
branding: {
primaryColor: "#2C2C2C",
accentColor: "#C9A227",
},
});
return auction;
}
Step 2: Build the Auction Experience
// components/AuctionExperience.tsx
import { useExperienceJourney } from "@fanfare-io/fanfare-sdk-react";
import { useState, useEffect, useCallback } from "react";
interface AuctionExperienceProps {
auctionId: string;
item: AuctionItem;
}
export function AuctionExperience({ auctionId, item }: AuctionExperienceProps) {
const { view } = useExperienceJourney(auctionId, { autoStart: true });
const auctionSequence =
view?.journeyStage === "routed" &&
view.sequence.phase === "participating" &&
view.sequence.mechanism === "auction"
? view.sequence
: null;
const auctionState = auctionSequence?.state$.get();
const currentBid = auctionState?.currentBid || auctionState?.startingBid || "0.00";
const isHighBidder = Boolean(auctionState?.isHighBidder);
const endTime = auctionState?.endsAt;
const bidIncrement = auctionState?.bidIncrement || "50.00";
return (
<div className="auction-experience">
<AuctionHeader item={item} endTime={endTime} />
<div className="auction-main">
<div className="current-bid-section">
<span className="label">Current Bid</span>
<span className="amount">{formatMoney(currentBid, "USD")}</span>
{auctionState?.totalBids && <span className="bid-count">{auctionState.totalBids} bids</span>}
</div>
{isHighBidder && (
<div className="high-bidder-notice">
<span className="icon">✓</span>
You're the highest bidder!
</div>
)}
<BidForm
currentBid={currentBid}
minBid={addMoney(currentBid, bidIncrement)}
bidIncrement={bidIncrement}
buyNowPrice={auctionState?.buyNowPrice}
isHighBidder={isHighBidder}
onBid={(amount) => auctionSequence?.bid(amount)}
/>
<BidHistory bids={auctionState?.recentBids || []} />
</div>
</div>
);
}
function AuctionHeader({ item, endTime }: { item: AuctionItem; endTime?: number }) {
const [timeLeft, setTimeLeft] = useState(0);
const [isEnding, setIsEnding] = useState(false);
useEffect(() => {
if (!endTime) return;
const interval = setInterval(() => {
const remaining = endTime - Date.now();
setTimeLeft(Math.max(0, remaining));
setIsEnding(remaining < 5 * 60 * 1000); // Less than 5 minutes
}, 1000);
return () => clearInterval(interval);
}, [endTime]);
return (
<header className="auction-header">
<h1>{item.name}</h1>
<div className={`auction-timer ${isEnding ? "ending-soon" : ""}`}>
<span className="label">{isEnding ? "Ending Soon!" : "Time Remaining"}</span>
<span className="time">{formatAuctionTime(timeLeft)}</span>
</div>
</header>
);
}
function BidForm({
currentBid,
minBid,
bidIncrement,
buyNowPrice,
isHighBidder,
onBid,
}: {
currentBid: string;
minBid: string;
bidIncrement: string;
buyNowPrice?: string;
isHighBidder: boolean;
onBid: (amount: string) => void;
}) {
const [bidAmount, setBidAmount] = useState(minBid);
const [isSubmitting, setIsSubmitting] = useState(false);
// Update minimum when current bid changes
useEffect(() => {
if (compareMoney(bidAmount, minBid) < 0) {
setBidAmount(minBid);
}
}, [minBid, bidAmount]);
const handleBid = async () => {
if (compareMoney(bidAmount, minBid) < 0) return;
setIsSubmitting(true);
try {
await onBid(bidAmount);
} finally {
setIsSubmitting(false);
}
};
const quickBidAmounts = [
minBid,
addMoney(minBid, bidIncrement),
addMoney(minBid, multiplyMoney(bidIncrement, 2)),
addMoney(minBid, multiplyMoney(bidIncrement, 5)),
];
return (
<div className="bid-form">
<div className="quick-bids">
{quickBidAmounts.map((amount) => (
<button key={amount} onClick={() => setBidAmount(amount)} className={bidAmount === amount ? "selected" : ""}>
{formatMoney(amount, "USD")}
</button>
))}
</div>
<div className="custom-bid">
<label htmlFor="bid-amount">Your Bid</label>
<div className="bid-input-group">
<span className="currency">$</span>
<input
id="bid-amount"
type="number"
value={bidAmount}
onChange={(e) => setBidAmount(normalizeMoneyInput(e.target.value, minBid))}
min={minBid}
step={bidIncrement}
/>
</div>
<span className="min-bid">Minimum: {formatMoney(minBid, "USD")}</span>
</div>
<button onClick={handleBid} disabled={isSubmitting || compareMoney(bidAmount, minBid) < 0} className="place-bid-btn">
{isSubmitting ? "Placing Bid..." : isHighBidder ? "Increase Bid" : "Place Bid"}
</button>
{buyNowPrice && (
<div className="buy-now-section">
<span className="divider">or</span>
<button onClick={() => onBid(buyNowPrice)} className="buy-now-btn">
Buy Now for {formatMoney(buyNowPrice, "USD")}
</button>
</div>
)}
</div>
);
}
function BidHistory({ bids }: { bids: Bid[] }) {
if (bids.length === 0) {
return (
<div className="bid-history empty">
<p>No bids yet. Be the first!</p>
</div>
);
}
return (
<div className="bid-history">
<h3>Recent Bids</h3>
<ul>
{bids.map((bid, index) => (
<li key={bid.id}>
<span className="bidder">{bid.displayName}</span>
<span className="amount">{formatMoney(bid.amount, "USD")}</span>
<span className="time">{formatTimeAgo(bid.timestamp)}</span>
{index === 0 && <span className="leader-badge">Leading</span>}
</li>
))}
</ul>
</div>
);
}
function formatAuctionTime(ms: number): string {
const hours = Math.floor(ms / (1000 * 60 * 60));
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((ms % (1000 * 60)) / 1000);
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
}
if (minutes > 0) {
return `${minutes}m ${seconds}s`;
}
return `${seconds}s`;
}
function formatTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return "Just now";
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return new Date(timestamp).toLocaleDateString();
}
Step 3: Winner Management
Process Draw Winners
// services/draw-winner-management.ts
async function processDrawWinners(drawId: string) {
// Get winners list
const winners = await getDrawWinners(drawId);
for (const winner of winners) {
const checkoutLink = await createSecureCheckoutLink({
consumerId: winner.consumerId,
drawId,
expiresInHours: 48,
});
await db.insert(drawClaims).values({
drawId,
consumerId: winner.consumerId,
expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000), // 48 hours
status: "pending",
});
await sendWinnerNotification(winner, checkoutLink);
}
// Process waitlist
const waitlist = await getDrawWaitlist(drawId);
for (const entry of waitlist) {
await sendWaitlistNotification(entry);
}
}
async function sendWinnerNotification(winner: DrawWinner, checkoutLink: string) {
await Promise.all([
sendEmail({
to: winner.email,
subject: "You Won! Complete Your Purchase",
template: "draw-winner",
data: {
name: winner.name,
productName: winner.prize.name,
checkoutLink,
deadline: new Date(Date.now() + 48 * 60 * 60 * 1000).toLocaleString(),
},
}),
sendSMS({
to: winner.phone,
message: `Congratulations! You won the ${winner.prize.name}. Check your email to complete checkout within 48h.`,
}),
]);
}
Process Auction Winner
async function processAuctionWinner(auctionId: string) {
const auction = await getAuctionResult(auctionId);
if (auction.status !== "ended") {
throw new Error("Auction not yet ended");
}
const winner = auction.highestBidder;
const finalPrice = auction.highestBid;
// Check reserve price met
if (compareMoney(finalPrice, auction.config.reservePrice) < 0) {
await handleReserveNotMet(auction);
return;
}
// Generate payment intent for final amount
const paymentIntent = await stripe.paymentIntents.create({
amount: toMinorUnits(finalPrice),
currency: "usd",
customer: winner.stripeCustomerId,
metadata: {
auctionId,
type: "auction_win",
},
});
// Send winner notification
await sendEmail({
to: winner.email,
subject: `Congratulations! You won the auction for ${auction.item.name}`,
template: "auction-winner",
data: {
itemName: auction.item.name,
finalPrice,
paymentUrl: `https://your-store.com/auction/pay/${auctionId}?pi=${paymentIntent.id}`,
deadline: new Date(Date.now() + 72 * 60 * 60 * 1000).toLocaleString(),
},
});
}
async function handleReserveNotMet(auction: Auction) {
// Notify seller
await sendEmail({
to: auction.sellerEmail,
subject: `Reserve Not Met - ${auction.item.name}`,
template: "auction-reserve-not-met",
data: {
itemName: auction.item.name,
highestBid: auction.highestBid,
reservePrice: auction.config.reservePrice,
},
});
// Notify high bidder
if (auction.highestBidder) {
await sendEmail({
to: auction.highestBidder.email,
subject: `Auction Ended - Reserve Not Met`,
template: "auction-reserve-not-met-bidder",
data: {
itemName: auction.item.name,
yourBid: auction.highestBid,
},
});
}
}
Best Practices
1. Prevent Gaming and Fraud
// Draw fraud prevention
const drawFraudPrevention = {
// Require verified email
requireEmailVerification: true,
// Block disposable emails
blockDisposableEmails: true,
// Limit entries by IP
maxEntriesPerIP: 3,
// Require account age
minAccountAgeDays: 7,
// Geographic restrictions
allowedCountries: ["US", "CA", "UK"],
};
// Auction fraud prevention
const auctionFraudPrevention = {
// Require verified payment method
requirePaymentMethod: true,
// Bidder deposit
bidderDeposit: 100,
// Account verification
requireIdentityVerification: true,
// Shill bidding detection
detectShillBidding: true,
};
2. Clear Communication
// Communication timeline for draws
const drawCommunicationSchedule = [
{ trigger: "entry_open", template: "draw-entry-open", channels: ["email"] },
{ trigger: "24h_before_close", template: "draw-reminder", channels: ["email", "push"] },
{ trigger: "entry_closed", template: "draw-closed", channels: ["email"] },
{ trigger: "draw_complete_winner", template: "draw-winner", channels: ["email", "sms", "push"] },
{ trigger: "draw_complete_non_winner", template: "draw-non-winner", channels: ["email"] },
{ trigger: "24h_before_claim_deadline", template: "claim-reminder", channels: ["email", "sms"] },
{ trigger: "claim_expired", template: "claim-expired-waitlist", channels: ["email", "sms"] },
];
3. Transparent Rules
function DrawRules({ draw }: { draw: Draw }) {
return (
<section className="draw-rules">
<h2>Official Rules</h2>
<dl>
<dt>Eligibility</dt>
<dd>Must be 18+ and resident of eligible countries</dd>
<dt>Entry Period</dt>
<dd>
{new Date(draw.entryStart).toLocaleString()} - {new Date(draw.entryEnd).toLocaleString()}
</dd>
<dt>Entry Limit</dt>
<dd>One entry per person</dd>
<dt>Selection</dt>
<dd>Winners selected randomly using certified random number generator</dd>
<dt>Notification</dt>
<dd>Winners notified via email and SMS within 1 hour of draw</dd>
<dt>Claim Period</dt>
<dd>Winners must complete purchase within 48 hours</dd>
<dt>Price</dt>
<dd>${draw.prize.price} (no additional fees)</dd>
</dl>
</section>
);
}
Troubleshooting
Draw Issues
| Problem | Cause | Solution |
|---|---|---|
| Duplicate entries | Multiple accounts | Implement email/phone verification |
| Low participation | Poor marketing timing | Announce earlier, extend entry period |
| Many unclaimed prizes | Short claim window | Extend to 72h, improve notifications |
Auction Issues
| Problem | Cause | Solution |
|---|---|---|
| Sniping complaints | Last-second bids | Enable anti-sniping extension |
| Low bidding activity | High starting price | Lower start, keep reserve |
| Winner doesn’t pay | No deposit required | Implement bidder deposits |
What’s Next
- Appointment Booking - Service scheduling
- Event Ticketing - Event access management
- Webhooks Guide - Real-time notifications