Compare commits

..

3 Commits

12 changed files with 674 additions and 161 deletions

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,7 +56,15 @@ 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
```
## Privatsphäre

288
app.py
View File

@@ -1,7 +1,10 @@
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 flask import Flask, render_template, request, session, send_from_directory, g, redirect, url_for
from flask_bootstrap import Bootstrap
app = Flask(__name__)
@@ -18,133 +21,210 @@ 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.0/2026-02-24"
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
background = "bg-white"
def landing():
if request.method == "POST":
postcounter += 1
# wert = float(request.form["wert"])
wert = request.form.get('wert', "0", type=float)
given = request.form.get('given', "0", type=float)
app.logger.debug('wert: %s, given: %s', wert, given)
wertfloat = float(wert)
givenfloat = float(given)
# 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
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 = {i: session.get(f'{session_prefix}item{i}', 0) for i in range(1, 7)}
background = "bg-white"
if request.method == "POST":
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 = {i: 0 for i in range(1, 7)}
background = "bg-white"
# summarize value
elif wertfloat != -2:
gesamtwert += wertfloat
gesamtwert = round(gesamtwert, 2)
if gesamtwert > 0:
sum = str(gesamtwert) + "0"
elif action == "calculate_change":
given = request.form.get('given', "0", type=float)
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
# 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"
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),
session[f'{session_prefix}gesamtwert'] = round(gesamtwert, 2)
session[f'{session_prefix}change'] = change
session[f'{session_prefix}given'] = round(givenfloat, 2)
for i in range(1, 7):
session[f'{session_prefix}item{i}'] = items[i]
# Update our local variables after processing so they reflect what is rendered
gesamtwert = round(gesamtwert, 2)
return render_template("index.html",
instance_id=instance_id,
products=products,
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}'
# Handle Login Submission
if request.method == "POST" and 'admin_password' in request.form:
if check_password_hash(instance['password'], request.form['admin_password']):
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'))
# Additional safety: Ensure that the post request isn't processed if not authenticated
# (Though we checked above, this is for the product update form submission)
for i in range(1, 7):
name = request.form.get(f'name_{i}')
price = request.form.get(f'price_{i}', type=float)
icon = request.form.get(f'icon_{i}')
color = request.form.get(f'color_{i}')
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, i))
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')

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.

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))
);
});

89
templates/admin.html Normal file
View File

@@ -0,0 +1,89 @@
<!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">
</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>
</tr>
</thead>
<tbody>
{% for i in range(1, 7) %}
{% set prod = products[i] %}
<tr>
<td class="align-middle">{{ i }}</td>
<td><input type="text" class="form-control" name="name_{{ i }}" value="{{ prod['name'] }}"
required></td>
<td><input type="number" step="0.01" class="form-control" name="price_{{ i }}"
value="{{ prod['price'] }}" required></td>
<td><input type="text" class="form-control" name="icon_{{ i }}" value="{{ prod['icon'] }}"
required></td>
<td>
<select class="form-control" name="color_{{ i }}">
<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>
</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>
</div>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
</script>
</body>
</html>

View File

@@ -1,125 +1,176 @@
<!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">
<style>
body, html {
body,
html {
height: 100%;
}
.table-container {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.table {
width: 100%;
height: 100%;
table-layout: fixed;
}
.table td {
height: calc(100vh / 6); /* Höhe der Zeilen dynamisch anpassen */
height: calc(100vh / 6);
vertical-align: middle;
text-align: center;
}
.btn {
width: 100%; /* Button füllt die Zelle */
height: 100%; /* Button füllt die Zelle */
width: 100%;
height: 100%;
white-space: normal;
}
.bold-row {
font-weight: bold;
font-size: 250%;
}
.large-font {
font-size: 300%;
}
.custom-btn-size {
font-size: 180%;
font-size: 180%;
}
.custom-btn-size-med {
font-size: 150%;
font-size: 150%;
}
.input-container {
display: flex;
flex-direction: column; /* Ändert die Richtung der Flex-Elemente zu Spalten */
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.input-container input {
margin-bottom: 10px;
width: 80%; /* Setzt die Breite des Eingabefelds */
width: 80%;
text-align: center;
font-size: 1.5rem;
}
.input-container button {
width: 80%; /* Setzt die Breite des Buttons */
width: 80%;
}
</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>
<td colspan="2">erdbeerrechner 🍓💶</td>
<td><a href="{{ url_for('admin', instance_id=instance_id) }}"
class="btn btn-outline-secondary custom-btn-size-med">⚙️ Setup</a></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>
{% for i in range(1, 4) %}
{% set prod = products[i] %}
<td>
<button type="submit" name="position" value="{{ i }}" title="{{ prod['name'] }}"
class="btn btn-xl {{ prod['color_class'] }} custom-btn-size">
{{ prod['icon'] }} <br> {{ '{:,.2f}'.format(prod['price']).replace('.', ',') }}€ <br>
({{ items[i] }})
</button>
</td>
{% endfor %}
</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>
{% for i in range(4, 7) %}
{% set prod = products[i] %}
<td>
<button type="submit" name="position" value="{{ i }}" title="{{ prod['name'] }}"
class="btn btn-xl {{ prod['color_class'] }} custom-btn-size">
{{ prod['icon'] }} <br> {{ '{:,.2f}'.format(prod['price']).replace('.', ',') }}€ <br>
({{ items[i] }})
</button>
</td>
{% endfor %}
</tr>
<tr>
<td data-toggle="tooltip" data-placement="top" title="Summe" class="bold-row">🫰 {{ gesamtwert }}€</td>
<td title="Summe" class="bold-row">🫰 {{ gesamtwert.replace('.', ',') }}€</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>
<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-xl btn-primary custom-btn-size-med">🧾
Berechnen</button>
</div>
</td>
<td data-toggle="tooltip" data-placement="top" title="Wechselgeld" class="bold-row {{ background }}">🪙 {{ change }}€</td>
<td title="Wechselgeld" class="bold-row {{ background }}">🪙 {{ change.replace('.', ',') }}€
</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>
<td colspan="3">
<button type="submit" name="action" value="reset" 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>
<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 }}, Instanz: {{ instance_id[:8] }}...
<button type="button" onclick="shareInstance()"
class="btn btn-sm btn-outline-primary ml-2">📤 URL Teilen</button>
</td>
</tr>
</tbody>
</tbody>
</form>
</table>
</div>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
<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>
// 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>
</html>

117
templates/landing.html Normal file
View File

@@ -0,0 +1,117 @@
<!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">
</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">
<small>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>

42
templates/login.html Normal file
View File

@@ -0,0 +1,42 @@
<!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">
</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">Passworteingabe erforderlich</label>
<input type="password" class="form-control" name="admin_password" id="admin_password" required
autofocus>
</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>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js');
});
}
</script>
</body>
</html>