Lade Men\u00fcdaten...
\ndiff --git a/changelog.md b/changelog.md index f55d2e1..5e249a1 100755 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,6 @@ +## v1.1.1 (2026-02-16) +- **Fix**: Kritischer Fehler behoben, der das Laden des Wrappers verhinderte. 🐛 + ## v1.1.0 (2026-02-16) - **Feature: Bestell-Countdown**: Zeigt 1 Stunde vor Bestellschluss einen roten Countdown an. ⏳ - **Feature: Smart Highlights**: Markiere deine Lieblingsspeisen (z.B. "Schnitzel", "Vegetarisch"), damit sie leuchten. 🌟 diff --git a/dist/bookmarklet-payload.js b/dist/bookmarklet-payload.js index 72868e9..ab6366b 100755 --- a/dist/bookmarklet-payload.js +++ b/dist/bookmarklet-payload.js @@ -4,6 +4,6 @@ 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 */ } /* Update Icon */ .update-icon { display: inline-flex; align-items: center; justify-content: center; margin-left: 8px; background-color: rgba(16, 185, 129, 0.2); /* Green tint */ color: var(--success-color); border-radius: 50%; width: 24px; height: 24px; cursor: pointer; font-size: 14px; transition: all 0.2s; text-decoration: none; animation: pulse 2s infinite; } .update-icon:hover { background-color: var(--success-color); color: white; transform: scale(1.1); } @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); } 70% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); } 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); } } /* Order Countdown */ #order-countdown { background: rgba(255, 255, 255, 0.1); padding: 0.25rem 0.75rem; border-radius: 99px; font-size: 0.85rem; display: flex; align-items: center; gap: 0.5rem; white-space: nowrap; border: 1px solid var(--border-color); } #order-countdown span { opacity: 0.7; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; } #order-countdown.urgent { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.5); color: #ef4444; animation: pulse-red 2s infinite; } @keyframes pulse-red { 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } 70% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); } 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } } /* Smart Highlights */ .highlight-glow { box-shadow: 0 0 15px rgba(59, 130, 246, 0.5); /* Blue glow */ border: 1px solid rgba(59, 130, 246, 0.8); background: rgba(59, 130, 246, 0.05); position: relative; z-index: 1; } /* Nav Badge with Count */ .nav-badge.has-highlights { background-color: var(--card-bg); /* Neutral background */ color: var(--text-primary); border: 1px solid var(--border-color); padding: 2px 6px; } .nav-badge .highlight-count { color: #3b82f6; /* Blue 500 */ font-weight: 700; margin-left: 4px; } /* Tag Management Modal */ #tags-list { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; min-height: 50px; } .tag-badge { display: inline-flex; align-items: center; background: rgba(59, 130, 246, 0.15); color: #3b82f6; padding: 4px 10px; border-radius: 99px; font-size: 0.85rem; font-weight: 500; } .tag-remove { margin-left: 6px; cursor: pointer; opacity: 0.7; font-size: 1.1em; line-height: 1; } .tag-remove:hover { opacity: 1; color: #ef4444; } .input-group { display: flex; gap: 0.5rem; } .input-group input { flex: 1; padding: 0.75rem; background: var(--bg-color); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; } /* Update Banner Enhanced */ .change-summary { font-size: 0.8rem; background: rgba(0,0,0,0.1); padding: 0.5rem; border-radius: 4px; margin: 0.5rem 0; white-space: pre-wrap; font-family: inherit; line-height: 1.4; max-height: 100px; overflow-y: auto; } .update-content { display: flex; flex-direction: column; gap: 4px; flex: 1; } /* Installer Changelog */ .changelog-container ul { padding-left: 1.5rem; margin: 0.5rem 0; } .changelog-container li { margin-bottom: 0.4rem; line-height: 1.5; } .changelog-container h3 { margin-top: 1.5rem; margin-bottom: 0.5rem; font-size: 1.1em; color: var(--accent-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
Lade Men\u00fcdaten...
\nKeine Men\u00fcdaten f\u00fcr KW ${targetWeek} (${targetYear}) verf\u00fcgbar.
\n Versuchen Sie eine andere Woche oder schauen Sie sp\u00e4ter vorbei.\n${escapeHtml(item.description)}
`;\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 // === Version Check ===\n async function checkForUpdates() {\n icon.className = 'update-icon';\n icon.href = url;\n icon.target = '_blank';\n icon.innerHTML = '\ud83c\udd95'; // User requested icon\n icon.title = `Neue Version verf\u00fcgbar (${newVersion}). Klick f\u00fcr download`;\n\n headerTitle.appendChild(icon);\n showToast(`Update verf\u00fcgbar: ${newVersion}`, 'info');\n }\n\n // === Order Countdown ===\n function updateCountdown() {\n const now = new Date();\n const currentDay = now.getDay();\n // Skip weekends (0=Sun, 6=Sat)\n if (currentDay === 0 || currentDay === 6) {\n removeCountdown();\n return;\n }\n\n const todayStr = now.toISOString().split('T')[0];\n\n // 1. Check if we already ordered for today\n let hasOrder = false;\n // Optimization: Check orderMap for today's date\n // Keys are \"YYYY-MM-DD_ArticleID\"\n for (const key of orderMap.keys()) {\n if (key.startsWith(todayStr)) {\n hasOrder = true;\n break;\n }\n }\n\n if (hasOrder) {\n removeCountdown();\n return;\n }\n\n // 2. Calculate time to cutoff (10:00 AM)\n const cutoff = new Date();\n cutoff.setHours(10, 0, 0, 0);\n\n const diff = cutoff - now;\n\n // If passed cutoff or more than 3 hours away (e.g. 07:00), maybe don't show?\n // User req: \"heute noch keine bestellung... countdown erscheinen\"\n // Let's show it if within valid order window (e.g. 00:00 - 10:00)\n\n if (diff <= 0) {\n removeCountdown();\n return;\n }\n\n // 3. Render Countdown\n const diffHrs = Math.floor(diff / 3600000);\n const diffMins = Math.floor((diff % 3600000) / 60000);\n\n const headerCenter = document.querySelector('.header-center-wrapper');\n if (!headerCenter) return;\n\n let countdownEl = document.getElementById('order-countdown');\n if (!countdownEl) {\n countdownEl = document.createElement('div');\n countdownEl.id = 'order-countdown';\n // Insert before cost display or append\n headerCenter.insertBefore(countdownEl, headerCenter.firstChild);\n }\n\n countdownEl.innerHTML = `Bestellschluss: ${diffHrs}h ${diffMins}m`;\n\n // Red Alert if < 1 hour\n if (diff < 3600000) { // 1 hour\n countdownEl.classList.add('urgent');\n\n // Notification logic (One time)\n const notifiedKey = `kantine_notified_${todayStr}`;\n if (!sessionStorage.getItem(notifiedKey)) {\n if (Notification.permission === 'granted') {\n new Notification('Kantine: Bestellschluss naht!', {\n body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!',\n icon: '\u23f3'\n });\n } else if (Notification.permission === 'default') {\n Notification.requestPermission();\n }\n sessionStorage.setItem(notifiedKey, 'true');\n }\n } else {\n countdownEl.classList.remove('urgent');\n }\n }\n\n function removeCountdown() {\n const el = document.getElementById('order-countdown');\n if (el) el.remove();\n }\n\n // Update countdown every minute\n setInterval(updateCountdown, 60000);\n // Also update on load\n setTimeout(updateCountdown, 1000);\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 }\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 // Check for updates\n checkForUpdates();\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 \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"; +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 = `\nLade Men\u00fcdaten...
\nKeine Men\u00fcdaten f\u00fcr KW ${targetWeek} (${targetYear}) verf\u00fcgbar.
\n Versuchen Sie eine andere Woche oder schauen Sie sp\u00e4ter vorbei.\n${escapeHtml(item.description)}
`;\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// === Version Check ===\nasync function checkForUpdates() {\n icon.className = 'update-icon';\n icon.href = url;\n icon.target = '_blank';\n icon.innerHTML = '\ud83c\udd95'; // User requested icon\n icon.title = `Neue Version verf\u00fcgbar (${newVersion}). Klick f\u00fcr download`;\n\n headerTitle.appendChild(icon);\n showToast(`Update verf\u00fcgbar: ${newVersion}`, 'info');\n}\n\n// === Order Countdown ===\nfunction updateCountdown() {\n const now = new Date();\n const currentDay = now.getDay();\n // Skip weekends (0=Sun, 6=Sat)\n if (currentDay === 0 || currentDay === 6) {\n removeCountdown();\n return;\n }\n\n const todayStr = now.toISOString().split('T')[0];\n\n // 1. Check if we already ordered for today\n let hasOrder = false;\n // Optimization: Check orderMap for today's date\n // Keys are \"YYYY-MM-DD_ArticleID\"\n for (const key of orderMap.keys()) {\n if (key.startsWith(todayStr)) {\n hasOrder = true;\n break;\n }\n }\n\n if (hasOrder) {\n removeCountdown();\n return;\n }\n\n // 2. Calculate time to cutoff (10:00 AM)\n const cutoff = new Date();\n cutoff.setHours(10, 0, 0, 0);\n\n const diff = cutoff - now;\n\n // If passed cutoff or more than 3 hours away (e.g. 07:00), maybe don't show?\n // User req: \"heute noch keine bestellung... countdown erscheinen\"\n // Let's show it if within valid order window (e.g. 00:00 - 10:00)\n\n if (diff <= 0) {\n removeCountdown();\n return;\n }\n\n // 3. Render Countdown\n const diffHrs = Math.floor(diff / 3600000);\n const diffMins = Math.floor((diff % 3600000) / 60000);\n\n const headerCenter = document.querySelector('.header-center-wrapper');\n if (!headerCenter) return;\n\n let countdownEl = document.getElementById('order-countdown');\n if (!countdownEl) {\n countdownEl = document.createElement('div');\n countdownEl.id = 'order-countdown';\n // Insert before cost display or append\n headerCenter.insertBefore(countdownEl, headerCenter.firstChild);\n }\n\n countdownEl.innerHTML = `Bestellschluss: ${diffHrs}h ${diffMins}m`;\n\n // Red Alert if < 1 hour\n if (diff < 3600000) { // 1 hour\n countdownEl.classList.add('urgent');\n\n // Notification logic (One time)\n const notifiedKey = `kantine_notified_${todayStr}`;\n if (!sessionStorage.getItem(notifiedKey)) {\n if (Notification.permission === 'granted') {\n new Notification('Kantine: Bestellschluss naht!', {\n body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!',\n icon: '\u23f3'\n });\n } else if (Notification.permission === 'default') {\n Notification.requestPermission();\n }\n sessionStorage.setItem(notifiedKey, 'true');\n }\n } else {\n countdownEl.classList.remove('urgent');\n }\n}\n\nfunction removeCountdown() {\n const el = document.getElementById('order-countdown');\n if (el) el.remove();\n}\n\n// Update countdown every minute\nsetInterval(updateCountdown, 60000);\n// Also update on load\nsetTimeout(updateCountdown, 1000);\n\n// === Helpers === \nfunction 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\nfunction 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\nfunction 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\nfunction escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text || '';\n return div.innerHTML;\n}\n\n// === Bootstrap ===\ninjectUI();\nbindEvents();\nupdateAuthUI();\ncleanupExpiredFlags();\n\n// Load cached data first for instant UI, then refresh from API\nconst hadCache = loadMenuCache();\nif (hadCache) {\n // Hide loading spinner since cache is shown\n document.getElementById('loading').classList.add('hidden');\n}\nloadMenuDataFromAPI();\n\n// Auto-start polling if already logged in\nif (authToken) {\n startPolling();\n}\n\n// Check for updates\ncheckForUpdates();\n\nconsole.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 \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); })(); diff --git a/dist/bookmarklet.txt b/dist/bookmarklet.txt index 9cf92be..f99df31 100755 --- a/dist/bookmarklet.txt +++ b/dist/bookmarklet.txt @@ -1 +1 @@ -javascript:(function(){ if(window.__KANTINE_LOADED){alert('Kantine Wrapper 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 */ } /* Update Icon */ .update-icon { display: inline-flex; align-items: center; justify-content: center; margin-left: 8px; background-color: rgba(16, 185, 129, 0.2); /* Green tint */ color: var(--success-color); border-radius: 50%; width: 24px; height: 24px; cursor: pointer; font-size: 14px; transition: all 0.2s; text-decoration: none; animation: pulse 2s infinite; } .update-icon:hover { background-color: var(--success-color); color: white; transform: scale(1.1); } @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); } 70% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); } 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); } } /* Order Countdown */ #order-countdown { background: rgba(255, 255, 255, 0.1); padding: 0.25rem 0.75rem; border-radius: 99px; font-size: 0.85rem; display: flex; align-items: center; gap: 0.5rem; white-space: nowrap; border: 1px solid var(--border-color); } #order-countdown span { opacity: 0.7; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; } #order-countdown.urgent { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.5); color: #ef4444; animation: pulse-red 2s infinite; } @keyframes pulse-red { 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } 70% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); } 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } } /* Smart Highlights */ .highlight-glow { box-shadow: 0 0 15px rgba(59, 130, 246, 0.5); /* Blue glow */ border: 1px solid rgba(59, 130, 246, 0.8); background: rgba(59, 130, 246, 0.05); position: relative; z-index: 1; } /* Nav Badge with Count */ .nav-badge.has-highlights { background-color: var(--card-bg); /* Neutral background */ color: var(--text-primary); border: 1px solid var(--border-color); padding: 2px 6px; } .nav-badge .highlight-count { color: #3b82f6; /* Blue 500 */ font-weight: 700; margin-left: 4px; } /* Tag Management Modal */ #tags-list { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; min-height: 50px; } .tag-badge { display: inline-flex; align-items: center; background: rgba(59, 130, 246, 0.15); color: #3b82f6; padding: 4px 10px; border-radius: 99px; font-size: 0.85rem; font-weight: 500; } .tag-remove { margin-left: 6px; cursor: pointer; opacity: 0.7; font-size: 1.1em; line-height: 1; } .tag-remove:hover { opacity: 1; color: #ef4444; } .input-group { display: flex; gap: 0.5rem; } .input-group input { flex: 1; padding: 0.75rem; background: var(--bg-color); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; } /* Update Banner Enhanced */ .change-summary { font-size: 0.8rem; background: rgba(0,0,0,0.1); padding: 0.5rem; border-radius: 4px; margin: 0.5rem 0; white-space: pre-wrap; font-family: inherit; line-height: 1.4; max-height: 100px; overflow-y: auto; } .update-content { display: flex; flex-direction: column; gap: 4px; flex: 1; } /* Installer Changelog */ .changelog-container ul { padding-left: 1.5rem; margin: 0.5rem 0; } .changelog-container li { margin-bottom: 0.4rem; line-height: 1.5; } .changelog-container h3 { margin-top: 1.5rem; margin-bottom: 0.5rem; font-size: 1.1em; color: var(--accent-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 = `\nLade Men\u00fcdaten...
\nKeine Men\u00fcdaten f\u00fcr KW ${targetWeek} (${targetYear}) verf\u00fcgbar.
\n Versuchen Sie eine andere Woche oder schauen Sie sp\u00e4ter vorbei.\n${escapeHtml(item.description)}
`;\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 // === Version Check ===\n async function checkForUpdates() {\n icon.className = 'update-icon';\n icon.href = url;\n icon.target = '_blank';\n icon.innerHTML = '\ud83c\udd95'; // User requested icon\n icon.title = `Neue Version verf\u00fcgbar (${newVersion}). Klick f\u00fcr download`;\n\n headerTitle.appendChild(icon);\n showToast(`Update verf\u00fcgbar: ${newVersion}`, 'info');\n }\n\n // === Order Countdown ===\n function updateCountdown() {\n const now = new Date();\n const currentDay = now.getDay();\n // Skip weekends (0=Sun, 6=Sat)\n if (currentDay === 0 || currentDay === 6) {\n removeCountdown();\n return;\n }\n\n const todayStr = now.toISOString().split('T')[0];\n\n // 1. Check if we already ordered for today\n let hasOrder = false;\n // Optimization: Check orderMap for today's date\n // Keys are \"YYYY-MM-DD_ArticleID\"\n for (const key of orderMap.keys()) {\n if (key.startsWith(todayStr)) {\n hasOrder = true;\n break;\n }\n }\n\n if (hasOrder) {\n removeCountdown();\n return;\n }\n\n // 2. Calculate time to cutoff (10:00 AM)\n const cutoff = new Date();\n cutoff.setHours(10, 0, 0, 0);\n\n const diff = cutoff - now;\n\n // If passed cutoff or more than 3 hours away (e.g. 07:00), maybe don't show?\n // User req: \"heute noch keine bestellung... countdown erscheinen\"\n // Let's show it if within valid order window (e.g. 00:00 - 10:00)\n\n if (diff <= 0) {\n removeCountdown();\n return;\n }\n\n // 3. Render Countdown\n const diffHrs = Math.floor(diff / 3600000);\n const diffMins = Math.floor((diff % 3600000) / 60000);\n\n const headerCenter = document.querySelector('.header-center-wrapper');\n if (!headerCenter) return;\n\n let countdownEl = document.getElementById('order-countdown');\n if (!countdownEl) {\n countdownEl = document.createElement('div');\n countdownEl.id = 'order-countdown';\n // Insert before cost display or append\n headerCenter.insertBefore(countdownEl, headerCenter.firstChild);\n }\n\n countdownEl.innerHTML = `Bestellschluss: ${diffHrs}h ${diffMins}m`;\n\n // Red Alert if < 1 hour\n if (diff < 3600000) { // 1 hour\n countdownEl.classList.add('urgent');\n\n // Notification logic (One time)\n const notifiedKey = `kantine_notified_${todayStr}`;\n if (!sessionStorage.getItem(notifiedKey)) {\n if (Notification.permission === 'granted') {\n new Notification('Kantine: Bestellschluss naht!', {\n body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!',\n icon: '\u23f3'\n });\n } else if (Notification.permission === 'default') {\n Notification.requestPermission();\n }\n sessionStorage.setItem(notifiedKey, 'true');\n }\n } else {\n countdownEl.classList.remove('urgent');\n }\n }\n\n function removeCountdown() {\n const el = document.getElementById('order-countdown');\n if (el) el.remove();\n }\n\n // Update countdown every minute\n setInterval(updateCountdown, 60000);\n // Also update on load\n setTimeout(updateCountdown, 1000);\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 }\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 // Check for updates\n checkForUpdates();\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 \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); })(); +javascript:(function(){ if(window.__KANTINE_LOADED){alert('Kantine Wrapper 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 */ } /* Update Icon */ .update-icon { display: inline-flex; align-items: center; justify-content: center; margin-left: 8px; background-color: rgba(16, 185, 129, 0.2); /* Green tint */ color: var(--success-color); border-radius: 50%; width: 24px; height: 24px; cursor: pointer; font-size: 14px; transition: all 0.2s; text-decoration: none; animation: pulse 2s infinite; } .update-icon:hover { background-color: var(--success-color); color: white; transform: scale(1.1); } @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); } 70% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); } 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); } } /* Order Countdown */ #order-countdown { background: rgba(255, 255, 255, 0.1); padding: 0.25rem 0.75rem; border-radius: 99px; font-size: 0.85rem; display: flex; align-items: center; gap: 0.5rem; white-space: nowrap; border: 1px solid var(--border-color); } #order-countdown span { opacity: 0.7; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; } #order-countdown.urgent { background: rgba(239, 68, 68, 0.2); border-color: rgba(239, 68, 68, 0.5); color: #ef4444; animation: pulse-red 2s infinite; } @keyframes pulse-red { 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } 70% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); } 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } } /* Smart Highlights */ .highlight-glow { box-shadow: 0 0 15px rgba(59, 130, 246, 0.5); /* Blue glow */ border: 1px solid rgba(59, 130, 246, 0.8); background: rgba(59, 130, 246, 0.05); position: relative; z-index: 1; } /* Nav Badge with Count */ .nav-badge.has-highlights { background-color: var(--card-bg); /* Neutral background */ color: var(--text-primary); border: 1px solid var(--border-color); padding: 2px 6px; } .nav-badge .highlight-count { color: #3b82f6; /* Blue 500 */ font-weight: 700; margin-left: 4px; } /* Tag Management Modal */ #tags-list { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; min-height: 50px; } .tag-badge { display: inline-flex; align-items: center; background: rgba(59, 130, 246, 0.15); color: #3b82f6; padding: 4px 10px; border-radius: 99px; font-size: 0.85rem; font-weight: 500; } .tag-remove { margin-left: 6px; cursor: pointer; opacity: 0.7; font-size: 1.1em; line-height: 1; } .tag-remove:hover { opacity: 1; color: #ef4444; } .input-group { display: flex; gap: 0.5rem; } .input-group input { flex: 1; padding: 0.75rem; background: var(--bg-color); border: 1px solid var(--border-color); color: var(--text-primary); border-radius: 8px; } /* Update Banner Enhanced */ .change-summary { font-size: 0.8rem; background: rgba(0,0,0,0.1); padding: 0.5rem; border-radius: 4px; margin: 0.5rem 0; white-space: pre-wrap; font-family: inherit; line-height: 1.4; max-height: 100px; overflow-y: auto; } .update-content { display: flex; flex-direction: column; gap: 4px; flex: 1; } /* Installer Changelog */ .changelog-container ul { padding-left: 1.5rem; margin: 0.5rem 0; } .changelog-container li { margin-bottom: 0.4rem; line-height: 1.5; } .changelog-container h3 { margin-top: 1.5rem; margin-bottom: 0.5rem; font-size: 1.1em; color: var(--accent-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 = `\nLade Men\u00fcdaten...
\nKeine Men\u00fcdaten f\u00fcr KW ${targetWeek} (${targetYear}) verf\u00fcgbar.
\n Versuchen Sie eine andere Woche oder schauen Sie sp\u00e4ter vorbei.\n${escapeHtml(item.description)}
`;\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// === Version Check ===\nasync function checkForUpdates() {\n icon.className = 'update-icon';\n icon.href = url;\n icon.target = '_blank';\n icon.innerHTML = '\ud83c\udd95'; // User requested icon\n icon.title = `Neue Version verf\u00fcgbar (${newVersion}). Klick f\u00fcr download`;\n\n headerTitle.appendChild(icon);\n showToast(`Update verf\u00fcgbar: ${newVersion}`, 'info');\n}\n\n// === Order Countdown ===\nfunction updateCountdown() {\n const now = new Date();\n const currentDay = now.getDay();\n // Skip weekends (0=Sun, 6=Sat)\n if (currentDay === 0 || currentDay === 6) {\n removeCountdown();\n return;\n }\n\n const todayStr = now.toISOString().split('T')[0];\n\n // 1. Check if we already ordered for today\n let hasOrder = false;\n // Optimization: Check orderMap for today's date\n // Keys are \"YYYY-MM-DD_ArticleID\"\n for (const key of orderMap.keys()) {\n if (key.startsWith(todayStr)) {\n hasOrder = true;\n break;\n }\n }\n\n if (hasOrder) {\n removeCountdown();\n return;\n }\n\n // 2. Calculate time to cutoff (10:00 AM)\n const cutoff = new Date();\n cutoff.setHours(10, 0, 0, 0);\n\n const diff = cutoff - now;\n\n // If passed cutoff or more than 3 hours away (e.g. 07:00), maybe don't show?\n // User req: \"heute noch keine bestellung... countdown erscheinen\"\n // Let's show it if within valid order window (e.g. 00:00 - 10:00)\n\n if (diff <= 0) {\n removeCountdown();\n return;\n }\n\n // 3. Render Countdown\n const diffHrs = Math.floor(diff / 3600000);\n const diffMins = Math.floor((diff % 3600000) / 60000);\n\n const headerCenter = document.querySelector('.header-center-wrapper');\n if (!headerCenter) return;\n\n let countdownEl = document.getElementById('order-countdown');\n if (!countdownEl) {\n countdownEl = document.createElement('div');\n countdownEl.id = 'order-countdown';\n // Insert before cost display or append\n headerCenter.insertBefore(countdownEl, headerCenter.firstChild);\n }\n\n countdownEl.innerHTML = `Bestellschluss: ${diffHrs}h ${diffMins}m`;\n\n // Red Alert if < 1 hour\n if (diff < 3600000) { // 1 hour\n countdownEl.classList.add('urgent');\n\n // Notification logic (One time)\n const notifiedKey = `kantine_notified_${todayStr}`;\n if (!sessionStorage.getItem(notifiedKey)) {\n if (Notification.permission === 'granted') {\n new Notification('Kantine: Bestellschluss naht!', {\n body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!',\n icon: '\u23f3'\n });\n } else if (Notification.permission === 'default') {\n Notification.requestPermission();\n }\n sessionStorage.setItem(notifiedKey, 'true');\n }\n } else {\n countdownEl.classList.remove('urgent');\n }\n}\n\nfunction removeCountdown() {\n const el = document.getElementById('order-countdown');\n if (el) el.remove();\n}\n\n// Update countdown every minute\nsetInterval(updateCountdown, 60000);\n// Also update on load\nsetTimeout(updateCountdown, 1000);\n\n// === Helpers === \nfunction 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\nfunction 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\nfunction 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\nfunction escapeHtml(text) {\n const div = document.createElement('div');\n div.textContent = text || '';\n return div.innerHTML;\n}\n\n// === Bootstrap ===\ninjectUI();\nbindEvents();\nupdateAuthUI();\ncleanupExpiredFlags();\n\n// Load cached data first for instant UI, then refresh from API\nconst hadCache = loadMenuCache();\nif (hadCache) {\n // Hide loading spinner since cache is shown\n document.getElementById('loading').classList.add('hidden');\n}\nloadMenuDataFromAPI();\n\n// Auto-start polling if already logged in\nif (authToken) {\n startPolling();\n}\n\n// Check for updates\ncheckForUpdates();\n\nconsole.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 \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); })(); diff --git a/dist/install.html b/dist/install.html index 15d4fdf..e0c0db6 100755 --- a/dist/install.html +++ b/dist/install.html @@ -2,7 +2,7 @@ -Kantine v1.1.0Kantine v1.1.1Keine Menüdaten für KW ${targetWeek} (${targetYear}) verfügbar.
Versuchen Sie eine andere Woche oder schauen Sie später vorbei.${escapeHtml(item.description)}
`; - // Event: Order - const orderBtn = itemEl.querySelector('.btn-order'); - if (orderBtn) { - orderBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const btn = e.currentTarget; - btn.disabled = true; - btn.classList.add('loading'); - placeOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, parseFloat(btn.dataset.price), btn.dataset.desc || '') - .finally(() => { btn.disabled = false; btn.classList.remove('loading'); }); - }); - } - - // Event: Cancel - const cancelBtn = itemEl.querySelector('.btn-cancel'); - if (cancelBtn) { - cancelBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const btn = e.currentTarget; - btn.disabled = true; - cancelOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name) - .finally(() => { btn.disabled = false; }); - }); - } - - // Event: Flag - const flagBtn = itemEl.querySelector('.btn-flag'); - if (flagBtn) { - flagBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const btn = e.currentTarget; - toggleFlag(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, btn.dataset.cutoff); - }); - } - - body.appendChild(itemEl); + // Event: Order + const orderBtn = itemEl.querySelector('.btn-order'); + if (orderBtn) { + orderBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const btn = e.currentTarget; + btn.disabled = true; + btn.classList.add('loading'); + placeOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, parseFloat(btn.dataset.price), btn.dataset.desc || '') + .finally(() => { btn.disabled = false; btn.classList.remove('loading'); }); }); - - card.appendChild(body); - return card; } - // === Version Check === - async function checkForUpdates() { - icon.className = 'update-icon'; - icon.href = url; - icon.target = '_blank'; - icon.innerHTML = '🆕'; // User requested icon - icon.title = `Neue Version verfügbar (${newVersion}). Klick für download`; - - headerTitle.appendChild(icon); - showToast(`Update verfügbar: ${newVersion}`, 'info'); + // Event: Cancel + const cancelBtn = itemEl.querySelector('.btn-cancel'); + if (cancelBtn) { + cancelBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const btn = e.currentTarget; + btn.disabled = true; + cancelOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name) + .finally(() => { btn.disabled = false; }); + }); } - // === Order Countdown === - function updateCountdown() { - const now = new Date(); - const currentDay = now.getDay(); - // Skip weekends (0=Sun, 6=Sat) - if (currentDay === 0 || currentDay === 6) { - removeCountdown(); - return; - } - - const todayStr = now.toISOString().split('T')[0]; - - // 1. Check if we already ordered for today - let hasOrder = false; - // Optimization: Check orderMap for today's date - // Keys are "YYYY-MM-DD_ArticleID" - for (const key of orderMap.keys()) { - if (key.startsWith(todayStr)) { - hasOrder = true; - break; - } - } - - if (hasOrder) { - removeCountdown(); - return; - } - - // 2. Calculate time to cutoff (10:00 AM) - const cutoff = new Date(); - cutoff.setHours(10, 0, 0, 0); - - const diff = cutoff - now; - - // If passed cutoff or more than 3 hours away (e.g. 07:00), maybe don't show? - // User req: "heute noch keine bestellung... countdown erscheinen" - // Let's show it if within valid order window (e.g. 00:00 - 10:00) - - if (diff <= 0) { - removeCountdown(); - return; - } - - // 3. Render Countdown - const diffHrs = Math.floor(diff / 3600000); - const diffMins = Math.floor((diff % 3600000) / 60000); - - const headerCenter = document.querySelector('.header-center-wrapper'); - if (!headerCenter) return; - - let countdownEl = document.getElementById('order-countdown'); - if (!countdownEl) { - countdownEl = document.createElement('div'); - countdownEl.id = 'order-countdown'; - // Insert before cost display or append - headerCenter.insertBefore(countdownEl, headerCenter.firstChild); - } - - countdownEl.innerHTML = `Bestellschluss: ${diffHrs}h ${diffMins}m`; - - // Red Alert if < 1 hour - if (diff < 3600000) { // 1 hour - countdownEl.classList.add('urgent'); - - // Notification logic (One time) - const notifiedKey = `kantine_notified_${todayStr}`; - if (!sessionStorage.getItem(notifiedKey)) { - if (Notification.permission === 'granted') { - new Notification('Kantine: Bestellschluss naht!', { - body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!', - icon: '⏳' - }); - } else if (Notification.permission === 'default') { - Notification.requestPermission(); - } - sessionStorage.setItem(notifiedKey, 'true'); - } - } else { - countdownEl.classList.remove('urgent'); - } + // Event: Flag + const flagBtn = itemEl.querySelector('.btn-flag'); + if (flagBtn) { + flagBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const btn = e.currentTarget; + toggleFlag(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, btn.dataset.cutoff); + }); } - function removeCountdown() { - const el = document.getElementById('order-countdown'); - if (el) el.remove(); - } + body.appendChild(itemEl); + }); - // Update countdown every minute - setInterval(updateCountdown, 60000); - // Also update on load - setTimeout(updateCountdown, 1000); + card.appendChild(body); + return card; +} - // === Helpers === - function getISOWeek(date) { - const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); - const dayNum = d.getUTCDay() || 7; - d.setUTCDate(d.getUTCDate() + 4 - dayNum); - const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); - return Math.ceil(((d - yearStart) / 86400000 + 1) / 7); - } +// === Version Check === +async function checkForUpdates() { + icon.className = 'update-icon'; + icon.href = url; + icon.target = '_blank'; + icon.innerHTML = '🆕'; // User requested icon + icon.title = `Neue Version verfügbar (${newVersion}). Klick für download`; - function getWeekYear(d) { - const date = new Date(d.getTime()); - date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7); - return date.getFullYear(); - } + headerTitle.appendChild(icon); + showToast(`Update verfügbar: ${newVersion}`, 'info'); +} +// === Order Countdown === +function updateCountdown() { + const now = new Date(); + const currentDay = now.getDay(); + // Skip weekends (0=Sun, 6=Sat) + if (currentDay === 0 || currentDay === 6) { + removeCountdown(); + return; } - function translateDay(englishDay) { - const map = { Monday: 'Montag', Tuesday: 'Dienstag', Wednesday: 'Mittwoch', Thursday: 'Donnerstag', Friday: 'Freitag', Saturday: 'Samstag', Sunday: 'Sonntag' }; - return map[englishDay] || englishDay; + const todayStr = now.toISOString().split('T')[0]; + + // 1. Check if we already ordered for today + let hasOrder = false; + // Optimization: Check orderMap for today's date + // Keys are "YYYY-MM-DD_ArticleID" + for (const key of orderMap.keys()) { + if (key.startsWith(todayStr)) { + hasOrder = true; + break; + } } - function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text || ''; - return div.innerHTML; + if (hasOrder) { + removeCountdown(); + return; } - // === Bootstrap === - injectUI(); - bindEvents(); - updateAuthUI(); - cleanupExpiredFlags(); + // 2. Calculate time to cutoff (10:00 AM) + const cutoff = new Date(); + cutoff.setHours(10, 0, 0, 0); - // Load cached data first for instant UI, then refresh from API - const hadCache = loadMenuCache(); - if (hadCache) { - // Hide loading spinner since cache is shown - document.getElementById('loading').classList.add('hidden'); - } - loadMenuDataFromAPI(); + const diff = cutoff - now; - // Auto-start polling if already logged in - if (authToken) { - startPolling(); + // If passed cutoff or more than 3 hours away (e.g. 07:00), maybe don't show? + // User req: "heute noch keine bestellung... countdown erscheinen" + // Let's show it if within valid order window (e.g. 00:00 - 10:00) + + if (diff <= 0) { + removeCountdown(); + return; } - // Check for updates - checkForUpdates(); + // 3. Render Countdown + const diffHrs = Math.floor(diff / 3600000); + const diffMins = Math.floor((diff % 3600000) / 60000); - console.log('Kantine Wrapper loaded ✅'); -})(); + const headerCenter = document.querySelector('.header-center-wrapper'); + if (!headerCenter) return; + + let countdownEl = document.getElementById('order-countdown'); + if (!countdownEl) { + countdownEl = document.createElement('div'); + countdownEl.id = 'order-countdown'; + // Insert before cost display or append + headerCenter.insertBefore(countdownEl, headerCenter.firstChild); + } + + countdownEl.innerHTML = `Bestellschluss: ${diffHrs}h ${diffMins}m`; + + // Red Alert if < 1 hour + if (diff < 3600000) { // 1 hour + countdownEl.classList.add('urgent'); + + // Notification logic (One time) + const notifiedKey = `kantine_notified_${todayStr}`; + if (!sessionStorage.getItem(notifiedKey)) { + if (Notification.permission === 'granted') { + new Notification('Kantine: Bestellschluss naht!', { + body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!', + icon: '⏳' + }); + } else if (Notification.permission === 'default') { + Notification.requestPermission(); + } + sessionStorage.setItem(notifiedKey, 'true'); + } + } else { + countdownEl.classList.remove('urgent'); + } +} + +function removeCountdown() { + const el = document.getElementById('order-countdown'); + if (el) el.remove(); +} + +// Update countdown every minute +setInterval(updateCountdown, 60000); +// Also update on load +setTimeout(updateCountdown, 1000); + +// === Helpers === +function getISOWeek(date) { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil(((d - yearStart) / 86400000 + 1) / 7); +} + +function getWeekYear(d) { + const date = new Date(d.getTime()); + date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7); + return date.getFullYear(); +} + + +function translateDay(englishDay) { + const map = { Monday: 'Montag', Tuesday: 'Dienstag', Wednesday: 'Mittwoch', Thursday: 'Donnerstag', Friday: 'Freitag', Saturday: 'Samstag', Sunday: 'Sonntag' }; + return map[englishDay] || englishDay; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text || ''; + return div.innerHTML; +} + +// === Bootstrap === +injectUI(); +bindEvents(); +updateAuthUI(); +cleanupExpiredFlags(); + +// Load cached data first for instant UI, then refresh from API +const hadCache = loadMenuCache(); +if (hadCache) { + // Hide loading spinner since cache is shown + document.getElementById('loading').classList.add('hidden'); +} +loadMenuDataFromAPI(); + +// Auto-start polling if already logged in +if (authToken) { + startPolling(); +} + +// Check for updates +checkForUpdates(); + +console.log('Kantine Wrapper loaded ✅'); +}) (); // === Error Modal === function showErrorModal(title, htmlContent, btnText, url) { diff --git a/kantine.js b/kantine.js index 6f2495a..8734599 100755 --- a/kantine.js +++ b/kantine.js @@ -651,7 +651,6 @@ } // Refresh menu data to update UI loadMenuDataFromAPI(); - break; // One refresh is enough } } } catch (err) { @@ -660,688 +659,689 @@ await new Promise(r => setTimeout(r, 200)); } } + } // === Highlight Management === let highlightTags = JSON.parse(localStorage.getItem('kantine_highlightTags') || '[]'); - function saveHighlightTags() { - localStorage.setItem('kantine_highlightTags', JSON.stringify(highlightTags)); - renderVisibleWeeks(); // Refresh UI to apply changes +function saveHighlightTags() { + localStorage.setItem('kantine_highlightTags', JSON.stringify(highlightTags)); + renderVisibleWeeks(); // Refresh UI to apply changes + updateNextWeekBadge(); +} + +function addHighlightTag(tag) { + tag = tag.trim().toLowerCase(); + if (tag && !highlightTags.includes(tag)) { + highlightTags.push(tag); + saveHighlightTags(); + return true; + } + return false; +} + +function removeHighlightTag(tag) { + highlightTags = highlightTags.filter(t => t !== tag); + saveHighlightTags(); +} + +function renderTagsList() { + const list = document.getElementById('tags-list'); + list.innerHTML = ''; + highlightTags.forEach(tag => { + const badge = document.createElement('span'); + badge.className = 'tag-badge'; + badge.innerHTML = `${tag} ×`; + list.appendChild(badge); + }); + + // Bind remove events + list.querySelectorAll('.tag-remove').forEach(btn => { + btn.addEventListener('click', (e) => { + removeHighlightTag(e.target.dataset.tag); + renderTagsList(); + }); + }); +} + +function checkHighlight(text) { + if (!text) return false; + text = text.toLowerCase(); + return highlightTags.some(tag => text.includes(tag)); +} + +// === Local Menu Cache (localStorage) === +const CACHE_KEY = 'kantine_menuCache'; +const CACHE_TS_KEY = 'kantine_menuCacheTs'; + +function saveMenuCache() { + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(allWeeks)); + localStorage.setItem(CACHE_TS_KEY, new Date().toISOString()); + } catch (e) { + console.warn('Failed to cache menu data:', e); + } +} + +function loadMenuCache() { + try { + const cached = localStorage.getItem(CACHE_KEY); + const cachedTs = localStorage.getItem(CACHE_TS_KEY); + if (cached) { + allWeeks = JSON.parse(cached); + currentWeekNumber = getISOWeek(new Date()); + currentYear = new Date().getFullYear(); + renderVisibleWeeks(); updateNextWeekBadge(); + if (cachedTs) updateLastUpdatedTime(cachedTs); + console.log('Loaded menu from cache'); + return true; } + } catch (e) { + console.warn('Failed to load cached menu:', e); + } + return false; +} - function addHighlightTag(tag) { - tag = tag.trim().toLowerCase(); - if (tag && !highlightTags.includes(tag)) { - highlightTags.push(tag); - saveHighlightTags(); - return true; - } - return false; - } +// === Menu Data Fetching (Direct from Bessa API) === +async function loadMenuDataFromAPI() { + const loading = document.getElementById('loading'); + const progressModal = document.getElementById('progress-modal'); + const progressFill = document.getElementById('progress-fill'); + const progressPercent = document.getElementById('progress-percent'); + const progressMessage = document.getElementById('progress-message'); - function removeHighlightTag(tag) { - highlightTags = highlightTags.filter(t => t !== tag); - saveHighlightTags(); - } + loading.classList.remove('hidden'); - function renderTagsList() { - const list = document.getElementById('tags-list'); - list.innerHTML = ''; - highlightTags.forEach(tag => { - const badge = document.createElement('span'); - badge.className = 'tag-badge'; - badge.innerHTML = `${tag} ×`; - list.appendChild(badge); - }); + const token = authToken || GUEST_TOKEN; - // Bind remove events - list.querySelectorAll('.tag-remove').forEach(btn => { - btn.addEventListener('click', (e) => { - removeHighlightTag(e.target.dataset.tag); - renderTagsList(); - }); - }); - } + try { + // Show progress modal + progressModal.classList.remove('hidden'); + progressMessage.textContent = 'Hole verfügbare Daten...'; + progressFill.style.width = '0%'; + progressPercent.textContent = '0%'; - function checkHighlight(text) { - if (!text) return false; - text = text.toLowerCase(); - return highlightTags.some(tag => text.includes(tag)); - } + // 1. Fetch available dates + const datesResponse = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/dates/`, { + headers: apiHeaders(token) + }); - // === Local Menu Cache (localStorage) === - const CACHE_KEY = 'kantine_menuCache'; - const CACHE_TS_KEY = 'kantine_menuCacheTs'; + if (!datesResponse.ok) throw new Error(`Failed to fetch dates: ${datesResponse.status}`); - function saveMenuCache() { - try { - localStorage.setItem(CACHE_KEY, JSON.stringify(allWeeks)); - localStorage.setItem(CACHE_TS_KEY, new Date().toISOString()); - } catch (e) { - console.warn('Failed to cache menu data:', e); - } - } + const datesData = await datesResponse.json(); + let availableDates = datesData.results || []; - function loadMenuCache() { - try { - const cached = localStorage.getItem(CACHE_KEY); - const cachedTs = localStorage.getItem(CACHE_TS_KEY); - if (cached) { - allWeeks = JSON.parse(cached); - currentWeekNumber = getISOWeek(new Date()); - currentYear = new Date().getFullYear(); - renderVisibleWeeks(); - updateNextWeekBadge(); - if (cachedTs) updateLastUpdatedTime(cachedTs); - console.log('Loaded menu from cache'); - return true; - } - } catch (e) { - console.warn('Failed to load cached menu:', e); - } - return false; - } + // Filter – last 7 days + future, limit 30 + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - 7); + const cutoffStr = cutoff.toISOString().split('T')[0]; - // === Menu Data Fetching (Direct from Bessa API) === - async function loadMenuDataFromAPI() { - const loading = document.getElementById('loading'); - const progressModal = document.getElementById('progress-modal'); - const progressFill = document.getElementById('progress-fill'); - const progressPercent = document.getElementById('progress-percent'); - const progressMessage = document.getElementById('progress-message'); + availableDates = availableDates + .filter(d => d.date >= cutoffStr) + .sort((a, b) => a.date.localeCompare(b.date)) + .slice(0, 30); - loading.classList.remove('hidden'); + const totalDates = availableDates.length; + progressMessage.textContent = `${totalDates} Tage gefunden. Lade Details...`; - const token = authToken || GUEST_TOKEN; + // 2. Fetch details for each date + const allDays = []; + let completed = 0; + + for (const dateObj of availableDates) { + const dateStr = dateObj.date; + const pct = Math.round(((completed + 1) / totalDates) * 100); + progressFill.style.width = `${pct}%`; + progressPercent.textContent = `${pct}%`; + progressMessage.textContent = `Lade Menü für ${dateStr}...`; try { - // Show progress modal - progressModal.classList.remove('hidden'); - progressMessage.textContent = 'Hole verfügbare Daten...'; - progressFill.style.width = '0%'; - progressPercent.textContent = '0%'; - - // 1. Fetch available dates - const datesResponse = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/dates/`, { + const detailResp = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${dateStr}/`, { headers: apiHeaders(token) }); - if (!datesResponse.ok) throw new Error(`Failed to fetch dates: ${datesResponse.status}`); - - const datesData = await datesResponse.json(); - let availableDates = datesData.results || []; - - // Filter – last 7 days + future, limit 30 - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - 7); - const cutoffStr = cutoff.toISOString().split('T')[0]; - - availableDates = availableDates - .filter(d => d.date >= cutoffStr) - .sort((a, b) => a.date.localeCompare(b.date)) - .slice(0, 30); - - const totalDates = availableDates.length; - progressMessage.textContent = `${totalDates} Tage gefunden. Lade Details...`; - - // 2. Fetch details for each date - const allDays = []; - let completed = 0; - - for (const dateObj of availableDates) { - const dateStr = dateObj.date; - const pct = Math.round(((completed + 1) / totalDates) * 100); - progressFill.style.width = `${pct}%`; - progressPercent.textContent = `${pct}%`; - progressMessage.textContent = `Lade Menü für ${dateStr}...`; - - try { - const detailResp = await fetch(`${API_BASE}/venues/${VENUE_ID}/menu/${MENU_ID}/${dateStr}/`, { - headers: apiHeaders(token) - }); - - if (detailResp.ok) { - const detailData = await detailResp.json(); - // Debug: log raw API response for first date - if (completed === 0) { - console.log('[Kantine Debug] Raw API response for', dateStr, ':', JSON.stringify(detailData).substring(0, 2000)); - } - const menuGroups = detailData.results || []; - let dayItems = []; - for (const group of menuGroups) { - if (group.items && Array.isArray(group.items)) { - dayItems = dayItems.concat(group.items); - } - } - if (dayItems.length > 0) { - // Debug: log first item structure - if (completed === 0) { - console.log('[Kantine Debug] First item keys:', Object.keys(dayItems[0])); - console.log('[Kantine Debug] First item:', JSON.stringify(dayItems[0]).substring(0, 500)); - } - allDays.push({ - date: dateStr, - menu_items: dayItems, - orders: dateObj.orders || [] - }); - } + if (detailResp.ok) { + const detailData = await detailResp.json(); + // Debug: log raw API response for first date + if (completed === 0) { + console.log('[Kantine Debug] Raw API response for', dateStr, ':', JSON.stringify(detailData).substring(0, 2000)); + } + const menuGroups = detailData.results || []; + let dayItems = []; + for (const group of menuGroups) { + if (group.items && Array.isArray(group.items)) { + dayItems = dayItems.concat(group.items); } - } catch (err) { - console.error(`Failed to fetch details for ${dateStr}:`, err); } - - completed++; - // Small delay to avoid rate limiting - await new Promise(r => setTimeout(r, 100)); + if (dayItems.length > 0) { + // Debug: log first item structure + if (completed === 0) { + console.log('[Kantine Debug] First item keys:', Object.keys(dayItems[0])); + console.log('[Kantine Debug] First item:', JSON.stringify(dayItems[0]).substring(0, 500)); + } + allDays.push({ + date: dateStr, + menu_items: dayItems, + orders: dateObj.orders || [] + }); + } } + } catch (err) { + console.error(`Failed to fetch details for ${dateStr}:`, err); + } - // 3. Group by ISO week (Merge with existing to preserve past days) - const weeksMap = new Map(); + completed++; + // Small delay to avoid rate limiting + await new Promise(r => setTimeout(r, 100)); + } - // Hydrate from existing cache (preserve past data) - if (allWeeks && allWeeks.length > 0) { - allWeeks.forEach(w => { - const key = `${w.year}-${w.weekNumber}`; - try { - weeksMap.set(key, { - year: w.year, - weekNumber: w.weekNumber, - days: w.days ? w.days.map(d => ({ ...d, items: d.items ? [...d.items] : [] })) : [] - }); - } catch (e) { console.warn('Error hydrating week:', e); } + // 3. Group by ISO week (Merge with existing to preserve past days) + const weeksMap = new Map(); + + // Hydrate from existing cache (preserve past data) + if (allWeeks && allWeeks.length > 0) { + allWeeks.forEach(w => { + const key = `${w.year}-${w.weekNumber}`; + try { + weeksMap.set(key, { + year: w.year, + weekNumber: w.weekNumber, + days: w.days ? w.days.map(d => ({ ...d, items: d.items ? [...d.items] : [] })) : [] }); - } - - for (const day of allDays) { - const d = new Date(day.date); - const weekNum = getISOWeek(d); - const year = getWeekYear(d); - const key = `${year}-${weekNum}`; - - if (!weeksMap.has(key)) { - weeksMap.set(key, { year, weekNumber: weekNum, days: [] }); - } - - const weekObj = weeksMap.get(key); - const weekday = d.toLocaleDateString('en-US', { weekday: 'long' }); - const orderCutoffDate = new Date(day.date); - orderCutoffDate.setHours(10, 0, 0, 0); - - const newDayObj = { - date: day.date, - weekday: weekday, - orderCutoff: orderCutoffDate.toISOString(), - items: day.menu_items.map(item => { - const isUnlimited = item.amount_tracking === false; - const hasStock = parseInt(item.available_amount) > 0; - return { - id: `${day.date}_${item.id}`, - articleId: item.id, - name: item.name || 'Unknown', - description: item.description || '', - price: parseFloat(item.price) || 0, - available: isUnlimited || hasStock, - availableAmount: parseInt(item.available_amount) || 0, - amountTracking: item.amount_tracking !== false - }; - }) - }; - - // Merge: Overwrite if exists, push if new - const existingIndex = weekObj.days.findIndex(existing => existing.date === day.date); - if (existingIndex >= 0) { - weekObj.days[existingIndex] = newDayObj; - } else { - weekObj.days.push(newDayObj); - } - } - - // Sort weeks and days - allWeeks = Array.from(weeksMap.values()).sort((a, b) => { - if (a.year !== b.year) return a.year - b.year; - return a.weekNumber - b.weekNumber; - }); - allWeeks.forEach(w => { - if (w.days) w.days.sort((a, b) => a.date.localeCompare(b.date)); - }); - - // Save to localStorage cache - saveMenuCache(); - - // Update timestamp - updateLastUpdatedTime(new Date().toISOString()); - - currentWeekNumber = getISOWeek(new Date()); - currentYear = new Date().getFullYear(); - - - - updateAuthUI(); // This will trigger fetchOrders if logged in - renderVisibleWeeks(); - updateNextWeekBadge(); - - progressMessage.textContent = 'Fertig!'; - setTimeout(() => progressModal.classList.add('hidden'), 500); - - } catch (error) { - console.error('Error fetching menu:', error); - progressModal.classList.add('hidden'); - - showErrorModal( - 'Keine Verbindung', - `Die Menüdaten konnten nicht geladen werden. Möglicherweise besteht keine Verbindung zur API oder zur Bessa-Webseite.Keine Menüdaten für KW ${targetWeek} (${targetYear}) verfügbar.
Versuchen Sie eine andere Woche oder schauen Sie später vorbei.${escapeHtml(item.description)}
`; - // Event: Order - const orderBtn = itemEl.querySelector('.btn-order'); - if (orderBtn) { - orderBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const btn = e.currentTarget; - btn.disabled = true; - btn.classList.add('loading'); - placeOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, parseFloat(btn.dataset.price), btn.dataset.desc || '') - .finally(() => { btn.disabled = false; btn.classList.remove('loading'); }); - }); - } - - // Event: Cancel - const cancelBtn = itemEl.querySelector('.btn-cancel'); - if (cancelBtn) { - cancelBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const btn = e.currentTarget; - btn.disabled = true; - cancelOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name) - .finally(() => { btn.disabled = false; }); - }); - } - - // Event: Flag - const flagBtn = itemEl.querySelector('.btn-flag'); - if (flagBtn) { - flagBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const btn = e.currentTarget; - toggleFlag(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, btn.dataset.cutoff); - }); - } - - body.appendChild(itemEl); + // Event: Order + const orderBtn = itemEl.querySelector('.btn-order'); + if (orderBtn) { + orderBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const btn = e.currentTarget; + btn.disabled = true; + btn.classList.add('loading'); + placeOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, parseFloat(btn.dataset.price), btn.dataset.desc || '') + .finally(() => { btn.disabled = false; btn.classList.remove('loading'); }); }); - - card.appendChild(body); - return card; } - // === Version Check === - async function checkForUpdates() { - icon.className = 'update-icon'; - icon.href = url; - icon.target = '_blank'; - icon.innerHTML = '🆕'; // User requested icon - icon.title = `Neue Version verfügbar (${newVersion}). Klick für download`; - - headerTitle.appendChild(icon); - showToast(`Update verfügbar: ${newVersion}`, 'info'); + // Event: Cancel + const cancelBtn = itemEl.querySelector('.btn-cancel'); + if (cancelBtn) { + cancelBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const btn = e.currentTarget; + btn.disabled = true; + cancelOrder(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name) + .finally(() => { btn.disabled = false; }); + }); } - // === Order Countdown === - function updateCountdown() { - const now = new Date(); - const currentDay = now.getDay(); - // Skip weekends (0=Sun, 6=Sat) - if (currentDay === 0 || currentDay === 6) { - removeCountdown(); - return; - } - - const todayStr = now.toISOString().split('T')[0]; - - // 1. Check if we already ordered for today - let hasOrder = false; - // Optimization: Check orderMap for today's date - // Keys are "YYYY-MM-DD_ArticleID" - for (const key of orderMap.keys()) { - if (key.startsWith(todayStr)) { - hasOrder = true; - break; - } - } - - if (hasOrder) { - removeCountdown(); - return; - } - - // 2. Calculate time to cutoff (10:00 AM) - const cutoff = new Date(); - cutoff.setHours(10, 0, 0, 0); - - const diff = cutoff - now; - - // If passed cutoff or more than 3 hours away (e.g. 07:00), maybe don't show? - // User req: "heute noch keine bestellung... countdown erscheinen" - // Let's show it if within valid order window (e.g. 00:00 - 10:00) - - if (diff <= 0) { - removeCountdown(); - return; - } - - // 3. Render Countdown - const diffHrs = Math.floor(diff / 3600000); - const diffMins = Math.floor((diff % 3600000) / 60000); - - const headerCenter = document.querySelector('.header-center-wrapper'); - if (!headerCenter) return; - - let countdownEl = document.getElementById('order-countdown'); - if (!countdownEl) { - countdownEl = document.createElement('div'); - countdownEl.id = 'order-countdown'; - // Insert before cost display or append - headerCenter.insertBefore(countdownEl, headerCenter.firstChild); - } - - countdownEl.innerHTML = `Bestellschluss: ${diffHrs}h ${diffMins}m`; - - // Red Alert if < 1 hour - if (diff < 3600000) { // 1 hour - countdownEl.classList.add('urgent'); - - // Notification logic (One time) - const notifiedKey = `kantine_notified_${todayStr}`; - if (!sessionStorage.getItem(notifiedKey)) { - if (Notification.permission === 'granted') { - new Notification('Kantine: Bestellschluss naht!', { - body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!', - icon: '⏳' - }); - } else if (Notification.permission === 'default') { - Notification.requestPermission(); - } - sessionStorage.setItem(notifiedKey, 'true'); - } - } else { - countdownEl.classList.remove('urgent'); - } + // Event: Flag + const flagBtn = itemEl.querySelector('.btn-flag'); + if (flagBtn) { + flagBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const btn = e.currentTarget; + toggleFlag(btn.dataset.date, parseInt(btn.dataset.article), btn.dataset.name, btn.dataset.cutoff); + }); } - function removeCountdown() { - const el = document.getElementById('order-countdown'); - if (el) el.remove(); - } + body.appendChild(itemEl); + }); - // Update countdown every minute - setInterval(updateCountdown, 60000); - // Also update on load - setTimeout(updateCountdown, 1000); + card.appendChild(body); + return card; +} - // === Helpers === - function getISOWeek(date) { - const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); - const dayNum = d.getUTCDay() || 7; - d.setUTCDate(d.getUTCDate() + 4 - dayNum); - const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); - return Math.ceil(((d - yearStart) / 86400000 + 1) / 7); - } +// === Version Check === +async function checkForUpdates() { + icon.className = 'update-icon'; + icon.href = url; + icon.target = '_blank'; + icon.innerHTML = '🆕'; // User requested icon + icon.title = `Neue Version verfügbar (${newVersion}). Klick für download`; - function getWeekYear(d) { - const date = new Date(d.getTime()); - date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7); - return date.getFullYear(); - } + headerTitle.appendChild(icon); + showToast(`Update verfügbar: ${newVersion}`, 'info'); +} +// === Order Countdown === +function updateCountdown() { + const now = new Date(); + const currentDay = now.getDay(); + // Skip weekends (0=Sun, 6=Sat) + if (currentDay === 0 || currentDay === 6) { + removeCountdown(); + return; } - function translateDay(englishDay) { - const map = { Monday: 'Montag', Tuesday: 'Dienstag', Wednesday: 'Mittwoch', Thursday: 'Donnerstag', Friday: 'Freitag', Saturday: 'Samstag', Sunday: 'Sonntag' }; - return map[englishDay] || englishDay; + const todayStr = now.toISOString().split('T')[0]; + + // 1. Check if we already ordered for today + let hasOrder = false; + // Optimization: Check orderMap for today's date + // Keys are "YYYY-MM-DD_ArticleID" + for (const key of orderMap.keys()) { + if (key.startsWith(todayStr)) { + hasOrder = true; + break; + } } - function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text || ''; - return div.innerHTML; + if (hasOrder) { + removeCountdown(); + return; } - // === Bootstrap === - injectUI(); - bindEvents(); - updateAuthUI(); - cleanupExpiredFlags(); + // 2. Calculate time to cutoff (10:00 AM) + const cutoff = new Date(); + cutoff.setHours(10, 0, 0, 0); - // Load cached data first for instant UI, then refresh from API - const hadCache = loadMenuCache(); - if (hadCache) { - // Hide loading spinner since cache is shown - document.getElementById('loading').classList.add('hidden'); - } - loadMenuDataFromAPI(); + const diff = cutoff - now; - // Auto-start polling if already logged in - if (authToken) { - startPolling(); + // If passed cutoff or more than 3 hours away (e.g. 07:00), maybe don't show? + // User req: "heute noch keine bestellung... countdown erscheinen" + // Let's show it if within valid order window (e.g. 00:00 - 10:00) + + if (diff <= 0) { + removeCountdown(); + return; } - // Check for updates - checkForUpdates(); + // 3. Render Countdown + const diffHrs = Math.floor(diff / 3600000); + const diffMins = Math.floor((diff % 3600000) / 60000); - console.log('Kantine Wrapper loaded ✅'); -})(); + const headerCenter = document.querySelector('.header-center-wrapper'); + if (!headerCenter) return; + + let countdownEl = document.getElementById('order-countdown'); + if (!countdownEl) { + countdownEl = document.createElement('div'); + countdownEl.id = 'order-countdown'; + // Insert before cost display or append + headerCenter.insertBefore(countdownEl, headerCenter.firstChild); + } + + countdownEl.innerHTML = `Bestellschluss: ${diffHrs}h ${diffMins}m`; + + // Red Alert if < 1 hour + if (diff < 3600000) { // 1 hour + countdownEl.classList.add('urgent'); + + // Notification logic (One time) + const notifiedKey = `kantine_notified_${todayStr}`; + if (!sessionStorage.getItem(notifiedKey)) { + if (Notification.permission === 'granted') { + new Notification('Kantine: Bestellschluss naht!', { + body: 'Du hast heute noch nichts bestellt. Nur noch 1 Stunde!', + icon: '⏳' + }); + } else if (Notification.permission === 'default') { + Notification.requestPermission(); + } + sessionStorage.setItem(notifiedKey, 'true'); + } + } else { + countdownEl.classList.remove('urgent'); + } +} + +function removeCountdown() { + const el = document.getElementById('order-countdown'); + if (el) el.remove(); +} + +// Update countdown every minute +setInterval(updateCountdown, 60000); +// Also update on load +setTimeout(updateCountdown, 1000); + +// === Helpers === +function getISOWeek(date) { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil(((d - yearStart) / 86400000 + 1) / 7); +} + +function getWeekYear(d) { + const date = new Date(d.getTime()); + date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7); + return date.getFullYear(); +} + + +function translateDay(englishDay) { + const map = { Monday: 'Montag', Tuesday: 'Dienstag', Wednesday: 'Mittwoch', Thursday: 'Donnerstag', Friday: 'Freitag', Saturday: 'Samstag', Sunday: 'Sonntag' }; + return map[englishDay] || englishDay; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text || ''; + return div.innerHTML; +} + +// === Bootstrap === +injectUI(); +bindEvents(); +updateAuthUI(); +cleanupExpiredFlags(); + +// Load cached data first for instant UI, then refresh from API +const hadCache = loadMenuCache(); +if (hadCache) { + // Hide loading spinner since cache is shown + document.getElementById('loading').classList.add('hidden'); +} +loadMenuDataFromAPI(); + +// Auto-start polling if already logged in +if (authToken) { + startPolling(); +} + +// Check for updates +checkForUpdates(); + +console.log('Kantine Wrapper loaded ✅'); +}) (); // === Error Modal === function showErrorModal(title, htmlContent, btnText, url) { diff --git a/version.txt b/version.txt index 795460f..56130fb 100755 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v1.1.0 +v1.1.1