Files
thrillwiki_laravel/memory-bank/prompts/ParksListingPagePrompt.md

22 KiB

Parks Listing Page Implementation Prompt

Django Parity Reference

Django Implementation: parks/views.py - ParkListView (lines 135-150+) Django Template: parks/templates/parks/park_list.html Django Features: Location-based search, operator filtering, region filtering, park type filtering, statistics display, pagination with HTMX, map integration

Core Implementation Requirements

Laravel/Livewire Architecture

Generate the parks listing system using ThrillWiki's custom generators:

# Generate the main listing component with location optimization
php artisan make:thrillwiki-livewire ParksListing --paginated --cached --with-tests

# Generate location-aware search component
php artisan make:thrillwiki-livewire ParksLocationSearch --reusable --with-tests

# Generate operator-specific park filters
php artisan make:thrillwiki-livewire ParksFilters --reusable --cached

# Generate parks map view component
php artisan make:thrillwiki-livewire ParksMapView --reusable --with-tests

# Generate operator-specific park listings
php artisan make:thrillwiki-livewire OperatorParksListing --paginated --cached --with-tests

# Generate regional park listings
php artisan make:thrillwiki-livewire RegionalParksListing --paginated --cached --with-tests

Django Parity Features

1. Location-Based Search Functionality

Django Implementation: Multi-term search with location awareness across:

  • Park name (name__icontains)
  • Park description (description__icontains)
  • Location city/state (location__city__icontains, location__state__icontains)
  • Operator name (operator__name__icontains)
  • Park type (park_type__icontains)

Laravel Implementation:

public function locationAwareSearch($query, $userLocation = null)
{
    return Park::query()
        ->when($query, function ($q) use ($query) {
            $terms = explode(' ', $query);
            foreach ($terms as $term) {
                $q->where(function ($subQuery) use ($term) {
                    $subQuery->where('name', 'ilike', "%{$term}%")
                            ->orWhere('description', 'ilike', "%{$term}%")
                            ->orWhere('park_type', 'ilike', "%{$term}%")
                            ->orWhereHas('location', function($locQuery) use ($term) {
                                $locQuery->where('city', 'ilike', "%{$term}%")
                                        ->orWhere('state', 'ilike', "%{$term}%")
                                        ->orWhere('country', 'ilike', "%{$term}%");
                            })
                            ->orWhereHas('operator', fn($opQuery) => 
                                $opQuery->where('name', 'ilike', "%{$term}%"));
                });
            }
        })
        ->when($userLocation, function ($q) use ($userLocation) {
            // Add distance-based ordering for location-aware results
            $q->selectRaw('parks.*, 
                (6371 * acos(cos(radians(?)) * cos(radians(locations.latitude)) * 
                cos(radians(locations.longitude) - radians(?)) + 
                sin(radians(?)) * sin(radians(locations.latitude)))) AS distance', 
                [$userLocation['lat'], $userLocation['lng'], $userLocation['lat']])
              ->join('locations', 'parks.location_id', '=', 'locations.id')
              ->orderBy('distance');
        })
        ->with(['location', 'operator', 'photos', 'statistics'])
        ->withCount(['rides', 'reviews']);
}

2. Advanced Filtering with Geographic Context

Django Filters:

  • Operator (operator__id)
  • Region/State (location__state)
  • Country (location__country)
  • Park type (park_type)
  • Opening year range
  • Size range (area_acres)
  • Ride count range
  • Distance from user location

Laravel Filters Implementation:

