From 4c954fff6f82682982ad3b1649aceae41dad58af Mon Sep 17 00:00:00 2001
From: pac7 <47831526-pac7@users.noreply.replit.com>
Date: Tue, 23 Sep 2025 21:44:12 +0000
Subject: [PATCH] Enhance park search with autocomplete and improved filtering
options
Introduce autocomplete for park searches, optimize park data fetching with select_related and prefetch_related, add new API endpoints for autocomplete and quick filters, and refactor the park list view to use new Django Cotton components for a more dynamic and user-friendly experience.
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: c446bc9e-66df-438c-a86c-f53e6da13649
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
---
.replit | 4 +
apps/parks/urls.py | 5 +-
apps/parks/views.py | 62 +++-
apps/parks/views_autocomplete.py | 178 ++++++++++++
static/css/tailwind.css | 92 +++---
templates/cotton/enhanced_search.html | 232 +++++++++++++++
templates/cotton/filter_chips.html | 81 ++++++
templates/cotton/result_stats.html | 122 ++++++++
templates/cotton/sort_controls.html | 193 +++++++++++++
templates/cotton/view_toggle.html | 67 +++++
templates/parks/park_list.html | 364 +++++++++++++++++++-----
templates/parks/park_list_enhanced.html | 301 ++++++++++++++++++++
templates/parks/partials/park_list.html | 132 ++-------
13 files changed, 1588 insertions(+), 245 deletions(-)
create mode 100644 apps/parks/views_autocomplete.py
create mode 100644 templates/cotton/enhanced_search.html
create mode 100644 templates/cotton/filter_chips.html
create mode 100644 templates/cotton/result_stats.html
create mode 100644 templates/cotton/sort_controls.html
create mode 100644 templates/cotton/view_toggle.html
create mode 100644 templates/parks/park_list_enhanced.html
diff --git a/.replit b/.replit
index 3520ba63..c547b9d1 100644
--- a/.replit
+++ b/.replit
@@ -54,6 +54,10 @@ outputType = "webview"
localPort = 5000
externalPort = 80
+[[ports]]
+localPort = 40077
+externalPort = 3002
+
[[ports]]
localPort = 41923
externalPort = 3000
diff --git a/apps/parks/urls.py b/apps/parks/urls.py
index 0df54cf6..e4950655 100644
--- a/apps/parks/urls.py
+++ b/apps/parks/urls.py
@@ -1,5 +1,5 @@
from django.urls import path, include
-from . import views, views_search
+from . import views, views_search, views_autocomplete
from apps.rides.views import ParkSingleCategoryListView
from .views_roadtrip import (
RoadTripPlannerView,
@@ -30,6 +30,9 @@ urlpatterns = [
path("areas/", views.get_park_areas, name="get_park_areas"),
path("suggest_parks/", views_search.suggest_parks, name="suggest_parks"),
path("search/", views.search_parks, name="search_parks"),
+ # Enhanced search endpoints
+ path("api/autocomplete/", views_autocomplete.ParkAutocompleteView.as_view(), name="park_autocomplete"),
+ path("api/quick-filters/", views_autocomplete.QuickFilterSuggestionsView.as_view(), name="quick_filter_suggestions"),
# Road trip planning URLs
path("roadtrip/", RoadTripPlannerView.as_view(), name="roadtrip_planner"),
path("roadtrip/create/", CreateTripView.as_view(), name="roadtrip_create"),
diff --git a/apps/parks/views.py b/apps/parks/views.py
index e383c4f5..88433f86 100644
--- a/apps/parks/views.py
+++ b/apps/parks/views.py
@@ -245,15 +245,56 @@ class ParkListView(HTMXFilterableMixin, ListView):
return get_view_mode(self.request)
def get_queryset(self) -> QuerySet[Park]:
- """Get optimized queryset with filter service"""
+ """Get optimized queryset with enhanced filtering and proper relations"""
try:
- # Use filter service for optimized filtering
- filter_params = dict(self.request.GET.items())
- queryset = self.filter_service.get_filtered_queryset(filter_params)
+ # Start with optimized base queryset
+ queryset = (
+ get_base_park_queryset()
+ .select_related(
+ 'operator',
+ 'property_owner',
+ 'location',
+ 'banner_image',
+ 'card_image'
+ )
+ .prefetch_related(
+ 'photos',
+ 'rides__manufacturer',
+ 'areas'
+ )
+ )
- # Also create filterset for form rendering
- self.filterset = self.filter_class(self.request.GET, queryset=queryset)
+ # Use filter service for enhanced filtering
+ filter_params = self._get_clean_filter_params()
+
+ # Apply ordering
+ ordering = self.request.GET.get('ordering', 'name')
+ if ordering:
+ # Validate ordering to prevent SQL injection
+ valid_orderings = [
+ 'name', '-name',
+ 'average_rating', '-average_rating',
+ 'coaster_count', '-coaster_count',
+ 'ride_count', '-ride_count',
+ 'opening_date', '-opening_date'
+ ]
+ if ordering in valid_orderings:
+ queryset = queryset.order_by(ordering)
+ else:
+ queryset = queryset.order_by('name') # Default fallback
+
+ # Apply other filters through service
+ filtered_queryset = self.filter_service.get_filtered_queryset(filter_params)
+
+ # Combine with optimized queryset maintaining the optimizations
+ final_queryset = queryset.filter(
+ pk__in=filtered_queryset.values_list('pk', flat=True)
+ )
+
+ # Create filterset for form rendering
+ self.filterset = self.filter_class(self.request.GET, queryset=final_queryset)
return self.filterset.qs
+
except Exception as e:
messages.error(self.request, f"Error loading parks: {str(e)}")
queryset = self.model.objects.none()
@@ -275,6 +316,12 @@ class ParkListView(HTMXFilterableMixin, ListView):
filter_counts = self.filter_service.get_filter_counts()
popular_filters = self.filter_service.get_popular_filters()
+ # Calculate active filters for chips component
+ active_filters = {}
+ for key, value in self.request.GET.items():
+ if key not in ['page', 'view_mode'] and value:
+ active_filters[key] = value
+
context.update(
{
"view_mode": self.get_view_mode(),
@@ -282,6 +329,9 @@ class ParkListView(HTMXFilterableMixin, ListView):
"search_query": self.request.GET.get("search", ""),
"filter_counts": filter_counts,
"popular_filters": popular_filters,
+ "active_filters": active_filters,
+ "filter_count": len(active_filters),
+ "current_ordering": self.request.GET.get("ordering", "name"),
"total_results": (
context.get("paginator").count
if context.get("paginator")
diff --git a/apps/parks/views_autocomplete.py b/apps/parks/views_autocomplete.py
new file mode 100644
index 00000000..dffef9be
--- /dev/null
+++ b/apps/parks/views_autocomplete.py
@@ -0,0 +1,178 @@
+"""
+Park search autocomplete views for enhanced search functionality.
+Provides fast, cached autocomplete suggestions for park search.
+"""
+
+from typing import Dict, List, Any
+from django.http import JsonResponse
+from django.views import View
+from django.core.cache import cache
+from django.db.models import Q
+from django.utils.decorators import method_decorator
+from django.views.decorators.cache import cache_page
+
+from .models import Park
+from .models.companies import Company
+from .services.filter_service import ParkFilterService
+
+
+class ParkAutocompleteView(View):
+ """
+ Provides autocomplete suggestions for park search.
+ Returns JSON with park names, operators, and location suggestions.
+ """
+
+ def get(self, request):
+ """Handle GET request for autocomplete suggestions."""
+ query = request.GET.get('q', '').strip()
+
+ if len(query) < 2:
+ return JsonResponse({
+ 'suggestions': [],
+ 'message': 'Type at least 2 characters to search'
+ })
+
+ # Check cache first
+ cache_key = f"park_autocomplete:{query.lower()}"
+ cached_result = cache.get(cache_key)
+
+ if cached_result:
+ return JsonResponse(cached_result)
+
+ # Generate suggestions
+ suggestions = self._get_suggestions(query)
+
+ # Cache results for 5 minutes
+ result = {
+ 'suggestions': suggestions,
+ 'query': query
+ }
+ cache.set(cache_key, result, 300)
+
+ return JsonResponse(result)
+
+ def _get_suggestions(self, query: str) -> List[Dict[str, Any]]:
+ """Generate autocomplete suggestions based on query."""
+ suggestions = []
+
+ # Park name suggestions (top 5)
+ park_suggestions = self._get_park_suggestions(query)
+ suggestions.extend(park_suggestions)
+
+ # Operator suggestions (top 3)
+ operator_suggestions = self._get_operator_suggestions(query)
+ suggestions.extend(operator_suggestions)
+
+ # Location suggestions (top 3)
+ location_suggestions = self._get_location_suggestions(query)
+ suggestions.extend(location_suggestions)
+
+ # Remove duplicates and limit results
+ seen = set()
+ unique_suggestions = []
+ for suggestion in suggestions:
+ key = suggestion['name'].lower()
+ if key not in seen:
+ seen.add(key)
+ unique_suggestions.append(suggestion)
+
+ return unique_suggestions[:10] # Limit to 10 suggestions
+
+ def _get_park_suggestions(self, query: str) -> List[Dict[str, Any]]:
+ """Get park name suggestions."""
+ parks = Park.objects.filter(
+ name__icontains=query,
+ status='OPERATING'
+ ).select_related('operator').order_by('name')[:5]
+
+ suggestions = []
+ for park in parks:
+ suggestion = {
+ 'name': park.name,
+ 'type': 'park',
+ 'operator': park.operator.name if park.operator else None,
+ 'url': f'/parks/{park.slug}/' if park.slug else None
+ }
+ suggestions.append(suggestion)
+
+ return suggestions
+
+ def _get_operator_suggestions(self, query: str) -> List[Dict[str, Any]]:
+ """Get operator suggestions."""
+ operators = Company.objects.filter(
+ roles__contains=['OPERATOR'],
+ name__icontains=query
+ ).order_by('name')[:3]
+
+ suggestions = []
+ for operator in operators:
+ suggestion = {
+ 'name': operator.name,
+ 'type': 'operator',
+ 'park_count': operator.operated_parks.filter(status='OPERATING').count()
+ }
+ suggestions.append(suggestion)
+
+ return suggestions
+
+ def _get_location_suggestions(self, query: str) -> List[Dict[str, Any]]:
+ """Get location (city/country) suggestions."""
+ # Get unique cities
+ city_parks = Park.objects.filter(
+ location__city__icontains=query,
+ status='OPERATING'
+ ).select_related('location').order_by('location__city').distinct()[:2]
+
+ # Get unique countries
+ country_parks = Park.objects.filter(
+ location__country__icontains=query,
+ status='OPERATING'
+ ).select_related('location').order_by('location__country').distinct()[:2]
+
+ suggestions = []
+
+ # Add city suggestions
+ for park in city_parks:
+ if park.location and park.location.city:
+ city_name = park.location.city
+ if park.location.country:
+ city_name += f", {park.location.country}"
+
+ suggestion = {
+ 'name': city_name,
+ 'type': 'location',
+ 'location_type': 'city'
+ }
+ suggestions.append(suggestion)
+
+ # Add country suggestions
+ for park in country_parks:
+ if park.location and park.location.country:
+ suggestion = {
+ 'name': park.location.country,
+ 'type': 'location',
+ 'location_type': 'country'
+ }
+ suggestions.append(suggestion)
+
+ return suggestions
+
+
+@method_decorator(cache_page(60 * 5), name='dispatch') # Cache for 5 minutes
+class QuickFilterSuggestionsView(View):
+ """
+ Provides quick filter suggestions and popular filters.
+ Used for search dropdown quick actions.
+ """
+
+ def get(self, request):
+ """Handle GET request for quick filter suggestions."""
+ filter_service = ParkFilterService()
+ popular_filters = filter_service.get_popular_filters()
+ filter_counts = filter_service.get_filter_counts()
+
+ return JsonResponse({
+ 'quick_filters': popular_filters.get('quick_filters', []),
+ 'filter_counts': filter_counts,
+ 'recommended_sorts': popular_filters.get('recommended_sorts', [])
+ })
\ No newline at end of file
diff --git a/static/css/tailwind.css b/static/css/tailwind.css
index fa09d9fb..3199a522 100644
--- a/static/css/tailwind.css
+++ b/static/css/tailwind.css
@@ -581,6 +581,9 @@
.mt-auto {
margin-top: auto;
}
+ .-mr-1 {
+ margin-right: calc(var(--spacing) * -1);
+ }
.mr-1 {
margin-right: calc(var(--spacing) * 1);
}
@@ -788,6 +791,9 @@
.min-h-\[120px\] {
min-height: 120px;
}
+ .min-h-\[400px\] {
+ min-height: 400px;
+ }
.min-h-screen {
min-height: 100vh;
}
@@ -929,6 +935,9 @@
.grow {
flex-grow: 1;
}
+ .origin-top-right {
+ transform-origin: top right;
+ }
.-translate-x-1\/2 {
--tw-translate-x: calc(calc(1/2 * 100%) * -1);
translate: var(--tw-translate-x) var(--tw-translate-y);
@@ -1322,9 +1331,6 @@
.border-gray-300 {
border-color: var(--color-gray-300);
}
- .border-gray-600 {
- border-color: var(--color-gray-600);
- }
.border-gray-700 {
border-color: var(--color-gray-700);
}
@@ -1472,9 +1478,6 @@
.bg-gray-600 {
background-color: var(--color-gray-600);
}
- .bg-gray-700 {
- background-color: var(--color-gray-700);
- }
.bg-gray-800 {
background-color: var(--color-gray-800);
}
@@ -1758,6 +1761,9 @@
.object-cover {
object-fit: cover;
}
+ .p-0\.5 {
+ padding: calc(var(--spacing) * 0.5);
+ }
.p-1 {
padding: calc(var(--spacing) * 1);
}
@@ -1857,6 +1863,9 @@
.pr-10 {
padding-right: calc(var(--spacing) * 10);
}
+ .pr-12 {
+ padding-right: calc(var(--spacing) * 12);
+ }
.pr-16 {
padding-right: calc(var(--spacing) * 16);
}
@@ -2133,6 +2142,9 @@
.text-yellow-800 {
color: var(--color-yellow-800);
}
+ .capitalize {
+ text-transform: capitalize;
+ }
.uppercase {
text-transform: uppercase;
}
@@ -2142,11 +2154,6 @@
.underline-offset-4 {
text-underline-offset: 4px;
}
- .placeholder-gray-400 {
- &::placeholder {
- color: var(--color-gray-400);
- }
- }
.placeholder-gray-500 {
&::placeholder {
color: var(--color-gray-500);
@@ -2890,13 +2897,6 @@
}
}
}
- .hover\:text-white {
- &:hover {
- @media (hover: hover) {
- color: var(--color-white);
- }
- }
- }
.hover\:underline {
&:hover {
@media (hover: hover) {
@@ -3256,11 +3256,6 @@
grid-column: span 3 / span 3;
}
}
- .md\:mt-0 {
- @media (width >= 48rem) {
- margin-top: calc(var(--spacing) * 0);
- }
- }
.md\:mb-8 {
@media (width >= 48rem) {
margin-bottom: calc(var(--spacing) * 8);
@@ -3296,11 +3291,6 @@
height: 140px;
}
}
- .md\:h-full {
- @media (width >= 48rem) {
- height: 100%;
- }
- }
.md\:w-7 {
@media (width >= 48rem) {
width: calc(var(--spacing) * 7);
@@ -3311,11 +3301,6 @@
width: calc(var(--spacing) * 48);
}
}
- .md\:flex-shrink-0 {
- @media (width >= 48rem) {
- flex-shrink: 0;
- }
- }
.md\:grid-cols-2 {
@media (width >= 48rem) {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -3341,21 +3326,6 @@
align-items: center;
}
}
- .md\:items-end {
- @media (width >= 48rem) {
- align-items: flex-end;
- }
- }
- .md\:items-start {
- @media (width >= 48rem) {
- align-items: flex-start;
- }
- }
- .md\:justify-between {
- @media (width >= 48rem) {
- justify-content: space-between;
- }
- }
.md\:gap-4 {
@media (width >= 48rem) {
gap: calc(var(--spacing) * 4);
@@ -4390,6 +4360,18 @@
}
}
}
+ .dark\:hover\:bg-blue-800\/50 {
+ @media (prefers-color-scheme: dark) {
+ &:hover {
+ @media (hover: hover) {
+ background-color: color-mix(in srgb, oklch(42.4% 0.199 265.638) 50%, transparent);
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: color-mix(in oklab, var(--color-blue-800) 50%, transparent);
+ }
+ }
+ }
+ }
+ }
.dark\:hover\:bg-blue-900 {
@media (prefers-color-scheme: dark) {
&:hover {
@@ -4662,6 +4644,13 @@
}
}
}
+ .dark\:focus\:border-blue-500 {
+ @media (prefers-color-scheme: dark) {
+ &:focus {
+ border-color: var(--color-blue-500);
+ }
+ }
+ }
.dark\:focus\:bg-gray-700 {
@media (prefers-color-scheme: dark) {
&:focus {
@@ -4676,6 +4665,13 @@
}
}
}
+ .dark\:focus\:ring-blue-500 {
+ @media (prefers-color-scheme: dark) {
+ &:focus {
+ --tw-ring-color: var(--color-blue-500);
+ }
+ }
+ }
.dark\:focus\:ring-blue-600 {
@media (prefers-color-scheme: dark) {
&:focus {
diff --git a/templates/cotton/enhanced_search.html b/templates/cotton/enhanced_search.html
new file mode 100644
index 00000000..1d018c2e
--- /dev/null
+++ b/templates/cotton/enhanced_search.html
@@ -0,0 +1,232 @@
+{% comment %}
+Enhanced Search Component - Django Cotton Version
+
+Advanced search input with autocomplete suggestions, debouncing, and loading states.
+Provides real-time search with suggestions and filtering integration.
+
+Usage Examples:
+
+
No results found
++ Try adjusting your search or removing some filters to see more results. +
+• Check your spelling
+• Try more general terms
+• Remove filters to broaden your search
++ Discover amazing theme parks around the world +
+ Discover amazing theme parks around the world +
+- - {% spaceless %} - {% if park.city %}{{ park.city }}{% endif %}{% if park.city and park.state %}, {% endif %}{% if park.state %}{{ park.state }}{% endif %}{% if park.country and park.state or park.city %}, {% endif %}{% if park.country %}{{ park.country }}{% endif %} - {% endspaceless %} -
- {% endif %} - {% if park.operator %} -- {{ park.operator.name }} -
- {% endif %} -No parks found matching your criteria.
No parks found matching your criteria.
-- - {% spaceless %} - {% if park.city %}{{ park.city }}{% endif %}{% if park.city and park.state %}, {% endif %}{% if park.state %}{{ park.state }}{% endif %}{% if park.country and park.state or park.city %}, {% endif %}{% if park.country %}{{ park.country }}{% endif %} - {% endspaceless %} -
- {% endif %} -No parks found matching your criteria.
No parks found matching your criteria.
-