From 50c880719a0bc723b07dc7fa1bccc031ff97438b Mon Sep 17 00:00:00 2001 From: imperosol Date: Sun, 17 May 2026 10:25:04 +0200 Subject: [PATCH 01/43] feat: basket timeout --- eboutic/api.py | 11 +++- eboutic/models.py | 20 +++++++ .../static/bundled/eboutic/checkout-index.ts | 60 +++++++++++++++++-- .../eboutic/eboutic_billing_info.jinja | 1 + .../templates/eboutic/eboutic_checkout.jinja | 26 +++++--- eboutic/tests/test_payment.py | 28 +++++++-- eboutic/views.py | 27 +++++++-- locale/fr/LC_MESSAGES/django.po | 11 +++- locale/fr/LC_MESSAGES/djangojs.po | 6 +- sith/settings.py | 5 ++ 10 files changed, 168 insertions(+), 27 deletions(-) diff --git a/eboutic/api.py b/eboutic/api.py index c44a8cc9..f1c6ccc2 100644 --- a/eboutic/api.py +++ b/eboutic/api.py @@ -1,3 +1,6 @@ +from typing import Any + +from ninja import Status from ninja_extra import ControllerBase, api_controller, route from ninja_extra.exceptions import NotFound @@ -8,13 +11,19 @@ from eboutic.models import Basket @api_controller("/etransaction", permissions=[CanView]) class EtransactionInfoController(ControllerBase): - @route.get("/data/{basket_id}", url_name="etransaction_data") + @route.get( + "/data/{basket_id}", + url_name="etransaction_data", + response={200: dict[str, Any], 410: str}, + ) def fetch_etransaction_data(self, basket_id: int): """Generate the data to pay an eboutic command with paybox. The data is generated with the basket that is used by the current session. """ basket: Basket = self.get_object_or_exception(Basket, pk=basket_id) + if basket.is_expired: + return Status(410, "This basket is expired.") try: return dict(basket.get_e_transaction_data()) except BillingInfo.DoesNotExist as e: diff --git a/eboutic/models.py b/eboutic/models.py index cf6e15ab..af029ae1 100644 --- a/eboutic/models.py +++ b/eboutic/models.py @@ -24,6 +24,7 @@ from django.conf import settings from django.db import DataError, models from django.db.models import F, OuterRef, Subquery, Sum from django.utils.functional import cached_property +from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from core.models import User @@ -95,6 +96,10 @@ class Basket(models.Model): ] ) + @property + def is_expired(self) -> bool: + return (self.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT) <= now() + def generate_sales( self, counter, seller: User, payment_method: Selling.PaymentMethod ): @@ -133,9 +138,20 @@ class Basket(models.Model): ] def get_e_transaction_data(self) -> list[tuple[str, str]]: + """Get data for etransaction payment. + + Raises: + Customer.DoesNotExist: if the user linked to this basket + has no customer account + BillingInfo.DoesNotExist: if the user linked to this basket has no + billing infos, or incorrect billing infos. + ValueError: if this is called on a basket which payment delay is expired. + """ user = self.user if not hasattr(user, "customer"): raise Customer.DoesNotExist + if self.is_expired: + raise ValueError("This method cannot be called on an expired basket.") customer = user.customer if ( not hasattr(user.customer, "billing_infos") @@ -155,6 +171,10 @@ class Basket(models.Model): ("PBX_IDENTIFIANT", settings.SITH_EBOUTIC_PBX_IDENTIFIANT), ("PBX_TOTAL", str(int(self.total * 100))), ("PBX_DEVISE", "978"), # This is Euro + ( + "PBX_DISPLAY", + str(int(settings.SITH_EBOUTIC_ETRANSACTION_TIMEOUT.total_seconds())), + ), ("PBX_CMD", str(self.id)), ("PBX_PORTEUR", user.email), ("PBX_RETOUR", "Amount:M;BasketID:R;Auto:A;Error:E;Sig:K"), diff --git a/eboutic/static/bundled/eboutic/checkout-index.ts b/eboutic/static/bundled/eboutic/checkout-index.ts index cb4be7f0..08683218 100644 --- a/eboutic/static/bundled/eboutic/checkout-index.ts +++ b/eboutic/static/bundled/eboutic/checkout-index.ts @@ -1,21 +1,71 @@ +import { type Notification, NotificationLevel } from "#core:utils/notifications"; import { etransactioninfoFetchEtransactionData } from "#openapi"; +interface Basket { + id: number; + timeout: Date; +} document.addEventListener("alpine:init", () => { - Alpine.data("etransaction", (initialData, basketId: number) => ({ + Alpine.data("etransaction", (initialData, basket: Basket) => ({ data: initialData, isCbAvailable: Object.keys(initialData).length > 0, + isSithAvailable: true, + init() { + const now = new Date(); + const timeout = basket.timeout.getTime() - now.getTime(); + if (timeout <= 0) { + // basket was already outdated at initial page load + this.timeoutBasket(); + } else { + setTimeout(() => this.timeoutBasket(), timeout); + } + }, + + /** + * Make this basket into a timeout state. + * All submission inputs are disabled, and an error message is displayed. + */ + timeoutBasket() { + this.isCbAvailable = false; + this.isSithAvailable = false; + const message = gettext("Basket expired"); + + const existingNotif: Notification | undefined = this.$notifications + .getAll() + .find( + (n: Notification) => + n.tag === NotificationLevel.Error && n.message === message, + ); + if (existingNotif === undefined) { + this.$notifications.error(message); + } + }, + + /** + * Refresh the data used for etransaction. + * + * Note: if this is called while the basket is expired, it will be a no-op + */ async fill() { + if (new Date() > basket.timeout) { + // refresh etransaction data only if the basket is still valid. + this.timeoutBasket(); + return; + } this.isCbAvailable = false; const res = await etransactioninfoFetchEtransactionData({ - path: { - // biome-ignore lint/style/useNamingConvention: api is in snake_case - basket_id: basketId, - }, + // biome-ignore lint/style/useNamingConvention: api is in snake_case + path: { basket_id: basket.id }, }); if (res.response.ok) { this.data = res.data; this.isCbAvailable = true; + } else if (res.response.status === 410) { + // The basket is expired, so no payment method should be available at all. + // This shouldn't happen, because we don't send the request + // when the timeout is passed, but we are better safe than sorry + this.timeoutBasket(); } }, })); diff --git a/eboutic/templates/eboutic/eboutic_billing_info.jinja b/eboutic/templates/eboutic/eboutic_billing_info.jinja index 57ea97ed..df8152ef 100644 --- a/eboutic/templates/eboutic/eboutic_billing_info.jinja +++ b/eboutic/templates/eboutic/eboutic_billing_info.jinja @@ -21,6 +21,7 @@ hx-swap="outerHTML" hx-target="#billing-infos-fragment" x-show="collapsed" + x-cloak > {% csrf_token %} {{ form.as_p() }} diff --git a/eboutic/templates/eboutic/eboutic_checkout.jinja b/eboutic/templates/eboutic/eboutic_checkout.jinja index 369c5d44..03707469 100644 --- a/eboutic/templates/eboutic/eboutic_checkout.jinja +++ b/eboutic/templates/eboutic/eboutic_checkout.jinja @@ -15,11 +15,10 @@ {% block content %}

{% trans %}Eboutic{% endtrans %}

- - -
+

{% trans %}Basket: {% endtrans %}

@@ -72,7 +71,11 @@ x-cloak type="submit" id="bank-submit-button" - :disabled="!isCbAvailable" + {% if basket.is_expired %} + disabled="disabled" + {% else %} + :disabled="!isCbAvailable" + {% endif %} class="btn btn-blue" value="{% trans %}Pay with credit card{% endtrans %}" /> @@ -93,7 +96,16 @@ {% else %} {% csrf_token %} - + {% endif %} diff --git a/eboutic/tests/test_payment.py b/eboutic/tests/test_payment.py index 5c4b1da1..0a568015 100644 --- a/eboutic/tests/test_payment.py +++ b/eboutic/tests/test_payment.py @@ -3,6 +3,7 @@ import urllib from decimal import Decimal from typing import TYPE_CHECKING +import freezegun from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey from cryptography.hazmat.primitives.hashes import SHA1 @@ -105,7 +106,7 @@ class TestPaymentSith(TestPaymentBase): ), reverse("eboutic:payment_result", kwargs={"result": "success"}), ) - assert Basket.objects.filter(id=self.basket.id).first() is None + assert not Basket.objects.filter(id=self.basket.id).exists() self.customer.customer.refresh_from_db() assert self.customer.customer.amount == Decimal(1) @@ -139,10 +140,7 @@ class TestPaymentSith(TestPaymentBase): assert len(messages) == 1 assert messages[0].level == DEFAULT_LEVELS["ERROR"] assert messages[0].message == "Solde insuffisant" - - assert Basket.objects.contains(self.basket), ( - "After an unsuccessful request, the basket should be kept" - ) + assert not Basket.objects.filter(id=self.basket.id).exists() def test_refilling_in_basket(self): BasketItem.from_price(self.refilling.prices.first(), 1, self.basket).save() @@ -157,7 +155,7 @@ class TestPaymentSith(TestPaymentBase): response, reverse("eboutic:payment_result", kwargs={"result": "failure"}), ) - assert Basket.objects.filter(id=self.basket.id).first() is not None + assert not Basket.objects.filter(id=self.basket.id).exists() messages = list(get_messages(response.wsgi_request)) assert messages[0].level == DEFAULT_LEVELS["ERROR"] assert ( @@ -167,6 +165,24 @@ class TestPaymentSith(TestPaymentBase): self.customer.customer.refresh_from_db() assert self.customer.customer.amount == initial_account_balance + def test_basket_expired(self): + self.client.force_login(self.customer) + initial_account_balance = self.customer.customer.amount + with freezegun.freeze_time(settings.SITH_EBOUTIC_BASKET_TIMEOUT): + response = self.client.post( + reverse("eboutic:pay_with_sith", kwargs={"basket_id": self.basket.id}) + ) + assertRedirects( + response, + reverse("eboutic:payment_result", kwargs={"result": "failure"}), + ) + messages = list(get_messages(response.wsgi_request)) + assert messages[0].level == DEFAULT_LEVELS["ERROR"] + assert messages[0].message == "Panier expiré" + assert not Basket.objects.filter(id=self.basket.id).exists() + self.customer.customer.refresh_from_db() + assert self.customer.customer.amount == initial_account_balance + class TestPaymentCard(TestPaymentBase): def generate_bank_valid_answer(self, basket: Basket): diff --git a/eboutic/views.py b/eboutic/views.py index 82704be3..c892a104 100644 --- a/eboutic/views.py +++ b/eboutic/views.py @@ -39,6 +39,8 @@ from django.db.utils import cached_property from django.http import HttpResponse from django.shortcuts import redirect, render from django.urls import reverse +from django.utils.formats import localize +from django.utils.timezone import localtime from django.utils.translation import gettext_lazy as _ from django.views.decorators.http import require_GET from django.views.generic import DetailView, FormView, TemplateView, UpdateView, View @@ -187,9 +189,7 @@ class BillingInfoFormFragment( def get_initial(self): if self.object is None: - return { - "country": Country(code="FR"), - } + return {"country": Country(code="FR")} return {} def render_fragment(self, request, **kwargs) -> SafeString: @@ -255,10 +255,19 @@ class EbouticCheckout(CanViewMixin, UseFragmentsMixin, DetailView): kwargs["customer_amount"] = None kwargs["billing_infos"] = {} - with contextlib.suppress(BillingInfo.DoesNotExist): - kwargs["billing_infos"] = json.dumps( - dict(self.object.get_e_transaction_data()) + if self.object.is_expired: + messages.error(self.request, _("Basket expired")) + else: + timeout = self.object.date + settings.SITH_EBOUTIC_BASKET_TIMEOUT + messages.warning( + self.request, + _("Basket available until %(until)s") + % {"until": localize(localtime(timeout).time())}, ) + with contextlib.suppress(BillingInfo.DoesNotExist): + kwargs["billing_infos"] = json.dumps( + dict(self.object.get_e_transaction_data()) + ) return kwargs @@ -268,9 +277,14 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View): def post(self, request, *args, **kwargs): basket = self.get_object() + if basket.is_expired: + messages.error(self.request, _("Basket expired")) + basket.delete() + return redirect("eboutic:payment_result", "failure") refilling = settings.SITH_COUNTER_PRODUCTTYPE_REFILLING if basket.items.filter(product__product_type_id=refilling).exists(): messages.error(self.request, _("You can't buy a refilling with sith money")) + basket.delete() return redirect("eboutic:payment_result", "failure") eboutic = get_eboutic() @@ -288,6 +302,7 @@ class EbouticPayWithSith(CanViewMixin, SingleObjectMixin, View): except DatabaseError as e: sentry_sdk.capture_exception(e) except ValidationError as e: + basket.delete() messages.error(self.request, e.message) return redirect("eboutic:payment_result", "failure") diff --git a/locale/fr/LC_MESSAGES/django.po b/locale/fr/LC_MESSAGES/django.po index 28f562b1..0c390058 100644 --- a/locale/fr/LC_MESSAGES/django.po +++ b/locale/fr/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-05-12 11:12+0200\n" +"POT-Creation-Date: 2026-05-15 11:46+0200\n" "PO-Revision-Date: 2016-07-18\n" "Last-Translator: Maréchal \n" @@ -4505,6 +4505,15 @@ msgstr "" "souhaitez payer par carte, vous devez rajouter un numéro de téléphone aux " "données que vous aviez déjà fourni." +#: eboutic/views.py +msgid "Basket expired" +msgstr "Panier expiré" + +#: eboutic/views.py +#, python-format +msgid "Basket available until %(until)s" +msgstr "Panier disponible jusqu'à %(until)s" + #: eboutic/views.py msgid "You can't buy a refilling with sith money" msgstr "Vous ne pouvez pas acheter un rechargement avec de l'argent du sith" diff --git a/locale/fr/LC_MESSAGES/djangojs.po b/locale/fr/LC_MESSAGES/djangojs.po index 78e15202..33d24897 100644 --- a/locale/fr/LC_MESSAGES/djangojs.po +++ b/locale/fr/LC_MESSAGES/djangojs.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-04-17 22:42+0200\n" +"POT-Creation-Date: 2026-05-17 10:03+0200\n" "PO-Revision-Date: 2024-09-17 11:54+0200\n" "Last-Translator: Sli \n" "Language-Team: AE info \n" @@ -263,6 +263,10 @@ msgstr "Types de produits réordonnés !" msgid "Product type reorganisation failed with status code : %d" msgstr "La réorganisation des types de produit a échoué avec le code : %d" +#: eboutic/static/bundled/eboutic/checkout-index.ts +msgid "Basket expired" +msgstr "Panier expiré" + #: sas/static/bundled/sas/pictures-download-index.ts msgid "pictures.%(extension)s" msgstr "photos.%(extension)s" diff --git a/sith/settings.py b/sith/settings.py index 9968055f..bf89dbda 100644 --- a/sith/settings.py +++ b/sith/settings.py @@ -571,6 +571,11 @@ SITH_BARMAN_TIMEOUT = 30 # Minutes to delete the last operations SITH_LAST_OPERATIONS_LIMIT = 10 +# time before a basket is considered expired +SITH_EBOUTIC_BASKET_TIMEOUT = timedelta(minutes=10) +# time that a user can spend on the CB payment page before it to timeout +SITH_EBOUTIC_ETRANSACTION_TIMEOUT = timedelta(minutes=10) + # ET variables SITH_EBOUTIC_CB_ENABLED = env.bool("SITH_EBOUTIC_CB_ENABLED", default=True) SITH_EBOUTIC_ET_URL = env.str( From 649190debef80801343526d18bb973902659ac31 Mon Sep 17 00:00:00 2001 From: imperosol Date: Tue, 26 May 2026 23:46:38 +0200 Subject: [PATCH 02/43] fix: wrong payment method for refills with eboutic --- eboutic/models.py | 13 ++++++------- eboutic/tests/test_payment.py | 6 +++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/eboutic/models.py b/eboutic/models.py index cf6e15ab..afa3b035 100644 --- a/eboutic/models.py +++ b/eboutic/models.py @@ -219,16 +219,14 @@ class Invoice(models.Model): if self.validated: raise DataError(_("Invoice already validated")) customer, _created = Customer.get_or_create(user=self.user) - kwargs = { - "counter": get_eboutic(), - "customer": customer, - "date": self.date, - "payment_method": Selling.PaymentMethod.CARD, - } + kwargs = {"counter": get_eboutic(), "customer": customer, "date": self.date} for i in self.items.select_related("product"): if i.product.product_type_id == settings.SITH_COUNTER_PRODUCTTYPE_REFILLING: Refilling.objects.create( - **kwargs, operator=self.user, amount=i.unit_price * i.quantity + **kwargs, + operator=self.user, + amount=i.unit_price * i.quantity, + payment_method=Refilling.PaymentMethod.CARD, ) else: Selling.objects.create( @@ -239,6 +237,7 @@ class Invoice(models.Model): seller=self.user, unit_price=i.unit_price, quantity=i.quantity, + payment_method=Selling.PaymentMethod.CARD, ) self.validated = True self.save() diff --git a/eboutic/tests/test_payment.py b/eboutic/tests/test_payment.py index 5c4b1da1..b59c05bb 100644 --- a/eboutic/tests/test_payment.py +++ b/eboutic/tests/test_payment.py @@ -17,7 +17,7 @@ from pytest_django.asserts import assertRedirects from core.baker_recipes import old_subscriber_user, subscriber_user from counter.baker_recipes import price_recipe, product_recipe -from counter.models import Product, ProductType, Selling +from counter.models import Product, ProductType, Refilling, Selling from counter.tests.test_counter import force_refill_user from eboutic.models import Basket, BasketItem @@ -236,6 +236,10 @@ class TestPaymentCard(TestPaymentBase): self.customer.customer.refresh_from_db() assert self.customer.customer.amount == price.amount * 2 + refill = self.customer.customer.refillings.last() + assert refill is not None + assert refill.amount == price.amount * 2 + assert refill.payment_method == Refilling.PaymentMethod.CARD def test_multiple_responses(self): bank_response = self.generate_bank_valid_answer(self.basket) From 3b3e33ed80e3747227fb567f702dddfed940ac7e Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 27 May 2026 12:24:27 +0200 Subject: [PATCH 03/43] fix: forgotten group assignation on club role update --- club/forms.py | 24 ++++++++++++++++++++++++ club/tests/test_clubrole.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/club/forms.py b/club/forms.py index 7c524f56..adbc9ba3 100644 --- a/club/forms.py +++ b/club/forms.py @@ -392,6 +392,30 @@ class ClubRoleForm(forms.ModelForm): self.instance.order = cleaned_data["ORDER"] - 1 return cleaned_data + def save(self, commit=True): # noqa: FBT002 + instance: ClubRole = super().save(commit=commit) + if commit and "is_board" in self.changed_data: + # if the role was moved from board to simple member, + # remove all users with that role from the club board group. + # If the role became a board role, add users with + # that role to the club board group. + group_id = instance.club.board_group_id + if self.cleaned_data["is_board"]: + User.groups.through.objects.bulk_create( + [ + User.groups.through(user_id=u, group_id=group_id) + for u in Membership.objects.ongoing() + .filter(role=instance) + .values_list("user_id", flat=True) + ], + ignore_conflicts=True, + ) + else: + User.groups.through.objects.filter( + user__memberships__role=instance, group_id=group_id + ).delete() + return instance + class ClubRoleCreateForm(forms.ModelForm): """Form to create a club role. diff --git a/club/tests/test_clubrole.py b/club/tests/test_clubrole.py index 0173c4d6..f6d61e5b 100644 --- a/club/tests/test_clubrole.py +++ b/club/tests/test_clubrole.py @@ -4,6 +4,7 @@ import pytest from django.contrib.auth.models import Permission from django.test import Client, TestCase from django.urls import reverse +from django.utils.timezone import now from model_bakery import baker, seq from model_bakery.recipe import Recipe from pytest_django.asserts import assertRedirects @@ -239,7 +240,7 @@ class TestClubRoleUpdate(TestCase): def test_president_moves_itself_out_of_the_presidency(self): """Test that if the user moves its own role out of the presidency, - then it's redirected to another page and loses access to the update page.""" + then it loses access to the update page.""" self.payload["roles-0-is_presidency"] = False self.client.force_login(self.user) res = self.client.post(self.url, data=self.payload) @@ -251,3 +252,29 @@ class TestClubRoleUpdate(TestCase): res = self.client.get(self.url) assert res.status_code == 403 + + def test_role_stops_being_board(self): + """Test that if a role stops being a board role, + its users lose the club board group.""" + self.payload["roles-0-is_board"] = False + self.payload["roles-0-is_presidency"] = False + self.payload["roles-1-is_board"] = False + formset = ClubRoleFormSet(data=self.payload, instance=self.club) + assert formset.is_valid() + formset.save() + assert not self.user.groups.contains(self.club.board_group) + + def test_role_becomes_board(self): + """Test that if a role becomes a board role, + its active users get the club board group""" + members = [ + baker.make(Membership, club=self.club, role=self.roles[0], end_date=None), + baker.make(Membership, club=self.club, role=self.roles[0], end_date=now()), + ] + self.payload["roles-2-is_board"] = True + formset = ClubRoleFormSet(data=self.payload, instance=self.club) + assert formset.is_valid() + formset.save() + # the second membership is finished, so its user shouldn't get the role + assert members[0].user.groups.contains(self.club.board_group) + assert not members[1].user.groups.contains(self.club.board_group) From 362b9eea0672d0fcdc9c6e77daab97b5bc639557 Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 27 May 2026 13:00:26 +0200 Subject: [PATCH 04/43] automatically add item to basket on counter product search --- .../components/counter-product-select-index.ts | 15 +++------------ .../static/bundled/counter/counter-click-index.ts | 4 ++++ counter/templates/counter/counter_click.jinja | 13 ++++++++----- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/counter/static/bundled/counter/components/counter-product-select-index.ts b/counter/static/bundled/counter/components/counter-product-select-index.ts index ec1d2773..d4e96a81 100644 --- a/counter/static/bundled/counter/components/counter-product-select-index.ts +++ b/counter/static/bundled/counter/components/counter-product-select-index.ts @@ -1,6 +1,6 @@ -import type { RecursivePartial, TomSettings } from "tom-select/dist/types/types"; -import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base.ts"; -import { registerComponent } from "#core:utils/web-components.ts"; +import type { RecursivePartial, TomSettings } from "tom-select/src/types"; +import { AutoCompleteSelectBase } from "#core:core/components/ajax-select-base"; +import { registerComponent } from "#core:utils/web-components"; const productParsingRegex = /^(\d+x)?(.*)/i; const codeParsingRegex = / \((\w+)\)$/; @@ -63,13 +63,6 @@ export class CounterProductSelect extends AutoCompleteSelectBase { ); }, ); - - this.widget.hook("after", "onOptionSelect", () => { - /* Focus the next element if it's an input */ - if (this.nextElementSibling.nodeName === "INPUT") { - (this.nextElementSibling as HTMLInputElement).focus(); - } - }); } protected tomSelectSettings(): RecursivePartial { /* We disable the dropdown on focus because we're going to always autofocus the widget */ @@ -80,9 +73,7 @@ export class CounterProductSelect extends AutoCompleteSelectBase { // We need to manually set weights or it results on an inconsistent // behavior between production and development environment searchField: [ - // @ts-expect-error documentation says it's fine, specified type is wrong { field: "code", weight: 2 }, - // @ts-expect-error documentation says it's fine, specified type is wrong { field: "text", weight: 0.5 }, ], }; diff --git a/counter/static/bundled/counter/counter-click-index.ts b/counter/static/bundled/counter/counter-click-index.ts index c8cea6a3..7228f067 100644 --- a/counter/static/bundled/counter/counter-click-index.ts +++ b/counter/static/bundled/counter/counter-click-index.ts @@ -25,6 +25,9 @@ document.addEventListener("alpine:init", () => { } this.codeField = this.$refs.codeField; + this.codeField.widget.hook("after", "onOptionSelect", () => { + this.handleCode(); + }); this.codeField.widget.focus(); // It's quite tricky to manually apply attributes to the management part @@ -154,6 +157,7 @@ document.addEventListener("alpine:init", () => { this.addToBasket(code, quantity); } this.codeField.widget.clear(); + this.codeField.widget.setTextboxValue(""); this.codeField.widget.focus(); }, })); diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index 44220599..c92c8f9f 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -56,10 +56,15 @@
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %} -
+ - + @@ -73,8 +78,6 @@ {%- endfor -%} - - {% for error in form.non_form_errors() %} From ebf0196bef04949b7d2d32115fb677b495ac9795 Mon Sep 17 00:00:00 2001 From: imperosol Date: Wed, 27 May 2026 18:20:53 +0200 Subject: [PATCH 05/43] improve counter basket item style --- core/static/core/accordion.scss | 4 +++ counter/static/counter/css/counter-click.scss | 23 ++++++++++++- counter/templates/counter/counter_click.jinja | 32 +++++++++++-------- 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/core/static/core/accordion.scss b/core/static/core/accordion.scss index 28a7f75b..23bb2fa6 100644 --- a/core/static/core/accordion.scss +++ b/core/static/core/accordion.scss @@ -46,6 +46,10 @@ details.accordion>.accordion-content { border-bottom-right-radius: 3px; border-bottom-left-radius: 3px; overflow: hidden; + + @media screen and (max-width: 600px) { + padding: .75em 1.5em; + } } @mixin animation($selector) { diff --git a/counter/static/counter/css/counter-click.scss b/counter/static/counter/css/counter-click.scss index 6288f902..00f882bf 100644 --- a/counter/static/counter/css/counter-click.scss +++ b/counter/static/counter/css/counter-click.scss @@ -42,7 +42,28 @@ min-width: 350px; ul { - list-style-type: none; + list-style: none; + display: flex; + flex-direction: column; + gap: .5rem; + margin-left: 0; + + .basket-row { + display: flex; + align-items: center; + gap: 1rem; + + .product-name { + flex: 1 2 0; + min-width: 0; + text-wrap: wrap; + } + } + } + + form { + margin-top: .5rem; + margin-bottom: .5rem; } } diff --git a/counter/templates/counter/counter_click.jinja b/counter/templates/counter/counter_click.jinja index c92c8f9f..cca00cda 100644 --- a/counter/templates/counter/counter_click.jinja +++ b/counter/templates/counter/counter_click.jinja @@ -56,7 +56,7 @@
{% set counter_click_url = url('counter:click', counter_id=counter.id, user_id=customer.user_id) %} -
+
    -
  • {% trans %}This basket is empty{% endtrans %}
  • +
  • + {% trans %}This basket is empty{% endtrans %} +