public function applyFilters($query, $filters, $userLocation = null)
{
    return $query
        ->when($filters['operator_id'] ?? null, fn($q, $operatorId) => 
            $q->where('operator_id', $operatorId))
        ->when($filters['region'] ?? null, fn($q, $region) => 
            $q->whereHas('location', fn($locQ) => $locQ->where('state', $region)))
        ->when($filters['country'] ?? null, fn($q, $country) => 
            $q->whereHas('location', fn($locQ) => $locQ->where('country', $country)))
        ->when($filters['park_type'] ?? null, fn($q, $type) => 
            $q->where('park_type', $type))
        ->when($filters['opening_year_from'] ?? null, fn($q, $year) => 
            $q->where('opening_date', '>=', "{$year}-01-01"))
        ->when($filters['opening_year_to'] ?? null, fn($q, $year) => 
            $q->where('opening_date', '<=', "{$year}-12-31"))
        ->when($filters['min_area'] ?? null, fn($q, $area) => 
            $q->where('area_acres', '>=', $area))
        ->when($filters['max_area'] ?? null, fn($q, $area) => 
            $q->where('area_acres', '<=', $area))
        ->when($filters['min_rides'] ?? null, fn($q, $count) => 
            $q->whereHas('rides', fn($rideQ) => $rideQ->havingRaw('COUNT(*) >= ?', [$count])))
        ->when($filters['max_distance'] ?? null && $userLocation, function($q) use ($filters, $userLocation) {
            $q->whereRaw('(6371 * acos(cos(radians(?)) * cos(radians(locations.latitude)) * 
                cos(radians(locations.longitude) - radians(?)) + 
                sin(radians(?)) * sin(radians(locations.latitude)))) <= ?', 
                [$userLocation['lat'], $userLocation['lng'], $userLocation['lat'], $filters['max_distance']]);
        });
}

3. Context-Aware Views with Statistics

Global Listing: All parks worldwide with statistics Operator-Specific Listing: Parks filtered by specific operator with comparisons Regional Listing: Parks filtered by geographic region with local insights Nearby Listing: Location-based parks with distance calculations

Screen-Agnostic Design Implementation

Mobile Layout (320px - 767px)

  • Single Column: Full-width park cards with essential info
  • Location Services: GPS-enabled "Near Me" functionality
  • Touch-Optimized Maps: Pinch-to-zoom, tap-to-select functionality
  • Swipe Navigation: Horizontal scrolling for quick filters
  • Bottom Sheet: Map/list toggle with smooth transitions

Mobile Component Structure:

<div class="parks-mobile-layout">
    <!-- GPS-Enabled Search Bar -->
    <div class="sticky top-0 bg-white dark:bg-gray-900 z-20 p-4">
        <livewire:parks-location-search :enable-gps="true" />
        <div class="flex items-center mt-2 space-x-2">
            <button wire:click="toggleNearbyMode" class="flex items-center space-x-1 px-3 py-1 bg-blue-100 dark:bg-blue-900 rounded-full">
                <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">...</svg>
                <span class="text-sm">Near Me</span>
            </button>
            <button wire:click="toggleMapView" class="flex items-center space-x-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
                <span class="text-sm">{{ $showMap ? 'List' : 'Map' }}</span>
            </button>
        </div>
    </div>
    
    <!-- Quick Filters -->
    <div class="horizontal-scroll p-4 pb-2">
        <livewire:parks-quick-filters />
    </div>
    
    @if($showMap)
        <!-- Mobile Map View -->
        <div class="h-64 relative">
            <livewire:parks-map-view :parks="$parks" :compact="true" />
        </div>
        <!-- Bottom Sheet Park List -->
        <div class="bg-white dark:bg-gray-900 rounded-t-xl shadow-lg mt-4">
            <div class="p-4 border-b border-gray-200 dark:border-gray-700">
                <h3 class="text-lg font-semibold">{{ $parks->count() }} Parks Found</h3>
            </div>
            <div class="max-h-96 overflow-y-auto">
                @foreach($parks as $park)
                    <livewire:park-mobile-card :park="$park" :show-distance="true" :key="$park->id" />
                @endforeach
            </div>
        </div>
    @else
        <!-- Park Cards -->
        <div class="space-y-4 p-4">
            @foreach($parks as $park)
                <livewire:park-mobile-card :park="$park" :show-distance="$nearbyMode" :key="$park->id" />
            @endforeach
        </div>
    @endif
    
    <!-- Mobile Pagination -->
    <div class="sticky bottom-0 bg-white dark:bg-gray-900 p-4">
        {{ $parks->links('pagination.mobile') }}
    </div>
</div>

Tablet Layout (768px - 1023px)

  • Dual-Pane with Map: Filter sidebar + map/list split view
  • Advanced Filtering: Expandable regional and operator filters
  • Split-Screen Mode: Map on one side, detailed list on the other
  • Touch + External Input: Keyboard shortcuts for power users

