From 7a680851b78eb5bf9da4d7fffd2f4df6c016c908 Mon Sep 17 00:00:00 2001 From: Celia Date: Thu, 27 Nov 2025 12:29:47 +0100 Subject: [PATCH 1/4] Correcciones vaias Co-authored-by: pakillodecm Co-authored-by: cmurillog06 --- essenza/cart/models.py | 18 +- essenza/cart/tests.py | 4 +- essenza/cart/views.py | 20 +- essenza/order/models.py | 19 +- essenza/order/views.py | 405 ++++++++++-------- essenza/templates/cart/cart_detail.html | 380 +++++++++++++--- essenza/templates/info/sales_history.html | 2 +- essenza/templates/order/order_detail.html | 2 +- essenza/templates/order/order_history.html | 2 +- essenza/templates/order/order_list_admin.html | 2 +- essenza/templates/order/tracking.html | 2 +- essenza/templates/product/dashboard.html | 16 + 12 files changed, 614 insertions(+), 258 deletions(-) diff --git a/essenza/cart/models.py b/essenza/cart/models.py index d40562d..d9c7ba1 100644 --- a/essenza/cart/models.py +++ b/essenza/cart/models.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django.db import models @@ -7,11 +9,19 @@ class Cart(models.Model): ) @property - def total_price(self): - total = 0 + def shipping(self): + return Decimal(4.99 if self.subtotal < 100 else 0) + + @property + def subtotal(self): + subtotal = 0 for product in self.cart_products.all(): - total += product.subtotal - return total + subtotal += product.subtotal + return Decimal(subtotal) + + @property + def total(self): + return self.subtotal + self.shipping def __str__(self): return f"Cart {self.id} by {self.user.email}" diff --git a/essenza/cart/tests.py b/essenza/cart/tests.py index ecff2dd..01cb9a9 100644 --- a/essenza/cart/tests.py +++ b/essenza/cart/tests.py @@ -65,7 +65,7 @@ def test_cart_detail_authenticated_with_items(self): self.assertEqual(response.status_code, 200) # Verifica que lee de la DB self.assertEqual(len(response.context["cart_products"]), 1) - self.assertEqual(response.context["total_price"], 20.00) # 2 * 10.00 + self.assertEqual(response.context["subtotal"], 20.00) # 2 * 10.00 def test_cart_detail_anonymous_session(self): """Usuario anónimo con datos en sesión.""" @@ -83,7 +83,7 @@ def test_cart_detail_anonymous_session(self): self.assertEqual(response.status_code, 200) # Tu vista pasa 'cart_products' también para anónimos (lo vi en tu código) self.assertEqual(len(response.context["cart_products"]), 1) - self.assertEqual(response.context["total_price"], 30.00) # 3 * 10.00 + self.assertEqual(response.context["subtotal"], 30.00) # 3 * 10.00 # --------------------------------------------------------- # BLOQUE 2: AÑADIR AL CARRITO (POST) diff --git a/essenza/cart/views.py b/essenza/cart/views.py index 2cbc086..036d519 100644 --- a/essenza/cart/views.py +++ b/essenza/cart/views.py @@ -1,3 +1,5 @@ +from decimal import Decimal + from django.shortcuts import get_object_or_404, redirect, render from django.views import View from product.models import Product @@ -15,7 +17,7 @@ class CartDetailView(View): template_name = "cart/cart_detail.html" def get(self, request): - context = {"cart_products": [], "total_price": 0} + context = {"cart_products": [], "shipping": 0, "subtotal": 0, "total": 0} # Si esta logueado if request.user.is_authenticated: @@ -24,7 +26,9 @@ def get(self, request): cart = get_object_or_404(Cart, user=request.user) # Cogemos los datos del carrito desde la base de datos context["cart_products"] = cart.cart_products.all() - context["total_price"] = cart.total_price + context["shipping"] = cart.shipping + context["subtotal"] = cart.subtotal + context["total"] = cart.total context["cart"] = cart except Exception: pass @@ -33,7 +37,7 @@ def get(self, request): else: cart_session = request.session.get("cart_session", {}) cart_products = [] - total_price = 0 + subtotal = 0 if cart_session: # Obtenemos los productos @@ -43,21 +47,23 @@ def get(self, request): # Construimos los items del carrito for product in products: quantity = cart_session[str(product.pk)]["quantity"] - subtotal = quantity * product.price + product_subtotal = quantity * product.price # Añadimos al listado de items del carrito la info necesaria cart_products.append( { "product": product, "quantity": quantity, - "subtotal": subtotal, + "subtotal": product_subtotal, "pk": product.pk, } ) - total_price += subtotal + subtotal += product_subtotal context["cart_products"] = cart_products - context["total_price"] = total_price + context["subtotal"] = subtotal + context["shipping"] = Decimal(4.99 if subtotal < 100 else 0) + context["total"] = subtotal + context["shipping"] return render(request, self.template_name, context) diff --git a/essenza/order/models.py b/essenza/order/models.py index 44dc09f..8e62d10 100644 --- a/essenza/order/models.py +++ b/essenza/order/models.py @@ -1,5 +1,6 @@ import random import string +from decimal import Decimal from django.contrib.auth import get_user_model from django.db import models @@ -25,20 +26,28 @@ class Order(models.Model): address = models.CharField(max_length=255) placed_at = models.DateTimeField(default=timezone.now) status = models.CharField(choices=Status.choices, default=Status.EN_PREPARACION) - tracking_code = models.CharField( max_length=8, unique=True, editable=False, # No se puede editar manualmente verbose_name="Localizador", ) + is_paid = models.BooleanField(default=False) + + @property + def shipping(self): + return Decimal(4.99 if self.subtotal < 100 else 0) @property - def total_price(self): - total = 0 + def subtotal(self): + subtotal = 0 for product in self.order_products.all(): - total += product.subtotal - return total + subtotal += product.subtotal + return Decimal(subtotal) + + @property + def total(self): + return self.subtotal + self.shipping def save(self, *args, **kwargs): """ diff --git a/essenza/order/views.py b/essenza/order/views.py index 59f6e35..4b1a433 100644 --- a/essenza/order/views.py +++ b/essenza/order/views.py @@ -4,7 +4,6 @@ from django.contrib import messages from django.contrib.auth import get_user_model from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin -from django.core.exceptions import PermissionDenied from django.core.mail import send_mail from django.db import transaction # Para la integridad de datos from django.db.models import ( @@ -178,213 +177,271 @@ def post(self, request, tracking_code): return redirect("order_tracking", tracking_code=order.tracking_code) -def create_checkout(request): - """ - Crea la sesión de pago en Stripe y configura la recolección de dirección. - Restringido: Los administradores NO pueden acceder aquí. - """ - if request.user.is_authenticated and getattr(request.user, "role", None) == "admin": - raise PermissionDenied("Los administradores no pueden realizar compras.") +# essenza/order/views.py - domain_url = settings.DOMAIN_URL - cart_items_temp = [] +# ... (tus importaciones se mantienen igual) ... - if request.user.is_authenticated: - cart = get_object_or_404(Cart, user=request.user) - for item in cart.cart_products.all(): - cart_items_temp.append( - { - "product": item.product, - "quantity": item.quantity, - "price": item.product.price, - } - ) - else: - cart_session = request.session.get("cart_session", {}) - if not cart_session: - return redirect("cart_detail") - product_pks = [int(pk) for pk in cart_session.keys()] - products = Product.objects.filter(pk__in=product_pks) - - for product in products: - qty = cart_session[str(product.pk)]["quantity"] - cart_items_temp.append( - {"product": product, "quantity": qty, "price": product.price} - ) - - line_items_stripe = [] - for item in cart_items_temp: - amount_in_cents = int(item["price"] * 100) - line_items_stripe.append( - { - "price_data": { - "currency": "eur", - "unit_amount": amount_in_cents, - "product_data": { - "name": item["product"].name, - "description": item["product"].description[:100] - if item["product"].description - else "Producto Essenza", - }, - }, - "quantity": item["quantity"], - } - ) +def _process_order(request, user, email, address, is_paid): + """ + Función auxiliar interna para procesar el pedido. + Se usa tanto para el retorno de Stripe como para Contrarreembolso. + """ + items_to_process = [] + cart_to_delete = None + + # 1. Obtener items (Lógica unificada para Auth/Anon) + if user: # Usuario autenticado + cart = Cart.objects.filter(user=user).first() + if cart: + cart_to_delete = cart + for cart_item in cart.cart_products.select_related("product").all(): + items_to_process.append( + { + "product": cart_item.product, + "quantity": cart_item.quantity, + } + ) + else: # Usuario anónimo (Sesión) + cart_session = request.session.get("cart_session", {}) + if cart_session: + product_pks = [int(pk) for pk in cart_session.keys()] + products = Product.objects.filter(pk__in=product_pks) + for product in products: + qty = cart_session[str(product.pk)]["quantity"] + items_to_process.append({"product": product, "quantity": qty}) + + if not items_to_process: + return None # Carrito vacío o error + + # 2. Buscar usuario registrado para asociar (si existe por email) + User = get_user_model() + user_for_order = user if user else User.objects.filter(email=email).first() + + # 3. Crear el Pedido + new_order = Order.objects.create( + user=user_for_order, + email=email, + address=address, + status=Status.EN_PREPARACION, + is_paid=is_paid, # <--- AQUÍ USAMOS EL VALOR QUE PASAMOS + ) + + # 4. Crear OrderProducts y actualizar Stock + for item_data in items_to_process: + product = item_data["product"] + qty = item_data["quantity"] + + OrderProduct.objects.create(order=new_order, product=product, quantity=qty) + # Actualizamos stock de forma segura + Product.objects.filter(pk=product.pk).update(stock=F("stock") - qty) + + # 5. Borrar el carrito + if cart_to_delete: + cart_to_delete.delete() + else: + request.session["cart_session"] = {} + request.session.modified = True + # 6. Enviar Email (Lógica común) try: - customer_email = request.user.email if request.user.is_authenticated else None - - checkout_session = stripe.checkout.Session.create( - payment_method_types=["card"], - line_items=line_items_stripe, - mode="payment", - shipping_address_collection={ - "allowed_countries": ["ES"], - }, - customer_email=customer_email, - success_url=domain_url + "/order/success/?session_id={CHECKOUT_SESSION_ID}", - cancel_url=domain_url + "/order/cancelled/", + tracking_url = request.build_absolute_uri( + reverse("order_tracking", args=[new_order.tracking_code]) + ) + payment_msg = ( + "Pagado con Tarjeta" if is_paid else "Pendiente de pago (Contrarreembolso)" ) - return redirect(checkout_session.url, code=303) + subject = f"Confirmación de Pedido #{new_order.tracking_code} - Essenza" + message = f""" + Hola! + Gracias por tu compra en Essenza. + + Detalles del pedido: + Localizador: {new_order.tracking_code} + Total: {new_order.total:.2f} € + Estado del pago: {payment_msg} + Dirección: {new_order.address} + + Sigue tu pedido aquí: {tracking_url} + """ + send_mail( + subject, + message, + settings.DEFAULT_FROM_EMAIL, + [new_order.email], + fail_silently=True, + ) except Exception as e: - return HttpResponse(f"Error al conectar con Stripe: {e}") + print(f"Error enviando email: {e}") + return new_order -def successful_payment(request): + +def create_checkout(request): """ - Verifica el pago, crea el pedido y ACTUALIZA EL STOCK. - Usa una transacción atómica para asegurar que todo se guarda o nada. + Maneja el inicio del proceso de pago. """ - session_id = request.GET.get("session_id") + if request.method != "POST": + return redirect("cart_detail") + + payment_method = request.POST.get("payment_method") # 'stripe' o 'cod' + + # --- OPCIÓN A: CONTRARREMBOLSO (COD) --- + if payment_method == "cod": + # 1. Capturamos los datos DEL FORMULARIO HTML + name = request.POST.get("shipping_name") + email_input = request.POST.get( + "shipping_email" + ) # El input se llama shipping_email en tu HTML + address = request.POST.get("shipping_address") + city = request.POST.get("shipping_city") + zip_code = request.POST.get("shipping_zip") + + # 2. Validación básica (Brutalmente honesta: si falta algo, detenemos todo) + if not (name and email_input and address and city and zip_code): + return HttpResponse( + "Error: Faltan datos de envío obligatorios.", status=400 + ) - if not session_id: - return HttpResponse("Error: No se ha recibido confirmación de pago.") + # 3. Construimos la dirección completa en un solo string + # Formato: "Nombre | Dirección, Ciudad (CP)" + full_address = f"{name} | {address}, {city} ({zip_code})" - try: - session = stripe.checkout.Session.retrieve(session_id) - customer_details = session.customer_details - stripe_email = customer_details.email + # 4. Determinamos el usuario y email + user = request.user if request.user.is_authenticated else None - address_data = customer_details.address - shipping_address = f"{address_data.line1}, {address_data.city}, {address_data.postal_code}, {address_data.country}" - if address_data.line2: - shipping_address += f", {address_data.line2}" + # Prioridad: Si el usuario escribió un email en el form, usamos ese. + # Si no (caso raro si usaste readonly), usamos el del user logueado. + final_email = email_input if email_input else (user.email if user else None) - if session.payment_status == "paid": - # --- INICIO DE TRANSACCIÓN --- - # Esto asegura que si falla la creación de productos, no se crea el pedido vacío - with transaction.atomic(): - items_to_process = [] - cart_to_delete = None - - # Si esta logueado - if request.user.is_authenticated: - cart = Cart.objects.filter(user=request.user).first() - if cart: - cart_to_delete = cart - for cart_item in cart.cart_products.select_related( - "product" - ).all(): - items_to_process.append( - { - "product": cart_item.product, - "quantity": cart_item.quantity, - } - ) - # Si no esta logueado, usamos la sesion - else: - cart_session = request.session.get("cart_session", {}) - if cart_session: - product_pks = [int(pk) for pk in cart_session.keys()] - products = Product.objects.filter(pk__in=product_pks) - for product in products: - qty = cart_session[str(product.pk)]["quantity"] - items_to_process.append( - {"product": product, "quantity": qty} - ) - - if not items_to_process: - # Si no hay productos, no creamos el pedido. - return HttpResponse( - "Error: No se encontraron productos en el carrito para procesar el pedido." - ) + if not final_email: + return HttpResponse("Error: Se requiere un email válido.", status=400) - # 3. Buscar usuario por email - User = get_user_model() - user_for_order = User.objects.filter(email=stripe_email).first() + # 5. Procesamos el pedido + with transaction.atomic(): + order = _process_order( + request, + user=user, + email=final_email, # Usamos el email del formulario + address=full_address, # Usamos la dirección concatenada + is_paid=False, # COD = No pagado aún + ) - # 4. Crear el Pedido - new_order = Order.objects.create( - user=user_for_order, # Si no existe el usuario, se pone None - status=Status.EN_PREPARACION, - address=shipping_address, - email=stripe_email, + if order: + # IMPORTANTE: Redirigimos pasando el ID del pedido para mostrar éxito + # Asegúrate de que tu URL espera un parámetro, o usa la sesión + return render(request, "order/success.html", {"order": order}) + else: + return redirect("cart_detail") + + # --- OPCIÓN B: STRIPE --- + elif payment_method == "stripe": + # --- Lógica de Items para Stripe --- + cart_items_temp = [] + if request.user.is_authenticated: + cart = Cart.objects.filter(user=request.user).first() + if not cart: + return redirect("cart_detail") # Seguridad extra + for item in cart.cart_products.all(): + cart_items_temp.append( + { + "product": item.product, + "quantity": item.quantity, + "price": item.product.price, + } + ) + else: + cart_session = request.session.get("cart_session", {}) + if not cart_session: + return redirect("cart_detail") + products = Product.objects.filter( + pk__in=[int(k) for k in cart_session.keys()] + ) + for p in products: + cart_items_temp.append( + { + "product": p, + "quantity": cart_session[str(p.pk)]["quantity"], + "price": p.price, + } ) - # 5. Crear OrderProducts y actualizamos el Stock - for item_data in items_to_process: - product = item_data["product"] - qty = item_data["quantity"] + line_items_stripe = [] + for item in cart_items_temp: + line_items_stripe.append( + { + "price_data": { + "currency": "eur", + "unit_amount": int(item["price"] * 100), + "product_data": {"name": item["product"].name}, + }, + "quantity": item["quantity"], + } + ) - OrderProduct.objects.create( - order=new_order, product=product, quantity=qty - ) + # --- Lógica de Envío (Hardcoded por ahora según tu código anterior) --- + # Si tienes lógica de envío, añádela aquí a line_items_stripe - Product.objects.filter(pk=product.pk).update(stock=F("stock") - qty) + domain_url = settings.DOMAIN_URL + try: + customer_email = ( + request.user.email + if request.user.is_authenticated + else request.POST.get("email_input", "") + ) - # 6. Borrar el carrito - if cart_to_delete: - cart_to_delete.delete() - else: - request.session["cart_session"] = {} - request.session.modified = True - # --- ENVÍO DE CORREO DE CONFIRMACIÓN --- - try: - # 1. Generar la URL absoluta de seguimiento - tracking_url = request.build_absolute_uri(reverse("order_search")) + checkout_session = stripe.checkout.Session.create( + payment_method_types=["card"], + line_items=line_items_stripe, + mode="payment", + shipping_address_collection={"allowed_countries": ["ES"]}, + customer_email=customer_email, + success_url=domain_url + + "/order/success/?session_id={CHECKOUT_SESSION_ID}", + cancel_url=domain_url + "/order/cancelled/", + ) + return redirect(checkout_session.url, code=303) - # 2. Definir asunto y mensaje - subject = f"Confirmación de Pedido #{new_order.tracking_code} - Essenza" + except Exception as e: + return HttpResponse(f"Error al conectar con Stripe: {e}") - # Mensaje simple en texto plano - message = f""" - Hola! + return redirect("cart_detail") - Gracias por tu compra en Essenza. - Tu pedido ha sido confirmado y se está preparando. - Detalles del pedido: - Nº de localizador: {new_order.tracking_code} - Total: {new_order.total_price} € - Dirección de envío: {new_order.address} +def successful_payment(request): + """ + Retorno de Stripe. + """ + session_id = request.GET.get("session_id") + if not session_id: + return HttpResponse("Error: No session ID") - Puedes seguir el estado de tu pedido aquí: - {tracking_url} + try: + session = stripe.checkout.Session.retrieve(session_id) + if session.payment_status == "paid": + # Extraer datos de Stripe + stripe_email = session.customer_details.email + address_data = session.customer_details.address + shipping_address = ( + f"{address_data.line1}, {address_data.city}, {address_data.postal_code}" + ) - Gracias por confiar en nosotros. - """ + user = request.user if request.user.is_authenticated else None - # 3. Enviar el correo - send_mail( - subject, - message, - settings.DEFAULT_FROM_EMAIL, # Asegúrate de tener esto en settings.py - [new_order.email], # El email del destinatario - fail_silently=True, # Si falla, no rompe la web + with transaction.atomic(): + # Llamamos a la función común + new_order = _process_order( + request, + user=user, + email=stripe_email, + address=shipping_address, + is_paid=True, # <--- STRIPE = TRUE ) - except Exception as e: - # Si falla el correo, lo imprimimos en consola pero dejamos pasar al usuario - print(f"Error enviando email: {e}") return render(request, "order/success.html", {"order": new_order}) - - else: - return HttpResponse("El pago no se ha completado.") - except Exception as e: - return HttpResponse(f"Error verificando el pago o creando el pedido: {e}") + return HttpResponse(f"Error: {e}") def cancelled_payment(request): diff --git a/essenza/templates/cart/cart_detail.html b/essenza/templates/cart/cart_detail.html index 7c8561c..63a2cae 100644 --- a/essenza/templates/cart/cart_detail.html +++ b/essenza/templates/cart/cart_detail.html @@ -7,22 +7,22 @@ {% block extra_head %} - -
- -
-

