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
|
||||
USE_X_FORWARDED_HOST=true
|
||||
SECURE_PROXY_SSL_HEADER=HTTP_X_FORWARDED_PROTO,https
|
||||
SECURE_REFERRER_POLICY=strict-origin-when-cross-origin
|
||||
SITE_NAME=SantehRay
|
||||
LOGIN_URL=/users/login/
|
||||
LOGIN_REDIRECT_URL=/users/profile/
|
||||
|
||||
@@ -3,7 +3,13 @@ from unittest.mock import patch
|
||||
|
||||
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):
|
||||
@@ -26,3 +32,25 @@ class SettingsNormalizationTests(SimpleTestCase):
|
||||
|
||||
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")
|
||||
|
||||
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.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):
|
||||
response = self.client.get("/catalog/plumbing/?subcategory=unknown")
|
||||
|
||||
@@ -95,6 +203,35 @@ class CatalogViewTests(TestCase):
|
||||
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):
|
||||
ItemImage.objects.create(
|
||||
item=self.sink,
|
||||
|
||||
156
items/views.py
156
items/views.py
@@ -1,7 +1,9 @@
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from urllib.parse import urlencode
|
||||
|
||||
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 items.models import Category, Item, Subcategory
|
||||
@@ -20,11 +22,90 @@ class SelectedSubcategory:
|
||||
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:
|
||||
selected_category = SelectedCategory(name=None, id=None)
|
||||
selected_subcategory = SelectedSubcategory(name=None, id=None)
|
||||
subcategories = Subcategory.objects.none()
|
||||
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 = (
|
||||
Item.objects.select_related('category', 'subcategory')
|
||||
.prefetch_related('images')
|
||||
@@ -52,12 +133,83 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
|
||||
|
||||
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', {
|
||||
'categories': Category.objects.order_by('name'),
|
||||
'categories': categories,
|
||||
'subcategories': subcategories,
|
||||
'items': items,
|
||||
'selected_category': selected_category,
|
||||
'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),
|
||||
})
|
||||
|
||||
|
||||
@@ -43,4 +43,7 @@ def navigation(request):
|
||||
"navigation_selected_value": selected_value,
|
||||
"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_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
|
||||
|
||||
|
||||
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]:
|
||||
merged: list[str] = []
|
||||
seen: set[str] = set()
|
||||
@@ -176,6 +187,20 @@ SECURE_PROXY_SSL_HEADER = env_pair(
|
||||
"SECURE_PROXY_SSL_HEADER",
|
||||
("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
|
||||
|
||||
|
||||
@@ -50,6 +50,116 @@ aside {
|
||||
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 {
|
||||
flex: 1 1 auto;
|
||||
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="header-top">
|
||||
<div class="logo"><a href="/">SantehRay</a></div>
|
||||
<form action="">
|
||||
<form action="/catalog/" method="get">
|
||||
<div class="search-wrap">
|
||||
<div class="search-select-wrap">
|
||||
<div class="search-category-dropdown" data-category-dropdown>
|
||||
@@ -67,9 +67,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
<input class="search-input" type="text" placeholder="Кто ищет, тот найдет...">
|
||||
<input class="search-input" type="text" name="q" value="{{ navigation_search_query }}" placeholder="Кто ищет, тот найдет...">
|
||||
</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"
|
||||
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>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<aside>
|
||||
<section class="categories a-section">
|
||||
<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 %}
|
||||
<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 }}
|
||||
</a>
|
||||
{% empty %}
|
||||
@@ -14,11 +14,11 @@
|
||||
<section class="sub-categories a-section">
|
||||
<h2>Подкатегории</h2>
|
||||
{% 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>
|
||||
{% 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 }}
|
||||
</a>
|
||||
{% empty %}
|
||||
@@ -29,9 +29,54 @@
|
||||
{% endif %}
|
||||
</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">
|
||||
<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 class="a-section color-section">
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<link rel="stylesheet" href="{% static "css/catalog.css" %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block add_js %}
|
||||
<script src="{% static 'js/catalog-price-slider.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- HEADER -->
|
||||
{% include "_fragments/header.html" with active="catalog" %}
|
||||
|
||||
Reference in New Issue
Block a user