feat(suff): UX improvements and bug fixes

- Style "Spenden" link as a compact right-aligned button
- Replace radio buttons with checkboxes for Gratis/Direkt-bezahlt (toggleable)
- Remove "Sonstiges" from payment method dropdown
- Disable submit buttons on form submit to prevent double-clicks and give loading feedback (fixes drink_id=None bug caused by disabled button value not being submitted)
- Block weak PINs (sequential and repeated digits)
- Limit usernames to 2–20 characters
- Style PIN errors consistently with other error messages
- Add self-service "PIN ändern" page, shown above Logout in 2-column layout
- Highlight own username in orange badge (matching staff-target cyan style)
- Update booking window: 2026-05-30 10:00 – 2026-06-14 22:00

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-31 17:37:30 +02:00
parent e0e2b08eef
commit 23b24ed0b9
7 changed files with 180 additions and 24 deletions
+60 -1
View File
@@ -140,6 +140,25 @@ button {
transition: transform 80ms ease, background-color 100ms ease;
}
a.btn-primary {
display: block;
width: fit-content;
margin-left: auto;
background-color: #EE9933;
color: #161616;
font-size: 1rem;
font-weight: bold;
padding: 10px 18px;
border-radius: 6px;
cursor: pointer;
text-decoration: none;
transition: background-color 100ms ease;
}
a.btn-primary:hover, a.btn-primary:focus {
background-color: #FFCC77;
}
button:active {
transform: scale(0.96);
}
@@ -480,6 +499,14 @@ section {
border: 1px solid rgba(102, 221, 238, 0.4);
}
.self-username {
color: #EE9933;
background-color: rgba(238, 153, 51, 0.12);
padding: 0 8px;
border-radius: 4px;
border: 1px solid rgba(238, 153, 51, 0.4);
}
.confirm-actions > button,
.confirm-actions > a {
flex: 1;
@@ -520,10 +547,42 @@ section {
color: #FFCC77;
}
.logout-form {
.bottom-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 24px;
}
.bottom-actions form {
margin: 0;
}
.bottom-actions button {
width: 100%;
}
.btn-secondary-link {
display: flex;
align-items: center;
justify-content: center;
padding: 14px 20px;
font-size: 1.1rem;
font-weight: bold;
border-radius: 6px;
border: 2px solid #885522;
color: #885522;
text-decoration: none;
text-align: center;
min-height: 52px;
box-sizing: border-box;
}
.btn-secondary-link:hover, .btn-secondary-link:focus {
background-color: rgba(80, 40, 10, 0.4);
color: #FFCC77;
}
.toast {
background-color: rgba(238, 153, 51, 0.15);
border: 2px solid #EE9933;
+52 -15
View File
@@ -22,11 +22,9 @@ User = get_user_model()
ANONYMOUS_USERNAME = "anonym"
BERLIN = ZoneInfo("Europe/Berlin")
# TEST WINDOW (2026-05-15 2026-05-31): enabled early for pre-festival testing.
# Original festival phase: BOOKING_START = 2026-06-11, BOOKING_END = 2026-06-14.
# Switch back to original dates before the festival.
BOOKING_START = datetime(2026, 5, 15, 0, 0, 0, tzinfo=BERLIN)
BOOKING_END = datetime(2026, 5, 31, 23, 59, 59, tzinfo=BERLIN)
# Festival window: 2026-05-30 10:00 2026-06-14 22:00 Berlin time.
BOOKING_START = datetime(2026, 5, 30, 10, 0, 0, tzinfo=BERLIN)
BOOKING_END = datetime(2026, 6, 14, 22, 0, 0, tzinfo=BERLIN)
DAY_BY_WEEKDAY = {3: 1, 4: 2, 5: 3, 6: 4}
DAY_CUTOFF_HOUR = 6
@@ -67,7 +65,13 @@ def _current_festival_day():
def _normalize_name(raw):
return slugify(raw or "")[:150]
return slugify(raw or "")[:20]
def _username_error(username):
if len(username) < 2:
return "Name muss mindestens 2 Zeichen lang sein."
return None
def _user_has_activity(user):
@@ -78,6 +82,17 @@ def _is_anonymous(user):
return user.username == ANONYMOUS_USERNAME
_WEAK_PINS = {"123", "234", "345", "456", "567", "678", "789", "987", "876", "765", "654", "543", "432", "321"} | {d * 3 for d in "0123456789"}
def _pin_error(pin):
if not (pin.isdigit() and len(pin) == 3):
return "PIN muss aus genau 3 Ziffern bestehen."
if pin in _WEAK_PINS:
return "Diese PIN ist zu einfach. Bitte eine andere wählen."
return None
def _staff_required(view):
def wrapped(request, *args, **kwargs):
if not request.user.is_authenticated:
@@ -126,7 +141,7 @@ def _tab_context(user):
"paid": paid,
"open_balance": total - paid,
"user_payments": payments,
"payment_methods": UserPayment.Method.choices,
"payment_methods": [(m, l) for m, l in UserPayment.Method.choices if m != "other"],
}
@@ -142,8 +157,8 @@ def name_view(request):
if request.method == "POST":
raw_name = request.POST.get("name", "")
username = _normalize_name(raw_name)
if not username:
error = "Bitte einen Namen eingeben."
if not username or (error := _username_error(username)):
error = error or "Bitte einen Namen eingeben."
else:
try:
existing = User.objects.get(username=username)
@@ -186,8 +201,8 @@ def pin_view(request):
error = None
if request.method == "POST":
pin = request.POST.get("pin", "")
if not (pin.isdigit() and len(pin) == 3):
error = "PIN muss aus genau 3 Ziffern bestehen."
if error := _pin_error(pin):
pass
elif mode == "create":
if User.objects.filter(username=username).exists():
error = "Name bereits vergeben."
@@ -242,6 +257,26 @@ def pin_view(request):
)
@login_required
@require_http_methods(["GET", "POST"])
def change_pin_view(request):
redirect, phase = _require_open(request)
if redirect:
return redirect
error = None
if request.method == "POST":
pin = request.POST.get("pin", "")
if error := _pin_error(pin):
pass
else:
request.user.set_pin(pin)
request.user.save()
return HttpResponseRedirect(reverse("suff:me"))
return render(request, "suff/change_pin.html", {"phase": phase, "error": error})
@login_required
@require_http_methods(["GET"])
def me_view(request):
@@ -447,10 +482,12 @@ def staff_register_view(request):
if not username:
error = "Bitte einen Namen eingeben."
elif error := _username_error(username):
pass
elif User.objects.filter(username=username).exists():
error = "Name bereits vergeben."
elif pin and not (pin.isdigit() and len(pin) == 3):
error = "PIN muss aus genau 3 Ziffern bestehen (oder leer lassen)."
elif pin and (error := _pin_error(pin)):
pass
else:
user = User(username=username)
if pin:
@@ -484,8 +521,8 @@ def staff_pin_reset_view(request, username):
error = None
if request.method == "POST":
pin = (request.POST.get("pin") or "").strip()
if not (pin.isdigit() and len(pin) == 3):
error = "PIN muss aus genau 3 Ziffern bestehen."
if error := _pin_error(pin):
pass
else:
target.set_pin(pin)
target.save()
+2
View File
@@ -2,6 +2,7 @@ from django.urls import path
from gaehsnitz.suff import (
book_view,
change_pin_view,
closed_view,
dashboard_view,
delete_consumption_view,
@@ -28,6 +29,7 @@ urlpatterns = [
path("closed/", closed_view, name="closed"),
path("pin/", pin_view, name="pin"),
path("me/", me_view, name="me"),
path("me/change-pin/", change_pin_view, name="change_pin"),
path("book/", book_view, name="book"),
path("book/<int:consumption_id>/delete/", delete_consumption_view, name="delete_consumption"),
path("pay/", pay_view, name="pay"),
+25
View File
@@ -14,5 +14,30 @@
<h1>Suff</h1>
{% block content %}{% endblock %}
</main>
<script>
// Track last clicked submit button so its value survives being disabled on submit.
document.addEventListener('click', function(e) {
var btn = e.target.closest('button[type="submit"], button:not([type])');
if (!btn || !btn.form) return;
var form = btn.form;
var existing = form.querySelector('input[data-submitter]');
if (existing) existing.remove();
if (btn.name) {
var hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = btn.name;
hidden.value = btn.value;
hidden.setAttribute('data-submitter', '1');
form.appendChild(hidden);
}
});
document.addEventListener('submit', function(e) {
var form = e.target;
form.querySelectorAll('button[type="submit"], button:not([type])').forEach(function(btn) {
btn.disabled = true;
btn.style.opacity = '0.5';
});
});
</script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>PIN ändern</h2>
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post" action="{% url 'suff:change_pin' %}">
{% csrf_token %}
<label>
Neue PIN:
<input type="text" name="pin" inputmode="numeric" pattern="[0-9]{3}" maxlength="3"
minlength="3" required autofocus autocomplete="off" />
</label>
<button type="submit">PIN speichern</button>
</form>
<p><a href="{% url 'suff:me' %}">Abbrechen</a></p>
{% endblock %}
+21 -7
View File
@@ -1,7 +1,7 @@
{% extends "suff/base.html" %}
{% block content %}
<h2>Hallo {{ tab_user.username }}</h2>
<h2>Hallo <span class="self-username">{{ tab_user.username }}</span></h2>
<p class="intro">
Tipp dich rein, sobald du was trinkst. Am Ende deines Besuchs kannst du
@@ -55,13 +55,24 @@
<form method="post" action="{% url 'suff:book' %}">
{% csrf_token %}
<label class="for-free-toggle">
<input type="radio" name="booking_mode" value="for_free" />
<input type="checkbox" name="booking_mode" value="for_free" />
<span>Gratis (z.B. Artists am Spieltag)</span>
</label>
<label class="for-free-toggle">
<input type="radio" name="booking_mode" value="cash_paid" />
<input type="checkbox" name="booking_mode" value="cash_paid" />
<span>Direkt bar bezahlt</span>
</label>
<script>
document.querySelectorAll('input[name="booking_mode"]').forEach(function(cb) {
cb.addEventListener('change', function() {
if (this.checked) {
document.querySelectorAll('input[name="booking_mode"]').forEach(function(other) {
if (other !== cb) other.checked = false;
});
}
});
});
</script>
<div class="drink-grid">
{% for drink in drinks %}
<button type="submit" name="drink_id" value="{{ drink.id }}" class="drink-btn drink-btn-{{ drink.category }}">
@@ -113,8 +124,11 @@
</div>
{% endif %}
<form method="post" action="{% url 'suff:logout' %}" class="logout-form">
{% csrf_token %}
<button type="submit" class="btn-secondary">Logout</button>
</form>
<div class="bottom-actions">
<a href="{% url 'suff:change_pin' %}" class="btn-secondary btn-secondary-link">PIN ändern</a>
<form method="post" action="{% url 'suff:logout' %}">
{% csrf_token %}
<button type="submit" class="btn-secondary">Logout</button>
</form>
</div>
{% endblock %}
+1 -1
View File
@@ -13,7 +13,7 @@
<p>Gib deine 3-stellige PIN ein.</p>
{% endif %}
{% if error %}<p><b>{{ error }}</b></p>{% endif %}
{% if error %}<p class="error">{{ error }}</p>{% endif %}
<form method="post" action="{% url 'suff:pin' %}">
{% csrf_token %}