← Back to documentation

compare-page-states

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

StateTriggerVisual Treatment
Loadingproducts === undefined or fetchingSkeleton placeholders matching selection grid
Emptyproducts.length === 0 after fetchIllustration + contextual message + actions
Partialproducts.length > 0 but < selectCountReduced grid + "Only X retailers" banner
Stale`data_freshness === "stale""very_stale"ORmeta.stale_count > 0`
ErrorAPI error or network failureErrorState 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 LoadingState component with variant="table"
  • Or create CompareSelectionSkeleton component in ProductComparisonSkeleton.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 isPartial prop to ProductComparison
  • 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

Tierdata_freshnessBadgeColor
Fresh"fresh" (<24h)None (default)
Recent"recent" (24h-7d)None or subtle
Stale"stale" (7-30d)⚠️ XdAmber
Very Stale"very_stale" (>30d)⚠️ XdRed

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 dataFreshness badge to each CompareMatch row
  • onRefresh callback triggers re-fetch of compare API

5. Error State

When

API returns error or network failure.

Implementation

  • Use existing ErrorState component
  • Pass onRetry callback to re-fetch

Component Inventory

ComponentFilePurpose
ProductComparisonProductComparison.tsxMain container, handles state logic
ProductComparisonSkeletonProductComparisonSkeleton.tsxLoading skeleton (new)
CompareEmptyStateCompareEmptyState.tsxEmpty/no matches state (new)
StaleDataBannerStaleDataBanner.tsxStale data warning (new)
FreshnessBadgeFreshnessBadge.tsxPer-retailer freshness indicator (new)
LoadingStateLoadingState.tsxExisting, reuse
ErrorStateErrorState.tsxExisting, reuse

Mobile / Desktop Behavior

BehaviorMobile (<640px)Desktop (≥640px)
Selection grid2 columns fixedrepeat(selectCount, 1fr)
Table scrollHorizontal scroll enabledFull width
Stale bannerFull-width, stackedFull-width
Empty illustration120px centered160px centered
Action buttonsFull-width stackedInline/horizontal
Freshness badgeBelow priceRight 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

  1. State Machine: ProductComparison should follow: idle → loading → success/error/partial/stale
  2. Accessibility: All banners need role="alert" and aria-live="polite"
  3. Animations: Use CSS transitions for state changes (200ms ease)
  4. Refresh behavior: onRefresh should preserve current selection
  5. Partial selection: When products.length < selectCount, auto-select all available
  6. Caching: Consider stale-while-revalidate pattern for better UX