CROP
ProjectsParts Services

Media Coverage API - Frontend Integration Guide

Version: 1.0.0 Last Updated: 2025-11-17 Target Audience: Frontend developers integrating Media Coverage API into CROP Admin Dashboard

Media Coverage API - Frontend Integration Guide

Version: 1.0.0 Last Updated: 2025-11-17 Target Audience: Frontend developers integrating Media Coverage API into CROP Admin Dashboard


Table of Contents

  1. Quick Start
  2. API Overview
  3. Installation
  4. TypeScript Types
  5. React Integration
  6. Component Examples
  7. Error Handling
  8. Caching Strategy
  9. Performance Tips
  10. Troubleshooting

Quick Start

Installation

# Using npm
npm install @tanstack/react-query axios

# Using yarn
yarn add @tanstack/react-query axios

# Using bun
bun add @tanstack/react-query axios

Basic Setup

// src/main.tsx or src/App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5 minutes - matches server cache
      cacheTime: 10 * 60 * 1000, // 10 minutes
      refetchOnWindowFocus: false,
      retry: 2,
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MediaDashboard />
    </QueryClientProvider>
  );
}

export default App;

API Overview

Base URL

const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3005';

Available Endpoints

EndpointMethodPurposeCache TTL
/api/health/media/coverageGETOverall media statistics5 min
/api/health/media/distributionGETImage type breakdown15 min
/api/health/media/gapsGETParts needing enrichment10 min
/api/health/media/cache/statsGETCache statisticsReal-time
/api/health/media/cache/invalidatePOSTClear cacheN/A

Common Query Parameters

All endpoints support:

  • environment (string): Environment to query (prod, dev, stage)
  • nocache (string): Set to '1' or 'true' to bypass cache

TypeScript Types

Response Wrapper Types

/**
 * Standard API success response
 */
interface ApiSuccessResponse<T> {
  success: true;
  data: T;
  meta: {
    environment: string;
    fromCache: boolean;
    timestamp: string;
    [key: string]: any;
  };
}

/**
 * Standard API error response
 */
interface ApiErrorResponse {
  success: false;
  error: {
    message: string;
    details?: string | object;
    code?: string;
  };
}

/**
 * Union type for all API responses
 */
type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;

Coverage Summary Types

/**
 * Generic coverage statistics
 */
interface CoverageStats {
  count: number;
  percentage: number;
}

/**
 * Gallery image coverage statistics
 */
interface GalleryCoverageStats {
  withGallery: CoverageStats;
  withoutGallery: CoverageStats;
  averageGallerySize: number;
  maxGallerySize: number;
}

/**
 * 360° view coverage statistics
 */
interface View360CoverageStats {
  with360: CoverageStats;
  without360: CoverageStats;
}

/**
 * Combined image coverage statistics
 */
interface ImageCoverageStats {
  gallery: GalleryCoverageStats;
  view360: View360CoverageStats;
  withAnyMedia: CoverageStats;
  withBothTypes: CoverageStats;
  withNoMedia: CoverageStats;
}

/**
 * PDF document type coverage
 */
interface PdfTypeCoverage {
  type: string;
  coverage: CoverageStats;
}

/**
 * Document coverage statistics
 */
interface DocumentCoverageStats {
  overall: CoverageStats;
  byType: PdfTypeCoverage[];
  averagePerPart: number;
  maxPerPart: number;
}

/**
 * Complete media coverage report
 */
interface MediaCoverageReport {
  summary: {
    totalParts: number;
    analyzedAt: string;
    environment: string;
    collection: string;
  };
  images: ImageCoverageStats;
  documents: DocumentCoverageStats;
  qualityCorrelation: {
    with360Score: number;
    without360Score: number;
    scoreDifference: number;
  };
  metadata: {
    generatedAt: string;
    durationMs: number;
    fromCache: boolean;
  };
}

Distribution Types

/**
 * Image type distribution category
 */
interface ImageDistributionCategory {
  category: string;
  count: number;
  percentage: number;
  description: string;
}

/**
 * Image type distribution report
 */
