feat: implement order management and checkout functionality
All checks were successful
Build and Push Docker Image / build (push) Successful in 50s
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:
23
.env
Normal file
23
.env
Normal 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
|
||||||
@@ -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
56
orders/forms.py
Normal 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
|
||||||
63
orders/migrations/0002_order_orderitem.py
Normal file
63
orders/migrations/0002_order_orderitem.py
Normal 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',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
125
orders/tests.py
125
orders/tests.py
@@ -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)
|
||||||
|
|||||||
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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_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")
|
|
||||||
)
|
|
||||||
total_quantity = sum(cart_item.quantity for cart_item in cart_items)
|
total_quantity = sum(cart_item.quantity for cart_item in cart_items)
|
||||||
total_amount_value = sum(
|
totals = calculate_checkout_totals(cart_items, Order.DeliveryMethod.COURIER)
|
||||||
(cart_item.item.price * cart_item.quantity for cart_item in cart_items),
|
form = CheckoutForm(initial={
|
||||||
start=Decimal("0.00"),
|
"customer_name": request.user.get_full_name() or request.user.get_username(),
|
||||||
)
|
"email": request.user.email,
|
||||||
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:
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "_layouts/base.html" %}
|
{% extends "_layouts/base.html" %}
|
||||||
{% load static %}
|
{% load static l10n %}
|
||||||
|
|
||||||
{% block title %}Корзина{% endblock %}
|
{% block title %}Корзина{% endblock %}
|
||||||
|
|
||||||
@@ -13,24 +13,11 @@
|
|||||||
{% 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">
|
|
||||||
<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 %}
|
{% if cart_items %}
|
||||||
|
<div class="checkout-layout">
|
||||||
|
<section class="cart-products" aria-label="Товары в корзине">
|
||||||
|
<h1 class="cart-section-title">ТОВАРЫ</h1>
|
||||||
|
|
||||||
<div class="cart-items">
|
<div class="cart-items">
|
||||||
{% for cart_item in cart_items %}
|
{% for cart_item in cart_items %}
|
||||||
{% with item=cart_item.item %}
|
{% with item=cart_item.item %}
|
||||||
@@ -40,22 +27,17 @@
|
|||||||
{% if primary_image %}
|
{% if primary_image %}
|
||||||
<img src="{{ primary_image.image.url }}" alt="{{ primary_image.alt|default:item.name }}">
|
<img src="{{ primary_image.image.url }}" alt="{{ primary_image.alt|default:item.name }}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="cart-item__placeholder">Нет фото</div>
|
<span class="cart-item__placeholder">Нет фото</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</a>
|
</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"
|
||||||
@@ -63,6 +45,7 @@
|
|||||||
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>
|
</button>
|
||||||
@@ -72,7 +55,7 @@
|
|||||||
<input type="hidden" name="item_id" value="{{ item.id }}">
|
<input type="hidden" name="item_id" value="{{ item.id }}">
|
||||||
<input type="hidden" name="quantity" value="{{ cart_item.quantity|add:1 }}">
|
<input type="hidden" name="quantity" value="{{ cart_item.quantity|add:1 }}">
|
||||||
<input type="hidden" name="next" value="{% url 'orders:cart' %}">
|
<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>
|
||||||
<form id="cart-decrement-{{ cart_item.id }}" method="post" action="{% url 'orders:update_quantity' %}">
|
<form id="cart-decrement-{{ cart_item.id }}" method="post" action="{% url 'orders:update_quantity' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -82,31 +65,159 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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 %}
|
|
||||||
<p class="cart-empty">Корзина пока пуста.</p>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
</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>
|
</main>
|
||||||
|
|
||||||
{% include "_fragments/footer.html" %}
|
{% include "_fragments/footer.html" %}
|
||||||
|
|||||||
38
templates/orders/emails/order_confirmation_email.html
Normal file
38
templates/orders/emails/order_confirmation_email.html
Normal 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>
|
||||||
25
templates/orders/emails/order_confirmation_email.txt
Normal file
25
templates/orders/emails/order_confirmation_email.txt
Normal 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 }}.
|
||||||
1
templates/orders/emails/order_confirmation_subject.txt
Normal file
1
templates/orders/emails/order_confirmation_subject.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{{ site_name }}: заказ №{{ order.id }} оплачен
|
||||||
53
templates/orders/success.html
Normal file
53
templates/orders/success.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user