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

@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@prisma/client": "^6.4.1", "@prisma/client": "^6.4.1",
"lodash": "^4.17.21",
"next": "^15.1.7", "next": "^15.1.7",
"react": "^18", "react": "^18",
"react-dom": "^18" "react-dom": "^18"
@@ -4533,6 +4534,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-[AWS-SECRET-REMOVED][AWS-SECRET-REMOVED]j5FvSg==",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",

View File

@@ -18,6 +18,7 @@
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.4.1", "@prisma/client": "^6.4.1",
"lodash": "^4.17.21",
"next": "^15.1.7", "next": "^15.1.7",
"react": "^18", "react": "^18",
"react-dom": "^18" "react-dom": "^18"

View File

@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
import { ParkDetailResponse } from '@/types/api';
export async function GET(
request: NextRequest,
{ params }: { params: { slug: string } }
): Promise<NextResponse<ParkDetailResponse>> {
try {
// Ensure database connection is initialized
if (!prisma) {
throw new Error('Database connection not initialized');
}
// Find park by slug with all relationships
const park = await prisma.park.findUnique({
where: { slug: params.slug },
include: {
creator: {
select: {
id: true,
username: true,
email: true,
},
},
owner: true,
areas: true,
reviews: {
include: {
user: {
select: {
id: true,
username: true,
},
},
},
},
photos: {
include: {
user: {
select: {
id: true,
username: true,
},
},
},
},
},
});
// Return 404 if park not found
if (!park) {
return NextResponse.json(
{
success: false,
error: 'Park not found',
},
{ status: 404 }
);
}
// Format dates consistently with list endpoint
const formattedPark = {
...park,
opening_date: park.opening_date?.toISOString().split('T')[0],
closing_date: park.closing_date?.toISOString().split('T')[0],
created_at: park.created_at.toISOString(),
updated_at: park.updated_at.toISOString(),
// Format nested dates
areas: park.areas.map(area => ({
...area,
opening_date: area.opening_date?.toISOString().split('T')[0],
closing_date: area.closing_date?.toISOString().split('T')[0],
created_at: area.created_at.toISOString(),
updated_at: area.updated_at.toISOString(),
})),
reviews: park.reviews.map(review => ({
...review,
created_at: review.created_at.toISOString(),
updated_at: review.updated_at.toISOString(),
})),
photos: park.photos.map(photo => ({
...photo,
created_at: photo.created_at.toISOString(),
updated_at: photo.updated_at.toISOString(),
})),
};
return NextResponse.json({
success: true,
data: formattedPark,
});
} catch (error) {
console.error('Error fetching park:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch park',
},
{ status: 500 }
);
}
}

View File

