Add park detail API and detail page implementation with loading states and error handling

This commit is contained in:
pacnpal
2025-02-23 17:57:52 -05:00
parent 730b165f9c
commit c9ab1f40ed
19 changed files with 1395 additions and 408 deletions

View File

@@ -0,0 +1,55 @@
import Link from 'next/link';
import type { Park } from '@/types/api';
interface ParkCardProps {
park: Park;
}
function getStatusBadgeClass(status: string): string {
const statusClasses = {
OPERATING: 'bg-green-100 text-green-800',
CLOSED_TEMP: 'bg-yellow-100 text-yellow-800',
CLOSED_PERM: 'bg-red-100 text-red-800',
UNDER_CONSTRUCTION: 'bg-blue-100 text-blue-800',
DEMOLISHED: 'bg-gray-100 text-gray-800',
RELOCATED: 'bg-purple-100 text-purple-800'
};
return statusClasses[status as keyof typeof statusClasses] || 'bg-gray-100 text-gray-500';
}
export function ParkCard({ park }: ParkCardProps) {
const statusClass = getStatusBadgeClass(park.status);
const formattedStatus = park.status.replace(/_/g, ' ');
return (
<div className="overflow-hidden transition-transform transform bg-white rounded-lg shadow-lg dark:bg-gray-800 hover:-translate-y-1">
<div className="p-4">
<h2 className="mb-2 text-xl font-bold">
<Link
href={`/parks/${park.slug}`}
className="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
>
{park.name}
</Link>
</h2>
<div className="flex flex-wrap gap-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusClass}`}>
{formattedStatus}
</span>
</div>
{park.owner && (
<div className="mt-4 text-sm">
<Link
href={`/companies/${park.owner.slug}`}
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{park.owner.name}
</Link>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,182 @@
'use client';
import { useState } from 'react';
import type { Company, ParkStatus } from '@/types/api';
const STATUS_OPTIONS = {
OPERATING: 'Operating',
CLOSED_TEMP: 'Temporarily Closed',
CLOSED_PERM: 'Permanently Closed',
UNDER_CONSTRUCTION: 'Under Construction',
DEMOLISHED: 'Demolished',
RELOCATED: 'Relocated'
} as const;
interface ParkFiltersProps {
onFiltersChange: (filters: ParkFilterValues) => void;
companies: Company[];
}
interface ParkFilterValues {
status?: ParkStatus;
ownerId?: string;
hasOwner?: boolean;
minRides?: number;
minCoasters?: number;
minSize?: number;
openingDateStart?: string;
openingDateEnd?: string;
}
export function ParkFilters({ onFiltersChange, companies }: ParkFiltersProps) {
const [filters, setFilters] = useState<ParkFilterValues>({});
const handleFilterChange = (field: keyof ParkFilterValues, value: any) => {
const newFilters = {
...filters,
[field]: value === '' ? undefined : value
};
setFilters(newFilters);
onFiltersChange(newFilters);
};
return (
<div className="bg-white shadow sm:rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium leading-6 text-gray-900">Filters</h3>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* Status Filter */}
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700">
Operating Status
</label>
<select
id="status"
name="status"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.status || ''}
onChange={(e) => handleFilterChange('status', e.target.value)}
>
<option value="">Any status</option>
{Object.entries(STATUS_OPTIONS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
{/* Owner Filter */}
<div>
<label htmlFor="owner" className="block text-sm font-medium text-gray-700">
Operating Company
</label>
<select
id="owner"
name="owner"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.ownerId || ''}
onChange={(e) => handleFilterChange('ownerId', e.target.value)}
>
<option value="">Any company</option>
{companies.map((company) => (
<option key={company.id} value={company.id}>
{company.name}
</option>
))}
</select>
</div>
{/* Has Owner Filter */}
<div>
<label htmlFor="hasOwner" className="block text-sm font-medium text-gray-700">
Company Status
</label>
<select
id="hasOwner"
name="hasOwner"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.hasOwner === undefined ? '' : filters.hasOwner.toString()}
onChange={(e) => handleFilterChange('hasOwner', e.target.value === '' ? undefined : e.target.value === 'true')}
>
<option value="">Show all</option>
<option value="true">Has company</option>
<option value="false">No company</option>
</select>
</div>
{/* Min Rides Filter */}
<div>
<label htmlFor="minRides" className="block text-sm font-medium text-gray-700">
Minimum Rides
</label>
<input
type="number"
id="minRides"
name="minRides"
min="0"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.minRides || ''}
onChange={(e) => handleFilterChange('minRides', e.target.value ? parseInt(e.target.value, 10) : '')}
/>
</div>
{/* Min Coasters Filter */}
<div>
<label htmlFor="minCoasters" className="block text-sm font-medium text-gray-700">
Minimum Roller Coasters
</label>
<input
type="number"
id="minCoasters"
name="minCoasters"
min="0"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.minCoasters || ''}
onChange={(e) => handleFilterChange('minCoasters', e.target.value ? parseInt(e.target.value, 10) : '')}
/>
</div>
{/* Min Size Filter */}
<div>
<label htmlFor="minSize" className="block text-sm font-medium text-gray-700">
Minimum Size (acres)
</label>
<input
type="number"
id="minSize"
name="minSize"
min="0"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.minSize || ''}
onChange={(e) => handleFilterChange('minSize', e.target.value ? parseInt(e.target.value, 10) : '')}
/>
</div>
{/* Opening Date Range */}
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700">Opening Date Range</label>
<div className="mt-1 grid grid-cols-2 gap-4">
<input
type="date"
id="openingDateStart"
name="openingDateStart"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.openingDateStart || ''}
onChange={(e) => handleFilterChange('openingDateStart', e.target.value)}
/>
<input
type="date"
id="openingDateEnd"
name="openingDateEnd"
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
value={filters.openingDateEnd || ''}
onChange={(e) => handleFilterChange('openingDateEnd', e.target.value)}
/>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import type { Park } from '@/types/api';
import { ParkCard } from './ParkCard';
import { ParkListItem } from './ParkListItem';
interface ParkListProps {
parks: Park[];
viewMode: 'grid' | 'list';
searchQuery?: string;
}
export function ParkList({ parks, viewMode, searchQuery }: ParkListProps) {
if (parks.length === 0) {
return (
<div className="col-span-full p-4 text-sm text-gray-500 text-center" data-testid="no-parks-found">
{searchQuery ? (
<>No parks found matching "{searchQuery}". Try adjusting your search terms.</>
) : (
<>No parks found matching your criteria. Try adjusting your filters.</>
)}
</div>
);
}
if (viewMode === 'list') {
return (
<div className="divide-y divide-gray-200">
{parks.map((park) => (
<ParkListItem key={park.id} park={park} />
))}
</div>
);
}
return (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{parks.map((park) => (
<ParkCard key={park.id} park={park} />
))}
</div>
);
}

