feat: implement order management and checkout functionality
All checks were successful
Build and Push Docker Image / build (push) Successful in 50s

- Added `Order` and `OrderItem` models with migrations for managing orders and their details
- Created `CheckoutForm` for validating order data and customer information
- Built templates for order confirmation, checkout success, and emails (HTML and plain text)
- Added logic for email notifications upon successful order placement
- Enhanced shopping cart template with checkout panel integration
- Updated styles and JavaScript for streamlined checkout UX
- Refactored cart view to support item summaries and final totals during checkout
This commit is contained in:
2026-05-25 16:45:42 +02:00
parent 438d547fb4
commit 81e30f853d
16 changed files with 1271 additions and 262 deletions

23
.env Normal file
View File

@@ -0,0 +1,23 @@
SECRET_KEY=replace-me
DEBUG=false
LOG_LEVEL=info
WHITENOISE_USE_MANIFEST=false
WHITENOISE_MANIFEST_STRICT=false
ALLOWED_HOSTS=example.com,www.example.com
CSRF_TRUSTED_ORIGINS=https://example.com,https://www.example.com
USE_X_FORWARDED_HOST=true
SECURE_PROXY_SSL_HEADER=HTTP_X_FORWARDED_PROTO,https
SECURE_REFERRER_POLICY=strict-origin-when-cross-origin
SITE_NAME=SantehRay
LOGIN_URL=/users/login/
LOGIN_REDIRECT_URL=/users/profile/
LOGOUT_REDIRECT_URL=/users/login/
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_USE_TLS=true
EMAIL_USE_SSL=false
DEFAULT_FROM_EMAIL=no-reply@example.com
ADMINISTRATOR_EMAIL=admin@example.com

View File

@@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from orders.models import Cart, CartItem from orders.models import Cart, CartItem, Order, OrderItem
class CartItemInline(admin.TabularInline): class CartItemInline(admin.TabularInline):
@@ -20,3 +20,44 @@ class CartItemAdmin(admin.ModelAdmin):
list_display = ("item", "cart", "quantity", "updated_at") list_display = ("item", "cart", "quantity", "updated_at")
list_select_related = ("cart", "item", "cart__user") list_select_related = ("cart", "item", "cart__user")
search_fields = ("item__name", "cart__user__username", "cart__user__email") search_fields = ("item__name", "cart__user__username", "cart__user__email")
class OrderItemInline(admin.TabularInline):
model = OrderItem
extra = 0
readonly_fields = ("item", "item_name", "item_slug", "item_price", "quantity", "line_total")
can_delete = False
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ("id", "customer_name", "email", "phone", "status", "total", "created_at")
list_filter = ("status", "delivery_method", "payment_method", "created_at")
list_select_related = ("user",)
search_fields = ("id", "customer_name", "email", "phone", "user__username", "user__email")
readonly_fields = (
"user",
"customer_name",
"phone",
"email",
"delivery_method",
"region",
"address",
"payment_method",
"promo_code",
"products_total",
"discount_total",
"delivery_total",
"floor_lift_total",
"total",
"status",
"created_at",
)
inlines = [OrderItemInline]
@admin.register(OrderItem)
class OrderItemAdmin(admin.ModelAdmin):
list_display = ("item_name", "order", "quantity", "item_price", "line_total")
list_select_related = ("order", "item")
search_fields = ("item_name", "order__customer_name", "order__email")

56
orders/forms.py Normal file
View File

@@ -0,0 +1,56 @@
from django import forms
from orders.models import Order
class CheckoutForm(forms.Form):
delivery_method = forms.ChoiceField(
choices=Order.DeliveryMethod.choices,
initial=Order.DeliveryMethod.COURIER,
widget=forms.RadioSelect,
label="Способ доставки",
)
region = forms.CharField(max_length=255, required=False, label="Регион")
address = forms.CharField(max_length=512, required=False, label="Адрес")
payment_method = forms.ChoiceField(
choices=Order.PaymentMethod.choices,
initial=Order.PaymentMethod.CARD,
widget=forms.RadioSelect,
label="Способ оплаты",
)
customer_name = forms.CharField(max_length=255, label="ФИО")
phone = forms.CharField(max_length=32, label="Телефон")
email = forms.EmailField(label="E-mail")
recipient_matches_customer = forms.BooleanField(required=False, initial=True)
promo_code = forms.CharField(max_length=64, required=False, label="Промокод")
terms_accepted = forms.BooleanField(required=True, label="Согласие с условиями")
def clean_email(self):
return self.cleaned_data["email"].strip().lower()
def clean_promo_code(self):
return self.cleaned_data["promo_code"].strip()
def clean_customer_name(self):
return self.cleaned_data["customer_name"].strip()
def clean_phone(self):
return self.cleaned_data["phone"].strip()
def clean_region(self):
return self.cleaned_data["region"].strip()
def clean_address(self):
return self.cleaned_data["address"].strip()
def clean(self):
cleaned_data = super().clean()
delivery_method = cleaned_data.get("delivery_method")
if delivery_method == Order.DeliveryMethod.COURIER:
if not cleaned_data.get("region"):
self.add_error("region", "Укажите регион доставки.")
if not cleaned_data.get("address"):
self.add_error("address", "Укажите адрес доставки.")
return cleaned_data

View File

@@ -0,0 +1,63 @@
# Generated by Django 6.0.5 on 2026-05-21 11:58
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('items', '0003_subcategory_item_subcategory_and_more'),
('orders', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Order',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('customer_name', models.CharField(max_length=255, verbose_name='ФИО')),
('phone', models.CharField(max_length=32, verbose_name='Телефон')),
('email', models.EmailField(max_length=254, verbose_name='E-mail')),
('delivery_method', models.CharField(choices=[('courier', 'Курьерская доставка'), ('pickup', 'Самовывоз')], default='courier', max_length=16, verbose_name='Способ доставки')),
('region', models.CharField(blank=True, max_length=255, verbose_name='Регион')),
('address', models.CharField(blank=True, max_length=512, verbose_name='Адрес')),
('payment_method', models.CharField(choices=[('card', 'Банковская карта'), ('credit', 'Кредит'), ('installment', 'Рассрочка')], default='card', max_length=16, verbose_name='Способ оплаты')),
('promo_code', models.CharField(blank=True, max_length=64, verbose_name='Промокод')),
('products_total', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Сумма товаров')),
('discount_total', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Скидка')),
('delivery_total', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Доставка')),
('floor_lift_total', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='Подъём на этаж')),
('total', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Итого к оплате')),
('status', models.CharField(choices=[('paid', 'Оплачен')], default='paid', max_length=16, verbose_name='Статус')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
],
options={
'verbose_name': 'Заказ',
'verbose_name_plural': 'Заказы',
'ordering': ('-created_at', '-id'),
},
),
migrations.CreateModel(
name='OrderItem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('item_name', models.CharField(max_length=255, verbose_name='Название товара')),
('item_slug', models.SlugField(blank=True, verbose_name='URL товара')),
('item_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Цена товара')),
('quantity', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1)], verbose_name='Количество')),
('line_total', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Сумма строки')),
('item', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='order_entries', to='items.item', verbose_name='Товар')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.order', verbose_name='Заказ')),
],
options={
'verbose_name': 'Позиция заказа',
'verbose_name_plural': 'Позиции заказа',
'ordering': ('id',),
},
),
]

