mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 18:11:08 -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",
|
"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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
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 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 }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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';
|
'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>
|
||||||
);
|
);
|
||||||
|
|||||||
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> {
|
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>;
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
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