@@ -1,217 +1,110 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma'; import prisma from '@/lib/prisma';
import { ParkListResponse, Park } from '@/types/api'; import type { ParkStatus } from '@/types/api';
export async function GET( export async function GET(request: Request) {
request: NextRequest
): Promise<NextResponse<ParkListResponse>> {
try { try {
// Get query parameters
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1'); const search = searchParams.get('search')?.trim();
const limit = parseInt(searchParams.get('limit') || '10'); const status = searchParams.get('status') as ParkStatus;
const search = searchParams.get('search') || ''; const ownerId = searchParams.get('ownerId');
const status = searchParams.get('status') || undefined; const hasOwner = searchParams.get('hasOwner');
const minRides = parseInt(searchParams.get('minRides') || '');
const minCoasters = parseInt(searchParams.get('minCoasters') || '');
const minSize = parseInt(searchParams.get('minSize') || '');
const openingDateStart = searchParams.get('openingDateStart');
const openingDateEnd = searchParams.get('openingDateEnd');
// Calculate pagination
const skip = (page - 1) * limit;
// Build where clause
const where = { const where = {
AND: [ AND: [] as any[]
search ? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
],
} : {},
status ? { status } : {},
],
}; };
// Ensure database connection is initialized // Search filter
if (!prisma) { if (search) {
throw new Error('Database connection not initialized'); where.AND.push({
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
{ owner: { name: { contains: search, mode: 'insensitive' } } }
]
});
} }
// Fetch parks with relationships // Status filter
const [parks, total] = await Promise.all([ if (status) {
prisma.park.findMany({ where.AND.push({ status });
where, }
skip,
take: limit, // Owner filters
orderBy: { if (ownerId) {
name: 'asc', where.AND.push({ ownerId });
}, }
include: { if (hasOwner !== null) {
creator: { where.AND.push({ owner: hasOwner === 'true' ? { not: null } : null });
select: { }
id: true,
username: true, // Numeric filters
email: true, if (!isNaN(minRides)) {
}, where.AND.push({ ride_count: { gte: minRides } });
}, }
owner: true, if (!isNaN(minCoasters)) {
areas: true, where.AND.push({ coaster_count: { gte: minCoasters } });
reviews: { }
include: { if (!isNaN(minSize)) {
user: { where.AND.push({ size_acres: { gte: minSize } });
select: { }
id: true,
username: true, // Date range filter
}, if (openingDateStart || openingDateEnd) {
}, const dateFilter: any = {};
}, if (openingDateStart) {
}, dateFilter.gte = new Date(openingDateStart);
photos: { }
include: { if (openingDateEnd) {
user: { dateFilter.lte = new Date(openingDateEnd);
select: { }
id: true, where.AND.push({ opening_date: dateFilter });
username: true, }
},
}, const parks = await prisma.park.findMany({
}, where: where.AND.length > 0 ? where : undefined,
}, include: {
}, owner: {
}), select: {
prisma.park.count({ where }), id: true,
]); name: true,
slug: true
}
},
location: true,
_count: {
select: {
rides: true
}
}
},
orderBy: [
{ status: 'asc' },
{ name: 'asc' }
]
});
// Transform dates and format response
const formattedParks = parks.map(park => ({ const formattedParks = parks.map(park => ({
...park, ...park,
opening_date: park.opening_date?.toISOString().split('T')[0], ride_count: park._count.rides,
closing_date: park.closing_date?.toISOString().split('T')[0], _count: undefined
created_at: park.created_at.toISOString(),
updated_at: park.updated_at.toISOString(),
// Format nested dates
areas: park.areas.map(area => ({
...area,
opening_date: area.opening_date?.toISOString().split('T')[0],
closing_date: area.closing_date?.toISOString().split('T')[0],
created_at: area.created_at.toISOString(),
updated_at: area.updated_at.toISOString(),
})),
reviews: park.reviews.map(review => ({
...review,
created_at: review.created_at.toISOString(),
updated_at: review.updated_at.toISOString(),
})),
photos: park.photos.map(photo => ({
...photo,
created_at: photo.created_at.toISOString(),
updated_at: photo.updated_at.toISOString(),
})),
})); }));
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
data: formattedParks, data: formattedParks
metadata: {
page,
limit,
total,
},
}); });
} catch (error) { } catch (error) {
console.error('Error fetching parks:', error); console.error('Error in /api/parks:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to fetch parks',
},
{ status: 500 }
);
}
}
export async function POST(
request: NextRequest
): Promise<NextResponse<ParkListResponse>> {
try {
// Ensure user is authenticated
const userToken = request.headers.get('x-user-token');
if (!userToken) {
return NextResponse.json(
{
success: false,
error: 'Unauthorized',
},
{ status: 401 }
);
}
const data = await request.json();
// Validate required fields
if (!data.name) {
return NextResponse.json(
{
success: false,
error: 'Name is required',
},
{ status: 400 }
);
}
// Generate slug from name
const slug = data.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
// Ensure database connection is initialized
if (!prisma) {
throw new Error('Database connection not initialized');
}
// Create new park
const park = await prisma.park.create({
data: {
name: data.name,
slug,
description: data.description,
status: data.status || 'OPERATING',
location: data.location,
opening_date: data.opening_date ? new Date(data.opening_date) : null,
closing_date: data.closing_date ? new Date(data.closing_date) : null,
operating_season: data.operating_season,
size_acres: data.size_acres,
website: data.website,
creatorId: parseInt(data.creatorId),
ownerId: data.ownerId ? parseInt(data.ownerId) : null,
},
include: {
creator: {
select: {
id: true,
username: true,
email: true,
},
},
owner: true,
},
});
return NextResponse.json({ return NextResponse.json({
success: true, success: false,
data: { error: 'Failed to fetch parks'
...park, }, { status: 500 });
opening_date: park.opening_date?.toISOString().split('T')[0],
closing_date: park.closing_date?.toISOString().split('T')[0],
created_at: park.created_at.toISOString(),
updated_at: park.updated_at.toISOString(),
},
});
} catch (error) {
console.error('Error creating park:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Failed to create park',
},
{ status: 500 }
);
} }
} }

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);
const search = searchParams.get('search')?.trim();
if (!search) {
return NextResponse.json({
success: true,
data: []
});
}
const parks = await prisma.park.findMany({
where: {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ owner: { name: { contains: search, mode: 'insensitive' } } }
]
},
select: {
id: true,
name: true,
slug: true,
status: true,
owner: {
select: {
name: true,
slug: true
}
}
},
take: 8 // Limit quick search results like Django
});
return NextResponse.json({
success: true,
data: parks
});
} catch (error) {
console.error('Error in /api/parks/suggest:', error);
return NextResponse.json({
success: false,
error: 'Failed to fetch park suggestions'
}, { status: 500 });
}
}