Tablet Component Structure:

<div class="parks-tablet-layout flex h-screen">
    <!-- Filter Sidebar -->
    <div class="w-80 bg-gray-50 dark:bg-gray-800 overflow-y-auto">
        <div class="p-6">
            <livewire:parks-location-search :advanced="true" />
            <div class="mt-6">
                <livewire:parks-filters :expanded="true" :show-regional="true" />
            </div>
        </div>
    </div>
    
    <!-- Main Content Area -->
    <div class="flex-1 flex flex-col">
        <!-- View Toggle and Stats -->
        <div class="bg-white dark:bg-gray-900 p-4 border-b border-gray-200 dark:border-gray-700">
            <div class="flex items-center justify-between">
                <div class="flex items-center space-x-4">
                    <h2 class="text-xl font-semibold">{{ $parks->total() }} Parks</h2>
                    <livewire:parks-statistics-summary />
                </div>
                <div class="flex items-center space-x-2">
                    <button wire:click="setView('list')" class="px-3 py-2 {{ $view === 'list' ? 'bg-blue-500 text-white' : 'bg-gray-200 dark:bg-gray-700' }} rounded">
                        List
                    </button>
                    <button wire:click="setView('map')" class="px-3 py-2 {{ $view === 'map' ? 'bg-blue-500 text-white' : 'bg-gray-200 dark:bg-gray-700' }} rounded">
                        Map
                    </button>
                    <button wire:click="setView('split')" class="px-3 py-2 {{ $view === 'split' ? 'bg-blue-500 text-white' : 'bg-gray-200 dark:bg-gray-700' }} rounded">
                        Split
                    </button>
                </div>
            </div>
        </div>
        
        <!-- Content Area -->
        <div class="flex-1 flex">
            @if($view === 'list')
                <!-- Full List View -->
                <div class="flex-1 overflow-y-auto p-6">
                    <div class="grid grid-cols-2 gap-6">
                        @foreach($parks as $park)
                            <livewire:park-tablet-card :park="$park" :key="$park->id" />
                        @endforeach
                    </div>
                    <div class="mt-6">
                        {{ $parks->links() }}
                    </div>
                </div>
            @elseif($view === 'map')
                <!-- Full Map View -->
                <div class="flex-1">
                    <livewire:parks-map-view :parks="$parks" :interactive="true" />
                </div>
            @else
                <!-- Split View -->
                <div class="flex-1">
                    <livewire:parks-map-view :parks="$parks" :interactive="true" />
                </div>
                <div class="w-96 bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 overflow-y-auto">
                    <div class="p-4">
                        @foreach($parks as $park)
                            <livewire:park-compact-card :park="$park" :key="$park->id" />
                        @endforeach
                    </div>
                </div>
            @endif
        </div>
    </div>
</div>

Desktop Layout (1024px - 1919px)

  • Three-Pane Layout: Filters + map/list + park details
  • Advanced Map Integration: Multiple layers, clustering, detailed overlays
  • Keyboard Navigation: Full keyboard shortcuts and accessibility
  • Multi-Window Support: Optimal for external monitor setups

Desktop Component Structure:

