Compare-Page State Design Spec
Overview
This spec defines UI states for the ProductComparison component when data is loading, partial, stale, or unavailable. Goal: make the product feel intentional under imperfect catalog conditions.
State Types
| State | Trigger | Visual Treatment |
|---|---|---|
| Loading | products === undefined or fetching | Skeleton placeholders matching selection grid |
| Empty | products.length === 0 after fetch | Illustration + contextual message + actions |
| Partial | products.length > 0 but < selectCount | Reduced grid + "Only X retailers" banner |
| Stale | `data_freshness === "stale" | "very_stale"ORmeta.stale_count > 0` |
| Error | API error or network failure | ErrorState component + retry |
1. Loading State
When
products is undefined / null or isLoading === true.
Desktop (≥640px)
┌─────────────────────────────────────────────────────────────┐
│ Compare Products │
│ [2 Products] [3 Products] [4 Products] ← active pill │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ ▓▓▓▓▓▓ │ │ ▓▓▓▓▓▓ │ │ ▓▓▓▓▓▓ │ │ ▓▓▓▓▓▓ │ │
│ │ ▓▓▓▓▓▓ │ │ ▓▓▓▓▓▓ │ │ ▓▓▓▓▓▓ │ │ ▓▓▓▓▓▓ │ │
│ │ ▓▓▓▓▓▓ │ │ ▓▓▓▓▓▓ │ │ ▓▓▓▓▓▓ │ │ ▓▓▓▓▓▓ │ │
│ │ Name │ │ Name │ │ Name │ │ Name │ │
│ │ $PRICE │ │ $PRICE │ │ $PRICE │ │ $PRICE │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────────────────────┘
Mobile (<640px)
┌───────────────────────────┐
│ Compare Products │
│ [2] [3] [4] │
│ │
│ ┌───────┐ ┌───────┐ │
│ │ ▓▓▓▓▓ │ │ ▓▓▓▓▓ │ │
│ │ ▓▓▓▓▓ │ │ ▓▓▓▓▓ │ │
│ │ Name │ │ Name │ │
│ │ $PRICE│ │ $PRICE│ │
│ └───────┘ └───────┘ │
└───────────────────────────┘
Implementation
- Reuse existing
LoadingStatecomponent withvariant="table" - Or create
CompareSelectionSkeletoncomponent inProductComparisonSkeleton.tsx - CSS: pulse animation on skeletons, 1.5s duration, ease-in-out
2. Empty State
When
products.length === 0 after a successful fetch (no matches found).
Desktop
┌─────────────────────────────────────────────────────────────┐
│ │
│ ┌─────────────┐ │
│ │ 🔍 No match │ │
│ └─────────────┘ │
│ │
│ No Products Found for Comparison │
│ │
│ We couldn't find other retailers selling this product. │
│ This could mean it's exclusive to one retailer or │
│ new to the market. │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ 🔔 Get Price Alert│ │ 📧 Suggest Retailer│ │
│ └──────────────────┘ └──────────────────┘ │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ 🔙 Back to Search │ │
│ └────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Mobile
┌───────────────────────────┐
│ │
│ ┌─────────┐ │
│ │🔍 No match│ │
│ └─────────┘ │
│ │
│ No Products Found │
│ │
│ We couldn't find other │
│ retailers for this. │
│ │
│ ┌──────────────────────┐ │
│ │ 🔔 Price Alert │ │
│ └──────────────────────┘ │
│ ┌──────────────────────┐ │
│ │ 📧 Suggest Retailer │ │
│ └──────────────────────┘ │
│ ┌──────────────────────┐ │
│ │ 🔙 Back to Search │ │
│ └──────────────────────┘ │
└───────────────────────────┘
Implementation
- New component:
CompareEmptyState.tsx - Props:
productName,onGetAlert,onSuggestRetailer,onBack - All actions optional; render only if provided
- Full-width container, vertically centered
3. Partial Data State
When
products.length > 0 but products.length < selectCount (expected N but got fewer).
Visual Pattern
- Show available products in selection grid
- Display amber info banner above grid
- Disable selection controls that exceed available count
Desktop
┌─────────────────────────────────────────────────────────────┐
│ Compare Products │
│ [2 Products] [3 Products] [4 Products] ← disabled pills │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ℹ️ Only 2 retailers available for comparison. │ │
│ │ Showing all available options. │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────┐ ┌─────────┐ │
│ │ IMAGE │ │ IMAGE │ │
│ │ Name │ │ Name │ │
│ │ $PRICE │ │ $PRICE │ │
│ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────────────────────┘
Mobile
┌───────────────────────────┐
│ Compare Products [2][3][4]│
│ ← 3 & 4 disabled │
│ │
│ ℹ️ Only 2 available │
│ │
│ ┌───────┐ ┌───────┐ │
│ │ IMAGE │ │ IMAGE │ │
│ │ Name │ │ Name │ │
│ └───────┘ └───────┘ │
└───────────────────────────┘
Implementation
- Add
isPartialprop toProductComparison - Compute
isPartial = products.length > 0 && products.length < selectCount - Amber banner using
data-state="partial"attribute - Count pills 3 and 4:
aria-disabled="true"+ reduced opacity
4. Stale Data State
When
- Any
CompareMatch.data_freshness === "stale"or"very_stale" - OR
CompareResponse.meta?.stale_count > 0 - OR
CompareResponse.meta?.very_stale_count > 0
Visual Pattern
- Amber warning banner at top of comparison table
- Show "as of" timestamp per retailer if available
- "Refresh" action to re-fetch data
Desktop
┌─────────────────────────────────────────────────────────────┐
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ⚠️ Price data may be outdated. Last updated 8 days │ │
│ │ ago for some retailers. [Refresh Prices] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ Compare Products │
│ [2 Products] [3 Products] [4 Products] │
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ Product │ │ Product │ │ Product │ │ Product │ │
│ │ $PRICE │ │ $PRICE │ │ $PRICE │ │ $PRICE │ │
│ │ ✓ fresh │ │ ⚠️ 8d │ │ ⚠️ 12d │ │ ✓ fresh │ │
│ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────────────────────┘
Freshness Badges
| Tier | data_freshness | Badge | Color |
|---|---|---|---|
| Fresh | "fresh" (<24h) | None (default) | — |
| Recent | "recent" (24h-7d) | None or subtle | — |
| Stale | "stale" (7-30d) | ⚠️ Xd | Amber |
| Very Stale | "very_stale" (>30d) | ⚠️ Xd | Red |
Mobile
┌───────────────────────────┐
│ ⚠️ Prices may be outdated │
│ Updated 8 days ago. │
│ [Refresh] │
│ │
│ Compare Products │
│ [2] [3] [4] │
│ │
│ ┌───────┐ ┌───────┐ │
│ │ ✓ │ │ ⚠️ 8d│ │
│ │ $price│ │ $price│ │
│ └───────┘ └───────┘ │
└───────────────────────────┘
Implementation
- New component:
StaleDataBanner.tsx - Props:
staleCount,veryStaleCount,oldestTimestamp,onRefresh - Add
dataFreshnessbadge to eachCompareMatchrow onRefreshcallback triggers re-fetch of compare API
5. Error State
When
API returns error or network failure.
Implementation
- Use existing
ErrorStatecomponent - Pass
onRetrycallback to re-fetch
Component Inventory
| Component | File | Purpose |
|---|---|---|
ProductComparison | ProductComparison.tsx | Main container, handles state logic |
ProductComparisonSkeleton | ProductComparisonSkeleton.tsx | Loading skeleton (new) |
CompareEmptyState | CompareEmptyState.tsx | Empty/no matches state (new) |
StaleDataBanner | StaleDataBanner.tsx | Stale data warning (new) |
FreshnessBadge | FreshnessBadge.tsx | Per-retailer freshness indicator (new) |
LoadingState | LoadingState.tsx | Existing, reuse |
ErrorState | ErrorState.tsx | Existing, reuse |
Mobile / Desktop Behavior
| Behavior | Mobile (<640px) | Desktop (≥640px) |
|---|---|---|
| Selection grid | 2 columns fixed | repeat(selectCount, 1fr) |
| Table scroll | Horizontal scroll enabled | Full width |
| Stale banner | Full-width, stacked | Full-width |
| Empty illustration | 120px centered | 160px centered |
| Action buttons | Full-width stacked | Inline/horizontal |
| Freshness badge | Below price | Right of price |
API Contract (Frontend Handoff)
Query Parameters
GET /compare/{product_id}
?min_price=<float>
&max_price=<float>
Response Fields Used
interface CompareResponse {
source_product_id: number;
source_product_name: string;
matches: CompareMatch[]; // products for comparison
total_matches: number; // total available
highlights: CompareHighlights;
meta?: {
stale_count?: number; // count of stale items
very_stale_count?: number; // count of very stale items
price_coverage_pct?: number; // market coverage %
};
}
interface CompareMatch {
// ... existing fields
data_freshness?: "fresh" | "recent" | "stale" | "very_stale";
last_checked?: string; // ISO timestamp
}
Staleness Detection Logic (Frontend)
const STALE_THRESHOLD_DAYS = 7;
const VERY_STALE_THRESHOLD_DAYS = 30;
function getDataFreshness(lastChecked: string | null): DataFreshness {
if (!lastChecked) return "unknown";
const daysSince = (Date.now() - new Date(lastChecked).getTime()) / 86400000;
if (daysSince <= 1) return "fresh";
if (daysSince <= 7) return "recent";
if (daysSince <= 30) return "stale";
return "very_stale";
}
Implementation Notes
- State Machine:
ProductComparisonshould follow:idle → loading → success/error/partial/stale - Accessibility: All banners need
role="alert"andaria-live="polite" - Animations: Use CSS transitions for state changes (200ms ease)
- Refresh behavior:
onRefreshshould preserve current selection - Partial selection: When
products.length < selectCount, auto-select all available - Caching: Consider
stale-while-revalidatepattern for better UX