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
- Quick Start
- API Overview
- Installation
- TypeScript Types
- React Integration
- Component Examples
- Error Handling
- Caching Strategy
- Performance Tips
- 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 axiosBasic 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
| Endpoint | Method | Purpose | Cache TTL |
|---|---|---|---|
/api/health/media/coverage | GET | Overall media statistics | 5 min |
/api/health/media/distribution | GET | Image type breakdown | 15 min |
/api/health/media/gaps | GET | Parts needing enrichment | 10 min |
/api/health/media/cache/stats | GET | Cache statistics | Real-time |
/api/health/media/cache/invalidate | POST | Clear cache | N/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 > {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.tsEnvironment 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
- API Documentation: http://localhost:3005/docs
- OpenAPI Spec: http://localhost:3005/api/openapi.json
- Backend Team: [Your contact info]
- Frontend Examples:
/docs/examples/
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