mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 10:51:09 -05:00
Add park detail API and detail page implementation with loading states and error handling
This commit is contained in:
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.4.1",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "^15.1.7",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
@@ -4533,6 +4534,12 @@
|
||||
"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": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.4.1",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "^15.1.7",
|
||||
"react": "^18",
|
||||
"react-dom": "^18"
|
||||
|
||||
103
frontend/src/app/api/parks/[slug]/route.ts
Normal file
103
frontend/src/app/api/parks/[slug]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,217 +1,110 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { ParkListResponse, Park } from '@/types/api';
|
||||
import type { ParkStatus } from '@/types/api';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest
|
||||
): Promise<NextResponse<ParkListResponse>> {
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
// Get query parameters
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '10');
|
||||
const search = searchParams.get('search') || '';
|
||||
const status = searchParams.get('status') || undefined;
|
||||
const search = searchParams.get('search')?.trim();
|
||||
const status = searchParams.get('status') as ParkStatus;
|
||||
const ownerId = searchParams.get('ownerId');
|
||||
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 = {
|
||||
AND: [
|
||||
search ? {
|
||||
AND: [] as any[]
|
||||
};
|
||||
|
||||
// Search filter
|
||||
if (search) {
|
||||
where.AND.push({
|
||||
OR: [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
} : {},
|
||||
status ? { status } : {},
|
||||
],
|
||||
};
|
||||
|
||||
// Ensure database connection is initialized
|
||||
if (!prisma) {
|
||||
throw new Error('Database connection not initialized');
|
||||
{ owner: { name: { contains: search, mode: 'insensitive' } } }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch parks with relationships
|
||||
const [parks, total] = await Promise.all([
|
||||
prisma.park.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.park.count({ where }),
|
||||
]);
|
||||
// Status filter
|
||||
if (status) {
|
||||
where.AND.push({ status });
|
||||
}
|
||||
|
||||
// Owner filters
|
||||
if (ownerId) {
|
||||
where.AND.push({ ownerId });
|
||||
}
|
||||
if (hasOwner !== null) {
|
||||
where.AND.push({ owner: hasOwner === 'true' ? { not: null } : null });
|
||||
}
|
||||
|
||||
// Numeric filters
|
||||
if (!isNaN(minRides)) {
|
||||
where.AND.push({ ride_count: { gte: minRides } });
|
||||
}
|
||||
if (!isNaN(minCoasters)) {
|
||||
where.AND.push({ coaster_count: { gte: minCoasters } });
|
||||
}
|
||||
if (!isNaN(minSize)) {
|
||||
where.AND.push({ size_acres: { gte: minSize } });
|
||||
}
|
||||
|
||||
// Date range filter
|
||||
if (openingDateStart || openingDateEnd) {
|
||||
const dateFilter: any = {};
|
||||
if (openingDateStart) {
|
||||
dateFilter.gte = new Date(openingDateStart);
|
||||
}
|
||||
if (openingDateEnd) {
|
||||
dateFilter.lte = new Date(openingDateEnd);
|
||||
}
|
||||
where.AND.push({ opening_date: dateFilter });
|
||||
}
|
||||
|
||||
const parks = await prisma.park.findMany({
|
||||
where: where.AND.length > 0 ? where : undefined,
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
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 => ({
|
||||
...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(),
|
||||
})),
|
||||
ride_count: park._count.rides,
|
||||
_count: undefined
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: formattedParks,
|
||||
metadata: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
},
|
||||
data: formattedParks
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching 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,
|
||||
},
|
||||
});
|
||||
|
||||
console.error('Error in /api/parks:', error);
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
...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(),
|
||||
},
|
||||
});
|
||||
} 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 }
|
||||
);
|
||||
error: 'Failed to fetch parks'
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
50
frontend/src/app/api/parks/suggest/route.ts
Normal file
50
frontend/src/app/api/parks/suggest/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
99
frontend/src/app/parks/[slug]/loading.tsx
Normal file
99
frontend/src/app/parks/[slug]/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
frontend/src/app/parks/[slug]/page.tsx
Normal file
194
frontend/src/app/parks/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,53 @@
|
||||
'use client';
|
||||
|
||||
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() {
|
||||
const [parks, setParks] = useState<Park[]>([]);
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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(() => {
|
||||
async function fetchParks() {
|
||||
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();
|
||||
|
||||
if (!data.success) {
|
||||
@@ -27,23 +63,14 @@ export default function ParksPage() {
|
||||
}
|
||||
|
||||
fetchParks();
|
||||
}, []);
|
||||
}, [searchQuery, filters]);
|
||||
|
||||
const getStatusColor = (status: ParkStatus): string => {
|
||||
const colors = {
|
||||
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 handleSearch = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
};
|
||||
|
||||
const formatRating = (rating: number | null | undefined): string => {
|
||||
if (typeof rating !== 'number') return 'No ratings';
|
||||
return `${rating.toFixed(1)}/5`;
|
||||
const handleFiltersChange = (newFilters: ParkFilterValues) => {
|
||||
setFilters(newFilters);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -59,10 +86,12 @@ export default function ParksPage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
<div className="rounded-lg bg-red-50 p-4 border border-red-200">
|
||||
<h2 className="text-red-800 font-semibold">Error</h2>
|
||||
<p className="text-red-600 mt-2">{error}</p>
|
||||
<div className="p-4" data-testid="park-list-error">
|
||||
<div className="inline-flex items-center px-4 py-2 rounded-md bg-red-50 text-red-700">
|
||||
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<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>
|
||||
);
|
||||
@@ -71,76 +100,32 @@ export default function ParksPage() {
|
||||
return (
|
||||
<div className="min-h-screen p-8">
|
||||
<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">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Parks</h1>
|
||||
<ViewToggle currentView={viewMode} onViewChange={setViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<ParkSearch onSearch={handleSearch} />
|
||||
<ParkFilters onFiltersChange={handleFiltersChange} companies={companies} />
|
||||
</div>
|
||||
|
||||
{parks.length === 0 ? (
|
||||
<p className="text-gray-600">No parks found</p>
|
||||
) : (
|
||||
<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"
|
||||
id="park-results"
|
||||
className="bg-white rounded-lg shadow overflow-hidden"
|
||||
data-view-mode={viewMode}
|
||||
>
|
||||
<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 className="transition-all duration-300 ease-in-out">
|
||||
<ParkList
|
||||
parks={parks}
|
||||
viewMode={viewMode}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
frontend/src/components/parks/ParkCard.tsx
Normal file
55
frontend/src/components/parks/ParkCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
182
frontend/src/components/parks/ParkFilters.tsx
Normal file
182
frontend/src/components/parks/ParkFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/parks/ParkList.tsx
Normal file
41
frontend/src/components/parks/ParkList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
frontend/src/components/parks/ParkListItem.tsx
Normal file
70
frontend/src/components/parks/ParkListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
frontend/src/components/parks/ParkSearch.tsx
Normal file
105
frontend/src/components/parks/ParkSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
frontend/src/components/parks/ViewToggle.tsx
Normal file
48
frontend/src/components/parks/ViewToggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +1,83 @@
|
||||
// API Response Types
|
||||
// General API response types
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
metadata?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
total?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Auth Types
|
||||
export interface UserAuth {
|
||||
id: number;
|
||||
email: string;
|
||||
username: string;
|
||||
token: string;
|
||||
// Park status type
|
||||
export type ParkStatus =
|
||||
| 'OPERATING'
|
||||
| 'CLOSED_TEMP'
|
||||
| 'CLOSED_PERM'
|
||||
| 'UNDER_CONSTRUCTION'
|
||||
| 'DEMOLISHED'
|
||||
| 'RELOCATED';
|
||||
|
||||
// Company (owner) type
|
||||
export interface Company {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
// Park Status Enum
|
||||
export enum ParkStatus {
|
||||
OPERATING = 'OPERATING',
|
||||
CLOSED_TEMP = 'CLOSED_TEMP',
|
||||
CLOSED_PERM = 'CLOSED_PERM',
|
||||
UNDER_CONSTRUCTION = 'UNDER_CONSTRUCTION',
|
||||
DEMOLISHED = 'DEMOLISHED',
|
||||
RELOCATED = 'RELOCATED'
|
||||
// Location type
|
||||
export interface Location {
|
||||
id: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
postal_code?: string;
|
||||
street_address?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
// Park Types
|
||||
// Park type
|
||||
export interface Park {
|
||||
id: number;
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
status: ParkStatus;
|
||||
location?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
owner?: Company;
|
||||
location?: Location;
|
||||
opening_date?: string;
|
||||
closing_date?: string;
|
||||
operating_season?: string;
|
||||
size_acres?: number;
|
||||
website?: string;
|
||||
average_rating?: number;
|
||||
ride_count?: number;
|
||||
size_acres?: number;
|
||||
ride_count: number;
|
||||
coaster_count?: number;
|
||||
creator?: User;
|
||||
creatorId?: number;
|
||||
owner?: Company;
|
||||
ownerId?: number;
|
||||
areas: ParkArea[];
|
||||
reviews: Review[];
|
||||
photos: Photo[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
average_rating?: number;
|
||||
}
|
||||
|
||||
// Park Area Types
|
||||
export interface ParkArea {
|
||||
id: number;
|
||||
// Park filter values type
|
||||
export interface ParkFilterValues {
|
||||
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;
|
||||
slug: string;
|
||||
description?: string;
|
||||
opening_date?: string;
|
||||
closing_date?: string;
|
||||
parkId: number;
|
||||
park: Park;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Company Types
|
||||
export interface Company {
|
||||
id: number;
|
||||
status: ParkStatus;
|
||||
owner?: {
|
||||
name: string;
|
||||
website?: string;
|
||||
parks: Park[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
slug: 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>;
|
||||
}
|
||||
export type ParkSuggestionResponse = ApiResponse<ParkSuggestion[]>;
|
||||
@@ -41,22 +41,50 @@
|
||||
### Immediate Next Steps
|
||||
|
||||
1. Park Detail Implementation (High Priority)
|
||||
- [ ] Create /api/parks/[slug] endpoint
|
||||
- [ ] Add park detail page component
|
||||
- [ ] Handle loading states
|
||||
- [x] Create /api/parks/[slug] endpoint
|
||||
- [x] Define response schema in api.ts
|
||||
- [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
|
||||
- [ ] Create reviews component
|
||||
- [ ] Add reviews API endpoint
|
||||
|
||||
2. Authentication (High Priority)
|
||||
- [ ] Implement JWT token management
|
||||
- [ ] Set up JWT middleware
|
||||
- [ ] Add token refresh handling
|
||||
- [ ] Store tokens securely
|
||||
- [ ] Add login/register forms
|
||||
- [ ] Create form components with validation
|
||||
- [ ] Add form submission handlers
|
||||
- [ ] Implement success/error states
|
||||
- [ ] Protected route middleware
|
||||
- [ ] Set up middleware.ts checks
|
||||
- [ ] Add authentication redirect logic
|
||||
- [ ] Auth context provider
|
||||
- [ ] Create auth state management
|
||||
- [ ] Add context hooks for components
|
||||
|
||||
3. UI Improvements (Medium Priority)
|
||||
- [ ] Add search input in UI
|
||||
- [ ] Create reusable search component
|
||||
- [ ] Implement debounced API calls
|
||||
- [ ] Implement filter controls
|
||||
- [ ] Add filter state management
|
||||
- [ ] Create filter UI components
|
||||
- [ ] Add proper loading skeletons
|
||||
- [ ] Design consistent skeleton layouts
|
||||
- [ ] Implement skeleton components
|
||||
- [ ] Improve error messages
|
||||
- [ ] Create error message component
|
||||
- [ ] Add error status pages
|
||||
|
||||
### Known Issues
|
||||
1. No authentication system yet
|
||||
|
||||
25
memory-bank/features/park_detail_api.md
Normal file
25
memory-bank/features/park_detail_api.md
Normal 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
|
||||
44
memory-bank/features/park_detail_page.md
Normal file
44
memory-bank/features/park_detail_page.md
Normal 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
|
||||
122
memory-bank/features/parks_page_nextjs.md
Normal file
122
memory-bank/features/parks_page_nextjs.md
Normal 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
|
||||
Reference in New Issue
Block a user