49 lines
84 KiB
HTML
Executable File
49 lines
84 KiB
HTML
Executable File
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Kantine Wrapper Installer (v1.8.6)</title>
|
|
<style>
|
|
body { font-family: 'Inter', sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; background: #1a1a2e; color: #eee; }
|
|
h1 { color: #029AA8; } /* Knapp Teal */
|
|
.instructions { background: #16213e; padding: 20px; border-radius: 12px; margin: 20px 0; }
|
|
.instructions ol li { margin: 10px 0; }
|
|
a.bookmarklet { display: inline-block; background: #029AA8; color: white; padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 18px; cursor: grab; }
|
|
a.bookmarklet:hover { background: #006269; }
|
|
code { background: #0f3460; padding: 2px 6px; border-radius: 4px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>🍽️ Kantine Wrapper <span style="font-size:0.5em; opacity:0.6; font-weight:400; vertical-align:middle; margin-left:10px;">v1.8.6</span></h1>
|
|
<div class="instructions">
|
|
<h2>Installation</h2>
|
|
<ol>
|
|
<li>Ziehe den Button unten in deine <strong>Lesezeichen-Leiste</strong> (Drag & Drop)</li>
|
|
<li>Navigiere zu <a href="https://web.bessa.app/knapp-kantine" style="color:#029AA8">web.bessa.app/knapp-kantine</a></li>
|
|
<li>Klicke auf das Lesezeichen <code>Kantine v1.8.6</code></li>
|
|
</ol>
|
|
|
|
<h2>✨ Features</h2>
|
|
<ul>
|
|
<li>📅 <strong>Wochenübersicht:</strong> Die ganze Woche auf einen Blick.</li>
|
|
<li>💰 <strong>Kostenkontrolle:</strong> Automatische Berechnung der Wochensumme.</li>
|
|
<li>🔑 <strong>Auto-Login:</strong> Nutzt deine bestehende Session.</li>
|
|
<li>🏷️ <strong>Badges & Status:</strong> Menü-Codes (M1, M2) und Bestellstatus direkt sichtbar.</li>
|
|
<li>🛡️ <strong>Offline-Support:</strong> Speichert Menüdaten lokal.</li>
|
|
</ul>
|
|
|
|
<div style="margin-top: 30px; padding: 15px; background: rgba(233, 69, 96, 0.1); border: 1px solid rgba(233, 69, 96, 0.3); border-radius: 8px; font-size: 0.85em; color: #ddd;">
|
|
<strong>⚠️ Haftungsausschluss:</strong><br>
|
|
Die Verwendung dieses Bookmarklets erfolgt auf eigene Verantwortung. Der Entwickler übernimmt keine Haftung für Schäden, Datenverlust oder ungewollte Bestellungen, die durch die Nutzung dieser Software entstehen.
|
|
</div>
|
|
</div>
|
|
<p>👇 Diesen Button in die Lesezeichen-Leiste ziehen:</p>
|
|
<p><a class="bookmarklet" id="bookmarklet-link" href="#">⏳ Wird generiert...</a></p>
|
|
<script>
|
|
document.getElementById('bookmarklet-link').href =
|
|
"javascript:(function(){if(window.__KANTINE_LOADED){alert(\"Already loaded\");return;}var s=document.createElement(\"style\");s.textContent=\":root { /* Premium Slate/Gray-Blue Palette - Light Mode */ --bg-body: #f1f5f9; /* Slate 100 */ --bg-card: #ffffff; --text-primary: #334155; /* Slate 700 */ --text-secondary: #64748b; --accent-color: #0f172a; /* Slate 900 (High contrast) */ --border-color: #cbd5e1; /* Slate 300 */ --banner-bg: #e2e8f0; --banner-text: #1e293b; --success-color: #059669; --error-color: #dc2626; --card-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05); --header-bg: rgba(255, 255, 255, 0.9); --header-border: 1px solid rgba(203, 213, 225, 0.6); } [data-theme=\\\"dark\\\"] { /* Premium Slate/Gray-Blue Palette - Dark Mode */ --bg-body: #1e293b; /* Deep Slate Gray (Requested) */ --bg-card: #334155; /* Slate 700 */ --text-primary: #f8fafc; /* Slate 50 */ --text-secondary: #cbd5e1; /* Slate 300 */ --accent-color: #60a5fa; /* Blue 400 */ --border-color: #475569; /* Slate 600 */ --banner-bg: #475569; --banner-text: #e2e8f0; --header-bg: rgba(30, 41, 59, 0.9); --header-border: 1px solid rgba(71, 85, 105, 0.6); --card-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.4); } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: 'Inter', system-ui, -apple-system, sans-serif; background-color: var(--bg-body); color: var(--text-primary); transition: background-color 0.3s ease, color 0.3s ease; line-height: 1.5; -webkit-font-smoothing: antialiased; } /* Fix scrolling bug: Reset html/body styles from host page */ html, body { height: auto !important; min-height: 100% !important; overflow-y: auto !important; overflow-x: hidden !important; position: static !important; margin: 0 !important; padding: 0 !important; } /* Header */ .app-header { position: sticky; top: 0; z-index: 100; backdrop-filter: blur(12px); background-color: var(--header-bg); border-bottom: var(--header-border); padding: 1rem 0; } .header-content { width: 100%; /* Full width */ padding: 0 2rem; /* Comfortable padding */ display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 1rem; } .brand { display: flex; align-items: center; gap: 0.75rem; } .brand-text { display: flex; flex-direction: column; } .brand h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em; margin-bottom: 0; } .subtitle { font-size: 0.85rem; color: var(--text-secondary); font-weight: 400; margin-left: 2px; } .logo-icon { font-size: 1.5rem; color: var(--accent-color); } /* Controls */ .controls { display: flex; align-items: center; gap: 1.5rem; justify-self: end; } /* Header Week Info (centered) */ .header-week-info { text-align: center; line-height: 1.3; } .header-center-wrapper { display: flex; flex-direction: row; align-items: center; gap: 1.5rem; justify-content: center; } .weekly-cost { white-space: nowrap; font-size: 0.9rem; font-weight: 600; color: var(--success-color); background-color: var(--bg-body); padding: 0.25rem 0.75rem; border-radius: 20px; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); border: 1px solid var(--border-color); } .header-week-title { font-size: 1.1rem; font-weight: 600; color: var(--text-primary); } .header-week-subtitle { font-size: 0.85rem; color: var(--text-secondary); } .nav-group { display: flex; background-color: var(--bg-card); border: 1px solid var(--border-color); padding: 0.25rem; border-radius: 8px; } .nav-btn { background: none; border: none; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; color: var(--text-secondary); cursor: pointer; border-radius: 6px; transition: all 0.2s; display: flex; align-items: center; gap: 0.5rem; } .nav-btn:hover { color: var(--text-primary); background-color: rgba(100, 116, 139, 0.1); } .nav-btn.active { background-color: var(--accent-color); color: white; } /* Badge for nav buttons (day count indicator) */ .nav-badge { background-color: var(--error-color); color: white; font-size: 0.75rem; font-weight: 600; padding: 0 6px; border-radius: 10px; min-width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; margin-left: 8px; gap: 3px; line-height: 1; } .nav-badge .orderable { color: #fff; font-weight: 800; } .nav-badge .separator { opacity: 0.6; font-weight: 400; } .nav-badge .total { opacity: 0.8; font-weight: 400; } .nav-btn.active .nav-badge { background: rgba(255, 255, 255, 0.3); } /* Primary style for Login Button to match header */ #btn-login-open { background-color: var(--accent-color); color: white; padding: 0.5rem 1.25rem; border-radius: 8px; font-weight: 600; letter-spacing: 0.025em; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } #btn-login-open:hover { background-color: #334155; /* Slightly lighter than slate-900 */ transform: translateY(-1px); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } /* User Badge Button (Login) */ .user-badge-btn { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 20px; font-size: 0.9rem; font-weight: 500; color: var(--text-primary); cursor: pointer; transition: all 0.2s; } .user-badge-btn:hover { background: rgba(100, 116, 139, 0.1); border-color: var(--accent-color); } .user-badge-btn .material-icons-round { font-size: 1.25rem; color: var(--accent-color); } .icon-btn { background: none; border: none; color: var(--text-primary); cursor: pointer; padding: 0.5rem; border-radius: 50%; transition: background-color 0.2s; display: flex; align-items: center; justify-content: center; } .icon-btn:hover { background-color: rgba(100, 116, 139, 0.1); } /* Refresh button animation */ #btn-refresh.refreshing .material-icons-round { animation: rotate 1s linear infinite; } @keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } /* Progress Modal */ .progress-container { margin-bottom: 1.5rem; } .progress-bar { width: 100%; height: 8px; background-color: var(--border-color); border-radius: 4px; overflow: hidden; margin-bottom: 0.75rem; } .progress-fill { height: 100%; background: linear-gradient(90deg, var(--accent-color) 0%, #60a5fa 100%); width: 0%; transition: width 0.3s ease; border-radius: 4px; } .progress-percent { text-align: center; font-size: 1.5rem; font-weight: 700; color: var(--text-primary); margin-bottom: 0.5rem; } .progress-message { text-align: center; color: var(--text-secondary); font-size: 0.9rem; font-weight: 500; } .weekly-cost { background-color: rgba(59, 130, 246, 0.1); /* Blue tint */ color: var(--accent-color); padding: 0.4rem 0.8rem; border-radius: 8px; font-weight: 600; font-size: 0.9rem; display: flex; align-items: center; gap: 0.5rem; border: 1px solid rgba(59, 130, 246, 0.2); } .weekly-cost .material-icons-round { font-size: 18px; } /* Container */ .container { width: 100%; /* Full width */ margin: 2rem auto; padding: 0 2rem; min-height: 80vh; } /* Banner */ .banner { background-color: var(--banner-bg); color: var(--banner-text); padding: 0.75rem 1rem; border-radius: 8px; display: flex; align-items: center; gap: 0.5rem; margin-bottom: 2rem; font-size: 0.875rem; font-weight: 500; border: 1px solid var(--border-color); max-width: fit-content; } /* User Badge */ .user-badge { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--bg-card); /* Changed from --surface */ border: 1px solid var(--border-color); /* Changed from --border */ border-radius: 20px; font-size: 0.9rem; font-weight: 500; } .icon-btn-small { background: none; border: none; padding: 4px; cursor: pointer; color: var(--text-secondary); /* Changed from --text-muted */ display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: all 0.2s; } .icon-btn-small:hover { color: var(--error-color); /* Changed from --danger */ background: rgba(239, 68, 68, 0.1); } /* Modal */ .modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; transition: all 0.3s; } .modal.hidden { opacity: 0; pointer-events: none; } .modal-content { background: var(--bg-card); /* Changed from --surface */ width: 90%; max-width: 400px; border-radius: 16px; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); overflow: hidden; animation: modalSlide 0.3s ease-out; } @keyframes modalSlide { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } } .modal-header { display: flex; align-items: center; justify-content: space-between; padding: 20px; border-bottom: 1px solid var(--border-color); /* Changed from --border */ } .modal-header h2 { margin: 0; font-size: 1.25rem; } #login-form { padding: 20px; } .form-group { margin-bottom: 20px; } .form-group label { display: block; margin-bottom: 6px; font-weight: 500; font-size: 0.9rem; } .form-group input { width: 100%; padding: 10px 12px; border: 1px solid var(--border-color); /* Changed from --border */ border-radius: 8px; background: var(--bg-body); /* Changed from --bg */ color: var(--text-primary); /* Changed from --text */ font-family: inherit; transition: border-color 0.2s; } .form-group input:focus { outline: none; border-color: var(--accent-color); /* Changed from --primary */ } .help-text { display: block; margin-top: 4px; color: var(--text-secondary); /* Changed from --text-muted */ font-size: 0.75rem; } .error-msg { margin-bottom: 16px; padding: 10px; background: rgba(239, 68, 68, 0.1); color: var(--error-color); /* Changed from --danger */ border-radius: 8px; font-size: 0.85rem; text-align: center; } .modal-actions { margin-top: 24px; } .btn-primary.wide { width: 100%; justify-content: center; } .hidden { display: none !important; } /* Menu Grid */ .menu-grid { display: grid; gap: 2rem; } .week-section { margin-bottom: 3rem; } .week-header { margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 1rem; text-align: center; } .week-title { font-size: 1.75rem; font-weight: 700; color: var(--text-primary); } .week-range { color: var(--text-secondary); font-size: 0.9rem; margin-top: 0.25rem; } .days-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; } /* Card */ .menu-card { background-color: var(--bg-card); border-radius: 12px; border: 1px solid var(--border-color); box-shadow: var(--card-shadow); overflow: hidden; transition: transform 0.2s ease, box-shadow 0.2s ease; display: flex; flex-direction: column; } /* Past Day Styling - Target specific elements so ordered items can remain visible */ .menu-card.past-day .card-header, .menu-card.past-day .menu-item:not(.ordered) { opacity: 0.6; filter: grayscale(0.8); transition: opacity 0.3s, filter 0.3s; } .menu-card.past-day:hover .card-header, .menu-card.past-day:hover .menu-item:not(.ordered) { opacity: 0.8; filter: grayscale(0.4); } /* Enhancements for ordered items */ .menu-card.past-day .menu-item.ordered { /* No opacity/filter here - fully visible */ background: var(--bg-card); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border: 1px solid var(--accent-color); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; position: relative; z-index: 10; } .menu-item.today-ordered { border: 2px solid var(--accent-color); box-shadow: 0 0 20px rgba(96, 165, 250, 0.4); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: pulse-glow 3s infinite; } @keyframes pulse-glow { 0% { box-shadow: 0 0 15px rgba(96, 165, 250, 0.3); } 50% { box-shadow: 0 0 25px rgba(96, 165, 250, 0.6); } 100% { box-shadow: 0 0 15px rgba(96, 165, 250, 0.3); } } .menu-card:hover { transform: translateY(-2px); box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); } .card-header { padding: 1rem 1.25rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: baseline; background-color: rgba(100, 116, 139, 0.05); } .day-name { font-size: 1.125rem; font-weight: 600; } .day-date { font-size: 0.875rem; color: var(--text-secondary); } .card-body { padding: 1.25rem; display: grid; grid-template-rows: auto; /* Each menu item gets its own row */ align-content: start; } .empty-state { color: var(--text-secondary); font-style: italic; text-align: center; padding: 1rem; } /* Menu Items */ .menu-item { margin-bottom: 1.5rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); } .menu-item:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; } .item-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 0.5rem; gap: 1rem; } .item-name { font-weight: 600; color: var(--text-primary); font-size: 1rem; } .item-price { font-weight: 700; color: var(--accent-color); white-space: nowrap; } .item-desc { font-size: 0.875rem; color: var(--text-secondary); line-height: 1.6; margin-bottom: 0.75rem; } .badges { display: flex; gap: 0.5rem; margin-left: auto; } .item-status-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; } .badge { display: inline-flex; align-items: center; justify-content: center; height: 24px; font-size: 0.75rem; padding: 0 10px; border-radius: 4px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; line-height: normal; white-space: nowrap; } .badge.available { background-color: rgba(16, 185, 129, 0.1); /* Emerald 500 / 10% */ color: var(--success-color); border: 1px solid rgba(16, 185, 129, 0.2); } .badge.sold-out { background-color: rgba(239, 68, 68, 0.1); /* Red 500 / 10% */ color: var(--error-color); border: 1px solid rgba(239, 68, 68, 0.2); } .badge.ordered { background-color: rgba(139, 92, 246, 0.1); /* Violet 500 / 10% */ color: #8b5cf6; border: 1px solid rgba(139, 92, 246, 0.2); gap: 4px; } .badge.ordered .material-icons-round { font-size: 1rem; } /* Loading */ .loading-state { text-align: center; padding: 4rem; color: var(--text-secondary); } .spinner { width: 40px; height: 40px; border: 3px solid var(--border-color); border-top-color: var(--accent-color); border-radius: 50%; margin: 0 auto 1rem; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } /* Footer */ .app-footer { text-align: center; padding: 2rem; color: var(--text-secondary); font-size: 0.875rem; border-top: 1px solid var(--border-color); margin-top: auto; } /* === Order / Cancel Buttons (inline in status row) === */ .btn-order { display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border: none; border-radius: 6px; background: var(--success-color); color: white; font-size: 0.75rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; font-family: inherit; } .btn-order .material-icons-round { font-size: 16px; } .btn-order:hover:not(:disabled) { filter: brightness(1.15); transform: translateY(-1px); } .btn-order:disabled { opacity: 0.5; cursor: not-allowed; } .btn-order.loading { pointer-events: none; opacity: 0.6; } .btn-order-compact { padding: 2px 4px; gap: 0; } .btn-order-compact .material-icons-round { font-size: 16px; } .btn-cancel { display: inline-flex; align-items: center; justify-content: center; padding: 4px 6px; border: none; border-radius: 6px; background: var(--error-color); color: white; font-size: 0.75rem; cursor: pointer; transition: all 0.2s ease; font-family: inherit; } .btn-cancel .material-icons-round { font-size: 16px; } .btn-cancel:hover:not(:disabled) { filter: brightness(1.15); transform: translateY(-1px); } .btn-cancel:disabled { opacity: 0.5; cursor: not-allowed; } /* Past days: hide action buttons */ .past-day .item-actions { display: none; } /* Order count badge (for multi-orders) */ .order-count-badge { display: inline-flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.3); color: white; font-size: 0.65rem; font-weight: 700; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; margin-left: 4px; line-height: 1; } /* === Toast Notifications === */ #toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 10000; display: flex; flex-direction: column; gap: 8px; pointer-events: none; } .toast { display: flex; align-items: center; gap: 8px; padding: 10px 16px; border-radius: 8px; font-size: 0.85rem; font-weight: 500; font-family: 'Inter', sans-serif; color: white; backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); pointer-events: auto; transform: translateX(120%); opacity: 0; transition: transform 0.3s ease, opacity 0.3s ease; } .toast.show { transform: translateX(0); opacity: 1; } .toast .material-icons-round { font-size: 18px; } .toast-success { background: rgba(5, 150, 105, 0.95); } .toast-error { background: rgba(220, 38, 38, 0.95); } .toast-info { background: rgba(59, 130, 246, 0.95); } /* === Mobile Responsiveness === */ @media (max-width: 600px) { .header-content { flex-direction: column; gap: 1rem; padding: 0.75rem; } .week-nav { width: 100%; justify-content: center; } .nav-pills { width: 100%; justify-content: space-between; } .nav-btn { flex: 1; justify-content: center; padding: 0.5rem; font-size: 0.85rem; } .days-grid { grid-template-columns: 1fr; /* Force single column */ } .main-content { padding: 1rem; } .week-title { font-size: 1.5rem; } /* Adjust toast position for mobile */ .toast-container { bottom: 1rem; right: 1rem; left: 1rem; /* Center on mobile */ width: auto; } .menu-card { margin-bottom: 1rem; } } /* === Flagging & Notification Styles === */ .btn-flag { display: inline-flex; align-items: center; justify-content: center; background: transparent; border: 1px solid var(--text-secondary); color: var(--text-secondary); border-radius: 6px; padding: 4px; cursor: pointer; transition: all 0.2s; margin-right: 0.5rem; width: 28px; height: 28px; } .btn-flag:hover { background: rgba(234, 179, 8, 0.1); /* Yellow-500 / 10% */ color: #eab308; border-color: #eab308; } .btn-flag.active { background: rgba(234, 179, 8, 0.1); color: #eab308; border-color: #eab308; } .btn-flag .material-icons-round { font-size: 1.1rem; } /* Flagged & Sold Out (Yellow Glow) */ .menu-item.flagged-sold-out { border: 1px solid #eab308; box-shadow: 0 0 10px rgba(234, 179, 8, 0.2); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: yellow-pulse 3s infinite; } @keyframes yellow-pulse { 0% { box-shadow: 0 0 8px rgba(234, 179, 8, 0.2); } 50% { box-shadow: 0 0 16px rgba(234, 179, 8, 0.5); } 100% { box-shadow: 0 0 8px rgba(234, 179, 8, 0.2); } } /* Flagged & Available (Green Glow) */ .menu-item.flagged-available { border: 2px solid var(--success-color); box-shadow: 0 0 15px rgba(16, 185, 129, 0.3); border-radius: 8px; padding: 1rem; margin: 0 -1rem 1.5rem -1rem; background: var(--bg-card); position: relative; z-index: 5; animation: green-pulse 3s infinite; } @keyframes green-pulse { 0% { box-shadow: 0 0 10px rgba(16, 185, 129, 0.3); } 50% { box-shadow: 0 0 20px rgba(16, 185, 129, 0.6); } 100% { box-shadow: 0 0 10px rgba(16, 185, 129, 0.3); } } /* Day Header Badges */ .day-header-left { display: flex; align-items: center; gap: 0.75rem; } .menu-code-badge { font-size: 0.75rem; font-weight: 700; color: #8b5cf6; /* Violet 500 */ background-color: rgba(139, 92, 246, 0.15); border: 1px solid rgba(139, 92, 246, 0.3); padding: 2px 6px; border-radius: 6px; line-height: normal; display: inline-block; } /* Detailed Badge Colors */ .nav-badge.badge-violet { background-color: #8b5cf6; } .nav-badge.badge-green { background-color: var(--success-color); } .nav-badge.badge-red { background-color: var(--error-color); } .nav-badge.badge-blue { background-color: var(--accent-color); } /* Day Header Status Colors (User Request) */ .card-header.header-violet { background-color: rgba(139, 92, 246, 0.15); border-bottom: 2px solid #8b5cf6; } .card-header.header-green { background-color: rgba(16, 185, 129, 0.15); border-bottom: 2px solid var(--success-color); } .card-header.header-red { background-color: rgba(239, 68, 68, 0.15); border-bottom: 2px solid var(--error-color); } .card-header.header-violet .day-name, .card-header.header-green .day-name, .card-header.header-red .day-name { font-weight: 700; color: var(--text-primary); /* Ensure text remains standard color */ }\";document.head.appendChild(s);var sc=document.createElement(\"script\");sc.textContent=\"/**\\n * Kantine Wrapper \\u2013 Client-Only Bookmarklet\\n * Replaces Bessa page content with enhanced weekly menu view.\\n * All API calls go directly to api.bessa.app (same origin).\\n * Data stored in localStorage (flags, theme, auth).\\n */\\n(function () {\\n 'use strict';\\n\\n // Prevent double injection\\n if (window.__KANTINE_LOADED) return;\\n window.__KANTINE_LOADED = true;\\n\\n // === Constants ===\\n const API_BASE = 'https://api.bessa.app/v1';\\n const GUEST_TOKEN = 'c3418725e95a9f90e3645cbc846b4d67c7c66131';\\n const CLIENT_VERSION = '1.7.0_prod/2026-01-26';\\n const VENUE_ID = 591;\\n const MENU_ID = 7;\\n const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes\\n\\n // === State ===\\n let allWeeks = [];\\n let currentWeekNumber = getISOWeek(new Date());\\n let currentYear = new Date().getFullYear();\\n let displayMode = 'this-week';\\n let authToken = sessionStorage.getItem('kantine_authToken');\\n let currentUser = sessionStorage.getItem('kantine_currentUser');\\n let orderMap = new Map();\\n let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));\\n let pollIntervalId = null;\\n\\n // === API Helpers ===\\n function apiHeaders(token) {\\n return {\\n 'Authorization': `Token ${token || GUEST_TOKEN}`,\\n 'Accept': 'application/json',\\n 'Content-Type': 'application/json',\\n 'X-Client-Version': CLIENT_VERSION\\n };\\n }\\n\\n // === Inject UI ===\\n function injectUI() {\\n // Replace entire page content\\n document.title = 'Kantine Weekly Menu';\\n\\n // Inject Google Fonts if not already present\\n if (!document.querySelector('link[href*=\\\"fonts.googleapis.com/css2?family=Inter\\\"]')) {\\n const fontLink = document.createElement('link');\\n fontLink.rel = 'stylesheet';\\n fontLink.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap';\\n document.head.appendChild(fontLink);\\n }\\n if (!document.querySelector('link[href*=\\\"Material+Icons+Round\\\"]')) {\\n const iconLink = document.createElement('link');\\n iconLink.rel = 'stylesheet';\\n iconLink.href = 'https://fonts.googleapis.com/icon?family=Material+Icons+Round';\\n document.head.appendChild(iconLink);\\n }\\n\\n document.body.innerHTML = `\\n <div id=\\\"kantine-wrapper\\\">\\n <header class=\\\"app-header\\\">\\n <div class=\\\"header-content\\\">\\n <div class=\\\"brand\\\">\\n <span class=\\\"material-icons-round logo-icon\\\">restaurant_menu</span>\\n <div class=\\\"header-left\\\">\\n <h1>Kantinen \\u00dcbersicht <small style=\\\"font-size: 0.6em; opacity: 0.7; font-weight: 400;\\\">v1.8.6</small></h1>\\n <div id=\\\"last-updated-subtitle\\\" class=\\\"subtitle\\\"></div>\\n </div>\\n </div>\\n <div class=\\\"header-center-wrapper\\\">\\n <div id=\\\"header-week-info\\\" class=\\\"header-week-info\\\"></div>\\n <div id=\\\"weekly-cost-display\\\" class=\\\"weekly-cost hidden\\\"></div>\\n </div>\\n <div class=\\\"controls\\\">\\n <button id=\\\"btn-refresh\\\" class=\\\"icon-btn\\\" aria-label=\\\"Men\\u00fcdaten aktualisieren\\\" title=\\\"Men\\u00fcdaten neu laden\\\">\\n <span class=\\\"material-icons-round\\\">refresh</span>\\n </button>\\n <div class=\\\"nav-group\\\">\\n <button id=\\\"btn-this-week\\\" class=\\\"nav-btn active\\\">Diese Woche</button>\\n <button id=\\\"btn-next-week\\\" class=\\\"nav-btn\\\">N\\u00e4chste Woche</button>\\n </div>\\n <button id=\\\"theme-toggle\\\" class=\\\"icon-btn\\\" aria-label=\\\"Toggle Theme\\\">\\n <span class=\\\"material-icons-round theme-icon\\\">light_mode</span>\\n </button>\\n <button id=\\\"btn-login-open\\\" class=\\\"user-badge-btn icon-btn-small\\\">\\n <span class=\\\"material-icons-round\\\">login</span>\\n <span>Anmelden</span>\\n </button>\\n <div id=\\\"user-info\\\" class=\\\"user-badge hidden\\\">\\n <span class=\\\"material-icons-round\\\">person</span>\\n <span id=\\\"user-id-display\\\"></span>\\n <button id=\\\"btn-logout\\\" class=\\\"icon-btn-small\\\" aria-label=\\\"Logout\\\">\\n <span class=\\\"material-icons-round\\\">logout</span>\\n </button>\\n </div>\\n </div>\\n </div>\\n </header>\\n\\n <div id=\\\"login-modal\\\" class=\\\"modal hidden\\\">\\n <div class=\\\"modal-content\\\">\\n <div class=\\\"modal-header\\\">\\n <h2>Login</h2>\\n <button id=\\\"btn-login-close\\\" class=\\\"icon-btn\\\" aria-label=\\\"Close\\\">\\n <span class=\\\"material-icons-round\\\">close</span>\\n </button>\\n </div>\\n <form id=\\\"login-form\\\">\\n <div class=\\\"form-group\\\">\\n <label for=\\\"employee-id\\\">Mitarbeiternummer</label>\\n <input type=\\\"text\\\" id=\\\"employee-id\\\" name=\\\"employee-id\\\" placeholder=\\\"z.B. 2041\\\" required>\\n <small class=\\\"help-text\\\">Deine offizielle Knapp Mitarbeiternummer.</small>\\n </div>\\n <div class=\\\"form-group\\\">\\n <label for=\\\"password\\\">Passwort</label>\\n <input type=\\\"password\\\" id=\\\"password\\\" name=\\\"password\\\" placeholder=\\\"Bessa Passwort\\\" required>\\n <small class=\\\"help-text\\\">Das Passwort f\\u00fcr deinen Bessa Account.</small>\\n </div>\\n <div id=\\\"login-error\\\" class=\\\"error-msg hidden\\\"></div>\\n <div class=\\\"modal-actions\\\">\\n <button type=\\\"submit\\\" class=\\\"btn-primary wide\\\">Einloggen</button>\\n </div>\\n </form>\\n </div>\\n </div>\\n\\n <div id=\\\"progress-modal\\\" class=\\\"modal hidden\\\">\\n <div class=\\\"modal-content\\\">\\n <div class=\\\"modal-header\\\">\\n <h2>Men\\u00fcdaten aktualisieren</h2>\\n </div>\\n <div class=\\\"modal-body\\\" style=\\\"padding: 20px;\\\">\\n <div class=\\\"progress-container\\\">\\n <div class=\\\"progress-bar\\\">\\n <div id=\\\"progress-fill\\\" class=\\\"progress-fill\\\"></div>\\n </div>\\n <div id=\\\"progress-percent\\\" class=\\\"progress-percent\\\">0%</div>\\n </div>\\n <p id=\\\"progress-message\\\" class=\\\"progress-message\\\">Initialisierung...</p>\\n </div>\\n </div>\\n </div>\\n\\n <main class=\\\"container\\\">\\n <div id=\\\"last-updated-banner\\\" class=\\\"banner hidden\\\">\\n <span class=\\\"material-icons-round\\\">update</span>\\n <span id=\\\"last-updated-text\\\">Gerade aktualisiert</span>\\n </div>\\n <div id=\\\"loading\\\" class=\\\"loading-state\\\">\\n <div class=\\\"spinner\\\"></div>\\n <p>Lade Men\\u00fcdaten...</p>\\n </div>\\n <div id=\\\"menu-container\\\" class=\\\"menu-grid\\\"></div>\\n </main>\\n\\n <footer class=\\\"app-footer\\\">\\n <p>Bessa Knapp-Kantine Wrapper • <span id=\\\"current-year\\\">${new Date().getFullYear()}</span></p>\\n </footer>\\n </div>`;\\n }\\n\\n // === Bind Events ===\\n function bindEvents() {\\n const btnThisWeek = document.getElementById('btn-this-week');\\n const btnNextWeek = document.getElementById('btn-next-week');\\n const btnRefresh = document.getElementById('btn-refresh');\\n const themeToggle = document.getElementById('theme-toggle');\\n const btnLoginOpen = document.getElementById('btn-login-open');\\n const btnLoginClose = document.getElementById('btn-login-close');\\n const btnLogout = document.getElementById('btn-logout');\\n const loginForm = document.getElementById('login-form');\\n const loginModal = document.getElementById('login-modal');\\n\\n // Theme\\n const savedTheme = localStorage.getItem('theme');\\n const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\\n const themeIcon = themeToggle.querySelector('.theme-icon');\\n\\n if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {\\n document.documentElement.setAttribute('data-theme', 'dark');\\n themeIcon.textContent = 'dark_mode';\\n } else {\\n document.documentElement.setAttribute('data-theme', 'light');\\n themeIcon.textContent = 'light_mode';\\n }\\n\\n themeToggle.addEventListener('click', () => {\\n const current = document.documentElement.getAttribute('data-theme');\\n const next = current === 'dark' ? 'light' : 'dark';\\n document.documentElement.setAttribute('data-theme', next);\\n localStorage.setItem('theme', next);\\n themeIcon.textContent = next === 'dark' ? 'dark_mode' : 'light_mode';\\n });\\n\\n // Navigation\\n btnThisWeek.addEventListener('click', () => {\\n if (displayMode !== 'this-week') {\\n displayMode = 'this-week';\\n btnThisWeek.classList.add('active');\\n btnNextWeek.classList.remove('active');\\n renderVisibleWeeks();\\n }\\n });\\n\\n btnNextWeek.addEventListener('click', () => {\\n if (displayMode !== 'next-week') {\\n displayMode = 'next-week';\\n btnNextWeek.classList.add('active');\\n btnThisWeek.classList.remove('active');\\n renderVisibleWeeks();\\n }\\n });\\n\\n // Refresh \\u2013 fetch fresh data from Bessa API\\n btnRefresh.addEventListener('click', () => {\\n if (!authToken) {\\n loginModal.classList.remove('hidden');\\n return;\\n }\\n loadMenuDataFromAPI();\\n });\\n\\n // Login Modal\\n btnLoginOpen.addEventListener('click', () => {\\n loginModal.classList.remove('hidden');\\n document.getElementById('login-error').classList.add('hidden');\\n loginForm.reset();\\n });\\n\\n btnLoginClose.addEventListener('click', () => {\\n loginModal.classList.add('hidden');\\n });\\n\\n window.addEventListener('click', (e) => {\\n if (e.target === loginModal) loginModal.classList.add('hidden');\\n });\\n\\n // Login Form Submit\\n loginForm.addEventListener('submit', async (e) => {\\n e.preventDefault();\\n const employeeId = document.getElementById('employee-id').value.trim();\\n const password = document.getElementById('password').value;\\n const loginError = document.getElementById('login-error');\\n const submitBtn = loginForm.querySelector('button[type=\\\"submit\\\"]');\\n const originalText = submitBtn.textContent;\\n\\n submitBtn.disabled = true;\\n submitBtn.textContent = 'Wird eingeloggt...';\\n\\n try {\\n const email = `knapp-${employeeId}@bessa.app`;\\n const response = await fetch(`${API_BASE}/auth/login/`, {\\n method: 'POST',\\n headers: apiHeaders(GUEST_TOKEN),\\n body: JSON.stringify({ email, password })\\n });\\n\\n const data = await response.json();\\n\\n if (response.ok) {\\n authToken = data.key;\\n currentUser = employeeId;\\n sessionStorage.setItem('kantine_authToken', data.key);\\n sessionStorage.setItem('kantine_currentUser', employeeId);\\n\\n // Fetch user name\\n try {\\n const userResp = await fetch(`${API_BASE}/auth/user/`, {\\n headers: apiHeaders(authToken)\\n });\\n if (userResp.ok) {\\n const userData = await userResp.json();\\n if (userData.first_name) sessionStorage.setItem('kantine_firstName', userData.first_name);\\n if (userData.last_name) sessionStorage.setItem('kantine_lastName', userData.last_name);\\n }\\n } catch (err) {\\n console.error('Failed to fetch user info:', err);\\n }\\n\\n updateAuthUI();\\n loginModal.classList.add('hidden');\\n fetchOrders();\\n loginForm.reset();\\n startPolling();\\n\\n // Reload menu data with auth for full details\\n loadMenuDataFromAPI();\\n } else {\\n loginError.textContent = data.non_field_errors?.[0] || data.error || 'Login fehlgeschlagen';\\n loginError.classList.remove('hidden');\\n }\\n } catch (error) {\\n console.error('Login error:', error);\\n loginError.textContent = 'Ein Fehler ist aufgetreten';\\n loginError.classList.remove('hidden');\\n } finally {\\n submitBtn.disabled = false;\\n submitBtn.textContent = originalText;\\n }\\n });\\n\\n // Logout\\n btnLogout.addEventListener('click', () => {\\n sessionStorage.removeItem('kantine_authToken');\\n sessionStorage.removeItem('kantine_currentUser');\\n sessionStorage.removeItem('kantine_firstName');\\n sessionStorage.removeItem('kantine_lastName');\\n authToken = null;\\n currentUser = null;\\n orderMap = new Map();\\n stopPolling();\\n updateAuthUI();\\n renderVisibleWeeks();\\n });\\n }\\n\\n // === Auth UI ===\\n function updateAuthUI() {\\n // Try to recover session from Bessa's storage if not already logged in\\n if (!authToken) {\\n try {\\n const akita = localStorage.getItem('AkitaStores');\\n if (akita) {\\n const parsed = JSON.parse(akita);\\n if (parsed.auth && parsed.auth.token) {\\n console.log('Found existing Bessa session!');\\n authToken = parsed.auth.token;\\n sessionStorage.setItem('kantine_authToken', authToken);\\n\\n if (parsed.auth.user) {\\n currentUser = parsed.auth.user.id || 'unknown';\\n sessionStorage.setItem('kantine_currentUser', currentUser);\\n if (parsed.auth.user.firstName) sessionStorage.setItem('kantine_firstName', parsed.auth.user.firstName);\\n if (parsed.auth.user.lastName) sessionStorage.setItem('kantine_lastName', parsed.auth.user.lastName);\\n }\\n }\\n }\\n } catch (e) {\\n console.warn('Failed to parse AkitaStores:', e);\\n }\\n }\\n\\n authToken = sessionStorage.getItem('kantine_authToken');\\n currentUser = sessionStorage.getItem('kantine_currentUser');\\n const firstName = sessionStorage.getItem('kantine_firstName');\\n const btnLoginOpen = document.getElementById('btn-login-open');\\n const userInfo = document.getElementById('user-info');\\n const userIdDisplay = document.getElementById('user-id-display');\\n\\n if (authToken) {\\n btnLoginOpen.classList.add('hidden');\\n userInfo.classList.remove('hidden');\\n userIdDisplay.textContent = firstName || (currentUser ? `User ${currentUser}` : 'Angemeldet');\\n fetchOrders(); // Always fetch fresh orders on auth update\\n } else {\\n btnLoginOpen.classList.remove('hidden');\\n userInfo.classList.add('hidden');\\n userIdDisplay.textContent = '';\\n }\\n\\n renderVisibleWeeks();\\n }\\n\\n // === Fetch Orders from Bessa ===\\n async function fetchOrders() {\\n if (!authToken) return;\\n try {\\n // Use user/orders endpoint for reliable history\\n const response = await fetch(`${API_BASE}/user/orders/?venue=${VENUE_ID}&ordering=-created&limit=50`, {\\n headers: apiHeaders(authToken)\\n });\\n const data = await response.json();\\n\\n if (response.ok) {\\n orderMap = new Map();\\n const results = data.results || [];\\n\\n for (const order of results) {\\n // Filter out cancelled orders (State 9)\\n // Accepting State 1 (Created?), 5 (Placed?), 8 (Completed)\\n // TODO: Verify exact states. Subagent saw 5=Active, 8=Completed, 9=Cancelled.\\n if (order.order_state === 9) continue;\\n\\n // Extract date properly (it comes as ISO string)\\n const orderDate = order.date.split('T')[0];\\n\\n for (const item of (order.items || [])) {\\n const key = `${orderDate}_${item.article}`;\\n if (!orderMap.has(key)) orderMap.set(key, []);\\n orderMap.get(key).push(order.id);\\n }\\n }\\n console.log(`Fetched ${results.length} orders, mapped active ones.`);\\n renderVisibleWeeks();\\n }\\n } catch (error) {\\n console.error('Error fetching orders:', error);\\n }\\n }\\n\\n // === Place Order ===\\n async function placeOrder(date, articleId, name, price, description) {\\n if (!authToken) return;\\n try {\\n // Get user data for customer object\\n const userResp = await fetch(`${API_BASE}/auth/user/`, {\\n headers: apiHeaders(authToken)\\n });\\n if (!userResp.ok) {\\n showToast('Fehler: Benutzerdaten konnten nicht geladen werden', 'error');\\n return;\\n }\\n const userData = await userResp.json();\\n const now = new Date().toISOString();\\n\\n const orderPayload = {\\n uuid: crypto.randomUUID(),\\n created: now,\\n updated: now,\\n order_type: 7,\\n items: [{\\n article: articleId,\\n course_group: null,\\n modifiers: [],\\n uuid: crypto.randomUUID(),\\n name: name,\\n description: description || '',\\n price: String(parseFloat(price)),\\n amount: 1,\\n vat: '10.00',\\n comment: ''\\n }],\\n table: null,\\n total: parseFloat(price),\\n tip: 0,\\n currency: 'EUR',\\n venue: VENUE_ID,\\n states: [],\\n order_state: 1,\\n date: `${date}T10:00:00.000Z`,\\n payment_method: 'payroll',\\n customer: {\\n first_name: userData.first_name,\\n last_name: userData.last_name,\\n email: userData.email,\\n newsletter: false\\n },\\n preorder: false,\\n delivery_fee: 0,\\n cash_box_table_name: null,\\n take_away: false\\n };\\n\\n const response = await fetch(`${API_BASE}/user/orders/`, {\\n method: 'POST',\\n headers: apiHeaders(authToken),\\n body: JSON.stringify(orderPayload)\\n });\\n\\n if (response.ok || response.status === 201) {\\n showToast(`Bestellt: ${name}`, 'success');\\n await fetchOrders();\\n } else {\\n const data = await response.json();\\n showToast(`Fehler: ${data.detail || data.non_field_errors?.[0] || 'Bestellung fehlgeschlagen'}`, 'error');\\n }\\n } catch (error) {\\n console.error('Order error:', error);\\n showToast('Netzwerkfehler bei Bestellung', 'error');\\n }\\n }\\n\\n // === Cancel Order ===\\n async function cancelOrder(date, articleId, name) {\\n if (!authToken) return;\\n const key = `${date}_${articleId}`;\\n const orderIds = orderMap.get(key);\\n if (!orderIds || orderIds.length === 0) return;\\n\\n // LIFO: cancel most recent\\n const orderId = orderIds[orderIds.length - 1];\\n try {\\n const response = await fetch(`${API_BASE}/user/orders/${orderId}/cancel/`, {\\n method: 'PATCH',\\n headers: apiHeaders(authToken),\\n body: JSON.stringify({})\\n });\\n\\n if (response.ok) {\\n showToast(`Storniert: ${name}`, 'success');\\n await fetchOrders();\\n } else {\\n const data = await response.json();\\n showToast(`Fehler: ${data.detail || 'Stornierung fehlgeschlagen'}`, 'error');\\n }\\n } catch (error) {\\n console.error('Cancel error:', error);\\n showToast('Netzwerkfehler bei Stornierung', 'error');\\n }\\n }\\n\\n // === Flag Management (localStorage) ===\\n function saveFlags() {\\n localStorage.setItem('kantine_flags', JSON.stringify([...userFlags]));\\n }\\n\\n function toggleFlag(date, articleId, name, cutoff) {\\n const id = `${date}_${articleId}`;\\n if (userFlags.has(id)) {\\n userFlags.delete(id);\\n showToast(`Flag entfernt f\\u00fcr ${name}`, 'success');\\n } else {\\n userFlags.add(id);\\n showToast(`Benachrichtigung aktiviert f\\u00fcr ${name}`, 'success');\\n if (Notification.permission === 'default') {\\n Notification.requestPermission();\\n }\\n }\\n saveFlags();\\n renderVisibleWeeks();\\n }\\n\\n // FR-019: Auto-remove flags whose cutoff has passed\\n function cleanupExpiredFlags() {\\n const now = new Date();\\n let changed = false;\\n for (const flagId of [...userFlags]) {\\n const [date] = flagId.split('_');\\n const cutoff = new Date(date);\\n cutoff.setHours(10, 0, 0, 0); // Standard cutoff 10:00\\n if (now >= cutoff) {\\n userFlags.delete(flagId);\\n changed = true;\\n }\\n }\\n if (changed) saveFlags();\\n }\\n\\n // === Polling (Client-Side) ===\\n function startPolling() {\\n if (pollIntervalId) return;\\n if (!authToken) return;\\n pollIntervalId = setInterval(() => pollFlaggedItems(), POLL_INTERVAL_MS);\\n console.log('Polling started (every 5 min)');\\n }\\n\\n function stopPolling() {\\n if (pollIntervalId) {\\n clearInterval(pollIntervalId);\\n pollIntervalId = null;\\n console.log('Polling stopped');\\n }\\n }\\n\\n async function pollFlaggedItems() {\\n if (userFlags.size === 0 || !authToken) return;\\n console.log(`Polling ${userFlags.size} flagged items...`);\\n\\n for (const flagId of userFlags) {\\n const [date, articleIdStr] = flagId.split('_');\\n const articleId = parseInt(articleIdStr);\\n\\n try {\\n const response = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${date}/`, {\\n headers: apiHeaders(authToken)\\n });\\n if (!response.ok) continue;\\n\\n const data = await response.json();\\n const groups = data.results || [];\\n let foundItem = null;\\n for (const group of groups) {\\n if (group.items) {\\n foundItem = group.items.find(i => i.id === articleId || i.article === articleId);\\n if (foundItem) break;\\n }\\n }\\n\\n if (foundItem) {\\n const isAvailable = (foundItem.amount_tracking === false) || (parseInt(foundItem.available_amount) > 0);\\n if (isAvailable) {\\n const itemName = foundItem.name || 'Unbekannt';\\n showToast(`${itemName} ist jetzt verf\\u00fcgbar!`, 'success');\\n if (Notification.permission === 'granted') {\\n new Notification('Kantine Wrapper', {\\n body: `${itemName} ist jetzt verf\\u00fcgbar!`,\\n icon: '\\ud83c\\udf7d\\ufe0f'\\n });\\n }\\n // Refresh menu data to update UI\\n loadMenuDataFromAPI();\\n break; // One refresh is enough\\n }\\n }\\n } catch (err) {\\n console.error(`Poll error for ${flagId}:`, err);\\n }\\n\\n // Small delay between checks\\n await new Promise(r => setTimeout(r, 200));\\n }\\n }\\n\\n // === Local Menu Cache (localStorage) ===\\n const CACHE_KEY = 'kantine_menuCache';\\n const CACHE_TS_KEY = 'kantine_menuCacheTs';\\n\\n function saveMenuCache() {\\n try {\\n localStorage.setItem(CACHE_KEY, JSON.stringify(allWeeks));\\n localStorage.setItem(CACHE_TS_KEY, new Date().toISOString());\\n } catch (e) {\\n console.warn('Failed to cache menu data:', e);\\n }\\n }\\n\\n function loadMenuCache() {\\n try {\\n const cached = localStorage.getItem(CACHE_KEY);\\n const cachedTs = localStorage.getItem(CACHE_TS_KEY);\\n if (cached) {\\n allWeeks = JSON.parse(cached);\\n currentWeekNumber = getISOWeek(new Date());\\n currentYear = new Date().getFullYear();\\n renderVisibleWeeks();\\n updateNextWeekBadge();\\n if (cachedTs) updateLastUpdatedTime(cachedTs);\\n console.log('Loaded menu from cache');\\n return true;\\n }\\n } catch (e) {\\n console.warn('Failed to load cached menu:', e);\\n }\\n return false;\\n }\\n\\n // === Menu Data Fetching (Direct from Bessa API) ===\\n async function loadMenuDataFromAPI() {\\n const loading = document.getElementById('loading');\\n const progressModal = document.getElementById('progress-modal');\\n const progressFill = document.getElementById('progress-fill');\\n const progressPercent = document.getElementById('progress-percent');\\n const progressMessage = document.getElementById('progress-message');\\n\\n loading.classList.remove('hidden');\\n\\n const token = authToken || GUEST_TOKEN;\\n\\n try {\\n // Show progress modal\\n progressModal.classList.remove('hidden');\\n progressMessage.textContent = 'Hole verf\\u00fcgbare Daten...';\\n progressFill.style.width = '0%';\\n progressPercent.textContent = '0%';\\n\\n // 1. Fetch available dates\\n const datesResponse = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/dates/`, {\\n headers: apiHeaders(token)\\n });\\n\\n if (!datesResponse.ok) throw new Error(`Failed to fetch dates: ${datesResponse.status}`);\\n\\n const datesData = await datesResponse.json();\\n let availableDates = datesData.results || [];\\n\\n // Filter \\u2013 last 7 days + future, limit 30\\n const cutoff = new Date();\\n cutoff.setDate(cutoff.getDate() - 7);\\n const cutoffStr = cutoff.toISOString().split('T')[0];\\n\\n availableDates = availableDates\\n .filter(d => d.date >= cutoffStr)\\n .sort((a, b) => a.date.localeCompare(b.date))\\n .slice(0, 30);\\n\\n const totalDates = availableDates.length;\\n progressMessage.textContent = `${totalDates} Tage gefunden. Lade Details...`;\\n\\n // 2. Fetch details for each date\\n const allDays = [];\\n let completed = 0;\\n\\n for (const dateObj of availableDates) {\\n const dateStr = dateObj.date;\\n const pct = Math.round(((completed + 1) / totalDates) * 100);\\n progressFill.style.width = `${pct}%`;\\n progressPercent.textContent = `${pct}%`;\\n progressMessage.textContent = `Lade Men\\u00fc f\\u00fcr ${dateStr}...`;\\n\\n try {\\n const detailResp = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${dateStr}/`, {\\n headers: apiHeaders(token)\\n });\\n\\n if (detailResp.ok) {\\n const detailData = await detailResp.json();\\n // Debug: log raw API response for first date\\n if (completed === 0) {\\n console.log('[Kantine Debug] Raw API response for', dateStr, ':', JSON.stringify(detailData).substring(0, 2000));\\n }\\n const menuGroups = detailData.results || [];\\n let dayItems = [];\\n for (const group of menuGroups) {\\n if (group.items && Array.isArray(group.items)) {\\n dayItems = dayItems.concat(group.items);\\n }\\n }\\n if (dayItems.length > 0) {\\n // Debug: log first item structure\\n if (completed === 0) {\\n console.log('[Kantine Debug] First item keys:', Object.keys(dayItems[0]));\\n console.log('[Kantine Debug] First item:', JSON.stringify(dayItems[0]).substring(0, 500));\\n }\\n allDays.push({\\n date: dateStr,\\n menu_items: dayItems,\\n orders: dateObj.orders || []\\n });\\n }\\n }\\n } catch (err) {\\n console.error(`Failed to fetch details for ${dateStr}:`, err);\\n }\\n\\n completed++;\\n // Small delay to avoid rate limiting\\n await new Promise(r => setTimeout(r, 100));\\n }\\n\\n // 3. Group by ISO week (Merge with existing to preserve past days)\\n const weeksMap = new Map();\\n\\n // Hydrate from existing cache (preserve past data)\\n if (allWeeks && allWeeks.length > 0) {\\n allWeeks.forEach(w => {\\n const key = `${w.year}-${w.weekNumber}`;\\n try {\\n weeksMap.set(key, {\\n year: w.year,\\n weekNumber: w.weekNumber,\\n days: w.days ? w.days.map(d => ({ ...d, items: d.items ? [...d.items] : [] })) : []\\n });\\n } catch (e) { console.warn('Error hydrating week:', e); }\\n });\\n }\\n\\n for (const day of allDays) {\\n const d = new Date(day.date);\\n const weekNum = getISOWeek(d);\\n const year = getWeekYear(d);\\n const key = `${year}-${weekNum}`;\\n\\n if (!weeksMap.has(key)) {\\n weeksMap.set(key, { year, weekNumber: weekNum, days: [] });\\n }\\n\\n const weekObj = weeksMap.get(key);\\n const weekday = d.toLocaleDateString('en-US', { weekday: 'long' });\\n const orderCutoffDate = new Date(day.date);\\n orderCutoffDate.setHours(10, 0, 0, 0);\\n\\n const newDayObj = {\\n date: day.date,\\n weekday: weekday,\\n orderCutoff: orderCutoffDate.toISOString(),\\n items: day.menu_items.map(item => {\\n const isUnlimited = item.amount_tracking === false;\\n const hasStock = parseInt(item.available_amount) > 0;\\n return {\\n id: `${day.date}_${item.id}`,\\n articleId: item.id,\\n name: item.name || 'Unknown',\\n description: item.description || '',\\n price: parseFloat(item.price) || 0,\\n available: isUnlimited || hasStock,\\n availableAmount: parseInt(item.available_amount) || 0,\\n amountTracking: item.amount_tracking !== false\\n };\\n })\\n };\\n\\n // Merge: Overwrite if exists, push if new\\n const existingIndex = weekObj.days.findIndex(existing => existing.date === day.date);\\n if (existingIndex >= 0) {\\n weekObj.days[existingIndex] = newDayObj;\\n } else {\\n weekObj.days.push(newDayObj);\\n }\\n }\\n\\n // Sort weeks and days\\n allWeeks = Array.from(weeksMap.values()).sort((a, b) => {\\n if (a.year !== b.year) return a.year - b.year;\\n return a.weekNumber - b.weekNumber;\\n });\\n allWeeks.forEach(w => {\\n if (w.days) w.days.sort((a, b) => a.date.localeCompare(b.date));\\n });\\n\\n // Save to localStorage cache\\n saveMenuCache();\\n\\n // Update timestamp\\n updateLastUpdatedTime(new Date().toISOString());\\n\\n currentWeekNumber = getISOWeek(new Date());\\n currentYear = new Date().getFullYear();\\n\\n\\n\\n updateAuthUI(); // This will trigger fetchOrders if logged in\\n renderVisibleWeeks();\\n updateNextWeekBadge();\\n\\n progressMessage.textContent = 'Fertig!';\\n setTimeout(() => progressModal.classList.add('hidden'), 500);\\n\\n } catch (error) {\\n console.error('Error fetching menu:', error);\\n progressModal.classList.add('hidden');\\n\\n showErrorModal(\\n 'Keine Verbindung',\\n `Die Men\\u00fcdaten konnten nicht geladen werden. M\\u00f6glicherweise besteht keine Verbindung zur API oder zur Bessa-Webseite.<br><br><small style=\\\"color:var(--text-secondary)\\\">${error.message}</small>`,\\n 'Zur Original-Seite',\\n 'https://web.bessa.app/knapp-kantine'\\n );\\n } finally {\\n loading.classList.add('hidden');\\n }\\n }\\n\\n // === Last Updated Display ===\\n function updateLastUpdatedTime(isoTimestamp) {\\n const subtitle = document.getElementById('last-updated-subtitle');\\n if (!isoTimestamp) return;\\n try {\\n const date = new Date(isoTimestamp);\\n const timeStr = date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });\\n const dateStr = date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });\\n subtitle.textContent = `Aktualisiert: ${dateStr} ${timeStr}`;\\n } catch (e) {\\n subtitle.textContent = '';\\n }\\n }\\n\\n // === Toast Notification ===\\n function showToast(message, type = 'info') {\\n let container = document.getElementById('toast-container');\\n if (!container) {\\n container = document.createElement('div');\\n container.id = 'toast-container';\\n document.body.appendChild(container);\\n }\\n const toast = document.createElement('div');\\n toast.className = `toast toast-${type}`;\\n const icon = type === 'success' ? 'check_circle' : type === 'error' ? 'error' : 'info';\\n toast.innerHTML = `<span class=\\\"material-icons-round\\\">${icon}</span><span>${message}</span>`;\\n container.appendChild(toast);\\n requestAnimationFrame(() => toast.classList.add('show'));\\n setTimeout(() => {\\n toast.classList.remove('show');\\n setTimeout(() => toast.remove(), 300);\\n }, 3000);\\n }\\n\\n // === Next Week Badge ===\\n function updateNextWeekBadge() {\\n const btnNextWeek = document.getElementById('btn-next-week');\\n let nextWeek = currentWeekNumber + 1;\\n let nextYear = currentYear;\\n if (nextWeek > 52) { nextWeek = 1; nextYear++; }\\n\\n const nextWeekData = allWeeks.find(w => w.weekNumber === nextWeek && w.year === nextYear);\\n let totalDataCount = 0;\\n let orderableCount = 0;\\n let daysWithOrders = 0;\\n let daysWithOrderableAndNoOrder = 0;\\n\\n if (nextWeekData && nextWeekData.days) {\\n nextWeekData.days.forEach(day => {\\n if (day.items && day.items.length > 0) {\\n totalDataCount++;\\n const isOrderable = day.items.some(item => item.available);\\n if (isOrderable) orderableCount++;\\n\\n let hasOrder = false;\\n day.items.forEach(item => {\\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\\n const key = `${day.date}_${articleId}`;\\n if (orderMap.has(key) && orderMap.get(key).length > 0) hasOrder = true;\\n });\\n\\n if (hasOrder) daysWithOrders++;\\n if (isOrderable && !hasOrder) daysWithOrderableAndNoOrder++;\\n }\\n });\\n }\\n\\n let badge = btnNextWeek.querySelector('.nav-badge');\\n if (totalDataCount > 0) {\\n if (!badge) {\\n badge = document.createElement('span');\\n badge.className = 'nav-badge';\\n btnNextWeek.appendChild(badge);\\n }\\n\\n // Format: ( Ordered / Orderable / Total )\\n badge.title = `${daysWithOrders} bestellt / ${orderableCount} bestellbar / ${totalDataCount} gesamt`;\\n badge.innerHTML = `<span class=\\\"ordered\\\">${daysWithOrders}</span><span class=\\\"separator\\\">/</span><span class=\\\"orderable\\\">${orderableCount}</span><span class=\\\"separator\\\">/</span><span class=\\\"total\\\">${totalDataCount}</span>`;\\n\\n // Color Logic\\n badge.classList.remove('badge-violet', 'badge-green', 'badge-red', 'badge-blue');\\n\\n // Refined Logic (v1.7.4):\\n // Violet: If we have orders AND there are no DAYS left that are orderable but un-ordered.\\n // (i.e. \\\"I have ordered everything I can\\\")\\n if (daysWithOrders > 0 && daysWithOrderableAndNoOrder === 0) {\\n badge.classList.add('badge-violet');\\n } else if (daysWithOrderableAndNoOrder > 0) {\\n badge.classList.add('badge-green'); // Orderable days exist without order\\n } else if (orderableCount === 0) {\\n badge.classList.add('badge-red'); // No orderable days at all & no orders\\n } else {\\n badge.classList.add('badge-blue'); // Default / partial state\\n }\\n\\n } else if (badge) {\\n badge.remove();\\n }\\n }\\n\\n // === Weekly Cost ===\\n function updateWeeklyCost(days) {\\n let totalCost = 0;\\n if (days && days.length > 0) {\\n days.forEach(day => {\\n if (day.items) {\\n day.items.forEach(item => {\\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\\n const key = `${day.date}_${articleId}`;\\n const orders = orderMap.get(key) || [];\\n if (orders.length > 0) totalCost += item.price * orders.length;\\n });\\n }\\n });\\n }\\n\\n const costDisplay = document.getElementById('weekly-cost-display');\\n if (totalCost > 0) {\\n costDisplay.innerHTML = `<span class=\\\"material-icons-round\\\">shopping_bag</span> <span>Gesamt: ${totalCost.toFixed(2).replace('.', ',')} \\u20ac</span>`;\\n costDisplay.classList.remove('hidden');\\n } else {\\n costDisplay.classList.add('hidden');\\n }\\n }\\n\\n // === Render Weeks ===\\n function renderVisibleWeeks() {\\n const menuContainer = document.getElementById('menu-container');\\n if (!menuContainer) return;\\n menuContainer.innerHTML = '';\\n\\n let targetWeek = currentWeekNumber;\\n let targetYear = currentYear;\\n\\n if (displayMode === 'next-week') {\\n targetWeek++;\\n if (targetWeek > 52) { targetWeek = 1; targetYear++; }\\n }\\n\\n // Flatten & filter by week + year\\n const allDays = allWeeks.flatMap(w => w.days || []);\\n const daysInTargetWeek = allDays.filter(day => {\\n const d = new Date(day.date);\\n return getISOWeek(d) === targetWeek && getWeekYear(d) === targetYear;\\n });\\n\\n if (daysInTargetWeek.length === 0) {\\n menuContainer.innerHTML = `\\n <div class=\\\"empty-state\\\">\\n <p>Keine Men\\u00fcdaten f\\u00fcr KW ${targetWeek} (${targetYear}) verf\\u00fcgbar.</p>\\n <small>Versuchen Sie eine andere Woche oder schauen Sie sp\\u00e4ter vorbei.</small>\\n </div>`;\\n document.getElementById('weekly-cost-display').classList.add('hidden');\\n return;\\n }\\n\\n updateWeeklyCost(daysInTargetWeek);\\n\\n // Update header\\n const headerWeekInfo = document.getElementById('header-week-info');\\n const weekTitle = displayMode === 'this-week' ? 'Diese Woche' : 'N\\u00e4chste Woche';\\n headerWeekInfo.innerHTML = `\\n <div class=\\\"header-week-title\\\">${weekTitle}</div>\\n <div class=\\\"header-week-subtitle\\\">Week ${targetWeek} \\u2022 ${targetYear}</div>`;\\n\\n const grid = document.createElement('div');\\n grid.className = 'days-grid';\\n\\n daysInTargetWeek.sort((a, b) => a.date.localeCompare(b.date));\\n\\n // Filter weekends\\n const workingDays = daysInTargetWeek.filter(d => {\\n const date = new Date(d.date);\\n const day = date.getDay();\\n return day !== 0 && day !== 6;\\n });\\n\\n workingDays.forEach(day => {\\n const card = createDayCard(day);\\n if (card) grid.appendChild(card);\\n });\\n\\n menuContainer.appendChild(grid);\\n setTimeout(() => syncMenuItemHeights(grid), 0);\\n }\\n\\n // === Sync Item Heights ===\\n function syncMenuItemHeights(grid) {\\n const cards = grid.querySelectorAll('.menu-card');\\n if (cards.length === 0) return;\\n let maxItems = 0;\\n cards.forEach(card => {\\n maxItems = Math.max(maxItems, card.querySelectorAll('.menu-item').length);\\n });\\n for (let i = 0; i < maxItems; i++) {\\n let maxHeight = 0;\\n const itemsAtPos = [];\\n cards.forEach(card => {\\n const items = card.querySelectorAll('.menu-item');\\n if (items[i]) {\\n items[i].style.height = 'auto';\\n maxHeight = Math.max(maxHeight, items[i].offsetHeight);\\n itemsAtPos.push(items[i]);\\n }\\n });\\n itemsAtPos.forEach(item => { item.style.height = `${maxHeight}px`; });\\n }\\n }\\n\\n // === Create Day Card ===\\n function createDayCard(day) {\\n if (!day.items || day.items.length === 0) return null;\\n\\n const card = document.createElement('div');\\n card.className = 'menu-card';\\n\\n const now = new Date();\\n const cardDate = new Date(day.date);\\n\\n let isPastCutoff = false;\\n if (day.orderCutoff) {\\n isPastCutoff = now >= new Date(day.orderCutoff);\\n } else {\\n const today = new Date();\\n today.setHours(0, 0, 0, 0);\\n const cd = new Date(day.date);\\n cd.setHours(0, 0, 0, 0);\\n isPastCutoff = cd < today;\\n }\\n\\n if (isPastCutoff) card.classList.add('past-day');\\n\\n // Collect ordered menu codes\\n const menuBadges = [];\\n if (day.items) {\\n day.items.forEach(item => {\\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\\n const orderKey = `${day.date}_${articleId}`;\\n const orders = orderMap.get(orderKey) || [];\\n const count = orders.length;\\n\\n if (count > 0) {\\n // Regex for M1, M2, M1F etc.\\n const match = item.name.match(/([M][1-9][Ff]?)/);\\n if (match) {\\n let code = match[1];\\n if (count > 1) code += '+';\\n menuBadges.push(code);\\n }\\n }\\n });\\n }\\n\\n // Header\\n const header = document.createElement('div');\\n header.className = 'card-header';\\n const dateStr = cardDate.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });\\n\\n const badgesHtml = menuBadges.map(code => `<span class=\\\"menu-code-badge\\\">${code}</span>`).join('');\\n\\n // Determine Day Status for Header Color\\n // Violet: Has Order\\n // Green: No Order but Orderable\\n // Red: No Order and Not Orderable (Locked/Sold Out)\\n let headerClass = '';\\n const hasAnyOrder = day.items && day.items.some(item => {\\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\\n const key = `${day.date}_${articleId}`;\\n return orderMap.has(key) && orderMap.get(key).length > 0;\\n });\\n\\n const hasOrderable = day.items && day.items.some(item => {\\n // Use pre-calculated available flag from loadMenuDataFromAPI calculation\\n return item.available;\\n });\\n\\n if (hasAnyOrder) {\\n headerClass = 'header-violet';\\n } else if (hasOrderable && !isPastCutoff) {\\n headerClass = 'header-green';\\n } else {\\n // Red if not orderable (or past cutoff)\\n headerClass = 'header-red';\\n }\\n\\n if (headerClass) header.classList.add(headerClass);\\n\\n header.innerHTML = `\\n <div class=\\\"day-header-left\\\">\\n <span class=\\\"day-name\\\">${translateDay(day.weekday)}</span>\\n <div class=\\\"day-badges\\\">${badgesHtml}</div>\\n </div>\\n <span class=\\\"day-date\\\">${dateStr}</span>`;\\n card.appendChild(header);\\n\\n // Body\\n const body = document.createElement('div');\\n body.className = 'card-body';\\n\\n const todayDateStr = new Date().toISOString().split('T')[0];\\n const isToday = day.date === todayDateStr;\\n\\n const sortedItems = [...day.items].sort((a, b) => {\\n if (isToday) {\\n const aId = a.articleId || parseInt(a.id.split('_')[1]);\\n const bId = b.articleId || parseInt(b.id.split('_')[1]);\\n const aOrdered = orderMap.has(`${day.date}_${aId}`);\\n const bOrdered = orderMap.has(`${day.date}_${bId}`);\\n\\n if (aOrdered && !bOrdered) return -1;\\n if (!aOrdered && bOrdered) return 1;\\n }\\n return a.name.localeCompare(b.name);\\n });\\n\\n sortedItems.forEach(item => {\\n const itemEl = document.createElement('div');\\n itemEl.className = 'menu-item';\\n\\n const articleId = item.articleId || parseInt(item.id.split('_')[1]);\\n const orderKey = `${day.date}_${articleId}`;\\n const orderIds = orderMap.get(orderKey) || [];\\n const orderCount = orderIds.length;\\n\\n // Status badge\\n let statusBadge = '';\\n if (item.available) {\\n statusBadge = item.amountTracking\\n ? `<span class=\\\"badge available\\\">Verf\\u00fcgbar (${item.availableAmount})</span>`\\n : `<span class=\\\"badge available\\\">Verf\\u00fcgbar</span>`;\\n } else {\\n statusBadge = `<span class=\\\"badge sold-out\\\">Ausverkauft</span>`;\\n }\\n\\n // Order badge\\n let orderedBadge = '';\\n if (orderCount > 0) {\\n const countBadge = orderCount > 1 ? `<span class=\\\"order-count-badge\\\">${orderCount}</span>` : '';\\n orderedBadge = `<span class=\\\"badge ordered\\\"><span class=\\\"material-icons-round\\\">check_circle</span> Bestellt${countBadge}</span>`;\\n itemEl.classList.add('ordered');\\n if (new Date(day.date).toDateString() === now.toDateString()) {\\n itemEl.classList.add('today-ordered');\\n }\\n }\\n\\n // Flagged styles\\n const flagId = `${day.date}_${articleId}`;\\n const isFlagged = userFlags.has(flagId);\\n if (isFlagged) {\\n itemEl.classList.add(item.available ? 'flagged-available' : 'flagged-sold-out');\\n }\\n\\n // Action buttons\\n let orderButton = '';\\n let cancelButton = '';\\n let flagButton = '';\\n\\n if (authToken && !isPastCutoff) {\\n // Flag button\\n const flagIcon = isFlagged ? 'notifications_active' : 'notifications_none';\\n const flagClass = isFlagged ? 'btn-flag active' : 'btn-flag';\\n const flagTitle = isFlagged ? 'Benachrichtigung deaktivieren' : 'Benachrichtigen wenn verf\\u00fcgbar';\\n if (!item.available || isFlagged) {\\n flagButton = `<button class=\\\"${flagClass}\\\" data-date=\\\"${day.date}\\\" data-article=\\\"${articleId}\\\" data-name=\\\"${escapeHtml(item.name)}\\\" data-cutoff=\\\"${day.orderCutoff}\\\" title=\\\"${flagTitle}\\\"><span class=\\\"material-icons-round\\\">${flagIcon}</span></button>`;\\n }\\n\\n // Order button\\n if (item.available) {\\n if (orderCount > 0) {\\n orderButton = `<button class=\\\"btn-order btn-order-compact\\\" data-date=\\\"${day.date}\\\" data-article=\\\"${articleId}\\\" data-name=\\\"${escapeHtml(item.name)}\\\" data-price=\\\"${item.price}\\\" data-desc=\\\"${escapeHtml(item.description || '')}\\\" title=\\\"${escapeHtml(item.name)} nochmal bestellen\\\"><span class=\\\"material-icons-round\\\">add</span></button>`;\\n } else {\\n orderButton = `<button class=\\\"btn-order\\\" data-date=\\\"${day.date}\\\" data-article=\\\"${articleId}\\\" data-name=\\\"${escapeHtml(item.name)}\\\" data-price=\\\"${item.price}\\\" data-desc=\\\"${escapeHtml(item.description || '')}\\\" title=\\\"${escapeHtml(item.name)} bestellen\\\"><span class=\\\"material-icons-round\\\">add_shopping_cart</span> Bestellen</button>`;\\n }\\n }\\n\\n // Cancel button\\n if (orderCount > 0) {\\n const cancelIcon = orderCount === 1 ? 'close' : 'remove';\\n const cancelTitle = orderCount === 1 ? 'Bestellung stornieren' : 'Eine Bestellung stornieren';\\n cancelButton = `<button class=\\\"btn-cancel\\\" data-date=\\\"${day.date}\\\" data-article=\\\"${articleId}\\\" data-name=\\\"${escapeHtml(item.name)}\\\" title=\\\"${cancelTitle}\\\"><span class=\\\"material-icons-round\\\">${cancelIcon}</span></button>`;\\n }\\n }\\n\\n itemEl.innerHTML = `\\n <div class=\\\"item-header\\\">\\n <span class=\\\"item-name\\\">${escapeHtml(item.name)}</span>\\n <span class=\\\"item-price\\\">${item.price.toFixed(2)} \\u20ac</span>\\n </div>\\n <div class=\\\"item-status-row\\\">\\n ${orderedBadge}\\n ${cancelButton}\\n ${orderButton}\\n ${flagButton}\\n <div class=\\\"badges\\\">${statusBadge}</div>\\n </div>\\n <p class=\\\"item-desc\\\">${escapeHtml(item.description)}</p>`;\\n\\n // Event: Order\\n const orderBtn = itemEl.querySelector('.btn-order');\\n if (orderBtn) {\\n orderBtn.addEventListener('click', (e) => {\\n e.stopPropagation();\\n const btn = e.currentTarget;\\n btn.disabled = true;\\n btn.classList.add('loading');\\n placeOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, parseFloat(btn.dataset.price), btn.dataset.desc || '')\\n .finally(() => { btn.disabled = false; btn.classList.remove('loading'); });\\n });\\n }\\n\\n // Event: Cancel\\n const cancelBtn = itemEl.querySelector('.btn-cancel');\\n if (cancelBtn) {\\n cancelBtn.addEventListener('click', (e) => {\\n e.stopPropagation();\\n const btn = e.currentTarget;\\n btn.disabled = true;\\n cancelOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name)\\n .finally(() => { btn.disabled = false; });\\n });\\n }\\n\\n // Event: Flag\\n const flagBtn = itemEl.querySelector('.btn-flag');\\n if (flagBtn) {\\n flagBtn.addEventListener('click', (e) => {\\n e.stopPropagation();\\n const btn = e.currentTarget;\\n toggleFlag(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, btn.dataset.cutoff);\\n });\\n }\\n\\n body.appendChild(itemEl);\\n });\\n\\n card.appendChild(body);\\n return card;\\n }\\n\\n // === Helpers ===\\n function getISOWeek(date) {\\n const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));\\n const dayNum = d.getUTCDay() || 7;\\n d.setUTCDate(d.getUTCDate() + 4 - dayNum);\\n const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));\\n return Math.ceil(((d - yearStart) / 86400000 + 1) / 7);\\n }\\n\\n function getWeekYear(d) {\\n const date = new Date(d.getTime());\\n date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7);\\n return date.getFullYear();\\n }\\n\\n function translateDay(englishDay) {\\n const map = { Monday: 'Montag', Tuesday: 'Dienstag', Wednesday: 'Mittwoch', Thursday: 'Donnerstag', Friday: 'Freitag', Saturday: 'Samstag', Sunday: 'Sonntag' };\\n return map[englishDay] || englishDay;\\n }\\n\\n function escapeHtml(text) {\\n const div = document.createElement('div');\\n div.textContent = text || '';\\n return div.innerHTML;\\n }\\n\\n // === Bootstrap ===\\n injectUI();\\n bindEvents();\\n updateAuthUI();\\n cleanupExpiredFlags();\\n\\n // Load cached data first for instant UI, then refresh from API\\n const hadCache = loadMenuCache();\\n if (hadCache) {\\n // Hide loading spinner since cache is shown\\n document.getElementById('loading').classList.add('hidden');\\n }\\n loadMenuDataFromAPI();\\n\\n // Auto-start polling if already logged in\\n if (authToken) {\\n startPolling();\\n }\\n\\n console.log('Kantine Wrapper loaded \\u2705');\\n})();\\n\\n// === Error Modal ===\\nfunction showErrorModal(title, htmlContent, btnText, url) {\\n const modalId = 'error-modal';\\n let modal = document.getElementById(modalId);\\n if (modal) modal.remove();\\n\\n modal = document.createElement('div');\\n modal.id = modalId;\\n modal.className = 'modal hidden';\\n modal.innerHTML = `\\n <div class=\\\"modal-content\\\">\\n <div class=\\\"modal-header\\\">\\n <h2 style=\\\"color: var(--error-color); display: flex; align-items: center; gap: 10px;\\\">\\n <span class=\\\"material-icons-round\\\">signal_wifi_off</span>\\n ${title}\\n </h2>\\n </div>\\n <div style=\\\"padding: 20px;\\\">\\n <p style=\\\"margin-bottom: 15px; color: var(--text-primary);\\\">${htmlContent}</p>\\n <div style=\\\"margin-top: 20px; display: flex; justify-content: center;\\\">\\n <button id=\\\"btn-error-redirect\\\" style=\\\"\\n background-color: var(--accent-color); \\n color: white; \\n padding: 12px 24px; \\n border-radius: 8px; \\n border: none; \\n font-weight: 600; \\n cursor: pointer;\\n display: flex;\\n align-items: center;\\n gap: 8px;\\n width: 100%;\\n justify-content: center;\\n transition: transform 0.1s;\\n \\\">\\n ${btnText}\\n <span class=\\\"material-icons-round\\\">open_in_new</span>\\n </button>\\n </div>\\n </div>\\n </div>\\n `;\\n document.body.appendChild(modal);\\n\\n document.getElementById('btn-error-redirect').addEventListener('click', () => {\\n window.location.href = url;\\n });\\n\\n requestAnimationFrame(() => {\\n modal.classList.remove('hidden');\\n });\\n}\\n\";document.head.appendChild(sc);})();";
|
|
document.getElementById('bookmarklet-link').textContent = 'Kantine v1.8.6';
|
|
</script>
|
|
</body>
|
|
</html>
|