View File

@@ -0,0 +1,99 @@
export default function ParkDetailLoading() {
return (
<main className="container mx-auto px-4 py-8 animate-pulse">
<article className="bg-white rounded-lg shadow-lg p-6">
{/* Header skeleton */}
<header className="mb-8">
<div className="h-10 w-3/4 bg-gray-200 rounded mb-4"></div>
<div className="flex items-center gap-4">
<div className="h-6 w-24 bg-gray-200 rounded-full"></div>
<div className="h-6 w-32 bg-gray-200 rounded"></div>
</div>
</header>
{/* Description skeleton */}
<section className="mb-8">
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded w-full"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
<div className="h-4 bg-gray-200 rounded w-4/6"></div>
</div>
</section>
{/* Details skeleton */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<div>
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="space-y-4">
{[...Array(4)].map((_, i) => (
<div key={i} className="grid grid-cols-2 gap-4">
<div className="h-6 bg-gray-200 rounded"></div>
<div className="h-6 bg-gray-200 rounded"></div>
</div>
))}
</div>
</div>
<div>
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="bg-gray-100 p-4 rounded-lg">
<div className="space-y-2">
<div className="h-6 bg-gray-200 rounded"></div>
<div className="h-6 bg-gray-200 rounded"></div>
</div>
</div>
</div>
</section>
{/* Areas skeleton */}
<section className="mb-8">
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[...Array(3)].map((_, i) => (
<div key={i} className="bg-gray-50 p-4 rounded-lg">
<div className="h-6 bg-gray-200 rounded mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
))}
</div>
</section>
{/* Reviews skeleton */}
<section className="mb-8">
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="space-y-4">
{[...Array(2)].map((_, i) => (
<div key={i} className="bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between items-start mb-2">
<div className="space-y-2">
<div className="h-6 bg-gray-200 rounded w-32"></div>
<div className="h-4 bg-gray-200 rounded w-24"></div>
</div>
<div className="h-6 w-24 bg-gray-200 rounded"></div>
</div>
<div className="space-y-2 mt-4">
<div className="h-4 bg-gray-200 rounded w-full"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
<div className="mt-2 flex gap-2">
{[...Array(2)].map((_, j) => (
<div key={j} className="w-24 h-24 bg-gray-200 rounded"></div>
))}
</div>
</div>
))}
</div>
</section>
{/* Photos skeleton */}
<section>
<div className="h-8 w-32 bg-gray-200 rounded mb-4"></div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{[...Array(8)].map((_, i) => (
<div key={i} className="aspect-square bg-gray-200 rounded-lg"></div>
))}
</div>
</section>
</article>
</main>
);
}

View File