<div class="parks-desktop-layout flex h-screen">
    <!-- Advanced Filter Sidebar -->
    <div class="w-80 bg-gray-50 dark:bg-gray-800 overflow-y-auto">
        <div class="p-6">
            <livewire:parks-location-search :advanced="true" :autocomplete="true" />
            <div class="mt-6">
                <livewire:parks-filters :expanded="true" :advanced="true" :show-statistics="true" />
            </div>
        </div>
    </div>
    
    <!-- Main Content -->
    <div class="flex-1 flex flex-col">
        <!-- Advanced Header -->
        <div class="bg-white dark:bg-gray-900 p-6 border-b border-gray-200 dark:border-gray-700">
            <div class="flex items-center justify-between mb-4">
                <div class="flex items-center space-x-6">
                    <h1 class="text-2xl font-bold">{{ $parks->total() }} Theme Parks</h1>
                    <livewire:parks-statistics-dashboard />
                </div>
                <div class="flex items-center space-x-4">
                    <livewire:parks-sort-selector :options="$advancedSortOptions" />
                    <livewire:parks-view-selector />
                    <livewire:parks-export-options />
                </div>
            </div>
            <livewire:parks-advanced-search-bar />
        </div>
        
        <!-- Content Area -->
        <div class="flex-1 flex">
            @if($view === 'grid')
                <!-- Advanced Grid View -->
                <div class="flex-1 overflow-y-auto p-6">
                    <div class="grid grid-cols-3 xl:grid-cols-4 gap-6">
                        @foreach($parks as $park)
                            <livewire:park-desktop-card :park="$park" :detailed="true" :key="$park->id" />
                        @endforeach
                    </div>
                    <div class="mt-8">
                        {{ $parks->links('pagination.desktop') }}
                    </div>
                </div>
            @elseif($view === 'map')
                <!-- Advanced Map View -->
                <div class="flex-1">
                    <livewire:parks-advanced-map :parks="$parks" :clustering="true" :layers="true" />
                </div>
            @else
                <!-- Dashboard View -->
                <div class="flex-1 p-6">
                    <livewire:parks-dashboard :parks="$parks" />
                </div>
            @endif
        </div>
    </div>
    
    <!-- Quick Info Panel -->
    <div class="w-80 bg-gray-50 dark:bg-gray-800 overflow-y-auto">
        <div class="p-6">
            <livewire:parks-quick-info />
            <div class="mt-6">
                <livewire:parks-recent-activity />
            </div>
        </div>
    </div>
</div>

Large Screen Layout (1920px+)

  • Dashboard-Style Interface: Multi-column with comprehensive analytics
  • Ultra-Wide Map Integration: Immersive geographic visualization
  • Advanced Data Visualization: Charts, graphs, and statistical overlays
  • Multi-Monitor Optimization: Designed for extended desktop setups

Performance Optimization Strategy

Location-Aware Caching

public function mount()
{
    $this->userLocation = $this->getUserLocation();
    
    $this->cachedFilters = Cache::remember(
        "parks.filters.{$this->userLocation['region']}",
        now()->addHours(2),
        fn() => $this->loadRegionalFilterOptions()
    );
}

public function getParksProperty()
{
    $cacheKey = "parks.listing." . md5(serialize([
        'search' => $this->search,
        'filters' => $this->filters,
        'location' => $this->userLocation,
        'sort' => $this->sort,
        'page' => $this->page
    ]));
    
    return Cache::remember($cacheKey, now()->addMinutes(20), function() {
        return $this->locationAwareSearch($this->search, $this->userLocation)
            ->applyFilters($this->filters, $this->userLocation)
            ->orderBy($this->sort['column'], $this->sort['direction'])
            ->paginate(18);
    });
}

Geographic Query Optimization

// Optimized query with spatial indexing
public function optimizedLocationQuery()
{
    return Park::select([
        'parks.*',
        DB::raw('(6371 * acos(cos(radians(?)) * cos(radians(locations.latitude)) * 
                cos(radians(locations.longitude) - radians(?)) + 
                sin(radians(?)) * sin(radians(locations.latitude)))) AS distance
        ')
    ])
    ->join('locations', 'parks.location_id', '=', 'locations.id')
    ->with([
        'location:id,city,state,country,latitude,longitude',
        'operator:id,name,slug',
        'photos' => fn($q) => $q->select(['id', 'park_id', 'url', 'thumbnail_url'])->limit(3),
        'statistics:park_id,total_rides,total_reviews,average_rating'
    ])
    ->withCount(['rides', 'reviews', 'favorites'])
    ->addBinding([$this->userLat, $this->userLng, $this->userLat], 'select');
}

Component Reuse Strategy

Shared Components

  • ParksLocationSearch: GPS-enabled search with autocomplete
  • ParksFilters: Regional and operator filtering with statistics
  • ParksMapView: Interactive map with clustering and layers
  • ParkCard: Responsive park display with distance calculations

Context Variations

  • GlobalParksListing: All parks worldwide with regional grouping
  • OperatorParksListing: Operator-specific parks with comparisons
  • RegionalParksListing: Geographic region parks with local insights
  • NearbyParksListing: Location-based parks with travel information

Testing Requirements

Feature Tests

