Enhance admin dashboard and team management features

- Added a quick admin link section in the admin dashboard for managing team cards.
- Updated the admin licenses template to correct German language spelling.
- Improved the appointments template with conditional rendering for test package options.
- Enhanced the base template with a logo image and adjusted brand styling.
- Updated the license management template for consistent German language usage.
- Removed the tutorial template and replaced it with a new admin team management template.
- Introduced a new admin team management page allowing for the addition, editing, and deletion of team members.
This commit is contained in:
2026-04-09 17:14:44 +02:00
parent b2cc2f820d
commit 776deaec97
10 changed files with 634 additions and 241 deletions
+264 -39
View File
@@ -40,6 +40,7 @@ def set_security_headers(response):
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
INVOICE_UPLOAD_DIR = os.path.join(BASE_DIR, "static", "uploads", "invoices")
TEAM_UPLOAD_DIR = os.path.join(BASE_DIR, "static", "uploads", "team")
MONGO_URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017")
MONGO_DB_NAME = os.environ.get("MONGO_DB_NAME", "Invario_Website")
@@ -56,6 +57,13 @@ def _is_allowed_invoice_filename(filename: str) -> bool:
return bool(filename) and filename.lower().endswith(".pdf")
def _is_allowed_image_filename(filename: str) -> bool:
if not filename:
return False
lowered = filename.lower()
return lowered.endswith(".jpg") or lowered.endswith(".jpeg") or lowered.endswith(".png") or lowered.endswith(".webp")
def _save_invoice_pdf(file_obj, invoice_number: str) -> str | None:
if not file_obj or not file_obj.filename:
return None
@@ -70,6 +78,21 @@ def _save_invoice_pdf(file_obj, invoice_number: str) -> str | None:
return f"uploads/invoices/{unique_name}"
def _save_team_photo(file_obj, identifier: str) -> str | None:
if not file_obj or not file_obj.filename:
return None
original_name = secure_filename(file_obj.filename)
if not _is_allowed_image_filename(original_name):
return None
os.makedirs(TEAM_UPLOAD_DIR, exist_ok=True)
extension = os.path.splitext(original_name)[1].lower() or ".jpg"
safe_identifier = secure_filename(identifier or "member")
unique_name = f"team_{safe_identifier}_{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{extension}"
target = os.path.join(TEAM_UPLOAD_DIR, unique_name)
file_obj.save(target)
return f"uploads/team/{unique_name}"
def _get_mongo_client() -> MongoClient:
return MongoClient(MONGO_URI, serverSelectionTimeoutMS=1200)
@@ -119,14 +142,14 @@ def _activate_test_license_for_user(username: str, school_name: str, package_nam
user_name = _sanitize_text(username or "", 80)
school = _sanitize_text(school_name or "", 200)
if not user_name:
return False, "Benutzername fehlt fuer Test-Key."
return False, "Benutzername fehlt für Test-Key."
client = None
try:
client, col = _get_collection("licenses")
normalized_package = _sanitize_text(package_name or "Normal", 40)
if normalized_package not in {"Normal", "Pro", "Buecherei", "Bücherei"}:
if normalized_package not in {"Normal", "Pro", "Bücherei", "Bücherei"}:
normalized_package = "Normal"
existing = col.find_one(
@@ -137,7 +160,7 @@ def _activate_test_license_for_user(username: str, school_name: str, package_nam
}
)
if existing:
return False, "Ein Test-Key ist fuer dieses Schulkonto bereits vorhanden."
return False, "Ein Test-Key ist für dieses Schulkonto bereits vorhanden."
test_key = f"TEST-{verify.key_generator()[:18].upper()}"
col.insert_one(
@@ -239,6 +262,42 @@ def _sanitize_html(html_content: str, max_length: int = 50000) -> str:
return bleach.clean(html_content, tags=allowed_tags, attributes=allowed_attrs, strip=True)
def _get_team_members() -> list:
members = []
client = None
try:
client, col = _get_collection("team_members")
members = list(col.find().sort([("sort_order", 1), ("created_at", 1)]))
except PyMongoError:
return []
finally:
if client:
client.close()
normalized = []
for member in members:
public_id = str(member.get("_id") or "")
sort_raw = member.get("sort_order", 999)
try:
sort_order = int(sort_raw)
except (TypeError, ValueError):
sort_order = 999
normalized.append(
{
"id": public_id,
"sort_order": sort_order,
"name": _sanitize_text(member.get("name") or "", 120),
"role": _sanitize_text(member.get("role") or "", 120),
"work": _sanitize_text(member.get("work") or "", 220),
"bio": _sanitize_text(member.get("bio") or "", 500),
"photo": _sanitize_text(member.get("photo") or "", 255),
}
)
normalized.sort(key=lambda value: (value.get("sort_order", 999), value.get("name", "")))
return normalized
def _validate_username(username: str) -> bool:
"""Validate username format: alphanumeric, underscore, dash, 3-30 chars."""
if not username or not isinstance(username, str):
@@ -337,14 +396,10 @@ def inventarsystem():
return render_template("inventarsystem.html")
@app.route('/tutorial')
def tutorial():
return render_template("tutorial.html")
@app.route('/team')
def team():
return render_template("team.html")
team_members = _get_team_members()
return render_template("team.html", team_members=team_members)
@app.route('/kontakt')
@@ -388,7 +443,7 @@ def login():
if request.is_json:
return jsonify({"error": "Invalid credentials"}), 401
flash('Login fehlgeschlagen. Bitte pruefen Sie Ihre Eingaben.', 'error')
flash('Login fehlgeschlagen. Bitte prüfen Sie Ihre Eingaben.', 'error')
get_flashed_messages()
return render_template('login.html')
@@ -406,7 +461,7 @@ def register():
password_repeat = request.form.get("password_repeat") or ""
if not username or not school_name or not contact_person or not email or not password or not password_repeat:
flash("Bitte alle Felder ausfuellen.", "error")
flash("Bitte alle Felder ausfüllen.", "error")
return redirect(url_for("register"))
if not _validate_username(username):
@@ -418,7 +473,7 @@ def register():
return redirect(url_for("register"))
if password != password_repeat:
flash("Die Passwoerter stimmen nicht ueberein.", "error")
flash("Die Passwörter stimmen nicht überein.", "error")
return redirect(url_for("register"))
if _find_user(username):
@@ -426,7 +481,7 @@ def register():
return redirect(url_for("register"))
if not _validate_email(email):
flash("Bitte eine gueltige E-Mail-Adresse angeben.", "error")
flash("Bitte eine gültige E-Mail-Adresse angeben.", "error")
return redirect(url_for("register"))
school_name = _sanitize_text(school_name, 120)
@@ -462,20 +517,30 @@ def register():
@app.route('/appointments', methods=['GET'])
def appointments():
software_packages = [
{
"slug": "test",
"name": "Testversion",
"headline": "30 Tage kostenlos zum Ausprobieren",
"features": [
"Sofortiger Zugang mit Test-Key",
"Ideal für Erstbewertung im Schulalltag",
"Ohne langfristige Bindung",
],
},
{
"slug": "normal",
"name": "Normal",
"headline": "Stabiler Einstieg fuer den Schulalltag",
"headline": "Stabiler Einstieg für den Schulalltag",
"features": [
"Inventarverwaltung mit Rollenrechten",
"Basis-Support und Ticketing",
"Schulweite Uebersichten",
"Schulweite Übersichten",
],
},
{
"slug": "pro",
"name": "Pro",
"headline": "Fuer Schulen mit erweitertem Bedarf",
"headline": "Für Schulen mit erweitertem Bedarf",
"features": [
"Erweiterte Lizenzverwaltung",
"Detailberichte und Admin-Insights",
@@ -485,10 +550,10 @@ def appointments():
{
"slug": "buecherei",
"name": "Bücherei",
"headline": "Optimiert fuer Bibliothek und Medien",
"headline": "Optimiert für Bibliothek und Medien",
"features": [
"Ausleih- und Rueckgabeprozesse",
"Bestandsuebersichten fuer Medien",
"Ausleih- und Rückgabeprozesse",
"Bestandsübersichten für Medien",
"Transparente Historie pro Medium",
],
},
@@ -525,26 +590,69 @@ def appointments():
def start_test_package():
package_raw = _sanitize_text(request.form.get("package") or "normal", 40).lower()
package_map = {
"test": "Normal",
"normal": "Normal",
"pro": "Pro",
"buecherei": "Buecherei",
"buecherei": "Bücherei",
}
selected_package = package_map.get(package_raw)
if not selected_package:
flash("Ungueltiges Paket ausgewaehlt.", "error")
flash("Ungültiges Paket ausgewählt.", "error")
return redirect(url_for("appointments"))
school_name = _sanitize_text(session.get("display_name") or session.get("username") or "", 200)
activated, message = _activate_test_license_for_user(session.get("username") or "", school_name, selected_package)
if activated:
flash(f"Testversion fuer Paket {selected_package} gestartet. Key: {message}", "success")
flash(f"Testversion für Paket {selected_package} gestartet. Key: {message}", "success")
else:
flash(message, "error")
return redirect(url_for("appointments"))
@app.route('/appointments/book-option', methods=['POST'])
@login_required
def book_option_package():
package_raw = _sanitize_text(request.form.get("package") or "", 40).lower()
package_map = {
"normal": "Normal",
"pro": "Pro",
"buecherei": "Bücherei",
}
selected_package = package_map.get(package_raw)
if not selected_package:
flash("Ungültige Buchungsoption ausgewählt.", "error")
return redirect(url_for("appointments"))
message = (
f"Ich möchte das Paket {selected_package} buchen. "
"Bitte kontaktieren Sie mich für die nächsten Schritte."
)
client = None
try:
client, col = _get_collection("chat_messages")
col.insert_one(
{
"username": session.get("username"),
"sender": session.get("display_name") or session.get("username"),
"sender_role": "user",
"message": message,
"created_at": _utc_now_iso(),
}
)
except PyMongoError:
flash("Buchungsanfrage konnte nicht gesendet werden.", "error")
return redirect(url_for("appointments"))
finally:
if client:
client.close()
flash(f"Buchungsanfrage für Paket {selected_package} wurde an den Admin gesendet.", "success")
return redirect(url_for("user_chat"))
@app.route('/admin/dashboard')
@admin_required
def admin_dashboard():
@@ -604,7 +712,7 @@ def admin_block_day():
try:
date.fromisoformat(block_date)
except ValueError:
flash("Bitte ein gueltiges Datum zum Sperren waehlen.", "error")
flash("Bitte ein gültiges Datum zum Sperren wählen.", "error")
return redirect(url_for("admin_dashboard"))
if col.find_one({"date": block_date}):
@@ -627,7 +735,7 @@ def admin_block_day():
return redirect(url_for("admin_dashboard"))
flash("Sperrtag entfernt.", "success")
else:
flash("Ungueltige Aktion fuer Kalendersperre.", "error")
flash("Ungültige Aktion für Kalendersperre.", "error")
return redirect(url_for("admin_dashboard"))
except PyMongoError:
flash("Kalendersperre konnte nicht gespeichert werden.", "error")
@@ -645,7 +753,7 @@ def update_appointment(appointment_id):
response_text = _sanitize_text(request.form.get("response") or "", 5000)
if action not in ["confirm", "reject"]:
flash("Ungueltige Aktion.", "error")
flash("Ungültige Aktion.", "error")
return redirect(url_for("admin_dashboard"))
query = _appointment_query_from_id(appointment_id)
@@ -695,7 +803,7 @@ def admin_blog():
excerpt = _sanitize_text(request.form.get("excerpt") or "", 500)
if not title or not content:
flash("Bitte Titel und inhalt ausfuellen.", "error")
flash("Bitte Titel und inhalt ausfüllen.", "error")
return redirect(url_for("admin_blog"))
client = None
try:
@@ -725,14 +833,14 @@ def admin_blog():
post_id = (request.form.get("post_id") or "").strip()
query = _post_query_from_id(post_id)
if not query:
flash("Ungueltige Beitrag-ID.", "error")
flash("Ungültige Beitrag-ID.", "error")
return redirect(url_for("admin_blog"))
client = None
try:
client, col = _get_collection("posts")
result = col.delete_one(query)
except PyMongoError:
flash("Beitrag konnte nicht geloescht werden.", "error")
flash("Beitrag konnte nicht gelöscht werden.", "error")
return redirect(url_for("admin_blog"))
finally:
if client:
@@ -742,7 +850,7 @@ def admin_blog():
flash("Beitrag nicht gefunden.", "error")
return redirect(url_for("admin_blog"))
flash("Beitrag geloescht.", "success")
flash("Beitrag gelöscht.", "success")
return redirect(url_for("admin_blog"))
posts = []
@@ -783,7 +891,7 @@ def blog():
def blog_post(post_id):
query = _post_query_from_id(post_id)
if not query:
flash("Ungueltige Beitrag-ID.", "error")
flash("Ungültige Beitrag-ID.", "error")
return redirect(url_for("blog"))
client = None
post = None
@@ -892,7 +1000,7 @@ def user_tickets():
description = _sanitize_text(request.form.get("description") or "", 5000)
priority = _sanitize_text(request.form.get("priority") or "Normal", 30)
if not title or not description:
flash("Bitte Titel und Beschreibung ausfuellen.", "error")
flash("Bitte Titel und Beschreibung ausfüllen.", "error")
return redirect(url_for("user_tickets"))
try:
client, col = _get_collection("support_tickets")
@@ -951,18 +1059,135 @@ def admin_users():
flash("Admin-Rechte entfernt.", "success")
elif action == "delete_user":
if username == session.get("username"):
flash("Sie koennen Ihr eigenes Konto nicht loeschen.", "error")
flash("Sie können Ihr eigenes Konto nicht löschen.", "error")
return redirect(url_for("admin_users"))
user_store.delete_user(username)
flash("Benutzer geloescht.", "success")
flash("Benutzer gelöscht.", "success")
else:
flash("Ungueltige Aktion.", "error")
flash("Ungültige Aktion.", "error")
return redirect(url_for("admin_users"))
users = _list_users_for_admin()
return render_template("admin_users.html", users=users)
@app.route('/admin/team', methods=['GET', 'POST'])
@admin_required
def admin_team():
if request.method == 'POST':
action = _sanitize_text(request.form.get("action") or "upsert", 20)
member_id = _sanitize_text(request.form.get("member_id") or "", 64)
client = None
try:
client, col = _get_collection("team_members")
if action == "delete":
try:
result = col.delete_one({"_id": ObjectId(member_id)})
except Exception:
flash("Ungültige Team-ID.", "error")
return redirect(url_for("admin_team"))
if result.deleted_count:
flash("Teammitglied entfernt.", "success")
else:
flash("Teammitglied nicht gefunden.", "error")
return redirect(url_for("admin_team"))
name = _sanitize_text(request.form.get("name") or "", 120)
role = _sanitize_text(request.form.get("role") or "", 120)
work = _sanitize_text(request.form.get("work") or "", 220)
bio = _sanitize_text(request.form.get("bio") or "", 500)
sort_raw = _sanitize_text(request.form.get("sort_order") or "999", 10)
try:
sort_order = int(sort_raw)
except ValueError:
sort_order = 999
sort_order = max(1, min(sort_order, 999))
if not name or not role or not work:
flash("Bitte Name, Rolle und Arbeit ausfüllen.", "error")
return redirect(url_for("admin_team"))
current = None
if member_id:
try:
current = col.find_one({"_id": ObjectId(member_id)})
except Exception:
flash("Ungültige Team-ID.", "error")
return redirect(url_for("admin_team"))
photo_identifier = member_id or name[:20]
photo_path = _save_team_photo(request.files.get("photo"), photo_identifier)
if request.files.get("photo") and request.files.get("photo").filename and not photo_path:
flash("Foto muss JPG, JPEG, PNG oder WEBP sein.", "error")
return redirect(url_for("admin_team"))
if not photo_path:
photo_path = (current or {}).get("photo", "")
if action == "create":
if not photo_path:
flash("Bitte ein Foto für das neue Teammitglied hochladen.", "error")
return redirect(url_for("admin_team"))
col.insert_one(
{
"name": name,
"role": role,
"work": work,
"bio": bio,
"photo": photo_path,
"sort_order": sort_order,
"created_by": session.get("username") or "admin",
"created_at": _utc_now_iso(),
"updated_at": _utc_now_iso(),
}
)
flash("Teammitglied hinzugefuegt.", "success")
elif action == "update":
if not member_id:
flash("Team-ID fehlt.", "error")
return redirect(url_for("admin_team"))
try:
result = col.update_one(
{"_id": ObjectId(member_id)},
{
"$set": {
"name": name,
"role": role,
"work": work,
"bio": bio,
"photo": photo_path,
"sort_order": sort_order,
"updated_by": session.get("username") or "admin",
"updated_at": _utc_now_iso(),
}
},
)
except Exception:
flash("Ungültige Team-ID.", "error")
return redirect(url_for("admin_team"))
if result.matched_count == 0:
flash("Teammitglied nicht gefunden.", "error")
return redirect(url_for("admin_team"))
flash("Teammitglied aktualisiert.", "success")
else:
flash("Ungültige Aktion.", "error")
return redirect(url_for("admin_team"))
except PyMongoError:
flash("Teamdaten konnten nicht gespeichert werden.", "error")
finally:
if client:
client.close()
return redirect(url_for("admin_team"))
team_members = _get_team_members()
return render_template("admin_team.html", team_members=team_members)
@app.route('/admin/chats', methods=['GET', 'POST'])
@admin_required
def admin_chats():
@@ -1114,9 +1339,9 @@ def admin_licenses():
elif action == "delete" and license_id:
col.delete_one({"_id": ObjectId(license_id)})
flash("Lizenz geloescht.", "success")
flash("Lizenz gelöscht.", "success")
else:
flash("Ungueltige Aktion.", "error")
flash("Ungültige Aktion.", "error")
except Exception:
flash("Lizenzverwaltung fehlgeschlagen.", "error")
finally:
@@ -1214,9 +1439,9 @@ def admin_invoices():
elif action == "delete" and invoice_id:
col.delete_one({"_id": ObjectId(invoice_id)})
flash("Rechnung geloescht.", "success")
flash("Rechnung gelöscht.", "success")
else:
flash("Ungueltige Aktion.", "error")
flash("Ungültige Aktion.", "error")
except Exception:
flash("Rechnungsverwaltung fehlgeschlagen.", "error")
finally:
@@ -1292,7 +1517,7 @@ def allocate_key():
license_key = _sanitize_text(request.form.get("license_key") or "", 160)
if not username or not license_key:
flash("Bitte Benutzer und Lizenz-Key auswaehlen.", "error")
flash("Bitte Benutzer und Lizenz-Key auswählen.", "error")
return redirect(url_for('admin_license_keys'))
if not _find_user(username):
+26
View File
@@ -42,6 +42,28 @@
box-shadow: 0 14px 32px rgba(20, 39, 55, 0.06);
}
.quick-admin-link {
margin-bottom: 1rem;
border: 1px solid #d8e3ec;
border-radius: 12px;
background: #f6fbff;
padding: 0.85rem 0.95rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.quick-admin-link a {
border-radius: 999px;
padding: 0.42rem 0.8rem;
border: 1px solid #b8ccda;
background: #ffffff;
color: #0b4d73;
font-weight: 700;
white-space: nowrap;
}
.appointments-section h2 {
font-size: 1.3rem;
color: #0f354d;
@@ -248,6 +270,10 @@
</section>
<section class="appointments-section">
<div class="quick-admin-link">
<p><strong>Team verwalten:</strong> Bilder, Rolle und Arbeit der zwei Team-Cards zentral pflegen.</p>
<a href="{{ url_for('admin_team') }}">Team Cards öffnen</a>
</div>
<h2>Buchungsanfragen verwalten</h2>
<div class="block-days-section">
<h3>Kalender-Tage sperren</h3>
+1 -1
View File
@@ -19,7 +19,7 @@
{% endfor %}
</select>
<input type="text" name="school_name" placeholder="Schulname" required>
<input type="text" name="license_key" placeholder="Lizenz-Key (optional, eigener Wert moeglich)">
<input type="text" name="license_key" placeholder="Lizenz-Key (optional, eigener Wert möglich)">
<input type="text" name="plan" placeholder="Plan (z. B. Standard)">
<input type="text" name="status" placeholder="Status (Aktiv, Pausiert)">
<input type="text" name="valid_until" placeholder="Gültig bis (YYYY-MM-DD)">
+180
View File
@@ -0,0 +1,180 @@
{% extends "base.html" %}
{% block title %}Admin | Team Cards{% endblock %}
{% block content %}
<section class="panel">
<h1>Team Cards verwalten</h1>
<p>Beliebig viele Teammitglieder können hinzugefügt und sortiert werden. Jedes Mitglied hat Name, Rolle, Arbeit, Kurztext und ein Foto.</p>
</section>
<section class="card add-card">
<h2>Neues Teammitglied hinzufügen</h2>
<form method="post" enctype="multipart/form-data" class="form-grid">
<input type="hidden" name="action" value="create">
<label>Name</label>
<input name="name" maxlength="120" required>
<label>Rolle</label>
<input name="role" maxlength="120" required>
<label>Arbeit</label>
<input name="work" maxlength="220" required>
<label>Kurzbeschreibung</label>
<textarea name="bio" rows="3" maxlength="500"></textarea>
<label>Reihenfolge (1-999)</label>
<input type="number" name="sort_order" min="1" max="999" value="100" required>
<label>Foto (JPG, JPEG, PNG, WEBP)</label>
<input type="file" name="photo" accept=".jpg,.jpeg,.png,.webp" required>
<div class="row">
<button type="submit" class="btn primary">Hinzufügen</button>
</div>
</form>
</section>
<section class="card-grid">
{% for member in team_members %}
<article class="card">
<h2>{{ member.name }}</h2>
<form method="post" enctype="multipart/form-data" class="form-grid">
<input type="hidden" name="action" value="update">
<input type="hidden" name="member_id" value="{{ member.id }}">
<label>Name</label>
<input name="name" value="{{ member.name }}" maxlength="120" required>
<label>Rolle</label>
<input name="role" value="{{ member.role }}" maxlength="120" required>
<label>Arbeit</label>
<input name="work" value="{{ member.work }}" maxlength="220" required>
<label>Kurzbeschreibung</label>
<textarea name="bio" rows="3" maxlength="500">{{ member.bio }}</textarea>
<label>Reihenfolge (1-999)</label>
<input type="number" name="sort_order" min="1" max="999" value="{{ member.sort_order or 100 }}" required>
<label>Foto (JPG, JPEG, PNG, WEBP)</label>
<input type="file" name="photo" accept=".jpg,.jpeg,.png,.webp">
{% if member.photo %}
<img class="preview" src="{{ url_for('static', filename=member.photo) }}" alt="Teamfoto {{ member.name }}">
{% endif %}
<div class="row">
<button type="submit" class="btn primary">Speichern</button>
</div>
</form>
<form method="post" class="delete-form" onsubmit="return confirm('Mitglied wirklich entfernen?');">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="member_id" value="{{ member.id }}">
<button type="submit" class="btn danger">Entfernen</button>
</form>
</article>
{% else %}
<article class="card">
<h2>Noch keine Teammitglieder</h2>
<p>Über das Formular oben kann jetzt das erste Teammitglied erstellt werden.</p>
</article>
{% endfor %}
</section>
<style>
.panel {
margin-bottom: 1rem;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
gap: 1rem;
}
.card {
background: #fff;
border: 1px solid #d8e1e8;
border-radius: 14px;
padding: 1rem;
box-shadow: 0 10px 24px rgba(20, 40, 55, 0.05);
}
.add-card {
margin-bottom: 1rem;
}
.card h2 {
margin: 0 0 0.7rem;
color: #113d59;
font-size: 1.2rem;
}
.form-grid {
display: grid;
gap: 0.45rem;
}
label {
font-size: 0.86rem;
font-weight: 700;
color: #4b677c;
}
input,
textarea {
border: 1px solid #bdd0dd;
border-radius: 9px;
padding: 0.5rem 0.6rem;
font: inherit;
width: 100%;
}
textarea {
resize: vertical;
}
.preview {
margin-top: 0.4rem;
width: 100%;
max-height: 200px;
object-fit: cover;
border: 1px solid #d0d9de;
border-radius: 10px;
}
.row {
margin-top: 0.4rem;
display: flex;
gap: 0.45rem;
}
.btn {
border: none;
border-radius: 999px;
padding: 0.5rem 0.85rem;
font-weight: 700;
cursor: pointer;
}
.btn.primary {
color: #fff;
background: linear-gradient(120deg, #0b5b89 0%, #0b4262 100%);
}
.btn.danger {
color: #842f2f;
background: #fff4f4;
border: 1px solid #e7b9b9;
}
.delete-form {
margin-top: 0.8rem;
}
</style>
{% endblock %}
+19 -8
View File
@@ -104,7 +104,7 @@
<section class="packages-hero">
<h1>Buchungsoptionen als Softwarepakete</h1>
<p>Alle Pakete sind frei einsehbar, auch ohne Admin. Die Testversion kann nur mit aktivem Konto gestartet werden.</p>
<p>Jedes Paket basiert auf derselben Sicherheitsarchitektur mit Rollenrechten, geschuetzten Sessions und nachvollziehbaren Aktionen.</p>
<p>Jedes Paket basiert auf derselben Sicherheitsarchitektur mit Rollenrechten, geschützten Sessions und nachvollziehbaren Aktionen.</p>
</section>
<section class="packages-grid">
@@ -119,12 +119,23 @@
</ul>
<div class="package-actions">
{% if 'username' in session %}
<form method="POST" action="{{ url_for('start_test_package') }}">
<input type="hidden" name="package" value="{{ package.slug }}">
<button class="btn" type="submit">Testversion starten</button>
</form>
{% if package.slug == 'test' %}
<form method="POST" action="{{ url_for('start_test_package') }}">
<input type="hidden" name="package" value="{{ package.slug }}">
<button class="btn" type="submit">Testversion starten</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('book_option_package') }}">
<input type="hidden" name="package" value="{{ package.slug }}">
<button class="btn" type="submit">Option buchen</button>
</form>
{% endif %}
{% else %}
<a class="btn secondary" href="{{ url_for('login') }}">Mit Konto starten</a>
{% if package.slug == 'test' %}
<a class="btn secondary" href="{{ url_for('login') }}">Mit Konto testen</a>
{% else %}
<a class="btn secondary" href="{{ url_for('login') }}">Mit Konto buchen</a>
{% endif %}
{% endif %}
</div>
</article>
@@ -134,9 +145,9 @@
<section class="notice">
{% if 'username' in session %}
{% if active_test_license %}
<p><strong>Aktive Testversion:</strong> {{ active_test_license.plan }} | Key: {{ active_test_license.license_key }} | Gueltig bis: {{ active_test_license.valid_until }}</p>
<p><strong>Aktive Testversion:</strong> {{ active_test_license.plan }} | Key: {{ active_test_license.license_key }} | Gültig bis: {{ active_test_license.valid_until }}</p>
{% else %}
<p><strong>Hinweis:</strong> Fuer Ihr Konto ist noch keine Testversion aktiv. Starten Sie oben eine Testversion fuer Normal, Pro oder Buecherei.</p>
<p><strong>Hinweis:</strong> Für Ihr Konto ist noch keine Testversion aktiv. Starten Sie oben die Testversion beim Paket Testversion.</p>
{% endif %}
{% else %}
<p><strong>Hinweis:</strong> Bitte einloggen oder registrieren, um die Testversion zu starten.</p>
+20 -4
View File
@@ -116,6 +116,9 @@
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.62rem;
font-family: "Merriweather", Georgia, serif;
font-size: 1.15rem;
font-weight: 700;
@@ -123,6 +126,16 @@
color: var(--brand-strong);
}
.brand img {
width: 38px;
height: 38px;
border-radius: 10px;
object-fit: cover;
border: 1px solid #bccdc4;
box-shadow: 0 8px 16px rgba(19, 49, 54, 0.14);
background: #ffffff;
}
.nav-links {
display: flex;
align-items: center;
@@ -352,12 +365,14 @@
<body>
<nav class="site-nav">
<div class="site-shell site-nav-inner">
<a class="brand" href="{{ url_for('default') }}">Invario Schule</a>
<a class="brand" href="{{ url_for('default') }}">
<img src="{{ url_for('static', filename='images/logo-1.jpeg') }}" alt="Invario Logo">
<span>Invario Schule</span>
</a>
<div class="nav-links" aria-label="Main Navigation">
<a href="{{ url_for('dienstleistungen') }}">Leistungen für Schulen</a>
<a href="{{ url_for('projekte') }}">Praxisbeispiele</a>
<a href="{{ url_for('tutorial') }}">Tutorial</a>
<a href="{{ url_for('team') }}">Unser Ansatz</a>
<a href="{{ url_for('team') }}">Team</a>
<a href="{{ url_for('kontakt') }}">Kontakt</a>
<a href="{{ url_for('inventarsystem') }}">Inventarsystem</a>
</div>
@@ -387,6 +402,7 @@
<a href="{{ url_for('admin_chats') }}">Admin-Chat</a>
<a href="{{ url_for('admin_tickets') }}">Support-Center</a>
<a href="{{ url_for('admin_blog') }}">Blog verwalten</a>
<a href="{{ url_for('admin_team') }}">Team Cards</a>
</div>
</details>
{% endif %}
@@ -424,7 +440,7 @@
<ul class="footer-links">
<li><a href="{{ url_for('default') }}">Start</a></li>
<li><a href="{{ url_for('dienstleistungen') }}">Leistungsübersicht</a></li>
<li><a href="{{ url_for('tutorial') }}">Tutorial</a></li>
<li><a href="{{ url_for('team') }}">Team</a></li>
<li><a href="{{ url_for('blog') }}">Neuigkeiten</a></li>
{% if 'username' in session %}
<li><a href="{{ url_for('appointments') }}">Buchungsoption</a></li>
+3 -3
View File
@@ -183,7 +183,7 @@
<form method="POST" action="{{ url_for('allocate_key') }}" class="key-form">
<label for="alloc_user">Benutzer</label>
<select id="alloc_user" name="username" required>
<option value="">Benutzer auswaehlen</option>
<option value="">Benutzer auswählen</option>
{% for user in users %}
<option value="{{ user.username }}">{{ user.username }} ({{ user.display_name }})</option>
{% endfor %}
@@ -191,7 +191,7 @@
<label for="alloc_key">Lizenz-Key</label>
<select id="alloc_key" name="license_key" required>
<option value="">Key auswaehlen</option>
<option value="">Key auswählen</option>
{% for item in keys %}
<option value="{{ item.license_key }}">{{ item.user_id or '----' }} | {{ item.license_key[:22] }}{% if item.license_key|length > 22 %}...{% endif %}</option>
{% endfor %}
@@ -246,7 +246,7 @@
</td>
<td>
{% if item.user_id %}
<form method="POST" action="{{ url_for('remove_key', user_id=item.user_id) }}" class="remove-form" onsubmit="return confirm('Key fuer User {{ item.user_id }} wirklich loeschen?');">
<form method="POST" action="{{ url_for('remove_key', user_id=item.user_id) }}" class="remove-form" onsubmit="return confirm('Key für User {{ item.user_id }} wirklich löschen?');">
<button type="submit" class="btn-danger">Entfernen</button>
</form>
{% else %}
+12 -89
View File
@@ -102,21 +102,6 @@
border: 1px solid #3a6970;
}
.hero-logo-strip {
margin-bottom: 0.7rem;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.4rem;
}
.hero-logo-strip img {
width: 100%;
height: 64px;
object-fit: cover;
border-radius: 8px;
border: 1px solid #4f7c82;
background: #ffffff;
}
.hero-card h3 {
font-size: clamp(1rem, 1.8vw, 1.35rem);
@@ -284,40 +269,6 @@
color: #ffffff;
}
.logo-wall {
margin-top: 2rem;
padding: 1.5rem;
border-radius: 18px;
border: 1px solid #d4ddd4;
background: #ffffff;
box-shadow: 0 14px 30px rgba(28, 45, 41, 0.05);
}
.logo-wall h2 {
color: #21444a;
margin-bottom: 0.4rem;
}
.logo-grid {
margin-top: 1rem;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.8rem;
}
.logo-item {
border: 1px solid #d8e0d8;
border-radius: 12px;
background: #f8faf7;
padding: 0.65rem;
}
.logo-item img {
width: 100%;
height: 160px;
object-fit: cover;
border-radius: 8px;
}
@keyframes rise {
from {
@@ -365,13 +316,6 @@
padding: 1.5rem;
}
.logo-grid {
grid-template-columns: 1fr;
}
.hero-logo-strip {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
@@ -382,7 +326,7 @@
<span class="eyebrow">Lehrmittelverwaltungssoftware für Schulen</span>
<h1>Lehrmittel, Schul-IT und Ausleihen in einem sicheren System.</h1>
<p>
Invario ist eine Lehrmittelverwaltungssoftware, die Schulbibliothek, Inventar und digitale Ausleihe zusammenfuehrt. Prozesse bleiben bewusst schlank und schnell, waehrend Sicherheitsfunktionen wie rollenbasierte Rechte, Sitzungsabsicherung und nachvollziehbare Protokolle im Hintergrund dauerhaft aktiv sind.
Invario ist eine Lehrmittelverwaltungssoftware, die Schulbibliothek, Inventar und digitale Ausleihe zusammenführt. Prozesse bleiben bewusst schlank und schnell, während Sicherheitsfunktionen wie rollenbasierte Rechte, Sitzungsabsicherung und nachvollziehbare Protokolle im Hintergrund dauerhaft aktiv sind.
</p>
<div class="cta-row">
{% if 'username' in session %}
@@ -391,16 +335,11 @@
<a class="btn btn-primary" href="{{ url_for('login') }}">Buchungsoption nutzen</a>
{% endif %}
<a class="btn btn-secondary" href="{{ url_for('dienstleistungen') }}">Leistungen ansehen</a>
<a class="btn btn-secondary" href="{{ url_for('tutorial') }}">Tutorial ansehen</a>
<a class="btn btn-secondary" href="{{ url_for('team') }}">Team ansehen</a>
<a class="btn btn-secondary" href="{{ url_for('kontakt') }}">Direkt kontaktieren</a>
</div>
</div>
<aside class="hero-card" aria-label="Kurzfakten für Schulen">
<div class="hero-logo-strip" aria-label="Logo Varianten">
<img src="{{ url_for('static', filename='images/logo-1.jpeg', v='20260330') }}" alt="Invario Logo 1">
<img src="{{ url_for('static', filename='images/logo-2.jpeg', v='20260330') }}" alt="Invario Logo 2">
<img src="{{ url_for('static', filename='images/logo-3.jpeg', v='20260330') }}" alt="Invario Logo 3">
</div>
<h3>Was Schulen besonders schätzen</h3>
<p>Klare Abläufe, feste Ansprechpartner und verständliche Oberflächen für den täglichen Einsatz.</p>
<div class="stats">
@@ -412,16 +351,16 @@
</section>
<section class="section" id="nutzen" style="animation-delay: 100ms;">
<h2>Vorteile fuer Verwaltung und Bibliothek</h2>
<p>Alle Daten zu Lehrmitteln, Geraeten und Ausleihen sind gebuendelt, sodass Rückfragen schnell geklaert werden koennen.</p>
<h2>Vorteile für Verwaltung und Bibliothek</h2>
<p>Alle Daten zu Lehrmitteln, Geraeten und Ausleihen sind gebündelt, sodass Rückfragen schnell geklärt werden können.</p>
<div class="section-grid">
<article class="card">
<h3>Lehrmittel pro Klasse steuern</h3>
<p>Buecher und Unterrichtsmaterialien lassen sich klassenweise ausgeben und zum Schuljahresende sauber rueckfuehren.</p>
<p>Bücher und Unterrichtsmaterialien lassen sich klassenweise ausgeben und zum Schuljahresende sauber rückführen.</p>
</article>
<article class="card">
<h3>Scanner-gestuetzte Ausleihe</h3>
<p>Barcode- oder QR-Workflows beschleunigen Ausgabe und Ruecknahme deutlich, auch bei grossen Bestandsmengen.</p>
<p>Barcode- oder QR-Workflows beschleunigen Ausgabe und Rücknahme deutlich, auch bei grossen Bestandsmengen.</p>
</article>
<article class="card">
<h3>IT- und Medienbestand kombiniert</h3>
@@ -443,8 +382,8 @@
</div>
<div class="routine">
<div>
<b>Rueckfrage zu ausgeliehenen Medien</b>
Verfuegbarkeit, Entleiher und Rueckgabedatum sind sofort sichtbar, ohne manuelle Listenpflege.
<b>Rückfrage zu ausgeliehenen Medien</b>
Verfügbarkeit, Entleiher und Rückgabedatum sind sofort sichtbar, ohne manuelle Listenpflege.
</div>
<span>Ausleihe</span>
</div>
@@ -459,8 +398,8 @@
</section>
<section class="section" id="sicherheit" style="animation-delay: 220ms;">
<h2>Erweiterte Sicherheit fuer den Schulbetrieb</h2>
<p>Die Plattform setzt auf Datenschutz und Betriebssicherheit, damit personenbezogene Daten und Inventardaten langfristig geschuetzt bleiben.</p>
<h2>Erweiterte Sicherheit für den Schulbetrieb</h2>
<p>Die Plattform setzt auf Datenschutz und Betriebssicherheit, damit personenbezogene Daten und Inventardaten langfristig geschützt bleiben.</p>
<div class="section-grid">
<article class="card">
<h3>Rollen- und Bereichstrennung</h3>
@@ -468,31 +407,15 @@
</article>
<article class="card">
<h3>Protokollierung und Nachweis</h3>
<p>Ausgaben, Ruecknahmen und kritische Admin-Aktionen werden dokumentiert und sind jederzeit auditierbar.</p>
<p>Ausgaben, Rücknahmen und kritische Admin-Aktionen werden dokumentiert und sind jederzeit auditierbar.</p>
</article>
<article class="card">
<h3>Gesicherte Web-Session</h3>
<p>Sicherheits-Header, HttpOnly-Cookies und klare Session-Richtlinien schuetzen den laufenden Betrieb vor typischen Webangriffen.</p>
<p>Sicherheits-Header, HttpOnly-Cookies und klare Session-Richtlinien schützen den laufenden Betrieb vor typischen Webangriffen.</p>
</article>
</div>
</section>
<section class="logo-wall" id="logos">
<h2>Einblicke und Logo-Varianten</h2>
<p>Die folgenden Grafiken sind direkt aus dem statischen Bereich Ihrer Website eingebunden.</p>
<div class="logo-grid">
<figure class="logo-item">
<img src="{{ url_for('static', filename='images/logo-1.jpeg') }}" alt="Invario Logo Variante 1">
</figure>
<figure class="logo-item">
<img src="{{ url_for('static', filename='images/logo-2.jpeg') }}" alt="Invario Logo Variante 2">
</figure>
<figure class="logo-item">
<img src="{{ url_for('static', filename='images/logo-3.jpeg') }}" alt="Invario Logo Variante 3">
</figure>
</div>
</section>
<section class="section" id="ablauf" style="animation-delay: 260ms;">
<h2>So verläuft die Zusammenarbeit</h2>
<p>Ein klarer Prozess sorgt für Sicherheit, besonders bei begrenzten Zeitfenstern im Schulbetrieb.</p>
+109 -33
View File
@@ -1,47 +1,123 @@
{% extends "base.html" %}
{% block title %}Unser Ansatz | Invario{% endblock %}
{% block title %}Team | Invario{% endblock %}
{% block content %}
<section class="page-hero">
<h1>Unser Ansatz für Schulen</h1>
<p>Die Arbeitsweise ist strukturiert, persönlich und auf den praktischen Nutzen in der täglichen Verwaltung ausgerichtet.</p>
<h1>Unser Team für Schulen</h1>
<p>Unsere Ansprechpartner mit klaren Verantwortungen. Inhalte und Fotos werden direkt im Admin-Bereich gepflegt.</p>
</section>
<section class="grid">
<article class="member">
<h2>Verständliche Beratung</h2>
<p>Technische Möglichkeiten werden in klare Entscheidungen übersetzt: Was bringt im Schulalltag wirklich Entlastung und was nicht.</p>
<p><strong>Ergebnis:</strong> Keine unnötigen Funktionen, sondern ein klarer Fahrplan mit nachvollziehbaren Prioritäten.</p>
</article>
<article class="member">
<h2>Saubere Einführung</h2>
<p>Das System wird gemeinsam mit den verantwortlichen Teams eingerichtet, ohne den laufenden Betrieb zu überlasten.</p>
<p><strong>Ergebnis:</strong> Schrittweise Umstellung mit Schulung direkt an typischen Arbeitsabläufen.</p>
</article>
<article class="member">
<h2>Zuverlässige Begleitung</h2>
<p>Nach der Einführung bleibt das Team erreichbar und unterstützt bei Anpassungen, Fragen und neuen Anforderungen.</p>
<p><strong>Ergebnis:</strong> Feste Ansprechpartner und verlässliche Rückmeldung statt wechselnder Kontaktwege.</p>
</article>
<section class="team-grid" aria-label="Team Cards">
{% for member in team_members %}
<article class="member-card">
{% if member and member.photo %}
<img class="member-photo" src="{{ url_for('static', filename=member.photo) }}" alt="Teamfoto {{ member.name }}">
{% endif %}
<div class="member-content">
<p class="member-slot">Teammitglied</p>
<h2>{{ member.name }}</h2>
<p class="member-role">{{ member.role }}</p>
<p><strong>Arbeit:</strong> {{ member.work }}</p>
{% if member.bio %}
<p>{{ member.bio }}</p>
{% endif %}
</div>
</article>
{% else %}
<article class="member-card">
<div class="member-content">
<p class="member-slot">Team</p>
<h2>Noch keine Einträge vorhanden</h2>
<p class="member-role">Der Admin kann Teammitglieder im Bereich „Admin > Team Cards“ hinzufügen.</p>
</div>
</article>
{% endfor %}
</section>
<section class="references">
<h2>Werte, nach denen wir arbeiten</h2>
<ul>
<li>Klarheit vor Komplexität</li>
<li>Datenschutz als Standard, nicht als Zusatz</li>
<li>Respekt für die Arbeitsrealität in Schulen</li>
<li>Verbindliche Kommunikation und transparente Schritte</li>
</ul>
<section class="values">
<h2>Arbeitsprinzipien</h2>
<p>Direkte Kommunikation, saubere Einführung und verbindliche Betreuung im laufenden Betrieb.</p>
</section>
<style>
.page-hero { margin-bottom: 1.2rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0.9rem; }
.member, .references { background: #fff; border: 1px solid #d8dfd7; border-radius: 14px; padding: 1rem; box-shadow: 0 10px 24px rgba(25, 45, 41, 0.04); }
.member h2, .references h2 { color: #265057; margin-bottom: 0.45rem; font-size: clamp(1.05rem, 1.8vw, 1.35rem); }
.references { margin-top: 0.9rem; }
.references ul { margin: 0; padding-left: 1rem; color: #4a6267; display: grid; gap: 0.5rem; }
.page-hero {
margin-bottom: 1.2rem;
}
.team-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.member-card {
background: #ffffff;
border: 1px solid #d8dfd7;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 12px 24px rgba(25, 45, 41, 0.06);
display: grid;
grid-template-rows: auto 1fr;
}
.member-photo {
width: 100%;
height: 220px;
object-fit: cover;
background: #eef2ee;
}
.member-content {
padding: 1rem;
display: grid;
gap: 0.5rem;
}
.member-slot {
margin: 0;
color: #5f7479;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.member-content h2 {
margin: 0;
color: #264f56;
font-size: clamp(1.12rem, 1.8vw, 1.42rem);
}
.member-role {
margin: 0;
color: #3b6168;
font-weight: 700;
}
.values {
margin-top: 1rem;
background: #ffffff;
border: 1px solid #d8dfd7;
border-radius: 14px;
padding: 1rem;
}
.values h2 {
color: #265057;
margin-bottom: 0.45rem;
font-size: clamp(1.05rem, 1.8vw, 1.35rem);
}
@media (max-width: 680px) {
.team-grid {
grid-template-columns: 1fr;
}
.member-photo {
height: 200px;
}
}
</style>
{% endblock %}
-64
View File
@@ -1,64 +0,0 @@
{% extends "base.html" %}
{% block title %}Tutorial | Invario{% endblock %}
{% block content %}
<section class="page-hero">
<h1>Tutorial: Sicherer Start mit Invario</h1>
<p>Diese Schritt-für-Schritt-Anleitung zeigt den Einstieg in Inventar, Lizenzen und Supportprozesse mit besonderem Fokus auf Sicherheit im Alltag.</p>
</section>
<section class="grid">
<article class="card">
<h2>1. Nutzerkonten und Rollen anlegen</h2>
<p>Zuerst werden Rollen für Verwaltung und Technik eingerichtet. Jede Rolle erhält nur die benötigten Rechte, um Risiken durch Überberechtigungen zu vermeiden.</p>
</article>
<article class="card">
<h2>2. Inventardaten strukturiert übernehmen</h2>
<p>Geräte, Räume und Zuständigkeiten werden einheitlich erfasst. Klare Datenstrukturen verbessern die Nachvollziehbarkeit und unterstützen Prüfungen.</p>
</article>
<article class="card">
<h2>3. Lizenzfristen aktiv überwachen</h2>
<p>Lizenzlaufzeiten und Erinnerungen werden zentral gepflegt. So lassen sich Ausfälle vermeiden und Fristen rechtzeitig bearbeiten.</p>
</article>
<article class="card">
<h2>4. Supportabläufe definieren</h2>
<p>Tickets erhalten klare Prioritäten und feste Statuswerte. Das schafft Transparenz und beschleunigt die Bearbeitung im Tagesbetrieb.</p>
</article>
</section>
<section class="security-box">
<h2>Sicherheitsleitfaden für den Betrieb</h2>
<ul>
<li><strong>Zugriffsschutz:</strong> Rollenbasiertes Arbeiten mit getrennten Zuständigkeiten.</li>
<li><strong>Sitzungssicherheit:</strong> Session-Cookies mit sicheren Standardeinstellungen.</li>
<li><strong>Übertragung:</strong> Einsatz sicherer Protokolle und Schutz-Header für Webanfragen.</li>
<li><strong>Nachvollziehbarkeit:</strong> Protokollierung relevanter Änderungen und Aktionen.</li>
<li><strong>Datenschutz:</strong> Datenminimierung und dokumentierte Verarbeitungsprozesse.</li>
<li><strong>Kontinuität:</strong> Regelmäßige Backups und Wiederanlaufplanung.</li>
</ul>
</section>
<section class="next-step">
<h2>Nächster Schritt</h2>
<p>Für die Einführung empfiehlt sich ein kurzer Pilotbetrieb mit realen Beispieldaten, bevor die vollständige Übernahme in den Produktivbetrieb erfolgt.</p>
{% if 'username' in session %}
<a class="btn" href="{{ url_for('appointments') }}">Pilotstart planen</a>
{% else %}
<a class="btn" href="{{ url_for('login') }}">Login für Buchung</a>
{% endif %}
</section>
<style>
.page-hero { margin-bottom: 1.2rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0.9rem; }
.card { background: #fff; border: 1px solid #d8dfd7; border-radius: 14px; padding: 1rem; box-shadow: 0 10px 24px rgba(25, 45, 41, 0.04); }
.card h2 { color: #265057; margin-bottom: 0.45rem; font-size: clamp(1.05rem, 1.8vw, 1.35rem); }
.security-box { margin-top: 0.9rem; background: linear-gradient(135deg, #f8f4eb 0%, #eef3eb 100%); border: 1px solid #d4dbd2; border-radius: 14px; padding: 1.1rem; }
.security-box h2 { color: #2a4d53; margin-bottom: 0.45rem; font-size: clamp(1.1rem, 2.2vw, 1.55rem); }
.security-box ul { margin: 0; padding-left: 1.1rem; color: #4a6267; display: grid; gap: 0.5rem; }
.next-step { margin-top: 0.9rem; background: #fff; border: 1px solid #d8dfd7; border-radius: 14px; padding: 1rem; box-shadow: 0 10px 24px rgba(25, 45, 41, 0.04); }
.next-step h2 { color: #265057; margin-bottom: 0.45rem; font-size: clamp(1.05rem, 1.8vw, 1.35rem); }
.btn { margin-top: 0.5rem; display: inline-flex; align-items: center; justify-content: center; border-radius: 999px; border: 1px solid #1d5a63; background: linear-gradient(120deg, #296f78 0%, #1a5057 100%); color: #fff; padding: 0.54rem 0.92rem; font-weight: 700; }
</style>
{% endblock %}