View File

@@ -57,3 +57,92 @@ class CartItem(models.Model):
@property @property
def subtotal(self): def subtotal(self):
return self.item.price * self.quantity return self.item.price * self.quantity
class Order(models.Model):
class DeliveryMethod(models.TextChoices):
COURIER = "courier", "Курьерская доставка"
PICKUP = "pickup", "Самовывоз"
class PaymentMethod(models.TextChoices):
CARD = "card", "Банковская карта"
CREDIT = "credit", "Кредит"
INSTALLMENT = "installment", "Рассрочка"
class Status(models.TextChoices):
PAID = "paid", "Оплачен"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="orders",
verbose_name="Пользователь",
)
customer_name = models.CharField(max_length=255, verbose_name="ФИО")
phone = models.CharField(max_length=32, verbose_name="Телефон")
email = models.EmailField(verbose_name="E-mail")
delivery_method = models.CharField(
max_length=16,
choices=DeliveryMethod.choices,
default=DeliveryMethod.COURIER,
verbose_name="Способ доставки",
)
region = models.CharField(max_length=255, blank=True, verbose_name="Регион")
address = models.CharField(max_length=512, blank=True, verbose_name="Адрес")
payment_method = models.CharField(
max_length=16,
choices=PaymentMethod.choices,
default=PaymentMethod.CARD,
verbose_name="Способ оплаты",
)
promo_code = models.CharField(max_length=64, blank=True, verbose_name="Промокод")
products_total = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Сумма товаров")
discount_total = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Скидка")
delivery_total = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Доставка")
floor_lift_total = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="Подъём на этаж")
total = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Итого к оплате")
status = models.CharField(
max_length=16,
choices=Status.choices,
default=Status.PAID,
verbose_name="Статус",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
class Meta:
ordering = ("-created_at", "-id")
verbose_name = "Заказ"
verbose_name_plural = "Заказы"
def __str__(self):
return f"Заказ №{self.pk}"
class OrderItem(models.Model):
order = models.ForeignKey(
Order,
on_delete=models.CASCADE,
related_name="items",
verbose_name="Заказ",
)
item = models.ForeignKey(
Item,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="order_entries",
verbose_name="Товар",
)
item_name = models.CharField(max_length=255, verbose_name="Название товара")
item_slug = models.SlugField(blank=True, verbose_name="URL товара")
item_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Цена товара")
quantity = models.PositiveIntegerField(validators=[MinValueValidator(1)], verbose_name="Количество")
line_total = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Сумма строки")
class Meta:
ordering = ("id",)
verbose_name = "Позиция заказа"
verbose_name_plural = "Позиции заказа"
def __str__(self):
return f"{self.item_name} x {self.quantity}"

View File

@@ -1,17 +1,45 @@
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.mail import EmailMultiAlternatives
from django.db import transaction
from django.template.loader import render_to_string
from django.utils import formats from django.utils import formats
from items.models import Item from items.models import Item
from orders.models import Cart, CartItem from orders.models import Cart, CartItem, Order, OrderItem
DELIVERY_PRICE = Decimal("500.00")
FLOOR_LIFT_PRICE = Decimal("45.00")
ZERO_MONEY = Decimal("0.00")
def format_currency(value: Decimal) -> str: def format_currency(value: Decimal) -> str:
return f"{formats.number_format(value, 2)} руб." return f"{formats.number_format(value, 2)} руб."
def calculate_checkout_totals(cart_items: list[CartItem], delivery_method: str) -> dict[str, Decimal]:
products_total = sum(
(cart_item.item.price * cart_item.quantity for cart_item in cart_items),
start=ZERO_MONEY,
)
discount_total = ZERO_MONEY
delivery_total = DELIVERY_PRICE if delivery_method == Order.DeliveryMethod.COURIER else ZERO_MONEY
floor_lift_total = FLOOR_LIFT_PRICE if delivery_method == Order.DeliveryMethod.COURIER else ZERO_MONEY
total = products_total - discount_total + delivery_total + floor_lift_total
return {
"products_total": products_total,
"discount_total": discount_total,
"delivery_total": delivery_total,
"floor_lift_total": floor_lift_total,
"total": total,
}
def get_or_create_cart(user: Any) -> Cart: def get_or_create_cart(user: Any) -> Cart:
cart, _ = Cart.objects.get_or_create(user=user) cart, _ = Cart.objects.get_or_create(user=user)
return cart return cart
@@ -38,6 +66,16 @@ def get_user_cart(user: Any) -> Cart | None:
) )
def get_user_cart_items(user: Any) -> list[CartItem]:
cart = get_user_cart(user)
if not cart:
return []
return list(
cart.items.select_related("item__category", "item__subcategory").prefetch_related("item__images")
)
def add_item_to_cart(user: Any, item: Item, quantity: int = 1) -> tuple[CartItem, bool]: def add_item_to_cart(user: Any, item: Item, quantity: int = 1) -> tuple[CartItem, bool]:
if quantity < 1: if quantity < 1:
raise ValidationError("Количество товара должно быть не меньше 1.") raise ValidationError("Количество товара должно быть не меньше 1.")
@@ -67,3 +105,75 @@ def update_cart_item_quantity(user: Any, item_id: int, quantity: int) -> CartIte
cart_item.quantity = quantity cart_item.quantity = quantity
cart_item.save(update_fields=["quantity", "updated_at"]) cart_item.save(update_fields=["quantity", "updated_at"])
return cart_item return cart_item
@transaction.atomic
def create_order_from_cart(user: Any, cleaned_data: dict[str, Any]) -> Order:
cart = get_user_cart(user)
if not cart:
raise ValidationError("Корзина пуста.")
cart_items = list(cart.items.select_related("item"))
if not cart_items:
raise ValidationError("Корзина пуста.")
totals = calculate_checkout_totals(cart_items, cleaned_data["delivery_method"])
order = Order.objects.create(
user=user,
customer_name=cleaned_data["customer_name"],
phone=cleaned_data["phone"],
email=cleaned_data["email"],
delivery_method=cleaned_data["delivery_method"],
region=cleaned_data.get("region", ""),
address=cleaned_data.get("address", ""),
payment_method=cleaned_data["payment_method"],
promo_code=cleaned_data.get("promo_code", ""),
products_total=totals["products_total"],
discount_total=totals["discount_total"],
delivery_total=totals["delivery_total"],
floor_lift_total=totals["floor_lift_total"],
total=totals["total"],
status=Order.Status.PAID,
)
OrderItem.objects.bulk_create(
[
OrderItem(
order=order,
item=cart_item.item,
item_name=cart_item.item.name,
item_slug=cart_item.item.slug,
item_price=cart_item.item.price,
quantity=cart_item.quantity,
line_total=cart_item.item.price * cart_item.quantity,
)
for cart_item in cart_items
]
)
cart.items.all().delete()
return order
def send_order_confirmation_email(order: Order) -> None:
order = (
Order.objects.select_related("user")
.prefetch_related("items")
.get(pk=order.pk)
)
context = {
"order": order,
"site_name": getattr(settings, "SITE_NAME", "SantehRay"),
}
subject = render_to_string("orders/emails/order_confirmation_subject.txt", context).strip()
text_body = render_to_string("orders/emails/order_confirmation_email.txt", context)
html_body = render_to_string("orders/emails/order_confirmation_email.html", context)
message = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[order.email],
bcc=[settings.ADMINISTRATOR_EMAIL],
)
message.attach_alternative(html_body, "text/html")
message.send()

