From c9ab1f40eddfc3dcd2353d10a725eb89dc74c713 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Sun, 23 Feb 2025 17:57:52 -0500 Subject: [PATCH] Add park detail API and detail page implementation with loading states and error handling --- frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/app/api/parks/[slug]/route.ts | 103 +++++++ frontend/src/app/api/parks/route.ts | 283 ++++++------------ frontend/src/app/api/parks/suggest/route.ts | 50 ++++ frontend/src/app/parks/[slug]/loading.tsx | 99 ++++++ frontend/src/app/parks/[slug]/page.tsx | 194 ++++++++++++ frontend/src/app/parks/page.tsx | 161 +++++----- frontend/src/components/parks/ParkCard.tsx | 55 ++++ frontend/src/components/parks/ParkFilters.tsx | 182 +++++++++++ frontend/src/components/parks/ParkList.tsx | 41 +++ .../src/components/parks/ParkListItem.tsx | 70 +++++ frontend/src/components/parks/ParkSearch.tsx | 105 +++++++ frontend/src/components/parks/ViewToggle.tsx | 48 +++ frontend/src/types/api.ts | 179 ++++------- memory-bank/activeContext.md | 34 ++- memory-bank/features/park_detail_api.md | 25 ++ memory-bank/features/park_detail_page.md | 44 +++ memory-bank/features/parks_page_nextjs.md | 122 ++++++++ 19 files changed, 1395 insertions(+), 408 deletions(-) create mode 100644 frontend/src/app/api/parks/[slug]/route.ts create mode 100644 frontend/src/app/api/parks/suggest/route.ts create mode 100644 frontend/src/app/parks/[slug]/loading.tsx create mode 100644 frontend/src/app/parks/[slug]/page.tsx create mode 100644 frontend/src/components/parks/ParkCard.tsx create mode 100644 frontend/src/components/parks/ParkFilters.tsx create mode 100644 frontend/src/components/parks/ParkList.tsx create mode 100644 frontend/src/components/parks/ParkListItem.tsx create mode 100644 frontend/src/components/parks/ParkSearch.tsx create mode 100644 frontend/src/components/parks/ViewToggle.tsx create mode 100644 memory-bank/features/park_detail_api.md create mode 100644 memory-bank/features/park_detail_page.md create mode 100644 memory-bank/features/parks_page_nextjs.md diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d2de4682..8bf621a2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 832fc839..a2430a74 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@prisma/client": "^6.4.1", + "lodash": "^4.17.21", "next": "^15.1.7", "react": "^18", "react-dom": "^18" diff --git a/frontend/src/app/api/parks/[slug]/route.ts b/frontend/src/app/api/parks/[slug]/route.ts new file mode 100644 index 00000000..4b91e1fd --- /dev/null +++ b/frontend/src/app/api/parks/[slug]/route.ts @@ -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> { + 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 } + ); + } +} \ No newline at end of file diff --git a/frontend/src/app/api/parks/route.ts b/frontend/src/app/api/parks/route.ts index 772b187a..82667965 100644 --- a/frontend/src/app/api/parks/route.ts +++ b/frontend/src/app/api/parks/route.ts @@ -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> { +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 ? { - OR: [ - { name: { contains: search, mode: 'insensitive' } }, - { description: { contains: search, mode: 'insensitive' } }, - ], - } : {}, - status ? { status } : {}, - ], + AND: [] as any[] }; - // Ensure database connection is initialized - if (!prisma) { - throw new Error('Database connection not initialized'); + // Search filter + if (search) { + where.AND.push({ + OR: [ + { name: { contains: search, mode: 'insensitive' } }, + { description: { contains: search, mode: 'insensitive' } }, + { 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> { - 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 } - ); + success: false, + error: 'Failed to fetch parks' + }, { status: 500 }); } } \ No newline at end of file diff --git a/frontend/src/app/api/parks/suggest/route.ts b/frontend/src/app/api/parks/suggest/route.ts new file mode 100644 index 00000000..5e982d9c --- /dev/null +++ b/frontend/src/app/api/parks/suggest/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/frontend/src/app/parks/[slug]/loading.tsx b/frontend/src/app/parks/[slug]/loading.tsx new file mode 100644 index 00000000..61514597 --- /dev/null +++ b/frontend/src/app/parks/[slug]/loading.tsx @@ -0,0 +1,99 @@ +export default function ParkDetailLoading() { + return ( +
+
+ {/* Header skeleton */} +
+
+
+
+
+
+
+ + {/* Description skeleton */} +
+
+
+
+
+
+
+ + {/* Details skeleton */} +
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+
+
+
+ ))} +
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Areas skeleton */} +
+
+
+ {[...Array(3)].map((_, i) => ( +
+
+
+
+ ))} +
+
+ + {/* Reviews skeleton */} +
+
+
+ {[...Array(2)].map((_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ {[...Array(2)].map((_, j) => ( +
+ ))} +
+
+ ))} +
+
+ + {/* Photos skeleton */} +
+
+
+ {[...Array(8)].map((_, i) => ( +
+ ))} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/parks/[slug]/page.tsx b/frontend/src/app/parks/[slug]/page.tsx new file mode 100644 index 00000000..3192bc9d --- /dev/null +++ b/frontend/src/app/parks/[slug]/page.tsx @@ -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 { + 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 { + 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 ( +
+
+ {/* Park header */} +
+

{park.name}

+
+ + {park.status.replace('_', ' ')} + + {park.opening_date && ( + Opened: {park.opening_date} + )} +
+
+ + {/* Park description */} + {park.description && ( +
+

{park.description}

+
+ )} + + {/* Park details */} +
+
+

Details

+
+ {park.size_acres && ( + <> +
Size
+
{park.size_acres} acres
+ + )} + {park.operating_season && ( + <> +
Season
+
{park.operating_season}
+ + )} + {park.ride_count && ( + <> +
Total Rides
+
{park.ride_count}
+ + )} + {park.coaster_count && ( + <> +
Roller Coasters
+
{park.coaster_count}
+ + )} + {park.average_rating && ( + <> +
Average Rating
+
{park.average_rating.toFixed(1)} / 5.0
+ + )} +
+
+ + {/* Location */} + {park.location && ( +
+

Location

+
+

Latitude: {park.location.latitude}

+

Longitude: {park.location.longitude}

+
+
+ )} +
+ + {/* Areas */} + {park.areas.length > 0 && ( +
+

Areas

+
+ {park.areas.map(area => ( +
+

{area.name}

+ {area.description && ( +

{area.description}

+ )} +
+ ))} +
+
+ )} + + {/* Reviews */} + {park.reviews.length > 0 && ( +
+

Reviews

+
+ {park.reviews.map(review => ( +
+
+
+ {review.user.username} + + {new Date(review.created_at).toLocaleDateString()} + +
+
{ + '★'.repeat(review.rating) + '☆'.repeat(5 - review.rating) + }
+
+

{review.content}

+ {review.photos.length > 0 && ( +
+ {review.photos.map(photo => ( + {photo.caption + ))} +
+ )} +
+ ))} +
+
+ )} + + {/* Photos */} + {park.photos.length > 0 && ( +
+

Photos

+
+ {park.photos.map(photo => ( +
+ {photo.caption +
+ ))} +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/parks/page.tsx b/frontend/src/app/parks/page.tsx index 9a9a8cc2..4a30dcce 100644 --- a/frontend/src/app/parks/page.tsx +++ b/frontend/src/app/parks/page.tsx @@ -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([]); + const [companies, setCompanies] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); + const [searchQuery, setSearchQuery] = useState(''); + const [filters, setFilters] = useState({}); + + 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 ( -
-
-

Error

-

{error}

+
+
+ + + + {error}
); @@ -71,75 +100,31 @@ export default function ParksPage() { return (
-

Theme Parks

- - {parks.length === 0 ? ( -

No parks found

- ) : ( -
- {parks.map((park) => ( -
-
-

- - {park.name} - -

- - {park.status.replace('_', ' ')} - -
- - {park.description && ( -

- {park.description.length > 150 - ? `${park.description.slice(0, 150)}...` - : park.description} -

- )} - -
- {park.location && ( -

- 📍 {park.location.latitude}, {park.location.longitude} -

- )} - {park.opening_date && ( -

🗓 Opened: {new Date(park.opening_date).toLocaleDateString()}

- )} - {park.operating_season && ( -

⏰ Season: {park.operating_season}

- )} - {park.website && ( -

- - Visit Website → - -

- )} -
- -
-
- 🎢 {park.ride_count || 0} rides - ⭐ {formatRating(park.average_rating)} -
-
-
- ))} +
+
+

Parks

+
- )} +
+ +
+ + +
+ +
+
+ +
+
); diff --git a/frontend/src/components/parks/ParkCard.tsx b/frontend/src/components/parks/ParkCard.tsx new file mode 100644 index 00000000..062404e7 --- /dev/null +++ b/frontend/src/components/parks/ParkCard.tsx @@ -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 ( +
+
+

+ + {park.name} + +

+ +
+ + {formattedStatus} + +
+ + {park.owner && ( +
+ + {park.owner.name} + +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/parks/ParkFilters.tsx b/frontend/src/components/parks/ParkFilters.tsx new file mode 100644 index 00000000..ac54d19a --- /dev/null +++ b/frontend/src/components/parks/ParkFilters.tsx @@ -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({}); + + const handleFilterChange = (field: keyof ParkFilterValues, value: any) => { + const newFilters = { + ...filters, + [field]: value === '' ? undefined : value + }; + setFilters(newFilters); + onFiltersChange(newFilters); + }; + + return ( +
+
+

Filters

+
+ {/* Status Filter */} +
+ + +
+ + {/* Owner Filter */} +
+ + +
+ + {/* Has Owner Filter */} +
+ + +
+ + {/* Min Rides Filter */} +
+ + handleFilterChange('minRides', e.target.value ? parseInt(e.target.value, 10) : '')} + /> +
+ + {/* Min Coasters Filter */} +
+ + handleFilterChange('minCoasters', e.target.value ? parseInt(e.target.value, 10) : '')} + /> +
+ + {/* Min Size Filter */} +
+ + handleFilterChange('minSize', e.target.value ? parseInt(e.target.value, 10) : '')} + /> +
+ + {/* Opening Date Range */} +
+ +
+ handleFilterChange('openingDateStart', e.target.value)} + /> + handleFilterChange('openingDateEnd', e.target.value)} + /> +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/parks/ParkList.tsx b/frontend/src/components/parks/ParkList.tsx new file mode 100644 index 00000000..b5e185ee --- /dev/null +++ b/frontend/src/components/parks/ParkList.tsx @@ -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 ( +
+ {searchQuery ? ( + <>No parks found matching "{searchQuery}". Try adjusting your search terms. + ) : ( + <>No parks found matching your criteria. Try adjusting your filters. + )} +
+ ); + } + + if (viewMode === 'list') { + return ( +
+ {parks.map((park) => ( + + ))} +
+ ); + } + + return ( +
+ {parks.map((park) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/parks/ParkListItem.tsx b/frontend/src/components/parks/ParkListItem.tsx new file mode 100644 index 00000000..1521312c --- /dev/null +++ b/frontend/src/components/parks/ParkListItem.tsx @@ -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 ( +
+
+
+

+ + {park.name} + +

+ + {formattedStatus} + +
+ + {park.owner && ( +
+ + {park.owner.name} + +
+ )} + +
+ {park.location && ( + + {[ + park.location.city, + park.location.state, + park.location.country + ].filter(Boolean).join(', ')} + + )} + {park.ride_count} rides + {park.opening_date && ( + Opened: {new Date(park.opening_date).toLocaleDateString()} + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/parks/ParkSearch.tsx b/frontend/src/components/parks/ParkSearch.tsx new file mode 100644 index 00000000..49c7f582 --- /dev/null +++ b/frontend/src/components/parks/ParkSearch.tsx @@ -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>([]); + + 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 ( +
+
+
+ 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 && ( +
+ + + + + Searching... +
+ )} +
+ + {suggestions.length > 0 && ( +
+
    + {suggestions.map((suggestion) => ( +
  • handleSuggestionClick(suggestion)} + > + {suggestion.name} +
  • + ))} +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/parks/ViewToggle.tsx b/frontend/src/components/parks/ViewToggle.tsx new file mode 100644 index 00000000..9dd758ba --- /dev/null +++ b/frontend/src/components/parks/ViewToggle.tsx @@ -0,0 +1,48 @@ +'use client'; + +interface ViewToggleProps { + currentView: 'grid' | 'list'; + onViewChange: (view: 'grid' | 'list') => void; +} + +export function ViewToggle({ currentView, onViewChange }: ViewToggleProps) { + return ( +
+ View mode selection + + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index bc6f4556..b1f94f0e 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -1,148 +1,83 @@ -// API Response Types +// General API response types export interface ApiResponse { 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 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; + status: ParkStatus; + owner?: { + name: string; + slug: string; + }; } -// Company Types -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 {} -export interface ParkDetailResponse extends ApiResponse {} -export interface ParkAreaListResponse extends ApiResponse {} -export interface ParkAreaDetailResponse extends ApiResponse {} -export interface CompanyListResponse extends ApiResponse {} -export interface CompanyDetailResponse extends ApiResponse {} -export interface ReviewListResponse extends ApiResponse {} -export interface ReviewDetailResponse extends ApiResponse {} -export interface UserListResponse extends ApiResponse {} -export interface UserDetailResponse extends ApiResponse {} -export interface PhotoListResponse extends ApiResponse {} -export interface PhotoDetailResponse extends ApiResponse {} - -// Error Types -export interface ApiError { - message: string; - code?: string; - details?: Record; -} \ No newline at end of file +export type ParkSuggestionResponse = ApiResponse; \ No newline at end of file diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md index 581d1a68..aa372dd7 100644 --- a/memory-bank/activeContext.md +++ b/memory-bank/activeContext.md @@ -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 diff --git a/memory-bank/features/park_detail_api.md b/memory-bank/features/park_detail_api.md new file mode 100644 index 00000000..50b17548 --- /dev/null +++ b/memory-bank/features/park_detail_api.md @@ -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 \ No newline at end of file diff --git a/memory-bank/features/park_detail_page.md b/memory-bank/features/park_detail_page.md new file mode 100644 index 00000000..280d7b1e --- /dev/null +++ b/memory-bank/features/park_detail_page.md @@ -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 \ No newline at end of file diff --git a/memory-bank/features/parks_page_nextjs.md b/memory-bank/features/parks_page_nextjs.md new file mode 100644 index 00000000..91739420 --- /dev/null +++ b/memory-bank/features/parks_page_nextjs.md @@ -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 \ No newline at end of file