feat: add favorites feature for users
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:
2026-05-10 20:06:12 +02:00
parent 812065a34b
commit 29d8302a4f
30 changed files with 1042 additions and 65 deletions

1
favorites/__init__.py Normal file
View File

@@ -0,0 +1 @@

11
favorites/admin.py Normal file
View 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
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class FavoritesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "favorites"

View 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')],
},
),
]

View File

@@ -0,0 +1 @@

32
favorites/models.py Normal file
View 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
View 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
View 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
View 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
View 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)

View File

@@ -2,6 +2,7 @@ from django.test import TestCase, override_settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from favorites.models import Favorite
from items.models import Category, Item, ItemImage, Subcategory
@@ -52,7 +53,7 @@ class CatalogViewTests(TestCase):
self.assertEqual(response.status_code, 200)
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):
Item.objects.create(
@@ -141,6 +142,36 @@ class CatalogViewTests(TestCase):
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):
ItemImage.objects.create(
item=self.sink,

View File

@@ -3,6 +3,7 @@ from dataclasses import dataclass
from django.http import HttpRequest, HttpResponse, Http404
from django.shortcuts import render
from favorites.services import get_user_favorite_item_ids
from items.models import Category, Item, Subcategory
@@ -56,6 +57,7 @@ def index(request: HttpRequest, category: str | None = None) -> HttpResponse:
'items': items,
'selected_category': selected_category,
'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,
'images': images,
'primary_image': images[0] if images else None,
'favorite_item_ids': get_user_favorite_item_ids(request.user),
})

View File

@@ -1,2 +1,46 @@
from django.db.models import Prefetch
from items.models import Category, Subcategory
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,
}

View File

@@ -188,6 +188,7 @@ INSTALLED_APPS = [
'django.contrib.staticfiles',
'home',
'items',
'favorites',
'orders',
'users',
]
@@ -368,6 +369,11 @@ LOGGING = {
"level": LOG_LEVEL,
"propagate": False,
},
"favorites": {
"handlers": ["console"],
"level": LOG_LEVEL,
"propagate": False,
},
"orders": {
"handlers": ["console"],
"level": LOG_LEVEL,

View File

@@ -28,6 +28,7 @@ urlpatterns = [
path("favicon.ico", views.favicon, name="favicon"),
path('', include('home.urls')),
path('catalog/', include('items.urls')),
path('favorites/', include('favorites.urls')),
path('orders/', include('orders.urls')),
path('users/', include('users.urls')),
path('admin/', admin.site.urls),

View File

@@ -74,9 +74,8 @@ aside {
transform: translateY(-2px);
}
.item-card-link {
.item-card-media-link {
display: block;
height: 100%;
}
.item-card-image {
@@ -106,11 +105,60 @@ aside {
padding: 18px;
}
.item-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.item-card-category {
color: #63768d;
font-size: 15px;
line-height: 1.2;
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 {
@@ -397,6 +445,15 @@ aside {
flex-direction: column;
}
.item-card-header {
gap: 10px;
}
.item-card-favorite-button {
width: 38px;
height: 38px;
}
.product-gallery__thumbs {
gap: 12px;
}

0
static/css/contacts.css Normal file
View File

115
static/css/favorites.css Normal file
View 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%;
}
}

View File

@@ -627,7 +627,7 @@
line-height: 24px;
text-align: left;
color: #b8f3ff;
color: #ffffff;
}
.faq-body {

View File

@@ -10,7 +10,6 @@
padding-top: 60px;
padding-bottom: 60px;
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%);
}
@@ -59,7 +58,7 @@
margin-bottom: 18px;
font-size: 20px;
line-height: 1.2;
color: #b8f3ff;
color: #ffffff;
}
.auth-title {

View File

@@ -240,16 +240,134 @@ header {
margin: 0 24px;
}
.search-wrap select {
border: none;
.search-select-wrap {
position: relative;
flex: 0 0 154px;
width: 154px;
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-size: 13px;
color: var(--mid);
background: transparent;
outline: none;
line-height: 1.2;
text-align: left;
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 {
@@ -393,7 +511,7 @@ footer {
}
.footer-logo {
font-family: 'Playfair Display', serif;
font-family: 'BerlinType', serif;
font-size: 30px;
color: rgba(184, 243, 255, .85);
margin-bottom: 8px;
@@ -459,11 +577,15 @@ footer {
margin: 0;
}
.search-wrap select {
.search-select-wrap {
flex: 0 0 132px;
max-width: none;
width: 132px;
}
.search-category-trigger {
padding-left: 8px;
padding-right: 8px;
padding-right: 28px;
}
.header-icons {

114
static/js/favorites.js Normal file
View 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;
}
});
});
});

View 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();
}
});
});
});

View File

@@ -7,19 +7,19 @@
<div class="footer-col">
<h4>ПОКУПАТЕЛЮ</h4>
<ul>
<li><a href="#">Каталог</a></li>
<li><a href="#">Услуги</a></li>
<li><a href="#">Акции</a></li>
<li><a href="#">Новинки</a></li>
<li><a href="/catalog/">Каталог</a></li>
<li><a href="{% url 'services' %}">Услуги</a></li>
<li><a href="/catalog/">Акции</a></li>
<li><a href="/catalog/">Новинки</a></li>
</ul>
</div>
<div class="footer-col">
<h4>ПОМОЩЬ</h4>
<ul>
<li><a href="#">Доставка и оплата</a></li>
<li><a href="#">Гарантия и возврат</a></li>
<li>Ответы на вопросы</li>
<li><a href="#">Контакты</a></li>
<li><a href="{% url 'shipping' %}">Доставка и оплата</a></li>
<li><a href="{% url 'warranty' %}">Гарантия и возврат</a></li>
<li><a href="{% url 'home' %}#faq-section">Ответы на вопросы</a></li>
<li><a href="{% url 'contacts' %}">Контакты</a></li>
</ul>
</div>
<div class="footer-col">

View File

@@ -5,13 +5,67 @@
<div class="logo"><a href="/">SantehRay</a></div>
<form action="">
<div class="search-wrap">
<label>
<select>
<option>Все категории</option>
<option>Сантехника</option>
<option>Декор</option>
</select>
</label>
<div class="search-select-wrap">
<div class="search-category-dropdown" data-category-dropdown>
<button
type="button"
class="search-category-trigger"
aria-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>
<input class="search-input" type="text" placeholder="Кто ищет, тот найдет...">
</label>
@@ -24,7 +78,8 @@
</div>
</form>
<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"
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>

View 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>

View File

@@ -16,6 +16,7 @@
<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/@fancyapps/ui@6.1/dist/fancybox/fancybox.umd.js"></script>
<script src="{% static 'js/header-category-dropdown.js' %}"></script>
{% block add_css %}
<!-- Additional CSS -->
{% endblock %}
@@ -52,5 +53,6 @@
})
})
</script>
<script src="{% static 'js/favorites.js' %}"></script>
</body>
</html>

View 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>

View File

@@ -1,34 +1,3 @@
<main class="item-list">
<div class="items-grid">
{% 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>
{% include "_fragments/item-grid.html" with items=items empty_message="В этой категории пока нет товаров." %}
</main>

View 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 %}