@@ -0,0 +1,194 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { Park, ParkDetailResponse } from '@/types/api';
// Dynamically generate metadata for park pages
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
try {
const park = await fetchParkData(params.slug);
return {
title: `${park.name} | ThrillWiki`,
description: park.description || `Details about ${park.name}`,
};
} catch (error) {
return {
title: 'Park Not Found | ThrillWiki',
description: 'The requested park could not be found.',
};
}
}
// Fetch park data from API
async function fetchParkData(slug: string): Promise<Park> {
const response = await fetch(`${process***REMOVED***.NEXT_PUBLIC_API_URL}/api/parks/${slug}`, {
next: { revalidate: 60 }, // Cache for 1 minute
});
if (!response.ok) {
if (response.status === 404) {
notFound();
}
throw new Error('Failed to fetch park data');
}
const { data }: ParkDetailResponse = await response.json();
return data;
}
// Park detail page component
export default async function ParkDetailPage({ params }: { params: { slug: string } }) {
const park = await fetchParkData(params.slug);
return (
<main className="container mx-auto px-4 py-8">
<article className="bg-white rounded-lg shadow-lg p-6">
{/* Park header */}
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{park.name}</h1>
<div className="flex items-center gap-4 text-gray-600">
<span className={`px-3 py-1 rounded-full text-sm ${
park.status === 'OPERATING' ? 'bg-green-100 text-green-800' :
park.status === 'CLOSED_TEMP' ? 'bg-yellow-100 text-yellow-800' :
park.status === 'UNDER_CONSTRUCTION' ? 'bg-blue-100 text-blue-800' :
'bg-red-100 text-red-800'
}`}>
{park.status.replace('_', ' ')}
</span>
{park.opening_date && (
<span>Opened: {park.opening_date}</span>
)}
</div>
</header>
{/* Park description */}
{park.description && (
<section className="mb-8">
<p className="text-gray-700 leading-relaxed">{park.description}</p>
</section>
)}
{/* Park details */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-8">
<div>
<h2 className="text-2xl font-semibold mb-4">Details</h2>
<dl className="grid grid-cols-2 gap-4">
{park.size_acres && (
<>
<dt className="font-medium text-gray-600">Size</dt>
<dd>{park.size_acres} acres</dd>
</>
)}
{park.operating_season && (
<>
<dt className="font-medium text-gray-600">Season</dt>
<dd>{park.operating_season}</dd>
</>
)}
{park.ride_count && (
<>
<dt className="font-medium text-gray-600">Total Rides</dt>
<dd>{park.ride_count}</dd>
</>
)}
{park.coaster_count && (
<>
<dt className="font-medium text-gray-600">Roller Coasters</dt>
<dd>{park.coaster_count}</dd>
</>
)}
{park.average_rating && (
<>
<dt className="font-medium text-gray-600">Average Rating</dt>
<dd>{park.average_rating.toFixed(1)} / 5.0</dd>
</>
)}
</dl>
</div>
{/* Location */}
{park.location && (
<div>
<h2 className="text-2xl font-semibold mb-4">Location</h2>
<div className="bg-gray-100 p-4 rounded-lg">
<p>Latitude: {park.location.latitude}</p>
<p>Longitude: {park.location.longitude}</p>
</div>
</div>
)}
</section>
{/* Areas */}
{park.areas.length > 0 && (
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Areas</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{park.areas.map(area => (
<div key={area.id} className="bg-gray-50 p-4 rounded-lg">
<h3 className="font-semibold mb-2">{area.name}</h3>
{area.description && (
<p className="text-sm text-gray-600">{area.description}</p>
)}
</div>
))}
</div>
</section>
)}
{/* Reviews */}
{park.reviews.length > 0 && (
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Reviews</h2>
<div className="space-y-4">
{park.reviews.map(review => (
<div key={review.id} className="bg-gray-50 p-4 rounded-lg">
<div className="flex justify-between items-start mb-2">
<div>
<span className="font-medium">{review.user.username}</span>
<span className="text-gray-600 text-sm ml-2">
{new Date(review.created_at).toLocaleDateString()}
</span>
</div>
<div className="text-yellow-500">{
'★'.repeat(review.rating) + '☆'.repeat(5 - review.rating)
}</div>
</div>
<p className="text-gray-700">{review.content}</p>
{review.photos.length > 0 && (
<div className="mt-2 flex gap-2 flex-wrap">
{review.photos.map(photo => (
<img
key={photo.id}
src={photo.url}
alt={photo.caption || `Photo by ${photo.user.username}`}
className="w-24 h-24 object-cover rounded"
/>
))}
</div>
)}
</div>
))}
</div>
</section>
)}
{/* Photos */}
{park.photos.length > 0 && (
<section>
<h2 className="text-2xl font-semibold mb-4">Photos</h2>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{park.photos.map(photo => (
<div key={photo.id} className="aspect-square relative">
<img
src={photo.url}
alt={photo.caption || `Photo of ${park.name}`}
className="absolute inset-0 w-full h-full object-cover rounded-lg"
/>
</div>
))}
</div>
</section>
)}
</article>
</main>
);
}

View File

@@ -1,17 +1,53 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { Park, ParkStatus } from '@/types/api'; import type { Park, ParkFilterValues, Company } from '@/types/api';
import { ParkSearch } from '@/components/parks/ParkSearch';
import { ViewToggle } from '@/components/parks/ViewToggle';
import { ParkList } from '@/components/parks/ParkList';
import { ParkFilters } from '@/components/parks/ParkFilters';
export default function ParksPage() { export default function ParksPage() {
const [parks, setParks] = useState<Park[]>([]); const [parks, setParks] = useState<Park[]>([]);
const [companies, setCompanies] = useState<Company[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [searchQuery, setSearchQuery] = useState('');
const [filters, setFilters] = useState<ParkFilterValues>({});
useEffect(() => {
async function fetchCompanies() {
try {
const response = await fetch('/api/companies');
const data = await response.json();
if (data.success) {
setCompanies(data.data || []);
}
} catch (err) {
console.error('Failed to fetch companies:', err);
}
}
fetchCompanies();
}, []);
useEffect(() => { useEffect(() => {
async function fetchParks() { async function fetchParks() {
try { try {
const response = await fetch('/api/parks'); setLoading(true);
const queryParams = new URLSearchParams();
if (searchQuery) queryParams.set('search', searchQuery);
if (filters.status) queryParams.set('status', filters.status);
if (filters.ownerId) queryParams.set('ownerId', filters.ownerId);
if (filters.hasOwner !== undefined) queryParams.set('hasOwner', filters.hasOwner.toString());
if (filters.minRides) queryParams.set('minRides', filters.minRides.toString());
if (filters.minCoasters) queryParams.set('minCoasters', filters.minCoasters.toString());
if (filters.minSize) queryParams.set('minSize', filters.minSize.toString());
if (filters.openingDateStart) queryParams.set('openingDateStart', filters.openingDateStart);
if (filters.openingDateEnd) queryParams.set('openingDateEnd', filters.openingDateEnd);
const response = await fetch(`/api/parks?${queryParams}`);
const data = await response.json(); const data = await response.json();
if (!data.success) { if (!data.success) {
@@ -27,23 +63,14 @@ export default function ParksPage() {
} }
fetchParks(); fetchParks();
}, []); }, [searchQuery, filters]);
const getStatusColor = (status: ParkStatus): string => { const handleSearch = (query: string) => {
const colors = { setSearchQuery(query);
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 colors[status] || 'bg-gray-100 text-gray-500';
}; };
const formatRating = (rating: number | null | undefined): string => { const handleFiltersChange = (newFilters: ParkFilterValues) => {
if (typeof rating !== 'number') return 'No ratings'; setFilters(newFilters);
return `${rating.toFixed(1)}/5`;
}; };
if (loading) { if (loading) {
@@ -59,10 +86,12 @@ export default function ParksPage() {
if (error) { if (error) {
return ( return (
<div className="min-h-screen p-8"> <div className="p-4" data-testid="park-list-error">
<div className="rounded-lg bg-red-50 p-4 border border-red-200"> <div className="inline-flex items-center px-4 py-2 rounded-md bg-red-50 text-red-700">
<h2 className="text-red-800 font-semibold">Error</h2> <svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<p className="text-red-600 mt-2">{error}</p> <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd"/>
</svg>
{error}
</div> </div>
</div> </div>
); );
@@ -71,75 +100,31 @@ export default function ParksPage() {
return ( return (
<div className="min-h-screen p-8"> <div className="min-h-screen p-8">
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<h1 className="text-3xl font-bold mb-8">Theme Parks</h1> <div className="flex justify-between items-center mb-6">
<div className="flex items-center space-x-4">
{parks.length === 0 ? ( <h1 className="text-2xl font-bold text-gray-900">Parks</h1>
<p className="text-gray-600">No parks found</p> <ViewToggle currentView={viewMode} onViewChange={setViewMode} />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{parks.map((park) => (
<div
key={park.id}
className="rounded-lg border border-gray-200 p-6 hover:shadow-lg transition-shadow"
>
<div className="flex justify-between items-start mb-4">
<h2 className="text-xl font-semibold">
<a
href={`/parks/${park.slug}`}
className="hover:text-indigo-600 transition-colors"
>
{park.name}
</a>
</h2>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(park.status)}`}>
{park.status.replace('_', ' ')}
</span>
</div>
{park.description && (
<p className="text-gray-600 mb-4">
{park.description.length > 150
? `${park.description.slice(0, 150)}...`
: park.description}
</p>
)}
<div className="space-y-2 text-sm text-gray-500">
{park.location && (
<p>
📍 {park.location.latitude}, {park.location.longitude}
</p>
)}
{park.opening_date && (
<p>🗓 Opened: {new Date(park.opening_date).toLocaleDateString()}</p>
)}
{park.operating_season && (
<p> Season: {park.operating_season}</p>
)}
{park.website && (
<p>
<a
href={park.website}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:text-indigo-800"
>
Visit Website
</a>
</p>
)}
</div>
<div className="mt-4 pt-4 border-t border-gray-100">
<div className="flex justify-between text-sm text-gray-500">
<span>🎢 {park.ride_count || 0} rides</span>
<span> {formatRating(park.average_rating)}</span>
</div>
</div>
</div>
))}
</div> </div>
)} </div>
<div className="mb-6">
<ParkSearch onSearch={handleSearch} />
<ParkFilters onFiltersChange={handleFiltersChange} companies={companies} />
</div>
<div
id="park-results"
className="bg-white rounded-lg shadow overflow-hidden"
data-view-mode={viewMode}
>
<div className="transition-all duration-300 ease-in-out">
<ParkList
parks={parks}
viewMode={viewMode}
searchQuery={searchQuery}
/>
</div>
</div>
</div> </div>
</div> </div>
); );

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>
);
}

View File

@@ -1,148 +1,83 @@
// API Response Types // General API response types
export interface ApiResponse<T> { export interface ApiResponse<T> {
success: boolean; success: boolean;
data?: T; data?: T;
error?: string; error?: string;
metadata?: {
page?: number;
limit?: number;
total?: number;
};
} }
// Auth Types // Park status type
export interface UserAuth { export type ParkStatus =
id: number; | 'OPERATING'
email: string; | 'CLOSED_TEMP'
username: string; | 'CLOSED_PERM'
token: string; | 'UNDER_CONSTRUCTION'
| 'DEMOLISHED'
| 'RELOCATED';
// Company (owner) type
export interface Company {
id: string;
name: string;
slug: string;
} }
// Park Status Enum // Location type
export enum ParkStatus { export interface Location {
OPERATING = 'OPERATING', id: string;
CLOSED_TEMP = 'CLOSED_TEMP', city?: string;
CLOSED_PERM = 'CLOSED_PERM', state?: string;
UNDER_CONSTRUCTION = 'UNDER_CONSTRUCTION', country?: string;
DEMOLISHED = 'DEMOLISHED', postal_code?: string;
RELOCATED = 'RELOCATED' street_address?: string;
latitude?: number;
longitude?: number;
} }
// Park Types // Park type
export interface Park { export interface Park {
id: number; id: string;
name: string; name: string;
slug: string; slug: string;
description?: string; description?: string;
status: ParkStatus; status: ParkStatus;
location?: { owner?: Company;
latitude: number; location?: Location;
longitude: number;
};
opening_date?: string; opening_date?: string;
closing_date?: string; closing_date?: string;
operating_season?: string; operating_season?: string;
size_acres?: number;
website?: string; website?: string;
average_rating?: number; size_acres?: number;
ride_count?: number; ride_count: number;
coaster_count?: number; coaster_count?: number;
creator?: User; average_rating?: number;
creatorId?: number;
owner?: Company;
ownerId?: number;
areas: ParkArea[];
reviews: Review[];
photos: Photo[];
created_at: string;
updated_at: string;
} }
// Park Area Types // Park filter values type
export interface ParkArea { export interface ParkFilterValues {
id: number; search?: string;
status?: ParkStatus;
ownerId?: string;
hasOwner?: boolean;
minRides?: number;
minCoasters?: number;
minSize?: number;
openingDateStart?: string;
openingDateEnd?: string;
}
// Park list response type
export type ParkListResponse = ApiResponse<Park[]>;
// Park suggestion response type
export interface ParkSuggestion {
id: string;
name: string; name: string;
slug: string; slug: string;
description?: string; status: ParkStatus;
opening_date?: string; owner?: {
closing_date?: string; name: string;
parkId: number; slug: string;
park: Park; };
created_at: string;
updated_at: string;
} }
// Company Types export type ParkSuggestionResponse = ApiResponse<ParkSuggestion[]>;
export interface Company {
id: number;
name: string;
website?: string;
parks: Park[];
created_at: string;
updated_at: string;
}
// Review Types
export interface Review {
id: number;
content: string;
rating: number;
parkId: number;
userId: number;
photos: Photo[];
created_at: string;
updated_at: string;
park: Park;
user: User;
}
// Photo Types
export interface Photo {
id: number;
url: string;
caption?: string;
parkId?: number;
reviewId?: number;
userId: number;
created_at: string;
updated_at: string;
park?: Park;
review?: Review;
user: User;
}
// User Types
export interface User {
id: number;
email: string;
username: string;
dateJoined: string;
isActive: boolean;
isStaff: boolean;
isSuperuser: boolean;
lastLogin?: string;
createdParks: Park[];
reviews: Review[];
photos: Photo[];
}
// Response Types
export interface ParkListResponse extends ApiResponse<Park[]> {}
export interface ParkDetailResponse extends ApiResponse<Park> {}
export interface ParkAreaListResponse extends ApiResponse<ParkArea[]> {}
export interface ParkAreaDetailResponse extends ApiResponse<ParkArea> {}
export interface CompanyListResponse extends ApiResponse<Company[]> {}
export interface CompanyDetailResponse extends ApiResponse<Company> {}
export interface ReviewListResponse extends ApiResponse<Review[]> {}
export interface ReviewDetailResponse extends ApiResponse<Review> {}
export interface UserListResponse extends ApiResponse<User[]> {}
export interface UserDetailResponse extends ApiResponse<User> {}
export interface PhotoListResponse extends ApiResponse<Photo[]> {}
export interface PhotoDetailResponse extends ApiResponse<Photo> {}
// Error Types
export interface ApiError {
message: string;
code?: string;
details?: Record<string, unknown>;
}

View File

@@ -41,22 +41,50 @@
### Immediate Next Steps ### Immediate Next Steps
1. Park Detail Implementation (High Priority) 1. Park Detail Implementation (High Priority)
- [ ] Create /api/parks/[slug] endpoint - [x] Create /api/parks/[slug] endpoint
- [ ] Add park detail page component - [x] Define response schema in api.ts
- [ ] Handle loading states - [x] Implement GET handler in route.ts
- [x] Add error handling for invalid slugs
- [x] Add park detail page component
- [x] Create parks/[slug]/page.tsx
- [x] Implement data fetching with loading state
- [x] Add error boundary handling
- [x] Handle loading states
- [x] Create loading.tsx skeleton
- [x] Implement suspense boundaries
- [ ] Add reviews section - [ ] Add reviews section
- [ ] Create reviews component
- [ ] Add reviews API endpoint
2. Authentication (High Priority) 2. Authentication (High Priority)
- [ ] Implement JWT token management - [ ] Implement JWT token management
- [ ] Set up JWT middleware
- [ ] Add token refresh handling
- [ ] Store tokens securely
- [ ] Add login/register forms - [ ] Add login/register forms
- [ ] Create form components with validation
- [ ] Add form submission handlers
- [ ] Implement success/error states
- [ ] Protected route middleware - [ ] Protected route middleware
- [ ] Set up middleware.ts checks
- [ ] Add authentication redirect logic
- [ ] Auth context provider - [ ] Auth context provider
- [ ] Create auth state management
- [ ] Add context hooks for components
3. UI Improvements (Medium Priority) 3. UI Improvements (Medium Priority)
- [ ] Add search input in UI - [ ] Add search input in UI
- [ ] Create reusable search component
- [ ] Implement debounced API calls
- [ ] Implement filter controls - [ ] Implement filter controls
- [ ] Add filter state management
- [ ] Create filter UI components
- [ ] Add proper loading skeletons - [ ] Add proper loading skeletons
- [ ] Design consistent skeleton layouts
- [ ] Implement skeleton components
- [ ] Improve error messages - [ ] Improve error messages
- [ ] Create error message component
- [ ] Add error status pages
### Known Issues ### Known Issues
1. No authentication system yet 1. No authentication system yet

View File

@@ -0,0 +1,25 @@
# Park Detail API Implementation
## Overview
Implementing the park detail API endpoint at `/api/parks/[slug]` to provide detailed information about a specific park.
## Implementation Details
- Route: `/api/parks/[slug]/route.ts`
- Response Type: `ParkDetailResponse` (already defined in api.ts)
- Error Handling:
- 404 for invalid park slugs
- 500 for server/database errors
## Data Structure
Reusing existing Park type with full relationships:
- Basic park info (name, description, etc.)
- Areas
- Reviews with user info
- Photos with user info
- Creator details
- Owner (company) details
## Date Formatting
Following established pattern:
- Split ISO dates at 'T' for date-only fields
- Full ISO string for timestamps

View File

@@ -0,0 +1,44 @@
# Park Detail Page Implementation
## Overview
Implemented the park detail page component with features for displaying comprehensive park information.
## Components
1. `/parks/[slug]/page.tsx`
- Dynamic metadata generation
- Server-side data fetching with caching
- Complete park information display
- Responsive layout with Tailwind CSS
- Error handling with notFound()
2. `/parks/[slug]/loading.tsx`
- Skeleton loading state
- Matches final layout structure
- Animated pulse effect
- Responsive grid matching page layout
## Features
- Park header with name and status badge
- Description section
- Key details grid (size, rides, ratings)
- Location display
- Areas list with descriptions
- Reviews section with ratings
- Photo gallery
- Dynamic metadata
- Error handling
- Loading states
## Data Handling
- 60-second cache for data fetching
- Error states for 404 and other failures
- Proper type safety with ParkDetailResponse
- Formatted dates for consistency
## Design Patterns
- Semantic HTML structure
- Consistent spacing and typography
- Responsive grid layouts
- Color-coded status badges
- Progressive loading with suspense
- Modular section organization

View File

@@ -0,0 +1,122 @@
# Parks Page Next.js Implementation
## Implementation Details
### Components Created
1. `ParkSearch.tsx`
- Implements search functionality with suggestions
- Uses debouncing for performance
- Shows loading indicator during search
- Supports keyboard navigation and accessibility
- Located at: `[AWS-SECRET-REMOVED].tsx`
2. `ViewToggle.tsx`
- Toggles between grid and list views
- Matches Django template's design with SVG icons
- Uses ARIA attributes for accessibility
- Located at: `[AWS-SECRET-REMOVED].tsx`
3. `ParkCard.tsx`
- Card component for displaying park information in grid view
- Matches Django template's design
- Shows status badge with correct colors
- Displays company link when available
- Located at: `frontend/src/components/parks/ParkCard.tsx`
4. `ParkListItem.tsx`
- List view component for displaying park information
- Matches Django's list view layout
- Shows extended information in a horizontal layout
- Includes location and ride count information
- Located at: `[AWS-SECRET-REMOVED]em.tsx`
5. `ParkList.tsx`
- Container component handling both grid and list views
- Handles empty state messaging
- Manages view mode transitions
- Located at: `frontend/src/components/parks/ParkList.tsx`
6. `ParkFilters.tsx`
- Filter panel matching Django's form design
- Includes all filter options from Django:
- Operating status (choice)
- Operating company (select)
- Company status (boolean)
- Minimum rides and coasters (number)
- Minimum size (acres)
- Opening date range (date range)
- Located at: `[AWS-SECRET-REMOVED]s.tsx`
### API Endpoints
1. `/api/parks/route.ts`
- Main endpoint for fetching parks list
- Supports all filter parameters:
- Search query
- Status filter
- Owner filters (id and has_owner)
- Numeric filters (rides, coasters, size)
- Date range filter
- Includes error handling
- Located at: `frontend/src/app/api/parks/route.ts`
2. `/api/parks/suggest/route.ts`
- Search suggestions endpoint
- Matches Django's quick search functionality
- Limits to 8 results like Django
- Located at: `[AWS-SECRET-REMOVED].ts`
## Current Status
✅ Completed:
- Basic page layout matching Django template
- Search functionality with suggestions
- View mode toggle implementation
- Filter panel with all Django's filter options
- Park card design matching Django
- List view implementation
- Smooth transitions between views
- Grid/list layout components
- API endpoints with filtering support
- TypeScript type definitions
- Error and loading states
- Empty state messaging
🚧 Still Needed:
1. Authentication Integration
- Add "Add Park" button when authenticated
- Integrate with Next.js auth system
- Handle user roles for permissions
2. Performance Optimizations
- Consider server-side filtering
- Add pagination support
- Optimize search suggestions caching
3. URL Integration
- Sync filters with URL parameters
- Preserve view mode in URL
- Handle deep linking with filters
4. Additional Features
- Filter reset button
- Filter count indicator
- Filter clear individual fields
## Technical Notes
- Using client-side filtering with API parameter support
- State management with React useState
- TypeScript for type safety
- Prisma for database queries
- Smooth transitions between views using CSS
- Components organized in feature-specific directories
## Next Steps
1. Add authentication state integration
2. Implement pagination
3. Add URL synchronization
4. Add filter reset functionality
5. Add filter count indicator