feat: add price filter and dynamic slider to catalog
All checks were successful
Build and Push Docker Image / build (push) Successful in 41s
All checks were successful
Build and Push Docker Image / build (push) Successful in 41s
- Implemented JavaScript-based price slider for catalog filtering - Updated catalog views to handle min/max price inputs and compute slider bounds - Enhanced templates (`index.html`, `side-bar.html`) with filtering form and updated URLs - Added CSS for price slider styling and form layout - Introduced utility functions for URL building and price parsing - Expanded tests for price filtering, sliders, and sidebar active states - Updated context processor and header for dynamic navigation with price filters
This commit is contained in:
@@ -7,6 +7,7 @@ ALLOWED_HOSTS=example.com,www.example.com
|
|||||||
CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
|
CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
|
||||||
USE_X_FORWARDED_HOST=true
|
USE_X_FORWARDED_HOST=true
|
||||||
SECURE_PROXY_SSL_HEADER=HTTP_X_FORWARDED_PROTO,https
|
SECURE_PROXY_SSL_HEADER=HTTP_X_FORWARDED_PROTO,https
|
||||||
|
SECURE_REFERRER_POLICY=strict-origin-when-cross-origin
|
||||||
SITE_NAME=SantehRay
|
SITE_NAME=SantehRay
|
||||||
LOGIN_URL=/users/login/
|
LOGIN_URL=/users/login/
|
||||||
LOGIN_REDIRECT_URL=/users/profile/
|
LOGIN_REDIRECT_URL=/users/profile/
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from django.test import SimpleTestCase
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
from santehray.settings import collect_hosts, collect_origins, normalize_host, normalize_origin
|
from santehray.settings import (
|
||||||
|
collect_hosts,
|
||||||
|
collect_origins,
|
||||||
|
env_choice,
|
||||||
|
normalize_host,
|
||||||
|
normalize_origin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SettingsNormalizationTests(SimpleTestCase):
|
class SettingsNormalizationTests(SimpleTestCase):
|
||||||
@@ -26,3 +32,25 @@ class SettingsNormalizationTests(SimpleTestCase):
|
|||||||
|
|
||||||
def test_normalize_origin_preserves_explicit_scheme_and_port(self):
|
def test_normalize_origin_preserves_explicit_scheme_and_port(self):
|
||||||
self.assertEqual(normalize_origin("https://preview-1.azimkin.dev:8443"), "https://preview-1.azimkin.dev:8443")
|
self.assertEqual(normalize_origin("https://preview-1.azimkin.dev:8443"), "https://preview-1.azimkin.dev:8443")
|
||||||
|
|
||||||
|
def test_env_choice_accepts_allowed_value(self):
|
||||||
|
with patch.dict(os.environ, {"SECURE_REFERRER_POLICY": "strict-origin-when-cross-origin"}):
|
||||||
|
self.assertEqual(
|
||||||
|
env_choice(
|
||||||
|
"SECURE_REFERRER_POLICY",
|
||||||
|
"same-origin",
|
||||||
|
{"same-origin", "strict-origin-when-cross-origin"},
|
||||||
|
),
|
||||||
|
"strict-origin-when-cross-origin",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_env_choice_falls_back_for_invalid_value(self):
|
||||||
|
with patch.dict(os.environ, {"SECURE_REFERRER_POLICY": "invalid-policy"}):
|
||||||
|
self.assertEqual(
|
||||||
|
env_choice(
|
||||||
|
"SECURE_REFERRER_POLICY",
|
||||||
|
"same-origin",
|
||||||
|
{"same-origin", "strict-origin-when-cross-origin"},
|
||||||
|
),
|
||||||
|
"same-origin",
|
||||||
|
)
|
||||||
|
|||||||
137
items/tests.py
137
items/tests.py
@@ -73,6 +73,114 @@ class CatalogViewTests(TestCase):
|
|||||||
self.assertContains(response, "Кухонная мойка")
|
self.assertContains(response, "Кухонная мойка")
|
||||||
self.assertNotContains(response, "Смеситель Mono")
|
self.assertNotContains(response, "Смеситель Mono")
|
||||||
|
|
||||||
|
def test_catalog_search_filters_items_by_name(self):
|
||||||
|
response = self.client.get("/catalog/", {"q": "мойка"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Кухонная мойка")
|
||||||
|
self.assertNotContains(response, "Зеркало Terra")
|
||||||
|
|
||||||
|
def test_catalog_search_is_case_insensitive(self):
|
||||||
|
response = self.client.get("/catalog/", {"q": "МОЙКА"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Кухонная мойка")
|
||||||
|
self.assertNotContains(response, "Зеркало Terra")
|
||||||
|
|
||||||
|
def test_category_search_filters_items_within_selected_category(self):
|
||||||
|
Item.objects.create(
|
||||||
|
name="Смеситель Terra",
|
||||||
|
slug="terra-tap",
|
||||||
|
category=self.plumbing,
|
||||||
|
subcategory=self.taps,
|
||||||
|
description="Смеситель для ванной",
|
||||||
|
price="450.00",
|
||||||
|
is_available=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get("/catalog/plumbing/", {"q": "terra"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Смеситель Terra")
|
||||||
|
self.assertNotContains(response, "Зеркало Terra")
|
||||||
|
|
||||||
|
def test_subcategory_search_filters_items_within_selected_subcategory(self):
|
||||||
|
Item.objects.create(
|
||||||
|
name="Terra Смеситель",
|
||||||
|
slug="terra-tap",
|
||||||
|
category=self.plumbing,
|
||||||
|
subcategory=self.taps,
|
||||||
|
description="Смеситель для раковины",
|
||||||
|
price="450.00",
|
||||||
|
is_available=True,
|
||||||
|
)
|
||||||
|
Item.objects.create(
|
||||||
|
name="Terra Мойка",
|
||||||
|
slug="terra-sink",
|
||||||
|
category=self.plumbing,
|
||||||
|
subcategory=self.sinks,
|
||||||
|
description="Кухонная мойка",
|
||||||
|
price="900.00",
|
||||||
|
is_available=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get("/catalog/plumbing/", {"subcategory": "sinks", "q": "terra"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Terra Мойка")
|
||||||
|
self.assertNotContains(response, "Terra Смеситель")
|
||||||
|
|
||||||
|
def test_min_price_filters_items(self):
|
||||||
|
response = self.client.get("/catalog/", {"min_price": "300"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Кухонная мойка")
|
||||||
|
self.assertNotContains(response, "Зеркало Terra")
|
||||||
|
|
||||||
|
def test_max_price_filters_items(self):
|
||||||
|
response = self.client.get("/catalog/", {"max_price": "300"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Зеркало Terra")
|
||||||
|
self.assertNotContains(response, "Кухонная мойка")
|
||||||
|
|
||||||
|
def test_price_range_filters_items(self):
|
||||||
|
Item.objects.create(
|
||||||
|
name="Смеситель Mono",
|
||||||
|
slug="mono-tap",
|
||||||
|
category=self.plumbing,
|
||||||
|
subcategory=self.taps,
|
||||||
|
description="Однорычажный смеситель",
|
||||||
|
price="320.00",
|
||||||
|
is_available=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.client.get("/catalog/", {"min_price": "300", "max_price": "500"})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Смеситель Mono")
|
||||||
|
self.assertNotContains(response, "Кухонная мойка")
|
||||||
|
self.assertNotContains(response, "Зеркало Terra")
|
||||||
|
|
||||||
|
def test_header_search_redirects_to_selected_category_url(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/catalog/",
|
||||||
|
{"category": "category:plumbing", "q": "terra", "min_price": "100", "max_price": "500"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(response, "/catalog/plumbing/?q=terra&min_price=100&max_price=500")
|
||||||
|
|
||||||
|
def test_header_search_redirects_to_selected_subcategory_url(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/catalog/",
|
||||||
|
{"category": "subcategory:plumbing:sinks", "q": "terra", "min_price": "100", "max_price": "500"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(
|
||||||
|
response,
|
||||||
|
"/catalog/plumbing/?subcategory=sinks&q=terra&min_price=100&max_price=500",
|
||||||
|
)
|
||||||
|
|
||||||
def test_unknown_subcategory_returns_404(self):
|
def test_unknown_subcategory_returns_404(self):
|
||||||
response = self.client.get("/catalog/plumbing/?subcategory=unknown")
|
response = self.client.get("/catalog/plumbing/?subcategory=unknown")
|
||||||
|
|
||||||
@@ -95,6 +203,35 @@ class CatalogViewTests(TestCase):
|
|||||||
self.assertContains(response, "Сантехника")
|
self.assertContains(response, "Сантехника")
|
||||||
self.assertContains(response, "Декор")
|
self.assertContains(response, "Декор")
|
||||||
|
|
||||||
|
def test_sidebar_links_preserve_active_search_and_price_filters(self):
|
||||||
|
response = self.client.get("/catalog/plumbing/", {"q": "terra", "min_price": "100", "max_price": "500"})
|
||||||
|
|
||||||
|
self.assertContains(response, 'href="/catalog/?q=terra&min_price=100&max_price=500"')
|
||||||
|
self.assertContains(response, 'href="/catalog/plumbing/?q=terra&min_price=100&max_price=500"')
|
||||||
|
self.assertContains(
|
||||||
|
response,
|
||||||
|
'href="/catalog/plumbing/?subcategory=sinks&q=terra&min_price=100&max_price=500"',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_catalog_forms_repeat_active_search_and_price_values(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/catalog/plumbing/",
|
||||||
|
{"subcategory": "sinks", "q": "terra", "min_price": "100", "max_price": "500"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertContains(response, 'name="q" value="terra"', html=False)
|
||||||
|
self.assertContains(response, 'name="min_price" value="100"', html=False)
|
||||||
|
self.assertContains(response, 'name="max_price" value="500"', html=False)
|
||||||
|
|
||||||
|
def test_catalog_price_filter_renders_range_slider(self):
|
||||||
|
response = self.client.get("/catalog/")
|
||||||
|
|
||||||
|
self.assertContains(response, 'data-price-slider', html=False)
|
||||||
|
self.assertContains(response, 'type="range"', count=2, html=False)
|
||||||
|
self.assertContains(response, 'min="200.00"', html=False)
|
||||||
|
self.assertContains(response, 'max="1600.00"', html=False)
|
||||||
|
self.assertContains(response, 'src="/static/js/catalog-price-slider.js"', html=False)
|
||||||
|
|
||||||
def test_item_card_uses_primary_image(self):
|
def test_item_card_uses_primary_image(self):
|
||||||
ItemImage.objects.create(
|
ItemImage.objects.create(
|
||||||
item=self.sink,
|
item=self.sink,
|
||||||
|
|||||||
156
items/views.py
156
items/views.py
@@ -1,7 +1,9 @@
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from django.http import HttpRequest, HttpResponse, Http404
|
from django.http import HttpRequest, HttpResponse, Http404
|
||||||
from django.shortcuts import render
|
from django.shortcuts import redirect, render
|
||||||
|
|
||||||
from favorites.services import get_user_favorite_item_ids
|
from favorites.services import get_user_favorite_item_ids
|
||||||
from items.models import Category, Item, Subcategory
|
from items.models import Category, Item, Subcategory
|
||||||
@@ -20,11 +22,90 @@ class SelectedSubcategory:
|
|||||||
id: str | None
|
id: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_price(value: str | None) -> Decimal | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized_value = value.strip()
|
||||||
|
if not normalized_value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return Decimal(normalized_value)
|
||||||
|
except InvalidOperation:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_catalog_url(
|
||||||
|
category_slug: str | None = None,
|
||||||
|
subcategory_slug: str | None = None,
|
||||||
|
*,
|
||||||
|
search_query: str = "",
|
||||||
|
min_price: str = "",
|
||||||
|
max_price: str = "",
|
||||||
|
) -> str:
|
||||||
|
base_url = "/catalog/"
|
||||||
|
if category_slug:
|
||||||
|
base_url = f"/catalog/{category_slug}/"
|
||||||
|
|
||||||
|
query_params: dict[str, str] = {}
|
||||||
|
if subcategory_slug:
|
||||||
|
query_params["subcategory"] = subcategory_slug
|
||||||
|
if search_query:
|
||||||
|
query_params["q"] = search_query
|
||||||
|
if min_price:
|
||||||
|
query_params["min_price"] = min_price
|
||||||
|
if max_price:
|
||||||
|
query_params["max_price"] = max_price
|
||||||
|
|
||||||
|
if not query_params:
|
||||||
|
return base_url
|
||||||
|
|
||||||
|
return f"{base_url}?{urlencode(query_params)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_catalog_scope(scope_value: str | None) -> tuple[str | None, str | None]:
|
||||||
|
if not scope_value:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
parts = [part.strip().lower() for part in scope_value.split(":")]
|
||||||
|
|
||||||
|
if len(parts) == 2 and parts[0] == "category" and parts[1]:
|
||||||
|
return parts[1], None
|
||||||
|
|
||||||
|
if len(parts) == 3 and parts[0] == "subcategory" and parts[1] and parts[2]:
|
||||||
|
return parts[1], parts[2]
|
||||||
|
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_price_value(value: Decimal) -> str:
|
||||||
|
return format(value, "f")
|
||||||
|
|
||||||
|
|
||||||
def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
|
def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
|
||||||
selected_category = SelectedCategory(name=None, id=None)
|
selected_category = SelectedCategory(name=None, id=None)
|
||||||
selected_subcategory = SelectedSubcategory(name=None, id=None)
|
selected_subcategory = SelectedSubcategory(name=None, id=None)
|
||||||
subcategories = Subcategory.objects.none()
|
subcategories = Subcategory.objects.none()
|
||||||
subcategory_slug = request.GET.get('subcategory')
|
subcategory_slug = request.GET.get('subcategory')
|
||||||
|
search_query = request.GET.get('q', '').strip()
|
||||||
|
min_price_raw = request.GET.get('min_price', '').strip()
|
||||||
|
max_price_raw = request.GET.get('max_price', '').strip()
|
||||||
|
selected_scope = request.GET.get('category', '').strip()
|
||||||
|
|
||||||
|
if category is None and selected_scope:
|
||||||
|
category_slug, selected_subcategory_slug = _resolve_catalog_scope(selected_scope)
|
||||||
|
if category_slug:
|
||||||
|
return redirect(
|
||||||
|
_build_catalog_url(
|
||||||
|
category_slug,
|
||||||
|
selected_subcategory_slug,
|
||||||
|
search_query=search_query,
|
||||||
|
min_price=min_price_raw,
|
||||||
|
max_price=max_price_raw,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
items = (
|
items = (
|
||||||
Item.objects.select_related('category', 'subcategory')
|
Item.objects.select_related('category', 'subcategory')
|
||||||
.prefetch_related('images')
|
.prefetch_related('images')
|
||||||
@@ -52,12 +133,83 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
|
|||||||
|
|
||||||
items = items.filter(subcategory=subcategory_obj)
|
items = items.filter(subcategory=subcategory_obj)
|
||||||
|
|
||||||
|
items = list(items)
|
||||||
|
|
||||||
|
if search_query:
|
||||||
|
normalized_search_query = search_query.casefold()
|
||||||
|
items = [
|
||||||
|
item
|
||||||
|
for item in items
|
||||||
|
if normalized_search_query in item.name.casefold()
|
||||||
|
]
|
||||||
|
|
||||||
|
item_prices = [item.price for item in items]
|
||||||
|
slider_min_bound = min(item_prices, default=Decimal('0'))
|
||||||
|
slider_max_bound = max(item_prices, default=slider_min_bound)
|
||||||
|
|
||||||
|
min_price = _parse_price(min_price_raw)
|
||||||
|
if min_price is not None:
|
||||||
|
items = [item for item in items if item.price >= min_price]
|
||||||
|
|
||||||
|
max_price = _parse_price(max_price_raw)
|
||||||
|
if max_price is not None:
|
||||||
|
items = [item for item in items if item.price <= max_price]
|
||||||
|
|
||||||
|
slider_selected_min = min_price if min_price is not None else slider_min_bound
|
||||||
|
slider_selected_max = max_price if max_price is not None else slider_max_bound
|
||||||
|
slider_selected_min = min(max(slider_selected_min, slider_min_bound), slider_max_bound)
|
||||||
|
slider_selected_max = min(max(slider_selected_max, slider_min_bound), slider_max_bound)
|
||||||
|
|
||||||
|
categories = list(Category.objects.order_by('name'))
|
||||||
|
all_items_url = _build_catalog_url(
|
||||||
|
search_query=search_query,
|
||||||
|
min_price=min_price_raw,
|
||||||
|
max_price=max_price_raw,
|
||||||
|
)
|
||||||
|
for catalog_category in categories:
|
||||||
|
catalog_category.catalog_url = _build_catalog_url(
|
||||||
|
catalog_category.slug,
|
||||||
|
search_query=search_query,
|
||||||
|
min_price=min_price_raw,
|
||||||
|
max_price=max_price_raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
subcategories = list(subcategories)
|
||||||
|
selected_category_url = None
|
||||||
|
if selected_category.id is not None:
|
||||||
|
selected_category_url = _build_catalog_url(
|
||||||
|
selected_category.id,
|
||||||
|
search_query=search_query,
|
||||||
|
min_price=min_price_raw,
|
||||||
|
max_price=max_price_raw,
|
||||||
|
)
|
||||||
|
for catalog_subcategory in subcategories:
|
||||||
|
catalog_subcategory.catalog_url = _build_catalog_url(
|
||||||
|
selected_category.id,
|
||||||
|
catalog_subcategory.slug,
|
||||||
|
search_query=search_query,
|
||||||
|
min_price=min_price_raw,
|
||||||
|
max_price=max_price_raw,
|
||||||
|
)
|
||||||
|
|
||||||
|
price_filter_action = _build_catalog_url(selected_category.id)
|
||||||
|
|
||||||
return render(request, 'catalog/index.html', {
|
return render(request, 'catalog/index.html', {
|
||||||
'categories': Category.objects.order_by('name'),
|
'categories': categories,
|
||||||
'subcategories': subcategories,
|
'subcategories': subcategories,
|
||||||
'items': items,
|
'items': items,
|
||||||
'selected_category': selected_category,
|
'selected_category': selected_category,
|
||||||
'selected_subcategory': selected_subcategory,
|
'selected_subcategory': selected_subcategory,
|
||||||
|
'all_items_url': all_items_url,
|
||||||
|
'selected_category_url': selected_category_url,
|
||||||
|
'price_filter_action': price_filter_action,
|
||||||
|
'current_search_query': search_query,
|
||||||
|
'current_min_price': min_price_raw,
|
||||||
|
'current_max_price': max_price_raw,
|
||||||
|
'price_slider_min_bound': _format_price_value(slider_min_bound),
|
||||||
|
'price_slider_max_bound': _format_price_value(slider_max_bound),
|
||||||
|
'price_slider_selected_min': _format_price_value(slider_selected_min),
|
||||||
|
'price_slider_selected_max': _format_price_value(slider_selected_max),
|
||||||
'favorite_item_ids': get_user_favorite_item_ids(request.user),
|
'favorite_item_ids': get_user_favorite_item_ids(request.user),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -43,4 +43,7 @@ def navigation(request):
|
|||||||
"navigation_selected_value": selected_value,
|
"navigation_selected_value": selected_value,
|
||||||
"navigation_selected_category_slug": current_category.slug if current_category else None,
|
"navigation_selected_category_slug": current_category.slug if current_category else None,
|
||||||
"navigation_selected_subcategory_slug": current_subcategory.slug if current_subcategory else None,
|
"navigation_selected_subcategory_slug": current_subcategory.slug if current_subcategory else None,
|
||||||
|
"navigation_search_query": request.GET.get("q", "").strip(),
|
||||||
|
"navigation_min_price": request.GET.get("min_price", "").strip(),
|
||||||
|
"navigation_max_price": request.GET.get("max_price", "").strip(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ def env_log_level(name: str, default: str = "INFO") -> str:
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def env_choice(name: str, default: str, allowed: set[str]) -> str:
|
||||||
|
value = os.getenv(name, default)
|
||||||
|
if not value:
|
||||||
|
return default
|
||||||
|
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized not in allowed:
|
||||||
|
return default
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
def merge_unique(*value_lists: list[str]) -> list[str]:
|
def merge_unique(*value_lists: list[str]) -> list[str]:
|
||||||
merged: list[str] = []
|
merged: list[str] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
@@ -176,6 +187,20 @@ SECURE_PROXY_SSL_HEADER = env_pair(
|
|||||||
"SECURE_PROXY_SSL_HEADER",
|
"SECURE_PROXY_SSL_HEADER",
|
||||||
("HTTP_X_FORWARDED_PROTO", "https"),
|
("HTTP_X_FORWARDED_PROTO", "https"),
|
||||||
)
|
)
|
||||||
|
SECURE_REFERRER_POLICY = env_choice(
|
||||||
|
"SECURE_REFERRER_POLICY",
|
||||||
|
"strict-origin-when-cross-origin",
|
||||||
|
{
|
||||||
|
"no-referrer",
|
||||||
|
"no-referrer-when-downgrade",
|
||||||
|
"origin",
|
||||||
|
"origin-when-cross-origin",
|
||||||
|
"same-origin",
|
||||||
|
"strict-origin",
|
||||||
|
"strict-origin-when-cross-origin",
|
||||||
|
"unsafe-url",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,116 @@ aside {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.catalog-filter-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-price-slider {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-price-slider__values {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: #554971;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-price-slider__controls {
|
||||||
|
position: relative;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-price-slider__track {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: rgba(99, 118, 141, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-price-slider__track-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: #63768d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-price-slider__range {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 32px;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-price-slider__range::-webkit-slider-thumb {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid #63768d;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
appearance: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 118, 141, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-price-slider__range::-moz-range-thumb {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid #63768d;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 118, 141, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-price-slider__range::-webkit-slider-runnable-track {
|
||||||
|
height: 4px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-price-slider__range::-moz-range-track {
|
||||||
|
height: 4px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-price-slider__range:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-filter-submit {
|
||||||
|
min-height: 42px;
|
||||||
|
margin-top: 16px;
|
||||||
|
border: 1px solid #63768d;
|
||||||
|
background: #63768d;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: 'Noah', sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.catalog-filter-submit:hover {
|
||||||
|
background: #554971;
|
||||||
|
border-color: #554971;
|
||||||
|
}
|
||||||
|
|
||||||
.item-list {
|
.item-list {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
61
static/js/catalog-price-slider.js
Normal file
61
static/js/catalog-price-slider.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const priceFormatter = new Intl.NumberFormat('ru-RU', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatQueryValue = function (value) {
|
||||||
|
return value.toFixed(2).replace(/\.?0+$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-price-slider]').forEach(function (slider) {
|
||||||
|
const minBound = Number.parseFloat(slider.dataset.minBound || '0');
|
||||||
|
const maxBound = Number.parseFloat(slider.dataset.maxBound || slider.dataset.minBound || '0');
|
||||||
|
const minRange = slider.querySelector('[data-price-range-min]');
|
||||||
|
const maxRange = slider.querySelector('[data-price-range-max]');
|
||||||
|
const minHidden = slider.querySelector('[data-price-hidden-min]');
|
||||||
|
const maxHidden = slider.querySelector('[data-price-hidden-max]');
|
||||||
|
const minLabel = slider.querySelector('[data-price-label-min]');
|
||||||
|
const maxLabel = slider.querySelector('[data-price-label-max]');
|
||||||
|
const trackFill = slider.querySelector('[data-price-track-fill]');
|
||||||
|
|
||||||
|
if (!minRange || !maxRange || !minHidden || !maxHidden || !minLabel || !maxLabel || !trackFill) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSlider = function () {
|
||||||
|
const minValue = Number.parseFloat(minRange.value);
|
||||||
|
const maxValue = Number.parseFloat(maxRange.value);
|
||||||
|
const rangeSpan = maxBound - minBound;
|
||||||
|
const left = rangeSpan > 0 ? ((minValue - minBound) / rangeSpan) * 100 : 0;
|
||||||
|
const right = rangeSpan > 0 ? ((maxValue - minBound) / rangeSpan) * 100 : 100;
|
||||||
|
|
||||||
|
trackFill.style.left = left + '%';
|
||||||
|
trackFill.style.width = Math.max(right - left, 0) + '%';
|
||||||
|
|
||||||
|
minLabel.textContent = priceFormatter.format(minValue) + ' руб.';
|
||||||
|
maxLabel.textContent = priceFormatter.format(maxValue) + ' руб.';
|
||||||
|
|
||||||
|
minHidden.value = minValue <= minBound ? '' : formatQueryValue(minValue);
|
||||||
|
maxHidden.value = maxValue >= maxBound ? '' : formatQueryValue(maxValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
minRange.addEventListener('input', function () {
|
||||||
|
if (Number.parseFloat(minRange.value) > Number.parseFloat(maxRange.value)) {
|
||||||
|
minRange.value = maxRange.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSlider();
|
||||||
|
});
|
||||||
|
|
||||||
|
maxRange.addEventListener('input', function () {
|
||||||
|
if (Number.parseFloat(maxRange.value) < Number.parseFloat(minRange.value)) {
|
||||||
|
maxRange.value = minRange.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSlider();
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSlider();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header-top">
|
<div class="header-top">
|
||||||
<div class="logo"><a href="/">SantehRay</a></div>
|
<div class="logo"><a href="/">SantehRay</a></div>
|
||||||
<form action="">
|
<form action="/catalog/" method="get">
|
||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
<div class="search-select-wrap">
|
<div class="search-select-wrap">
|
||||||
<div class="search-category-dropdown" data-category-dropdown>
|
<div class="search-category-dropdown" data-category-dropdown>
|
||||||
@@ -67,9 +67,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
<input class="search-input" type="text" placeholder="Кто ищет, тот найдет...">
|
<input class="search-input" type="text" name="q" value="{{ navigation_search_query }}" placeholder="Кто ищет, тот найдет...">
|
||||||
</label>
|
</label>
|
||||||
<button>
|
<input type="hidden" name="min_price" value="{{ navigation_min_price }}">
|
||||||
|
<input type="hidden" name="max_price" value="{{ navigation_max_price }}">
|
||||||
|
<button type="submit" aria-label="Найти товары">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
||||||
class="bi bi-search" viewBox="0 0 16 16">
|
class="bi bi-search" viewBox="0 0 16 16">
|
||||||
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"></path>
|
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"></path>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<aside>
|
<aside>
|
||||||
<section class="categories a-section">
|
<section class="categories a-section">
|
||||||
<h2>Категории</h2>
|
<h2>Категории</h2>
|
||||||
<a href="/catalog/" class="category {% if selected_category.id == None %}active{% endif %}">Все товары</a>
|
<a href="{{ all_items_url }}" class="category {% if selected_category.id == None %}active{% endif %}">Все товары</a>
|
||||||
{% for category in categories %}
|
{% for category in categories %}
|
||||||
<a href="/catalog/{{ category.slug }}/" class="category {% if selected_category.id == category.slug %}active{% endif %}">
|
<a href="{{ category.catalog_url }}" class="category {% if selected_category.id == category.slug %}active{% endif %}">
|
||||||
{{ category.name }}
|
{{ category.name }}
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
@@ -14,11 +14,11 @@
|
|||||||
<section class="sub-categories a-section">
|
<section class="sub-categories a-section">
|
||||||
<h2>Подкатегории</h2>
|
<h2>Подкатегории</h2>
|
||||||
{% if selected_category.id != None %}
|
{% if selected_category.id != None %}
|
||||||
<a href="/catalog/{{ selected_category.id }}/" class="category {% if selected_subcategory.id == None %}active{% endif %}">
|
<a href="{{ selected_category_url }}" class="category {% if selected_subcategory.id == None %}active{% endif %}">
|
||||||
Все подкатегории
|
Все подкатегории
|
||||||
</a>
|
</a>
|
||||||
{% for subcategory in subcategories %}
|
{% for subcategory in subcategories %}
|
||||||
<a href="/catalog/{{ selected_category.id }}/?subcategory={{ subcategory.slug }}" class="category {% if selected_subcategory.id == subcategory.slug %}active{% endif %}">
|
<a href="{{ subcategory.catalog_url }}" class="category {% if selected_subcategory.id == subcategory.slug %}active{% endif %}">
|
||||||
{{ subcategory.name }}
|
{{ subcategory.name }}
|
||||||
</a>
|
</a>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
@@ -29,9 +29,54 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form action="">
|
<form action="{{ price_filter_action }}" method="get" class="catalog-filter-form">
|
||||||
|
<input type="hidden" name="q" value="{{ current_search_query }}">
|
||||||
|
{% if selected_subcategory.id != None %}
|
||||||
|
<input type="hidden" name="subcategory" value="{{ selected_subcategory.id }}">
|
||||||
|
{% endif %}
|
||||||
<section class="a-section cost-section">
|
<section class="a-section cost-section">
|
||||||
<h2>Стоимость</h2>
|
<h2>Стоимость</h2>
|
||||||
|
<div
|
||||||
|
class="catalog-price-slider"
|
||||||
|
data-price-slider
|
||||||
|
data-min-bound="{{ price_slider_min_bound }}"
|
||||||
|
data-max-bound="{{ price_slider_max_bound }}"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="min_price" value="{{ current_min_price }}" data-price-hidden-min>
|
||||||
|
<input type="hidden" name="max_price" value="{{ current_max_price }}" data-price-hidden-max>
|
||||||
|
|
||||||
|
<div class="catalog-price-slider__values" aria-live="polite">
|
||||||
|
<span data-price-label-min>{{ price_slider_selected_min }} руб.</span>
|
||||||
|
<span data-price-label-max>{{ price_slider_selected_max }} руб.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="catalog-price-slider__controls">
|
||||||
|
<div class="catalog-price-slider__track">
|
||||||
|
<div class="catalog-price-slider__track-fill" data-price-track-fill></div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="{{ price_slider_min_bound }}"
|
||||||
|
max="{{ price_slider_max_bound }}"
|
||||||
|
step="0.01"
|
||||||
|
value="{{ price_slider_selected_min }}"
|
||||||
|
class="catalog-price-slider__range"
|
||||||
|
aria-label="Минимальная цена"
|
||||||
|
data-price-range-min
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="{{ price_slider_min_bound }}"
|
||||||
|
max="{{ price_slider_max_bound }}"
|
||||||
|
step="0.01"
|
||||||
|
value="{{ price_slider_selected_max }}"
|
||||||
|
class="catalog-price-slider__range"
|
||||||
|
aria-label="Максимальная цена"
|
||||||
|
data-price-range-max
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="catalog-filter-submit">Применить</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="a-section color-section">
|
<section class="a-section color-section">
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
<link rel="stylesheet" href="{% static "css/catalog.css" %}">
|
<link rel="stylesheet" href="{% static "css/catalog.css" %}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block add_js %}
|
||||||
|
<script src="{% static 'js/catalog-price-slider.js' %}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- HEADER -->
|
<!-- HEADER -->
|
||||||
{% include "_fragments/header.html" with active="catalog" %}
|
{% include "_fragments/header.html" with active="catalog" %}
|
||||||
|
|||||||
Reference in New Issue
Block a user