Complete Next.js migration with SSR and Docker deployment
- Migrate from vanilla JavaScript SPA to Next.js 16 with App Router - Add server-side rendering for all pages (Home, Compare, Rankings) - Create individual school pages with dynamic routing (/school/[urn]) - Implement Chart.js and Leaflet map integrations - Add comprehensive SEO with sitemap, robots.txt, and JSON-LD - Set up Docker multi-service architecture (PostgreSQL, FastAPI, Next.js) - Update CI/CD pipeline to build both backend and frontend images - Fix Dockerfile to include devDependencies for TypeScript compilation - Add Jest testing configuration - Implement performance optimizations (code splitting, caching) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
335
nextjs-app/lib/api.ts
Normal file
335
nextjs-app/lib/api.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* API Client for SchoolCompare FastAPI Backend
|
||||
* Handles all data fetching from the API with proper error handling and caching
|
||||
*/
|
||||
|
||||
import type {
|
||||
SchoolsResponse,
|
||||
SchoolDetailsResponse,
|
||||
ComparisonResponse,
|
||||
RankingsResponse,
|
||||
FiltersResponse,
|
||||
MetricsResponse,
|
||||
DataInfoResponse,
|
||||
SchoolSearchParams,
|
||||
RankingsParams,
|
||||
APIError,
|
||||
} from './types';
|
||||
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api';
|
||||
|
||||
// Cache configuration for server-side fetching (Next.js revalidate)
|
||||
export const CACHE_DURATION = {
|
||||
FILTERS: 3600, // 1 hour
|
||||
METRICS: 3600, // 1 hour
|
||||
SCHOOLS_LIST: 60, // 1 minute
|
||||
SCHOOL_DETAILS: 300, // 5 minutes
|
||||
RANKINGS: 300, // 5 minutes
|
||||
COMPARISON: 60, // 1 minute
|
||||
DATA_INFO: 600, // 10 minutes
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Error Handling
|
||||
// ============================================================================
|
||||
|
||||
export class APIFetchError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status?: number,
|
||||
public detail?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'APIFetchError';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let errorDetail = `HTTP ${response.status}: ${response.statusText}`;
|
||||
|
||||
try {
|
||||
const errorData: APIError = await response.json();
|
||||
errorDetail = errorData.detail || errorDetail;
|
||||
} catch {
|
||||
// If parsing JSON fails, use the default error
|
||||
}
|
||||
|
||||
throw new APIFetchError(
|
||||
`API request failed: ${errorDetail}`,
|
||||
response.status,
|
||||
errorDetail
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
function buildQueryString(params: Record<string, any>): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
return searchParams.toString();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// School APIs
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch schools with optional search and filters
|
||||
* Supports both server-side (SSR) and client-side fetching
|
||||
*/
|
||||
export async function fetchSchools(
|
||||
params: SchoolSearchParams = {},
|
||||
options: RequestInit = {}
|
||||
): Promise<SchoolsResponse> {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `${API_BASE_URL}/schools${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
next: {
|
||||
revalidate: CACHE_DURATION.SCHOOLS_LIST,
|
||||
...options.next,
|
||||
},
|
||||
});
|
||||
|
||||
return handleResponse<SchoolsResponse>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch detailed information for a specific school by URN
|
||||
*/
|
||||
export async function fetchSchoolDetails(
|
||||
urn: number,
|
||||
options: RequestInit = {}
|
||||
): Promise<SchoolDetailsResponse> {
|
||||
const url = `${API_BASE_URL}/schools/${urn}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
next: {
|
||||
revalidate: CACHE_DURATION.SCHOOL_DETAILS,
|
||||
...options.next,
|
||||
},
|
||||
});
|
||||
|
||||
return handleResponse<SchoolDetailsResponse>(response);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Comparison APIs
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch comparison data for multiple schools
|
||||
* @param urns - Comma-separated URNs or array of URNs
|
||||
*/
|
||||
export async function fetchComparison(
|
||||
urns: string | number[],
|
||||
options: RequestInit = {}
|
||||
): Promise<ComparisonResponse> {
|
||||
const urnsString = Array.isArray(urns) ? urns.join(',') : urns;
|
||||
const url = `${API_BASE_URL}/compare?urns=${urnsString}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
next: {
|
||||
revalidate: CACHE_DURATION.COMPARISON,
|
||||
...options.next,
|
||||
},
|
||||
});
|
||||
|
||||
return handleResponse<ComparisonResponse>(response);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Rankings APIs
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch school rankings by metric
|
||||
*/
|
||||
export async function fetchRankings(
|
||||
params: RankingsParams,
|
||||
options: RequestInit = {}
|
||||
): Promise<RankingsResponse> {
|
||||
const queryString = buildQueryString(params);
|
||||
const url = `${API_BASE_URL}/rankings?${queryString}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
next: {
|
||||
revalidate: CACHE_DURATION.RANKINGS,
|
||||
...options.next,
|
||||
},
|
||||
});
|
||||
|
||||
return handleResponse<RankingsResponse>(response);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Filter & Metadata APIs
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Fetch available filter options (local authorities, school types, years)
|
||||
*/
|
||||
export async function fetchFilters(
|
||||
options: RequestInit = {}
|
||||
): Promise<FiltersResponse> {
|
||||
const url = `${API_BASE_URL}/filters`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
next: {
|
||||
revalidate: CACHE_DURATION.FILTERS,
|
||||
...options.next,
|
||||
},
|
||||
});
|
||||
|
||||
return handleResponse<FiltersResponse>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch metric definitions (labels, descriptions, formats)
|
||||
*/
|
||||
export async function fetchMetrics(
|
||||
options: RequestInit = {}
|
||||
): Promise<MetricsResponse> {
|
||||
const url = `${API_BASE_URL}/metrics`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
next: {
|
||||
revalidate: CACHE_DURATION.METRICS,
|
||||
...options.next,
|
||||
},
|
||||
});
|
||||
|
||||
return handleResponse<MetricsResponse>(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch database statistics and info
|
||||
*/
|
||||
export async function fetchDataInfo(
|
||||
options: RequestInit = {}
|
||||
): Promise<DataInfoResponse> {
|
||||
const url = `${API_BASE_URL}/data-info`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
next: {
|
||||
revalidate: CACHE_DURATION.DATA_INFO,
|
||||
...options.next,
|
||||
},
|
||||
});
|
||||
|
||||
return handleResponse<DataInfoResponse>(response);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Client-Side Fetcher (for SWR)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generic fetcher function for use with SWR
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data, error } = useSWR('/api/schools', fetcher);
|
||||
* ```
|
||||
*/
|
||||
export async function fetcher<T>(url: string): Promise<T> {
|
||||
const fullUrl = url.startsWith('http') ? url : `${API_BASE_URL}${url.startsWith('/') ? url.slice(4) : url}`;
|
||||
|
||||
const response = await fetch(fullUrl);
|
||||
return handleResponse<T>(response);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Geocoding API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Geocode a UK postcode using postcodes.io
|
||||
*/
|
||||
export async function geocodePostcode(postcode: string): Promise<{
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
} | null> {
|
||||
try {
|
||||
const cleanPostcode = postcode.trim().toUpperCase();
|
||||
const response = await fetch(
|
||||
`https://api.postcodes.io/postcodes/${encodeURIComponent(cleanPostcode)}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.result) {
|
||||
return {
|
||||
latitude: data.result.latitude,
|
||||
longitude: data.result.longitude,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Geocoding error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Calculate distance between two coordinates using Haversine formula
|
||||
* @returns Distance in kilometers
|
||||
*/
|
||||
export function calculateDistance(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const R = 6371; // Earth's radius in kilometers
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert kilometers to miles
|
||||
*/
|
||||
export function kmToMiles(km: number): number {
|
||||
return km * 0.621371;
|
||||
}
|
||||
Reference in New Issue
Block a user