View File

@@ -1,16 +1,21 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core import mail
from django.db import IntegrityError from django.db import IntegrityError
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from items.models import Category, Item, ItemImage, Subcategory from items.models import Category, Item, ItemImage, Subcategory
from orders.models import Cart, CartItem from orders.models import Cart, CartItem, Order, OrderItem
User = get_user_model() User = get_user_model()
@override_settings(MEDIA_URL="/media/") @override_settings(
MEDIA_URL="/media/",
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
ADMINISTRATOR_EMAIL="admin@example.com",
)
class CartFlowTests(TestCase): class CartFlowTests(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create_user( self.user = User.objects.create_user(
@@ -40,6 +45,22 @@ class CartFlowTests(TestCase):
is_available=False, is_available=False,
) )
def checkout_data(self, **overrides):
data = {
"delivery_method": "courier",
"region": "Минск",
"address": "ул. Тимирязева, 123",
"payment_method": "card",
"customer_name": "Иван Петров",
"phone": "+375296881819",
"email": "buyer@example.com",
"recipient_matches_customer": "on",
"promo_code": "SPRING",
"terms_accepted": "on",
}
data.update(overrides)
return data
def test_cart_item_is_unique_per_cart(self): def test_cart_item_is_unique_per_cart(self):
cart = Cart.objects.create(user=self.user) cart = Cart.objects.create(user=self.user)
CartItem.objects.create(cart=cart, item=self.item, quantity=1) CartItem.objects.create(cart=cart, item=self.item, quantity=1)
@@ -99,8 +120,10 @@ class CartFlowTests(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, "1600,00 руб.") self.assertContains(response, "1600,00 руб.")
self.assertContains(response, "3200,00 руб.") self.assertContains(response, "3200,00 руб.")
self.assertContains(response, "3745,00 руб.")
self.assertContains(response, "/media/items/cart-item.jpg") self.assertContains(response, "/media/items/cart-item.jpg")
def test_cart_page_redirects_guest_to_login(self): def test_cart_page_redirects_guest_to_login(self):
@@ -108,3 +131,101 @@ class CartFlowTests(TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertIn(reverse("users:login"), response["Location"]) self.assertIn(reverse("users:login"), response["Location"])
def test_checkout_creates_paid_order_clears_cart_and_sends_email(self):
cart = Cart.objects.create(user=self.user)
CartItem.objects.create(cart=cart, item=self.item, quantity=2)
self.client.force_login(self.user)
response = self.client.post(reverse("orders:checkout"), data=self.checkout_data())
order = Order.objects.get()
self.assertRedirects(response, reverse("orders:success", kwargs={"order_id": order.id}))
self.assertEqual(order.status, Order.Status.PAID)
self.assertEqual(order.products_total, 3200)
self.assertEqual(order.discount_total, 0)
self.assertEqual(order.delivery_total, 500)
self.assertEqual(order.floor_lift_total, 45)
self.assertEqual(order.total, 3745)
self.assertEqual(order.promo_code, "SPRING")
self.assertFalse(CartItem.objects.filter(cart=cart).exists())
order_item = OrderItem.objects.get(order=order)
self.assertEqual(order_item.item_name, "Кухонная мойка")
self.assertEqual(order_item.quantity, 2)
self.assertEqual(order_item.line_total, 3200)
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].to, ["buyer@example.com"])
self.assertEqual(mail.outbox[0].bcc, ["admin@example.com"])
self.assertIn(f"заказ №{order.id}", mail.outbox[0].subject.lower())
self.assertIn("Кухонная мойка", mail.outbox[0].body)
def test_checkout_rejects_empty_cart(self):
self.client.force_login(self.user)
response = self.client.post(reverse("orders:checkout"), data=self.checkout_data())
self.assertRedirects(response, reverse("orders:cart"))
self.assertFalse(Order.objects.exists())
self.assertEqual(len(mail.outbox), 0)
def test_checkout_requires_address_for_courier_delivery(self):
cart = Cart.objects.create(user=self.user)
CartItem.objects.create(cart=cart, item=self.item, quantity=1)
self.client.force_login(self.user)
response = self.client.post(
reverse("orders:checkout"),
data=self.checkout_data(region="", address=""),
)
self.assertEqual(response.status_code, 400)
self.assertContains(response, "Укажите регион доставки.", status_code=400)
self.assertContains(response, "Укажите адрес доставки.", status_code=400)
self.assertFalse(Order.objects.exists())
def test_checkout_pickup_does_not_require_address_or_add_services(self):
cart = Cart.objects.create(user=self.user)
CartItem.objects.create(cart=cart, item=self.item, quantity=1)
self.client.force_login(self.user)
response = self.client.post(
reverse("orders:checkout"),
data=self.checkout_data(delivery_method="pickup", region="", address=""),
)
order = Order.objects.get()
self.assertRedirects(response, reverse("orders:success", kwargs={"order_id": order.id}))
self.assertEqual(order.delivery_total, 0)
self.assertEqual(order.floor_lift_total, 0)
self.assertEqual(order.total, 1600)
def test_success_page_is_only_available_to_order_owner(self):
other_user = User.objects.create_user(
username="other-user",
email="other@example.com",
password="StrongPassword123",
is_active=True,
)
order = Order.objects.create(
user=self.user,
customer_name="Иван Петров",
phone="+375296881819",
email="buyer@example.com",
delivery_method=Order.DeliveryMethod.COURIER,
region="Минск",
address="ул. Тимирязева, 123",
payment_method=Order.PaymentMethod.CARD,
products_total=1600,
discount_total=0,
delivery_total=500,
floor_lift_total=45,
total=2145,
status=Order.Status.PAID,
)
self.client.force_login(other_user)
response = self.client.get(reverse("orders:success", kwargs={"order_id": order.id}))
self.assertEqual(response.status_code, 404)

