feat: implement shopping cart feature with user authentication support
All checks were successful
Build and Push Docker Image / build (push) Successful in 42s

- Added `Cart` and `CartItem` models with migrations for managing user-specific carts and items
- Developed services for cart-related operations: adding, removing, and updating cart items
- Built specialized templates (`cart.html`) and styled them with new CSS assets (`cart.css`)
- Enabled real-time UI updates via AJAX in shopping cart interactions (`cart.js`)
- Modified header to dynamically display cart link with user authentication awareness
- Added Leaflet-based interactive map to the contacts page and integrated CSS/JS dependencies
- Expanded tests for cart functionality, item detail page cart integration, and contacts page maps
This commit is contained in:
2026-05-15 23:18:16 +02:00
parent d2126e0fba
commit 52cbc1c2b3
22 changed files with 1317 additions and 18 deletions

View File

@@ -85,7 +85,7 @@
<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>
</svg>
</a>
<a href="#">
<a href="{% if request.user.is_authenticated %}{% url 'orders:cart' %}{% else %}{% url 'users:login' %}?next={% url 'orders:cart' %}{% endif %}" aria-label="Корзина">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
class="bi bi-cart2" viewBox="0 0 16 16">
<path d="M0 2.5A.5.5 0 0 1 .5 2H2a.5.5 0 0 1 .485.379L2.89 4H14.5a.5.5 0 0 1 .485.621l-1.5 6A.5.5 0 0 1 13 11H4a.5.5 0 0 1-.485-.379L1.61 3H.5a.5.5 0 0 1-.5-.5M3.14 5l1.25 5h8.22l1.25-5zM5 13a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-2 1a2 2 0 1 1 4 0 2 2 0 0 1-4 0m9-1a1 1 0 1 0 0 2 1 1 0 0 0 0-2m-2 1a2 2 0 1 1 4 0 2 2 0 0 1-4 0"></path>

View File

@@ -54,5 +54,7 @@
})
</script>
<script src="{% static 'js/favorites.js' %}"></script>
<script src="{% static 'js/cart.js' %}"></script>
{% block add_js %}{% endblock %}
</body>
</html>

View File

@@ -61,7 +61,28 @@
{% endif %}
<div class="product-summary__actions">
<button class="product-summary__button product-summary__button--primary" type="button">В корзину</button>
{% if item.id in cart_item_ids %}
<a class="product-summary__button product-summary__button--primary product-summary__button--link" href="{% url 'orders:cart' %}">
Перейти в корзину
</a>
{% elif request.user.is_authenticated %}
<form method="post" action="{% url 'orders:add' %}" class="product-summary__cart-form">
{% csrf_token %}
<input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="next" value="{{ request.get_full_path }}">
<button class="product-summary__button product-summary__button--primary" type="submit" {% if not item.is_available %}disabled{% endif %}>
В корзину
</button>
</form>
{% else %}
<a
class="product-summary__button product-summary__button--primary product-summary__button--link"
href="{% url 'users:login' %}?next={{ request.get_full_path|urlencode }}"
{% if not item.is_available %}aria-disabled="true"{% endif %}
>
В корзину
</a>
{% endif %}
<button class="product-summary__button product-summary__button--secondary" type="button">Купить</button>
</div>
</section>

View File

@@ -1,16 +1,110 @@
{% extends "_layouts/base.html" %}
{% load static %}
{% block title %}Главная{% endblock %}
{% block title %}Контакты{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{% static 'css/contacts.css' %}">
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
>
{% endblock %}
{% block content %}
{% include "_fragments/header.html" %}
{% include "_fragments/header.html" with active="contacts" breadcrumb=1 %}
<main class="container contacts-page">
{% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2='/contacts/' text_2='Контакты' %}
<section class="contacts-page__hero">
<h1>КОНТАКТЫ</h1>
<div class="contacts-grid">
<div class="contacts-column">
<section class="contacts-card">
<h2>Адрес:</h2>
<p>{{ contact_address }}</p>
</section>
<section class="contacts-card">
<h2>Режим работы:</h2>
<p>{{ contact_hours_weekdays }}</p>
<p>{{ contact_hours_weekends }}</p>
</section>
</div>
<div class="contacts-column">
<section class="contacts-card">
<h2>Телефон:</h2>
<p><a href="tel:{{ contact_phone_primary_url }}">{{ contact_phone_primary }}</a></p>
<p><a href="tel:{{ contact_phone_secondary_url }}">{{ contact_phone_secondary }}</a></p>
</section>
<section class="contacts-card">
<h2>E-mail</h2>
<p><a href="mailto:{{ contact_email }}">{{ contact_email }}</a></p>
</section>
</div>
</div>
</section>
<section class="contacts-map-section" aria-labelledby="contacts-map-title">
<h2 class="contacts-map-section__title" id="contacts-map-title">Как нас найти</h2>
<div
id="contacts-map"
class="contacts-map"
data-lat="{{ map_lat }}"
data-lon="{{ map_lon }}"
data-zoom="{{ map_zoom }}"
data-title="SantehRay"
></div>
<noscript>
<div class="contacts-map contacts-map--fallback contacts-map--noscript">
<div class="contacts-map-fallback">
<h3>Для интерактивной карты нужен JavaScript.</h3>
<a href="{{ map_external_url }}" target="_blank" rel="noreferrer">Открыть адрес в OpenStreetMap</a>
</div>
</div>
</noscript>
</section>
</main>
{# TODO #}
В разработке
{% include "_fragments/footer.html" %}
{% endblock %}
{% endblock %}
{% block add_js %}
<script
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""
></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
const mapRoot = document.getElementById('contacts-map')
if (!mapRoot || typeof L === 'undefined') {
return
}
const lat = Number(mapRoot.dataset.lat)
const lon = Number(mapRoot.dataset.lon)
const zoom = Number(mapRoot.dataset.zoom)
const title = mapRoot.dataset.title || 'SantehRay'
const map = L.map(mapRoot, {
scrollWheelZoom: true,
}).setView([lat, lon], zoom)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map)
L.marker([lat, lon]).addTo(map).bindPopup(title).openPopup()
})
</script>
{% endblock %}

