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:
@@ -140,6 +140,25 @@ button {
|
|||||||
transition: transform 80ms ease, background-color 100ms ease;
|
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 {
|
button:active {
|
||||||
transform: scale(0.96);
|
transform: scale(0.96);
|
||||||
}
|
}
|
||||||
@@ -480,6 +499,14 @@ section {
|
|||||||
border: 1px solid rgba(102, 221, 238, 0.4);
|
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 > button,
|
||||||
.confirm-actions > a {
|
.confirm-actions > a {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -520,10 +547,42 @@ section {
|
|||||||
color: #FFCC77;
|
color: #FFCC77;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout-form {
|
.bottom-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
margin-top: 24px;
|
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 {
|
.toast {
|
||||||
background-color: rgba(238, 153, 51, 0.15);
|
background-color: rgba(238, 153, 51, 0.15);
|
||||||
border: 2px solid #EE9933;
|
border: 2px solid #EE9933;
|
||||||
|
|||||||
+52
-15
@@ -22,11 +22,9 @@ User = get_user_model()
|
|||||||
ANONYMOUS_USERNAME = "anonym"
|
ANONYMOUS_USERNAME = "anonym"
|
||||||
|
|
||||||
BERLIN = ZoneInfo("Europe/Berlin")
|
BERLIN = ZoneInfo("Europe/Berlin")
|
||||||
# TEST WINDOW (2026-05-15 – 2026-05-31): enabled early for pre-festival testing.
|
# Festival window: 2026-05-30 10:00 – 2026-06-14 22:00 Berlin time.
|
||||||
# Original festival phase: BOOKING_START = 2026-06-11, BOOKING_END = 2026-06-14.
|
BOOKING_START = datetime(2026, 5, 30, 10, 0, 0, tzinfo=BERLIN)
|
||||||
# Switch back to original dates before the festival.
|
BOOKING_END = datetime(2026, 6, 14, 22, 0, 0, tzinfo=BERLIN)
|
||||||
BOOKING_START = datetime(2026, 5, 15, 0, 0, 0, tzinfo=BERLIN)
|
|
||||||
BOOKING_END = datetime(2026, 5, 31, 23, 59, 59, tzinfo=BERLIN)
|
|
||||||
|
|
||||||
DAY_BY_WEEKDAY = {3: 1, 4: 2, 5: 3, 6: 4}
|
DAY_BY_WEEKDAY = {3: 1, 4: 2, 5: 3, 6: 4}
|
||||||
DAY_CUTOFF_HOUR = 6
|
DAY_CUTOFF_HOUR = 6
|
||||||
@@ -67,7 +65,13 @@ def _current_festival_day():
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_name(raw):
|
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):
|
def _user_has_activity(user):
|
||||||
@@ -78,6 +82,17 @@ def _is_anonymous(user):
|
|||||||
return user.username == ANONYMOUS_USERNAME
|
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 _staff_required(view):
|
||||||
def wrapped(request, *args, **kwargs):
|
def wrapped(request, *args, **kwargs):
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
@@ -126,7 +141,7 @@ def _tab_context(user):
|
|||||||
"paid": paid,
|
"paid": paid,
|
||||||
"open_balance": total - paid,
|
"open_balance": total - paid,
|
||||||
"user_payments": payments,
|
"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":
|
if request.method == "POST":
|
||||||
raw_name = request.POST.get("name", "")
|
raw_name = request.POST.get("name", "")
|
||||||
username = _normalize_name(raw_name)
|
username = _normalize_name(raw_name)
|
||||||
if not username:
|
if not username or (error := _username_error(username)):
|
||||||
error = "Bitte einen Namen eingeben."
|
error = error or "Bitte einen Namen eingeben."
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
existing = User.objects.get(username=username)
|
existing = User.objects.get(username=username)
|
||||||
@@ -186,8 +201,8 @@ def pin_view(request):
|
|||||||
error = None
|
error = None
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
pin = request.POST.get("pin", "")
|
pin = request.POST.get("pin", "")
|
||||||
if not (pin.isdigit() and len(pin) == 3):
|
if error := _pin_error(pin):
|
||||||
error = "PIN muss aus genau 3 Ziffern bestehen."
|
pass
|
||||||
elif mode == "create":
|
elif mode == "create":
|
||||||
if User.objects.filter(username=username).exists():
|
if User.objects.filter(username=username).exists():
|
||||||
error = "Name bereits vergeben."
|
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
|
@login_required
|
||||||
@require_http_methods(["GET"])
|
@require_http_methods(["GET"])
|
||||||
def me_view(request):
|
def me_view(request):
|
||||||
@@ -447,10 +482,12 @@ def staff_register_view(request):
|
|||||||
|
|
||||||
if not username:
|
if not username:
|
||||||
error = "Bitte einen Namen eingeben."
|
error = "Bitte einen Namen eingeben."
|
||||||
|
elif error := _username_error(username):
|
||||||
|
pass
|
||||||
elif User.objects.filter(username=username).exists():
|
elif User.objects.filter(username=username).exists():
|
||||||
error = "Name bereits vergeben."
|
error = "Name bereits vergeben."
|
||||||
elif pin and not (pin.isdigit() and len(pin) == 3):
|
elif pin and (error := _pin_error(pin)):
|
||||||
error = "PIN muss aus genau 3 Ziffern bestehen (oder leer lassen)."
|
pass
|
||||||
else:
|
else:
|
||||||
user = User(username=username)
|
user = User(username=username)
|
||||||
if pin:
|
if pin:
|
||||||
@@ -484,8 +521,8 @@ def staff_pin_reset_view(request, username):
|
|||||||
error = None
|
error = None
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
pin = (request.POST.get("pin") or "").strip()
|
pin = (request.POST.get("pin") or "").strip()
|
||||||
if not (pin.isdigit() and len(pin) == 3):
|
if error := _pin_error(pin):
|
||||||
error = "PIN muss aus genau 3 Ziffern bestehen."
|
pass
|
||||||
else:
|
else:
|
||||||
target.set_pin(pin)
|
target.set_pin(pin)
|
||||||
target.save()
|
target.save()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from django.urls import path
|
|||||||
|
|
||||||
from gaehsnitz.suff import (
|
from gaehsnitz.suff import (
|
||||||
book_view,
|
book_view,
|
||||||
|
change_pin_view,
|
||||||
closed_view,
|
closed_view,
|
||||||
dashboard_view,
|
dashboard_view,
|
||||||
delete_consumption_view,
|
delete_consumption_view,
|
||||||
@@ -28,6 +29,7 @@ urlpatterns = [
|
|||||||
path("closed/", closed_view, name="closed"),
|
path("closed/", closed_view, name="closed"),
|
||||||
path("pin/", pin_view, name="pin"),
|
path("pin/", pin_view, name="pin"),
|
||||||
path("me/", me_view, name="me"),
|
path("me/", me_view, name="me"),
|
||||||
|
path("me/change-pin/", change_pin_view, name="change_pin"),
|
||||||
path("book/", book_view, name="book"),
|
path("book/", book_view, name="book"),
|
||||||
path("book/<int:consumption_id>/delete/", delete_consumption_view, name="delete_consumption"),
|
path("book/<int:consumption_id>/delete/", delete_consumption_view, name="delete_consumption"),
|
||||||
path("pay/", pay_view, name="pay"),
|
path("pay/", pay_view, name="pay"),
|
||||||
|
|||||||
@@ -14,5 +14,30 @@
|
|||||||
<h1>Suff</h1>
|
<h1>Suff</h1>
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends "suff/base.html" %}
|
{% extends "suff/base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h2>Hallo {{ tab_user.username }}</h2>
|
<h2>Hallo <span class="self-username">{{ tab_user.username }}</span></h2>
|
||||||
|
|
||||||
<p class="intro">
|
<p class="intro">
|
||||||
Tipp dich rein, sobald du was trinkst. Am Ende deines Besuchs kannst du
|
Tipp dich rein, sobald du was trinkst. Am Ende deines Besuchs kannst du
|
||||||
@@ -55,13 +55,24 @@
|
|||||||
<form method="post" action="{% url 'suff:book' %}">
|
<form method="post" action="{% url 'suff:book' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<label class="for-free-toggle">
|
<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>
|
<span>Gratis (z.B. Artists am Spieltag)</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="for-free-toggle">
|
<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>
|
<span>Direkt bar bezahlt</span>
|
||||||
</label>
|
</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">
|
<div class="drink-grid">
|
||||||
{% for drink in drinks %}
|
{% for drink in drinks %}
|
||||||
<button type="submit" name="drink_id" value="{{ drink.id }}" class="drink-btn drink-btn-{{ drink.category }}">
|
<button type="submit" name="drink_id" value="{{ drink.id }}" class="drink-btn drink-btn-{{ drink.category }}">
|
||||||
@@ -113,8 +124,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="{% url 'suff:logout' %}" class="logout-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 %}
|
{% csrf_token %}
|
||||||
<button type="submit" class="btn-secondary">Logout</button>
|
<button type="submit" class="btn-secondary">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<p>Gib deine 3-stellige PIN ein.</p>
|
<p>Gib deine 3-stellige PIN ein.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if error %}<p><b>{{ error }}</b></p>{% endif %}
|
{% if error %}<p class="error">{{ error }}</p>{% endif %}
|
||||||
|
|
||||||
<form method="post" action="{% url 'suff:pin' %}">
|
<form method="post" action="{% url 'suff:pin' %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|||||||
Reference in New Issue
Block a user