View File

@@ -10,4 +10,6 @@ urlpatterns = [
path("cart/add/", views.add, name="add"), path("cart/add/", views.add, name="add"),
path("cart/remove/", views.remove, name="remove"), path("cart/remove/", views.remove, name="remove"),
path("cart/update-quantity/", views.update_quantity, name="update_quantity"), path("cart/update-quantity/", views.update_quantity, name="update_quantity"),
path("checkout/", views.checkout, name="checkout"),
path("success/<int:order_id>/", views.success, name="success"),
] ]

View File

@@ -1,5 +1,3 @@
from decimal import Decimal
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
@@ -10,12 +8,16 @@ from django.utils.http import url_has_allowed_host_and_scheme
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from items.models import Item from items.models import Item
from orders.models import CartItem from orders.forms import CheckoutForm
from orders.models import CartItem, Order
from orders.services import ( from orders.services import (
add_item_to_cart, add_item_to_cart,
calculate_checkout_totals,
create_order_from_cart,
format_currency, format_currency,
get_user_cart, get_user_cart_items,
remove_item_from_cart, remove_item_from_cart,
send_order_confirmation_email,
update_cart_item_quantity, update_cart_item_quantity,
) )
@@ -33,29 +35,78 @@ def resolve_next_url(request: HttpRequest, fallback: str) -> str:
@login_required @login_required
def cart(request: HttpRequest) -> HttpResponse: def cart(request: HttpRequest) -> HttpResponse:
cart_items: list[CartItem] = [] cart_items = get_user_cart_items(request.user)
total_quantity = 0 total_quantity = sum(cart_item.quantity for cart_item in cart_items)
total_amount = "0,00 руб." totals = calculate_checkout_totals(cart_items, Order.DeliveryMethod.COURIER)
form = CheckoutForm(initial={
cart_obj = get_user_cart(request.user) "customer_name": request.user.get_full_name() or request.user.get_username(),
if cart_obj: "email": request.user.email,
cart_items = list( })
cart_obj.items.select_related("item__category", "item__subcategory").prefetch_related("item__images")
)
total_quantity = sum(cart_item.quantity for cart_item in cart_items)
total_amount_value = sum(
(cart_item.item.price * cart_item.quantity for cart_item in cart_items),
start=Decimal("0.00"),
)
total_amount = format_currency(total_amount_value)
return render(request, "orders/cart.html", { return render(request, "orders/cart.html", {
"cart_items": cart_items, "cart_items": cart_items,
"checkout_form": form,
"total_quantity": total_quantity, "total_quantity": total_quantity,
"total_amount": total_amount, "products_total_value": totals["products_total"],
"products_total": format_currency(totals["products_total"]),
"discount_total": format_currency(totals["discount_total"]),
"delivery_total": format_currency(totals["delivery_total"]),
"floor_lift_total": format_currency(totals["floor_lift_total"]),
"total_amount": format_currency(totals["total"]),
}) })
@login_required
@require_POST
def checkout(request: HttpRequest) -> HttpResponse:
cart_items = get_user_cart_items(request.user)
if not cart_items:
messages.error(request, "Корзина пуста. Добавьте товары перед оформлением заказа.")
return redirect("orders:cart")
form = CheckoutForm(request.POST)
if not form.is_valid():
total_quantity = sum(cart_item.quantity for cart_item in cart_items)
delivery_method = request.POST.get("delivery_method") or Order.DeliveryMethod.COURIER
totals = calculate_checkout_totals(cart_items, delivery_method)
return render(request, "orders/cart.html", {
"cart_items": cart_items,
"checkout_form": form,
"total_quantity": total_quantity,
"products_total_value": totals["products_total"],
"products_total": format_currency(totals["products_total"]),
"discount_total": format_currency(totals["discount_total"]),
"delivery_total": format_currency(totals["delivery_total"]),
"floor_lift_total": format_currency(totals["floor_lift_total"]),
"total_amount": format_currency(totals["total"]),
}, status=400)
try:
order = create_order_from_cart(request.user, form.cleaned_data)
except ValidationError as error:
messages.error(request, error.message)
return redirect("orders:cart")
try:
send_order_confirmation_email(order)
except Exception:
messages.error(request, "Заказ создан, но письмо с деталями не удалось отправить. Мы свяжемся с вами.")
else:
messages.success(request, "Оплата прошла успешно. Детали заказа отправлены на почту.")
return redirect("orders:success", order_id=order.id)
@login_required
def success(request: HttpRequest, order_id: int) -> HttpResponse:
order = get_object_or_404(
Order.objects.prefetch_related("items"),
pk=order_id,
user=request.user,
)
return render(request, "orders/success.html", {"order": order})
@login_required @login_required
@require_POST @require_POST
def add(request: HttpRequest) -> HttpResponse: def add(request: HttpRequest) -> HttpResponse:

View File

