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;
|
||||
}
|
||||
|
||||
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
@@ -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,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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user