implementation for the warenkorb system

This commit is contained in:
2026-08-24 21:49:18 +02:00
parent 75c051a20d
commit 9d33e9c996
3 changed files with 415 additions and 424 deletions
+58 -34
View File
@@ -1972,34 +1972,54 @@ def preise():
@app.route('/preise/book-option', methods=['POST'])
@login_required
def book_option_package():
package_raw = _sanitize_text(request.form.get("package") or "", 40).lower()
# Unterstützt "packages" (Warenkorb) und Fallback auf "package" (altes Formular)
packages_raw = _sanitize_text(request.form.get("packages") or request.form.get("package") or "", 200).lower()
booking_flow = _sanitize_text(request.form.get("flow") or "payment", 24).lower()
if booking_flow not in BOOKING_FLOW_MAP:
booking_flow = "payment"
selected_package = BOOKING_PACKAGE_MAP.get(package_raw)
if not selected_package:
flash("Ungültige Buchungsoption ausgewählt.", "error")
if not packages_raw:
flash("Keine Buchungsoption ausgewählt.", "error")
return redirect(url_for("preise"))
return redirect(url_for("booking_payment", package=package_raw, flow=booking_flow))
return redirect(url_for("booking_payment", packages=packages_raw, flow=booking_flow))
@app.route('/booking/payment', methods=['GET', 'POST'])
@login_required
def booking_payment():
package_raw = _sanitize_text((request.values.get("package") or ""), 40).lower()
booking_flow = _sanitize_text((request.values.get("flow") or "payment"), 24).lower()
# Hole kommagetrennte Liste (z. B. "inventarsystem,terminverwaltung")
packages_str = _sanitize_text(request.values.get("packages") or request.values.get("package") or "", 200).lower()
booking_flow = _sanitize_text(request.values.get("flow") or "payment", 24).lower()
if booking_flow not in BOOKING_FLOW_MAP:
booking_flow = "payment"
selected_package = BOOKING_PACKAGE_MAP.get(package_raw)
package_price = BOOKING_PRICE_MAP.get(package_raw)
if not selected_package:
flash("Ungültige Buchungsoption ausgewählt.", "error")
if not packages_str:
flash("Keine Buchungsoption ausgewählt.", "error")
return redirect(url_for("preise"))
package_list = [p.strip() for p in packages_str.split(',') if p.strip()]
# Validiere Pakete, sammle Namen und berechne Gesamtpreis
selected_package_names = []
total_price = 0.0
for pkg in package_list:
name = BOOKING_PACKAGE_MAP.get(pkg)
if name:
selected_package_names.append(name)
total_price += BOOKING_PRICE_MAP.get(pkg, 0.0)
else:
flash(f"Ungültige Buchungsoption: {pkg}", "error")
return redirect(url_for("preise"))
# Zusammengefasster String für Frontend und Datenbank (z.B. "Inventarsystem, Terminplaner")
selected_package = ", ".join(selected_package_names)
package_raw = packages_str # Speichert alle raw-keys für DB
flow_config = BOOKING_FLOW_MAP[booking_flow]
form_data = {
"contact_person": _sanitize_text(request.values.get("contact_person") or "", 120),
"school_name": _sanitize_text(request.values.get("school_name") or "", 160),
@@ -2018,7 +2038,7 @@ def booking_payment():
review_data = {
"package": selected_package,
"package_key": package_raw,
"package_price": package_price,
"package_price": total_price,
"flow": booking_flow,
"title": flow_config["title"],
"intro": flow_config["intro"],
@@ -2031,6 +2051,7 @@ def booking_payment():
if request.method == "GET":
return render_template("schnell_buchung.html", **review_data)
# --- POST Logik & Validierung ---
required_fields = {
"contact_person": "Ansprechpartner",
"school_name": "Schule/Einrichtung",
@@ -2039,6 +2060,7 @@ def booking_payment():
"billing_zip": "PLZ",
"billing_city": "Ort",
}
missing_labels = [label for key, label in required_fields.items() if not form_data.get(key)]
if missing_labels:
flash(f"Bitte folgende Pflichtfelder ausfüllen: {', '.join(missing_labels)}.", "error")
@@ -2079,20 +2101,17 @@ def booking_payment():
"message": message,
"created_at": _utc_now_iso(),
"booking_flow": booking_flow,
"booking_package": package_raw,
"booking_package": package_raw, # Hier steht nun z.B. "inventarsystem,terminverwaltung"
"booking_data": form_data,
}
)
if booking_flow == "payment":
# Do NOT create an invoice directly. Instead request provisioning via server_steering
booking_number = f"BOOK-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}"
# Build a subdomain from the school name (fallback to username)
# Use lowercase, sanitized school name and check for existing instances.
raw_base = form_data.get("school_name") or session.get("username") or booking_number
base = _slugify_subdomain(raw_base).lower()[:51]
# Check existing instances and require the user to choose a different name on conflict
try:
existing = steering.instace.list() or []
except Exception:
@@ -2103,11 +2122,10 @@ def booking_payment():
return render_template("schnell_buchung.html", **review_data)
subdomain = base[:63]
provision_status = "not_started"
provision_port = None
provision_error = ""
provision_config_ok = False
provision_config_ok = True # Startet mit True, wird False wenn ein Modul fehlschlägt
try:
try:
@@ -2115,6 +2133,7 @@ def booking_payment():
except Exception as e:
port = False
provision_error = str(e)
provision_config_ok = False
if port and port is not False:
provision_status = "started"
@@ -2123,19 +2142,24 @@ def booking_payment():
except Exception:
provision_port = port
# Configure module if applicable
module = None
if package_raw in {"inventarsystem", "buecherei", "terminverwaltung", "emailversand", "starter", "advanced"}:
module = package_raw
if module:
# Multi-Modul-Konfiguration: Startpaket wird in Inventar + Bücherei aufgeschlüsselt
modules_to_provision = set()
for pkg in package_list:
if pkg == "startpaket":
modules_to_provision.update(["inventarsystem", "buecherei"])
elif pkg in {"inventarsystem", "buecherei", "terminverwaltung", "emailversand", "starter", "advanced"}:
modules_to_provision.add(pkg)
# Einzelne Module in der erstellten Instanz editieren/aktivieren
for mod in modules_to_provision:
try:
edit_ok = steering.instace.edit(subdomain, module)
provision_config_ok = bool(edit_ok)
if not provision_config_ok and not provision_error:
provision_error = f"Module configuration failed for {module}"
edit_ok = steering.instace.edit(subdomain, mod)
if not edit_ok:
provision_config_ok = False
provision_error += f" | Edit failed for {mod}"
except Exception as e:
provision_config_ok = False
provision_error = str(e)
provision_error += f" | {mod}: {str(e)}"
else:
provision_status = "failed"
if not provision_error:
@@ -2149,7 +2173,7 @@ def booking_payment():
"booking_number": booking_number,
"package": package_raw,
"package_label": selected_package,
"amount_eur": BOOKING_PRICE_MAP.get(package_raw, 0.0),
"amount_eur": total_price,
"booking_flow": booking_flow,
"booking_data": form_data,
"assigned_admin_username": assigned_admin_username,
@@ -2159,7 +2183,7 @@ def booking_payment():
"provision_status": provision_status,
"provision_port": provision_port or "",
"provision_config_ok": provision_config_ok,
"provision_error": provision_error,
"provision_error": provision_error.strip(" | "),
"created_at": _utc_now_iso(),
"source": "booking_payment",
}
@@ -2173,8 +2197,8 @@ def booking_payment():
req_client.close()
except PyMongoError:
flash("Buchungsanfrage konnte nicht gesendet werden.", "error")
return redirect(url_for("booking_payment", package=package_raw, flow=booking_flow))
flash("Buchungsanfrage konnte nicht gesendet werden. Bitte versuchen Sie es erneut.", "error")
return redirect(url_for("booking_payment", packages=package_raw, flow=booking_flow))
finally:
if client:
client.close()
@@ -2185,13 +2209,13 @@ def booking_payment():
else f"Buchung für {selected_package} wurde erfolgreich erfasst. Die Rechnung wird per E-Mail versendet."
)
flash(success_text, "success")
if booking_flow == "payment":
return redirect(url_for("my_invoices"))
else:
return redirect(url_for("user_chat"))
@app.route('/instance_request/delete', methods=['POST'])
@login_required
def instance_request_delete():
+313 -383
View File
@@ -5,6 +5,7 @@
{% block head %}
{{ super() }}
<style>
/* Hero & Grid Styles (Beibehalten) */
.booking-hero {
background: linear-gradient(135deg, rgba(28, 90, 99, 0.14), rgba(255, 255, 255, 0.65)),
linear-gradient(180deg, #fffefb 0%, #f2f5f2 100%);
@@ -16,7 +17,6 @@
overflow: hidden;
position: relative;
}
.booking-hero::before {
content: "";
position: absolute;
@@ -24,24 +24,9 @@
background: radial-gradient(circle at top right, rgba(34, 98, 128, 0.16), transparent 36%);
pointer-events: none;
}
.booking-hero > * {
position: relative;
z-index: 1;
}
.booking-hero h1 {
margin-bottom: 0.65rem;
color: #133d4a;
font-size: clamp(2rem, 3.5vw, 3.2rem);
}
.booking-hero p {
margin: 0.75rem 0 0;
max-width: 780px;
color: #3b5f69;
font-size: clamp(1rem, 1.05vw, 1.1rem);
}
.booking-hero > * { position: relative; z-index: 1; }
.booking-hero h1 { margin-bottom: 0.65rem; color: #133d4a; font-size: clamp(2rem, 3.5vw, 3.2rem); }
.booking-hero p { margin: 0.75rem 0 0; max-width: 780px; color: #3b5f69; font-size: clamp(1rem, 1.05vw, 1.1rem); }
.packages-grid {
display: grid;
@@ -63,304 +48,105 @@
gap: 1.2rem;
transition: transform 0.3s ease, box-shadow 0.3s ease, border-color 0.3s ease;
}
.package-card:hover {
transform: translateY(-3px);
border-color: #b2d4dc;
box-shadow: 0 24px 48px rgba(23, 44, 57, 0.1);
}
.package-card__header {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 1.2rem;
}
.package-card__header > div {
min-width: 0;
width: 100%;
}
.package-card__tag {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.8rem;
border-radius: 999px;
background: #e6f5f8;
color: #106476;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.01em;
margin-bottom: 0.6rem;
}
.package-card__name {
margin: 0;
color: #143f46;
font-size: clamp(1.4rem, 2.5vw, 1.8rem);
line-height: 1.2;
word-wrap: break-word;
}
.package-card__price {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
font-weight: 700;
color: #102d37;
white-space: normal;
margin-top: 0.2rem;
}
.package-card__price span {
font-size: clamp(1.8rem, 3vw, 2.4rem);
letter-spacing: -0.04em;
line-height: 1.1;
}
.package-card__old-price {
color: #c62828;
text-decoration: line-through;
margin-right: 0.5rem;
font-size: clamp(1rem, 2.2vw, 1.1rem);
font-weight: 700;
opacity: 0.95;
}
.package-card__new-price {
color: #0b6440;
font-size: clamp(1.8rem, 3vw, 2.4rem);
font-weight: 800;
letter-spacing: -0.04em;
}
.package-card__price small {
color: #57717b;
font-size: 0.95rem;
}
.package-card__description {
margin: 0;
color: #4b6a72;
line-height: 1.75;
}
.package-card__features {
margin: 1rem 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.75rem;
flex: 1 1 auto;
}
.package-card__features li {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem;
align-items: flex-start;
color: #4f6a72;
font-size: 0.98rem;
}
.package-card__features li::before {
content: "✓";
color: #187490;
font-weight: 700;
line-height: 1.2;
}
.package-card__actions {
margin-top: auto;
padding-top: 0.25rem;
}
.package-card__actions form,
.package-card__actions a {
width: 100%;
display: inline-flex;
}
.package-card:hover { transform: translateY(-3px); border-color: #b2d4dc; box-shadow: 0 24px 48px rgba(23, 44, 57, 0.1); }
.package-card.highlight { border-color: #106476; box-shadow: 0 18px 40px rgba(16, 100, 118, 0.15); }
.package-card__header { display: flex; flex-direction: column; gap: 1.2rem; }
.package-card__tag { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.45rem 0.8rem; border-radius: 999px; background: #e6f5f8; color: #106476; font-size: 0.9rem; font-weight: 700; margin-bottom: 0.6rem; }
.package-card__tag.highlight-tag { background: #106476; color: white; }
.package-card__name { margin: 0; color: #143f46; font-size: clamp(1.4rem, 2.5vw, 1.8rem); line-height: 1.2; }
.package-card__price { display: inline-flex; align-items: baseline; gap: 0.35rem; font-weight: 700; color: #102d37; margin-top: 0.2rem; }
.package-card__price span { font-size: clamp(1.8rem, 3vw, 2.4rem); letter-spacing: -0.04em; }
.package-card__price small { color: #57717b; font-size: 0.95rem; }
.package-card__description { margin: 0; color: #4b6a72; line-height: 1.75; }
.package-card__features { margin: 1rem 0 0; padding: 0; list-style: none; display: grid; gap: 0.75rem; flex: 1 1 auto; }
.package-card__features li { display: grid; grid-template-columns: auto 1fr; gap: 0.75rem; color: #4f6a72; font-size: 0.98rem; }
.package-card__features li::before { content: "✓"; color: #187490; font-weight: 700; }
.package-card__actions { margin-top: auto; padding-top: 0.25rem; }
.btn {
width: 100%;
border: 1px solid #1c5a63;
background: linear-gradient(120deg, #1d6d79 0%, #164b55 100%);
color: #ffffff;
border-radius: 999px;
padding: 0.95rem 1.2rem;
font-weight: 700;
cursor: pointer;
text-align: center;
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
width: 100%; border: 1px solid #1c5a63; background: linear-gradient(120deg, #1d6d79 0%, #164b55 100%);
color: #ffffff; border-radius: 999px; padding: 0.95rem 1.2rem; font-weight: 700;
cursor: pointer; text-align: center; transition: all 0.2s ease;
display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
}
.btn:hover, .btn:focus-visible { transform: translateY(-1px); box-shadow: 0 12px 24px rgba(28, 90, 99, 0.22); }
.btn.secondary { border-color: #c7dde3; background: #f7fbfb; color: #1d5f6f; }
.btn.outline { background: transparent; color: #1d6d79; }
.btn.outline:hover { background: #f0f7f8; }
.btn:hover,
.btn:focus-visible {
transform: translateY(-1px);
box-shadow: 0 12px 24px rgba(28, 90, 99, 0.22);
}
.notice { padding: 1.3rem 1.35rem; border-radius: 20px; background: #ffffff; border: 1px solid #dfe7ea; color: #304f5a; box-shadow: 0 12px 24px rgba(23, 44, 57, 0.04); }
.notice strong { color: #15444c; }
.btn.secondary {
border-color: #c7dde3;
background: #f7fbfb;
color: #1d5f6f;
}
.notice {
padding: 1.3rem 1.35rem;
border-radius: 20px;
background: #ffffff;
border: 1px solid #dfe7ea;
color: #304f5a;
box-shadow: 0 12px 24px rgba(23, 44, 57, 0.04);
}
.notice strong {
color: #15444c;
}
@media (max-width: 760px) {
.booking-hero {
padding: 1.5rem 1.2rem;
border-radius: 18px;
}
.booking-hero h1 {
font-size: 1.8rem;
}
.packages-grid {
grid-template-columns: 1fr;
gap: 1.2rem;
}
.package-card {
padding: 1.4rem 1.2rem;
border-radius: 18px;
gap: 1rem;
}
.package-card__header {
gap: 0.8rem;
}
.package-card__price {
margin-top: 0;
}
.package-card__price span {
font-size: 2rem;
}
.btn {
padding: 1rem 1.2rem;
font-size: 1.05rem;
}
}
/* Custom Modal Styles */
/* Modals */
.custom-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: none;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1rem;
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
display: none; align-items: center; justify-content: center; z-index: 1000; padding: 1rem;
}
.custom-modal.active {
display: flex;
}
.custom-modal-backdrop {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(18, 49, 54, 0.4);
backdrop-filter: blur(4px);
}
.custom-modal.active { display: flex; }
.custom-modal-backdrop { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(18, 49, 54, 0.4); backdrop-filter: blur(4px); }
.custom-modal-content {
position: relative;
background: #ffffff;
border-radius: 20px;
padding: 2.2rem;
max-width: 500px;
width: 100%;
box-shadow: 0 24px 54px rgba(23, 44, 57, 0.2);
z-index: 101;
position: relative; background: #ffffff; border-radius: 20px; padding: 2.2rem;
max-width: 550px; width: 100%; box-shadow: 0 24px 54px rgba(23, 44, 57, 0.2); z-index: 101;
animation: modalEnter 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modalEnter { from { opacity: 0; transform: translateY(20px) scale(0.95); } to { opacity: 1; transform: translateY(0) scale(1); } }
.custom-modal-content h3 { margin-top: 0; margin-bottom: 1rem; color: #133d4a; }
.custom-modal-actions { display: flex; gap: 1rem; margin-top: 2rem; justify-content: flex-end; }
/* Cart Specific Styles */
.cart-items-list { margin: 1.5rem 0; display: flex; flex-direction: column; gap: 1rem; }
.cart-item { display: flex; justify-content: space-between; align-items: center; padding: 1rem; background: #f7fbfb; border: 1px solid #e1eced; border-radius: 12px; }
.cart-item-info { display: flex; flex-direction: column; }
.cart-item-title { font-weight: 700; color: #133d4a; }
.cart-item-price { color: #187490; font-size: 0.95rem; }
.cart-item-remove { background: none; border: none; color: #c62828; cursor: pointer; font-size: 0.9rem; font-weight: 700; padding: 0.5rem; transition: opacity 0.2s; }
.cart-item-remove:hover { opacity: 0.7; }
.cart-total-row { display: flex; justify-content: space-between; font-size: 1.2rem; font-weight: 800; color: #133d4a; margin-top: 1rem; padding-top: 1rem; border-top: 2px solid #e1eced; }
@keyframes modalEnter {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
/* Floating Cart Button */
.floating-cart {
position: fixed; bottom: 2rem; right: 2rem; background: #164b55; color: white;
width: 65px; height: 65px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
box-shadow: 0 10px 25px rgba(22, 75, 85, 0.4); cursor: pointer; z-index: 900;
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
display: none; /* Hidden when empty */
}
.custom-modal-content h3 {
margin-top: 0;
margin-bottom: 1rem;
color: #133d4a;
.floating-cart.visible { display: flex; animation: popIn 0.4s; }
.floating-cart:hover { transform: scale(1.08); }
.cart-badge {
position: absolute; top: -5px; right: -5px; background: #c62828; color: white;
font-size: 0.8rem; font-weight: 700; width: 24px; height: 24px; border-radius: 50%;
display: flex; align-items: center; justify-content: center; border: 2px solid white;
}
.floating-cart svg { width: 28px; height: 28px; fill: currentColor; }
@keyframes popIn { from { transform: scale(0); } to { transform: scale(1); } }
.custom-modal-content p {
color: #4b6a72;
margin-bottom: 0.8rem;
}
.custom-modal-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
justify-content: flex-end;
}
.custom-modal-actions button {
width: auto;
min-width: 120px;
}
@media (max-width: 600px) {
.custom-modal-content {
padding: 1.6rem;
border-radius: 16px;
}
.custom-modal-actions {
flex-direction: column;
}
.custom-modal-actions button {
width: 100%;
}
/* Upsell Highlights */
.upsell-box { background: #e6f5f8; border: 1px solid #b2d4dc; border-radius: 12px; padding: 1.2rem; margin: 1rem 0; }
.upsell-box h4 { margin: 0 0 0.5rem 0; color: #106476; }
@media (max-width: 760px) {
.custom-modal-actions { flex-direction: column; }
.custom-modal-actions button { width: 100%; }
.floating-cart { bottom: 1.5rem; right: 1.5rem; width: 55px; height: 55px; }
}
</style>
{% endblock %}
{% block content %}
<section class="booking-hero">
<h1>Preise</h1>
<p>Preise zu unseren Modulen
<p>Unsere Module und Pakete sind klar strukturiert und transparent. Wählen Sie die passende Lösung für Ihre Schule und senden Sie die Anfrage direkt an unser Admin-Team.</p>
<p>Nach der Anfrage wird sich ein Administrator zeitnah über das Portal mit Ihnen in Verbindung setzen, um die Buchung persönlich vor Ort zu besprechen oder einen direkten Start zu vereinbaren.</p>
<p>Unsere Server stehen in einem Deutschen Data-Center der Firma Strato und garantieren eine hohe Verfügbarkeit und Sicherheit Ihrer Daten.</p>
<h1>Preise & Module</h1>
<p>Unsere Module und Pakete sind klar strukturiert und transparent. Stellen Sie sich Ihre Lösung flexibel zusammen.</p>
<p>Nach Ihrer Anfrage meldet sich unser Admin-Team, um die Bereitstellung oder einen Termin zur Abstimmung einzuplanen. Die Server stehen in einem deutschen Rechenzentrum (Strato) DSGVO-konform und hochverfügbar.</p>
</section>
<section class="packages-grid" aria-label="Buchungspakete">
<!-- INVENTARSYSTEM -->
<article class="package-card">
<div class="package-card__header">
<div>
@@ -368,30 +154,28 @@
<h2 class="package-card__name">Inventarsystem</h2>
</div>
<div class="package-card__price">
<span>350 </span>
<small>/ Jahr</small>
<span>350 </span><small>/ Jahr</small>
</div>
</div>
<p class="package-card__description">Der stabile Einstieg in die Schulverwaltung mit Inventarverwaltung, Benutzerrechten und Berichten für den gesamten Schulbetrieb.</p>
<p class="package-card__description">Der stabile Einstieg in die Schulverwaltung mit Inventarverwaltung, Benutzerrechten und Berichten.</p>
<ul class="package-card__features">
<li>Inventarverwaltung mit Rollenrechten</li>
<li>Basis-Support und Ticketing</li>
<li>Schulweite Übersichten und Berichte</li>
<li>Erweiterte Instanzverwaltung</li>
<li>Ihre Schullehrmittel auf einen Blick</li>
</ul>
<div class="package-card__actions">
{% if 'username' in session %}
<form method="POST" action="{{ url_for('book_option_package') }}" class="booking-form">
<input type="hidden" name="package" value="inventarsystem">
<button class="btn" type="button" onclick="openBookingModal(this.form)">Jetzt buchen</button>
</form>
<button class="btn add-to-cart-btn" data-id="inventarsystem" data-name="Inventarsystem" data-price="350" onclick="handleAddToCart(this)">
In den Warenkorb
</button>
{% else %}
<a class="btn secondary" href="{{ url_for('login') }}">Hinweis: Registrieren Sie sich oder loggen sich bitte ein, um einen gemeinsamen Termin zu vereinbaren.</a>
<a class="btn secondary" href="{{ url_for('login') }}">Zum Buchen einloggen</a>
{% endif %}
</div>
</article>
<!-- BIBLIOTHEKSSYSTEM -->
<article class="package-card">
<div class="package-card__header">
<div>
@@ -399,29 +183,27 @@
<h2 class="package-card__name">Bibliothekssystem</h2>
</div>
<div class="package-card__price">
<span>100 €</span>
<small>/ Jahr</small>
<span>100 €</span><small>/ Jahr</small>
</div>
</div>
<p class="package-card__description">Ein spezialisiertes Modul für Bibliotheken und Medienzentren mit Ausleihprozessen, Medienbeständen und historischer Transparenz.</p>
<p class="package-card__description">Spezialisiertes Modul für Bibliotheken und Medienzentren inkl. Ausleihprozessen und Historie.</p>
<ul class="package-card__features">
<li>Ausgabe- und Rückgabeprozesse</li>
<li>Bestandsübersichten für Medien</li>
<li>Transparente Historie pro Medium</li>
<li></li>
</ul>
<div class="package-card__actions">
{% if 'username' in session %}
<form method="POST" action="{{ url_for('book_option_package') }}" class="booking-form">
<input type="hidden" name="package" value="buecherei">
<button class="btn" type="button" onclick="openBookingModal(this.form)">Jetzt buchen</button>
</form>
<button class="btn add-to-cart-btn" data-id="buecherei" data-name="Bibliothekssystem" data-price="100" onclick="handleAddToCart(this)">
In den Warenkorb
</button>
{% else %}
<a class="btn secondary" href="{{ url_for('login') }}">Mit Konto buchen</a>
<a class="btn secondary" href="{{ url_for('login') }}">Zum Buchen einloggen</a>
{% endif %}
</div>
</article>
<!-- TERMINPLANER -->
<article class="package-card">
<div class="package-card__header">
<div>
@@ -429,59 +211,57 @@
<h2 class="package-card__name">Terminplaner</h2>
</div>
<div class="package-card__price">
<span>100 </span>
<small>/ Jahr</small>
<span>100 </span><small>/ Jahr</small>
</div>
</div>
<p class="package-card__description">Ein für flexible Terminplanung entwickeltes Modul, dass dem Nutzer erlaubt, seine Termine für beispielsweise Elternsprechstunden komfortabel und schnell im Zugriff zu haben.</p>
<p class="package-card__description">Für flexible Terminplanung entwickelt (z.B. Elternsprechstunden), um Termine hürdenlos online anzubieten.</p>
<ul class="package-card__features">
<li>Flexible Slot- und Zeitfenster-Erstellung </li>
<li>Flexible Slot- und Zeitfenster-Erstellung</li>
<li>Hürdenlose Buchung ohne Registrierung</li>
<li>Echtzeit-Aktualisierung gebuchter Termine</li>
<li>Direkte Terminvergabe per Einladungslink</li>
<li>Echtzeit-Aktualisierung</li>
<li>Terminvergabe per Einladungslink</li>
</ul>
<div class="package-card__actions">
{% if 'username' in session %}
<form method="POST" action="{{ url_for('book_option_package') }}" class="booking-form">
<input type="hidden" name="package" value="Terminverwaltung">
<button class="btn" type="button" onclick="openBookingModal(this.form)">Jetzt buchen</button>
</form>
<button class="btn add-to-cart-btn" data-id="terminverwaltung" data-name="Terminplaner" data-price="100" onclick="handleAddToCart(this)">
In den Warenkorb
</button>
{% else %}
<a class="btn secondary" href="{{ url_for('login') }}">Mit Konto buchen</a>
<a class="btn secondary" href="{{ url_for('login') }}">Zum Buchen einloggen</a>
{% endif %}
</div>
</article>
<article class="package-card">
<!-- STARTPAKET (Kombi) -->
<article class="package-card highlight">
<div class="package-card__header">
<div>
<span class="package-card__tag">Paket</span>
<span class="package-card__tag highlight-tag">Sparpaket</span>
<h2 class="package-card__name">Startpaket</h2>
</div>
<div class="package-card__price">
<span>420 </span>
<small>/ Jahr</small>
<span>420 </span><small>/ Jahr</small>
</div>
</div>
<p class="package-card__description">Das Starterpaket enhält die Inventarverwaltung und das Bibliothekssystem, als ideale Komplettlösung.</p>
<p class="package-card__description">Enthält <strong>Inventarverwaltung</strong> und <strong>Bibliothekssystem</strong> als ideale Komplettlösung zum Vorzugspreis.</p>
<ul class="package-card__features">
<li>Das Inventarsystem idealer start Punkt</li>
<li>Das Bibliothekssystem als passende Ergänzung</li>
<li>Ein einfaches und intuitive Benutzeroberfläche</li>
<li>Der Perfekte Start für Ihre Digitalisierung</li>
<li>Beinhaltet alle Features des Inventarsystems</li>
<li>Beinhaltet alle Features des Bibliothekssystems</li>
<li>Perfekter Start für Ihre Digitalisierung</li>
<li><strong>30 € Ersparnis</strong> gegenüber Einzelkauf</li>
</ul>
<div class="package-card__actions">
{% if 'username' in session %}
<form method="POST" action="{{ url_for('book_option_package') }}" class="booking-form">
<input type="hidden" name="package" value="Terminverwaltung">
<button class="btn" type="button" onclick="openBookingModal(this.form)">Jetzt buchen</button>
</form>
<button class="btn add-to-cart-btn" data-id="startpaket" data-name="Startpaket (Inventar + Bibliothek)" data-price="420" onclick="handleAddToCart(this)">
In den Warenkorb
</button>
{% else %}
<a class="btn secondary" href="{{ url_for('login') }}">Mit Konto buchen</a>
<a class="btn secondary" href="{{ url_for('login') }}">Zum Buchen einloggen</a>
{% endif %}
</div>
</article>
<!-- SCHULTRÄGER -->
<article class="package-card">
<div class="package-card__header">
<div>
@@ -489,88 +269,238 @@
<h2 class="package-card__name">Schulträger</h2>
</div>
<div class="package-card__price">
<span>Auf Anfrage</span>
<span>Individuell</span>
</div>
</div>
<p class="package-card__description">Individuelles Angebot für Schulträger mit mehreren Schulen und speziellen Anforderungen an die Verwaltung.</p>
<p class="package-card__description">Individuelles Angebot für Schulträger mit mehreren Schulen und speziellen Anforderungen.</p>
<ul class="package-card__features">
<li>Mandantenfähige Verwaltung</li>
<li>Zentrale Übersicht aller Schulen</li>
<li>Individuelle Anpassungen</li>
<li>Individuelle Anpassungen & SSO</li>
<li>Persönlicher Ansprechpartner</li>
</ul>
<div class="package-card__actions">
{% if 'username' in session %}
<form method="POST" action="{{ url_for('book_option_package') }}" class="booking-form">
<input type="hidden" name="package" value="schultraeger">
<button class="btn" type="button" onclick="openBookingModal(this.form)">Jetzt anfragen</button>
</form>
<button class="btn add-to-cart-btn" data-id="schultraeger" data-name="Schulträger (Individuell)" data-price="0" onclick="handleAddToCart(this)">
Auf die Liste setzen
</button>
{% else %}
<a class="btn secondary" href="{{ url_for('login') }}">Mit Konto anfragen</a>
<a class="btn secondary" href="{{ url_for('login') }}">Zum Anfragen einloggen</a>
{% endif %}
</div>
</article>
</section>
<section class="notice">
{% if 'username' in session %}
<p><strong>Hinweis:</strong> Deine Buchungsanfrage wird direkt vom Admin-Team bearbeitet und deiner Schulinstanz zugeordnet.</p>
{% else %}
<p><strong>Hinweis:</strong> Bitte logge dich ein oder registriere dich, um einen Termin zu vereinbaren.</p>
{% endif %}
</section>
<!-- Floating Cart Button -->
<div class="floating-cart" id="cartFab" onclick="openCartModal()">
<svg viewBox="0 0 24 24">
<path d="M7 18c-1.1 0-1.99.9-1.99 2S5.9 22 7 22s2-.9 2-2-.9-2-2-2zM1 2v2h2l3.6 7.59-1.35 2.45c-.16.28-.25.61-.25.96 0 1.1.9 2 2 2h12v-2H7.42c-.14 0-.25-.11-.25-.25l.03-.12.9-1.63h7.45c.75 0 1.41-.41 1.75-1.03l3.58-6.49c.08-.14.12-.31.12-.48 0-.55-.45-1-1-1H5.21l-.94-2H1zm16 16c-1.1 0-1.99.9-1.99 2s.89 2 1.99 2 2-.9 2-2-.9-2-2-2z"/>
</svg>
<div class="cart-badge" id="cartBadge">0</div>
</div>
<!-- Custom Modal -->
<div class="custom-modal" id="bookingModal" aria-hidden="true" role="dialog" aria-modal="true">
<div class="custom-modal-backdrop" onclick="closeBookingModal()"></div>
<!-- Upsell Modal -->
<div class="custom-modal" id="upsellModal" aria-hidden="true" role="dialog">
<div class="custom-modal-backdrop" onclick="closeUpsellModal()"></div>
<div class="custom-modal-content">
<h3>Buchung bestätigen</h3>
<p>Möchten Sie dieses Paket jetzt direkt buchen oder zuerst einen Gesprächstermin vereinbaren?</p>
<p style="font-size: 0.95rem;">Bitte wählen sie eine der Beiden Optionen aus um mit dem Verfahren weiter fortzufahren.</p>
<h3>Tipp: Sparen Sie mit dem Startpaket!</h3>
<p>Sie sind dabei, ein Einzelmodul in den Warenkorb zu legen. Wussten Sie, dass Inventar- und Bibliothekssystem perfekt zusammenarbeiten?</p>
<div class="upsell-box">
<h4>Das Startpaket (420 € / Jahr)</h4>
<p style="font-size: 0.9rem; margin-bottom: 0;">Beinhaltet das Inventarsystem (regulär 350 €) und das Bibliothekssystem (regulär 100 €). Sie sparen 30 € jährlich!</p>
</div>
<div class="custom-modal-actions">
<button class="btn secondary" type="button" onclick="closeBookingModal()">Abbrechen</button>
<button class="btn" type="button" onclick="goToBookingReview('consultation')">Gesprächstermin vereinbaren</button>
<button class="btn" type="button" onclick="goToBookingReview('payment')">Buchen</button>
<button class="btn outline" type="button" onclick="declineUpsell()">Nein, beim Einzelmodul bleiben</button>
<button class="btn" type="button" onclick="acceptUpsell()">Zum Startpaket upgraden</button>
</div>
</div>
</div>
<!-- Cart Modal -->
<div class="custom-modal" id="cartModal" aria-hidden="true" role="dialog">
<div class="custom-modal-backdrop" onclick="closeCartModal()"></div>
<div class="custom-modal-content">
<h3>Ihr Warenkorb</h3>
<div id="cartEmptyMessage" style="display:none; color: #4b6a72;">Ihr Warenkorb ist noch leer.</div>
<div class="cart-items-list" id="cartItemsContainer">
<!-- Items werden per JS hier eingefügt -->
</div>
<div class="cart-total-row" id="cartTotalRow">
<span>Gesamtsumme:</span>
<span id="cartTotalPrice">0 €</span>
</div>
<div class="custom-modal-actions" id="cartActions">
<button class="btn secondary" type="button" onclick="closeCartModal()">Weiter umsehen</button>
<button class="btn outline" type="button" onclick="goToCheckout('consultation')">Beratungstermin anfragen</button>
<button class="btn" type="button" onclick="goToCheckout('payment')">Jetzt Zahlungspflichtig buchen</button>
</div>
</div>
</div>
<script>
// System Config
const bookingReviewBaseUrl = "{{ url_for('booking_payment') }}";
let currentBookingForm = null;
// Cart State
let cart = []; // Speichert {id, name, price}
let pendingItemForUpsell = null;
function openBookingModal(formElement) {
currentBookingForm = formElement;
const modal = document.getElementById('bookingModal');
modal.classList.add('active');
modal.setAttribute('aria-hidden', 'false');
// Hilfsfunktion: Warenkorb UI aktualisieren
function updateCartUI() {
const fab = document.getElementById('cartFab');
const badge = document.getElementById('cartBadge');
const container = document.getElementById('cartItemsContainer');
const emptyMsg = document.getElementById('cartEmptyMessage');
const totalRow = document.getElementById('cartTotalRow');
const actions = document.getElementById('cartActions');
const totalPriceEl = document.getElementById('cartTotalPrice');
// Badge & FAB Sichtbarkeit
if (cart.length > 0) {
fab.classList.add('visible');
badge.innerText = cart.length;
emptyMsg.style.display = 'none';
totalRow.style.display = 'flex';
actions.style.display = 'flex';
} else {
fab.classList.remove('visible');
emptyMsg.style.display = 'block';
totalRow.style.display = 'none';
actions.style.display = 'none';
}
// Artikel rendern
container.innerHTML = '';
let total = 0;
let hasIndividualPrice = false;
cart.forEach(item => {
if(item.price > 0) total += item.price;
if(item.price === 0) hasIndividualPrice = true;
const priceText = item.price > 0 ? `${item.price} €` : "Auf Anfrage";
container.innerHTML += `
<div class="cart-item">
<div class="cart-item-info">
<span class="cart-item-title">${item.name}</span>
<span class="cart-item-price">${priceText}</span>
</div>
<button class="cart-item-remove" onclick="removeFromCart('${item.id}')" aria-label="Entfernen">Entfernen</button>
</div>
`;
});
// Summe formatieren
if(hasIndividualPrice && total > 0) {
totalPriceEl.innerText = `${total} € + ind. Paket`;
} else if (hasIndividualPrice && total === 0) {
totalPriceEl.innerText = "Auf Anfrage";
} else {
totalPriceEl.innerText = `${total} € / Jahr`;
}
}
function closeBookingModal() {
currentBookingForm = null;
const modal = document.getElementById('bookingModal');
modal.classList.remove('active');
modal.setAttribute('aria-hidden', 'true');
}
function goToBookingReview(flow) {
if (!currentBookingForm) {
// Artikel zum Warenkorb hinzufügen
function addItem(id, name, price) {
// Prüfen, ob schon drin
if(cart.find(i => i.id === id)) {
alert(`${name} ist bereits im Warenkorb.`);
return;
}
const packageInput = currentBookingForm.querySelector('input[name="package"]');
const packageValue = packageInput ? packageInput.value : '';
// Konflikte mit Startpaket abfangen
const hasStartpaket = cart.find(i => i.id === 'startpaket');
if (hasStartpaket && (id === 'inventarsystem' || id === 'buecherei')) {
alert(`Das Modul ist bereits in Ihrem gewählten Startpaket enthalten!`);
return;
}
if (id === 'startpaket') {
// Wenn Startpaket hinzugefügt wird, Einzelmodule (Inventar/Bib) entfernen, um doppelte Kosten zu vermeiden
cart = cart.filter(i => i.id !== 'inventarsystem' && i.id !== 'buecherei');
}
cart.push({id, name, price: parseInt(price, 10)});
updateCartUI();
// Modal öffnen zur Bestätigung
openCartModal();
}
// Logic Klick auf "In den Warenkorb"
function handleAddToCart(btn) {
const id = btn.dataset.id;
const name = btn.dataset.name;
const price = btn.dataset.price;
// Upsell Logik triggern, wenn Inventar oder Bib einzeln gewählt wird
if ((id === 'inventarsystem' || id === 'buecherei') && !cart.find(i => i.id === 'startpaket')) {
pendingItemForUpsell = { id, name, price };
openUpsellModal();
} else {
// Normal hinzufügen
addItem(id, name, price);
}
}
// Artikel aus Warenkorb entfernen
function removeFromCart(id) {
cart = cart.filter(i => i.id !== id);
updateCartUI();
if(cart.length === 0) {
setTimeout(closeCartModal, 1500); // Modal nach kurzer Zeit schließen wenn leer
}
}
// Upsell-Entscheidungen
function acceptUpsell() {
closeUpsellModal();
addItem('startpaket', 'Startpaket (Inventar + Bibliothek)', 420);
pendingItemForUpsell = null;
}
function declineUpsell() {
closeUpsellModal();
if(pendingItemForUpsell) {
addItem(pendingItemForUpsell.id, pendingItemForUpsell.name, pendingItemForUpsell.price);
}
pendingItemForUpsell = null;
}
// Checkout Routing
function goToCheckout(flow) {
if (cart.length === 0) return;
// Alle IDs der Cart zusammenführen (z.B. "startpaket,terminverwaltung")
const packageIds = cart.map(i => i.id).join(',');
const reviewUrl = new URL(bookingReviewBaseUrl, window.location.origin);
reviewUrl.searchParams.set('package', packageValue);
reviewUrl.searchParams.set('packages', packageIds);
reviewUrl.searchParams.set('flow', flow);
window.location.href = reviewUrl.toString();
}
// Modal Steuerungen
function openCartModal() {
updateCartUI();
document.getElementById('cartModal').classList.add('active');
}
function closeCartModal() { document.getElementById('cartModal').classList.remove('active'); }
function openUpsellModal() { document.getElementById('upsellModal').classList.add('active'); }
function closeUpsellModal() { document.getElementById('upsellModal').classList.remove('active'); }
// Escape Key für Modals
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeBookingModal();
closeCartModal();
closeUpsellModal();
}
});
</script>
{% endblock %}
{% endblock %}
+44 -7
View File
@@ -416,6 +416,7 @@
const form = document.getElementById('checkout-form');
const errorAlert = document.getElementById('form-error-alert');
const statusText = document.getElementById('loading-status-text');
const spinner = document.querySelector('.spinner'); // Referenz auf den Ladekreis
// Subdomain Live-Prüfung
const slugInput = document.getElementById('tenant_slug');
@@ -423,17 +424,23 @@
const submitBtn = document.getElementById('booking_submit');
let slugTimer = null;
// OPTIMIERT: Alles im 'input'-Event (fängt auch Maus-Paste und Autocomplete)
slugInput.addEventListener('input', (e) => {
// 1. Sanitization
e.target.value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '');
});
slugInput.addEventListener('keyup', (e) => {
// 2. Debounced API Check
clearTimeout(slugTimer);
slugTimer = setTimeout(() => checkSubdomain(e.target.value), 600);
});
async function checkSubdomain(name) {
if (name.length < 3) return;
if (name.length < 3) {
feedback.textContent = 'Bitte mindestens 3 Zeichen eingeben.';
feedback.style.color = '#5e757d';
submitBtn.disabled = true;
return;
}
try {
const res = await fetch(`/instance_request/check_subdomain?slug=${name}`);
const j = await res.json();
@@ -448,6 +455,8 @@
}
} catch (e) {
console.error(e);
feedback.textContent = 'Prüfung fehlgeschlagen. Bitte erneut versuchen.';
feedback.style.color = '#b02a37';
}
}
@@ -457,7 +466,11 @@
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorAlert.style.display = 'none';
// OPTIMIERT: Visuelles Feedback für den initialen Submit
const originalBtnText = submitBtn.textContent;
submitBtn.disabled = true;
submitBtn.textContent = "Wird verarbeitet...";
const formData = new FormData(form);
const tenantSlug = formData.get('tenant_slug');
@@ -486,6 +499,7 @@
errorAlert.textContent = error.message;
errorAlert.style.display = 'block';
submitBtn.disabled = false;
submitBtn.textContent = originalBtnText; // Button zurücksetzen
}
});
@@ -509,9 +523,22 @@
}
}, 3500);
let retryCount = 0;
const maxRetries = 30; // OPTIMIERT: Max 90 Sekunden Polling (30 * 3s)
const checkStatus = async () => {
retryCount++;
// Timeout-Abbruch
if (retryCount > maxRetries) {
handlePollingError("Zeitüberschreitung. Die Bereitstellung dauert ungewöhnlich lange. Sie werden per E-Mail informiert, sobald das System bereit ist.", msgInterval);
return;
}
try {
const res = await fetch(`/instance_request/status?slug=${slug}`);
if (!res.ok) throw new Error("Network response was not ok");
const data = await res.json();
if (data.status === 'ready') {
@@ -520,14 +547,13 @@
showSuccessState(data.url, data.username, data.password, data.invoice_url);
} else if (data.status === 'failed') {
// SETUP FEHLGESCHLAGEN
clearInterval(msgInterval);
statusText.textContent = "Fehler bei der Bereitstellung. Bitte Support kontaktieren.";
statusText.style.color = '#b02a37';
handlePollingError("Fehler bei der Bereitstellung. Bitte kontaktieren Sie unseren Support.", msgInterval);
} else {
// SETUP LÄUFT NOCH -> in 3 Sekunden erneut prüfen
setTimeout(checkStatus, 3000);
}
} catch (err) {
// Bei Netzwerkfehler weiter versuchen, solange maxRetries nicht erreicht ist
setTimeout(checkStatus, 3000);
}
};
@@ -535,6 +561,17 @@
// Starte den ersten Check nach 2 Sekunden
setTimeout(checkStatus, 2000);
}
// OPTIMIERT: Hilfsfunktion für Polling-Fehler (verhindert Sackgassen-UI)
function handlePollingError(message, intervalId) {
clearInterval(intervalId);
statusText.textContent = message;
statusText.style.color = '#b02a37';
if (spinner) spinner.style.display = 'none'; // Spinner ausblenden
// Optional: Einen Zurück/Support-Button einfügen
statusText.insertAdjacentHTML('afterend', '<br><a href="#" onclick="window.location.reload(); return false;" class="btn secondary" style="margin-top:1rem;">Seite neu laden</a>');
}
// ==========================================
// Erfolgs-Bildschirm anzeigen