113
templates/orders/cart.html Normal file
View File

@@ -0,0 +1,113 @@
{% extends "_layouts/base.html" %}
{% load static %}
{% block title %}Корзина{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{% static "css/cart.css" %}">
{% endblock %}
{% block content %}
{% include "_fragments/header.html" %}
{% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2='/orders/cart/' text_2='Корзина' %}
<main class="container cart-page">
<section class="cart-hero">
<p class="cart-kicker">Покупки</p>
<div class="cart-hero__row">
<div>
<h1 class="cart-title">Корзина</h1>
<p class="cart-description">
Проверьте выбранные позиции, измените количество или удалите лишнее перед оформлением.
</p>
</div>
<div class="cart-summary-card">
<span class="cart-summary-card__value" data-cart-total-quantity>{{ total_quantity }}</span>
<span class="cart-summary-card__label">товаров в корзине</span>
</div>
</div>
</section>
<section class="cart-content" aria-label="Содержимое корзины">
{% if cart_items %}
<div class="cart-items">
{% for cart_item in cart_items %}
{% with item=cart_item.item %}
<article class="cart-item" data-auth-cart-item data-current-quantity="{{ cart_item.quantity }}">
<a class="cart-item__media" href="{% url 'detail' item.category.slug item.slug %}">
{% with primary_image=item.primary_image %}
{% if primary_image %}
<img src="{{ primary_image.image.url }}" alt="{{ primary_image.alt|default:item.name }}">
{% else %}
<div class="cart-item__placeholder">Нет фото</div>
{% endif %}
{% endwith %}
</a>
<div class="cart-item__body">
<div class="cart-item__main">
<p class="cart-item__category">
{{ item.category.name }}{% if item.subcategory %} / {{ item.subcategory.name }}{% endif %}
</p>
<a class="cart-item__title" href="{% url 'detail' item.category.slug item.slug %}">{{ item.name }}</a>
<p class="cart-item__price">{{ item.price }} руб.</p>
</div>
<div class="cart-item__controls">
<div class="cart-quantity">
<button
class="cart-quantity__button"
type="button"
data-cart-decrement
data-remove-form-id="cart-remove-{{ cart_item.id }}"
data-update-form-id="cart-decrement-{{ cart_item.id }}"
data-current-quantity="{{ cart_item.quantity }}"
>
-
</button>
<span class="cart-quantity__value">{{ cart_item.quantity }}</span>
<form id="cart-increment-{{ cart_item.id }}" method="post" action="{% url 'orders:update_quantity' %}">
{% csrf_token %}
<input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="quantity" value="{{ cart_item.quantity|add:1 }}">
<input type="hidden" name="next" value="{% url 'orders:cart' %}">
<button class="cart-quantity__button" type="submit">+</button>
</form>
<form id="cart-decrement-{{ cart_item.id }}" method="post" action="{% url 'orders:update_quantity' %}">
{% csrf_token %}
<input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="quantity" value="{{ cart_item.quantity|add:-1 }}">
<input type="hidden" name="next" value="{% url 'orders:cart' %}">
</form>
</div>
<div class="cart-item__aside">
<span class="cart-item__subtotal">{{ cart_item.subtotal }} руб.</span>
<form id="cart-remove-{{ cart_item.id }}" method="post" action="{% url 'orders:remove' %}">
{% csrf_token %}
<input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="next" value="{% url 'orders:cart' %}">
<button class="cart-item__remove" type="submit">Удалить</button>
</form>
</div>
</div>
</div>
</article>
{% endwith %}
{% endfor %}
</div>
<aside class="cart-totals">
<p class="cart-totals__label">Итого</p>
<p class="cart-totals__value" data-cart-total-amount>{{ total_amount }}</p>
<p class="cart-totals__meta">Позиции можно изменить прямо на этой странице.</p>
</aside>
{% else %}
<p class="cart-empty">Корзина пока пуста.</p>
{% endif %}
</section>
</main>
{% include "_fragments/footer.html" %}
{% endblock %}