interface ImageTypeDistribution {
  categories: ImageDistributionCategory[];
  totalParts: number;
  mostCommon: {
    category: string;
    count: number;
    percentage: number;
  };
  metadata: {
    generatedAt: string;
    durationMs: number;
    fromCache: boolean;
  };
}

Media Gaps Types

/**
 * Part with missing media
 */
interface MediaGapPart {
  _id: string;
  partNumber: string;
  title: string;
  media: {
    galleryCount: number;
    has360: boolean;
    pdfCount: number;
  };
  qualityScore: number;
  missingMedia: string[];
}

/**
 * Media gaps report
 */
interface MediaGapsReport {
  summary: {
    totalGaps: number;
    minQualityThreshold: number;
    limit: number;
  };
  parts: MediaGapPart[];
  gapBreakdown: {
    missingGallery: number;
    missing360: number;
    missingPdfs: number;
    missingAll: number;
  };
  metadata: {
    generatedAt: string;
    durationMs: number;
    fromCache: boolean;
  };
}

React Integration

Custom Hooks

Create a hooks file for API calls:

// src/hooks/useMediaApi.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';

const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3005';

const api = axios.create({
  baseURL: API_BASE,
  headers: {
    'Content-Type': 'application/json',
  },
});

/**
 * Hook for fetching media coverage summary
 */
export function useMediaCoverage(environment = 'prod', nocache = false) {
  return useQuery({
    queryKey: ['media-coverage', environment, nocache],
    queryFn: async () => {
      const params = new URLSearchParams({ environment });
      if (nocache) params.append('nocache', '1');

      const { data } = await api.get<ApiSuccessResponse<MediaCoverageReport>>(
        `/api/health/media/coverage?${params}`
      );

      if (!data.success) {
        throw new Error('Failed to fetch media coverage');
      }

      return data;
    },
    staleTime: 5 * 60 * 1000, // 5 minutes
    retry: 2,
    refetchOnWindowFocus: false,
  });
}

/**
 * Hook for fetching image type distribution
 */
export function useMediaDistribution(environment = 'prod') {
  return useQuery({
    queryKey: ['media-distribution', environment],
    queryFn: async () => {
      const { data } = await api.get<ApiSuccessResponse<ImageTypeDistribution>>(
        `/api/health/media/distribution?environment=${environment}`
      );

      if (!data.success) {
        throw new Error('Failed to fetch distribution');
      }

      return data;
    },
    staleTime: 15 * 60 * 1000, // 15 minutes
    retry: 2,
  });
}

/**
 * Hook for fetching media gaps
 */
export function useMediaGaps(options: {
  minQuality?: number;
  limit?: number;
  environment?: string;
} = {}) {
  const { minQuality = 60, limit = 100, environment = 'prod' } = options;

  return useQuery({
    queryKey: ['media-gaps', minQuality, limit, environment],
    queryFn: async () => {
      const params = new URLSearchParams({
        minQuality: minQuality.toString(),
        limit: limit.toString(),
        environment,
      });

      const { data } = await api.get<ApiSuccessResponse<MediaGapsReport>>(
        `/api/health/media/gaps?${params}`
      );

      if (!data.success) {
        throw new Error('Failed to fetch media gaps');
      }

      return data;
    },
    staleTime: 10 * 60 * 1000, // 10 minutes
    retry: 2,
  });
}

/**
 * Hook for cache invalidation
 */
export function useInvalidateMediaCache() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (pattern?: string) => {
      await api.post('/api/health/media/cache/invalidate', { pattern });
    },
    onSuccess: () => {
      // Invalidate all media queries
      queryClient.invalidateQueries({ queryKey: ['media-coverage'] });
      queryClient.invalidateQueries({ queryKey: ['media-distribution'] });
      queryClient.invalidateQueries({ queryKey: ['media-gaps'] });
    },
  });
}

Component Examples

Dashboard Overview Component

// src/components/MediaDashboard.tsx
import { useMediaCoverage } from '@/hooks/useMediaApi';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { ErrorAlert } from '@/components/ui/ErrorAlert';
import { StatCard } from '@/components/ui/StatCard';
import { PackageIcon, CameraIcon, ImageIcon, FileTextIcon } from 'lucide-react';

