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: + + + + + +Parameters: +- placeholder: Search input placeholder text (default: "Search parks...") +- current_value: Current search value (optional) +- autocomplete_url: URL for autocomplete suggestions (optional) +- debounce_delay: Debounce delay in milliseconds (default: 300) +- class: Additional CSS classes (optional) + +Features: +- Real-time search with debouncing +- Autocomplete dropdown with suggestions +- Loading states and indicators +- HTMX integration for seamless search +- Keyboard navigation support +- Clear button functionality +{% endcomment %} + + + +
+ +
+ +
+ + + +
+ + + + + +
+ + + + +
+ + + +
+ + + +
\ No newline at end of file diff --git a/templates/cotton/filter_chips.html b/templates/cotton/filter_chips.html new file mode 100644 index 00000000..d5750dca --- /dev/null +++ b/templates/cotton/filter_chips.html @@ -0,0 +1,81 @@ +{% comment %} +Filter Chips Component - Django Cotton Version + +Displays active filters as removable chips/badges with clear functionality. +Shows current filter state and allows users to remove individual filters. + +Usage Examples: + + + +Parameters: +- filters: Dictionary of active filters (required) +- base_url: Base URL for filter removal links (default: current URL) +- class: Additional CSS classes (optional) + +Features: +- Clean chip design with remove buttons +- HTMX integration for seamless removal +- Support for various filter types +- Accessible with proper ARIA labels +- Shows filter count in chips +{% endcomment %} + + + +{% if filters %} +
+ {% for filter_name, filter_value in filters.items %} + {% if filter_value and filter_name != 'page' and filter_name != 'view_mode' %} +
+ {{ filter_name|title }}: + + {% if filter_value == 'True' %} + Yes + {% elif filter_value == 'False' %} + No + {% else %} + {{ filter_value }} + {% endif %} + + +
+ {% endif %} + {% endfor %} + + {% if filters|length > 1 %} + + {% endif %} +
+{% endif %} \ No newline at end of file diff --git a/templates/cotton/result_stats.html b/templates/cotton/result_stats.html new file mode 100644 index 00000000..7e96f741 --- /dev/null +++ b/templates/cotton/result_stats.html @@ -0,0 +1,122 @@ +{% comment %} +Result Statistics Component - Django Cotton Version + +Displays result counts, filter summaries, and statistics for park listings. +Shows current page info, total results, and search context. + +Usage Examples: + + + + + +Parameters: +- total_results: Total number of results (required) +- page_obj: Django page object for pagination info (optional) +- search_query: Current search query (optional) +- is_search: Whether this is a search result (default: False) +- filter_count: Number of active filters (optional) +- class: Additional CSS classes (optional) + +Features: +- Clear result count display +- Search context information +- Pagination information +- Filter summary +- Responsive design +{% endcomment %} + + + +
+
+ +
+ {% if total_results == 0 %} + + {% if is_search %} + No parks found + {% if search_query %} + for "{{ search_query }}" + {% endif %} + {% else %} + No parks available + {% endif %} + + {% elif total_results == 1 %} + 1 park + {% if is_search and search_query %} + found for "{{ search_query }}" + {% endif %} + {% else %} + {{ total_results|floatformat:0 }} parks + {% if is_search and search_query %} + found for "{{ search_query }}" + {% endif %} + {% endif %} +
+ + + {% if filter_count and filter_count > 0 %} +
+ + + + {{ filter_count }} filter{{ filter_count|pluralize }} active +
+ {% endif %} +
+ + + {% if page_obj and page_obj.has_other_pages %} +
+ + Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} + + {% if page_obj.start_index and page_obj.end_index %} + | + + Showing {{ page_obj.start_index }}-{{ page_obj.end_index }} + + {% endif %} +
+ {% endif %} +
+ + +{% if total_results == 0 and is_search %} +
+
+ + + +
+

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