@@ -1,89 +1,43 @@
.cart-page { .cart-page {
margin-bottom: 88px; margin-bottom: 88px;
padding-top: 34px;
} }
.cart-hero { .checkout-layout {
padding: 28px 0 34px;
}
.cart-kicker {
color: #63768d;
font-size: 16px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.cart-hero__row {
margin-top: 12px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
}
.cart-title {
font-size: 44px;
line-height: 1.04;
}
.cart-description {
margin-top: 12px;
max-width: 620px;
color: #554971;
font-size: 18px;
line-height: 1.4;
}
.cart-summary-card {
min-width: 220px;
padding: 18px 22px;
border: 1px solid rgba(54, 33, 62, 0.12);
background: rgba(99, 118, 141, 0.06);
}
.cart-summary-card__value {
display: block;
font-size: 34px;
font-weight: 700;
}
.cart-summary-card__label {
display: block;
margin-top: 6px;
color: #554971;
}
.cart-content {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 280px; grid-template-columns: minmax(0, 1fr) minmax(360px, 490px);
gap: 28px; gap: 68px;
align-items: start; align-items: start;
} }
.cart-content--guest { .cart-section-title {
min-height: 280px; margin: 0 0 28px;
color: var(--dark);
font-family: 'BerlinType', serif;
font-size: 38px;
font-weight: 400;
line-height: 1.05;
} }
.cart-items { .cart-items {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 28px;
} }
.cart-item { .cart-item {
display: grid; display: grid;
grid-template-columns: 180px minmax(0, 1fr); grid-template-columns: 230px minmax(0, 1fr);
gap: 22px; gap: 34px;
padding: 22px; align-items: start;
border: 1px solid rgba(54, 33, 62, 0.12);
background: #ffffff;
} }
.cart-item__media { .cart-item__media {
display: block; display: block;
aspect-ratio: 4 / 3; width: 100%;
background: rgba(99, 118, 141, 0.08); aspect-ratio: 1.55 / 1;
overflow: hidden; overflow: hidden;
background: rgba(99, 118, 141, 0.12);
} }
.cart-item__media img { .cart-item__media img {
@@ -99,170 +53,399 @@
justify-content: center; justify-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
color: #63768d; color: var(--mid);
} }
.cart-item__body { .cart-item__info {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 18px;
min-width: 0; min-width: 0;
} }
.cart-item__category {
color: #63768d;
font-size: 15px;
}
.cart-item__title { .cart-item__title {
display: inline-block; display: inline-block;
margin-top: 8px; color: var(--dark);
font-family: 'BerlinType', serif; font-size: 25px;
font-size: 28px;
line-height: 1.1; line-height: 1.1;
} }
.cart-item__price { .cart-item__price {
margin-top: 10px; margin-top: 24px;
color: #554971; color: var(--dark);
font-size: 18px; font-size: 18px;
} }
.cart-item__controls { .cart-item__controls {
display: flex; display: flex;
align-items: flex-end; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 18px; gap: 24px;
margin-top: 28px;
max-width: 180px;
} }
.cart-quantity { .cart-quantity {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 10px; gap: 12px;
} }
.cart-quantity form { .cart-quantity form {
margin: 0; margin: 0;
} }
.cart-quantity__button { .cart-quantity__button,
width: 38px; .cart-item__remove {
height: 38px; display: inline-flex;
border: 1px solid #63768d; align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 0;
background: transparent; background: transparent;
color: #36213e; color: var(--dark);
font-size: 24px; font-size: 18px;
line-height: 1; line-height: 1;
cursor: pointer; cursor: pointer;
} }
.cart-quantity__value { .cart-quantity__value {
min-width: 24px; display: inline-flex;
text-align: center; align-items: center;
font-size: 18px; justify-content: center;
} width: 28px;
height: 28px;
.cart-item__aside { border-radius: 50%;
display: flex; background: var(--dark);
flex-direction: column; color: var(--white);
align-items: flex-end; font-size: 16px;
gap: 12px;
}
.cart-item__subtotal {
font-size: 17px;
color: #36213e;
} }
.cart-item__remove { .cart-item__remove {
width: auto;
min-width: 30px;
color: transparent;
position: relative;
}
.cart-item__remove::before {
content: "";
position: absolute;
left: 50%;
top: 8px;
transform: translateX(-50%);
width: 15px;
height: 18px;
border: 2px solid var(--dark);
border-top: 0;
border-radius: 0 0 3px 3px;
}
.cart-item__remove::after {
content: "";
position: absolute;
left: 50%;
top: 4px;
transform: translateX(-50%);
width: 19px;
height: 2px;
background: var(--dark);
box-shadow: 0 -3px 0 -1px var(--dark);
}
.cart-products-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
margin-top: 34px;
color: var(--mid);
font-size: 16px;
}
.checkout-summary p,
.checkout-services p,
.order-success__items p {
display: flex;
justify-content: space-between;
gap: 18px;
}
.cart-products-summary p {
display: block;
}
.cart-products-summary span,
.cart-products-summary strong {
display: block;
font-weight: 400;
}
.checkout-panel {
min-width: 0;
}
.checkout-form {
color: var(--dark);
}
.checkout-fieldset {
margin: 0 0 18px;
padding: 0;
border: 0; border: 0;
background: transparent; }
color: #a14b4b;
.checkout-fieldset legend {
margin-bottom: 12px;
font-size: 19px;
text-transform: uppercase;
}
.checkout-options {
display: grid;
gap: 14px 34px;
}
.checkout-options--two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.checkout-options--three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.checkout-radio,
.checkout-checkbox {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--dark);
font-size: 16px;
line-height: 1.25;
}
.checkout-radio input,
.checkout-checkbox input {
width: 13px;
height: 13px;
accent-color: var(--mid);
}
.checkout-grid {
display: grid;
grid-template-columns: minmax(0, 190px) minmax(0, 1fr);
gap: 14px;
}
.checkout-grid--contacts {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.checkout-input {
display: block;
min-width: 0;
}
.checkout-input--wide {
grid-column: 1 / -1;
}
.checkout-input span {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
.checkout-input input {
width: 100%;
height: 38px;
border: 1px solid rgba(85, 73, 113, 0.65);
background: #dedede;
padding: 0 13px;
color: var(--dark);
font: inherit;
}
.checkout-input input::placeholder {
color: rgba(85, 73, 113, 0.55);
}
.checkout-checkbox {
margin-top: 12px;
color: var(--mid);
}
.promo-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 178px;
margin-top: 10px;
}
.promo-row button {
border: 0;
background: var(--blue-gray);
color: var(--white);
font: inherit;
cursor: pointer; cursor: pointer;
text-decoration: underline;
} }
.cart-totals { .checkout-summary {
position: sticky; display: grid;
top: 120px; gap: 14px;
padding: 24px; width: min(320px, 100%);
border: 1px solid rgba(54, 33, 62, 0.12); margin-top: 24px;
background: rgba(99, 118, 141, 0.06); font-size: 16px;
} }
.cart-totals__label { .checkout-summary strong,
color: #554971; .checkout-services strong,
.order-success__items strong {
font-weight: 400;
}
.checkout-submit,
.order-success__link,
.cart-empty a {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 245px;
height: 38px;
margin-top: 18px;
border: 0;
background: var(--blue-gray);
color: var(--white);
font: inherit;
cursor: pointer;
}
.checkout-checkbox--terms {
display: flex;
margin-top: 12px;
}
.checkout-services {
display: grid;
gap: 12px;
width: min(260px, 100%);
margin-top: 18px;
font-size: 17px;
}
.checkout-services h3 {
font-family: 'Noah', sans-serif;
font-size: 19px;
font-weight: 400;
}
.field-error,
.checkout-errors {
margin-top: 8px;
color: #a14b4b;
font-size: 15px;
line-height: 1.35;
}
.checkout-errors {
margin-bottom: 18px;
}
.cart-empty,
.order-success {
max-width: 760px;
margin: 0 auto;
padding: 54px 0 72px;
}
.cart-empty h1,
.order-success h1 {
font-size: 44px;
font-weight: 400;
line-height: 1.08;
}
.cart-empty p,
.order-success__lead {
margin-top: 14px;
color: var(--mid);
font-size: 19px;
line-height: 1.45;
}
.order-success__eyebrow {
margin-bottom: 10px;
color: var(--blue-gray);
font-size: 16px; font-size: 16px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em;
} }
.cart-totals__value { .order-success__summary {
margin-top: 10px; display: grid;
font-family: 'BerlinType', serif; grid-template-columns: repeat(2, minmax(0, 1fr));
font-size: 36px; gap: 18px;
line-height: 1.1; margin-top: 28px;
padding: 24px;
background: rgba(99, 118, 141, 0.08);
} }
.cart-totals__meta { .order-success__summary span {
margin-top: 12px; display: block;
color: #554971; color: var(--mid);
font-size: 16px;
line-height: 1.4;
} }
.cart-empty { .order-success__summary strong {
padding: 32px; display: block;
border: 1px solid rgba(54, 33, 62, 0.12); margin-top: 6px;
color: #554971;
font-size: 18px; font-size: 18px;
line-height: 1.4;
} }
.cart-item__summary-line { .order-success__items {
display: flex; display: grid;
flex-wrap: wrap; gap: 12px;
gap: 8px; margin-top: 28px;
margin-top: 10px;
color: #554971;
font-size: 16px;
} }
@media (max-width: 900px) { @media (max-width: 980px) {
.cart-page { .cart-page {
padding-left: 24px; padding-left: 24px;
padding-right: 24px; padding-right: 24px;
} }
.cart-hero__row, .checkout-layout {
.cart-content {
grid-template-columns: 1fr; grid-template-columns: 1fr;
display: grid; gap: 44px;
}
.cart-summary-card,
.cart-totals {
min-width: 0;
position: static;
} }
} }
@media (max-width: 680px) { @media (max-width: 680px) {
.cart-section-title,
.cart-empty h1,
.order-success h1 {
font-size: 34px;
}
.cart-item { .cart-item {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 16px;
}
.cart-products-summary,
.checkout-options--two,
.checkout-options--three,
.checkout-grid,
.checkout-grid--contacts,
.promo-row,
.order-success__summary {
grid-template-columns: 1fr;
} }
.cart-item__controls { .promo-row button {
align-items: flex-start; height: 38px;
flex-direction: column;
} }
.cart-item__aside { .checkout-submit,
align-items: flex-start; .order-success__link,
} .cart-empty a {
width: 100%;
.cart-title { min-width: 0;
font-size: 34px;
} }
} }

