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 orders.models import Cart, CartItem
from orders.models import Cart, CartItem, Order, OrderItem
class CartItemInline(admin.TabularInline):
@@ -20,3 +20,44 @@ class CartItemAdmin(admin.ModelAdmin):
list_display = ("item", "cart", "quantity", "updated_at")
list_select_related = ("cart", "item", "cart__user")
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
def subtotal(self):
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 typing import Any
from django.conf import settings
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 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:
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:
cart, _ = Cart.objects.get_or_create(user=user)
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]:
if quantity < 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.save(update_fields=["quantity", "updated_at"])
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.core import mail
from django.db import IntegrityError
from django.test import TestCase, override_settings
from django.urls import reverse
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()
@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):
def setUp(self):
self.user = User.objects.create_user(
@@ -40,6 +45,22 @@ class CartFlowTests(TestCase):
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):
cart = Cart.objects.create(user=self.user)
CartItem.objects.create(cart=cart, item=self.item, quantity=1)
@@ -99,8 +120,10 @@ class CartFlowTests(TestCase):
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Кухонная мойка")
self.assertContains(response, "Оформление заказа")
self.assertContains(response, "1600,00 руб.")
self.assertContains(response, "3200,00 руб.")
self.assertContains(response, "3745,00 руб.")
self.assertContains(response, "/media/items/cart-item.jpg")
def test_cart_page_redirects_guest_to_login(self):
@@ -108,3 +131,101 @@ class CartFlowTests(TestCase):
self.assertEqual(response.status_code, 302)
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/remove/", views.remove, name="remove"),
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.auth.decorators import login_required
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 items.models import Item
from orders.models import CartItem
from orders.forms import CheckoutForm
from orders.models import CartItem, Order
from orders.services import (
add_item_to_cart,
calculate_checkout_totals,
create_order_from_cart,
format_currency,
get_user_cart,
get_user_cart_items,
remove_item_from_cart,
send_order_confirmation_email,
update_cart_item_quantity,
)
@@ -33,29 +35,78 @@ def resolve_next_url(request: HttpRequest, fallback: str) -> str:
@login_required
def cart(request: HttpRequest) -> HttpResponse:
cart_items: list[CartItem] = []
total_quantity = 0
total_amount = "0,00 руб."
cart_obj = get_user_cart(request.user)
if cart_obj:
cart_items = list(
cart_obj.items.select_related("item__category", "item__subcategory").prefetch_related("item__images")
)
cart_items = get_user_cart_items(request.user)
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)
totals = calculate_checkout_totals(cart_items, Order.DeliveryMethod.COURIER)
form = CheckoutForm(initial={
"customer_name": request.user.get_full_name() or request.user.get_username(),
"email": request.user.email,
})
return render(request, "orders/cart.html", {
"cart_items": cart_items,
"checkout_form": form,
"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
@require_POST
def add(request: HttpRequest) -> HttpResponse:

View File

@@ -1,89 +1,43 @@
.cart-page {
margin-bottom: 88px;
padding-top: 34px;
}
.cart-hero {
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 {
.checkout-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 28px;
grid-template-columns: minmax(0, 1fr) minmax(360px, 490px);
gap: 68px;
align-items: start;
}
.cart-content--guest {
min-height: 280px;
.cart-section-title {
margin: 0 0 28px;
color: var(--dark);
font-family: 'BerlinType', serif;
font-size: 38px;
font-weight: 400;
line-height: 1.05;
}
.cart-items {
display: flex;
flex-direction: column;
gap: 18px;
gap: 28px;
}
.cart-item {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 22px;
padding: 22px;
border: 1px solid rgba(54, 33, 62, 0.12);
background: #ffffff;
grid-template-columns: 230px minmax(0, 1fr);
gap: 34px;
align-items: start;
}
.cart-item__media {
display: block;
aspect-ratio: 4 / 3;
background: rgba(99, 118, 141, 0.08);
width: 100%;
aspect-ratio: 1.55 / 1;
overflow: hidden;
background: rgba(99, 118, 141, 0.12);
}
.cart-item__media img {
@@ -99,170 +53,399 @@
justify-content: center;
width: 100%;
height: 100%;
color: #63768d;
color: var(--mid);
}
.cart-item__body {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 18px;
.cart-item__info {
min-width: 0;
}
.cart-item__category {
color: #63768d;
font-size: 15px;
}
.cart-item__title {
display: inline-block;
margin-top: 8px;
font-family: 'BerlinType', serif;
font-size: 28px;
color: var(--dark);
font-size: 25px;
line-height: 1.1;
}
.cart-item__price {
margin-top: 10px;
color: #554971;
margin-top: 24px;
color: var(--dark);
font-size: 18px;
}
.cart-item__controls {
display: flex;
align-items: flex-end;
align-items: center;
justify-content: space-between;
gap: 18px;
gap: 24px;
margin-top: 28px;
max-width: 180px;
}
.cart-quantity {
display: inline-flex;
align-items: center;
gap: 10px;
gap: 12px;
}
.cart-quantity form {
margin: 0;
}
.cart-quantity__button {
width: 38px;
height: 38px;
border: 1px solid #63768d;
.cart-quantity__button,
.cart-item__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 0;
background: transparent;
color: #36213e;
font-size: 24px;
color: var(--dark);
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.cart-quantity__value {
min-width: 24px;
text-align: center;
font-size: 18px;
}
.cart-item__aside {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
}
.cart-item__subtotal {
font-size: 17px;
color: #36213e;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--dark);
color: var(--white);
font-size: 16px;
}
.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;
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;
text-decoration: underline;
}
.cart-totals {
position: sticky;
top: 120px;
padding: 24px;
border: 1px solid rgba(54, 33, 62, 0.12);
background: rgba(99, 118, 141, 0.06);
.checkout-summary {
display: grid;
gap: 14px;
width: min(320px, 100%);
margin-top: 24px;
font-size: 16px;
}
.cart-totals__label {
color: #554971;
.checkout-summary strong,
.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;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.cart-totals__value {
margin-top: 10px;
font-family: 'BerlinType', serif;
font-size: 36px;
line-height: 1.1;
.order-success__summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
margin-top: 28px;
padding: 24px;
background: rgba(99, 118, 141, 0.08);
}
.cart-totals__meta {
margin-top: 12px;
color: #554971;
font-size: 16px;
line-height: 1.4;
.order-success__summary span {
display: block;
color: var(--mid);
}
.cart-empty {
padding: 32px;
border: 1px solid rgba(54, 33, 62, 0.12);
color: #554971;
.order-success__summary strong {
display: block;
margin-top: 6px;
font-size: 18px;
line-height: 1.4;
}
.cart-item__summary-line {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
color: #554971;
font-size: 16px;
.order-success__items {
display: grid;
gap: 12px;
margin-top: 28px;
}
@media (max-width: 900px) {
@media (max-width: 980px) {
.cart-page {
padding-left: 24px;
padding-right: 24px;
}
.cart-hero__row,
.cart-content {
.checkout-layout {
grid-template-columns: 1fr;
display: grid;
}
.cart-summary-card,
.cart-totals {
min-width: 0;
position: static;
gap: 44px;
}
}
@media (max-width: 680px) {
.cart-section-title,
.cart-empty h1,
.order-success h1 {
font-size: 34px;
}
.cart-item {
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 {
align-items: flex-start;
flex-direction: column;
.promo-row button {
height: 38px;
}
.cart-item__aside {
align-items: flex-start;
}
.cart-title {
font-size: 34px;
.checkout-submit,
.order-success__link,
.cart-empty a {
width: 100%;
min-width: 0;
}
}

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" %}
{% load static %}
{% load static l10n %}
{% block title %}Корзина{% endblock %}
@@ -13,24 +13,11 @@
{% 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="checkout-layout">
<section class="cart-products" aria-label="Товары в корзине">
<h1 class="cart-section-title">ТОВАРЫ</h1>
<div class="cart-items">
{% for cart_item in cart_items %}
{% with item=cart_item.item %}
@@ -40,22 +27,17 @@
{% if primary_image %}
<img src="{{ primary_image.image.url }}" alt="{{ primary_image.alt|default:item.name }}">
{% else %}
<div class="cart-item__placeholder">Нет фото</div>
<span class="cart-item__placeholder">Нет фото</span>
{% 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>
<div class="cart-item__info">
<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">
<div class="cart-quantity" aria-label="Количество товара">
<button
class="cart-quantity__button"
type="button"
@@ -63,6 +45,7 @@
data-remove-form-id="cart-remove-{{ cart_item.id }}"
data-update-form-id="cart-decrement-{{ cart_item.id }}"
data-current-quantity="{{ cart_item.quantity }}"
aria-label="Уменьшить количество"
>
-
</button>
@@ -72,7 +55,7 @@
<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>
<button class="cart-quantity__button" type="submit" aria-label="Увеличить количество">+</button>
</form>
<form id="cart-decrement-{{ cart_item.id }}" method="post" action="{% url 'orders:update_quantity' %}">
{% csrf_token %}
@@ -82,31 +65,159 @@
</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>
<button class="cart-item__remove" type="submit" aria-label="Удалить товар">Удалить</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 %}
<div class="cart-products-summary">
<p><span>Товары, {{ total_quantity }} шт.</span><strong>{{ products_total }}</strong></p>
<p><span>Скидка</span><strong>{{ discount_total }}</strong></p>
<p><span>Итого к оплате</span><strong data-checkout-total-text>{{ total_amount }}</strong></p>
</div>
</section>
<section class="checkout-panel" aria-label="Оформление заказа">
<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>
{% 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 %}