interface MediaDashboardProps {
  environment?: string;
}

export function MediaDashboard({ environment = 'prod' }: MediaDashboardProps) {
  const { data, isLoading, error, isFetching } = useMediaCoverage(environment);

  if (isLoading) {
    return (
      <div className="flex items-center justify-center min-h-[400px]">
        <LoadingSpinner size="lg" />
      </div>
    );
  }

  if (error) {
    return (
      <ErrorAlert
        title="Failed to load media coverage"
        message={error.message}
      />
    );
  }

  if (!data) return null;

  const { summary, images, documents, qualityCorrelation } = data.data;

  return (
    <div className="space-y-6">
      {/* Header */}
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-3xl font-bold">Media Coverage Dashboard</h1>
          <p className="text-muted-foreground">
            Environment: {summary.environment} |
            Last updated: {new Date(data.meta.timestamp).toLocaleString()}
            {isFetching && ' (Refreshing...)'}
          </p>
        </div>

        {data.meta.fromCache && (
          <span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
            Cached
          </span>
        )}
      </div>

      {/* Summary Stats */}
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
        <StatCard
          title="Total Parts"
          value={summary.totalParts.toLocaleString()}
          icon={<PackageIcon className="w-5 h-5" />}
          variant="default"
        />

        <StatCard
          title="With 360° View"
          value={images.view360.with360.count.toLocaleString()}
          subtitle={`${images.view360.with360.percentage.toFixed(1)}% coverage`}
          icon={<CameraIcon className="w-5 h-5" />}
          variant="primary"
        />

        <StatCard
          title="With Gallery Images"
          value={images.gallery.withGallery.count.toLocaleString()}
          subtitle={`Avg ${images.gallery.averageGallerySize.toFixed(1)} images/part`}
          icon={<ImageIcon className="w-5 h-5" />}
          variant="success"
        />

        <StatCard
          title="With Documents"
          value={documents.overall.count.toLocaleString()}
          subtitle={`Avg ${documents.averagePerPart.toFixed(1)} PDFs/part`}
          icon={<FileTextIcon className="w-5 h-5" />}
          variant="info"
        />
      </div>

      {/* Quality Correlation */}
      <div className="bg-gradient-to-r from-purple-50 to-blue-50 border border-purple-200 rounded-lg p-6">
        <h3 className="text-lg font-semibold mb-2">Media Quality Impact</h3>
        <div className="grid grid-cols-3 gap-4 text-center">
          <div>
            <div className="text-2xl font-bold text-purple-600">
              {qualityCorrelation.with360Score.toFixed(1)}
            </div>
            <div className="text-sm text-gray-600">Avg Quality with 360°</div>
          </div>
          <div>
            <div className="text-2xl font-bold text-gray-600">
              {qualityCorrelation.without360Score.toFixed(1)}
            </div>
            <div className="text-sm text-gray-600">Avg Quality without 360°</div>
          </div>
          <div>
            <div className="text-2xl font-bold text-green-600">
              +{qualityCorrelation.scoreDifference.toFixed(1)}
            </div>
            <div className="text-sm text-gray-600">Quality Boost</div>
          </div>
        </div>
        <p className="text-sm text-gray-600 mt-4 text-center">
          Parts with 360° views have {qualityCorrelation.scoreDifference.toFixed(1)} points
          higher quality scores on average
        </p>
      </div>

      {/* Media Coverage Breakdown */}
      <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
        <MediaCoverageCard
          title="Image Coverage"
          stats={[
            { label: 'Any Media', count: images.withAnyMedia.count, percentage: images.withAnyMedia.percentage },
            { label: 'Both Types', count: images.withBothTypes.count, percentage: images.withBothTypes.percentage },
            { label: 'No Media', count: images.withNoMedia.count, percentage: images.withNoMedia.percentage, variant: 'warning' },
          ]}
        />

        <MediaCoverageCard
          title="Document Types"
          stats={documents.byType.map(type => ({
            label: type.type,
            count: type.coverage.count,
            percentage: type.coverage.percentage,
          }))}
        />
      </div>
    </div>
  );
}