View File

@@ -17,4 +17,46 @@ document.addEventListener('DOMContentLoaded', function () {
} }
}); });
}); });
const checkoutForm = document.querySelector('[data-checkout-form]');
if (!checkoutForm) {
return;
}
const productsTotal = Number(checkoutForm.dataset.productsTotal || '0');
const deliveryPrice = 500;
const floorLiftPrice = 45;
const totalNodes = document.querySelectorAll('[data-checkout-total-text]');
const deliveryNode = document.querySelector('[data-delivery-total-text]');
const floorLiftNode = document.querySelector('[data-floor-lift-total-text]');
const formatCurrency = function (value) {
return new Intl.NumberFormat('ru-RU', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value) + ' руб.';
};
const updateCheckoutTotals = function () {
const selectedDelivery = checkoutForm.querySelector('input[name="delivery_method"]:checked');
const hasDelivery = selectedDelivery && selectedDelivery.value === 'courier';
const deliveryTotal = hasDelivery ? deliveryPrice : 0;
const floorLiftTotal = hasDelivery ? floorLiftPrice : 0;
const total = productsTotal + deliveryTotal + floorLiftTotal;
totalNodes.forEach(function (node) {
node.textContent = formatCurrency(total);
});
if (deliveryNode) {
deliveryNode.textContent = formatCurrency(deliveryTotal);
}
if (floorLiftNode) {
floorLiftNode.textContent = formatCurrency(floorLiftTotal);
}
};
checkoutForm.querySelectorAll('input[name="delivery_method"]').forEach(function (input) {
input.addEventListener('change', updateCheckoutTotals);
});
updateCheckoutTotals();
}); });

View File

