Compare commits

18 Commits

Author SHA1 Message Date
dbe22cd175 Update article tile button styles in index.html to improve responsiveness and visual consistency 2026-03-22 19:01:31 +01:00
c94e313672 Refactor price display in index.html to use span elements for better structure and styling 2026-03-22 18:59:04 +01:00
83afc3c2f1 Update version number to 1.1.1 in app.py 2026-03-22 18:57:28 +01:00
c0abf76643 Emojis auf Kassen-Tiles in maximaler Größe
Made-with: Cursor
2026-03-22 18:54:54 +01:00
67489bb46b Add X-Forwarded-Host header to nginx-proxy configuration
- Updated nginx-proxy.example.conf to include proxy_set_header for X-Forwarded-Host, enhancing header forwarding capabilities.
2026-03-22 18:53:23 +01:00
73cb0c7777 ProxyFix und nginx-Beispiel für Reverse-Proxy
- Werkzeug ProxyFix für X-Forwarded-* Header
- nginx-proxy.example.conf mit korrekten proxy_set_header
- README: Hinweise zu 502-Beseitigung hinter nginx

Made-with: Cursor
2026-03-22 18:43:55 +01:00
c9ea1c924a docker-compose.yml hinzugefügt
Made-with: Cursor
2026-03-22 17:08:58 +01:00
c523085405 Update .gitignore to exclude all database files and modify kasse.db 2026-03-22 17:05:41 +01:00
bd4664f23b Preis-Eingabe auf europäisches Komma-Format normalisiert
- Neuer Artikel: Textfeld mit Placeholder 4,50 statt Dezimalpunkt
- Artikel bearbeiten: Preise mit Komma anzeigen und akzeptieren
- Backend: Komma vor float()-Konvertierung in Punkt umwandeln

Made-with: Cursor
2026-03-22 17:04:30 +01:00
5c8c4a947e Artikel-Verwaltung und responsive Tiles
- Admin: Artikel löschen (min. 1 erforderlich)
- Admin: Neue Artikel hinzufügen
- Admin: Update-Logik für variable Artikelanzahl
- Kasse: Dynamische items/Session pro Produkt
- Kasse: CSS Grid Layout - alle Artikel fit auf einen Bildschirm
- Kasse: Leerzustand wenn keine Artikel

Made-with: Cursor
2026-03-22 16:56:34 +01:00
ef36d63aa7 Fix: Kassen ohne Passwort können nun wieder geöffnet werden
- Backend erkennt passwortlose Instanzen (Hash von leerem String)
- Login-Formular: required entfernt, Hinweis für passwortlose Kassen
- Bei leerem Feld und passwortloser Kasse wird Zugang gewährt

Made-with: Cursor
2026-03-22 16:53:14 +01:00
c23f08fdd9 feat: Einbinden des Footers in Landing-, Admin- und Login-Ansicht 2026-02-24 17:08:40 +01:00
9f3951e6ef feat: Plausible Privacy Monitoring in Templates integriert 2026-02-24 17:02:10 +01:00
8d8dd115c5 gitignore: add kasse.db 2026-02-24 17:00:23 +01:00
7ffad354fa Fix Dockerfile template copy rules and uwsgi app.ini logging output 2026-02-24 16:54:04 +01:00
1d2d8230f1 Aktualisiere README mit Multi-Instanzen und PWA Features 2026-02-24 16:37:40 +01:00
18a0c34036 docs: Add instructions for persistent Docker database storage to README. 2026-02-24 16:35:47 +01:00
8b12d17935 Implementieren der PWA, Multi-Instanzen-Passwort-Schutz und Kassen-Löschfunktion 2026-02-24 16:33:16 +01:00
17 changed files with 924 additions and 212 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.venv/
log/
__pycache__/
*.db

View File

