feat: add favorites feature for users
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
All checks were successful
Build and Push Docker Image / build (push) Successful in 51s
- Implemented `Favorite` model with unique user-item constraint and timestamped records - Added `favorites:index` and `favorites:toggle` views with AJAX support for toggling favorites - Built templates and front-end assets for the favorites page and integration with item grids - Extended header with favorites link and dynamic counter - Added tests covering models, views, and templates for the favorites functionality - Updated admin to enable management of favorites via Django admin interface
This commit is contained in:
1
favorites/__init__.py
Normal file
1
favorites/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
11
favorites/admin.py
Normal file
11
favorites/admin.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Favorite
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Favorite)
|
||||||
|
class FavoriteAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "item", "created_at")
|
||||||
|
list_filter = ("created_at",)
|
||||||
|
search_fields = ("user__username", "user__email", "item__name", "item__slug")
|
||||||
|
|
||||||
7
favorites/apps.py
Normal file
7
favorites/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class FavoritesConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "favorites"
|
||||||
|
|
||||||
33
favorites/migrations/0001_initial.py
Normal file
33
favorites/migrations/0001_initial.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 6.0.4 on 2026-05-10 17:49
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('items', '0003_subcategory_item_subcategory_and_more'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Favorite',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата добавления')),
|
||||||
|
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_entries', to='items.item', verbose_name='Товар')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_entries', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Избранный товар',
|
||||||
|
'verbose_name_plural': 'Избранные товары',
|
||||||
|
'ordering': ('-created_at', '-id'),
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('user', 'item'), name='unique_favorite_user_item')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
1
favorites/migrations/__init__.py
Normal file
1
favorites/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
32
favorites/models.py
Normal file
32
favorites/models.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from items.models import Item
|
||||||
|
|
||||||
|
|
||||||
|
class Favorite(models.Model):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="favorite_entries",
|
||||||
|
verbose_name="Пользователь",
|
||||||
|
)
|
||||||
|
item = models.ForeignKey(
|
||||||
|
Item,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="favorite_entries",
|
||||||
|
verbose_name="Товар",
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата добавления")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ("-created_at", "-id")
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(fields=("user", "item"), name="unique_favorite_user_item"),
|
||||||
|
]
|
||||||
|
verbose_name = "Избранный товар"
|
||||||
|
verbose_name_plural = "Избранные товары"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} -> {self.item}"
|
||||||
|
|
||||||
12
favorites/services.py
Normal file
12
favorites/services.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from favorites.models import Favorite
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_favorite_item_ids(user: Any) -> set[int]:
|
||||||
|
if not user.is_authenticated:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
return set(
|
||||||
|
Favorite.objects.filter(user=user).values_list("item_id", flat=True)
|
||||||
|
)
|
||||||
120
favorites/tests.py
Normal file
120
favorites/tests.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import IntegrityError
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from favorites.models import Favorite
|
||||||
|
from items.models import Category, Item, ItemImage, Subcategory
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class FavoritePageTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
username="marina",
|
||||||
|
email="marina@example.com",
|
||||||
|
password="StrongPassword123",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.plumbing = Category.objects.create(name="Сантехника", slug="plumbing")
|
||||||
|
self.sinks = Subcategory.objects.create(name="Мойки", slug="sinks", category=self.plumbing)
|
||||||
|
self.taps = Subcategory.objects.create(name="Смесители", slug="taps", category=self.plumbing)
|
||||||
|
self.sink = Item.objects.create(
|
||||||
|
name="Кухонная мойка",
|
||||||
|
slug="kitchen-sink",
|
||||||
|
category=self.plumbing,
|
||||||
|
subcategory=self.sinks,
|
||||||
|
description="Искусственный камень",
|
||||||
|
price="1600.00",
|
||||||
|
is_available=True,
|
||||||
|
)
|
||||||
|
self.tap = Item.objects.create(
|
||||||
|
name="Смеситель Mono",
|
||||||
|
slug="mono-tap",
|
||||||
|
category=self.plumbing,
|
||||||
|
subcategory=self.taps,
|
||||||
|
description="Однорычажный смеситель",
|
||||||
|
price="320.00",
|
||||||
|
is_available=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_favorites_page_redirects_unauthenticated_users(self):
|
||||||
|
response = self.client.get(reverse("favorites:index"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn(reverse("users:login"), response["Location"])
|
||||||
|
|
||||||
|
def test_favorites_page_shows_empty_state(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("favorites:index"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "В избранном пока нет товаров.")
|
||||||
|
|
||||||
|
def test_favorites_page_shows_items_in_tile_grid(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
ItemImage.objects.create(
|
||||||
|
item=self.sink,
|
||||||
|
image="items/sink-primary.jpg",
|
||||||
|
alt="Главное фото мойки",
|
||||||
|
sort_order=0,
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
Favorite.objects.create(user=self.user, item=self.tap)
|
||||||
|
Favorite.objects.create(user=self.user, item=self.sink)
|
||||||
|
|
||||||
|
response = self.client.get(reverse("favorites:index"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Избранное")
|
||||||
|
self.assertContains(response, "Кухонная мойка")
|
||||||
|
self.assertContains(response, "Смеситель Mono")
|
||||||
|
self.assertContains(response, "/media/items/sink-primary.jpg")
|
||||||
|
self.assertContains(response, 'href="/catalog/plumbing/kitchen-sink/"', html=False)
|
||||||
|
self.assertContains(response, 'href="/catalog/plumbing/mono-tap/"', html=False)
|
||||||
|
self.assertContains(response, "Удалить из избранного")
|
||||||
|
|
||||||
|
def test_toggle_endpoint_adds_and_removes_favorite(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
add_response = self.client.post(
|
||||||
|
reverse("favorites:toggle"),
|
||||||
|
data={"item_id": self.sink.pk, "next": reverse("favorites:index")},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(add_response, reverse("favorites:index"))
|
||||||
|
self.assertTrue(Favorite.objects.filter(user=self.user, item=self.sink).exists())
|
||||||
|
|
||||||
|
remove_response = self.client.post(
|
||||||
|
reverse("favorites:toggle"),
|
||||||
|
data={"item_id": self.sink.pk, "next": reverse("favorites:index")},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertRedirects(remove_response, reverse("favorites:index"))
|
||||||
|
self.assertFalse(Favorite.objects.filter(user=self.user, item=self.sink).exists())
|
||||||
|
|
||||||
|
def test_toggle_endpoint_returns_json_for_ajax_requests(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("favorites:toggle"),
|
||||||
|
data={"item_id": self.sink.pk, "next": reverse("favorites:index")},
|
||||||
|
HTTP_X_REQUESTED_WITH="XMLHttpRequest",
|
||||||
|
HTTP_ACCEPT="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response["Content-Type"], "application/json")
|
||||||
|
self.assertEqual(response.json()["item_id"], self.sink.pk)
|
||||||
|
self.assertTrue(response.json()["is_favorite"])
|
||||||
|
self.assertEqual(response.json()["favorite_count"], 1)
|
||||||
|
self.assertTrue(Favorite.objects.filter(user=self.user, item=self.sink).exists())
|
||||||
|
|
||||||
|
def test_favorite_pair_is_unique_per_user_and_item(self):
|
||||||
|
Favorite.objects.create(user=self.user, item=self.sink)
|
||||||
|
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
Favorite.objects.create(user=self.user, item=self.sink)
|
||||||
10
favorites/urls.py
Normal file
10
favorites/urls.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from favorites import views
|
||||||
|
|
||||||
|
app_name = "favorites"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.index, name="index"),
|
||||||
|
path("toggle/", views.toggle, name="toggle"),
|
||||||
|
]
|
||||||
73
favorites/views.py
Normal file
73
favorites/views.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.http import HttpRequest, HttpResponse, JsonResponse
|
||||||
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
|
from django.views.decorators.http import require_POST
|
||||||
|
|
||||||
|
from favorites.models import Favorite
|
||||||
|
from items.models import Item
|
||||||
|
|
||||||
|
|
||||||
|
def wants_json_response(request: HttpRequest) -> bool:
|
||||||
|
return (
|
||||||
|
request.headers.get("x-requested-with") == "XMLHttpRequest"
|
||||||
|
or "application/json" in request.headers.get("accept", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def index(request: HttpRequest) -> HttpResponse:
|
||||||
|
favorite_records = (
|
||||||
|
Favorite.objects.filter(user=request.user)
|
||||||
|
.select_related("item__category", "item__subcategory")
|
||||||
|
.prefetch_related("item__images")
|
||||||
|
.order_by("-created_at", "-id")
|
||||||
|
)
|
||||||
|
items = [favorite.item for favorite in favorite_records]
|
||||||
|
|
||||||
|
return render(request, "favorites/index.html", {
|
||||||
|
"items": items,
|
||||||
|
"favorite_count": len(items),
|
||||||
|
"favorite_item_ids": {item.id for item in items},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def toggle(request: HttpRequest) -> HttpResponse:
|
||||||
|
item_id = request.POST.get("item_id")
|
||||||
|
item = get_object_or_404(Item, pk=item_id)
|
||||||
|
|
||||||
|
favorite = Favorite.objects.filter(user=request.user, item=item).first()
|
||||||
|
if favorite:
|
||||||
|
favorite.delete()
|
||||||
|
is_favorite = False
|
||||||
|
message = f"Товар «{item.name}» удалён из избранного."
|
||||||
|
else:
|
||||||
|
Favorite.objects.create(user=request.user, item=item)
|
||||||
|
is_favorite = True
|
||||||
|
message = f"Товар «{item.name}» добавлен в избранное."
|
||||||
|
|
||||||
|
favorite_count = Favorite.objects.filter(user=request.user).count()
|
||||||
|
next_url = request.POST.get("next") or reverse("favorites:index")
|
||||||
|
|
||||||
|
if wants_json_response(request):
|
||||||
|
return JsonResponse({
|
||||||
|
"item_id": item.pk,
|
||||||
|
"is_favorite": is_favorite,
|
||||||
|
"favorite_count": favorite_count,
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
|
||||||
|
messages.success(request, message)
|
||||||
|
|
||||||
|
if not url_has_allowed_host_and_scheme(
|
||||||
|
next_url,
|
||||||
|
allowed_hosts={request.get_host()},
|
||||||
|
require_https=request.is_secure(),
|
||||||
|
):
|
||||||
|
next_url = reverse("favorites:index")
|
||||||
|
|
||||||
|
return redirect(next_url)
|
||||||
@@ -2,6 +2,7 @@ from django.test import TestCase, override_settings
|
|||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
from favorites.models import Favorite
|
||||||
from items.models import Category, Item, ItemImage, Subcategory
|
from items.models import Category, Item, ItemImage, Subcategory
|
||||||
|
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ class CatalogViewTests(TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, "Мойки")
|
self.assertContains(response, "Мойки")
|
||||||
self.assertContains(response, "Смесители")
|
self.assertContains(response, "Смесители")
|
||||||
self.assertNotContains(response, "Подсветка")
|
self.assertNotContains(response, 'href="/catalog/decor/?subcategory=lights"')
|
||||||
|
|
||||||
def test_subcategory_page_shows_only_selected_subcategory_items(self):
|
def test_subcategory_page_shows_only_selected_subcategory_items(self):
|
||||||
Item.objects.create(
|
Item.objects.create(
|
||||||
@@ -141,6 +142,36 @@ class CatalogViewTests(TestCase):
|
|||||||
|
|
||||||
self.assertContains(response, 'href="/catalog/plumbing/kitchen-sink/"')
|
self.assertContains(response, 'href="/catalog/plumbing/kitchen-sink/"')
|
||||||
|
|
||||||
|
def test_catalog_item_card_shows_favorite_button_for_authenticated_user(self):
|
||||||
|
user = get_user_model().objects.create_user(
|
||||||
|
username="ann",
|
||||||
|
email="ann@example.com",
|
||||||
|
password="StrongPassword123",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
|
response = self.client.get("/catalog/")
|
||||||
|
|
||||||
|
self.assertContains(response, 'action="/favorites/toggle/"', html=False)
|
||||||
|
self.assertContains(response, "Добавить в избранное")
|
||||||
|
self.assertContains(response, 'name="item_id" value="', html=False)
|
||||||
|
|
||||||
|
def test_catalog_item_card_marks_favorited_item(self):
|
||||||
|
user = get_user_model().objects.create_user(
|
||||||
|
username="ann2",
|
||||||
|
email="ann2@example.com",
|
||||||
|
password="StrongPassword123",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.client.force_login(user)
|
||||||
|
Favorite.objects.create(user=user, item=self.sink)
|
||||||
|
|
||||||
|
response = self.client.get("/catalog/")
|
||||||
|
|
||||||
|
self.assertContains(response, "Удалить из избранного")
|
||||||
|
self.assertContains(response, "is-active")
|
||||||
|
|
||||||
def test_detail_page_shows_item_data(self):
|
def test_detail_page_shows_item_data(self):
|
||||||
ItemImage.objects.create(
|
ItemImage.objects.create(
|
||||||
item=self.sink,
|
item=self.sink,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from dataclasses import dataclass
|
|||||||
from django.http import HttpRequest, HttpResponse, Http404
|
from django.http import HttpRequest, HttpResponse, Http404
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
from favorites.services import get_user_favorite_item_ids
|
||||||
from items.models import Category, Item, Subcategory
|
from items.models import Category, Item, Subcategory
|
||||||
|
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
|
|||||||
'items': items,
|
'items': items,
|
||||||
'selected_category': selected_category,
|
'selected_category': selected_category,
|
||||||
'selected_subcategory': selected_subcategory,
|
'selected_subcategory': selected_subcategory,
|
||||||
|
'favorite_item_ids': get_user_favorite_item_ids(request.user),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -76,4 +78,5 @@ def detail(request: HttpRequest, category: str, slug: str) -> HttpResponse:
|
|||||||
'item': item,
|
'item': item,
|
||||||
'images': images,
|
'images': images,
|
||||||
'primary_image': images[0] if images else None,
|
'primary_image': images[0] if images else None,
|
||||||
|
'favorite_item_ids': get_user_favorite_item_ids(request.user),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,2 +1,46 @@
|
|||||||
|
from django.db.models import Prefetch
|
||||||
|
|
||||||
|
from items.models import Category, Subcategory
|
||||||
|
|
||||||
|
|
||||||
def navigation(request):
|
def navigation(request):
|
||||||
return {"active": ""}
|
categories = (
|
||||||
|
Category.objects
|
||||||
|
.prefetch_related(Prefetch("subcategories", queryset=Subcategory.objects.order_by("name")))
|
||||||
|
.order_by("name")
|
||||||
|
)
|
||||||
|
|
||||||
|
current_category = None
|
||||||
|
current_subcategory = None
|
||||||
|
selected_label = "Все категории"
|
||||||
|
selected_value = ""
|
||||||
|
resolver_match = getattr(request, "resolver_match", None)
|
||||||
|
category_slug = None
|
||||||
|
if resolver_match is not None:
|
||||||
|
category_slug = resolver_match.kwargs.get("category")
|
||||||
|
|
||||||
|
if category_slug:
|
||||||
|
current_category = Category.objects.filter(slug=category_slug.lower()).first()
|
||||||
|
|
||||||
|
subcategory_slug = request.GET.get("subcategory")
|
||||||
|
if current_category and subcategory_slug:
|
||||||
|
current_subcategory = Subcategory.objects.filter(
|
||||||
|
category=current_category,
|
||||||
|
slug=subcategory_slug.lower(),
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if current_category and current_subcategory:
|
||||||
|
selected_label = f"{current_category.name} / {current_subcategory.name}"
|
||||||
|
selected_value = f"subcategory:{current_category.slug}:{current_subcategory.slug}"
|
||||||
|
elif current_category:
|
||||||
|
selected_label = current_category.name
|
||||||
|
selected_value = f"category:{current_category.slug}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"active": "",
|
||||||
|
"navigation_categories": categories,
|
||||||
|
"navigation_selected_label": selected_label,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'home',
|
'home',
|
||||||
'items',
|
'items',
|
||||||
|
'favorites',
|
||||||
'orders',
|
'orders',
|
||||||
'users',
|
'users',
|
||||||
]
|
]
|
||||||
@@ -368,6 +369,11 @@ LOGGING = {
|
|||||||
"level": LOG_LEVEL,
|
"level": LOG_LEVEL,
|
||||||
"propagate": False,
|
"propagate": False,
|
||||||
},
|
},
|
||||||
|
"favorites": {
|
||||||
|
"handlers": ["console"],
|
||||||
|
"level": LOG_LEVEL,
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
"orders": {
|
"orders": {
|
||||||
"handlers": ["console"],
|
"handlers": ["console"],
|
||||||
"level": LOG_LEVEL,
|
"level": LOG_LEVEL,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ urlpatterns = [
|
|||||||
path("favicon.ico", views.favicon, name="favicon"),
|
path("favicon.ico", views.favicon, name="favicon"),
|
||||||
path('', include('home.urls')),
|
path('', include('home.urls')),
|
||||||
path('catalog/', include('items.urls')),
|
path('catalog/', include('items.urls')),
|
||||||
|
path('favorites/', include('favorites.urls')),
|
||||||
path('orders/', include('orders.urls')),
|
path('orders/', include('orders.urls')),
|
||||||
path('users/', include('users.urls')),
|
path('users/', include('users.urls')),
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
|
|||||||
@@ -74,9 +74,8 @@ aside {
|
|||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card-link {
|
.item-card-media-link {
|
||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card-image {
|
.item-card-image {
|
||||||
@@ -106,11 +105,60 @@ aside {
|
|||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.item-card-category {
|
.item-card-category {
|
||||||
color: #63768d;
|
color: #63768d;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-favorite-form {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-favorite-button {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
color: #63768d;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, opacity 0.2s, transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-favorite-button:hover {
|
||||||
|
color: #36213e;
|
||||||
|
opacity: 0.82;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-favorite-button svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-favorite-button.is-active {
|
||||||
|
color: #c54c5d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-favorite-button--login {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-title-link {
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card-title {
|
.item-card-title {
|
||||||
@@ -397,6 +445,15 @@ aside {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-card-header {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card-favorite-button {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
.product-gallery__thumbs {
|
.product-gallery__thumbs {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
0
static/css/contacts.css
Normal file
0
static/css/contacts.css
Normal file
115
static/css/favorites.css
Normal file
115
static/css/favorites.css
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
.favorites-page {
|
||||||
|
margin-top: 18px;
|
||||||
|
margin-bottom: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-hero {
|
||||||
|
margin-bottom: 28px;
|
||||||
|
padding: 30px 32px;
|
||||||
|
border: 1px solid rgba(54, 33, 62, 0.12);
|
||||||
|
background:
|
||||||
|
/*radial-gradient(circle at top right, rgba(184, 243, 255, 0.55) 0, rgba(184, 243, 255, 0) 34%),*/
|
||||||
|
linear-gradient(180deg, rgba(99, 118, 141, 0.07) 0%, rgba(99, 118, 141, 0.12) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-kicker {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #63768d;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-hero__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-title {
|
||||||
|
font-size: 50px;
|
||||||
|
line-height: 1.05;
|
||||||
|
color: #36213e;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-description {
|
||||||
|
max-width: 680px;
|
||||||
|
margin-top: 14px;
|
||||||
|
color: #554971;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-counter {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 130px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border: 1px solid rgba(54, 33, 62, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-counter__value {
|
||||||
|
display: block;
|
||||||
|
color: #36213e;
|
||||||
|
font-family: "BerlinType", serif;
|
||||||
|
font-size: 44px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-counter__label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #63768d;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-list .items-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.favorites-page {
|
||||||
|
padding-left: 24px;
|
||||||
|
padding-right: 24px;
|
||||||
|
margin-bottom: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-hero {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-title {
|
||||||
|
font-size: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-description {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-hero__row {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.favorites-page {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-title {
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-counter {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -627,7 +627,7 @@
|
|||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
color: #b8f3ff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faq-body {
|
.faq-body {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
padding-top: 60px;
|
padding-top: 60px;
|
||||||
padding-bottom: 60px;
|
padding-bottom: 60px;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top right, rgba(184, 243, 255, 0.45) 0, rgba(184, 243, 255, 0) 26%),
|
|
||||||
linear-gradient(180deg, rgba(99, 118, 141, 0.06) 0%, rgba(99, 118, 141, 0.12) 100%);
|
linear-gradient(180deg, rgba(99, 118, 141, 0.06) 0%, rgba(99, 118, 141, 0.12) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@
|
|||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: #b8f3ff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-title {
|
.auth-title {
|
||||||
|
|||||||
@@ -240,16 +240,134 @@ header {
|
|||||||
margin: 0 24px;
|
margin: 0 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-wrap select {
|
.search-select-wrap {
|
||||||
border: none;
|
position: relative;
|
||||||
|
flex: 0 0 154px;
|
||||||
|
width: 154px;
|
||||||
border-right: 1px solid var(--dark);
|
border-right: 1px solid var(--dark);
|
||||||
padding: 10px 14px;
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-category-dropdown {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-category-trigger {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 34px 10px 14px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--mid);
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--mid);
|
line-height: 1.2;
|
||||||
background: transparent;
|
text-align: left;
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-category-trigger:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-category-label {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-select-arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 12px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--mid);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: transform .2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-category-dropdown.is-open .search-select-arrow {
|
||||||
|
transform: translateY(-50%) rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-category-menu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 8px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 30;
|
||||||
|
width: max(100%, 290px);
|
||||||
|
max-height: 340px;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px 0 10px;
|
||||||
|
border: 1px solid rgba(54, 33, 62, .16);
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 16px 36px rgba(54, 33, 62, .14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-category-dropdown.is-open .search-category-menu {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-category-option {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
transition: background-color .15s ease, color .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-category-option:hover,
|
||||||
|
.search-category-option:focus-visible {
|
||||||
|
background: rgba(99, 118, 141, 0.08);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-category-option--all {
|
||||||
|
padding: 10px 14px 11px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-category-group {
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-category-sublist {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-category-option--category {
|
||||||
|
padding: 9px 14px 9px 22px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-category-option--subcategory {
|
||||||
|
padding: 7px 14px 7px 34px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--mid);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-wrap input {
|
.search-wrap input {
|
||||||
@@ -393,7 +511,7 @@ footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer-logo {
|
.footer-logo {
|
||||||
font-family: 'Playfair Display', serif;
|
font-family: 'BerlinType', serif;
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
color: rgba(184, 243, 255, .85);
|
color: rgba(184, 243, 255, .85);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@@ -459,11 +577,15 @@ footer {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-wrap select {
|
.search-select-wrap {
|
||||||
flex: 0 0 132px;
|
flex: 0 0 132px;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
width: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-category-trigger {
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
padding-right: 8px;
|
padding-right: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-icons {
|
.header-icons {
|
||||||
|
|||||||
114
static/js/favorites.js
Normal file
114
static/js/favorites.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const favoriteForms = document.querySelectorAll('[data-favorite-form]');
|
||||||
|
|
||||||
|
const pluralizeFavorites = function (count) {
|
||||||
|
const value = Math.abs(Number(count) || 0);
|
||||||
|
const lastTwo = value % 100;
|
||||||
|
if (lastTwo >= 11 && lastTwo <= 14) {
|
||||||
|
return 'товаров';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (value % 10) {
|
||||||
|
case 1:
|
||||||
|
return 'товар';
|
||||||
|
case 2:
|
||||||
|
case 3:
|
||||||
|
case 4:
|
||||||
|
return 'товара';
|
||||||
|
default:
|
||||||
|
return 'товаров';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFavoritesCounter = function (favoriteCount) {
|
||||||
|
const valueNode = document.querySelector('[data-favorites-count-value]');
|
||||||
|
const labelNode = document.querySelector('[data-favorites-count-label]');
|
||||||
|
|
||||||
|
if (valueNode) {
|
||||||
|
valueNode.textContent = String(favoriteCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (labelNode) {
|
||||||
|
labelNode.textContent = pluralizeFavorites(favoriteCount);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureEmptyFavoritesState = function () {
|
||||||
|
const grid = document.querySelector('[data-favorites-grid]');
|
||||||
|
if (!grid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = grid.querySelectorAll('[data-item-card]');
|
||||||
|
if (cards.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMessage = grid.dataset.emptyMessage || 'В избранном пока нет товаров.';
|
||||||
|
grid.innerHTML = '<p class="item-list-empty">' + emptyMessage + '</p>';
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncButtonState = function (button, payload) {
|
||||||
|
const isFavorite = Boolean(payload.is_favorite);
|
||||||
|
const addLabel = button.dataset.labelAdd || 'Добавить в избранное';
|
||||||
|
const removeLabel = button.dataset.labelRemove || 'Удалить из избранного';
|
||||||
|
const label = isFavorite ? removeLabel : addLabel;
|
||||||
|
|
||||||
|
button.classList.toggle('is-active', isFavorite);
|
||||||
|
button.setAttribute('title', label);
|
||||||
|
button.setAttribute('aria-label', label);
|
||||||
|
};
|
||||||
|
|
||||||
|
favoriteForms.forEach(function (form) {
|
||||||
|
form.addEventListener('submit', async function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const button = form.querySelector('[data-favorite-button]');
|
||||||
|
if (!button || form.dataset.loading === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.dataset.loading = '1';
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: new FormData(form),
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update favorites');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json();
|
||||||
|
const card = form.closest('[data-item-card]');
|
||||||
|
const favoritesPage = Boolean(document.querySelector('[data-favorites-page]'));
|
||||||
|
|
||||||
|
syncButtonState(button, payload);
|
||||||
|
updateFavoritesCounter(payload.favorite_count);
|
||||||
|
|
||||||
|
if (card) {
|
||||||
|
card.classList.toggle('is-favorite', Boolean(payload.is_favorite));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (favoritesPage && !payload.is_favorite && card) {
|
||||||
|
card.remove();
|
||||||
|
ensureEmptyFavoritesState();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
HTMLFormElement.prototype.submit.call(form);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
form.dataset.loading = '0';
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
56
static/js/header-category-dropdown.js
Normal file
56
static/js/header-category-dropdown.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
document.querySelectorAll('[data-category-dropdown]').forEach(function (dropdown) {
|
||||||
|
const trigger = dropdown.querySelector('[data-category-trigger]');
|
||||||
|
const label = dropdown.querySelector('[data-selected-label]');
|
||||||
|
const valueInput = dropdown.querySelector('[data-category-value]');
|
||||||
|
const menu = dropdown.querySelector('[data-category-menu]');
|
||||||
|
const options = dropdown.querySelectorAll('[data-category-option]');
|
||||||
|
|
||||||
|
if (!trigger || !label || !valueInput || !menu) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const setExpanded = function (expanded) {
|
||||||
|
trigger.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||||
|
dropdown.classList.toggle('is-open', expanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDropdown = function () {
|
||||||
|
setExpanded(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDropdown = function () {
|
||||||
|
setExpanded(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
trigger.addEventListener('click', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (dropdown.classList.contains('is-open')) {
|
||||||
|
closeDropdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openDropdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
options.forEach(function (option) {
|
||||||
|
option.addEventListener('click', function () {
|
||||||
|
label.textContent = option.dataset.label || option.textContent.trim();
|
||||||
|
valueInput.value = option.dataset.value || '';
|
||||||
|
closeDropdown();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
if (!dropdown.contains(event.target)) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,19 +7,19 @@
|
|||||||
<div class="footer-col">
|
<div class="footer-col">
|
||||||
<h4>ПОКУПАТЕЛЮ</h4>
|
<h4>ПОКУПАТЕЛЮ</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="#">Каталог</a></li>
|
<li><a href="/catalog/">Каталог</a></li>
|
||||||
<li><a href="#">Услуги</a></li>
|
<li><a href="{% url 'services' %}">Услуги</a></li>
|
||||||
<li><a href="#">Акции</a></li>
|
<li><a href="/catalog/">Акции</a></li>
|
||||||
<li><a href="#">Новинки</a></li>
|
<li><a href="/catalog/">Новинки</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-col">
|
<div class="footer-col">
|
||||||
<h4>ПОМОЩЬ</h4>
|
<h4>ПОМОЩЬ</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="#">Доставка и оплата</a></li>
|
<li><a href="{% url 'shipping' %}">Доставка и оплата</a></li>
|
||||||
<li><a href="#">Гарантия и возврат</a></li>
|
<li><a href="{% url 'warranty' %}">Гарантия и возврат</a></li>
|
||||||
<li>Ответы на вопросы</li>
|
<li><a href="{% url 'home' %}#faq-section">Ответы на вопросы</a></li>
|
||||||
<li><a href="#">Контакты</a></li>
|
<li><a href="{% url 'contacts' %}">Контакты</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-col">
|
<div class="footer-col">
|
||||||
|
|||||||
@@ -5,13 +5,67 @@
|
|||||||
<div class="logo"><a href="/">SantehRay</a></div>
|
<div class="logo"><a href="/">SantehRay</a></div>
|
||||||
<form action="">
|
<form action="">
|
||||||
<div class="search-wrap">
|
<div class="search-wrap">
|
||||||
<label>
|
<div class="search-select-wrap">
|
||||||
<select>
|
<div class="search-category-dropdown" data-category-dropdown>
|
||||||
<option>Все категории</option>
|
<button
|
||||||
<option>Сантехника</option>
|
type="button"
|
||||||
<option>Декор</option>
|
class="search-category-trigger"
|
||||||
</select>
|
aria-label="Выбор категории"
|
||||||
</label>
|
aria-expanded="false"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
data-category-trigger
|
||||||
|
>
|
||||||
|
<span class="search-category-label"
|
||||||
|
data-selected-label>{{ navigation_selected_label }}</span>
|
||||||
|
<svg class="search-select-arrow" xmlns="http://www.w3.org/2000/svg" width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M3.2 6.2 8 11l4.8-4.8" stroke="currentColor" stroke-width="1.8"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input type="hidden" name="category" value="{{ navigation_selected_value }}"
|
||||||
|
data-category-value>
|
||||||
|
<div class="search-category-menu" role="listbox" aria-label="Категории и подкатегории"
|
||||||
|
data-category-menu>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search-category-option search-category-option--all"
|
||||||
|
data-category-option
|
||||||
|
data-label="Все категории"
|
||||||
|
data-value=""
|
||||||
|
>
|
||||||
|
Все категории
|
||||||
|
</button>
|
||||||
|
{% for category in navigation_categories %}
|
||||||
|
<div class="search-category-group">
|
||||||
|
<div class="search-category-sublist">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search-category-option search-category-option--category"
|
||||||
|
data-category-option
|
||||||
|
data-label="{{ category.name }}"
|
||||||
|
data-value="category:{{ category.slug }}"
|
||||||
|
>
|
||||||
|
{{ category.name }}
|
||||||
|
</button>
|
||||||
|
{% for subcategory in category.subcategories.all %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search-category-option search-category-option--subcategory"
|
||||||
|
data-category-option
|
||||||
|
data-label="{{ category.name }} / {{ subcategory.name }}"
|
||||||
|
data-value="subcategory:{{ category.slug }}:{{ subcategory.slug }}"
|
||||||
|
>
|
||||||
|
{{ subcategory.name }}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<label>
|
<label>
|
||||||
<input class="search-input" type="text" placeholder="Кто ищет, тот найдет...">
|
<input class="search-input" type="text" placeholder="Кто ищет, тот найдет...">
|
||||||
</label>
|
</label>
|
||||||
@@ -24,7 +78,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="header-icons">
|
<div class="header-icons">
|
||||||
<a href="">
|
<a href="{% if request.user.is_authenticated %}{% url 'favorites:index' %}{% else %}{% url 'users:login' %}{% endif %}"
|
||||||
|
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-heart" viewBox="0 0 16 16">
|
class="bi bi-heart" viewBox="0 0 16 16">
|
||||||
<path d="m8 2.748-.717-.737C5.6.281 2.514.878 1.4 3.053c-.523 1.023-.641 2.5.314 4.385.92 1.815 2.834 3.989 6.286 6.357 3.452-2.368 5.365-4.542 6.286-6.357.955-1.886.838-3.362.314-4.385C13.486.878 10.4.28 8.717 2.01zM8 15C-7.333 4.868 3.279-3.04 7.824 1.143q.09.083.176.171a3 3 0 0 1 .176-.17C12.72-3.042 23.333 4.867 8 15"></path>
|
<path d="m8 2.748-.717-.737C5.6.281 2.514.878 1.4 3.053c-.523 1.023-.641 2.5.314 4.385.92 1.815 2.834 3.989 6.286 6.357 3.452-2.368 5.365-4.542 6.286-6.357.955-1.886.838-3.362.314-4.385C13.486.878 10.4.28 8.717 2.01zM8 15C-7.333 4.868 3.279-3.04 7.824 1.143q.09.083.176.171a3 3 0 0 1 .176-.17C12.72-3.042 23.333 4.867 8 15"></path>
|
||||||
|
|||||||
7
templates/_fragments/item-grid.html
Normal file
7
templates/_fragments/item-grid.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<div class="items-grid"{% if favorites_grid %} data-favorites-grid data-empty-message="{{ empty_message|escape }}"{% endif %}>
|
||||||
|
{% for item in items %}
|
||||||
|
{% include "catalog/_fragments/item-card.html" with item=item %}
|
||||||
|
{% empty %}
|
||||||
|
<p class="item-list-empty">{{ empty_message }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fancyapps/ui@6.1/dist/fancybox/fancybox.css"/>
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fancyapps/ui@6.1/dist/fancybox/fancybox.css"/>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/swiper@12/swiper-bundle.min.js"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@fancyapps/ui@6.1/dist/fancybox/fancybox.umd.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@fancyapps/ui@6.1/dist/fancybox/fancybox.umd.js"></script>
|
||||||
|
<script src="{% static 'js/header-category-dropdown.js' %}"></script>
|
||||||
{% block add_css %}
|
{% block add_css %}
|
||||||
<!-- Additional CSS -->
|
<!-- Additional CSS -->
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -52,5 +53,6 @@
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
<script src="{% static 'js/favorites.js' %}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
57
templates/catalog/_fragments/item-card.html
Normal file
57
templates/catalog/_fragments/item-card.html
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<article class="item-card{% if item.id in favorite_item_ids %} is-favorite{% endif %}" data-item-card="{{ item.id }}">
|
||||||
|
<a class="item-card-media-link" href="{% url 'detail' item.category.slug item.slug %}" aria-label="Открыть товар {{ item.name }}">
|
||||||
|
{% with primary_image=item.primary_image %}
|
||||||
|
<div class="item-card-image">
|
||||||
|
{% if primary_image %}
|
||||||
|
<img src="{{ primary_image.image.url }}" alt="{{ primary_image.alt|default:item.name }}">
|
||||||
|
{% else %}
|
||||||
|
<div class="item-card-placeholder">Нет фото</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="item-card-body">
|
||||||
|
<div class="item-card-header">
|
||||||
|
<div class="item-card-category">
|
||||||
|
{{ item.category.name }}{% if item.subcategory %} / {{ item.subcategory.name }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<form class="item-card-favorite-form" method="post" action="{% url 'favorites:toggle' %}" data-favorite-form>
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||||
|
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||||
|
<button
|
||||||
|
class="item-card-favorite-button{% if item.id in favorite_item_ids %} is-active{% endif %}"
|
||||||
|
type="submit"
|
||||||
|
title="{% if item.id in favorite_item_ids %}Удалить из избранного{% else %}Добавить в избранное{% endif %}"
|
||||||
|
aria-label="{% if item.id in favorite_item_ids %}Удалить из избранного{% else %}Добавить в избранное{% endif %}"
|
||||||
|
data-favorite-button
|
||||||
|
data-label-add="Добавить в избранное"
|
||||||
|
data-label-remove="Удалить из избранного"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" focusable="false">
|
||||||
|
<path d="m8 2.748-.717-.737C5.6.281 2.514.878 1.4 3.053c-.523 1.023-.641 2.5.314 4.385.92 1.815 2.834 3.989 6.286 6.357 3.452-2.368 5.365-4.542 6.286-6.357.955-1.886.838-3.362.314-4.385C13.486.878 10.4.28 8.717 2.01z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<a class="item-card-favorite-button item-card-favorite-button--login" href="{% url 'users:login' %}?next={{ request.get_full_path|urlencode }}" title="Войдите, чтобы добавить в избранное" aria-label="Войдите, чтобы добавить в избранное" data-favorite-button data-label-add="Добавить в избранное" data-label-remove="Удалить из избранного">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" focusable="false">
|
||||||
|
<path d="m8 2.748-.717-.737C5.6.281 2.514.878 1.4 3.053c-.523 1.023-.641 2.5.314 4.385.92 1.815 2.834 3.989 6.286 6.357 3.452-2.368 5.365-4.542 6.286-6.357.955-1.886.838-3.362.314-4.385C13.486.878 10.4.28 8.717 2.01z"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="item-card-title-link" href="{% url 'detail' item.category.slug item.slug %}" aria-label="Открыть товар {{ item.name }}">
|
||||||
|
<h2 class="item-card-title">{{ item.name }}</h2>
|
||||||
|
</a>
|
||||||
|
<div class="item-card-footer">
|
||||||
|
<span class="item-card-price">{{ item.price }} руб.</span>
|
||||||
|
<span class="item-card-availability {% if item.is_available %}available{% else %}unavailable{% endif %}">
|
||||||
|
{% if item.is_available %}В наличии{% else %}Нет в наличии{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
@@ -1,34 +1,3 @@
|
|||||||
<main class="item-list">
|
<main class="item-list">
|
||||||
<div class="items-grid">
|
{% include "_fragments/item-grid.html" with items=items empty_message="В этой категории пока нет товаров." %}
|
||||||
{% for item in items %}
|
|
||||||
<article class="item-card">
|
|
||||||
<a class="item-card-link" href="{% url 'detail' item.category.slug item.slug %}" aria-label="Открыть товар {{ item.name }}">
|
|
||||||
{% with primary_image=item.primary_image %}
|
|
||||||
<div class="item-card-image">
|
|
||||||
{% if primary_image %}
|
|
||||||
<img src="{{ primary_image.image.url }}" alt="{{ primary_image.alt|default:item.name }}">
|
|
||||||
{% else %}
|
|
||||||
<div class="item-card-placeholder">Нет фото</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endwith %}
|
|
||||||
|
|
||||||
<div class="item-card-body">
|
|
||||||
<div class="item-card-category">
|
|
||||||
{{ item.category.name }}{% if item.subcategory %} / {{ item.subcategory.name }}{% endif %}
|
|
||||||
</div>
|
|
||||||
<h2 class="item-card-title">{{ item.name }}</h2>
|
|
||||||
<div class="item-card-footer">
|
|
||||||
<span class="item-card-price">{{ item.price }} руб.</span>
|
|
||||||
<span class="item-card-availability {% if item.is_available %}available{% else %}unavailable{% endif %}">
|
|
||||||
{% if item.is_available %}В наличии{% else %}Нет в наличии{% endif %}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</article>
|
|
||||||
{% empty %}
|
|
||||||
<p class="item-list-empty">В этой категории пока нет товаров.</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
39
templates/favorites/index.html
Normal file
39
templates/favorites/index.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "_layouts/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}Избранное{% endblock %}
|
||||||
|
|
||||||
|
{% block add_css %}
|
||||||
|
<link rel="stylesheet" href="{% static "css/catalog.css" %}">
|
||||||
|
<link rel="stylesheet" href="{% static "css/favorites.css" %}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include "_fragments/header.html" with active="favorites" %}
|
||||||
|
|
||||||
|
{% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2='/favorites/' text_2='Избранное' %}
|
||||||
|
|
||||||
|
<main class="container favorites-page" data-favorites-page>
|
||||||
|
<section class="favorites-hero">
|
||||||
|
<p class="favorites-kicker">Личный список</p>
|
||||||
|
<div class="favorites-hero__row">
|
||||||
|
<div>
|
||||||
|
<h1 class="favorites-title">Избранные товары</h1>
|
||||||
|
<p class="favorites-description">
|
||||||
|
Здесь собраны товары, которые вы хотите сохранить для быстрого возврата к ним позже.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="favorites-counter">
|
||||||
|
<span class="favorites-counter__value" data-favorites-count-value>{{ favorite_count }}</span>
|
||||||
|
<span class="favorites-counter__label" data-favorites-count-label>товаров</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="favorites-list" aria-label="Список избранных товаров">
|
||||||
|
{% include "_fragments/item-grid.html" with items=items empty_message="В избранном пока нет товаров." favorites_grid=True %}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{% include "_fragments/footer.html" %}
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user