@@ -1,5 +1,5 @@
{% extends "_layouts/base.html" %} {% extends "_layouts/base.html" %}
{% load static %} {% load static l10n %}
{% block title %}Корзина{% endblock %} {% block title %}Корзина{% endblock %}
@@ -13,100 +13,211 @@
{% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2='/orders/cart/' text_2='Корзина' %} {% include '_fragments/breadcrumbs.html' with url_1='/' text_1='Главная' url_2='/orders/cart/' text_2='Корзина' %}
<main class="container cart-page"> <main class="container cart-page">
<section class="cart-hero"> {% if cart_items %}
<p class="cart-kicker">Покупки</p> <div class="checkout-layout">
<div class="cart-hero__row"> <section class="cart-products" aria-label="Товары в корзине">
<div> <h1 class="cart-section-title">ТОВАРЫ</h1>
<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="Содержимое корзины"> <div class="cart-items">
{% if cart_items %} {% for cart_item in cart_items %}
<div class="cart-items"> {% with item=cart_item.item %}
{% for cart_item in cart_items %} <article class="cart-item" data-auth-cart-item data-current-quantity="{{ cart_item.quantity }}">
{% with item=cart_item.item %} <a class="cart-item__media" href="{% url 'detail' item.category.slug item.slug %}">
<article class="cart-item" data-auth-cart-item data-current-quantity="{{ cart_item.quantity }}"> {% with primary_image=item.primary_image %}
<a class="cart-item__media" href="{% url 'detail' item.category.slug item.slug %}"> {% if primary_image %}
{% with primary_image=item.primary_image %} <img src="{{ primary_image.image.url }}" alt="{{ primary_image.alt|default:item.name }}">
{% if primary_image %} {% else %}
<img src="{{ primary_image.image.url }}" alt="{{ primary_image.alt|default:item.name }}"> <span class="cart-item__placeholder">Нет фото</span>
{% else %} {% endif %}
<div class="cart-item__placeholder">Нет фото</div> {% endwith %}
{% endif %} </a>
{% endwith %}
</a>
<div class="cart-item__body"> <div class="cart-item__info">
<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> <a class="cart-item__title" href="{% url 'detail' item.category.slug item.slug %}">{{ item.name }}</a>
<p class="cart-item__price">{{ item.price }} руб.</p> <p class="cart-item__price">{{ item.price }} руб.</p>
</div>
<div class="cart-item__controls"> <div class="cart-item__controls">
<div class="cart-quantity"> <div class="cart-quantity" aria-label="Количество товара">
<button <button
class="cart-quantity__button" class="cart-quantity__button"
type="button" type="button"
data-cart-decrement data-cart-decrement
data-remove-form-id="cart-remove-{{ cart_item.id }}" data-remove-form-id="cart-remove-{{ cart_item.id }}"
data-update-form-id="cart-decrement-{{ cart_item.id }}" data-update-form-id="cart-decrement-{{ cart_item.id }}"
data-current-quantity="{{ cart_item.quantity }}" data-current-quantity="{{ cart_item.quantity }}"
> aria-label="Уменьшить количество"
- >
</button> -
<span class="cart-quantity__value">{{ cart_item.quantity }}</span> </button>
<form id="cart-increment-{{ cart_item.id }}" method="post" action="{% url 'orders:update_quantity' %}"> <span class="cart-quantity__value">{{ cart_item.quantity }}</span>
{% csrf_token %} <form id="cart-increment-{{ cart_item.id }}" method="post" action="{% url 'orders:update_quantity' %}">
<input type="hidden" name="item_id" value="{{ item.id }}"> {% csrf_token %}
<input type="hidden" name="quantity" value="{{ cart_item.quantity|add:1 }}"> <input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="next" value="{% url 'orders:cart' %}"> <input type="hidden" name="quantity" value="{{ cart_item.quantity|add:1 }}">
<button class="cart-quantity__button" type="submit">+</button> <input type="hidden" name="next" value="{% url 'orders:cart' %}">
</form> <button class="cart-quantity__button" type="submit" aria-label="Увеличить количество">+</button>
<form id="cart-decrement-{{ cart_item.id }}" method="post" action="{% url 'orders:update_quantity' %}"> </form>
{% csrf_token %} <form id="cart-decrement-{{ cart_item.id }}" method="post" action="{% url 'orders:update_quantity' %}">
<input type="hidden" name="item_id" value="{{ item.id }}"> {% csrf_token %}
<input type="hidden" name="quantity" value="{{ cart_item.quantity|add:-1 }}"> <input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="next" value="{% url 'orders:cart' %}"> <input type="hidden" name="quantity" value="{{ cart_item.quantity|add:-1 }}">
</form> <input type="hidden" name="next" value="{% url 'orders:cart' %}">
</div> </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' %}"> <form id="cart-remove-{{ cart_item.id }}" method="post" action="{% url 'orders:remove' %}">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="item_id" value="{{ item.id }}"> <input type="hidden" name="item_id" value="{{ item.id }}">
<input type="hidden" name="next" value="{% url 'orders:cart' %}"> <input type="hidden" name="next" value="{% url 'orders:cart' %}">
<button class="cart-item__remove" type="submit">Удалить</button> <button class="cart-item__remove" type="submit" aria-label="Удалить товар">Удалить</button>
</form> </form>
</div> </div>
</div> </div>
</div> </article>
</article> {% endwith %}
{% endwith %} {% endfor %}
{% endfor %} </div>
</div>
<aside class="cart-totals"> <div class="cart-products-summary">
<p class="cart-totals__label">Итого</p> <p><span>Товары, {{ total_quantity }} шт.</span><strong>{{ products_total }}</strong></p>
<p class="cart-totals__value" data-cart-total-amount>{{ total_amount }}</p> <p><span>Скидка</span><strong>{{ discount_total }}</strong></p>
<p class="cart-totals__meta">Позиции можно изменить прямо на этой странице.</p> <p><span>Итого к оплате</span><strong data-checkout-total-text>{{ total_amount }}</strong></p>
</aside> </div>
{% else %} </section>
<p class="cart-empty">Корзина пока пуста.</p>
{% endif %} <section class="checkout-panel" aria-label="Оформление заказа">
</section> <h2 class="cart-section-title">Оформление заказа</h2>
<form
class="checkout-form"
method="post"
action="{% url 'orders:checkout' %}"
data-checkout-form
data-products-total="{{ products_total_value|unlocalize }}"
>
{% csrf_token %}
{% if checkout_form.non_field_errors %}
<div class="checkout-errors">
{% for error in checkout_form.non_field_errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<fieldset class="checkout-fieldset">
<legend>СПОСОБ ДОСТАВКИ</legend>
<div class="checkout-options checkout-options--two">
<label class="checkout-radio">
<input type="radio" name="delivery_method" value="courier" {% if checkout_form.delivery_method.value == "courier" or not checkout_form.is_bound %}checked{% endif %}>
<span>Курьерская доставка</span>
</label>
<label class="checkout-radio">
<input type="radio" name="delivery_method" value="pickup" {% if checkout_form.delivery_method.value == "pickup" %}checked{% endif %}>
<span>Самовывоз</span>
</label>
</div>
{% for error in checkout_form.delivery_method.errors %}<p class="field-error">{{ error }}</p>{% endfor %}
</fieldset>
<fieldset class="checkout-fieldset">
<legend>АДРЕС ДОСТАВКИ</legend>
<div class="checkout-grid">
<label class="checkout-input checkout-input--short">
<span>Регион*</span>
<input type="text" name="region" value="{{ checkout_form.region.value|default:'' }}" placeholder="Регион*" autocomplete="address-level1">
</label>
<label class="checkout-input checkout-input--wide">
<span>Улица, дом, квартира*</span>
<input type="text" name="address" value="{{ checkout_form.address.value|default:'' }}" placeholder="Улица, дом, квартира*" autocomplete="street-address">
</label>
</div>
{% for error in checkout_form.region.errors %}<p class="field-error">{{ error }}</p>{% endfor %}
{% for error in checkout_form.address.errors %}<p class="field-error">{{ error }}</p>{% endfor %}
</fieldset>
<fieldset class="checkout-fieldset">
<legend>СПОСОБ ОПЛАТЫ</legend>
<div class="checkout-options checkout-options--three">
<label class="checkout-radio">
<input type="radio" name="payment_method" value="card" {% if checkout_form.payment_method.value == "card" or not checkout_form.is_bound %}checked{% endif %}>
<span>Банковская карта</span>
</label>
<label class="checkout-radio">
<input type="radio" name="payment_method" value="credit" {% if checkout_form.payment_method.value == "credit" %}checked{% endif %}>
<span>Кредит</span>
</label>
<label class="checkout-radio">
<input type="radio" name="payment_method" value="installment" {% if checkout_form.payment_method.value == "installment" %}checked{% endif %}>
<span>Рассрочка</span>
</label>
</div>
{% for error in checkout_form.payment_method.errors %}<p class="field-error">{{ error }}</p>{% endfor %}
</fieldset>
<fieldset class="checkout-fieldset">
<legend>КОНТАКТЫ</legend>
<div class="checkout-grid checkout-grid--contacts">
<label class="checkout-input checkout-input--wide">
<span>ФИО*</span>
<input type="text" name="customer_name" value="{{ checkout_form.customer_name.value|default:'' }}" placeholder="ФИО*" autocomplete="name">
</label>
<label class="checkout-input">
<span>Телефон*</span>
<input type="tel" name="phone" value="{{ checkout_form.phone.value|default:'' }}" placeholder="Телефон*" autocomplete="tel">
</label>
<label class="checkout-input">
<span>E-mail*</span>
<input type="email" name="email" value="{{ checkout_form.email.value|default:'' }}" placeholder="E-mail*" autocomplete="email">
</label>
</div>
{% for error in checkout_form.customer_name.errors %}<p class="field-error">{{ error }}</p>{% endfor %}
{% for error in checkout_form.phone.errors %}<p class="field-error">{{ error }}</p>{% endfor %}
{% for error in checkout_form.email.errors %}<p class="field-error">{{ error }}</p>{% endfor %}
<label class="checkout-checkbox">
<input type="checkbox" name="recipient_matches_customer" value="on" {% if checkout_form.recipient_matches_customer.value or not checkout_form.is_bound %}checked{% endif %}>
<span>ФИО получателя совпадает с ФИО покупателя</span>
</label>
</fieldset>
<div class="promo-row">
<label class="checkout-input">
<span>Промокод</span>
<input type="text" name="promo_code" value="{{ checkout_form.promo_code.value|default:'' }}" placeholder="Промокод">
</label>
<button type="button">ПРИМЕНИТЬ</button>
</div>
<div class="checkout-summary">
<p><strong>Товары, {{ total_quantity }} шт.</strong><span>{{ products_total }}</span></p>
<p><strong>Скидка</strong><span>{{ discount_total }}</span></p>
<p><strong>Итого к оплате</strong><span data-checkout-total-text>{{ total_amount }}</span></p>
</div>
<button class="checkout-submit" type="submit">ОФОРМИТЬ ЗАКАЗ</button>
<label class="checkout-checkbox checkout-checkbox--terms">
<input type="checkbox" name="terms_accepted" value="on" {% if checkout_form.terms_accepted.value %}checked{% endif %}>
<span>Согласен с условиями продажи, оплаты и возврата</span>
</label>
{% for error in checkout_form.terms_accepted.errors %}<p class="field-error">{{ error }}</p>{% endfor %}
<div class="checkout-services">
<h3>Услуги</h3>
<p><span>Доставка</span><strong data-delivery-total-text>{{ delivery_total }}</strong></p>
<p><span>Подъём на этаж</span><strong data-floor-lift-total-text>{{ floor_lift_total }}</strong></p>
</div>
</form>
</section>
</div>
{% else %}
<section class="cart-empty">
<h1>Корзина пуста</h1>
<p>Добавьте товары из каталога, чтобы оформить заказ.</p>
<a href="/catalog/">Перейти в каталог</a>
</section>
{% endif %}
</main> </main>
{% include "_fragments/footer.html" %} {% include "_fragments/footer.html" %}

View File

@@ -0,0 +1,38 @@
<div style="max-width:640px;margin:0 auto;background:#ffffff;border:1px solid #d6dde6;padding:32px;font-family:Arial,sans-serif;color:#36213e;">
<h1 style="margin:0 0 16px;font-size:28px;">Заказ №{{ order.id }} оплачен</h1>
<p>Здравствуйте, {{ order.customer_name }}!</p>
<p>Ваш заказ успешно оформлен и оплачен.</p>
<table style="width:100%;border-collapse:collapse;margin:24px 0;">
<thead>
<tr>
<th style="text-align:left;border-bottom:1px solid #d6dde6;padding:8px;">Товар</th>
<th style="text-align:right;border-bottom:1px solid #d6dde6;padding:8px;">Кол-во</th>
<th style="text-align:right;border-bottom:1px solid #d6dde6;padding:8px;">Сумма</th>
</tr>
</thead>
<tbody>
{% for item in order.items.all %}
<tr>
<td style="padding:8px;border-bottom:1px solid #eef2f6;">{{ item.item_name }}</td>
<td style="padding:8px;border-bottom:1px solid #eef2f6;text-align:right;">{{ item.quantity }}</td>
<td style="padding:8px;border-bottom:1px solid #eef2f6;text-align:right;">{{ item.line_total }} руб.</td>
</tr>
{% endfor %}
</tbody>
</table>
<p><strong>Товары:</strong> {{ order.products_total }} руб.</p>
<p><strong>Скидка:</strong> {{ order.discount_total }} руб.</p>
<p><strong>Доставка:</strong> {{ order.delivery_total }} руб.</p>
<p><strong>Подъём на этаж:</strong> {{ order.floor_lift_total }} руб.</p>
<p style="font-size:18px;"><strong>Итого к оплате:</strong> {{ order.total }} руб.</p>
<p><strong>Доставка:</strong> {{ order.get_delivery_method_display }}</p>
{% if order.region or order.address %}
<p><strong>Адрес:</strong> {{ order.region }}{% if order.address %}, {{ order.address }}{% endif %}</p>
{% endif %}
<p><strong>Оплата:</strong> {{ order.get_payment_method_display }}</p>
<p style="margin-top:24px;color:#554971;">Спасибо за покупку в {{ site_name }}.</p>
</div>

View File

@@ -0,0 +1,25 @@
Здравствуйте, {{ order.customer_name }}!
Ваш заказ №{{ order.id }} успешно оплачен.
Состав заказа:
{% for item in order.items.all %}
- {{ item.item_name }} — {{ item.quantity }} шт. × {{ item.item_price }} руб. = {{ item.line_total }} руб.
{% endfor %}
Товары: {{ order.products_total }} руб.
Скидка: {{ order.discount_total }} руб.
Доставка: {{ order.delivery_total }} руб.
Подъём на этаж: {{ order.floor_lift_total }} руб.
Итого к оплате: {{ order.total }} руб.
Доставка: {{ order.get_delivery_method_display }}
{% if order.region or order.address %}Адрес: {{ order.region }}{% if order.address %}, {{ order.address }}{% endif %}
{% endif %}Оплата: {{ order.get_payment_method_display }}
Контакты:
ФИО: {{ order.customer_name }}
Телефон: {{ order.phone }}
E-mail: {{ order.email }}
Спасибо за покупку в {{ site_name }}.

View File

@@ -0,0 +1 @@
{{ site_name }}: заказ №{{ order.id }} оплачен

View File

@@ -0,0 +1,53 @@
{% 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='Корзина' url_3=request.path text_3='Заказ оплачен' %}
<main class="container cart-page">
<section class="order-success">
<p class="order-success__eyebrow">Оплата прошла успешно</p>
<h1>Заказ №{{ order.id }} оформлен</h1>
<p class="order-success__lead">
Мы отправили письмо с деталями заказа на {{ order.email }}. Менеджер свяжется с вами при необходимости.
</p>
<div class="order-success__summary">
<div>
<span>Получатель</span>
<strong>{{ order.customer_name }}</strong>
</div>
<div>
<span>Способ доставки</span>
<strong>{{ order.get_delivery_method_display }}</strong>
</div>
<div>
<span>Способ оплаты</span>
<strong>{{ order.get_payment_method_display }}</strong>
</div>
<div>
<span>Итого</span>
<strong>{{ order.total }} руб.</strong>
</div>
</div>
<div class="order-success__items">
{% for item in order.items.all %}
<p><span>{{ item.item_name }} × {{ item.quantity }}</span><strong>{{ item.line_total }} руб.</strong></p>
{% endfor %}
</div>
<a class="order-success__link" href="/catalog/">Вернуться в каталог</a>
</section>
</main>
{% include "_fragments/footer.html" %}
{% endblock %}