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 block 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.
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 block. 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.
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.
// 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 (0.1 to 10.0) only sorts within a cascade level. It never promotes a block 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 block from accidentally overriding a lower-priority active block.
Every block goes through a single activation check. No exceptions.
// lib/spots/index.ts
export function isSpotActive(block: Spot): boolean {
// Must be published
if (block.status !== "published") return false;
// Must be in schedule window
const now = new Date();
if (block.startsAt && now < block.startsAt) return false;
if (block.endsAt && now > block.endsAt) return false;
return true;
}
Every web query uses getActiveSpotFilter() which wraps this logic. There's no alternate activation path. One gate. One truth.
When a block 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 block 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 block"
// }
This powers the admin UI's diagnostic cards — operators see exactly what's preventing their promotion from showing.
Challenge: You're running a 24-hour flash sale. At midnight, the hero block should disappear — but not leave the homepage empty.
Traditional approach:
Spot approach:
// Active block
{
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:
Challenge: You're promoting a limited edition curation. When it sells out, the block 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 (block.curationId) {
const hasStock = await curationHasStock(block.curationId);
if (!hasStock) {
// Spot is suppressed, falls through to next cascade level
return { isActive: false, reason: "inventory_exhausted" };
}
}
When the last item sells, the block silently falls through. No broken promises to customers.
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.
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.
Operators don't manage one block at a time. They manage campaigns with dozens of spots.
// 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:
const result = await batchPublishSpots(spotIds);
// {
// succeeded: ["spot_1", "spot_2"],
// failed: [{ id: "spot_3", reason: "Already published" }],
// revisions: ["rev_abc", "rev_def"]
// }
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."
Every block mutation creates an immutable revision:
model SpotRevision {
id String @id @default(cuid())
spotId String
content Json // Full block state snapshot
metadata Json? // Who, why, from what source
createdAt DateTime @default(now())
}
Metadata tracks:
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 block evolved over time.
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, storytellingCardType 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 imageryA block can render as hero or compact. An editorial block can render as split or carousel.
Intent is stable. Rendering adapts to the surface.
New surfaces don't require schema migrations:
// config/placements.ts
export const PLACEMENT_REGISTRY = {
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 the placement registry",
});
The Spot system handles promotion orchestration. What's coming:
But the foundation — cascading fallbacks, diagnostic transparency, batch operations, revision tracking — that's production-ready today.
Your surfaces will never go dark again.