+
+
+
+
+{% endif %} \ No newline at end of file diff --git a/templates/cotton/sort_controls.html b/templates/cotton/sort_controls.html new file mode 100644 index 00000000..8eaaedf9 --- /dev/null +++ b/templates/cotton/sort_controls.html @@ -0,0 +1,193 @@ +{% comment %} +Sort Controls Component - Django Cotton Version + +Provides sorting dropdown with common sort options for park listings. +Integrates with HTMX for seamless sorting without page reloads. + +Usage Examples: + + + + + +Parameters: +- current_sort: Currently selected sort option (default: "name") +- options: Custom sort options list (optional, uses defaults if not provided) +- class: Additional CSS classes (optional) + +Features: +- Dropdown with common sort options +- HTMX integration for seamless sorting +- Visual indicators for current sort +- Accessible with proper ARIA labels +- Support for ascending/descending indicators +{% endcomment %} + + + +
+
+ +
+ + +
\ No newline at end of file diff --git a/templates/cotton/view_toggle.html b/templates/cotton/view_toggle.html new file mode 100644 index 00000000..0fdef931 --- /dev/null +++ b/templates/cotton/view_toggle.html @@ -0,0 +1,67 @@ +{% comment %} +View Toggle Component - Django Cotton Version + +Provides toggle between grid and list view modes with visual indicators. +Integrates with HTMX for seamless view switching without page reloads. + +Usage Examples: + + + + + +Parameters: +- current_view: Currently selected view mode ("grid" or "list", default: "grid") +- class: Additional CSS classes (optional) + +Features: +- Clean toggle button design +- Visual indicators for current view +- HTMX integration for seamless switching +- Accessible with proper ARIA labels +- Icons for grid and list views +{% endcomment %} + + + +
+ + + +
\ No newline at end of file diff --git a/templates/parks/park_list.html b/templates/parks/park_list.html index 28a78a75..2a181cdf 100644 --- a/templates/parks/park_list.html +++ b/templates/parks/park_list.html @@ -1,93 +1,301 @@ {% extends "base/base.html" %} {% load static %} +{% load cotton %} {% block title %}Parks{% endblock %} {% block content %} -
- -
-
- -
-
-
- - - -
- -
- - - - -
-
+
+ +
+
+
+

+ Theme Parks +

+

+ Discover amazing theme parks around the world +

- - -
- -
- Parks - {% if total_results %} - ({{ total_results }} found) - {% endif %} + + +
+
+
{{ filter_counts.total_parks|default:0 }}
+
Total Parks
- - -
- - - - - - - +
+
{{ filter_counts.operating_parks|default:0 }}
+
Operating
+
+
+
{{ filter_counts.parks_with_coasters|default:0 }}
+
With Coasters
- -
- {% include "parks/partials/park_list.html" %} + +
+
+ +
+ +
+ +
+ + +
+ + + + + + + + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+
+
+ + + {% if active_filters %} +
+ +
+ {% endif %} +
+
+ + +
+ + + + +
+
+
+
+ + + + + Loading parks... +
+
+
+
+ + +
+ {% include "parks/partials/park_list.html" %} +
-{% endblock %} + + + +{% endblock %} \ No newline at end of file diff --git a/templates/parks/park_list_enhanced.html b/templates/parks/park_list_enhanced.html new file mode 100644 index 00000000..2a181cdf --- /dev/null +++ b/templates/parks/park_list_enhanced.html @@ -0,0 +1,301 @@ +{% extends "base/base.html" %} +{% load static %} +{% load cotton %} + +{% block title %}Parks{% endblock %} + +{% block content %} +
+ +
+
+
+

+ Theme Parks +

+

+ Discover amazing theme parks around the world +

+
+ + +
+
+
{{ filter_counts.total_parks|default:0 }}
+
Total Parks
+
+
+
{{ filter_counts.operating_parks|default:0 }}
+
Operating
+
+
+
{{ filter_counts.parks_with_coasters|default:0 }}
+
With Coasters
+
+
+
+
+ + +
+
+ +
+ +
+ +
+ + +
+ + + + + + + + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+
+
+ + + {% if active_filters %} +
+ +
+ {% endif %} +
+
+ + +
+ + + + +
+
+
+
+ + + + + Loading parks... +
+
+
+
+ + +
+ {% include "parks/partials/park_list.html" %} +
+
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/templates/parks/partials/park_list.html b/templates/parks/partials/park_list.html index 43db0f80..61f38824 100644 --- a/templates/parks/partials/park_list.html +++ b/templates/parks/partials/park_list.html @@ -1,119 +1,27 @@ +{% load cotton %} + {% if view_mode == 'list' %} - -
- {% for park in parks %} -
-
- {% if park.photos.exists %} -
- {{ park.name }} -
- {% endif %} -
-
-
-

- - {{ park.name }} - -

- {% if park.city or park.state or park.country %} -

- - {% 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 %} -
-
- - {{ park.get_status_display }} - - {% if park.average_rating %} - - - {{ park.average_rating|floatformat:1 }}/10 - - {% endif %} -
-
-
+ +
+ {% for park in parks %} + + {% empty %} +
+

No parks found matching your criteria.

-
- {% empty %} -
-

No parks found matching your criteria.

-
- {% endfor %} -
+ {% endfor %} +
{% else %} - -
- {% for park in parks %} -
- {% if park.photos.exists %} -
- {{ park.name }} -
- {% endif %} -
-

- - {{ park.name }} - -

- {% if park.city or park.state or park.country %} -

- - {% 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 %} -
- - {{ park.get_status_display }} - - {% if park.average_rating %} - - - {{ park.average_rating|floatformat:1 }}/10 - - {% endif %} -
- {% if park.operator %} -
- {{ park.operator.name }} -
- {% endif %} + +
+ {% for park in parks %} + + {% empty %} +
+

No parks found matching your criteria.

-
- {% empty %} -
-

No parks found matching your criteria.

-
- {% endfor %} -
+ {% endfor %} +
{% endif %}