@@ -13,7 +13,8 @@ WORKDIR /app
# copy every content from the local file to the image
COPY ./app.py /app
COPY ./templates/index.html /app/templates/index.html
COPY ./templates/ /app/templates/
RUN mkdir -p /app/log
COPY ./static/ /app/static/
RUN ls -la /app/static/*
COPY ./app.ini /app

View File

@@ -4,15 +4,17 @@
## Motivation
Viele Erdbeerstände in Deutschland haben keine elektronische Kasse. Aufgrund der geringen Zahl unterschiedlicher Artikel bietet sich diese simple Rechner-App an, die bis zu sechs verschiedene Artikel in unterschiedlicher Stückzahl summieren kann.
Viele Obst- und Gemüse-Verkaufsstände in Deutschland haben keine elektronische Kasse. Aufgrund der geringen Zahl unterschiedlicher Artikel bietet sich diese simple, offline-fähige Progressive Web App (PWA) an, die bis zu sechs verschiedene Artikel in unterschiedlicher Stückzahl summieren kann.
Für jeden Artikel gibt es einen großen Knopf. Ein Klick addiert 1x den ausgewählten Artikel zur Gesamtsumme. Optional kann per Eingabefeld das Wechselgeld berechnet werden.
Über die Startseite (Landing Page) kann jeder Benutzer mit einem Klick und einem optionalen Passwort eine **eigene, geschützte Kassen-Instanz** erzeugen. Jede Kasse erhält dabei eine einzigartige, kryptische URL (UUID) und kann direkt im Browser konfiguriert werden.
Für jeden der bis zu sechs Artikel gibt es einen großen Knopf. Ein Klick addiert 1x den ausgewählten Artikel zur Gesamtsumme. Über ein Eingabefeld kann blitzschnell das Rückgeld berechnet werden.
Eine Demo-Instanz der App kann [hier](https://erdbeerhannah.elpatron.me/) zur freien Nutzung aufgerufen werden.
Die Demo-Instanz ist werbefrei und die Nutzung dauerhaft kostenlos. Details siehe in den Abschnitten *Anpassung* und *Privatsphäre*.
Die Nutzung ist werbefrei und dauerhaft kostenlos. Details siehe im Abschnitt *Privatsphäre*.
**Der Autor übernimmt keinerlei Haftung für Verfügbarkeit, Funktionalität, insbesondere Richtigkeit der Rechenergebnisse.**
**Der Autor übernimmt keinerlei Haftung für Verfügbarkeit, Berechnungen und Richtigkeit der Rechenergebnisse.**
## Design
@@ -27,11 +29,22 @@ Die Web-App *erdbeerhannah* wurde für einfachste Bedienbarkeit auf allen Gerät
![Darstellung auf dem iPad](./static/image-20240526130249495.png)
*Darstellung auf dem iPad*
## Anpassung der Artikel und Preise
## Features & PWA
Die Anpassung von Artikeln und Preisen ist derzeit lediglich im Quellcode der App möglich. Die App ist auf maximal sechs Artikel beschränkt.
- **Multi-Instanzen:** Kassen-Ansichten arbeiten unabhängig voneinander, gesichert durch UUIDs und Sessions. Man kann bedenkenlos mehrere Kassen-Tabs für unterschiedliche Schicht-Mitarbeiter öffnen.
- **Teilen leicht gemacht:** URLs zu erstellten Kassen können per "Teilen"-Button dank Web Share API sofort auf Mobile-Geräten geteilt (WhatsApp, E-Mail) oder in die Zwischenablage kopiert werden. Die zuletzt genutzten Instanzen merkt sich der eigene Browser automatisch für einen Schellzugriff auf der Startseite.
- **Progressive Web App (PWA):** erdbeerhannah lässt sich über den Browser ("Zum Startbildschirm hinzufügen") als eigenständige, offline-fähige App auf Smartphones und Tablets installieren.
Individuelle Anpassungen (Birnen? Kirschen? Spargel?) oder ein passendes Branding können auf Wunsch vom Autor gegen ein angemessenes Entgeld oder eine Spende an eine [Organisation der Wahl des Autors](https://www.tagesschau.de/spendenkonten/spendenkonten-133.html) vorgenommen werden. Ein Hosting unter der eigenen Domain (z.B. https://kasse.mein-erdbeerhof.de) ist ebenfalls möglich. Bei Interesse schreiben Sie bitte eine Mail an [m.busche@mailbox.org](mailto:m.busche@mailbox.org?subject=Erdbeerhannah).
## Anpassung der Artikel und Preise (Admin-Setup)
Im Gegensatz zu klassischen Kassensystemen benötigt man hier keine Programmierkenntnisse mehr:
Direkt nach der Erstellung landet man im passwortgeschützten **Setup-Bereich** (`/<uuid>/admin`). Hier können folgende Werte für jeden der bis zu sechs Artikel dynamisch eingestellt werden:
* **Name** (z.B. "Himbeeren", "Spargel")
* **Preis** (z.B. 4.90)
* **Icon / Emoji** (z.B. 🍓, 🛍️)
* **Darstellungsfarbe / Bootstrap Klasse** (Primär/Blau, Danger/Rot, Success/Grün, Warning/Gelb, ...)
Diese Änderungen werden sofort in Echtzeit für diese spezifische Kasse in die integrierte SQLite-Datenbank übernommen. Es ist auch jederzeit möglich, ungebrauchte Kassen über den roten Lösch-Button unwiderruflich aus der Datenbank zu entfernen.
## Deployment
@@ -43,11 +56,29 @@ Die Bereitstellung der App erfolgt am einfachsten mit Docker:
`docker image build -t erdbeerhannah:latest .`
`docker run -p 9090:90 -t erdbeerhannah:latest`
Um die erstellten Kassen-Instanzen und deren Passwörter bei einem Neustart des Containers nicht zu verlieren, sollte für eine **persistente Datenspeicherung** die genutzte SQLite-Datenbank als Volume auf das Host-System gemountet werden:
```bash
# Vorab eine leere Datei anlegen, damit Docker fälschlicherweise kein Verzeichnis erstellt
touch kasse.db
# Container mit gemounteter Datenbank starten
docker run -p 9090:90 -v $(pwd)/kasse.db:/app/kasse.db -t erdbeerhannah:latest
```
### Nginx Reverse-Proxy
Bei 502 Bad Gateway hinter nginx:
1. **Proxy-Header**: Nginx muss `X-Forwarded-For`, `X-Forwarded-Proto`, `Host` setzen (die App nutzt `ProxyFix`).
2. **Upstream-Erreichbarkeit**: `proxy_pass` muss den Container erreichen:
- nginx auf gleichem Host wie Docker: `http://127.0.0.1:9090`
- nginx auf anderem Host: `http://<Docker-Host-IP>:9090`
3. **Beispielkonfiguration**: Siehe `nginx-proxy.example.conf`
## Privatsphäre
Die App *erdbeerhannah* protokolliert keinerlei Daten. Keine IP-Adressen, keine Klicks, keine Benutzer-Eingaben und auch sonst nichts. Es werden keine Cookies genutzt, es findet kein Tracking statt und es gibt keine Werbung.
Die App *erdbeerhannah* protokolliert keine personenbezogenen Daten. Keine IP-Adressen und keine persönlichen Benutzer-Eingaben. Es werden keine Cookies genutzt. Zur reinen Nutzungsstatistik (Seitenaufrufe) verwenden wir das datenschutzfreundliche, Cookie-lose [Plausible Analytics](https://plausible.io/). Es gibt keine Werbung.
## Lizenz

View File

@@ -1,6 +1,6 @@
[uwsgi]
wsgi-file = wsgi.py
master = 5
master = true
processes = 4
http = :90
die-on-term = true
logto = /app/log/marmelade.log

335
app.py
View File

@@ -1,10 +1,16 @@
import math
import os
import time
import logging
from flask import Flask, render_template, request, session, send_from_directory, g
import sqlite3
import uuid
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.middleware.proxy_fix import ProxyFix
from flask import Flask, render_template, request, session, send_from_directory, g, redirect, url_for
from flask_bootstrap import Bootstrap
app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
app.config['SECRET_KEY'] = 'j69ol5mcHLsEtLg4Y/+myd9wWD4pp56E'
# setup logging
@@ -18,133 +24,266 @@ app.logger.addHandler(handler)
Bootstrap(app)
version = "1.0.9/2024-05-28"
postcounter = 0
gesamtwert = 0
change = 0
givenfloat = 0
sum = ""
item1 = 0
item2 = 0
item3 = 0
item4 = 0
item5 = 0
item6 = 0
version = "1.1.1/2026-03-22"
def get_db_connection():
conn = sqlite3.connect('kasse.db')
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db_connection()
conn.execute('''
CREATE TABLE IF NOT EXISTS instances (
id TEXT PRIMARY KEY,
password TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
try:
conn.execute('ALTER TABLE instances ADD COLUMN password TEXT')
except sqlite3.OperationalError:
pass # Column already exists
conn.execute('''
CREATE TABLE IF NOT EXISTS products (
instance_id TEXT,
position INTEGER,
name TEXT,
price REAL,
icon TEXT,
color_class TEXT,
FOREIGN KEY (instance_id) REFERENCES instances (id),
PRIMARY KEY (instance_id, position)
)
''')
conn.commit()
conn.close()
init_db()
app.logger.info('Starting erdbeerhannah ' + version)
# https://code-maven.com/flask-display-elapsed-time
@app.before_request
def before_request():
g.request_start_time = time.time()
g.request_time = lambda: "%.4fs" % (time.time() - g.request_start_time)
# prevent cached responses
# https://stackoverflow.com/questions/47376744/how-to-prevent-cached-response-flask-server-using-chrome
@app.after_request
def add_header(r):
"""
Add headers to both force latest IE rendering engine or Chrome Frame,
and also to cache the rendered page for 10 minutes.
"""
r.headers["Cache-Control"] = "no-store, max-age=0"
return r
@app.route("/", methods=["GET", "POST"])
def index():
global gesamtwert, item1, item2, item3, item4, item5, item6, sum, givenfloat, change, given, background, postcounter, version
def landing():
if request.method == "POST":
password = request.form.get('password', '')
instance_id = str(uuid.uuid4())
conn = get_db_connection()
conn.execute('INSERT INTO instances (id, password) VALUES (?, ?)', (instance_id, generate_password_hash(password)))
default_products = [
(instance_id, 1, "500g Erdbeeren", 4.9, "🍓", "btn-primary"),
(instance_id, 2, "Marmelade groß", 4.8, "🫙🫙", "btn-danger"),
(instance_id, 3, "Marmelade klein", 3.3, "🫙", "btn-danger"),
(instance_id, 4, "500g Kirschen", 5.0, "🍒", "btn-warning"),
(instance_id, 5, "500g Himbeeren", 4.5, "🫐", "btn-warning"),
(instance_id, 6, "Tragetasche", 0.2, "🛍️", "btn-success")
]
conn.executemany('INSERT INTO products (instance_id, position, name, price, icon, color_class) VALUES (?, ?, ?, ?, ?, ?)', default_products)
conn.commit()
conn.close()
return redirect(url_for('admin', instance_id=instance_id))
return render_template("landing.html", version=version)
@app.route("/<instance_id>", methods=["GET", "POST"])
def index(instance_id):
conn = get_db_connection()
instance = conn.execute('SELECT * FROM instances WHERE id = ?', (instance_id,)).fetchone()
if not instance:
conn.close()
return "Instance not found", 404
products_rows = conn.execute('SELECT * FROM products WHERE instance_id = ? ORDER BY position', (instance_id,)).fetchall()
conn.close()
products = {row['position']: dict(row) for row in products_rows}
session_prefix = f"kasse_{instance_id}_"
gesamtwert = session.get(f'{session_prefix}gesamtwert', 0.0)
change = session.get(f'{session_prefix}change', "0.00")
givenfloat = session.get(f'{session_prefix}given', 0.0)
items = {p: session.get(f'{session_prefix}item{p}', 0) for p in products}
background = "bg-white"
if request.method == "POST":
postcounter += 1
# wert = float(request.form["wert"])
wert = request.form.get('wert', "0", type=float)
action = request.form.get('action')
position = request.form.get('position', type=int)
if action == "reset":
gesamtwert = 0.0
change = "0.00"
givenfloat = 0.0
items = {p: 0 for p in products}
background = "bg-white"
elif action == "calculate_change":
given = request.form.get('given', "0", type=float)
app.logger.debug('wert: %s, given: %s', wert, given)
wertfloat = float(wert)
givenfloat = float(given)
givenfloat = given
change_val = givenfloat - gesamtwert
change = f"{change_val:.2f}"
background = "bg-danger" if change_val < 0 else "bg-white"
elif position and position in products:
price = products[position]['price']
gesamtwert += price
items[position] += 1
# reset button
if wertfloat == 0:
global gesamtwert
gesamtwert = 0
change = 0
sum = "0"
item1 = 0
item2 = 0
item3 = 0
item4 = 0
item5 = 0
item6 = 0
background = "bg-white"
# summarize value
elif wertfloat != -2:
gesamtwert += wertfloat
session[f'{session_prefix}gesamtwert'] = round(gesamtwert, 2)
session[f'{session_prefix}change'] = change
session[f'{session_prefix}given'] = round(givenfloat, 2)
for p in products:
session[f'{session_prefix}item{p}'] = items[p]
# Update our local variables after processing so they reflect what is rendered
gesamtwert = round(gesamtwert, 2)
if gesamtwert > 0:
sum = str(gesamtwert) + "0"
# summarize items
if wertfloat == 4.9:
item1 += 1
if wertfloat == 4.8:
item2 += 1
if wertfloat == 3.3:
item3 += 1
if wertfloat == 4.5:
item4 += 1
if wertfloat == 5:
item5 += 1
if wertfloat == .2:
item6 += 1
if givenfloat > 0:
try:
gesamtwert = session['summefloat'] or 0
sum = str(gesamtwert) + "0"
change = str(round((givenfloat - gesamtwert) * -1, 2)) + "0"
except:
app.logger.warning("Failed to read sum")
if givenfloat - gesamtwert < 0:
background = "bg-danger"
else:
background = "bg-white"
n = len(products)
grid_cols = math.ceil(math.sqrt(n)) if n else 1
grid_rows = math.ceil(n / grid_cols) if n else 1
session['item1'] = item1
session['item2'] = item2
session['item3'] = item3
session['item4'] = item4
session['item5'] = item5
session['item6'] = item6
session['summefloat'] = gesamtwert
session['summestring'] = sum
session['change'] = change
session['given'] = givenfloat
app.logger.info('*** sum %s, given %s, change %s', sum, givenfloat, change)
app.logger.info('*** postcounter %s', postcounter)
return render_template("index.html", gesamtwert=session.get('summestring', 0),
change=session.get('change', 0),
given=session.get('given', 0),
item1=session.get('item1', 0),
item2=session.get('item2', 0),
item3=session.get('item3', 0),
item4=session.get('item4', 0),
item5=session.get('item5', 0),
item6=session.get('item6', 0),
return render_template("index.html",
instance_id=instance_id,
products=products,
grid_cols=grid_cols,
grid_rows=grid_rows,
gesamtwert=f"{gesamtwert:.2f}",
change=change,
given=f"{givenfloat:.2f}" if givenfloat > 0 else "0",
items=items,
background=background,
version=version,
version=version
)
@app.route("/<instance_id>/admin", methods=["GET", "POST"])
def admin(instance_id):
conn = get_db_connection()
instance = conn.execute('SELECT * FROM instances WHERE id = ?', (instance_id,)).fetchone()
if not instance:
conn.close()
return "Instance not found", 404
auth_key = f'admin_auth_{instance_id}'
# Check if instance has no password (empty or None)
stored_password = instance['password']
has_no_password = (
stored_password is None or
check_password_hash(stored_password, '')
)
# Handle Login Submission
if request.method == "POST" and 'admin_password' in request.form:
entered = request.form['admin_password']
if has_no_password and entered == '':
session[auth_key] = True
conn.close()
return redirect(url_for('admin', instance_id=instance_id))
elif not has_no_password and check_password_hash(stored_password, entered):
session[auth_key] = True
conn.close()
return redirect(url_for('admin', instance_id=instance_id))
else:
conn.close()
return render_template("login.html", instance_id=instance_id, error="Falsches Passwort", version=version)
# Require Authentication
if not session.get(auth_key):
conn.close()
return render_template("login.html", instance_id=instance_id, error=None, version=version)
if request.method == "POST":
action = request.form.get('action')
if action == "delete":
conn.execute('DELETE FROM products WHERE instance_id = ?', (instance_id,))
conn.execute('DELETE FROM instances WHERE id = ?', (instance_id,))
conn.commit()
conn.close()
session.pop(auth_key, None)
return redirect(url_for('landing'))
for key in request.form:
if key.startswith('delete_') and request.form[key]:
pos = int(key.split('_')[1])
conn.execute('DELETE FROM products WHERE instance_id = ? AND position = ?',
(instance_id, pos))
conn.commit()
conn.close()
return redirect(url_for('admin', instance_id=instance_id))
if action == "add_product":
name = request.form.get('add_name', '').strip()
price_str = request.form.get('add_price', '').replace(',', '.').strip()
try:
price = float(price_str) if price_str else None
except ValueError:
price = None
icon = request.form.get('add_icon', '🛒').strip() or '🛒'
color = request.form.get('add_color', 'btn-primary')
if name and price is not None:
max_pos = conn.execute(
'SELECT COALESCE(MAX(position), 0) FROM products WHERE instance_id = ?',
(instance_id,)
).fetchone()[0]
conn.execute(
'INSERT INTO products (instance_id, position, name, price, icon, color_class) VALUES (?, ?, ?, ?, ?, ?)',
(instance_id, max_pos + 1, name, price, icon, color)
)
conn.commit()
conn.close()
return redirect(url_for('admin', instance_id=instance_id))
# Update existing products
products_rows = conn.execute(
'SELECT position FROM products WHERE instance_id = ?', (instance_id,)
).fetchall()
for row in products_rows:
pos = row['position']
name = request.form.get(f'name_{pos}')
price_str = request.form.get(f'price_{pos}', '').replace(',', '.').strip()
try:
price = float(price_str) if price_str else None
except ValueError:
price = None
icon = request.form.get(f'icon_{pos}')
color = request.form.get(f'color_{pos}')
if name is not None and price is not None:
conn.execute('''
UPDATE products SET name = ?, price = ?, icon = ?, color_class = ?
WHERE instance_id = ? AND position = ?
''', (name, price, icon, color, instance_id, pos))
conn.commit()
conn.close()
return redirect(url_for('index', instance_id=instance_id))
products_rows = conn.execute('SELECT * FROM products WHERE instance_id = ? ORDER BY position', (instance_id,)).fetchall()
conn.close()
products = {row['position']: dict(row) for row in products_rows}
return render_template("admin.html", instance_id=instance_id, products=products, version=version)
@app.route('/favicon.ico')
def favicon():
return send_from_directory(os.path.join(app.root_path, 'static'),
'favicon.ico', mimetype='image/vnd.microsoft.icon')
# @app.route('/about')
# def about():
# return send_from_directory(os.path.join(app.root_path, 'static'),
# 'about.html')
@app.route('/manifest.json')
def manifest():
return send_from_directory('static', 'manifest.json')
@app.route('/sw.js')
def service_worker():
return send_from_directory('static', 'sw.js', mimetype='application/javascript')
if __name__ == "__main__":
app.run(debug=True, host='127.0.0.1')

10
docker-compose.yml Normal file
View File

@@ -0,0 +1,10 @@
# Vor dem ersten Start: touch kasse.db (bzw. echo. > kasse.db unter Windows)
# damit Docker keine Verzeichnis-Datei erstellt
services:
erdbeerkasse:
build: .
container_name: erdbeerkasse
ports:
- "9090:90"
volumes:
- ./kasse.db:/app/kasse.db

36
gen_icons.py Normal file
View File

@@ -0,0 +1,36 @@
from PIL import Image, ImageDraw, ImageFont
import os
def create_icon(size, filename):
# Create a red square
img = Image.new('RGB', (size, size), color='#dc3545')
draw = ImageDraw.Draw(img)
# Try to draw a simple 'E' in the center as a fallback if no emoji font
# Calculate approximate font size
font_size = int(size * 0.6)
try:
# standard windows font
font = ImageFont.truetype("arialbd.ttf", font_size)
except IOError:
font = ImageFont.load_default()
text = "E"
# Get bounding box
left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
text_w = right - left
text_h = bottom - top
x = (size - text_w) / 2
y = (size - text_h) / 2
draw.text((x, y), text, fill=(255, 255, 255), font=font)
# Save the image
filepath = os.path.join('static', filename)
img.save(filepath)
print(f"Saved {filepath}")
if __name__ == "__main__":
create_icon(192, 'icon-192x192.png')
create_icon(512, 'icon-512x512.png')

BIN
kasse.db Normal file

Binary file not shown.

29
nginx-proxy.example.conf Normal file
View File

@@ -0,0 +1,29 @@
# Beispiel nginx Reverse-Proxy Konfiguration für erdbeerhannah
# Pfad anpassen, z.B. /etc/nginx/sites-available/erdbeerhannah
#
# Upstream-URL je nach Deployment:
# - nginx auf gleichem Host wie Docker: http://127.0.0.1:9090
# - nginx auf anderem Host: http://10.7.0.5:9090 (Docker-Host-IP)
# - Beide im gleichen docker-compose: http://erdbeerkasse:90
server {
listen 443 ssl http2;
server_name erdbeerhannah.elpatron.me;
# SSL-Zertifikate
ssl_certificate /pfad/zu/cert.pem;
ssl_certificate_key /pfad/zu/key.pem;
location / {
proxy_pass http://127.0.0.1:9090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_read_timeout 60s;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
}
}

BIN
static/icon-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

BIN
static/icon-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

21
static/manifest.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "erdbeerrechner",
"short_name": "erdbeer",
"description": "Eine anpassbare Kassen-App.",
"start_url": "/",
"display": "standalone",
"background_color": "#f8f9fa",
"theme_color": "#dc3545",
"icons": [
{
"src": "/static/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

56
static/sw.js Normal file
View File

@@ -0,0 +1,56 @@
const CACHE_NAME = 'erdbeerkasse-v1';
const ASSETS_TO_CACHE = [
'/',
'/manifest.json',
'/static/icon-192x192.png',
'/static/icon-512x512.png',
'https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(ASSETS_TO_CACHE))
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
return caches.delete(cacheName);
}
})
);
})
);
self.clients.claim();
});
// Network-first strategy for dynamic instances, fallback to cache
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') {
return;
}
event.respondWith(
fetch(event.request)
.then((response) => {
// Clone the response before caching if it's a valid response
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
const responseToCache = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(event.request, responseToCache);
});
return response;
})
.catch(() => caches.match(event.request))
);
});

144
templates/admin.html Normal file
View File

@@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>erdbeerhannah 🍓💶 - Admin</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#dc3545">
<link rel="apple-touch-icon" href="/static/icon-192x192.png">
<script defer data-domain="erdbeerhannah.elpatron.me" src="https://plausible.elpatron.me/js/script.js"></script>
</head>
<body class="bg-light">
<div class="container mt-5">
<h2 class="mb-4">Kassen-Konfiguration</h2>
<p>Dies ist die Konfiguration für deine Kasse: <strong>{{ instance_id }}</strong></p>
<p>Speichere dir diese URL ab, um später Änderungen vorzunehmen.</p>
<div class="alert alert-info">
Deine Kasse ist erreichbar unter: <a href="{{ url_for('index', instance_id=instance_id) }}">/{{ instance_id
}}</a>
</div>
<form method="post" class="bg-white p-4 shadow-sm rounded">
<table class="table">
<thead>
<tr>
<th>Position</th>
<th>Name</th>
<th>Preis (€)</th>
<th>Icon/Emoji</th>
<th>Farbe (Bootstrap Klasse)</th>
<th></th>
</tr>
</thead>
<tbody>
{% if not products %}
<tr>
<td colspan="6" class="text-muted text-center py-4">Keine Artikel. Füge unten einen neuen hinzu.</td>
</tr>
{% endif %}
{% for pos, prod in products|dictsort %}
<tr>
<td class="align-middle">{{ pos }}</td>
<td><input type="text" class="form-control" name="name_{{ pos }}" value="{{ prod['name'] }}"
required></td>
<td><input type="text" inputmode="decimal" class="form-control" name="price_{{ pos }}"
value="{{ '{:.2f}'.format(prod['price']).replace('.', ',') }}" required></td>
<td><input type="text" class="form-control" name="icon_{{ pos }}" value="{{ prod['icon'] }}"
required></td>
<td>
<select class="form-control" name="color_{{ pos }}">
<option value="btn-primary" {% if prod['color_class']=='btn-primary' %}selected{% endif
%}>Blau (Primary)</option>
<option value="btn-secondary" {% if prod['color_class']=='btn-secondary' %}selected{%
endif %}>Grau (Secondary)</option>
<option value="btn-success" {% if prod['color_class']=='btn-success' %}selected{% endif
%}>Grün (Success)</option>
<option value="btn-danger" {% if prod['color_class']=='btn-danger' %}selected{% endif
%}>Rot (Danger)</option>
<option value="btn-warning" {% if prod['color_class']=='btn-warning' %}selected{% endif
%}>Gelb (Warning)</option>
<option value="btn-info" {% if prod['color_class']=='btn-info' %}selected{% endif %}>
Hellblau (Info)</option>
<option value="btn-dark" {% if prod['color_class']=='btn-dark' %}selected{% endif %}>
Schwarz (Dark)</option>
</select>
</td>
<td class="align-middle">
{% if products|length > 1 %}
<button type="submit" name="delete_{{ pos }}" value="1" class="btn btn-outline-danger btn-sm"
onclick="return confirm('Artikel wirklich löschen?');">Löschen</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="d-flex justify-content-between">
<div>
<a href="{{ url_for('index', instance_id=instance_id) }}" class="btn btn-secondary">Zurück zur
Kasse</a>
<button type="submit" name="action" value="delete" class="btn btn-danger ml-2"
onclick="return confirm('Möchtest du diese Kasse wirklich unwiderruflich löschen? Alle zugehörigen Verkaufsdaten und Produkteinstellungen gehen verloren.');">🗑️
Kasse löschen</button>
</div>
<button type="submit" name="action" value="save" class="btn btn-success">Speichern & Zur Kasse</button>
</div>
</form>
<form method="post" class="bg-white p-4 shadow-sm rounded mt-4">
<input type="hidden" name="action" value="add_product">
<h5 class="mb-3">Neuer Artikel hinzufügen</h5>
<div class="form-row align-items-end">
<div class="form-group col-md-3">
<label for="add_name">Name</label>
<input type="text" class="form-control" name="add_name" id="add_name" required
placeholder="z.B. 500g Brombeeren">
</div>
<div class="form-group col-md-2">
<label for="add_price">Preis (€)</label>
<input type="text" inputmode="decimal" class="form-control" name="add_price" id="add_price" required
placeholder="4,50">
</div>
<div class="form-group col-md-1">
<label for="add_icon">Icon</label>
<input type="text" class="form-control" name="add_icon" id="add_icon" placeholder="🫐"
maxlength="4">
</div>
<div class="form-group col-md-2">
<label for="add_color">Farbe</label>
<select class="form-control" name="add_color" id="add_color">
<option value="btn-primary">Blau</option>
<option value="btn-secondary">Grau</option>
<option value="btn-success">Grün</option>
<option value="btn-danger">Rot</option>
<option value="btn-warning">Gelb</option>
<option value="btn-info">Hellblau</option>
<option value="btn-dark">Schwarz</option>
</select>
</div>
<div class="form-group col-md-2">
<button type="submit" class="btn btn-primary">Artikel hinzufügen</button>
</div>
</div>
</form>
<div class="mt-4 text-center text-muted pb-4">
<small>Made with ♥️, marmalade and zero knowledge in <a href="https://kiel-sailing-city.de/"
target="_blank">Kiel Strawberry City.</a><br>
Version: {{ version }}, Instanz: {{ instance_id[:8] }}...</small>
</div>
</div>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
</script>
</body>
</html>

View File

@@ -1,125 +1,201 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="content-Type" content="text/html; utf-8" />
<meta http-equiv="Pragma" content="cache" />
<meta name="robots" content="INDEX,FOLLOW" />
<meta http-equiv="content-Language" content="de" />
<meta name="description" content="Viele Obststände in Deutschland haben keine elektronische Kasse. Aufgrund der geringen Zahl unterschiedlicher Artikel bietet sich diese simple Rechner-App an, die bis zu sechs verschiedene Artikel in unterschiedlicher Stückzahl summieren kann." />
<meta name="keywords" content="rechner kasse calculator bargeld artikel obst erdbeeren spargel kirschen verkaufsstand wechselgeld kostenlos opensource werbefrei" />
<meta name="author" content="Markus Busche" />
<meta name="publisher" content="Markus Busche" />
<meta name="copyright" content="2024 Markus Busche" />
<meta name="audience" content="Alle" />
<meta name="page-type" content="HTML-Formular" />
<meta name="page-topic" content="Dienstleistung" />
<meta http-equiv="Reply-to" content="m.busche@mailbox.org" />
<meta name="expires" content="" />
<meta name="revisit-after" content="2 days" />
<meta name="robots" content="noindex,nofollow" />
<title>erdbeerhannah 🍓💶</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#dc3545">
<link rel="apple-touch-icon" href="/static/icon-192x192.png">
<script defer data-domain="erdbeerhannah.elpatron.me" src="https://plausible.elpatron.me/js/script.js"></script>
<style>
body, html {
body,
html {
height: 100%;
margin: 0;
}
.table-container {
height: 100%;
.kasse-container {
height: 100vh;
display: flex;
flex-direction: column;
min-height: 0;
}
.kasse-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
font-weight: bold;
font-size: 1.5rem;
}
.article-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat({{ grid_cols }}, 1fr);
grid-template-rows: repeat({{ grid_rows }}, 1fr);
gap: 4px;
padding: 4px;
}
.article-tile {
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
}
.table {
.article-tile .btn {
width: 100%;
height: 100%;
table-layout: fixed;
}
.table td {
height: calc(100vh / 6); /* Höhe der Zeilen dynamisch anpassen */
vertical-align: middle;
text-align: center;
}
.btn {
width: 100%; /* Button füllt die Zelle */
height: 100%; /* Button füllt die Zelle */
}
.bold-row {
font-weight: bold;
font-size: 250%;
}
.large-font {
font-size: 300%;
}
.custom-btn-size {
font-size: 180%;
}
.custom-btn-size-med {
font-size: 150%;
}
.input-container {
white-space: normal;
font-size: clamp(0.9rem, 4vw, 1.5rem);
padding: 0.5rem;
display: flex;
flex-direction: column; /* Ändert die Richtung der Flex-Elemente zu Spalten */
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.article-tile .btn .article-icon {
font-size: min(12vw, 11vh);
line-height: 1.1;
margin-bottom: 0.25rem;
}
.kasse-sum-row {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-around;
padding: 0.5rem;
font-weight: bold;
font-size: 1.5rem;
}
.kasse-reset {
flex-shrink: 0;
padding: 0.5rem;
}
.kasse-footer {
flex-shrink: 0;
padding: 0.5rem;
text-align: center;
font-size: 0.9rem;
}
.input-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.input-container input {
margin-bottom: 10px;
width: 80%; /* Setzt die Breite des Eingabefelds */
text-align: center;
font-size: 1.2rem;
width: 120px;
}
.input-container button {
width: 80%; /* Setzt die Breite des Buttons */
.empty-state {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem;
color: #6c757d;
}
</style>
</head>
<script>
$(document).ready(function(){
$('[data-toggle="tooltip"]').tooltip();
});
</script>
<body>
<div class="container-fluid table-container">
<table class="table table-bordered">
<form method="post">
<tbody>
<tr class="bold-row">
<td colspan="3">erdbeerrechner 🍓💶</td>
</tr>
<tr>
<td><button type="submit" name="wert" value="4.9" data-toggle="tooltip" data-placement="top" title="500g Erdbeeren" class="btn btn-xl btn-primary custom-btn-size">🍓 4,90€ ({{ item1 }})</button></td>
<td><button type="submit" name="wert" value="4.8" data-toggle="tooltip" data-placement="top" title="Marmelade groß" class="btn btn-xl btn-danger custom-btn-size">🫙🫙 4,80€ ({{ item2 }})</button></td>
<td><button type="submit" name="wert" value="3.3" data-toggle="tooltip" data-placement="top" title="Marmelade klein" class="btn btn-xl btn-danger custom-btn-size">🫙 3,30€ ({{ item3 }})</button></td>
</tr>
<tr>
<td><button type="submit" name="wert" value="5.0" data-toggle="tooltip" data-placement="top" title="500g Kirschen" class="btn btn-xl btn-warning custom-btn-size">🍒 5,00€ ({{ item4 }})</button></td>
<td><button type="submit" name="wert" value="4.5" data-toggle="tooltip" data-placement="top" title="500g Himbeeren" class="btn btn-xl btn-warning custom-btn-size">🫐 4,50€ ({{ item5 }})</button></td>
<td><button type="submit" name="wert" value="0.2" data-toggle="tooltip" data-placement="top" title="Tragetasche" class="btn btn-xl btn-success custom-btn-size">🛍️ 0,20€ ({{ item6 }})</button></td>
</tr>
<tr>
<td data-toggle="tooltip" data-placement="top" title="Summe" class="bold-row">🫰 {{ gesamtwert }}€</td>
<td>
<div class="input-container">
<input type="text" class="form-control" name="given" placeholder="{{ given }}">
<button type="submit" name="wert" value="-2" data-toggle="tooltip" data-placement="top" title="Wechselgeld berechnen" class="btn btn-xl btn-primary custom-btn-size-med">🧾</button>
</div>
</td>
<td data-toggle="tooltip" data-placement="top" title="Wechselgeld" class="bold-row {{ background }}">🪙 {{ change }}€</td>
</tr>
<tr>
<td colspan="3"><button type="submit" name="wert" value="0" id="reset" class="btn btn-xl btn-dark custom-btn-size">Reset 🦭</button></td>
</tr>
<tr>
<td colspan="3">Made with ♥️, marmalade and zero knowledge in <a href="https://kiel-sailing-city.de/" target="_blank">Kiel Strawberry City.</a><br>
Version: {{ version }} ({{ g.request_time() }}), <a href="https://gitea.elpatron.me/elpatron/erdbeerhannah/src/branch/main/README.md" target="_blank">Infos</a></td>
</tr>
</tbody>
</form>
</table>
<form method="post" class="kasse-container container-fluid">
<div class="kasse-header">
<span>erdbeerrechner 🍓💶</span>
<a href="{{ url_for('admin', instance_id=instance_id) }}"
class="btn btn-outline-secondary">⚙️ Setup</a>
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.16.0/umd/popper.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
<div class="article-grid">
{% if not products %}
<div class="empty-state">
Keine Artikel. Bitte im <a href="{{ url_for('admin', instance_id=instance_id) }}">Admin</a> hinzufügen.
</div>
{% else %}
{% for pos, prod in products|dictsort %}
<div class="article-tile">
<button type="submit" name="position" value="{{ pos }}" title="{{ prod['name'] }}"
class="btn {{ prod['color_class'] }}">
<span class="article-icon">{{ prod['icon'] }}</span>
<span>{{ '{:,.2f}'.format(prod['price']).replace('.', ',') }}€</span>
<span>({{ items.get(pos, 0) }})</span>
</button>
</div>
{% endfor %}
{% endif %}
</div>
<div class="kasse-sum-row">
<span title="Summe">🫰 {{ gesamtwert.replace('.', ',') }}€</span>
<div class="input-container">
<input type="number" step="0.01" class="form-control" name="given"
placeholder="{{ given }}" value="{% if given != '0' %}{{ given }}{% endif %}">
<button type="submit" name="action" value="calculate_change"
title="Wechselgeld berechnen" class="btn btn-primary btn-sm">🧾 Berechnen</button>
</div>
<span title="Wechselgeld" class="{{ background }}">🪙 {{ change.replace('.', ',') }}€</span>
</div>
<div class="kasse-reset">
<button type="submit" name="action" value="reset" class="btn btn-dark btn-block">Reset 🦭</button>
</div>
<div class="kasse-footer">
Made with ♥️, marmalade and zero knowledge in <a href="https://kiel-sailing-city.de/"
target="_blank">Kiel Strawberry City.</a><br>
Version: {{ version }}, Instanz: {{ instance_id[:8] }}...
<button type="button" onclick="shareInstance()" class="btn btn-sm btn-outline-primary ml-2">📤 URL Teilen</button>
</div>
</form>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
// Save instance to localStorage for the landing page
const instanceId = "{{ instance_id }}";
let saved = JSON.parse(localStorage.getItem('erdbeerkassen')) || [];
// Optional: remove if exists so it gets pushed to the end (most recent)
saved = saved.filter(i => i.id !== instanceId);
saved.push({ id: instanceId, date: new Date().toISOString() });
localStorage.setItem('erdbeerkassen', JSON.stringify(saved));
function shareInstance() {
const url = window.location.href;
if (navigator.share) {
navigator.share({
title: 'erdbeerrechner 🍓💶',
url: url
}).catch(console.error);
} else {
navigator.clipboard.writeText(url);
alert('URL in die Zwischenablage kopiert!');
}
}
</script>
</body>
</html>

120
templates/landing.html Normal file
View File

@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>erdbeerhannah 🍓💶 - Start</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<style>
body,
html {
height: 100%;
background-color: #f8f9fa;
}
.container {
height: 100%;
display: flex;
align-items: center;
flex-direction: column;
text-align: center;
padding-top: 10vh;
}
</style>
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#dc3545">
<link rel="apple-touch-icon" href="/static/icon-192x192.png">
<script defer data-domain="erdbeerhannah.elpatron.me" src="https://plausible.elpatron.me/js/script.js"></script>
</head>
<body>
<div class="container">
<h1 class="mb-4">Willkommen beim erdbeerrechner 🍓💶</h1>
<p class="lead mb-5">Erstelle deine eigene, anpassbare Kassen-Instanz für deinen Verkaufsstand.</p>
<form method="post" class="bg-white p-4 shadow-sm rounded" style="max-width: 400px; width: 100%;">
<div class="form-group text-left">
<label for="password">Admin Passwort (optional)</label>
<input type="password" class="form-control" name="password" id="password" placeholder="Passwort...">
<small class="form-text text-muted">Dieses Passwort wird für den /admin Bereich benötigt.</small>
</div>
<button type="submit" class="btn btn-primary btn-lg shadow-sm w-100">Neue Kasse erstellen</button>
</form>
<div class="mt-5 w-100" id="saved-instances-container" style="display: none; max-width: 500px;">
<h4 class="mb-3 text-secondary">Gespeicherte Kassen:</h4>
<ul class="list-group shadow-sm" id="saved-instances-list">
</ul>
</div>
<div class="mt-5 text-muted pb-4">
<small>Made with ♥️, marmalade and zero knowledge in <a href="https://kiel-sailing-city.de/"
target="_blank">Kiel Strawberry City.</a><br>
Version: {{ version }}</small>
</div>
</div>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
document.addEventListener("DOMContentLoaded", () => {
let saved = JSON.parse(localStorage.getItem('erdbeerkassen')) || [];
if (saved.length > 0) {
document.getElementById('saved-instances-container').style.display = 'block';
const list = document.getElementById('saved-instances-list');
[...saved].reverse().forEach(instance => {
const li = document.createElement('li');
li.className = 'list-group-item d-flex justify-content-between align-items-center flex-wrap text-left';
const link = document.createElement('a');
link.href = '/' + instance.id;
link.textContent = 'Kasse ' + instance.id.substring(0, 8);
link.className = 'font-weight-bold flex-grow-1 text-decoration-none';
const btnGroup = document.createElement('div');
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-sm btn-outline-danger mr-2';
deleteBtn.textContent = '🗑️';
deleteBtn.title = 'Aus Liste entfernen';
deleteBtn.onclick = () => {
saved = saved.filter(i => i.id !== instance.id);
localStorage.setItem('erdbeerkassen', JSON.stringify(saved));
li.remove();
if (list.children.length === 0) {
document.getElementById('saved-instances-container').style.display = 'none';
}
};
const shareBtn = document.createElement('button');
shareBtn.className = 'btn btn-sm btn-outline-primary';
shareBtn.textContent = '📤 Teilen';
shareBtn.onclick = () => {
const url = window.location.origin + '/' + instance.id;
if (navigator.share) {
navigator.share({
title: 'erdbeerrechner 🍓💶',
url: url
}).catch(console.error);
} else {
navigator.clipboard.writeText(url);
alert('URL in die Zwischenablage kopiert!');
}
};
btnGroup.appendChild(deleteBtn);
btnGroup.appendChild(shareBtn);
li.appendChild(link);
li.appendChild(btnGroup);
list.appendChild(li);
});
}
});
</script>
</body>
</html>

48
templates/login.html Normal file
View File

@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>erdbeerhannah 🍓💶 - Login</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#dc3545">
<link rel="apple-touch-icon" href="/static/icon-192x192.png">
<script defer data-domain="erdbeerhannah.elpatron.me" src="https://plausible.elpatron.me/js/script.js"></script>
</head>
<body class="bg-light">
<div class="container mt-5" style="max-width: 400px;">
<h2 class="mb-4 text-center">Admin Login</h2>
{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}
<form method="post" class="bg-white p-4 shadow-sm rounded">
<div class="form-group">
<label for="admin_password">Passwort (bei passwortloser Kasse leer lassen)</label>
<input type="password" class="form-control" name="admin_password" id="admin_password"
autofocus placeholder="Leer lassen wenn keine Kasse mit Passwort">
</div>
<button type="submit" class="btn btn-primary w-100">Einloggen</button>
<a href="{{ url_for('index', instance_id=instance_id) }}" class="btn btn-secondary w-100 mt-2">Zurück zur
Kasse</a>
</form>
<div class="mt-4 text-center text-muted pb-4">
<small>Made with ♥️, marmalade and zero knowledge in <a href="https://kiel-sailing-city.de/"
target="_blank">Kiel Strawberry City.</a><br>
Version: {{ version }}, Instanz: {{ instance_id[:8] }}...</small>
</div>
</div>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
</script>
</body>
</html>