/** @test */
public function can_search_parks_with_location_awareness()
{
    $magicKingdom = Park::factory()->create(['name' => 'Magic Kingdom']);
    $magicKingdom->location()->create([
        'city' => 'Orlando',
        'state' => 'Florida',
        'latitude' => 28.3772,
        'longitude' => -81.5707
    ]);
    
    Livewire::test(ParksListing::class)
        ->set('search', 'Magic Orlando')
        ->set('userLocation', ['lat' => 28.4, 'lng' => -81.6])
        ->assertSee($magicKingdom->name)
        ->assertSee('Orlando');
}

/** @test */
public function filters_parks_by_distance_from_user_location()
{
    $nearPark = Park::factory()->create(['name' => 'Near Park']);
    $nearPark->location()->create(['latitude' => 28.3772, 'longitude' => -81.5707]);
    
    $farPark = Park::factory()->create(['name' => 'Far Park']);
    $farPark->location()->create(['latitude' => 40.7128, 'longitude' => -74.0060]);
    
    Livewire::test(ParksListing::class)
        ->set('userLocation', ['lat' => 28.4, 'lng' => -81.6])
        ->set('filters.max_distance', 50)
        ->assertSee($nearPark->name)
        ->assertDontSee($farPark->name);
}

/** @test */
public function maintains_django_parity_performance_with_location()
{
    Park::factory()->count(100)->create();
    
    $start = microtime(true);
    Livewire::test(ParksListing::class)
        ->set('userLocation', ['lat' => 28.4, 'lng' => -81.6]);
    $end = microtime(true);
    
    $this->assertLessThan(0.5, $end - $start); // < 500ms with location
}

Location-Specific Tests

/** @test */
public function calculates_accurate_distances_between_parks_and_user()
{
    $park = Park::factory()->create();
    $park->location()->create([
        'latitude' => 28.3772,  // Magic Kingdom coordinates
        'longitude' => -81.5707
    ]);
    
    $component = Livewire::test(ParksListing::class)
        ->set('userLocation', ['lat' => 28.4, 'lng' => -81.6]);
        
    $distance = $component->get('parks')->first()->distance;
    $this->assertLessThan(5, $distance); // Should be less than 5km
}

/** @test */
public function handles_gps_permission_denied_gracefully()
{
    Livewire::test(ParksListing::class)
        ->set('gpsPermissionDenied', true)
        ->assertSee('Enter your location manually')
        ->assertDontSee('Near Me');
}

Performance Targets

Universal Performance Standards with Location

  • Initial Load: < 500ms (matches Django with location services)
  • GPS Location Acquisition: < 2 seconds
  • Distance Calculation: < 100ms for 100 parks
  • Map Rendering: < 1 second for initial load
  • Filter Response: < 200ms with location context

Location-Aware Caching Strategy

  • Regional Filter Cache: 2 hours (changes infrequently)
  • Distance Calculations: 30 minutes (user location dependent)
  • Map Tile Cache: 24 hours (geographic data stable)
  • Nearby Parks Cache: 15 minutes (location and time sensitive)

Success Criteria Checklist

Django Parity Verification

  • Location-based search matches Django behavior exactly
  • All geographic filters implemented and functional
  • Distance calculations accurate within 1% of Django results
  • Regional grouping works identically to Django
  • Statistics display matches Django formatting

Screen-Agnostic Compliance

  • Mobile layout optimized with GPS integration
  • Tablet layout provides effective split-screen experience
  • Desktop layout maximizes map and data visualization
  • Large screen layout provides comprehensive dashboard
  • All layouts handle location permissions gracefully

Performance Benchmarks

  • Initial load under 500ms including location services
  • GPS acquisition under 2 seconds
  • Map rendering under 1 second
  • Distance calculations under 100ms
  • Regional caching reduces server load by 70%

Geographic Feature Completeness

  • GPS location services work on all supported devices
  • Distance calculations accurate across all coordinate systems
  • Map integration functional on all screen sizes
  • Regional filtering provides meaningful results
  • Location search provides relevant autocomplete suggestions

This prompt ensures complete Django parity while adding location-aware enhancements that leverage modern browser capabilities and maintain ThrillWiki's screen-agnostic design principles.