diff --git a/padelclub_backend/settings_app.py b/padelclub_backend/settings_app.py index c34bc82..e69f82e 100644 --- a/padelclub_backend/settings_app.py +++ b/padelclub_backend/settings_app.py @@ -52,8 +52,7 @@ SYNC_APPS = { STRIPE_CURRENCY = 'eur' # Add managers who should receive internal emails SHOP_MANAGERS = [ - ('Razmig Sarkissian', 'razmig@padelclub.app'), - # ('Shop Admin', 'shop-admin@padelclub.app'), + ('Shop Admin', 'shop-admin@padelclub.app'), # ('Laurent Morvillier', 'laurent@padelclub.app'), # ('Xavier Rousset', 'xavier@padelclub.app'), ] diff --git a/shop/cart.py b/shop/cart.py index 76dcec6..18bb05c 100644 --- a/shop/cart.py +++ b/shop/cart.py @@ -74,3 +74,21 @@ def get_cart_item(request, item_id): return CartItem.objects.get(id=item_id, session_id=cart_id) except CartItem.DoesNotExist: raise Exception("Cart item not found") + +def transfer_cart(request, old_session_key): + """ + Transfer cart items from an anonymous session to an authenticated user's session + """ + from django.contrib.sessions.models import Session + from django.contrib.sessions.backends.db import SessionStore + + # Get the old session + try: + old_session = SessionStore(session_key=old_session_key) + # Check if there are cart items in the old session + if 'cart_items' in old_session: + # Transfer cart items to the new session + request.session['cart_items'] = old_session['cart_items'] + request.session.modified = True + except Session.DoesNotExist: + pass diff --git a/shop/signals.py b/shop/signals.py index 83be688..70ed58a 100644 --- a/shop/signals.py +++ b/shop/signals.py @@ -5,6 +5,8 @@ from django.conf import settings from django.urls import reverse from .models import Order, OrderItem, OrderStatus from django.db import transaction +from django.contrib.auth.signals import user_logged_in +from .cart import transfer_cart @receiver([post_save, post_delete], sender=Order) def send_order_notification(sender, instance, **kwargs): @@ -307,3 +309,17 @@ Merci de votre confiance. L'équipe PadelClub """ + +@receiver(user_logged_in) +def user_logged_in_handler(sender, request, user, **kwargs): + """ + When a user logs in, transfer any cart items from their anonymous session + """ + # Get the anonymous session key + if hasattr(request, 'session') and not request.session.is_empty(): + anonymous_session_key = request.session.session_key + + # After the user logs in, the session key changes + # So we transfer cart from the old session to the new session + if anonymous_session_key: + transfer_cart(request, anonymous_session_key) diff --git a/shop/static/shop/images/products/noir_hat.png.avif b/shop/static/shop/images/products/noir_hat.png.avif new file mode 100644 index 0000000..b451e1d Binary files /dev/null and b/shop/static/shop/images/products/noir_hat.png.avif differ diff --git a/shop/stripe_utils.py b/shop/stripe_utils.py index 99fb354..a6a5934 100644 --- a/shop/stripe_utils.py +++ b/shop/stripe_utils.py @@ -25,12 +25,13 @@ class StripeService: mode_str = "TEST" if self.is_test_mode else "LIVE" logger.debug(f"Initialized StripeService in {mode_str} mode") - def create_checkout_session(self, line_items, success_url, cancel_url, metadata=None): + def create_checkout_session(self, customer_email, line_items, success_url, cancel_url, metadata=None): """Create a Stripe Checkout Session for one-time payments""" if self.is_test_mode: logger.info(f"Creating checkout session in TEST mode with metadata: {metadata}") session = stripe.checkout.Session.create( + customer_email=customer_email, payment_method_types=['card'], line_items=line_items, mode='payment', @@ -67,24 +68,3 @@ class StripeService: # Create a singleton instance for import and use throughout the app stripe_service = StripeService() - -# For backward compatibility, expose some functions directly -def create_payment_intent(amount, currency=None, metadata=None): - """Legacy function for backward compatibility""" - if currency is None: - currency = stripe_service.currency - - return stripe.PaymentIntent.create( - amount=amount, - currency=currency, - metadata=metadata or {}, - ) - -def create_checkout_session(line_items, success_url, cancel_url, metadata=None): - """Legacy function for backward compatibility""" - return stripe_service.create_checkout_session( - line_items=line_items, - success_url=success_url, - cancel_url=cancel_url, - metadata=metadata or {}, - ) diff --git a/shop/templates/shop/product_item.html b/shop/templates/shop/product_item.html index e00c05e..047ec50 100644 --- a/shop/templates/shop/product_item.html +++ b/shop/templates/shop/product_item.html @@ -1,11 +1,15 @@ +{% load shop_extras %}
{% if product.image %} - {{ product.title }} + {{ product.title }} {% else %}
No Image Available
{% endif %} -
+ {% csrf_token %}
@@ -33,6 +37,7 @@ title="{{ color.name }}" data-color-id="{{ color.id }}" data-color-name="{{ color.name }}" + data-color-image="{{ product.image|color_image_url:color.name }}" onclick="selectColor('{{ product.id }}', '{{ color.id }}', '{{ color.name }}', this)">
{% endfor %}
@@ -66,7 +71,7 @@
{{ product.price }}
- + @@ -123,6 +128,88 @@ function selectColor(productId, colorId, colorName, element) { // Add selected class to clicked color element.classList.add('selected'); + + // Update product image based on selected color + const productImage = document.getElementById(`product-image-${productId}`); + + if (productImage) { + const colorImage = element.getAttribute('data-color-image'); + if (colorImage) { + productImage.src = colorImage; + } + } } +function addToCartAjax(productId) { + // Get the form + const form = document.getElementById(`cart-form-${productId}`); + const formData = new FormData(form); + + // Add CSRF token + const csrftoken = document.querySelector('[name=csrfmiddlewaretoken]').value; + + // Create notification element + const notification = document.createElement('div'); + notification.className = 'add-to-cart-notification'; + notification.style.position = 'fixed'; + notification.style.bottom = '20px'; + notification.style.right = '20px'; + notification.style.padding = '20px'; + notification.style.backgroundColor = '#90ee90'; + notification.style.color = '#707070'; + notification.style.borderRadius = '12px'; + notification.style.zIndex = '9999'; + notification.style.opacity = '0'; + notification.style.transition = 'opacity 0.3s'; + + // Send AJAX request + fetch(form.action, { + method: 'POST', + body: formData, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': csrftoken, + } + }) + .then(response => response.json()) + .then(data => { + // Show success message + notification.textContent = data.message; + document.body.appendChild(notification); + + // Update cart total in the navigation + const cartTotalElements = document.querySelectorAll('.confirm-nav-button'); + cartTotalElements.forEach(element => { + element.textContent = `Voir mon panier (${data.cart_total} €)`; + }); + + // Show notification + setTimeout(() => { notification.style.opacity = '1'; }, 100); + + // Hide notification after 3 seconds + setTimeout(() => { + notification.style.opacity = '0'; + setTimeout(() => { + document.body.removeChild(notification); + }, 300); // Wait for fade-out animation + }, 3000); + }) + .catch(error => { + console.error('Error:', error); + notification.textContent = "Erreur lors de l'ajout au panier"; + notification.style.backgroundColor = '#F44336'; + document.body.appendChild(notification); + + // Show notification + setTimeout(() => { notification.style.opacity = '1'; }, 100); + + // Hide notification after 3 seconds + setTimeout(() => { + notification.style.opacity = '0'; + setTimeout(() => { + document.body.removeChild(notification); + }, 300); // Wait for fade-out animation + }, 3000); + }); +} diff --git a/shop/templatetags/__init__.py b/shop/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shop/templatetags/shop_extras.py b/shop/templatetags/shop_extras.py new file mode 100644 index 0000000..c6f6406 --- /dev/null +++ b/shop/templatetags/shop_extras.py @@ -0,0 +1,82 @@ +from django import template +import os + +register = template.Library() + +@register.filter +def color_image_url(product_image, color_name): + """ + Returns color-specific image URL with any supported extension. + Falls back to the original image if no color variant exists. + """ + if not product_image or not color_name: + return product_image + + # Generate color suffix + suffix = generate_color_suffix(color_name) + + # Split path + directory, filename = os.path.split(product_image) + base_name, original_ext = os.path.splitext(filename) + + # List of supported image extensions to check + supported_extensions = ['.png.avif', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif'] + + # Check for the color image with original extension first + color_filename = f"{suffix}_{base_name}{original_ext}" + color_image = os.path.join(directory, color_filename) + + # Extract the path after /static/ + static_prefix = '/static/' + if color_image.startswith(static_prefix): + rel_path = color_image[len(static_prefix):] + else: + rel_path = color_image.lstrip('/') + + # Check if file with original extension exists + from django.conf import settings + app_static_path = os.path.join(settings.BASE_DIR, 'shop', 'static', rel_path) + + if os.path.exists(app_static_path): + return color_image + + # If not found with original extension, try other extensions + for ext in supported_extensions: + if ext == original_ext: + continue # Skip the original extension as we already checked it + + color_filename = f"{suffix}_{base_name}{ext}" + color_image = os.path.join(directory, color_filename) + + if color_image.startswith(static_prefix): + rel_path = color_image[len(static_prefix):] + else: + rel_path = color_image.lstrip('/') + + app_static_path = os.path.join(settings.BASE_DIR, 'shop', 'static', rel_path) + + if os.path.exists(app_static_path): + return color_image + + # If no color variant is found with any extension, return the original image + return product_image + +def generate_color_suffix(color_name): + """ + Generates a URL-friendly suffix from a color name + Example: "Noir / Gris Foncé Chiné" becomes "noir_gris_fonce_chine" + """ + import unicodedata + import re + + # Convert to lowercase and replace accents + value = color_name.lower() + value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii') + + # Replace slashes and spaces with underscores + value = re.sub(r'[/\s]+', '_', value) + + # Remove any remaining non-alphanumeric characters + value = re.sub(r'[^\w_]', '', value) + + return value diff --git a/shop/views.py b/shop/views.py index dcf6089..8ddd755 100644 --- a/shop/views.py +++ b/shop/views.py @@ -50,15 +50,24 @@ def _create_stripe_checkout_session(request, order, line_items): 'order_id': str(order.id), } + # Set up customer information + customer_email = None + # Add user info to metadata if available if request.user.is_authenticated: metadata['user_id'] = str(request.user.id) + customer_email = request.user.email + elif order.guest_user: + metadata['guest_email'] = order.guest_user.email + customer_email = order.guest_user.email elif 'guest_email' in request.session: metadata['guest_email'] = request.session.get('guest_email', '') + customer_email = request.session.get('guest_email', '') try: # Use the service to create the session checkout_session = stripe_service.create_checkout_session( + customer_email=customer_email, line_items=line_items, success_url=success_url, cancel_url=cancel_url, @@ -114,8 +123,21 @@ def add_to_cart_view(request, product_id): size_id = request.POST.get('size') cart_item = cart.add_to_cart(request, product_id, quantity, color_id, size_id) - messages.success(request, f'{cart_item.quantity} x {product.title} added to your cart') + message = f'{cart_item.quantity} x {product.title} dans le panier', + + # Check if this is an AJAX request + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': + cart_items = cart.get_cart_items(request) + total = cart.get_cart_total(request) + return JsonResponse({ + 'success': True, + 'message': message, + 'cart_total': total, + 'cart_count': cart_items.count() + }) + # For non-AJAX requests, fall back to the original behavior + messages.success(request, message) return redirect('shop:product_list') def update_cart_view(request, product_id): diff --git a/tournaments/models/match.py b/tournaments/models/match.py index c4f7e67..bb23a2e 100644 --- a/tournaments/models/match.py +++ b/tournaments/models/match.py @@ -424,13 +424,16 @@ class Match(SideStoreModel): time_indication = self.time_indication() court = self.court_name(self.court_index) group_stage_name = None + bracket_name = None if self.group_stage: group_stage_name = self.group_stage.display_name() + else: + bracket_name = f"Match n˚{self.index_in_round() + 1}" ended = self.end_date is not None live_format = "Format " + FederalMatchCategory(self.format).format_label_short - livematch = LiveMatch(self.index, title, date, time_indication, court, self.started(), ended, group_stage_name, live_format, self.start_date, self.court_index, self.disabled) + livematch = LiveMatch(self.index, title, date, time_indication, court, self.started(), ended, group_stage_name, live_format, self.start_date, self.court_index, self.disabled, bracket_name) for team in self.live_teams(): livematch.add_team(team) @@ -491,7 +494,7 @@ class Team: } class LiveMatch: - def __init__(self, index, title, date, time_indication, court, started, ended, group_stage_name, format, start_date, court_index, disabled): + def __init__(self, index, title, date, time_indication, court, started, ended, group_stage_name, format, start_date, court_index, disabled, bracket_name): self.index = index self.title = title self.date = date @@ -506,6 +509,7 @@ class LiveMatch: self.disabled = disabled self.start_date = start_date self.court_index = court_index + self.bracket_name = bracket_name def add_team(self, team): self.teams.append(team) @@ -526,7 +530,8 @@ class LiveMatch: "group_stage_name": self.group_stage_name, "format": self.format, "disabled": self.disabled, - "court_index": self.court_index + "court_index": self.court_index, + "bracket_name": self.bracket_name } def show_time_indication(self): diff --git a/tournaments/static/rules/padel-guide-cdc.pdf b/tournaments/static/rules/padel-guide-cdc.pdf new file mode 100644 index 0000000..f45e9da Binary files /dev/null and b/tournaments/static/rules/padel-guide-cdc.pdf differ diff --git a/tournaments/static/rules/padel-guide-general.pdf b/tournaments/static/rules/padel-guide-general.pdf new file mode 100644 index 0000000..693575e Binary files /dev/null and b/tournaments/static/rules/padel-guide-general.pdf differ diff --git a/tournaments/static/rules/padel-guide-rankings.pdf b/tournaments/static/rules/padel-guide-rankings.pdf new file mode 100644 index 0000000..4a5ffc9 Binary files /dev/null and b/tournaments/static/rules/padel-guide-rankings.pdf differ diff --git a/tournaments/static/rules/padel-rules-2024.pdf b/tournaments/static/rules/padel-rules-2024.pdf deleted file mode 100644 index 5465bd4..0000000 Binary files a/tournaments/static/rules/padel-rules-2024.pdf and /dev/null differ diff --git a/tournaments/static/rules/padel-rules.pdf b/tournaments/static/rules/padel-rules.pdf new file mode 100644 index 0000000..62e691b Binary files /dev/null and b/tournaments/static/rules/padel-rules.pdf differ diff --git a/tournaments/static/tournaments/css/style.css b/tournaments/static/tournaments/css/style.css index 205c41c..b287a0b 100644 --- a/tournaments/static/tournaments/css/style.css +++ b/tournaments/static/tournaments/css/style.css @@ -902,6 +902,23 @@ h-margin { border-radius: 0 0 24px 24px; /* Match the bubble's bottom corners */ } +.status-container-bracket { + margin: 0px -20px -20px -20px; /* Negative margin to counter the bubble padding, including bottom */ + padding: 10px 20px 20px 20px; /* Add padding back to maintain text alignment, including bottom */ + border-radius: 0 0 24px 24px; /* Match the bubble's bottom corners */ +} + +.status-container-bracket-header { + height: 30px; + text-align: left; +} + +.status-container-bracket-header-bottom { + height: 30px; + text-align: left; +} + +.status-container-bracket.running, .status-container.running { background-color: #90ee90; /* Light green color */ } diff --git a/tournaments/static/tournaments/css/tournament_bracket.css b/tournaments/static/tournaments/css/tournament_bracket.css index 1be8f50..ed1a1a4 100644 --- a/tournaments/static/tournaments/css/tournament_bracket.css +++ b/tournaments/static/tournaments/css/tournament_bracket.css @@ -43,7 +43,7 @@ transform: translateX(-50%); /* Center it exactly */ text-align: center; font-weight: bold; - width: auto; /* Change from 100% to auto */ + width: 100%; /* Change from 100% to auto */ padding: 5px 10px; white-space: nowrap; /* Prevent text from wrapping */ @@ -53,6 +53,10 @@ justify-content: center; } +.round-title.broadcast-mode { + width: auto; /* Change from 100% to auto */ +} + .round-name { color: #707070; font-size: 1.5em; diff --git a/tournaments/static/tournaments/js/tournament_bracket.js b/tournaments/static/tournaments/js/tournament_bracket.js index 3f610f1..bbf058d 100644 --- a/tournaments/static/tournaments/js/tournament_bracket.js +++ b/tournaments/static/tournaments/js/tournament_bracket.js @@ -66,7 +66,9 @@ function renderBracket(options) { // Create title const titleDiv = document.createElement("div"); titleDiv.className = "round-title"; - + if (isBroadcast) { + titleDiv.className = "round-title broadcast-mode"; + } // Get the match group name and format const firstMatchTemplate = roundMatches[0].closest(".match-template"); const matchGroupName = firstMatchTemplate.dataset.matchGroupName; diff --git a/tournaments/templates/registration/signup.html b/tournaments/templates/registration/signup.html index a6a083f..d541d72 100644 --- a/tournaments/templates/registration/signup.html +++ b/tournaments/templates/registration/signup.html @@ -36,10 +36,6 @@ - - {% for message in messages %} -
{{ message }}
- {% endfor %} diff --git a/tournaments/templates/tournaments/bracket_match_cell.html b/tournaments/templates/tournaments/bracket_match_cell.html index 6df8cb2..33a3bbc 100644 --- a/tournaments/templates/tournaments/bracket_match_cell.html +++ b/tournaments/templates/tournaments/bracket_match_cell.html @@ -2,6 +2,17 @@
+ +
+ {% if match.bracket_name %} + + {% endif %} + {% if not match.ended %} + + {% endif %} +
+ +
{% for team in match.teams %}
@@ -53,5 +64,14 @@ {% endfor %}
+
+
+ +
+
diff --git a/tournaments/views.py b/tournaments/views.py index 1679a84..75424ab 100644 --- a/tournaments/views.py +++ b/tournaments/views.py @@ -111,7 +111,11 @@ def index(request): thirty_days_future = now + timedelta(days=30) club_id = request.GET.get('club') - tournaments = tournaments_query(Q(end_date__isnull=True, start_date__gte=thirty_days_ago, start_date__lte=thirty_days_future), club_id, True, 50) + if club_id: + tournaments = tournaments_query(Q(end_date__isnull=True), club_id, True, 50) + else: + tournaments = tournaments_query(Q(end_date__isnull=True, start_date__gte=thirty_days_ago, start_date__lte=thirty_days_future), club_id, True, 50) + display_tournament = [t for t in tournaments if t.display_tournament()] live = [] future = []