/**
 * Reusable stat card component
 */
interface MediaCoverageCardProps {
  title: string;
  stats: Array<{
    label: string;
    count: number;
    percentage: number;
    variant?: 'default' | 'warning';
  }>;
}

function MediaCoverageCard({ title, stats }: MediaCoverageCardProps) {
  return (
    <div className="bg-white border rounded-lg p-6">
      <h3 className="text-lg font-semibold mb-4">{title}</h3>
      <div className="space-y-3">
        {stats.map((stat, index) => (
          <div key={index}>
            <div className="flex justify-between items-center mb-1">
              <span className="text-sm font-medium">{stat.label}</span>
              <span className="text-sm text-gray-600">{stat.count.toLocaleString()}</span>
            </div>
            <div className="relative h-2 bg-gray-100 rounded-full overflow-hidden">
              <div
                className={`absolute h-full rounded-full transition-all ${
                  stat.variant === 'warning' ? 'bg-orange-500' : 'bg-blue-500'
                }`}
                style={{ width: `${stat.percentage}%` }}
              />
            </div>
            <div className="text-xs text-gray-500 mt-1">{stat.percentage.toFixed(1)}%</div>
          </div>
        ))}
      </div>
    </div>
  );
}

Distribution Chart Component

// src/components/MediaDistributionChart.tsx
import { useMediaDistribution } from '@/hooks/useMediaApi';
import { PieChart, Pie, Cell, Legend, Tooltip, ResponsiveContainer } from 'recharts';

const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];

interface MediaDistributionChartProps {
  environment?: string;
}

export function MediaDistributionChart({ environment = 'prod' }: MediaDistributionChartProps) {
  const { data, isLoading, error } = useMediaDistribution(environment);

  if (isLoading) {
    return (
      <div className="flex items-center justify-center h-[400px]">
        <LoadingSpinner />
      </div>
    );
  }

  if (error || !data) {
    return <ErrorAlert message="Failed to load distribution data" />;
  }

  const chartData = data.data.categories.map(cat => ({
    name: cat.description,
    value: cat.count,
    percentage: cat.percentage,
    category: cat.category,
  }));

  return (
    <div className="bg-white border rounded-lg p-6">
      <h3 className="text-lg font-semibold mb-4">Image Type Distribution</h3>

      <ResponsiveContainer width="100%" height={400}>
        <PieChart>
          <Pie
            data={chartData}
            dataKey="value"
            nameKey="name"
            cx="50%"
            cy="50%"
            outerRadius={120}
            label={({ percentage }) => `${percentage.toFixed(1)}%`}
            labelLine={true}
          >
            {chartData.map((entry, index) => (
              <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
            ))}
          </Pie>
          <Tooltip
            formatter={(value: number) => value.toLocaleString()}
            labelFormatter={(label) => `${label}`}
          />
          <Legend
            verticalAlign="bottom"
            height={36}
            formatter={(value, entry: any) => `${value} (${entry.payload.value.toLocaleString()})`}
          />
        </PieChart>
      </ResponsiveContainer>

      {/* Most Common */}
      <div className="mt-4 p-4 bg-blue-50 rounded-lg">
        <div className="text-sm font-medium text-blue-900">Most Common Category</div>
        <div className="text-2xl font-bold text-blue-700">
          {data.data.mostCommon.category}
        </div>
        <div className="text-sm text-blue-600">
          {data.data.mostCommon.count.toLocaleString()} parts
          ({data.data.mostCommon.percentage.toFixed(1)}%)
        </div>
      </div>
    </div>
  );
}

Media Gaps Table Component

// src/components/MediaGapsTable.tsx
import { useState } from 'react';
import { useMediaGaps } from '@/hooks/useMediaApi';
import { CSVLink } from 'react-csv';
import { DownloadIcon, ExternalLinkIcon } from 'lucide-react';

interface MediaGapsTableProps {
  environment?: string;
  onEditPart?: (partId: string) => void;
}

