Spots: The Promotion Primitive That Never Leaves Surfaces Empty
How we built a promotion system with cascading fallbacks, inventory awareness, and diagnostic transparency. No surface ever goes dark.
•7 min readMost e-commerce promotion systems have a fatal flaw: they can fail silently.
A banner expires at midnight. The scheduled replacement didn't get published. Your homepage hero shows... nothing. Or worse, an error. Your marketing team doesn't notice until morning. By then, thousands of visitors saw a broken experience.
We built Spots to make this impossible.
What Is a Spot?
A Spot is a promotion primitive — a first-class object that declares commercial intent:
{
name: "Summer Sale",
placement: "landing_hero",
cardType: "hero",
startsAt: "2025-06-01",
endsAt: "2025-08-31",
priority: 8.5,
curationId: "summer-curation",
status: "published"
}
This isn't a banner. It's not a widget. It's a declaration: "This message should occupy this surface during this time window."
The key insight: rendering is a downstream projection. A Spot doesn't exist to produce UI. It exists to express commercial intent. The system decides how to project that intent onto surfaces.
The Cascade System
Here's the magic. Every placement query goes through a three-level cascade:
Level 1: Active Spots (published + in schedule window + priority sorted)
↓ (if empty)
Level 2: Default Spots (evergreen content, no specific placement)
↓ (if empty)
Level 3: Fallback Children (product cards, curation cards)
Every placement always resolves. This is enforced at the type level:
type CascadeResult = {
spots: Spot[];
fallbackProducts: Product[];
fallbackCollections: Collection[];
isEmpty: false; // Literally impossible to be true
};
The cascade guarantees something will render. Always.
How It Works
// lib/spots/cascade.ts
export async function getSpotsWithFallback(
placement: string,
): Promise<CascadeResult> {
// Level 1: Active spots for this placement
const activeSpots = await getActiveSpots(placement);
if (activeSpots.length > 0) {
return {
spots: activeSpots,
fallbackProducts: [],
fallbackCollections: [],
isEmpty: false,
};
}
// Level 2: Default/evergreen spots
const defaultSpots = await getDefaultSpots();
if (defaultSpots.length > 0) {
return {
spots: defaultSpots,
fallbackProducts: [],
fallbackCollections: [],
isEmpty: false,
};
}
// Level 3: Fallback content (products, curations)
const fallbackProducts = await getFallbackProducts(placement);
const fallbackCollections = await getFallbackCollections(placement);
return {
spots: [],
fallbackProducts,
fallbackCollections,
isEmpty: false, // Fallbacks always exist
};
}
Priority Within Levels
Priority (0.1 to 10.0) only sorts within a cascade level. It never promotes a banner across levels.
Level 1 spots: priority 8.5, 7.2, 6.0 → sorted by priority
Level 2 spots: priority 9.0 → doesn't matter, Level 1 wins if non-empty
This prevents a high-priority default banner from accidentally overriding a lower-priority active banner.
The Activation Gate
Every banner goes through a single activation check. No exceptions.
// lib/spots/index.ts
export function isSpotActive(banner: Spot): boolean {
// Must be published
if (banner.status !== "published") return false;
// Must be in schedule window
const now = new Date();
if (banner.startsAt && now < banner.startsAt) return false;
if (banner.endsAt && now > banner.endsAt) return false;
return true;
}
Every web query uses getActiveSpotFilter() which wraps this logic. There's no alternate activation path. One gate. One truth.
Diagnostic Transparency
When a banner isn't showing, operators need to know why. Not just "inactive" — the specific reason.
// lib/spots/diagnostics.ts
export type InactivityReason =
| "not_published" // Status is draft or archived
| "scheduled_future" // startsAt hasn't arrived
| "schedule_expired" // endsAt has passed
| "placement_mismatch" // Spot is for a different surface
| "priority_outranked" // Another banner has higher priority
| "inventory_exhausted" // Bound curation has no stock
| "campaign_paused"; // Parent campaign is inactive
The diagnostics API returns structured explanations:
const diagnostics = await getSpotDiagnostics(spotId);
// {
// isActive: false,
// reason: "schedule_expired",
// details: {
// endsAt: "2025-01-15T00:00:00Z",
// expiredFor: "14 days"
// },
// suggestion: "Update the schedule or archive this banner"
// }
This powers the admin UI's diagnostic cards — operators see exactly what's preventing their promotion from showing.
Real-Life Scenarios
Scenario: Flash Sale That Ends Gracefully
Challenge: You're running a 24-hour flash sale. At midnight, the hero banner should disappear — but not leave the homepage empty.
Traditional approach:
- Create flash sale banner
- Set expiration
- Pray you remembered to create the "after" state
- Wake up to support tickets about empty homepage
Spot approach:
// Active banner
{
name: "24-Hour Flash Sale",
placement: "landing_hero",
priority: 9.0,
endsAt: "2025-01-30T00:00:00Z"
}
// Evergreen default (already exists)
{
name: "Welcome to Our Store",
placement: null, // No specific placement = available everywhere
priority: 5.0,
status: "published"
}
At midnight:
- Flash sale banner becomes inactive (schedule expired)
- Cascade falls through to default banner
- Welcome message renders automatically
- No manual intervention. No tickets. No stress.
Scenario: Inventory-Aware Promotions
Challenge: You're promoting a limited edition curation. When it sells out, the banner shouldn't still say "Shop Now."
Solution: Inventory-aware suppression.
// Spot bound to curation
{
name: "Limited Edition - Only 50 Made",
placement: "landing_secondary",
curationId: "limited-edition-spring",
priority: 8.0
}
The system checks: does this curation have at least one product in stock?
// In getActiveSpotFilter()
if (banner.curationId) {
const hasStock = await curationHasStock(banner.curationId);
if (!hasStock) {
// Spot is suppressed, falls through to next cascade level
return { isActive: false, reason: "inventory_exhausted" };
}
}
When the last item sells, the banner silently falls through. No broken promises to customers.
Scenario: Campaign Coordination
Challenge: You have 12 spots across 5 placements for a Valentine's Day campaign. You need to turn them all on/off together.
Solution: Campaign binding.
// All spots share a campaign
{
name: "Valentine's Hero",
placement: "landing_hero",
campaignId: "valentines-2025"
}
{
name: "Valentine's Sidebar",
placement: "curation_sidebar",
campaignId: "valentines-2025"
}
// ... 10 more
To activate: publish the campaign. All 12 spots go live. To pause: pause the campaign. All 12 spots fall through to their cascade fallbacks.
One action, coordinated result.
Scenario: Priority Competition
Challenge: Two spots are scheduled for the same placement. Marketing set them both to priority 8.0. Which one wins?
Solution: Deterministic tiebreaking.
// When priorities are equal, we use creation date (older wins)
// This is documented and predictable
const sorted = spots.sort((a, b) => {
if (a.priority !== b.priority) return b.priority - a.priority;
return a.createdAt.getTime() - b.createdAt.getTime();
});
The admin UI shows this clearly:
⚠️ Priority conflict detected
"Summer Sale" (8.0) will outrank "New Arrivals" (8.0)
Reason: Created earlier (Jan 15 vs Jan 20)
No surprises. No debugging sessions.
Batch Operations
Operators don't manage one banner at a time. They manage campaigns with dozens of spots.
Supported Batch Actions
// Bulk publish
await batchPublishSpots(["spot_1", "spot_2", "spot_3"]);
// Bulk archive
await batchArchiveSpots(["spot_4", "spot_5"]);
// Bulk reassign campaign
await batchReassignCampaign(["spot_6", "spot_7"], "new-campaign-id");
// Bulk update schedule
await batchUpdateSchedule(["spot_8", "spot_9"], {
startsAt: "2025-02-01",
endsAt: "2025-02-14",
});
Every batch operation:
- Runs in a transaction (all succeed or all fail)
- Creates revision records for each banner
- Validates state transitions (no illegal publish from archived)
- Returns detailed results
const result = await batchPublishSpots(spotIds);
// {
// succeeded: ["spot_1", "spot_2"],
// failed: [{ id: "spot_3", reason: "Already published" }],
// revisions: ["rev_abc", "rev_def"]
// }
Preview Before Apply
Operators can preview batch effects before committing:
const preview = await previewBatchOperation("publish", spotIds);
// {
// willChange: 5,
// alreadyInState: 2,
// blockedTransitions: 1,
// cascadeEffects: [
// { placement: "landing_hero", currentSpot: "spot_old", willBecome: "spot_1" }
// ]
// }
This shows exactly what will happen. "If you publish these 5 spots, spot_1 will replace spot_old on the homepage hero."
The Revision System
Every banner mutation creates an immutable revision:
model SpotRevision {
id String @id @default(cuid())
spotId String
content Json // Full banner state snapshot
metadata Json? // Who, why, from what source
createdAt DateTime @default(now())
}
Metadata tracks:
- source: manual_edit, batch_update, ai_generated, rollback, import, api
- aiConfig: If AI-generated, what model/prompt/reasoning
- batchContext: If part of a batch, what operation and which other spots
Rollback
Any revision can be restored:
await rollbackSpot(spotId, revisionId);
// Creates a new revision with the old content
// Preserves audit trail
The current state is never lost. You can see exactly how a banner evolved over time.
SpotType vs CardType
A common confusion: what's the difference?
SpotType classifies commercial intent:
promotional— Sale, discount, limited time offerseasonal— Holiday, event-tied promotionpartnership— Sourcing collaboration, sponsored contentfeatured— Badgeed product/curationinformational— Announcement, policy updateeditorial— Content-driven, storytelling
CardType determines rendering:
hero— Full-width, large image, bold CTAcompact— Small card, minimal infosplit— Image left, content right (or vice versa)stats— Numbers-forward (500+ products, 4.9 rating)carousel— Multiple items, swipeableminimal— Just text, no imagery
A promotional banner can render as hero or compact. An editorial banner can render as split or carousel.
Intent is stable. Rendering adapts to the surface.
Config-Driven Placements
New surfaces don't require schema migrations:
// config/spots.ts
export const SPOT_PLACEMENTS = {
landing_hero: {
label: "Homepage Hero",
allowedCardTypes: ["hero", "split"],
maxSpots: 1,
},
landing_secondary: {
label: "Homepage Secondary",
allowedCardTypes: ["compact", "stats", "carousel"],
maxSpots: 3,
},
curation_header: {
label: "Collection Header",
allowedCardTypes: ["hero", "split"],
maxSpots: 1,
},
// Add new placements here — no migration needed
};
Unknown placements are allowed (escape hatch) but logged as warnings:
// If operator uses "custom_widget_xyz"
logger.warn("Unknown placement used", {
placement: "custom_widget_xyz",
hint: "Consider adding to SPOT_PLACEMENTS registry",
});
What's Next
The Spot system handles promotion orchestration. What's coming:
- Product resolution — Spots bound to curations can resolve and display actual products
- Live stats enrichment — Stats-type spots pull numbers from system data instead of manual entry
- Performance-based suggestions — AI recommends priority adjustments based on CTR/CVR trends
- Cross-surface deduplication — Spot on hero suppressed from feed inject
But the foundation — cascading fallbacks, diagnostic transparency, batch operations, revision tracking — that's production-ready today.
Your surfaces will never go dark again.