View File

@@ -0,0 +1,70 @@
import Link from 'next/link';
import type { Park } from '@/types/api';
interface ParkListItemProps {
park: Park;
}
function getStatusBadgeClass(status: string): string {
const statusClasses = {
OPERATING: 'bg-green-100 text-green-800',
CLOSED_TEMP: 'bg-yellow-100 text-yellow-800',
CLOSED_PERM: 'bg-red-100 text-red-800',
UNDER_CONSTRUCTION: 'bg-blue-100 text-blue-800',
DEMOLISHED: 'bg-gray-100 text-gray-800',
RELOCATED: 'bg-purple-100 text-purple-800'
};
return statusClasses[status as keyof typeof statusClasses] || 'bg-gray-100 text-gray-500';
}
export function ParkListItem({ park }: ParkListItemProps) {
const statusClass = getStatusBadgeClass(park.status);
const formattedStatus = park.status.replace(/_/g, ' ');
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border-b border-gray-200 last:border-b-0">
<div className="flex-1">
<div className="flex items-start justify-between">
<h2 className="text-lg font-semibold mb-1">
<Link
href={`/parks/${park.slug}`}
className="text-gray-900 hover:text-blue-600 dark:text-white dark:hover:text-blue-400"
>
{park.name}
</Link>
</h2>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusClass}`}>
{formattedStatus}
</span>
</div>
{park.owner && (
<div className="text-sm mb-2">
<Link
href={`/companies/${park.owner.slug}`}
className="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
{park.owner.name}
</Link>
</div>
)}
<div className="text-sm text-gray-600 space-x-4">
{park.location && (
<span>
{[
park.location.city,
park.location.state,
park.location.country
].filter(Boolean).join(', ')}
</span>
)}
<span>{park.ride_count} rides</span>
{park.opening_date && (
<span>Opened: {new Date(park.opening_date).toLocaleDateString()}</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import { useState, useCallback } from 'react';
import debounce from 'lodash/debounce';
interface ParkSearchProps {
onSearch: (query: string) => void;
}
export function ParkSearch({ onSearch }: ParkSearchProps) {
const [query, setQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [suggestions, setSuggestions] = useState<Array<{ id: string; name: string; slug: string }>>([]);
const debouncedFetchSuggestions = useCallback(
debounce(async (searchQuery: string) => {
if (!searchQuery.trim()) {
setSuggestions([]);
return;
}
try {
setIsLoading(true);
const response = await fetch(`/api/parks/suggest?search=${encodeURIComponent(searchQuery)}`);
const data = await response.json();
if (data.success) {
setSuggestions(data.data || []);
}
} catch (error) {
console.error('Failed to fetch suggestions:', error);
setSuggestions([]);
} finally {
setIsLoading(false);
}
}, 300),
[]
);
const handleSearch = (searchQuery: string) => {
setQuery(searchQuery);
debouncedFetchSuggestions(searchQuery);
onSearch(searchQuery);
};
const handleSuggestionClick = (suggestion: { name: string; slug: string }) => {
setQuery(suggestion.name);
setSuggestions([]);
onSearch(suggestion.name);
};
return (
<div className="max-w-3xl mx-auto relative mb-8">
<div className="w-full relative">
<div className="relative">
<input
type="search"
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search parks..."
className="w-full border-gray-300 rounded-lg form-input dark:border-gray-600 dark:bg-gray-700 dark:text-white"
aria-label="Search parks"
aria-controls="search-results"
aria-expanded={suggestions.length > 0}
/>
{isLoading && (
<div
className="absolute right-3 top-1/2 -translate-y-1/2"
role="status"
aria-label="Loading search results"
>
<svg className="w-5 h-5 text-gray-400 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"/>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
</svg>
<span className="sr-only">Searching...</span>
</div>
)}
</div>
{suggestions.length > 0 && (
<div
id="search-results"
className="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 rounded-md shadow-lg"
role="listbox"
>
<ul>
{suggestions.map((suggestion) => (
<li
key={suggestion.id}
className="px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
role="option"
onClick={() => handleSuggestionClick(suggestion)}
>
{suggestion.name}
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
interface ViewToggleProps {
currentView: 'grid' | 'list';
onViewChange: (view: 'grid' | 'list') => void;
}
export function ViewToggle({ currentView, onViewChange }: ViewToggleProps) {
return (
<fieldset className="flex items-center space-x-2 bg-gray-100 rounded-lg p-1">
<legend className="sr-only">View mode selection</legend>
<button
onClick={() => onViewChange('grid')}
className={`p-2 rounded transition-colors duration-200 ${
currentView === 'grid' ? 'bg-white shadow-sm' : ''
}`}
aria-label="Grid view"
aria-pressed={currentView === 'grid'}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
</svg>
</button>
<button
onClick={() => onViewChange('list')}
className={`p-2 rounded transition-colors duration-200 ${
currentView === 'list' ? 'bg-white shadow-sm' : ''
}`}
aria-label="List view"
aria-pressed={currentView === 'list'}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h7"
/>
</svg>
</button>
</fieldset>
);
}