export function MediaGapsTable({
  environment = 'prod',
  onEditPart
}: MediaGapsTableProps) {
  const [minQuality, setMinQuality] = useState(60);
  const [limit, setLimit] = useState(100);

  const { data, isLoading, error } = useMediaGaps({
    minQuality,
    limit,
    environment
  });

  if (isLoading) {
    return <LoadingSpinner />;
  }

  if (error || !data) {
    return <ErrorAlert message="Failed to load media gaps" />;
  }

  const { summary, parts, gapBreakdown } = data.data;

  // Prepare CSV data
  const csvData = parts.map(part => ({
    'Part Number': part.partNumber,
    'Title': part.title,
    'Quality Score': part.qualityScore,
    'Gallery Count': part.media.galleryCount,
    'Has 360°': part.media.has360 ? 'Yes' : 'No',
    'PDF Count': part.media.pdfCount,
    'Missing Media': part.missingMedia.join(', '),
  }));

  return (
    <div className="bg-white border rounded-lg">
      {/* Header */}
      <div className="p-6 border-b">
        <div className="flex items-center justify-between mb-4">
          <div>
            <h3 className="text-lg font-semibold">Parts Needing Media Enrichment</h3>
            <p className="text-sm text-gray-600">
              {summary.totalGaps.toLocaleString()} parts with quality score &gt; {summary.minQualityThreshold}
            </p>
          </div>

          <CSVLink
            data={csvData}
            filename={`media-gaps-${environment}-${new Date().toISOString()}.csv`}
            className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
          >
            <DownloadIcon className="w-4 h-4" />
            Export CSV
          </CSVLink>
        </div>

        {/* Filters */}
        <div className="flex gap-4">
          <div>
            <label className="block text-sm font-medium mb-1">
              Min Quality Score
            </label>
            <input
              type="number"
              min="0"
              max="100"
              value={minQuality}
              onChange={(e) => setMinQuality(Number(e.target.value))}
              className="px-3 py-2 border rounded-lg w-24"
            />
          </div>

          <div>
            <label className="block text-sm font-medium mb-1">
              Limit
            </label>
            <select
              value={limit}
              onChange={(e) => setLimit(Number(e.target.value))}
              className="px-3 py-2 border rounded-lg"
            >
              <option value="50">50</option>
              <option value="100">100</option>
              <option value="250">250</option>
              <option value="500">500</option>
            </select>
          </div>
        </div>

        {/* Gap Breakdown */}
        <div className="grid grid-cols-4 gap-4 mt-4">
          <div className="p-3 bg-orange-50 rounded-lg">
            <div className="text-xs text-orange-600 font-medium">Missing Gallery</div>
            <div className="text-2xl font-bold text-orange-700">
              {gapBreakdown.missingGallery.toLocaleString()}
            </div>
          </div>
          <div className="p-3 bg-red-50 rounded-lg">
            <div className="text-xs text-red-600 font-medium">Missing 360°</div>
            <div className="text-2xl font-bold text-red-700">
              {gapBreakdown.missing360.toLocaleString()}
            </div>
          </div>
          <div className="p-3 bg-yellow-50 rounded-lg">
            <div className="text-xs text-yellow-600 font-medium">Missing PDFs</div>
            <div className="text-2xl font-bold text-yellow-700">
              {gapBreakdown.missingPdfs.toLocaleString()}
            </div>
          </div>
          <div className="p-3 bg-gray-50 rounded-lg">
            <div className="text-xs text-gray-600 font-medium">Missing All</div>
            <div className="text-2xl font-bold text-gray-700">
              {gapBreakdown.missingAll.toLocaleString()}
            </div>
          </div>
        </div>
      </div>

      {/* Table */}
      <div className="overflow-x-auto">
        <table className="w-full">
          <thead className="bg-gray-50 border-b">
            <tr>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Part Number
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Title
              </th>
              <th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
                Quality
              </th>
              <th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
                Gallery
              </th>
              <th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
                360°
              </th>
              <th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
                PDFs
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
                Missing
              </th>
              <th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
                Actions
              </th>
            </tr>
          </thead>
          <tbody className="bg-white divide-y divide-gray-200">
            {parts.map((part) => (
              <tr key={part._id} className="hover:bg-gray-50">
                <td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
                  {part.partNumber}
                </td>
                <td className="px-6 py-4 text-sm text-gray-700 max-w-xs truncate">
                  {part.title}
                </td>
                <td className="px-6 py-4 whitespace-nowrap text-center">
                  <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
                    part.qualityScore >= 80 ? 'bg-green-100 text-green-800' :
                    part.qualityScore >= 60 ? 'bg-yellow-100 text-yellow-800' :
                    'bg-red-100 text-red-800'
                  }`}>
                    {part.qualityScore}
                  </span>
                </td>
                <td className="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-700">
                  {part.media.galleryCount || <span className="text-red-500">0</span>}
                </td>
                <td className="px-6 py-4 whitespace-nowrap text-center">
                  {part.media.has360 ? (
                    <span className="text-green-500"></span>
                  ) : (
                    <span className="text-red-500"></span>
                  )}
                </td>
                <td className="px-6 py-4 whitespace-nowrap text-center text-sm text-gray-700">
                  {part.media.pdfCount || <span className="text-red-500">0</span>}
                </td>
                <td className="px-6 py-4 text-sm text-gray-500">
                  <div className="flex flex-wrap gap-1">
                    {part.missingMedia.map((missing, idx) => (
                      <span
                        key={idx}
                        className="inline-flex items-center px-2 py-0.5 rounded text-xs bg-orange-100 text-orange-800"
                      >
                        {missing}
                      </span>
                    ))}
                  </div>
                </td>
                <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
                  <button
                    onClick={() => onEditPart?.(part._id)}
                    className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-900"
                  >
                    Add Media
                    <ExternalLinkIcon className="w-3 h-3" />
                  </button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {/* Footer */}
      <div className="p-4 bg-gray-50 border-t text-center text-sm text-gray-600">
        Showing {parts.length} of {summary.totalGaps.toLocaleString()} total gaps
      </div>
    </div>
  );
}

Environment Switcher Component

// src/components/EnvironmentSwitcher.tsx
import { useState } from 'react';

type Environment = 'prod' | 'dev' | 'stage';

interface EnvironmentSwitcherProps {
  value: Environment;
  onChange: (env: Environment) => void;
}

export function EnvironmentSwitcher({ value, onChange }: EnvironmentSwitcherProps) {
  return (
    <div className="inline-flex rounded-lg border border-gray-300 bg-white p-1">
      {(['prod', 'dev', 'stage'] as Environment[]).map((env) => (
        <button
          key={env}
          onClick={() => onChange(env)}
          className={`px-4 py-2 text-sm font-medium rounded-md transition ${
            value === env
              ? 'bg-blue-600 text-white'
              : 'text-gray-700 hover:bg-gray-100'
          }`}
        >
          {env.charAt(0).toUpperCase() + env.slice(1)}
        </button>
      ))}
    </div>
  );
}

Error Handling

Global Error Boundary

// src/components/ErrorBoundary.tsx
import { Component, ReactNode } from 'react';

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

interface State {
  hasError: boolean;
  error?: Error;
}

export class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error('Error boundary caught:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="p-8 text-center">
          <h2 className="text-xl font-bold text-red-600 mb-2">
            Something went wrong
          </h2>
          <p className="text-gray-600">
            {this.state.error?.message || 'Unknown error occurred'}
          </p>
        </div>
      );
    }

    return this.props.children;
  }
}

Axios Error Interceptor

// src/lib/axios.ts
import axios from 'axios';

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 30000,
});

// Response interceptor for error handling
api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response) {
      // Server responded with error status
      const { status, data } = error.response;

      if (status === 503) {
        console.error('Database connection failed');
        throw new Error('Service temporarily unavailable. Please try again later.');
      }

      if (status === 400) {
        throw new Error(data.error?.message || 'Invalid request parameters');
      }

      if (status >= 500) {
        throw new Error('Server error. Please try again later.');
      }

      throw new Error(data.error?.message || 'Request failed');
    }

    if (error.request) {
      // Request made but no response
      throw new Error('Network error. Please check your connection.');
    }

    // Something else happened
    throw error;
  }
);

export default api;

Query Error Handling

// src/components/MediaDashboardWithErrorHandling.tsx
import { useMediaCoverage } from '@/hooks/useMediaApi';
import { AlertCircle, RefreshCw } from 'lucide-react';

export function MediaDashboardWithErrorHandling() {
  const { data, error, isError, refetch, isLoading } = useMediaCoverage();

  if (isError) {
    const errorMessage = error instanceof Error ? error.message : 'Unknown error';
    const is503 = errorMessage.includes('503') || errorMessage.includes('unavailable');

    return (
      <div className="p-8 bg-red-50 border border-red-200 rounded-lg">
        <div className="flex items-start gap-3">
          <AlertCircle className="w-6 h-6 text-red-600 flex-shrink-0 mt-0.5" />
          <div className="flex-1">
            <h3 className="text-lg font-semibold text-red-900 mb-1">
              {is503 ? 'Service Temporarily Unavailable' : 'Failed to Load Media Coverage'}
            </h3>
            <p className="text-red-700 mb-4">{errorMessage}</p>
            <button
              onClick={() => refetch()}
              disabled={isLoading}
              className="inline-flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50"
            >
              <RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
              Try Again
            </button>
          </div>
        </div>
      </div>
    );
  }

  // Rest of component...
  return <MediaDashboard data={data} />;
}

Caching Strategy

React Query Configuration

// src/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // Cache for 5 minutes (matches server cache)
      staleTime: 5 * 60 * 1000,

      // Keep unused data for 10 minutes
      cacheTime: 10 * 60 * 1000,

      // Don't refetch on window focus (data changes infrequently)
      refetchOnWindowFocus: false,

      // Retry failed requests twice
      retry: 2,

      // Exponential backoff
      retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
    },
  },
});

Manual Cache Invalidation

// src/components/RefreshButton.tsx
import { useQueryClient } from '@tanstack/react-query';
import { useInvalidateMediaCache } from '@/hooks/useMediaApi';
import { RefreshCw } from 'lucide-react';

export function RefreshButton() {
  const queryClient = useQueryClient();
  const { mutate: invalidateCache, isLoading } = useInvalidateMediaCache();

  const handleRefresh = async () => {
    // Option 1: Invalidate server cache and refetch
    invalidateCache();

    // Option 2: Just refetch with nocache parameter
    // queryClient.refetchQueries({ queryKey: ['media-coverage'], exact: false });
  };

  return (
    <button
      onClick={handleRefresh}
      disabled={isLoading}
      className="inline-flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50"
      title="Refresh all media data"
    >
      <RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
      Refresh
    </button>
  );
}

Cache Debugging

// src/components/CacheDebugger.tsx
import { useQuery } from '@tanstack/react-query';

export function CacheDebugger() {
  const { data } = useQuery({
    queryKey: ['cache-stats'],
    queryFn: async () => {
      const response = await fetch('/api/health/media/cache/stats');
      return response.json();
    },
    refetchInterval: 5000, // Update every 5 seconds
  });

  if (!data) return null;

  return (
    <div className="fixed bottom-4 right-4 bg-black bg-opacity-75 text-white p-4 rounded-lg text-xs font-mono">
      <div className="font-bold mb-2">Cache Stats</div>
      <div>Size: {data.data.size}</div>
      <div>Hits: {data.data.hits}</div>
      <div>Misses: {data.data.misses}</div>
      <div>Hit Rate: {((data.data.hits / (data.data.hits + data.data.misses)) * 100).toFixed(1)}%</div>
    </div>
  );
}

Performance Tips

1. Prefetch Data

// Prefetch on hover
function PartsList() {
  const queryClient = useQueryClient();

  const handleMouseEnter = (environment: string) => {
    queryClient.prefetchQuery({
      queryKey: ['media-coverage', environment],
      queryFn: () => fetchMediaCoverage(environment),
    });
  };

  return (
    <button onMouseEnter={() => handleMouseEnter('prod')}>
      View Production
    </button>
  );
}

2. Optimistic Updates

function useOptimisticMediaUpdate() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: updatePartMedia,
    onMutate: async (newData) => {
      // Cancel outgoing refetches
      await queryClient.cancelQueries({ queryKey: ['media-gaps'] });

      // Snapshot previous value
      const previousGaps = queryClient.getQueryData(['media-gaps']);

      // Optimistically update
      queryClient.setQueryData(['media-gaps'], (old: any) => {
        // Update logic here
        return old;
      });

      return { previousGaps };
    },
    onError: (err, newData, context) => {
      // Rollback on error
      queryClient.setQueryData(['media-gaps'], context?.previousGaps);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['media-gaps'] });
    },
  });
}

3. Pagination for Large Datasets

function useMediaGapsPaginated(pageSize = 50) {
  const [page, setPage] = useState(0);

  const query = useMediaGaps({
    limit: pageSize,
    // In future API: offset: page * pageSize
  });

  return {
    ...query,
    page,
    setPage,
    hasNextPage: query.data?.data.parts.length === pageSize,
  };
}

4. Debounce Filters

import { useDebouncedValue } from '@/hooks/useDebouncedValue';

function MediaGapsTableWithDebounce() {
  const [minQuality, setMinQuality] = useState(60);
  const debouncedMinQuality = useDebouncedValue(minQuality, 500);

  const { data } = useMediaGaps({ minQuality: debouncedMinQuality });

  // Input won't trigger API calls on every keystroke
  return (
    <input
      type="number"
      value={minQuality}
      onChange={(e) => setMinQuality(Number(e.target.value))}
    />
  );
}

Troubleshooting

CORS Errors in Development

If you encounter CORS errors during development:

Option 1: Configure Vite Proxy

// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3005',
        changeOrigin: true,
        secure: false,
      },
    },
  },
});

Option 2: Use package.json proxy (Create React App)

{
  "proxy": "http://localhost:3005"
}

Slow Initial Load

The first request may take up to 500ms as the cache warms up:

function MediaDashboard() {
  const { data, isLoading, isFetching } = useMediaCoverage();

  // Show different messages for initial load vs. background refresh
  if (isLoading && !data) {
    return <LoadingState message="Loading initial data (may take a few seconds)..." />;
  }

  if (isFetching && data) {
    return (
      <div>
        <Dashboard data={data} />
        <div className="text-xs text-gray-500">Refreshing in background...</div>
      </div>
    );
  }

  return <Dashboard data={data} />;
}

Type Generation from OpenAPI

Generate TypeScript types automatically:

# Install generator
npm install -g @openapitools/openapi-generator-cli

# Generate types
openapi-generator-cli generate \
  -i http://localhost:3005/api/openapi.json \
  -g typescript-fetch \
  -o src/api/generated

# Or use openapi-typescript (recommended)
npm install -D openapi-typescript
npx openapi-typescript http://localhost:3005/api/openapi.json -o src/api/schema.d.ts

Environment Variables

# .env.local
VITE_API_URL=http://localhost:3005
VITE_DEFAULT_ENVIRONMENT=prod
VITE_ENABLE_DEBUG=true
// src/config.ts
export const config = {
  apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:3005',
  defaultEnvironment: import.meta.env.VITE_DEFAULT_ENVIRONMENT || 'prod',
  enableDebug: import.meta.env.VITE_ENABLE_DEBUG === 'true',
} as const;

API Reference

Coverage Endpoint

GET /api/health/media/coverage?environment={env}&nocache={0|1}

Response:

{
  success: true,
  data: MediaCoverageReport,
  meta: {
    environment: string,
    fromCache: boolean,
    timestamp: string
  }
}

Distribution Endpoint

GET /api/health/media/distribution?environment={env}

Response:

{
  success: true,
  data: ImageTypeDistribution,
  meta: { ... }
}

Gaps Endpoint

GET /api/health/media/gaps?minQuality={0-100}&limit={1-1000}&environment={env}

Response:

{
  success: true,
  data: MediaGapsReport,
  meta: { ... }
}

Support


Changelog

v1.0.0 (2025-11-17)

  • Initial release
  • Coverage, distribution, and gaps endpoints
  • React hooks and components
  • TypeScript type definitions
  • Error handling and caching strategies

On this page