Pedido #{{ order.id }}

-
- -
-

Estado: {{ order.get_status_display }}

-

Fecha: {{ order.placed_at|date:"d/m/Y H:i" }}

-

Dirección: {{ order.address|default:"Sin dirección" }}

-
- -

Productos

- -
- {% for op in order.order_products.all %} -
- {% if op.product.photo %} - {{ op.product.name }} - {% else %} - No image - {% endif %} - -
-

{{ op.product.name }}

-

Cantidad: {{ op.quantity }}

-

Precio: {{ op.product.price }} €

-

Subtotal: {{ op.subtotal }} €

-
-
- {% endfor %} -
- -
- Total: {{ order.total }} € -
- - ← Volver - - -
- -{% endblock %} diff --git a/essenza/templates/order/order_history.html b/essenza/templates/order/order_history.html index 94f225e..b26e517 100644 --- a/essenza/templates/order/order_history.html +++ b/essenza/templates/order/order_history.html @@ -169,11 +169,17 @@ min-height: 80px; } + .shipping-total { + font-size: 0.9rem; + font-weight: 400; + color: #888; + margin-top: 20px; + } + .order-total { font-size: 1.5rem; font-weight: 800; color: #c06b3e; - margin-top: 15px; } /* Badges */ @@ -208,15 +214,15 @@ color: #e0e0e0; } .btn-shop { - display: inline-block; - margin-top: 15px; - background-color: #c06b3e; - color: white; - padding: 10px 20px; - border-radius: 8px; - text-decoration: none; - font-weight: 600; - transition: background 0.2s; + display: inline-block; + margin-top: 15px; + background-color: #c06b3e; + color: white; + padding: 10px 20px; + border-radius: 8px; + text-decoration: none; + font-weight: 600; + transition: background 0.2s; } .btn-shop:hover { background-color: #a35a34; } @@ -313,9 +319,16 @@

Mis pedidos

{{ order.get_status_display }} {% endif %} - -
- {{ order.total|floatformat:2 }} € +
+ +
+ Envío: {{ order.shipping|floatformat:2 }} € +
+ + +
+ {{ order.total|floatformat:2 }} € +
diff --git a/essenza/templates/order/order_list_admin.html b/essenza/templates/order/order_list_admin.html index 761de63..55d4b3a 100644 --- a/essenza/templates/order/order_list_admin.html +++ b/essenza/templates/order/order_list_admin.html @@ -208,11 +208,17 @@ min-height: 80px; } + .shipping-total { + font-size: 0.9rem; + font-weight: 400; + color: #888; + margin-top: 20px; + } + .order-total { font-size: 1.5rem; /* Grande como en product-list */ font-weight: 800; color: #c06b3e; /* Color marca */ - margin-top: 15px; } /* Badges de Estado */ @@ -379,9 +385,16 @@

Pedidos

{{ order.get_status_display }} {% endif %} - -
- {{ order.total|floatformat:2 }} € +
+ +
+ Envío: {{ order.shipping|floatformat:2 }} € +
+ + +
+ {{ order.total|floatformat:2 }} € +
diff --git a/essenza/templates/order/tracking.html b/essenza/templates/order/tracking.html index a422929..5c4e259 100644 --- a/essenza/templates/order/tracking.html +++ b/essenza/templates/order/tracking.html @@ -246,18 +246,23 @@ padding-top: 20px; border-top: 2px solid #eee; display: flex; - justify-content: flex-end; - align-items: center; - gap: 20px; + flex-direction: column; + align-items: flex-end; + gap: 5px; } - .total-label { - font-size: 1.1rem; - color: #666; + .shipping-line { + font-size: 0.95rem; + color: #888; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; } - .order-total-price { - font-size: 1.5rem; + .total-line { + font-size: 1.6rem; color: #c06b3e; font-weight: 800; + line-height: 1.1; } .btn-back { @@ -421,8 +426,16 @@
- TOTAL PAGADO: - {{ order.total|floatformat:2 }} € + +
+ + Envío: {{ order.shipping|floatformat:2 }} € +
+ +
+ {{ order.total|floatformat:2 }} € +
+
diff --git a/essenza/user/tests.py b/essenza/user/tests.py index c052e97..501b4f0 100644 --- a/essenza/user/tests.py +++ b/essenza/user/tests.py @@ -215,7 +215,7 @@ def test_logout_deletes_session_cookie(self): # 3. Comprobar que un usuario no autenticado también redirige correctamente def test_logout_redirects_even_if_not_authenticated(self): response = self.client.get(self.logout_url) - self.assertRedirects(response, self.dashboard_url) + self.assertRedirects(response, self.login_url) class UserAdminViewsTests(TestCase): diff --git a/essenza/user/views.py b/essenza/user/views.py index baf9696..631d4b2 100644 --- a/essenza/user/views.py +++ b/essenza/user/views.py @@ -1,8 +1,5 @@ from django.contrib.auth import authenticate, login, logout -from django.contrib.auth.mixins import ( # Para proteger vistas - LoginRequiredMixin, - UserPassesTestMixin, -) +from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.db.models import F from django.shortcuts import get_object_or_404, redirect, render from django.views import View @@ -17,10 +14,19 @@ from .models import Usuario -class LoginView(View): +class LoginView(UserPassesTestMixin, View): form_class = LoginForm template_name = "user/login.html" + def test_func(self): + return not self.request.user.is_authenticated + + def handle_no_permission(self): + if not self.request.user.role == "user": + return redirect("dashboard") + else: + return redirect("stock") + def get(self, request, *args, **kwargs): # Si el usuario ya está autenticado, lo mandamos a dashboard if request.user.is_authenticated: @@ -50,7 +56,13 @@ def post(self, request, *args, **kwargs): return render(request, self.template_name, {"form": form}) -class LogoutView(View): +class LogoutView(LoginRequiredMixin, View): + def test_func(self): + return self.request.user.is_authenticated + + def handle_no_permission(self): + return redirect("login") + def get(self, request): logout(request) response = redirect("dashboard") @@ -64,10 +76,19 @@ def post(self, request): return response -class RegisterView(View): +class RegisterView(UserPassesTestMixin, View): form_class = RegisterForm template_name = "user/register.html" + def test_func(self): + return not self.request.user.is_authenticated + + def handle_no_permission(self): + if not self.request.user.role == "user": + return redirect("dashboard") + else: + return redirect("stock") + def get(self, request, *args, **kwargs): form = self.form_class() return render(request, self.template_name, {"form": form}) @@ -86,6 +107,12 @@ def post(self, request, *args, **kwargs): class ProfileView(LoginRequiredMixin, View): template_name = "user/profile.html" + def test_func(self): + return self.request.user.is_authenticated + + def handle_no_permission(self): + return redirect("login") + def get(self, request, *args, **kwargs): return render(request, self.template_name) @@ -94,6 +121,12 @@ class ProfileEditView(LoginRequiredMixin, View): form_class = ProfileEditForm template_name = "user/edit_profile.html" + def test_func(self): + return self.request.user.is_authenticated + + def handle_no_permission(self): + return redirect("login") + def get(self, request, *args, **kwargs): # Rellena el formulario con los datos actuales del usuario form = self.form_class(instance=request.user) @@ -124,6 +157,12 @@ def post(self, request, *args, **kwargs): class ProfileDeleteView(LoginRequiredMixin, View): template_name = "user/confirm_delete_profile.html" + def test_func(self): + return self.request.user.is_authenticated + + def handle_no_permission(self): + return redirect("login") + def get(self, request, *args, **kwargs): # Muestra la página de confirmación return render(request, self.template_name) @@ -195,6 +234,8 @@ def test_func(self): return self.request.user.is_authenticated and self.request.user.role == "admin" def handle_no_permission(self): + if not self.request.user.is_authenticated: + return redirect("login") return redirect("dashboard") def get(self, request, *args, **kwargs): @@ -220,6 +261,8 @@ def test_func(self): return self.request.user.is_authenticated and self.request.user.role == "admin" def handle_no_permission(self): + if not self.request.user.is_authenticated: + return redirect("login") return redirect("dashboard") def get(self, request, pk, *args, **kwargs): @@ -259,6 +302,8 @@ def test_func(self): return self.request.user.is_authenticated and self.request.user.role == "admin" def handle_no_permission(self): + if not self.request.user.is_authenticated: + return redirect("login") return redirect("dashboard") def get(self, request, pk, *args, **kwargs): From 8902b5c8e43108e284b74a19da150dee95adbf4f Mon Sep 17 00:00:00 2001 From: FRANCISCO DE CASTRO Date: Thu, 27 Nov 2025 18:27:09 +0100 Subject: [PATCH 3/4] Corregido pago con stripe --- essenza/order/tests.py | 2 +- essenza/order/views.py | 27 +++++++------------------ essenza/templates/cart/cart_detail.html | 16 ++++++++++----- essenza/templates/info/info.html | 5 +++++ 4 files changed, 24 insertions(+), 26 deletions(-) diff --git a/essenza/order/tests.py b/essenza/order/tests.py index 940e412..f9f930f 100644 --- a/essenza/order/tests.py +++ b/essenza/order/tests.py @@ -89,7 +89,7 @@ def test_cod_checkout_success(self): self.assertFalse(order.is_paid) # [VERDAD DE DATOS] La dirección se concatenó correctamente - expected_address = "Juan Pérez | Calle Falsa 123, Madrid (28001)" + expected_address = "Calle Falsa 123, Madrid (28001)" self.assertEqual(order.address, expected_address) self.assertEqual(order.email, "juan@test.com") diff --git a/essenza/order/views.py b/essenza/order/views.py index b866ecf..f85b2cd 100644 --- a/essenza/order/views.py +++ b/essenza/order/views.py @@ -193,11 +193,6 @@ def post(self, request, tracking_code): return redirect("order_tracking", tracking_code=order.tracking_code) -# essenza/order/views.py - -# ... (tus importaciones se mantienen igual) ... - - def _process_order(request, user, email, address, is_paid): """ Función auxiliar interna para procesar el pedido. @@ -240,7 +235,7 @@ def _process_order(request, user, email, address, is_paid): email=email, address=address, status=Status.EN_PREPARACION, - is_paid=is_paid, # <--- AQUÍ USAMOS EL VALOR QUE PASAMOS + is_paid=is_paid, ) # 4. Crear OrderProducts y actualizar Stock @@ -307,9 +302,7 @@ def create_checkout(request): if payment_method == "cod": # 1. Capturamos los datos DEL FORMULARIO HTML name = request.POST.get("shipping_name") - email_input = request.POST.get( - "shipping_email" - ) # El input se llama shipping_email en tu HTML + email_input = request.POST.get("shipping_email") address = request.POST.get("shipping_address") city = request.POST.get("shipping_city") zip_code = request.POST.get("shipping_zip") @@ -321,15 +314,13 @@ def create_checkout(request): ) # 3. Construimos la dirección completa en un solo string - # Formato: "Nombre | Dirección, Ciudad (CP)" full_address = f"{address}, {city} ({zip_code})" # 4. Determinamos el usuario y email user = request.user if request.user.is_authenticated else None # Prioridad: Si el usuario escribió un email en el form, usamos ese. - # Si no (caso raro si usaste readonly), usamos el del user logueado. - final_email = email_input if email_input else (user.email if user else None) + final_email = email_input if email_input else user.email if not final_email: return HttpResponse("Error: Se requiere un email válido.", status=400) @@ -339,14 +330,13 @@ def create_checkout(request): order = _process_order( request, user=user, - email=final_email, # Usamos el email del formulario - address=full_address, # Usamos la dirección concatenada + email=final_email, + address=full_address, is_paid=False, # COD = No pagado aún ) if order: # IMPORTANTE: Redirigimos pasando el ID del pedido para mostrar éxito - # Asegúrate de que tu URL espera un parámetro, o usa la sesión return render(request, "order/success.html", {"order": order}) else: return redirect("cart_detail") @@ -396,15 +386,12 @@ def create_checkout(request): } ) - # --- Lógica de Envío (Hardcoded por ahora según tu código anterior) --- - # Si tienes lógica de envío, añádela aquí a line_items_stripe + # --- Lógica de Envío --- domain_url = settings.DOMAIN_URL try: customer_email = ( - request.user.email - if request.user.is_authenticated - else request.POST.get("email_input", "") + request.user.email if request.user.is_authenticated else None ) checkout_session = stripe.checkout.Session.create( diff --git a/essenza/templates/cart/cart_detail.html b/essenza/templates/cart/cart_detail.html index 6deb9e1..51e0834 100644 --- a/essenza/templates/cart/cart_detail.html +++ b/essenza/templates/cart/cart_detail.html @@ -139,7 +139,7 @@ display: flex; flex-direction: column; justify-content: center; - min-width: 200px; + min-width: 210px; } .price-row-small { font-size: 14px; @@ -464,15 +464,18 @@

Tu carrito está vacío

const stripeRadio = document.getElementById('pay_stripe'); const codRadio = document.getElementById('pay_cod'); const formDiv = document.getElementById('cod-shipping-details'); - const inputs = document.querySelectorAll('.cod-input'); + const codInputs = document.querySelectorAll('.cod-input'); const mainCard = document.querySelector('.cart-summary'); if (codRadio.checked) { // Mostrar formulario formDiv.style.display = 'block'; - // Hacer inputs obligatorios - inputs.forEach(input => input.required = true); + // Hacer cod_inputs obligatorios + codInputs.forEach(input => { + input.required = true; + input.disabled = false; + }); // Ajuste visual para conectar la tarjeta de arriba con el form de abajo mainCard.style.borderRadius = "16px 16px 0 0"; @@ -484,7 +487,10 @@

Tu carrito está vacío

formDiv.style.display = 'none'; // Quitar obligatoriedad (para que no falle al enviar a Stripe) - inputs.forEach(input => input.required = false); + codInputs.forEach(input => { + input.required = false; + input.disabled = true; + }); // Restaurar estilos originales mainCard.style.borderRadius = "16px"; diff --git a/essenza/templates/info/info.html b/essenza/templates/info/info.html index c5c12d5..da11bb3 100644 --- a/essenza/templates/info/info.html +++ b/essenza/templates/info/info.html @@ -141,6 +141,11 @@

2.2. Proceso de Compra y Precio

2.3. Envíos, Plazos y Riesgos

    +
  • + Envío: El envío tendrá un coste fijo de 4.99€ si el + valor subtotal de la compra no alcanza los 100€. Para compras iguales + o superiores a 100€, el envío será gratuito. +
  • Plazo: Los plazos de entrega estimados son de 3-5 días laborables y se contabilizan desde la confirmación del pago. From ef272aac18170540a71d2e1a5342f790cb0ac68d Mon Sep 17 00:00:00 2001 From: FRANCISCO DE CASTRO Date: Mon, 1 Dec 2025 12:06:13 +0100 Subject: [PATCH 4/4] =?UTF-8?q?A=C3=B1adida=20funcionalidad=20de=20compra?= =?UTF-8?q?=20r=C3=A1pida?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- essenza/cart/tests.py | 100 ++-- essenza/cart/views.py | 62 +- essenza/templates/product/catalog.html | 299 ++++++++-- essenza/templates/product/dashboard.html | 635 +++++++++++++-------- essenza/templates/product/detail_user.html | 350 ++++++++---- 5 files changed, 977 insertions(+), 469 deletions(-) diff --git a/essenza/cart/tests.py b/essenza/cart/tests.py index 01cb9a9..941581e 100644 --- a/essenza/cart/tests.py +++ b/essenza/cart/tests.py @@ -5,7 +5,6 @@ from cart.models import Cart, CartProduct -# Usamos get_user_model() porque usas un usuario personalizado (user.Usuario) User = get_user_model() @@ -23,7 +22,6 @@ def setUp(self): ) # 2. Crear Producto - # Usamos las choices reales de tu modelo self.product = Product.objects.create( name="Producto Test", description="Descripción de prueba", @@ -34,44 +32,38 @@ def setUp(self): is_active=True, ) - # 3. URLs (Sin namespace 'order' según tu urls.py actual) + # 3. URLs self.url_detail = reverse("cart_detail") self.url_add = reverse("add_to_cart", args=[self.product.pk]) self.url_update = reverse("update_cart_item", args=[self.product.pk]) self.url_remove = reverse("remove_from_cart", args=[self.product.pk]) + # URL ficticia para simular el "referer" (la página anterior) + self.url_catalog = reverse("catalog") + # --------------------------------------------------------- # BLOQUE 1: DETALLE DEL CARRITO (GET) # --------------------------------------------------------- def test_cart_detail_authenticated_empty(self): - """Usuario logueado sin carrito previo. Debe cargar vacío sin fallar.""" self.client.force_login(self.user) response = self.client.get(self.url_detail) self.assertEqual(response.status_code, 200) - # Tu vista pasa 'cart_products' vacío si falla el try/except o no hay carrito self.assertEqual(len(response.context.get("cart_products", [])), 0) def test_cart_detail_authenticated_with_items(self): - """Usuario logueado con carrito en DB.""" self.client.force_login(self.user) - - # Setup DB cart = Cart.objects.create(user=self.user) CartProduct.objects.create(cart=cart, product=self.product, quantity=2) response = self.client.get(self.url_detail) self.assertEqual(response.status_code, 200) - # Verifica que lee de la DB self.assertEqual(len(response.context["cart_products"]), 1) - self.assertEqual(response.context["subtotal"], 20.00) # 2 * 10.00 + self.assertEqual(response.context["subtotal"], 20.00) def test_cart_detail_anonymous_session(self): - """Usuario anónimo con datos en sesión.""" self.client.logout() - - # Inyectar sesión session = self.client.session session["cart_session"] = { str(self.product.pk): {"quantity": 3, "price": "10.00"} @@ -81,33 +73,64 @@ def test_cart_detail_anonymous_session(self): response = self.client.get(self.url_detail) self.assertEqual(response.status_code, 200) - # Tu vista pasa 'cart_products' también para anónimos (lo vi en tu código) self.assertEqual(len(response.context["cart_products"]), 1) - self.assertEqual(response.context["subtotal"], 30.00) # 3 * 10.00 + self.assertEqual(response.context["subtotal"], 30.00) # --------------------------------------------------------- - # BLOQUE 2: AÑADIR AL CARRITO (POST) + # BLOQUE 2: AÑADIR AL CARRITO (POST) - ACTUALIZADO # --------------------------------------------------------- - def test_add_item_authenticated(self): - """Añadir ítem crea Cart y CartProduct en DB.""" + def test_add_item_action_buy_redirects_to_cart(self): + """ + Prueba el flujo 'Comprar ahora' (action='buy'). + Debe añadir el producto y redirigir al carrito. + """ self.client.force_login(self.user) - response = self.client.post(self.url_add, {"quantity": 1}) - response = self.client.post(self.url_add, {"quantity": 3}) + # Enviamos action='buy' explícitamente + response = self.client.post(self.url_add, {"quantity": 1, "action": "buy"}) + # Debe redirigir a cart_detail self.assertRedirects(response, self.url_detail) # Verificar DB cart = Cart.objects.get(user=self.user) cp = CartProduct.objects.get(cart=cart, product=self.product) - self.assertEqual(cp.quantity, 4) + self.assertEqual(cp.quantity, 1) + + def test_add_item_action_add_stays_on_page(self): + """ + Prueba el flujo 'Añadir al carrito' (action='add'). + Debe añadir el producto y redirigir a la página anterior (Referer). + """ + self.client.force_login(self.user) + + # Simulamos que venimos del catálogo + referer = self.url_catalog + + # Enviamos action='add' y el HTTP_REFERER + response = self.client.post( + self.url_add, {"quantity": 2, "action": "add"}, HTTP_REFERER=referer + ) + + # Debe redirigir DE VUELTA al catálogo, no al carrito + self.assertRedirects(response, referer) + + # Verificar DB + cart = Cart.objects.get(user=self.user) + cp = CartProduct.objects.get(cart=cart, product=self.product) + self.assertEqual(cp.quantity, 2) + + # Verificar mensajes (feedback visual) + messages = list(response.wsgi_request._messages) + self.assertEqual(len(messages), 1) + self.assertIn("añadido al carrito", str(messages[0])) def test_add_item_anonymous(self): - """Añadir ítem guarda en Sesión.""" + """Añadir ítem guarda en Sesión (usando 'buy' para verificar redirect clásico).""" self.client.logout() - response = self.client.post(self.url_add, {"quantity": 1}) + response = self.client.post(self.url_add, {"quantity": 1, "action": "buy"}) self.assertRedirects(response, self.url_detail) @@ -122,24 +145,29 @@ def test_add_item_out_of_stock(self): self.client.force_login(self.user) - # Debería redirigir al catálogo (o donde definas 'catalog') y mostrar error - # Como no sé tu URL 'catalog', verificamos que NO se creó el CartProduct + # Simulamos venir del catálogo + referer = self.url_catalog + response = self.client.post(self.url_add, {"quantity": 1}, HTTP_REFERER=referer) + + # Debe redirigir atrás con error + self.assertRedirects(response, referer) + + # Verificar mensaje de error + messages = list(response.wsgi_request._messages) + self.assertIn("agotado", str(messages[0]).lower()) + + # Verificar que NO se creó nada en DB self.assertFalse(CartProduct.objects.filter(product=self.product).exists()) # --------------------------------------------------------- - # BLOQUE 3: ACTUALIZAR (POST) - AQUI ESTÁ EL PELIGRO + # BLOQUE 3: ACTUALIZAR (POST) # --------------------------------------------------------- def test_auth_update_item(self): - """ - Verifica update para logueados. - NOTA: Este test está 'trucado' para que pase con tu bug actual. - """ self.client.force_login(self.user) cart = Cart.objects.create(user=self.user) # TRUCO: Forzamos que el ID del CartProduct sea igual al del Product - # para que tu vista rota (pk=product_id) lo encuentre. cp = CartProduct( id=self.product.pk, cart=cart, product=self.product, quantity=1 ) @@ -152,10 +180,9 @@ def test_auth_update_item(self): self.assertEqual(cp.quantity, 5) def test_anon_update_item(self): - """Verifica update para anónimos (Sesión).""" self.client.logout() - # Añadimos primero - self.client.post(self.url_add, {"quantity": 1}) + # Añadimos primero (action buy para ir al carrito) + self.client.post(self.url_add, {"quantity": 1, "action": "buy"}) # Actualizamos response = self.client.post(self.url_update, {"quantity": 4}) @@ -169,10 +196,6 @@ def test_anon_update_item(self): # --------------------------------------------------------- def test_auth_remove_item(self): - """ - Verifica borrado para logueados. - NOTA: También trucado por tu bug. - """ self.client.force_login(self.user) cart = Cart.objects.create(user=self.user) @@ -188,7 +211,6 @@ def test_auth_remove_item(self): self.assertFalse(CartProduct.objects.filter(pk=cp.pk).exists()) def test_anon_remove_item(self): - """Verifica borrado para anónimos.""" self.client.logout() # Setup sesión session = self.client.session diff --git a/essenza/cart/views.py b/essenza/cart/views.py index 9986ae9..a90d936 100644 --- a/essenza/cart/views.py +++ b/essenza/cart/views.py @@ -1,5 +1,6 @@ from decimal import Decimal +from django.contrib import messages from django.contrib.auth.mixins import UserPassesTestMixin from django.shortcuts import get_object_or_404, redirect, render from django.views import View @@ -79,7 +80,9 @@ def get(self, request): class AddToCartView(UserPassesTestMixin, View): """ - Añade productos al carrito (DB o Sesión). + Añade productos al carrito. + - Acción 'add': Se queda en la página y muestra mensaje. + - Acción 'buy': Redirige al carrito. """ def test_func(self): @@ -93,8 +96,12 @@ def handle_no_permission(self): def post(self, request, product_id): product = get_object_or_404(Product, pk=product_id) + # Guardar la URL anterior para volver si es "Añadir" + next_url = request.META.get("HTTP_REFERER", "catalog") + if product.stock <= 0: - return redirect("catalog") + messages.error(request, "Este producto está agotado.") + return redirect(next_url) try: quantity = int(request.POST.get("quantity", 1)) @@ -103,36 +110,40 @@ def post(self, request, product_id): except ValueError: quantity = 1 - # Si el usuario está logueado + # --- LÓGICA DE AÑADIR (unificada) --- if request.user.is_authenticated: - cart, create = Cart.objects.get_or_create(user=request.user) + cart, _ = Cart.objects.get_or_create(user=request.user) + # Usamos get_or_create con defaults para evitar condiciones de carrera simples + cart_product, created = CartProduct.objects.get_or_create( + cart=cart, product=product, defaults={"quantity": 0} + ) - if cart: - cart_product, created = CartProduct.objects.get_or_create( - cart=cart, product=product, defaults={"quantity": quantity} - ) + # Si se acaba de crear, quantity es 0 (por el default), si ya existía tiene X + # Así que simplemente sumamos la cantidad nueva. + if created: + cart_product.quantity = quantity else: - cart_product, created = CartProduct.objects.get_or_create( - cart=create, product=product, defaults={"quantity": quantity} + cart_product.quantity += quantity + + # Validación Stock + if cart_product.quantity > product.stock: + cart_product.quantity = product.stock + messages.warning( + request, f"Has alcanzado el límite de stock ({product.stock})." ) - if not created: - if cart_product.quantity + quantity > product.stock: - cart_product.quantity = product.stock - return redirect("cart_detail") - else: - cart_product.quantity += quantity - cart_product.save() + cart_product.save() - # Si el usuario no está logueado, guardamos en sesión else: + # Lógica de Sesión cart_session = request.session.get("cart_session", {}) product_id_str = str(product_id) if product_id_str in cart_session: - if cart_session[product_id_str]["quantity"] + quantity > product.stock: + current_qty = cart_session[product_id_str]["quantity"] + if current_qty + quantity > product.stock: cart_session[product_id_str]["quantity"] = product.stock - return redirect("cart_detail") + messages.warning(request, "Has alcanzado el límite de stock.") else: cart_session[product_id_str]["quantity"] += quantity else: @@ -144,7 +155,16 @@ def post(self, request, product_id): request.session["cart_session"] = cart_session request.session.modified = True - return redirect("cart_detail") + # --- AQUÍ ESTÁ LA MAGIA DE LA REDIRECCIÓN --- + action = request.POST.get("action", "add") # 'add' o 'buy' + + if action == "buy": + # Si quiere comprar ya, lo llevamos al carrito + return redirect("cart_detail") + else: + # Si solo añade, le damos feedback y lo dejamos donde estaba + messages.success(request, f"¡{product.name} añadido al carrito!") + return redirect(next_url) class RemoveFromCartView(UserPassesTestMixin, View): diff --git a/essenza/templates/product/catalog.html b/essenza/templates/product/catalog.html index 783c57b..19e1685 100644 --- a/essenza/templates/product/catalog.html +++ b/essenza/templates/product/catalog.html @@ -16,7 +16,6 @@ padding: 20px 0; } .list-page .container { - /* Contenedor principal centrado para el contenido */ max-width: 1200px; margin: 0 auto; padding: 20px; @@ -209,7 +208,7 @@ max-width: 1100px; margin: 30px auto; display: grid; - grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 22px; } .card { @@ -219,8 +218,12 @@ box-shadow: 0 3px 12px rgba(0, 0, 0, 0.1); text-align: center; transition: 0.25s; - cursor: pointer; + cursor: default; position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; } .card:hover { transform: translateY(-6px); @@ -231,23 +234,27 @@ height: 180px; object-fit: contain; border-radius: 10px; + margin-bottom: 10px; } .card-link-overlay { position: absolute; top: 0; left: 0; width: 100%; - height: 100%; + height: 55%; /* Solo cubre imagen y título */ z-index: 1; - } + cursor: pointer; + } .price { - margin-top: 8px; + margin-top: 5px; color: #c06b3e; font-weight: bold; + font-size: 1.1rem; } .category-tag { display: inline-block; - margin-top: 8px; + margin-left: 50px; + margin-right: 50px; background: #f2e5df; color: #c06b3e; padding: 4px 10px; @@ -255,18 +262,162 @@ font-size: 12px; } .product-stock { - margin-top: 12px; + margin-top: 10px; + font-weight: 600; + font-size: 14px; + } + + /* --- NUEVOS ESTILOS: CONTROLES DE COMPRA --- */ + .purchase-form { + width: 100%; + margin-top: 15px; + position: relative; + z-index: 2; /* Encima del overlay */ + } + + /* Selector de Cantidad */ + .qty-control-wrapper { + display: flex; + justify-content: center; + margin-bottom: 12px; + } + + .qty-control { + display: flex; + align-items: center; + border: 1px solid #ddd; + border-radius: 6px; + overflow: hidden; + background: #fff; + } + + .btn-qty { + width: 32px; + height: 32px; + background-color: #f9f9f9; + border: none; + font-weight: bold; + color: #555; + cursor: pointer; + transition: background 0.2s; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + padding: 0; + } + + .btn-qty:hover { + background-color: #e0e0e0; + color: #333; + } + + .btn-qty:active { + background-color: #ccc; + } + + .qty-input { + width: 40px; + height: 32px; + border: none; + border-left: 1px solid #eee; + border-right: 1px solid #eee; + text-align: center; font-weight: 600; font-size: 14px; + color: #333; + background: #fff; + cursor: default; + } + .qty-input:focus { outline: none; } + .qty-input::-webkit-inner-spin-button, + .qty-input::-webkit-outer-spin-button { + -webkit-appearance: none; margin: 0; + } + + /* Botones de Acción */ + .btns-row { + display: flex; + gap: 8px; + width: 100%; + } + + .btn-card { + flex: 1; + padding: 10px 0; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 700; + font-size: 13px; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + } + + .btn-add { + background-color: #fff; + border: 1px solid #c06b3e; + color: #c06b3e; + } + .btn-add:hover { + background-color: #fff5f0; + } + + .btn-buy { + background-color: #c06b3e; + color: white; + border: 1px solid #c06b3e; } + .btn-buy:hover { + background-color: #a35a34; + border-color: #a35a34; + } + + /* --- MENSAJES FLOTANTES --- */ + .messages-container { + position: fixed; + top: 90px; + right: 20px; + z-index: 9999; + display: flex; + flex-direction: column; + gap: 10px; + } + .msg { + padding: 12px 20px; + border-radius: 8px; + color: white; + font-weight: 600; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + animation: slideIn 0.3s ease-out, fadeOut 0.5s ease-in 3.5s forwards; + font-size: 14px; + } + .msg.success { background-color: #2e7d32; } + .msg.warning { background-color: #f57c00; } + .msg.error { background-color: #c62828; } + + @keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } + @keyframes fadeOut { to { opacity: 0; display: none; } } +
    + + {% if messages %} +
    + {% for message in messages %} +
    {{ message }}
    + {% endfor %} +
    + {% endif %} +

    Catálogo Essenza

    Explora nuestra selección de productos mejor valorados

    -
    -
    {% if products %} {% for product in products %}
    Catálogo Essenza

    {{ product.name }}

    {{ product.price }} €

    {{ product.get_category_display }} +
    {% if product.stock == 0 %} Producto agotado - {% endif %} {% if product.stock < 10 and product.stock > 0 %} - ¡Últimas unidades! + {% elif product.stock < 10 %} + ¡Últimas unidades! {% endif %}
    + +
    + {% csrf_token %} + + {% if product.stock > 0 %} +
    +
    + + + +
    +
    + +
    + + +
    + {% endif %} +
    +
    {% endfor %} {% else %}

    @@ -339,7 +520,21 @@

    {{ product.name }}

    - - -{% endblock %} + } + })(); + +{% endblock %} \ No newline at end of file diff --git a/essenza/templates/product/detail_user.html b/essenza/templates/product/detail_user.html index 2e8ac7e..1b9a724 100644 --- a/essenza/templates/product/detail_user.html +++ b/essenza/templates/product/detail_user.html @@ -1,25 +1,11 @@ {% extends "base.html" %} {% load static %} {% block title %} {{ product.name }} · Essenza -{% endblock %} {% block extra_head %} +{% endblock %} + +{% block extra_head %} -{% endblock %} {% block content %} +{% endblock %} + +{% block content %}
    + + {% if messages %} +
    + {% for message in messages %} +
    {{ message }}
    + {% endfor %} +
    + {% endif %} +
    -
    +
    {% if product.photo %} - {{ product.name }} + {{ product.name }} {% else %} - {{ product.name }} + {{ product.name }} {% endif %}

    {{ product.name }}

    {{ product.brand }}
    -
    {{ product.get_category_display }}
    -
    -
    € {{ product.price }}
    -
    - {% if product.stock == 0 %} - Producto agotado - {% endif %} {% if product.stock < 10 and product.stock > 0 %} - ¡Últimas unidades! - {% endif %} -
    + {{ product.get_category_display }} + +
    € {{ product.price|floatformat:2 }}
    + +
    + {% if product.stock == 0 %} + Producto agotado + {% elif product.stock < 10 %} + ¡Últimas unidades! + {% else %} + En Stock + {% endif %}
    +
    Descripción:

    {{ product.description }}

    -
    -
    - -
    -
    - {% csrf_token %} - -
    - - ← Volver +
    +
    + {% csrf_token %} + + {% if product.stock > 0 %} +
    + Cantidad: +
    + + + +
    +
    + +
    + + +
    + {% else %} + + {% endif %} +
    + + ← Volver al catálogo +
    + +
    -{% endblock %} + + +{% endblock %} \ No newline at end of file