Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e62f0f03f3 | ||
|
|
16fe0884aa | ||
|
|
b93f1000be | ||
|
|
32c1e1e383 | ||
|
|
877f0f3649 | ||
|
|
b5568c964b | ||
|
|
d1d355d3c2 | ||
|
|
e33ec3eb1a | ||
|
|
a9ec4ff8f6 | ||
|
|
38b6ad503f | ||
|
|
570b0674b7 | ||
|
|
36770f62b0 | ||
|
|
9989cb687f | ||
|
|
368696b0b7 | ||
|
|
8960a7f0b3 | ||
|
|
4c253f4162 | ||
|
|
9fddf74eb2 | ||
|
|
00015007d8 | ||
|
|
856f0dc2be |
@@ -17,7 +17,7 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
|
||||
| FR-003 | Das System darf keine Zugangsdaten dauerhaft speichern. Die Authentifizierung muss sitzungsbasiert sein. | Hoch | v1.0.1 |
|
||||
| FR-004 | Dem Benutzer muss angezeigt werden, ob und als wer er angemeldet ist (Vorname, Name oder ID). | Mittel | v1.0.1 |
|
||||
| FR-005 | Nicht authentifizierte Benutzer müssen die Menüdaten einsehen können (eingeschränkter Lesezugriff). | Mittel | v1.0.1 |
|
||||
| FR-006 | Das System muss eine explizite Logout-Funktion bereitstellen, die alle sitzungsbezogenen Daten entfernt. | Mittel | v1.0.1 |
|
||||
| FR-006 | Das System muss eine explizite Logout-Funktion bereitstellen, die alle sitzungsbezogenen Daten entfernt. | Mittel | v1.0.1 (Update v1.7.2) |
|
||||
| **Menüanzeige** | | | |
|
||||
| FR-010 | Das System muss dem Benutzer alle verfügbaren Tagesmenüs einer Woche gleichzeitig in einer Übersicht darstellen. | Hoch | v1.0.1 |
|
||||
| FR-011 | Das System muss dem Benutzer die Navigation zwischen der aktuellen und der kommenden Woche ermöglichen. | Mittel | v1.0.1 |
|
||||
@@ -39,13 +39,13 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
|
||||
| FR-032 | Nach Bestellschluss (Cutoff-Zeit) dürfen keine neuen Bestellungen oder Stornierungen für diesen Tag möglich sein. | Hoch | v1.0.1 |
|
||||
| FR-033 | Es muss möglich sein, dasselbe Menü mehrfach zu bestellen. Bei Mehrfachbestellungen muss die Anzahl angezeigt werden. | Niedrig | v1.0.1 |
|
||||
| **Kostentransparenz & Bestellhistorie** | | | |
|
||||
| FR-040 | Das System muss die Gesamtkosten aller Bestellungen einer Woche automatisch berechnen und anzeigen. | Mittel | v1.1.0 |
|
||||
| ~~FR-040~~ | ~~Das System muss die Gesamtkosten aller Bestellungen einer Woche automatisch berechnen und anzeigen.~~ | ~~Mittel~~ | ~~v1.1.0~~ (Obsolet: Display entfernt auf User-Wunsch) |
|
||||
| FR-041 | Das System muss dem Benutzer eine Bestellhistorie (gruppiert nach Monat und KW) mit Fortschrittsanzeige auf Abruf in einem Modal bereitstellen. Die Historie muss über ein lokales Delta-Caching verfügen, um Ladezeiten zu minimieren. | Mittel | v1.4.0 (Update v1.4.7) |
|
||||
| **Bestell-Countdown** | | | |
|
||||
| FR-050 | Das System muss vor Bestellschluss einen visuell hervorgehobenen Countdown anzeigen. | Mittel | v1.1.0 |
|
||||
| **Menü-Flagging & Benachrichtigungen** | | | |
|
||||
| FR-060 | Authentifizierte Benutzer müssen ausverkaufte Menüs zur Beobachtung markieren können ("flaggen"). | Mittel | v1.0.1 |
|
||||
| FR-061 | Das System muss geflaggte Menüs periodisch auf Verfügbarkeitsänderungen prüfen. | Mittel | v1.0.1 |
|
||||
| FR-061 | Das System muss geflaggte Menüs periodisch auf Verfügbarkeitsänderungen prüfen. Dabei dürfen ausschließlich die geflaggten Artikel aktualisiert werden – nicht sämtliche Menüs des betroffenen Tages. | Mittel | v1.0.1 (Update v1.7.0) |
|
||||
| FR-062 | Bei Statusänderung eines geflaggten Menüs auf „verfügbar" muss der Benutzer benachrichtigt werden (In-App + Systembenachrichtigung). | Mittel | v1.0.1 |
|
||||
| FR-063 | Geflaggte Menüs müssen im UI visuell hervorgehoben werden (gelber Glow bei ausverkauft, grüner Glow bei verfügbar). | Mittel | v1.0.1 |
|
||||
| FR-064 | Wenn die Bestell-Cutoff-Zeit erreicht ist, muss das System ein Flag automatisch entfernen. | Mittel | v1.0.1 |
|
||||
@@ -60,16 +60,17 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
|
||||
| **Header UI & Navigation** | | | |
|
||||
| FR-090 | Die Hauptnavigation (Wochen-Toggles) muss linksbündig neben dem App-Titel positioniert sein. | Niedrig | v1.5.0 |
|
||||
| FR-091 | Ein dynamisches Alarm-Icon im Header muss den Überwachungsstatus geflaggter Menüs anzeigen (Gelb=Überwachung aktiv aber kein Menü verfügbar, Grün=Mindestens ein Menü verfügbar, Versteckt=keine Flags). Der Tooltip muss den Zeitpunkt der letzten Prüfung als relativen String (z.B. "vor 4 Min.") enthalten. | Mittel | v1.6.11 (Update v1.5.0) |
|
||||
| FR-092 | Solange Menüdaten für die Nächste Woche verfügbar sind, aber noch keine Bestellungen getätigt wurden, muss der entsprechende Navigation-Button animiert und farblich (Gelb) hervorgehoben werden. Nach der ersten Bestellung muss die Hervorhebung automatisch erlöschen. Zusätzlich muss beim erstmaligen Erscheinen der Daten ein einmaliger Toast-Hinweis angezeigt werden. | Mittel | v1.6.0 (Update v1.4.21) |
|
||||
| FR-092 | Solange bestellbare Menüs für nächste Woche vorhanden sind, aber noch keine Bestellungen getätigt wurden (Prüfung Montag–Donnerstag; Freitag ist ausgenommen), muss der entsprechende Navigation-Button animiert und farblich hervorgehoben werden. Nach der ersten Bestellung muss die Hervorhebung erlöschen. Zusätzlich muss beim erstmaligen Erscheinen der Daten ein einmaliger Toast-Hinweis angezeigt werden. | Mittel | v1.6.0 (Update v1.7.0) |
|
||||
| FR-093 | Das System muss dem Benutzer ermöglichen, durch Klicken auf das Alarm-Icon im Header eine manuelle Prüfung der geflaggten Menüs auszulösen. Während der Prüfung muss das Icon visuell animiert sein (Rotation). Nach Abschluss der Prüfung muss eine Toast-Nachricht mit der Anzahl der geprüften Menüs angezeigt werden. | Mittel | v1.6.13 |
|
||||
| **Sprachfilter** | | | |
|
||||
| FR-120 | Das System muss zweisprachige Menübeschreibungen (Deutsch/Englisch) erkennen und dem Benutzer erlauben, via UI-Toggle zwischen DE, EN und ALL (beide Sprachen) zu wechseln. Die Sprachpräferenz muss persistent gespeichert werden. Allergen-Codes müssen in allen Modi angezeigt werden. | Mittel | v1.6.0 |
|
||||
| FR-120 | Das System muss zweisprachige Menübeschreibungen (Deutsch/Englisch) erkennen und dem Benutzer erlauben, via UI-Dropdown (Icon mit Label) zwischen DE, EN und ALL (beide Sprachen) zu wechseln. Die Sprachpräferenz muss persistent gespeichert werden. Allergen-Codes müssen in allen Modi angezeigt werden. | Mittel | v1.6.0 (Update v1.6.21) |
|
||||
| FR-121 | Das System muss bei fehlenden Übersetzungen in zweisprachigen Menüs robust reagieren. Wenn ein Gang nur in einer Sprache vorliegt, muss dieser Teil für beide Sprachansichten herangezogen werden, um die Konsistenz der Ganganzahl zu gewährleisten. | Mittel | v1.6.10 |
|
||||
| FR-122 | Bei Auswahl von EN muss die gesamte Benutzeroberfläche (Buttons, Tooltips, Modale, Status-Badges) auf Englisch umgestellt werden. Bei DE oder ALL verbleibt die GUI auf Deutsch. | Mittel | v1.7.0 |
|
||||
| **Benutzer-Feedback** | | | |
|
||||
| FR-095 | Alle benutzerrelevanten Aktionen (Bestellung, Stornierung, Fehler) müssen durch nicht-blockierende Benachrichtigungen (Toasts) bestätigt werden. | Mittel | v1.0.1 |
|
||||
| FR-096 | Bei einem Verbindungsfehler muss ein Fehlerdialog mit Fallback-Link zur Originalseite angezeigt werden. | Mittel | v1.0.1 |
|
||||
| **Nächste-Woche-Badge** | | | |
|
||||
| FR-100 | Die Navigation zur nächsten Woche muss ein Badge anzeigen, das den Überblick über den Bestellstatus der kommenden Woche visualisiert (bestellt / bestellbar / gesamt). | Niedrig | v1.0.1 |
|
||||
| FR-100 | Die Navigation zur nächsten Woche muss einen Tooltip anzeigen, der den Überblick über den Bestellstatus der kommenden Woche visualisiert (bestellt / bestellbar / gesamt). Die Zahlen-Badges sind ausgeblendet. | Niedrig | v1.0.1 (Update v1.7.0) |
|
||||
| **Update-Management** | | | |
|
||||
| FR-110 | Das System muss periodisch prüfen, ob eine neuere Version verfügbar ist. | Niedrig | v1.0.3 |
|
||||
| FR-111 | Bei Verfügbarkeit einer neueren Version muss ein diskreter Indikator im Header angezeigt werden. | Niedrig | v1.0.3 |
|
||||
@@ -87,16 +88,16 @@ Das System umfasst die Darstellung von Menüplänen in einer Wochenübersicht, d
|
||||
| **Performance** | NFR-001 | Die Darstellung bereits gecachter Daten muss ohne spürbare Verzögerung erfolgen. | < 200 ms (UI-Rendering) |
|
||||
| **Performance** | NFR-002 | Das Polling für geflaggte Menüs darf die reguläre Nutzung nicht beeinträchtigen. | Intervall ≥ 5 Minuten |
|
||||
| **Sicherheit** | NFR-003 | Es dürfen keine Zugangsdaten dauerhaft gespeichert werden. | 0 (keine persistente Speicherung von Passwörtern) |
|
||||
| **Sicherheit** | NFR-004 | Auth-Tokens müssen sitzungsbasiert gespeichert werden und bei Schließen des Browsers verfallen. | sessionStorage |
|
||||
| **Sicherheit** | NFR-004 | Auth-Tokens werden persistent gespeichert, um eine dauerhafte Anmeldung zu ermöglichen. | localStorage |
|
||||
| **Benutzbarkeit** | NFR-005 | Die Oberfläche muss auf mobilen Geräten fehlerfrei nutzbar sein. | Viewports ab 320px Breite |
|
||||
| **Benutzbarkeit** | NFR-006 | Alle interaktiven Elemente müssen Tooltips oder Hilfetexte bieten. | 100% Coverage |
|
||||
| **Benutzbarkeit** | NFR-007 | Die Benutzeroberfläche muss vollständig in deutscher Sprache sein. | Vollständige Lokalisierung |
|
||||
| **Benutzbarkeit** | NFR-007 | Die Benutzeroberfläche muss standardmäßig in Deutsch sein. Bei aktivem EN-Modus muss die gesamte GUI auf Englisch umgestellt werden. | Vollständige Lokalisierung (DE default / EN on demand) |
|
||||
| **Wartbarkeit** | NFR-008 | Die Build-Artefakte müssen durch automatisierte Tests validiert werden. | Build-Tests + Logik-Tests + DOM-Tests |
|
||||
|
||||
## 4. Technische Randbedingungen
|
||||
* **Deployment**: Das System wird als Bookmarklet ausgeliefert, das auf der Bessa-Webseite ausgeführt wird.
|
||||
* **Datenquelle**: Direkte Integration mit der Bessa REST-API (`api.bessa.app/v1`).
|
||||
* **Datenhaltung**: Clientseitig via `localStorage` (Menü-Cache, Flags, Highlights, Theme) und `sessionStorage` (Auth-Token).
|
||||
* **Datenhaltung**: Clientseitig via `localStorage` (Menü-Cache, Flags, Highlights, Theme, Auth-Token).
|
||||
* **Build**: Bash-basiertes Build-Script, das Bookmarklet-URL, Standalone-HTML und Installer-Seite generiert.
|
||||
* **Versionierung**: SemVer, verwaltet über GitHub Releases/Tags.
|
||||
* **Tests**: Python-basierte Build-Tests (`python3`) + Node.js-basierte Logik-Tests + Node.js-basierte DOM-Interaktionstests (JSDOM).
|
||||
|
||||
Binary file not shown.
63
changelog.md
63
changelog.md
@@ -1,3 +1,66 @@
|
||||
## v1.7.2 (2026-03-12)
|
||||
- 🛡️ **Security**: Logout-Logik vervollständigt (FR-006). Beim Abmelden werden nun alle App-bezogenen Daten (inkl. Bestellhistorie, Cache und Einstellungen) aus dem `localStorage` gelöscht.
|
||||
- 🧹 **Cleanup**: Veraltete `GUEST_TOKEN` Rückstände in `events.js` und `ui_helpers.js` entfernt.
|
||||
- 🧪 **Testing**: Die Security-Test-Suite wurde um eine Verifikation der Logout-Datenlöschung erweitert.
|
||||
|
||||
## v1.7.1 (2026-03-12)
|
||||
- 🛡️ **Security**: Kritischer Security-Fix und Härtung:
|
||||
- **XSS-Schutz**: `innerHTML` durch `textContent` in `renderTagsList` (Actions) und `showErrorModal` (UI-Helpers) ersetzt.
|
||||
- **XSS-Schutz**: Dynamische Kartenelemente in `createDayCard` validiert.
|
||||
- **Input-Validierung**: Neue Schlagwörter werden nun auf Länge (2-20 Zeichen) und erlaubte Zeichen (Alphanumerisch + Food-Sonderzeichen) geprüft.
|
||||
- **GUEST_TOKEN**: Der hardcodierte Gast-Token wurde komplett aus dem Code entfernt. Nicht-eingeloggte Nutzer haben keinen API-Zugriff mehr (Sicherheitsbestimmung).
|
||||
- **Auth-Guards**: API-Funktionen (`loadMenuDataFromAPI`, `refreshFlaggedItems`) prüfen nun explizit auf vorhandene Authentifizierung vor dem Fetch.
|
||||
- 🛡️ **Tech**: Sicherheits-Test-Suite `tests/test_security.js` implementiert.
|
||||
|
||||
## v1.6.25 (2026-03-12)
|
||||
- ⚡ **Performance**: Debounced Resize-Listener hinzugefügt. Die Höhen-Synchronisierung der Menü-Karten wird nun auch bei Viewport-Änderungen (z.B. Fenster-Skalierung oder Orientierungswechsel) automatisch und effizient ausgeführt.
|
||||
- 🧹 **Tech**: `debounce` Utility-Funktion in `utils.js` ergänzt.
|
||||
|
||||
## v1.6.24 (2026-03-12)
|
||||
- ⚡ **Performance**: Layout Thrashing in `syncMenuItemHeights` behoben. Durch Batch-Verarbeitung von DOM-Lese- und Schreibvorgängen wurde die Rendering-Effizienz beim Wochenwechsel verbessert.
|
||||
|
||||
## v1.6.23 (2026-03-12)
|
||||
- 🎨 **UI**: Umfassende UI-Verbesserungen umgesetzt:
|
||||
- **Glassmorphism**: Header-Hintergrundtransparenz auf 72% reduziert (war 90%) – der Blur-Effekt ist nun beim Scrollen sichtbar.
|
||||
- **Dark-Mode Kontrast**: `--bg-card` abgedunkelt (`#283548`), `--border-color` leicht aufgehellt (`#526377`) – bessere Trennung zwischen Body und Card.
|
||||
- **Accent-Color**: Im Light-Mode von Slate-900 (fast schwarz) auf Blue-600 (`#2563eb`) geändert – klarer sichtbarer Akzent.
|
||||
- **Typography**: `.item-desc` `line-height` auf 1.5 (body-konsistent), `.day-date` kleiner und dezenter (0.8rem, opacity 0.75), `.item-name` leicht reduziert (0.95rem).
|
||||
- **Item-Separator**: Subtile Trennlinie zwischen Menü-Items in der Tageskarte.
|
||||
- **Badge-Konsistenz**: Alle Badges (`badge`, `tag-badge-small`) auf `border-radius: 6px` vereinheitlicht.
|
||||
- **A11y – Reduced Motion**: `@media (prefers-reduced-motion: reduce)` deaktiviert alle dekorativen Puls-/Glow-Animationen für Motion-sensitive Nutzer.
|
||||
- **A11y – Focus-Visible**: Globaler `:focus-visible` Outline-Ring (2px, accent-color) für Tastaturnavigation.
|
||||
- **Active-States**: `:active` Feedback (`scale(0.97)`) für Bestell-, Storno- und Flag-Buttons.
|
||||
- **Mobile Breakpoint**: Von 600px auf 768px erweitert (deckt Tablets ab); Grid-Deklaration explizit gesetzt um Browser-Override-Bug zu vermeiden.
|
||||
|
||||
## v1.6.22 (2026-03-12)
|
||||
- 🧹 **UX Cleanup**: Text-Label am Sprachumschalter entfernt. Der Button zeigt nun nur noch das `translate`-Icon an, was die Controls-Bar ruhiger macht.
|
||||
|
||||
## v1.6.21 (2026-03-12)
|
||||
- ✨ **Feature**: Sprachumschaltung Redesign – Die Sprachwahl (DE/EN/ALL) wurde von der Header-Mitte in den rechten Controls-Bereich verschoben. Sie ist nun als Icon-Dropdown mit aktueller Status-Anzeige (z.B. "DE") verfügbar. Für die deutsche Sprache wird die 🇦🇹 Flagge verwendet.
|
||||
|
||||
## v1.6.20 (2026-03-12)
|
||||
- 🧹 **Cleanup**: Wochenkosten-Anzeige entfernt (Weekly Cost Display) – Auf User-Wunsch wurde die Anzeige der wöchentlichen Gesamtkosten im Header entfernt, um die UI zu entschlacken. FR-040 als obsolet markiert.
|
||||
|
||||
## v1.6.19 (2026-03-11)
|
||||
- 🎨 **UX**: Grid-Layout & Glow Overlap Fix – Die Karten-Inhalte wurden auf ein sauberes Grid-Gap-Modell umgestellt (`row-gap: 1.5rem`). Dies verhindert technische Überlappungen von Menü-Items und stellt sicher, dass Glow-Effekte (Bestellt, Highlight) alle Inhalte korrekt umschließen. Manuelle Abstände wurden bereinigt.
|
||||
|
||||
- 🎨 **UX**: Glow-Styling angepasst – Die farblichen Hervorhebungen (Bestellt, Highlight, Flagged) wurden so korrigiert, dass sie nicht mehr bis an den Kartenrand reichen, sondern innerhalb des Karten-Bodys mit entsprechendem Seitenabstand angezeigt werden.
|
||||
|
||||
- 🎨 **UX**: Fix Card Content Overflow – In der 5-Tage-Ansicht (Landscape) auf schmalen Bildschirmen umbrechen die Status-Badges und Buttons jetzt korrekt in eine neue Zeile, statt über den Kartenrand hinauszuragen. Das Karten-Padding wurde für Desktop-Ansichten optimiert.
|
||||
|
||||
- 🧹 **Wartbarkeit**: Alle verbliebenen hardcodierten deutschen UI-Strings in `actions.js` via `t()` übersetzt (Progress-Texte, Fehler-Labels, 'Angemeldet', 'Hintergrund-Synchronisation').
|
||||
- 🔑 **Wartbarkeit**: Alle `localStorage`-Schlüssel in einheitliches `LS`-Objekt in `constants.js` zentralisiert. Alle Quelldateien verwenden jetzt `LS.*` statt Rohstrings.
|
||||
- 🛡️ **Robustheit**: `setLangMode()` und `setDisplayMode()` in `state.js` prüfen jetzt Eingabewerte – ungültige Werte werden verworfen und protokolliert.
|
||||
- 📝 **Kodierung**: JSDoc für `ui.js` und `injectUI()` ergänzt.
|
||||
|
||||
- 🐛 **Bugfix**: Geprüfte Menüs (`refreshFlaggedItems`) aktualisieren jetzt nur noch die tatsächlich geflaggten Artikel – nicht mehr alle Menüs des betroffenen Tages ([Bug 1]).
|
||||
- 🐛 **Bugfix**: Beim Öffnen des Highlights-Modals werden bestehende Tags sofort angezeigt, auch ohne vorherige Neueingabe ([Bug 2]).
|
||||
- 🎨 **UX**: Die Zahlen-Badges im „Nächste Woche"-Button wurden entfernt. Die Bestellübersicht (bestellt / bestellbar / gesamt + Highlights) ist jetzt als Tooltip abrufbar ([FR-100 Update]).
|
||||
- 🌍 **Feature**: Bei Auswahl von EN wird die gesamte Benutzeroberfläche auf Englisch umgestellt (Buttons, Tooltips, Modals, Status-Badges, Wochentage, Bestellhistorie). DE und ALL behalten Deutsch bei ([FR-122]).
|
||||
- ✨ **Feature**: Das Glühen des „Nächste Woche"-Buttons wird jetzt nur noch ausgelöst, wenn für Montag–Donnerstag bestellbare Menüs ohne bestehende Bestellung vorhanden sind. Freitag ist von dieser Prüfung ausgenommen ([FR-092 Update]).
|
||||
- 🧹 **Wartbarkeit**: Code-Qualitätsprüfung aller Quelldateien – JSDoc-Kommentare ergänzt, Erklärungen für komplexe Logikblöcke hinzugefügt.
|
||||
- 📦 **Neu**: `src/i18n.js` – Zentrales Übersetzungsmodul für alle statischen UI-Labels (DE/EN).
|
||||
|
||||
## v1.6.14 (2026-03-10)
|
||||
- 🐛 **Bugfix**: Die globale "Aktualisiert am"-Zeit im Header wird bei einer manuellen Prüfung der geflaggten Menüs nicht mehr zurückgesetzt.
|
||||
|
||||
|
||||
4
dist/bookmarklet-payload.js
vendored
4
dist/bookmarklet-payload.js
vendored
File diff suppressed because one or more lines are too long
2
dist/bookmarklet.txt
vendored
2
dist/bookmarklet.txt
vendored
File diff suppressed because one or more lines are too long
84
dist/install.html
vendored
84
dist/install.html
vendored
File diff suppressed because one or more lines are too long
1464
dist/kantine-standalone.html
vendored
1464
dist/kantine-standalone.html
vendored
File diff suppressed because one or more lines are too long
1253
dist/kantine.bundle.js
vendored
1253
dist/kantine.bundle.js
vendored
File diff suppressed because it is too large
Load Diff
353
src/actions.js
353
src/actions.js
@@ -1,8 +1,9 @@
|
||||
import { authToken, currentUser, orderMap, userFlags, pollIntervalId, highlightTags, allWeeks, currentWeekNumber, currentYear, displayMode, langMode, setAuthToken, setCurrentUser, setOrderMap, setUserFlags, setPollIntervalId, setHighlightTags, setAllWeeks, setCurrentWeekNumber, setCurrentYear } from './state.js';
|
||||
import { getISOWeek, getWeekYear, translateDay, escapeHtml, getRelativeTime, isNewer } from './utils.js';
|
||||
import { API_BASE, GUEST_TOKEN, VENUE_ID, MENU_ID, POLL_INTERVAL_MS, GITHUB_API, INSTALLER_BASE, CLIENT_VERSION } from './constants.js';
|
||||
import { API_BASE, VENUE_ID, MENU_ID, POLL_INTERVAL_MS, GITHUB_API, INSTALLER_BASE, CLIENT_VERSION, LS } from './constants.js';
|
||||
import { apiHeaders, githubHeaders } from './api.js';
|
||||
import { renderVisibleWeeks, updateNextWeekBadge, updateAlarmBell } from './ui_helpers.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
let fullOrderHistoryCache = null;
|
||||
|
||||
@@ -14,13 +15,13 @@ export function updateAuthUI() {
|
||||
const parsed = JSON.parse(akita);
|
||||
if (parsed.auth && parsed.auth.token) {
|
||||
setAuthToken(parsed.auth.token);
|
||||
localStorage.setItem('kantine_authToken', parsed.auth.token);
|
||||
localStorage.setItem(LS.AUTH_TOKEN, parsed.auth.token);
|
||||
|
||||
if (parsed.auth.user) {
|
||||
setCurrentUser(parsed.auth.user.id || 'unknown');
|
||||
localStorage.setItem('kantine_currentUser', parsed.auth.user.id || 'unknown');
|
||||
if (parsed.auth.user.firstName) localStorage.setItem('kantine_firstName', parsed.auth.user.firstName);
|
||||
if (parsed.auth.user.lastName) localStorage.setItem('kantine_lastName', parsed.auth.user.lastName);
|
||||
localStorage.setItem(LS.CURRENT_USER, parsed.auth.user.id || 'unknown');
|
||||
if (parsed.auth.user.firstName) localStorage.setItem(LS.FIRST_NAME, parsed.auth.user.firstName);
|
||||
if (parsed.auth.user.lastName) localStorage.setItem(LS.LAST_NAME, parsed.auth.user.lastName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,9 +30,9 @@ export function updateAuthUI() {
|
||||
}
|
||||
}
|
||||
|
||||
setAuthToken(localStorage.getItem('kantine_authToken'));
|
||||
setCurrentUser(localStorage.getItem('kantine_currentUser'));
|
||||
const firstName = localStorage.getItem('kantine_firstName');
|
||||
setAuthToken(localStorage.getItem(LS.AUTH_TOKEN));
|
||||
setCurrentUser(localStorage.getItem(LS.CURRENT_USER));
|
||||
const firstName = localStorage.getItem(LS.FIRST_NAME);
|
||||
const btnLoginOpen = document.getElementById('btn-login-open');
|
||||
const userInfo = document.getElementById('user-info');
|
||||
const userIdDisplay = document.getElementById('user-id-display');
|
||||
@@ -39,7 +40,7 @@ export function updateAuthUI() {
|
||||
if (authToken) {
|
||||
btnLoginOpen.classList.add('hidden');
|
||||
userInfo.classList.remove('hidden');
|
||||
userIdDisplay.textContent = firstName || (currentUser ? `User ${currentUser}` : 'Angemeldet');
|
||||
userIdDisplay.textContent = firstName || (currentUser ? `User ${currentUser}` : t('loggedIn'));
|
||||
fetchOrders();
|
||||
} else {
|
||||
btnLoginOpen.classList.remove('hidden');
|
||||
@@ -91,7 +92,7 @@ export async function fetchFullOrderHistory() {
|
||||
if (fullOrderHistoryCache) {
|
||||
localCache = fullOrderHistoryCache;
|
||||
} else {
|
||||
const ls = localStorage.getItem('kantine_history_cache');
|
||||
const ls = localStorage.getItem(LS.HISTORY_CACHE);
|
||||
if (ls) {
|
||||
try {
|
||||
localCache = JSON.parse(ls);
|
||||
@@ -114,7 +115,7 @@ export async function fetchFullOrderHistory() {
|
||||
}
|
||||
|
||||
progressFill.style.width = '0%';
|
||||
progressText.textContent = localCache.length > 0 ? 'Suche nach neuen Bestellungen...' : 'Lade Bestellhistorie...';
|
||||
progressText.textContent = localCache.length > 0 ? t('historyLoadingDelta') : t('historyLoadingFull');
|
||||
if (localCache.length > 0) historyLoading.classList.remove('hidden');
|
||||
|
||||
let nextUrl = localCache.length > 0
|
||||
@@ -155,12 +156,12 @@ export async function fetchFullOrderHistory() {
|
||||
if (totalCount > 0) {
|
||||
const pct = Math.round((fetchedOrders.length / totalCount) * 100);
|
||||
progressFill.style.width = `${pct}%`;
|
||||
progressText.textContent = `Lade Bestellung ${fetchedOrders.length} von ${totalCount}...`;
|
||||
progressText.textContent = `${t('historyLoadingItem')} ${fetchedOrders.length} ${t('historyLoadingOf')} ${totalCount}...`;
|
||||
} else {
|
||||
progressText.textContent = `Lade Bestellung ${fetchedOrders.length}...`;
|
||||
progressText.textContent = `${t('historyLoadingItem')} ${fetchedOrders.length}...`;
|
||||
}
|
||||
} else if (!deltaComplete) {
|
||||
progressText.textContent = `${fetchedOrders.length} neue/geänderte Bestellungen gefunden...`;
|
||||
progressText.textContent = `${fetchedOrders.length} ${t('historyLoadingNew')}`;
|
||||
}
|
||||
|
||||
nextUrl = deltaComplete ? null : data.next;
|
||||
@@ -176,7 +177,7 @@ export async function fetchFullOrderHistory() {
|
||||
|
||||
fullOrderHistoryCache = mergedOrders;
|
||||
try {
|
||||
localStorage.setItem('kantine_history_cache', JSON.stringify(mergedOrders));
|
||||
localStorage.setItem(LS.HISTORY_CACHE, JSON.stringify(mergedOrders));
|
||||
} catch (e) {
|
||||
console.warn('History cache write error', e);
|
||||
}
|
||||
@@ -185,9 +186,9 @@ export async function fetchFullOrderHistory() {
|
||||
} catch (error) {
|
||||
console.error('Error in history sync:', error);
|
||||
if (localCache.length === 0) {
|
||||
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">Fehler beim Laden der Historie.</p>`;
|
||||
historyContent.innerHTML = `<p style="color:var(--error-color);text-align:center;">${t('historyLoadError')}</p>`;
|
||||
} else {
|
||||
showToast('Hintergrund-Synchronisation fehlgeschlagen', 'error');
|
||||
showToast(t('bgSyncFailed'), 'error');
|
||||
}
|
||||
} finally {
|
||||
historyLoading.classList.add('hidden');
|
||||
@@ -197,7 +198,7 @@ export async function fetchFullOrderHistory() {
|
||||
export function renderHistory(orders) {
|
||||
const content = document.getElementById('history-content');
|
||||
if (!orders || orders.length === 0) {
|
||||
content.innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:20px;">Keine Bestellungen gefunden.</p>';
|
||||
content.innerHTML = `<p style="text-align:center;color:var(--text-secondary);padding:20px;">${t('noOrders')}</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -208,7 +209,8 @@ export function renderHistory(orders) {
|
||||
const y = d.getFullYear();
|
||||
const m = d.getMonth();
|
||||
const monthKey = `${y}-${m.toString().padStart(2, '0')}`;
|
||||
const monthName = d.toLocaleString('de-AT', { month: 'long' });
|
||||
const uiLocale = langMode === 'en' ? 'en-US' : 'de-AT';
|
||||
const monthName = d.toLocaleString(uiLocale, { month: 'long' });
|
||||
|
||||
const kw = getISOWeek(d);
|
||||
|
||||
@@ -219,7 +221,7 @@ export function renderHistory(orders) {
|
||||
groups[y].months[monthKey] = { name: monthName, year: y, monthIndex: m, count: 0, total: 0, weeks: {} };
|
||||
}
|
||||
if (!groups[y].months[monthKey].weeks[kw]) {
|
||||
groups[y].months[monthKey].weeks[kw] = { label: `KW ${kw}`, items: [], count: 0, total: 0 };
|
||||
groups[y].months[monthKey].weeks[kw] = { label: langMode === 'en' ? `CW ${kw}` : `KW ${kw}`, items: [], count: 0, total: 0 };
|
||||
}
|
||||
|
||||
const items = order.items || [];
|
||||
@@ -241,87 +243,155 @@ export function renderHistory(orders) {
|
||||
});
|
||||
});
|
||||
|
||||
content.innerHTML = '';
|
||||
const sortedYears = Object.keys(groups).sort((a, b) => b - a);
|
||||
let html = '';
|
||||
|
||||
sortedYears.forEach(yKey => {
|
||||
const yearGroup = groups[yKey];
|
||||
html += `<div class="history-year-group">
|
||||
<h2 class="history-year-header">${yearGroup.year}</h2>`;
|
||||
const yearGroupDiv = document.createElement('div');
|
||||
yearGroupDiv.className = 'history-year-group';
|
||||
|
||||
const yearHeader = document.createElement('h2');
|
||||
yearHeader.className = 'history-year-header';
|
||||
yearHeader.textContent = yearGroup.year;
|
||||
yearGroupDiv.appendChild(yearHeader);
|
||||
|
||||
const sortedMonths = Object.keys(yearGroup.months).sort((a, b) => b.localeCompare(a));
|
||||
|
||||
sortedMonths.forEach(mKey => {
|
||||
const monthGroup = yearGroup.months[mKey];
|
||||
|
||||
html += `<div class="history-month-group">
|
||||
<div class="history-month-header" tabindex="0" role="button" aria-expanded="false" title="Klicken, um die Bestellungen für diesen Monat ein-/auszublenden">
|
||||
<div style="display:flex; flex-direction:column; gap:4px;">
|
||||
<span>${monthGroup.name}</span>
|
||||
<div class="history-month-summary">
|
||||
<span>${monthGroup.count} Bestellungen • <strong>€${monthGroup.total.toFixed(2)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="material-icons-round">expand_more</span>
|
||||
</div>
|
||||
<div class="history-month-content">`;
|
||||
const monthGroupDiv = document.createElement('div');
|
||||
monthGroupDiv.className = 'history-month-group';
|
||||
|
||||
const monthHeader = document.createElement('div');
|
||||
monthHeader.className = 'history-month-header';
|
||||
monthHeader.setAttribute('tabindex', '0');
|
||||
monthHeader.setAttribute('role', 'button');
|
||||
monthHeader.setAttribute('aria-expanded', 'false');
|
||||
monthHeader.setAttribute('title', t('historyMonthToggle'));
|
||||
|
||||
const monthHeaderContent = document.createElement('div');
|
||||
monthHeaderContent.style.display = 'flex';
|
||||
monthHeaderContent.style.flexDirection = 'column';
|
||||
monthHeaderContent.style.gap = '4px';
|
||||
|
||||
const monthNameSpan = document.createElement('span');
|
||||
monthNameSpan.textContent = monthGroup.name;
|
||||
monthHeaderContent.appendChild(monthNameSpan);
|
||||
|
||||
const monthSummary = document.createElement('div');
|
||||
monthSummary.className = 'history-month-summary';
|
||||
|
||||
const monthSummarySpan = document.createElement('span');
|
||||
monthSummarySpan.innerHTML = `${monthGroup.count} ${t('orders')} • <strong>€${monthGroup.total.toFixed(2)}</strong>`;
|
||||
monthSummary.appendChild(monthSummarySpan);
|
||||
|
||||
monthHeaderContent.appendChild(monthSummary);
|
||||
monthHeader.appendChild(monthHeaderContent);
|
||||
|
||||
const expandIcon = document.createElement('span');
|
||||
expandIcon.className = 'material-icons-round';
|
||||
expandIcon.textContent = 'expand_more';
|
||||
monthHeader.appendChild(expandIcon);
|
||||
|
||||
monthHeader.addEventListener('click', () => {
|
||||
const parentGroup = monthHeader.parentElement;
|
||||
const isOpen = parentGroup.classList.contains('open');
|
||||
|
||||
if (isOpen) {
|
||||
parentGroup.classList.remove('open');
|
||||
monthHeader.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
parentGroup.classList.add('open');
|
||||
monthHeader.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
monthGroupDiv.appendChild(monthHeader);
|
||||
|
||||
const monthContentDiv = document.createElement('div');
|
||||
monthContentDiv.className = 'history-month-content';
|
||||
|
||||
const sortedKWs = Object.keys(monthGroup.weeks).sort((a, b) => parseInt(b) - parseInt(a));
|
||||
|
||||
sortedKWs.forEach(kw => {
|
||||
const week = monthGroup.weeks[kw];
|
||||
html += `<div class="history-week-group">
|
||||
<div class="history-week-header">
|
||||
<strong>${week.label}</strong>
|
||||
<span>${week.count} Bestellungen • <strong>€${week.total.toFixed(2)}</strong></span>
|
||||
</div>`;
|
||||
const weekGroupDiv = document.createElement('div');
|
||||
weekGroupDiv.className = 'history-week-group';
|
||||
|
||||
const weekHeader = document.createElement('div');
|
||||
weekHeader.className = 'history-week-header';
|
||||
|
||||
const weekLabel = document.createElement('strong');
|
||||
weekLabel.textContent = week.label;
|
||||
weekHeader.appendChild(weekLabel);
|
||||
|
||||
const weekSummary = document.createElement('span');
|
||||
weekSummary.innerHTML = `${week.count} ${t('orders')} • <strong>€${week.total.toFixed(2)}</strong>`;
|
||||
weekHeader.appendChild(weekSummary);
|
||||
|
||||
weekGroupDiv.appendChild(weekHeader);
|
||||
|
||||
week.items.forEach(item => {
|
||||
const dateObj = new Date(item.date);
|
||||
const dayStr = dateObj.toLocaleDateString('de-AT', { weekday: 'short', day: '2-digit', month: '2-digit' });
|
||||
const uiLocale = langMode === 'en' ? 'en-US' : 'de-AT';
|
||||
const dayStr = dateObj.toLocaleDateString(uiLocale, { weekday: 'short', day: '2-digit', month: '2-digit' });
|
||||
|
||||
let statusBadge = '';
|
||||
const historyItem = document.createElement('div');
|
||||
historyItem.className = 'history-item';
|
||||
if (item.state === 9) {
|
||||
statusBadge = '<span class="history-item-status">Storniert</span>';
|
||||
} else if (item.state === 8) {
|
||||
statusBadge = '<span class="history-item-status">Abgeschlossen</span>';
|
||||
} else {
|
||||
statusBadge = '<span class="history-item-status">Übertragen</span>';
|
||||
historyItem.classList.add('history-item-cancelled');
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="history-item ${item.state === 9 ? 'history-item-cancelled' : ''}">
|
||||
<div style="font-size: 0.85rem; color: var(--text-secondary);">${dayStr}</div>
|
||||
<div class="history-item-details">
|
||||
<span class="history-item-name">${escapeHtml(item.name)}</span>
|
||||
<div>${statusBadge}</div>
|
||||
</div>
|
||||
<div class="history-item-price ${item.state === 9 ? 'history-item-price-cancelled' : ''}">€${item.price.toFixed(2)}</div>
|
||||
</div>`;
|
||||
const dateDiv = document.createElement('div');
|
||||
dateDiv.style.fontSize = '0.85rem';
|
||||
dateDiv.style.color = 'var(--text-secondary)';
|
||||
dateDiv.textContent = dayStr;
|
||||
historyItem.appendChild(dateDiv);
|
||||
|
||||
const detailsDiv = document.createElement('div');
|
||||
detailsDiv.className = 'history-item-details';
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'history-item-name';
|
||||
nameSpan.textContent = item.name;
|
||||
detailsDiv.appendChild(nameSpan);
|
||||
|
||||
const statusDiv = document.createElement('div');
|
||||
const statusSpan = document.createElement('span');
|
||||
statusSpan.className = 'history-item-status';
|
||||
if (item.state === 9) {
|
||||
statusSpan.textContent = t('stateCancelled');
|
||||
} else if (item.state === 8) {
|
||||
statusSpan.textContent = t('stateCompleted');
|
||||
} else {
|
||||
statusSpan.textContent = t('stateTransferred');
|
||||
}
|
||||
statusDiv.appendChild(statusSpan);
|
||||
detailsDiv.appendChild(statusDiv);
|
||||
|
||||
historyItem.appendChild(detailsDiv);
|
||||
|
||||
const priceDiv = document.createElement('div');
|
||||
priceDiv.className = 'history-item-price';
|
||||
if (item.state === 9) {
|
||||
priceDiv.classList.add('history-item-price-cancelled');
|
||||
}
|
||||
priceDiv.textContent = `€${item.price.toFixed(2)}`;
|
||||
historyItem.appendChild(priceDiv);
|
||||
|
||||
weekGroupDiv.appendChild(historyItem);
|
||||
});
|
||||
html += `</div>`;
|
||||
|
||||
monthContentDiv.appendChild(weekGroupDiv);
|
||||
});
|
||||
html += `</div></div>`;
|
||||
|
||||
monthGroupDiv.appendChild(monthContentDiv);
|
||||
yearGroupDiv.appendChild(monthGroupDiv);
|
||||
});
|
||||
html += `</div>`;
|
||||
});
|
||||
|
||||
content.innerHTML = html;
|
||||
|
||||
const monthHeaders = content.querySelectorAll('.history-month-header');
|
||||
monthHeaders.forEach(header => {
|
||||
header.addEventListener('click', () => {
|
||||
const parentGroup = header.parentElement;
|
||||
const isOpen = parentGroup.classList.contains('open');
|
||||
|
||||
if (isOpen) {
|
||||
parentGroup.classList.remove('open');
|
||||
header.setAttribute('aria-expanded', 'false');
|
||||
} else {
|
||||
parentGroup.classList.add('open');
|
||||
header.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
content.appendChild(yearGroupDiv);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -383,7 +453,7 @@ export async function placeOrder(date, articleId, name, price, description) {
|
||||
});
|
||||
|
||||
if (response.ok || response.status === 201) {
|
||||
showToast(`Bestellt: ${name}`, 'success');
|
||||
showToast(`${t('orderSuccess')}: ${name}`, 'success');
|
||||
fullOrderHistoryCache = null;
|
||||
await fetchOrders();
|
||||
} else {
|
||||
@@ -411,7 +481,7 @@ export async function cancelOrder(date, articleId, name) {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
showToast(`Storniert: ${name}`, 'success');
|
||||
showToast(`${t('cancelSuccess')}: ${name}`, 'success');
|
||||
fullOrderHistoryCache = null;
|
||||
await fetchOrders();
|
||||
} else {
|
||||
@@ -430,9 +500,15 @@ export function saveFlags() {
|
||||
|
||||
export async function refreshFlaggedItems() {
|
||||
if (userFlags.size === 0) return;
|
||||
const token = authToken || GUEST_TOKEN;
|
||||
const datesToFetch = new Set();
|
||||
const token = authToken;
|
||||
if (!token) {
|
||||
const bellBtn = document.getElementById('alarm-bell');
|
||||
if (bellBtn) bellBtn.classList.remove('refreshing');
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect unique dates that have flagged items
|
||||
const datesToFetch = new Set();
|
||||
for (const flagId of userFlags) {
|
||||
const [dateStr] = flagId.split('_');
|
||||
datesToFetch.add(dateStr);
|
||||
@@ -451,32 +527,37 @@ export async function refreshFlaggedItems() {
|
||||
if (!resp.ok) continue;
|
||||
const data = await resp.json();
|
||||
const menuGroups = data.results || [];
|
||||
let dayItems = [];
|
||||
|
||||
// Build a lookup of fresh API items by article ID
|
||||
const apiItemMap = new Map();
|
||||
for (const group of menuGroups) {
|
||||
if (group.items && Array.isArray(group.items)) {
|
||||
dayItems = dayItems.concat(group.items);
|
||||
for (const item of group.items) {
|
||||
apiItemMap.set(item.id, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only update items that are actually flagged
|
||||
for (let week of allWeeks) {
|
||||
if (!week.days) continue;
|
||||
let dayObj = week.days.find(d => d.date === dateStr);
|
||||
if (dayObj) {
|
||||
dayObj.items = dayItems.map(item => {
|
||||
const isUnlimited = item.amount_tracking === false;
|
||||
const hasStock = parseInt(item.available_amount) > 0;
|
||||
return {
|
||||
id: `${dateStr}_${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
|
||||
};
|
||||
});
|
||||
updated = true;
|
||||
const dayObj = week.days.find(d => d.date === dateStr);
|
||||
if (!dayObj || !dayObj.items) continue;
|
||||
|
||||
for (let i = 0; i < dayObj.items.length; i++) {
|
||||
const existing = dayObj.items[i];
|
||||
const flagId = `${dateStr}_${existing.articleId}`;
|
||||
if (!userFlags.has(flagId)) continue;
|
||||
|
||||
const apiItem = apiItemMap.get(existing.articleId);
|
||||
if (apiItem) {
|
||||
const isUnlimited = apiItem.amount_tracking === false;
|
||||
const hasStock = parseInt(apiItem.available_amount) > 0;
|
||||
existing.available = isUnlimited || hasStock;
|
||||
existing.availableAmount = parseInt(apiItem.available_amount) || 0;
|
||||
existing.amountTracking = apiItem.amount_tracking !== false;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -486,12 +567,14 @@ export async function refreshFlaggedItems() {
|
||||
|
||||
if (updated) {
|
||||
saveMenuCache();
|
||||
localStorage.setItem('kantine_flagged_items_last_checked', new Date().toISOString());
|
||||
updateAlarmBell();
|
||||
renderVisibleWeeks();
|
||||
}
|
||||
|
||||
showToast(`${userFlags.size} ${userFlags.size === 1 ? 'Menü' : 'Menüs'} geprüft`, 'info');
|
||||
|
||||
// Always update the check timestamp and bell status
|
||||
localStorage.setItem('kantine_flagged_items_last_checked', new Date().toISOString());
|
||||
updateAlarmBell();
|
||||
renderVisibleWeeks();
|
||||
|
||||
showToast(`${userFlags.size} ${userFlags.size === 1 ? t('menuSingular') : t('menuPlural')} ${t('menuChecked')}`, 'info');
|
||||
} finally {
|
||||
if (bellBtn) bellBtn.classList.remove('refreshing');
|
||||
}
|
||||
@@ -503,11 +586,11 @@ export function toggleFlag(date, articleId, name, cutoff) {
|
||||
let flagAdded = false;
|
||||
if (userFlags.has(id)) {
|
||||
userFlags.delete(id);
|
||||
showToast(`Flag entfernt für ${name}`, 'success');
|
||||
showToast(`${t('flagRemoved')} ${name}`, 'success');
|
||||
} else {
|
||||
userFlags.add(id);
|
||||
flagAdded = true;
|
||||
showToast(`Benachrichtigung aktiviert für ${name}`, 'success');
|
||||
showToast(`${t('flagActivated')} ${name}`, 'success');
|
||||
if (Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
@@ -615,14 +698,26 @@ export function saveHighlightTags() {
|
||||
}
|
||||
|
||||
export function addHighlightTag(tag) {
|
||||
tag = tag.trim().toLowerCase();
|
||||
if (tag && !highlightTags.includes(tag)) {
|
||||
const newTags = [...highlightTags, tag];
|
||||
setHighlightTags(newTags);
|
||||
saveHighlightTags();
|
||||
return true;
|
||||
if (!tag) return false;
|
||||
tag = tag.trim();
|
||||
if (tag.length < 2) {
|
||||
showToast('Tag muss mindestens 2 Zeichen lang sein.', 'error');
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
if (tag.length > 20) {
|
||||
showToast('Tag darf maximal 20 Zeichen lang sein.', 'error');
|
||||
return false;
|
||||
}
|
||||
// Only allow alphanumeric characters, spaces and common special chars for food
|
||||
if (!/^[a-zA-Z0-9äöüÄÖÜß\s\-\.]+$/.test(tag)) {
|
||||
showToast('Ungültige Zeichen im Tag.', 'error');
|
||||
return false;
|
||||
}
|
||||
if (highlightTags.includes(tag)) return false;
|
||||
const newTags = [...highlightTags, tag];
|
||||
setHighlightTags(newTags);
|
||||
saveHighlightTags();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeHighlightTag(tag) {
|
||||
@@ -633,19 +728,26 @@ export function removeHighlightTag(tag) {
|
||||
|
||||
export function renderTagsList() {
|
||||
const list = document.getElementById('tags-list');
|
||||
list.innerHTML = '';
|
||||
if (!list) return;
|
||||
list.innerHTML = ''; // Clear existing content
|
||||
highlightTags.forEach(tag => {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'tag-badge';
|
||||
badge.innerHTML = `${tag} <span class="tag-remove" data-tag="${tag}" title="Schlagwort entfernen">×</span>`;
|
||||
list.appendChild(badge);
|
||||
});
|
||||
|
||||
list.querySelectorAll('.tag-remove').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
removeHighlightTag(e.target.dataset.tag);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = tag;
|
||||
badge.appendChild(label);
|
||||
|
||||
const removeBtn = document.createElement('span');
|
||||
removeBtn.className = 'tag-remove';
|
||||
removeBtn.innerHTML = '×';
|
||||
removeBtn.title = t('removeTagTooltip') || 'Entfernen';
|
||||
removeBtn.onclick = () => {
|
||||
removeHighlightTag(tag);
|
||||
renderTagsList();
|
||||
});
|
||||
};
|
||||
badge.appendChild(removeBtn);
|
||||
list.appendChild(badge);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -729,7 +831,11 @@ export async function loadMenuDataFromAPI() {
|
||||
|
||||
loading.classList.remove('hidden');
|
||||
|
||||
const token = authToken || GUEST_TOKEN;
|
||||
const token = authToken;
|
||||
if (!token) {
|
||||
loading.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
progressModal.classList.remove('hidden');
|
||||
@@ -896,7 +1002,8 @@ export async function loadMenuDataFromAPI() {
|
||||
import('./ui_helpers.js').then(uiHelpers => {
|
||||
uiHelpers.showErrorModal(
|
||||
'Keine Verbindung',
|
||||
`Die Menüdaten konnten nicht geladen werden. Möglicherweise besteht keine Verbindung zur API oder zur Bessa-Webseite.<br><br><small style="color:var(--text-secondary)">${escapeHtml(error.message)}</small>`,
|
||||
'Die Menüdaten konnten nicht geladen werden. Möglicherweise besteht keine Verbindung zur API oder zur Bessa-Webseite.',
|
||||
error.message,
|
||||
'Zur Original-Seite',
|
||||
'https://web.bessa.app/knapp-kantine'
|
||||
);
|
||||
|
||||
24
src/api.js
24
src/api.js
@@ -1,14 +1,32 @@
|
||||
import { API_BASE, GUEST_TOKEN, CLIENT_VERSION } from './constants.js';
|
||||
/**
|
||||
* API header factories for the Bessa REST API and GitHub API.
|
||||
* All fetch calls in the app route through these helpers to ensure
|
||||
* consistent auth and versioning headers.
|
||||
*/
|
||||
import { API_BASE, CLIENT_VERSION } from './constants.js';
|
||||
|
||||
/**
|
||||
* Returns request headers for the Bessa REST API.
|
||||
* @param {string|null} token - Auth token.
|
||||
* @returns {Object} HTTP headers for fetch()
|
||||
*/
|
||||
export function apiHeaders(token) {
|
||||
return {
|
||||
'Authorization': `Token ${token || GUEST_TOKEN}`,
|
||||
const headers = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-Version': CLIENT_VERSION
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Token ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns request headers for the GitHub REST API v3.
|
||||
* Used for version checks and release listing.
|
||||
* @returns {Object} HTTP headers for fetch()
|
||||
*/
|
||||
export function githubHeaders() {
|
||||
return { 'Accept': 'application/vnd.github.v3+json' };
|
||||
}
|
||||
|
||||
@@ -1,10 +1,51 @@
|
||||
export const API_BASE = 'https://api.bessa.app/v1';
|
||||
export const GUEST_TOKEN = 'c3418725e95a9f90e3645cbc846b4d67c7c66131';
|
||||
export const CLIENT_VERSION = 'v1.6.11';
|
||||
export const VENUE_ID = 591;
|
||||
export const MENU_ID = 7;
|
||||
export const POLL_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
/**
|
||||
* Application-wide constants.
|
||||
* All API endpoints, IDs and timing parameters are centralized here
|
||||
* to make changes easy and avoid magic numbers scattered across the codebase.
|
||||
*/
|
||||
|
||||
/** Base URL for the Bessa REST API (v1). */
|
||||
export const API_BASE = 'https://api.bessa.app/v1';
|
||||
|
||||
/** The client version injected into every API request header. */
|
||||
export const CLIENT_VERSION = '{{VERSION}}';
|
||||
|
||||
/** Bessa venue ID for Knapp-Kantine. */
|
||||
export const VENUE_ID = 591;
|
||||
|
||||
/** Bessa menu ID for the weekly lunch menu. */
|
||||
export const MENU_ID = 7;
|
||||
|
||||
/** Polling interval for flagged-menu availability checks (5 minutes). */
|
||||
export const POLL_INTERVAL_MS = 5 * 60 * 1000;
|
||||
|
||||
/** GitHub repository identifier for update checks and release links. */
|
||||
export const GITHUB_REPO = 'TauNeutrino/kantine-overview';
|
||||
|
||||
/** GitHub REST API base URL for this repository. */
|
||||
export const GITHUB_API = `https://api.github.com/repos/${GITHUB_REPO}`;
|
||||
|
||||
/** Base URL for htmlpreview-hosted installer pages. */
|
||||
export const INSTALLER_BASE = `https://htmlpreview.github.io/?https://github.com/${GITHUB_REPO}/blob`;
|
||||
|
||||
/**
|
||||
* Centralized localStorage key registry.
|
||||
* Always use these constants instead of raw strings to avoid typos and ease renaming.
|
||||
*/
|
||||
export const LS = {
|
||||
AUTH_TOKEN: 'kantine_authToken',
|
||||
CURRENT_USER: 'kantine_currentUser',
|
||||
FIRST_NAME: 'kantine_firstName',
|
||||
LAST_NAME: 'kantine_lastName',
|
||||
LANG: 'kantine_lang',
|
||||
FLAGS: 'kantine_flags',
|
||||
FLAGGED_LAST_CHECKED: 'kantine_flagged_items_last_checked',
|
||||
LAST_CHECKED: 'kantine_last_checked',
|
||||
MENU_CACHE: 'kantine_menuCache',
|
||||
MENU_CACHE_TS: 'kantine_menuCacheTs',
|
||||
HISTORY_CACHE: 'kantine_history_cache',
|
||||
HIGHLIGHT_TAGS: 'kantine_highlightTags',
|
||||
LAST_UPDATED: 'kantine_last_updated',
|
||||
VERSION_CACHE: 'kantine_version_cache',
|
||||
DEV_MODE: 'kantine_dev_mode',
|
||||
};
|
||||
|
||||
145
src/events.js
145
src/events.js
@@ -1,8 +1,104 @@
|
||||
import { displayMode, langMode, authToken, currentUser, orderMap, userFlags, pollIntervalId, setLangMode, setDisplayMode, setAuthToken, setCurrentUser, setOrderMap } from './state.js';
|
||||
import { updateAuthUI, loadMenuDataFromAPI, fetchOrders, startPolling, stopPolling, fetchFullOrderHistory, addHighlightTag, renderTagsList, refreshFlaggedItems } from './actions.js';
|
||||
import { renderVisibleWeeks, openVersionMenu } from './ui_helpers.js';
|
||||
import { API_BASE, GUEST_TOKEN } from './constants.js';
|
||||
import { renderVisibleWeeks, openVersionMenu, updateNextWeekBadge, updateAlarmBell, syncMenuItemHeights } from './ui_helpers.js';
|
||||
import { API_BASE, LS } from './constants.js';
|
||||
import { apiHeaders } from './api.js';
|
||||
import { t } from './i18n.js';
|
||||
import { debounce } from './utils.js';
|
||||
|
||||
/**
|
||||
* Updates all static UI labels/tooltips to match the current language.
|
||||
* Called when the user switches the language toggle.
|
||||
*/
|
||||
function updateUILanguage() {
|
||||
// Navigation buttons
|
||||
const btnThisWeek = document.getElementById('btn-this-week');
|
||||
const btnNextWeek = document.getElementById('btn-next-week');
|
||||
if (btnThisWeek) {
|
||||
btnThisWeek.textContent = t('thisWeek');
|
||||
btnThisWeek.title = t('thisWeekTooltip');
|
||||
}
|
||||
if (btnNextWeek) {
|
||||
btnNextWeek.textContent = t('nextWeek');
|
||||
// Tooltip will be re-set by updateNextWeekBadge()
|
||||
}
|
||||
|
||||
// Header title
|
||||
const appTitle = document.querySelector('.header-left h1');
|
||||
if (appTitle) {
|
||||
const versionTag = appTitle.querySelector('.version-tag');
|
||||
const updateIcon = appTitle.querySelector('.update-icon');
|
||||
appTitle.textContent = t('appTitle') + ' ';
|
||||
if (versionTag) appTitle.appendChild(versionTag);
|
||||
if (updateIcon) appTitle.appendChild(updateIcon);
|
||||
}
|
||||
|
||||
// Action button tooltips
|
||||
const btnRefresh = document.getElementById('btn-refresh');
|
||||
if (btnRefresh) btnRefresh.setAttribute('aria-label', t('refresh'));
|
||||
if (btnRefresh) btnRefresh.title = t('refresh');
|
||||
|
||||
const btnHistory = document.getElementById('btn-history');
|
||||
if (btnHistory) btnHistory.setAttribute('aria-label', t('history'));
|
||||
if (btnHistory) btnHistory.title = t('history');
|
||||
|
||||
const btnHighlights = document.getElementById('btn-highlights');
|
||||
if (btnHighlights) btnHighlights.setAttribute('aria-label', t('highlights'));
|
||||
if (btnHighlights) btnHighlights.title = t('highlights');
|
||||
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) themeToggle.title = t('themeTooltip');
|
||||
|
||||
// Login/Logout
|
||||
const btnLoginOpen = document.getElementById('btn-login-open');
|
||||
if (btnLoginOpen) {
|
||||
btnLoginOpen.title = t('loginTooltip');
|
||||
const loginText = btnLoginOpen.querySelector('span:last-child');
|
||||
if (loginText && !loginText.classList.contains('material-icons-round')) {
|
||||
loginText.textContent = t('login');
|
||||
}
|
||||
}
|
||||
|
||||
const btnLogout = document.getElementById('btn-logout');
|
||||
if (btnLogout) btnLogout.title = t('logoutTooltip');
|
||||
|
||||
// Language toggle tooltip
|
||||
const langToggle = document.getElementById('lang-toggle');
|
||||
if (langToggle) langToggle.title = t('langTooltip');
|
||||
|
||||
// Modal headers
|
||||
const highlightsHeader = document.querySelector('#highlights-modal .modal-header h2');
|
||||
if (highlightsHeader) highlightsHeader.textContent = t('highlightsTitle');
|
||||
const highlightsDesc = document.querySelector('#highlights-modal .modal-body > p');
|
||||
if (highlightsDesc) highlightsDesc.textContent = t('highlightsDesc');
|
||||
const tagInput = document.getElementById('tag-input');
|
||||
if (tagInput) {
|
||||
tagInput.placeholder = t('tagInputPlaceholder');
|
||||
tagInput.title = t('tagInputTooltip');
|
||||
}
|
||||
const btnAddTag = document.getElementById('btn-add-tag');
|
||||
if (btnAddTag) {
|
||||
btnAddTag.textContent = t('addTag');
|
||||
btnAddTag.title = t('addTagTooltip');
|
||||
}
|
||||
|
||||
const historyHeader = document.querySelector('#history-modal .modal-header h2');
|
||||
if (historyHeader) historyHeader.textContent = t('historyTitle');
|
||||
|
||||
const loginHeader = document.querySelector('#login-modal .modal-header h2');
|
||||
if (loginHeader) loginHeader.textContent = t('loginTitle');
|
||||
|
||||
// Alarm bell
|
||||
const alarmBell = document.getElementById('alarm-bell');
|
||||
if (alarmBell && userFlags.size === 0) {
|
||||
alarmBell.title = t('alarmTooltipNone');
|
||||
}
|
||||
|
||||
// Re-render dynamic parts that may use t()
|
||||
renderVisibleWeeks();
|
||||
updateNextWeekBadge();
|
||||
updateAlarmBell();
|
||||
}
|
||||
|
||||
export function bindEvents() {
|
||||
const btnThisWeek = document.getElementById('btn-this-week');
|
||||
@@ -25,18 +121,29 @@ export function bindEvents() {
|
||||
const historyModal = document.getElementById('history-modal');
|
||||
const btnHistoryClose = document.getElementById('btn-history-close');
|
||||
|
||||
const btnLangToggle = document.getElementById('btn-lang-toggle');
|
||||
const langDropdown = document.getElementById('lang-dropdown');
|
||||
if (btnLangToggle && langDropdown) {
|
||||
btnLangToggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
langDropdown.classList.toggle('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.lang-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
setLangMode(btn.dataset.lang);
|
||||
localStorage.setItem('kantine_lang', btn.dataset.lang);
|
||||
localStorage.setItem(LS.LANG, btn.dataset.lang);
|
||||
document.querySelectorAll('.lang-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
renderVisibleWeeks();
|
||||
if (langDropdown) langDropdown.classList.add('hidden');
|
||||
updateUILanguage();
|
||||
});
|
||||
});
|
||||
|
||||
if (btnHighlights) {
|
||||
btnHighlights.addEventListener('click', () => {
|
||||
renderTagsList();
|
||||
highlightsModal.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
@@ -63,6 +170,9 @@ export function bindEvents() {
|
||||
window.addEventListener('click', (e) => {
|
||||
if (e.target === historyModal) historyModal.classList.add('hidden');
|
||||
if (e.target === highlightsModal) highlightsModal.classList.add('hidden');
|
||||
if (langDropdown && !langDropdown.classList.contains('hidden') && !e.target.closest('#lang-toggle')) {
|
||||
langDropdown.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
const versionTag = document.querySelector('.version-tag');
|
||||
@@ -198,7 +308,7 @@ export function bindEvents() {
|
||||
const email = `knapp-${employeeId}@bessa.app`;
|
||||
const response = await fetch(`${API_BASE}/auth/login/`, {
|
||||
method: 'POST',
|
||||
headers: apiHeaders(GUEST_TOKEN),
|
||||
headers: apiHeaders(),
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
@@ -207,8 +317,8 @@ export function bindEvents() {
|
||||
if (response.ok) {
|
||||
setAuthToken(data.key);
|
||||
setCurrentUser(employeeId);
|
||||
localStorage.setItem('kantine_authToken', data.key);
|
||||
localStorage.setItem('kantine_currentUser', employeeId);
|
||||
localStorage.setItem(LS.AUTH_TOKEN, data.key);
|
||||
localStorage.setItem(LS.CURRENT_USER, employeeId);
|
||||
|
||||
try {
|
||||
const userResp = await fetch(`${API_BASE}/auth/user/`, {
|
||||
@@ -216,8 +326,8 @@ export function bindEvents() {
|
||||
});
|
||||
if (userResp.ok) {
|
||||
const userData = await userResp.json();
|
||||
if (userData.first_name) localStorage.setItem('kantine_firstName', userData.first_name);
|
||||
if (userData.last_name) localStorage.setItem('kantine_lastName', userData.last_name);
|
||||
if (userData.first_name) localStorage.setItem(LS.FIRST_NAME, userData.first_name);
|
||||
if (userData.last_name) localStorage.setItem(LS.LAST_NAME, userData.last_name);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user info:', err);
|
||||
@@ -244,10 +354,13 @@ export function bindEvents() {
|
||||
});
|
||||
|
||||
btnLogout.addEventListener('click', () => {
|
||||
localStorage.removeItem('kantine_authToken');
|
||||
localStorage.removeItem('kantine_currentUser');
|
||||
localStorage.removeItem('kantine_firstName');
|
||||
localStorage.removeItem('kantine_lastName');
|
||||
// Secure Logout (FR-006): Clear all application-related data from localStorage
|
||||
Object.keys(localStorage).forEach(key => {
|
||||
if (key.startsWith('kantine_')) {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
});
|
||||
|
||||
setAuthToken(null);
|
||||
setCurrentUser(null);
|
||||
setOrderMap(new Map());
|
||||
@@ -255,4 +368,10 @@ export function bindEvents() {
|
||||
updateAuthUI();
|
||||
renderVisibleWeeks();
|
||||
});
|
||||
|
||||
// Sync heights on window resize (FR-Performance)
|
||||
window.addEventListener('resize', debounce(() => {
|
||||
const grid = document.querySelector('.days-grid');
|
||||
if (grid) syncMenuItemHeights(grid);
|
||||
}, 150));
|
||||
}
|
||||
|
||||
300
src/i18n.js
Normal file
300
src/i18n.js
Normal file
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Internationalization (i18n) module for the Kantine Wrapper UI.
|
||||
* Provides translations for all static UI text based on the current language mode.
|
||||
* German (de) is the default; English (en) is fully supported.
|
||||
* When langMode is 'all', German labels are used for the GUI.
|
||||
*/
|
||||
import { langMode } from './state.js';
|
||||
|
||||
const TRANSLATIONS = {
|
||||
de: {
|
||||
// Navigation
|
||||
thisWeek: 'Diese Woche',
|
||||
nextWeek: 'Nächste Woche',
|
||||
nextWeekTooltipDefault: 'Menü nächster Woche anzeigen',
|
||||
thisWeekTooltip: 'Menü dieser Woche anzeigen',
|
||||
|
||||
// Header
|
||||
appTitle: 'Kantinen Übersicht',
|
||||
updatedAt: 'Aktualisiert',
|
||||
langTooltip: 'Sprache der Menübeschreibung',
|
||||
weekLabel: 'Woche',
|
||||
|
||||
// Action buttons
|
||||
refresh: 'Menüdaten neu laden',
|
||||
history: 'Bestellhistorie',
|
||||
highlights: 'Persönliche Highlights verwalten',
|
||||
themeTooltip: 'Erscheinungsbild (Hell/Dunkel) wechseln',
|
||||
login: 'Anmelden',
|
||||
loginTooltip: 'Mit Bessa.app Account anmelden',
|
||||
logout: 'Abmelden',
|
||||
logoutTooltip: 'Von Bessa.app abmelden',
|
||||
|
||||
// Login modal
|
||||
loginTitle: 'Login',
|
||||
employeeId: 'Mitarbeiternummer',
|
||||
employeeIdPlaceholder: 'z.B. 2041',
|
||||
employeeIdHelp: 'Deine offizielle Knapp Mitarbeiternummer.',
|
||||
password: 'Passwort',
|
||||
passwordPlaceholder: 'Bessa Passwort',
|
||||
passwordHelp: 'Das Passwort für deinen Bessa Account.',
|
||||
loginButton: 'Einloggen',
|
||||
loggingIn: 'Wird eingeloggt...',
|
||||
|
||||
// Highlights modal
|
||||
highlightsTitle: 'Meine Highlights',
|
||||
highlightsDesc: 'Markiere Menüs automatisch, wenn sie diese Schlagwörter enthalten.',
|
||||
tagInputPlaceholder: 'z.B. Schnitzel, Vegetarisch...',
|
||||
tagInputTooltip: 'Neues Schlagwort zum Hervorheben eingeben',
|
||||
addTag: 'Hinzufügen',
|
||||
addTagTooltip: 'Schlagwort zur Liste hinzufügen',
|
||||
removeTagTooltip: 'Schlagwort entfernen',
|
||||
|
||||
// History modal
|
||||
historyTitle: 'Bestellhistorie',
|
||||
loadingHistory: 'Lade Historie...',
|
||||
noOrders: 'Keine Bestellungen gefunden.',
|
||||
orders: 'Bestellungen',
|
||||
historyMonthToggle: 'Klicken, um die Bestellungen für diesen Monat ein-/auszublenden',
|
||||
|
||||
// Menu item labels
|
||||
available: 'Verfügbar',
|
||||
soldOut: 'Ausverkauft',
|
||||
ordered: 'Bestellt',
|
||||
orderButton: 'Bestellen',
|
||||
orderAgainTooltip: 'nochmal bestellen',
|
||||
orderTooltip: 'bestellen',
|
||||
cancelOrder: 'Bestellung stornieren',
|
||||
cancelOneOrder: 'Eine Bestellung stornieren',
|
||||
flagActivate: 'Benachrichtigen wenn verfügbar',
|
||||
flagDeactivate: 'Benachrichtigung deaktivieren',
|
||||
|
||||
// Alarm bell
|
||||
alarmTooltipNone: 'Keine beobachteten Menüs',
|
||||
alarmLastChecked: 'Zuletzt geprüft',
|
||||
|
||||
// Version modal
|
||||
versionsTitle: '📦 Versionen',
|
||||
currentVersion: 'Aktuell',
|
||||
devModeLabel: 'Dev-Mode (alle Tags anzeigen)',
|
||||
loadingVersions: 'Lade Versionen...',
|
||||
noVersions: 'Keine Versionen gefunden.',
|
||||
installed: '✓ Installiert',
|
||||
newVersion: '⬆ Neu!',
|
||||
installLink: 'Installieren',
|
||||
reportBug: 'Fehler melden',
|
||||
reportBugTooltip: 'Melde einen Fehler auf GitHub',
|
||||
featureRequest: 'Feature vorschlagen',
|
||||
featureRequestTooltip: 'Schlage ein neues Feature auf GitHub vor',
|
||||
clearCache: 'Lokalen Cache leeren',
|
||||
clearCacheTooltip: 'Löscht alle lokalen Daten & erzwingt einen Neuladen',
|
||||
clearCacheConfirm: 'Möchtest du wirklich alle lokalen Daten (inkl. Login-Session, Cache und Einstellungen) löschen? Die Seite wird danach neu geladen.',
|
||||
versionMenuTooltip: 'Klick für Versionsmenü',
|
||||
|
||||
// Progress modal
|
||||
progressTitle: 'Menüdaten aktualisieren',
|
||||
progressInit: 'Initialisierung...',
|
||||
|
||||
// Empty state
|
||||
noMenuData: 'Keine Menüdaten für KW',
|
||||
noMenuDataHint: 'Versuchen Sie eine andere Woche oder schauen Sie später vorbei.',
|
||||
|
||||
// Weekly cost
|
||||
|
||||
// Countdown
|
||||
orderDeadline: 'Bestellschluss',
|
||||
|
||||
// Toast messages
|
||||
flagRemoved: 'Flag entfernt für',
|
||||
flagActivated: 'Benachrichtigung aktiviert für',
|
||||
menuChecked: 'geprüft',
|
||||
menuSingular: 'Menü',
|
||||
menuPlural: 'Menüs',
|
||||
newMenuDataAvailable: 'Neue Menüdaten für nächste Woche verfügbar!',
|
||||
orderSuccess: 'Bestellt',
|
||||
cancelSuccess: 'Storniert',
|
||||
bgSyncFailed: 'Hintergrund-Synchronisation fehlgeschlagen',
|
||||
historyLoadError: 'Fehler beim Laden der Historie.',
|
||||
historyLoadingFull: 'Lade Bestellhistorie...',
|
||||
historyLoadingDelta: 'Suche nach neuen Bestellungen...',
|
||||
historyLoadingItem: 'Lade Bestellung',
|
||||
historyLoadingOf: 'von',
|
||||
historyLoadingNew: 'neue/geänderte Bestellungen gefunden...',
|
||||
|
||||
// Badge tooltip parts
|
||||
badgeOrdered: 'bestellt',
|
||||
badgeOrderable: 'bestellbar',
|
||||
badgeTotal: 'gesamt',
|
||||
badgeHighlights: 'Highlights gefunden',
|
||||
|
||||
// History item states
|
||||
stateCancelled: 'Storniert',
|
||||
stateCompleted: 'Abgeschlossen',
|
||||
stateTransferred: 'Übertragen',
|
||||
|
||||
// Close button
|
||||
close: 'Schließen',
|
||||
|
||||
// Error modal
|
||||
noConnection: 'Keine Verbindung',
|
||||
toOriginalPage: 'Zur Original-Seite',
|
||||
|
||||
// Misc
|
||||
loggedIn: 'Angemeldet',
|
||||
},
|
||||
en: {
|
||||
// Navigation
|
||||
thisWeek: 'This Week',
|
||||
nextWeek: 'Next Week',
|
||||
nextWeekTooltipDefault: 'Show next week\'s menu',
|
||||
thisWeekTooltip: 'Show this week\'s menu',
|
||||
|
||||
// Header
|
||||
appTitle: 'Canteen Overview',
|
||||
updatedAt: 'Updated',
|
||||
langTooltip: 'Menu description language',
|
||||
weekLabel: 'Week',
|
||||
|
||||
// Action buttons
|
||||
refresh: 'Reload menu data',
|
||||
history: 'Order history',
|
||||
highlights: 'Manage personal highlights',
|
||||
themeTooltip: 'Toggle appearance (Light/Dark)',
|
||||
login: 'Sign in',
|
||||
loginTooltip: 'Sign in with Bessa.app account',
|
||||
logout: 'Sign out',
|
||||
logoutTooltip: 'Sign out from Bessa.app',
|
||||
|
||||
// Login modal
|
||||
loginTitle: 'Login',
|
||||
employeeId: 'Employee ID',
|
||||
employeeIdPlaceholder: 'e.g. 2041',
|
||||
employeeIdHelp: 'Your official Knapp employee number.',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: 'Bessa password',
|
||||
passwordHelp: 'The password for your Bessa account.',
|
||||
loginButton: 'Log in',
|
||||
loggingIn: 'Logging in...',
|
||||
|
||||
// Highlights modal
|
||||
highlightsTitle: 'My Highlights',
|
||||
highlightsDesc: 'Automatically highlight menus containing these keywords.',
|
||||
tagInputPlaceholder: 'e.g. Schnitzel, Vegetarian...',
|
||||
tagInputTooltip: 'Enter new keyword to highlight',
|
||||
addTag: 'Add',
|
||||
addTagTooltip: 'Add keyword to list',
|
||||
removeTagTooltip: 'Remove keyword',
|
||||
|
||||
// History modal
|
||||
historyTitle: 'Order History',
|
||||
loadingHistory: 'Loading history...',
|
||||
noOrders: 'No orders found.',
|
||||
orders: 'Orders',
|
||||
historyMonthToggle: 'Click to expand/collapse orders for this month',
|
||||
|
||||
// Menu item labels
|
||||
available: 'Available',
|
||||
soldOut: 'Sold out',
|
||||
ordered: 'Ordered',
|
||||
orderButton: 'Order',
|
||||
orderAgainTooltip: 'order again',
|
||||
orderTooltip: 'order',
|
||||
cancelOrder: 'Cancel order',
|
||||
cancelOneOrder: 'Cancel one order',
|
||||
flagActivate: 'Notify when available',
|
||||
flagDeactivate: 'Deactivate notification',
|
||||
|
||||
// Alarm bell
|
||||
alarmTooltipNone: 'No flagged menus',
|
||||
alarmLastChecked: 'Last checked',
|
||||
|
||||
// Version modal
|
||||
versionsTitle: '📦 Versions',
|
||||
currentVersion: 'Current',
|
||||
devModeLabel: 'Dev mode (show all tags)',
|
||||
loadingVersions: 'Loading versions...',
|
||||
noVersions: 'No versions found.',
|
||||
installed: '✓ Installed',
|
||||
newVersion: '⬆ New!',
|
||||
installLink: 'Install',
|
||||
reportBug: 'Report a bug',
|
||||
reportBugTooltip: 'Report a bug on GitHub',
|
||||
featureRequest: 'Request a feature',
|
||||
featureRequestTooltip: 'Suggest a new feature on GitHub',
|
||||
clearCache: 'Clear local cache',
|
||||
clearCacheTooltip: 'Deletes all local data & forces a reload',
|
||||
clearCacheConfirm: 'Do you really want to delete all local data (including login session, cache, and settings)? The page will reload afterwards.',
|
||||
versionMenuTooltip: 'Click for version menu',
|
||||
|
||||
// Progress modal
|
||||
progressTitle: 'Updating menu data',
|
||||
progressInit: 'Initializing...',
|
||||
|
||||
// Empty state
|
||||
noMenuData: 'No menu data for CW',
|
||||
noMenuDataHint: 'Try another week or check back later.',
|
||||
|
||||
// Weekly cost
|
||||
|
||||
// Countdown
|
||||
orderDeadline: 'Order deadline',
|
||||
|
||||
// Toast messages
|
||||
flagRemoved: 'Flag removed for',
|
||||
flagActivated: 'Notification activated for',
|
||||
menuChecked: 'checked',
|
||||
menuSingular: 'menu',
|
||||
menuPlural: 'menus',
|
||||
newMenuDataAvailable: 'New menu data available for next week!',
|
||||
orderSuccess: 'Ordered',
|
||||
cancelSuccess: 'Cancelled',
|
||||
bgSyncFailed: 'Background synchronisation failed',
|
||||
historyLoadError: 'Error loading history.',
|
||||
historyLoadingFull: 'Loading order history...',
|
||||
historyLoadingDelta: 'Checking for new orders...',
|
||||
historyLoadingItem: 'Loading order',
|
||||
historyLoadingOf: 'of',
|
||||
historyLoadingNew: 'new/updated orders found...',
|
||||
|
||||
// Badge tooltip parts
|
||||
badgeOrdered: 'ordered',
|
||||
badgeOrderable: 'orderable',
|
||||
badgeTotal: 'total',
|
||||
badgeHighlights: 'highlights found',
|
||||
|
||||
// History item states
|
||||
stateCancelled: 'Cancelled',
|
||||
stateCompleted: 'Completed',
|
||||
stateTransferred: 'Transferred',
|
||||
|
||||
// Close button
|
||||
close: 'Close',
|
||||
|
||||
// Error modal
|
||||
noConnection: 'No connection',
|
||||
toOriginalPage: 'Go to original page',
|
||||
|
||||
// Misc
|
||||
loggedIn: 'Logged in',
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the translated string for the given key.
|
||||
* Uses the current langMode (en = English, anything else = German).
|
||||
* Falls back to German if a key is missing in the target language.
|
||||
* @param {string} key - Translation key
|
||||
* @returns {string} Translated text
|
||||
*/
|
||||
export function t(key) {
|
||||
const lang = langMode === 'en' ? 'en' : 'de';
|
||||
return TRANSLATIONS[lang][key] || TRANSLATIONS['de'][key] || key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective UI language code ('en' or 'de').
|
||||
* 'all' mode uses German for the GUI.
|
||||
*/
|
||||
export function getUILang() {
|
||||
return langMode === 'en' ? 'en' : 'de';
|
||||
}
|
||||
31
src/state.js
31
src/state.js
@@ -1,25 +1,42 @@
|
||||
import { getISOWeek } from './utils.js';
|
||||
import { LS } from './constants.js';
|
||||
|
||||
export let allWeeks = [];
|
||||
export let currentWeekNumber = getISOWeek(new Date());
|
||||
export let currentYear = new Date().getFullYear();
|
||||
export let displayMode = 'this-week';
|
||||
export let authToken = localStorage.getItem('kantine_authToken');
|
||||
export let currentUser = localStorage.getItem('kantine_currentUser');
|
||||
export let authToken = localStorage.getItem(LS.AUTH_TOKEN);
|
||||
export let currentUser = localStorage.getItem(LS.CURRENT_USER);
|
||||
export let orderMap = new Map();
|
||||
export let userFlags = new Set(JSON.parse(localStorage.getItem('kantine_flags') || '[]'));
|
||||
export let userFlags = new Set(JSON.parse(localStorage.getItem(LS.FLAGS) || '[]'));
|
||||
export let pollIntervalId = null;
|
||||
export let langMode = localStorage.getItem('kantine_lang') || 'de';
|
||||
export let highlightTags = JSON.parse(localStorage.getItem('kantine_highlightTags') || '[]');
|
||||
export let langMode = localStorage.getItem(LS.LANG) || 'de';
|
||||
export let highlightTags = JSON.parse(localStorage.getItem(LS.HIGHLIGHT_TAGS) || '[]');
|
||||
|
||||
export function setAllWeeks(weeks) { allWeeks = weeks; }
|
||||
export function setCurrentWeekNumber(week) { currentWeekNumber = week; }
|
||||
export function setCurrentYear(year) { currentYear = year; }
|
||||
export function setDisplayMode(mode) { displayMode = mode; }
|
||||
export function setAuthToken(token) { authToken = token; }
|
||||
export function setCurrentUser(user) { currentUser = user; }
|
||||
export function setOrderMap(map) { orderMap = map; }
|
||||
export function setUserFlags(flags) { userFlags = flags; }
|
||||
export function setPollIntervalId(id) { pollIntervalId = id; }
|
||||
export function setLangMode(lang) { langMode = lang; }
|
||||
export function setHighlightTags(tags) { highlightTags = tags; }
|
||||
|
||||
/** Only 'this-week' and 'next-week' are valid display modes. */
|
||||
export function setDisplayMode(mode) {
|
||||
if (mode !== 'this-week' && mode !== 'next-week') {
|
||||
console.warn(`[state] Invalid displayMode: "${mode}". Ignoring.`);
|
||||
return;
|
||||
}
|
||||
displayMode = mode;
|
||||
}
|
||||
|
||||
/** Only 'de', 'en', and 'all' are valid language modes. */
|
||||
export function setLangMode(lang) {
|
||||
if (!['de', 'en', 'all'].includes(lang)) {
|
||||
console.warn(`[state] Invalid langMode: "${lang}". Ignoring.`);
|
||||
return;
|
||||
}
|
||||
langMode = lang;
|
||||
}
|
||||
|
||||
26
src/ui.js
26
src/ui.js
@@ -1,5 +1,15 @@
|
||||
/**
|
||||
* UI injection module.
|
||||
* Renders the full Kantine Wrapper HTML skeleton into the current page,
|
||||
* including fonts, icon stylesheet, favicon, and all modal/panel containers.
|
||||
* Must be called before bindEvents() and any state-rendering logic.
|
||||
*/
|
||||
import { langMode } from './state.js';
|
||||
|
||||
/**
|
||||
* Injects the full application HTML into the current tab.
|
||||
* Idempotent in conjunction with the __KANTINE_LOADED guard in index.js.
|
||||
*/
|
||||
export function injectUI() {
|
||||
document.title = 'Kantine Weekly Menu';
|
||||
|
||||
@@ -44,13 +54,7 @@ export function injectUI() {
|
||||
</button>
|
||||
</div>
|
||||
<div class="header-center-wrapper">
|
||||
<div id="lang-toggle" class="lang-toggle" title="Sprache der Menübeschreibung">
|
||||
<button class="lang-btn${langMode === 'de' ? ' active' : ''}" data-lang="de">DE</button>
|
||||
<button class="lang-btn${langMode === 'en' ? ' active' : ''}" data-lang="en">EN</button>
|
||||
<button class="lang-btn${langMode === 'all' ? ' active' : ''}" data-lang="all">ALL</button>
|
||||
</div>
|
||||
<div id="header-week-info" class="header-week-info"></div>
|
||||
<div id="weekly-cost-display" class="weekly-cost hidden"></div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button id="btn-refresh" class="icon-btn" aria-label="Menüdaten aktualisieren" title="Menüdaten neu laden">
|
||||
@@ -65,6 +69,16 @@ export function injectUI() {
|
||||
<button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme" title="Erscheinungsbild (Hell/Dunkel) wechseln">
|
||||
<span class="material-icons-round theme-icon">light_mode</span>
|
||||
</button>
|
||||
<div id="lang-toggle" class="lang-toggle-dropdown" title="Sprache der Menübeschreibung">
|
||||
<button id="btn-lang-toggle" class="icon-btn" aria-label="Sprache wählen" title="Sprache der Menübeschreibung">
|
||||
<span class="material-icons-round">translate</span>
|
||||
</button>
|
||||
<div id="lang-dropdown" class="lang-dropdown-menu hidden">
|
||||
<button class="lang-btn${langMode === 'de' ? ' active' : ''}" data-lang="de">🇦🇹 DE</button>
|
||||
<button class="lang-btn${langMode === 'en' ? ' active' : ''}" data-lang="en">🇬🇧 EN</button>
|
||||
<button class="lang-btn${langMode === 'all' ? ' active' : ''}" data-lang="all">🌐 ALL</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-login-open" class="user-badge-btn icon-btn-small" title="Mit Bessa.app Account anmelden">
|
||||
<span class="material-icons-round">login</span>
|
||||
<span>Anmelden</span>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { authToken, currentUser, orderMap, userFlags, pollIntervalId, highlightTags, allWeeks, currentWeekNumber, currentYear, displayMode, langMode, setAuthToken, setCurrentUser, setOrderMap, setUserFlags, setPollIntervalId, setHighlightTags, setAllWeeks, setCurrentWeekNumber, setCurrentYear } from './state.js';
|
||||
import { getISOWeek, getWeekYear, translateDay, escapeHtml, getRelativeTime, isNewer, getLocalizedText } from './utils.js';
|
||||
import { API_BASE, GUEST_TOKEN, VENUE_ID, MENU_ID, POLL_INTERVAL_MS, GITHUB_API, INSTALLER_BASE, CLIENT_VERSION } from './constants.js';
|
||||
import { API_BASE, VENUE_ID, MENU_ID, POLL_INTERVAL_MS, GITHUB_API, INSTALLER_BASE, CLIENT_VERSION, LS } from './constants.js';
|
||||
import { apiHeaders, githubHeaders } from './api.js';
|
||||
import { placeOrder, cancelOrder, toggleFlag, showToast, checkHighlight, loadMenuDataFromAPI } from './actions.js';
|
||||
import { t } from './i18n.js';
|
||||
|
||||
/**
|
||||
* Updates the "Next Week" button tooltip and glow state.
|
||||
* Tooltip shows order status summary and highlight count.
|
||||
* Glow activates only if Mon-Thu have orderable menus without orders (Friday exempt).
|
||||
*/
|
||||
export function updateNextWeekBadge() {
|
||||
const btnNextWeek = document.getElementById('btn-next-week');
|
||||
let nextWeek = currentWeekNumber + 1;
|
||||
@@ -14,7 +20,7 @@ export function updateNextWeekBadge() {
|
||||
let totalDataCount = 0;
|
||||
let orderableCount = 0;
|
||||
let daysWithOrders = 0;
|
||||
let daysWithOrderableAndNoOrder = 0;
|
||||
let monThuOrderableNoOrder = 0;
|
||||
|
||||
if (nextWeekData && nextWeekData.days) {
|
||||
nextWeekData.days.forEach(day => {
|
||||
@@ -31,34 +37,22 @@ export function updateNextWeekBadge() {
|
||||
});
|
||||
|
||||
if (hasOrder) daysWithOrders++;
|
||||
if (isOrderable && !hasOrder) daysWithOrderableAndNoOrder++;
|
||||
|
||||
// Feature 5: Only Mon(1)-Thu(4) count for glow logic, Friday(5) is exempt
|
||||
const dayOfWeek = new Date(day.date).getDay();
|
||||
if (dayOfWeek >= 1 && dayOfWeek <= 4 && isOrderable && !hasOrder) {
|
||||
monThuOrderableNoOrder++;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let badge = btnNextWeek.querySelector('.nav-badge');
|
||||
// Remove any old visible badge element (Feature 3: numbers hidden)
|
||||
const existingBadge = btnNextWeek.querySelector('.nav-badge');
|
||||
if (existingBadge) existingBadge.remove();
|
||||
|
||||
if (totalDataCount > 0) {
|
||||
if (!badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = 'nav-badge';
|
||||
btnNextWeek.appendChild(badge);
|
||||
}
|
||||
|
||||
badge.title = `${daysWithOrders} bestellt / ${orderableCount} bestellbar / ${totalDataCount} gesamt`;
|
||||
badge.innerHTML = `<span class="ordered">${daysWithOrders}</span><span class="separator">/</span><span class="orderable">${orderableCount}</span><span class="separator">/</span><span class="total">${totalDataCount}</span>`;
|
||||
|
||||
badge.classList.remove('badge-violet', 'badge-green', 'badge-red', 'badge-blue');
|
||||
|
||||
if (daysWithOrders > 0 && daysWithOrderableAndNoOrder === 0) {
|
||||
badge.classList.add('badge-violet');
|
||||
} else if (daysWithOrderableAndNoOrder > 0) {
|
||||
badge.classList.add('badge-green');
|
||||
} else if (orderableCount === 0) {
|
||||
badge.classList.add('badge-red');
|
||||
} else {
|
||||
badge.classList.add('badge-blue');
|
||||
}
|
||||
|
||||
// Count highlight menus in next week
|
||||
let highlightCount = 0;
|
||||
if (nextWeekData && nextWeekData.days) {
|
||||
nextWeekData.days.forEach(day => {
|
||||
@@ -72,52 +66,31 @@ export function updateNextWeekBadge() {
|
||||
});
|
||||
}
|
||||
|
||||
// Feature 3: All info goes to button tooltip instead of visible badge
|
||||
let tooltipParts = [`${daysWithOrders} ${t('badgeOrdered')} / ${orderableCount} ${t('badgeOrderable')} / ${totalDataCount} ${t('badgeTotal')}`];
|
||||
if (highlightCount > 0) {
|
||||
badge.insertAdjacentHTML('beforeend', `<span class="highlight-count" title="${highlightCount} Highlight Menüs">(${highlightCount})</span>`);
|
||||
badge.title += ` • ${highlightCount} Highlights gefunden`;
|
||||
badge.classList.add('has-highlights');
|
||||
tooltipParts.push(`${highlightCount} ${t('badgeHighlights')}`);
|
||||
}
|
||||
btnNextWeek.title = tooltipParts.join(' • ');
|
||||
|
||||
if (daysWithOrders === 0) {
|
||||
// Feature 5: Glow only if Mon-Thu have orderable days without existing orders
|
||||
if (monThuOrderableNoOrder > 0) {
|
||||
btnNextWeek.classList.add('new-week-available');
|
||||
const storageKey = `kantine_notified_nextweek_${nextYear}_${nextWeek}`;
|
||||
if (!localStorage.getItem(storageKey)) {
|
||||
localStorage.setItem(storageKey, 'true');
|
||||
showToast('Neue Menüdaten für nächste Woche verfügbar!', 'info');
|
||||
showToast(t('newMenuDataAvailable'), 'info');
|
||||
}
|
||||
} else {
|
||||
btnNextWeek.classList.remove('new-week-available');
|
||||
}
|
||||
|
||||
} else if (badge) {
|
||||
badge.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export function updateWeeklyCost(days) {
|
||||
let totalCost = 0;
|
||||
if (days && days.length > 0) {
|
||||
days.forEach(day => {
|
||||
if (day.items) {
|
||||
day.items.forEach(item => {
|
||||
const articleId = item.articleId || parseInt(item.id.split('_')[1]);
|
||||
const key = `${day.date}_${articleId}`;
|
||||
const orders = orderMap.get(key) || [];
|
||||
if (orders.length > 0) totalCost += item.price * orders.length;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const costDisplay = document.getElementById('weekly-cost-display');
|
||||
if (totalCost > 0) {
|
||||
costDisplay.innerHTML = `<span class="material-icons-round">shopping_bag</span> <span>Gesamt: ${totalCost.toFixed(2).replace('.', ',')} €</span>`;
|
||||
costDisplay.classList.remove('hidden');
|
||||
} else {
|
||||
costDisplay.classList.add('hidden');
|
||||
btnNextWeek.title = t('nextWeekTooltipDefault');
|
||||
btnNextWeek.classList.remove('new-week-available');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function renderVisibleWeeks() {
|
||||
const menuContainer = document.getElementById('menu-container');
|
||||
if (!menuContainer) return;
|
||||
@@ -140,20 +113,18 @@ export function renderVisibleWeeks() {
|
||||
if (daysInTargetWeek.length === 0) {
|
||||
menuContainer.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<p>Keine Menüdaten für KW ${targetWeek} (${targetYear}) verfügbar.</p>
|
||||
<small>Versuchen Sie eine andere Woche oder schauen Sie später vorbei.</small>
|
||||
<p>${t('noMenuData')} ${targetWeek} (${targetYear}).</p>
|
||||
<small>${t('noMenuDataHint')}</small>
|
||||
</div>`;
|
||||
document.getElementById('weekly-cost-display').classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
updateWeeklyCost(daysInTargetWeek);
|
||||
|
||||
const headerWeekInfo = document.getElementById('header-week-info');
|
||||
const weekTitle = displayMode === 'this-week' ? 'Diese Woche' : 'Nächste Woche';
|
||||
const weekTitle = displayMode === 'this-week' ? t('thisWeek') : t('nextWeek');
|
||||
headerWeekInfo.innerHTML = `
|
||||
<div class="header-week-title">${weekTitle}</div>
|
||||
<div class="header-week-subtitle">Week ${targetWeek} • ${targetYear}</div>`;
|
||||
<div class="header-week-subtitle">${t('weekLabel')} ${targetWeek} • ${targetYear}</div>`;
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'days-grid';
|
||||
@@ -178,23 +149,39 @@ export function renderVisibleWeeks() {
|
||||
export function syncMenuItemHeights(grid) {
|
||||
const cards = grid.querySelectorAll('.menu-card');
|
||||
if (cards.length === 0) return;
|
||||
|
||||
// 1. Gather all menu-item groups (rows) across cards
|
||||
const itemRows = [];
|
||||
let maxItems = 0;
|
||||
cards.forEach(card => {
|
||||
maxItems = Math.max(maxItems, card.querySelectorAll('.menu-item').length);
|
||||
|
||||
const cardItems = Array.from(cards).map(card => {
|
||||
const items = Array.from(card.querySelectorAll('.menu-item'));
|
||||
maxItems = Math.max(maxItems, items.length);
|
||||
return items;
|
||||
});
|
||||
|
||||
for (let i = 0; i < maxItems; i++) {
|
||||
let maxHeight = 0;
|
||||
const itemsAtPos = [];
|
||||
cards.forEach(card => {
|
||||
const items = card.querySelectorAll('.menu-item');
|
||||
if (items[i]) {
|
||||
items[i].style.height = 'auto';
|
||||
maxHeight = Math.max(maxHeight, items[i].offsetHeight);
|
||||
itemsAtPos.push(items[i]);
|
||||
}
|
||||
});
|
||||
itemsAtPos.forEach(item => { item.style.height = `${maxHeight}px`; });
|
||||
// Collect i-th item from each card (forming a "row")
|
||||
itemRows[i] = cardItems.map(items => items[i]).filter(item => !!item);
|
||||
}
|
||||
|
||||
// 2. Batch Reset (Write phase) - clear old heights to let them flow naturally
|
||||
itemRows.flat().forEach(item => {
|
||||
item.style.height = 'auto';
|
||||
});
|
||||
|
||||
// 3. Batch Read (Read phase) - measure all heights in one pass to avoid layout thrashing
|
||||
const rowMaxHeights = itemRows.map(row => {
|
||||
return Math.max(...row.map(item => item.offsetHeight));
|
||||
});
|
||||
|
||||
// 4. Batch Apply (Write phase) - set synchronized heights
|
||||
itemRows.forEach((row, i) => {
|
||||
const height = `${rowMaxHeights[i]}px`;
|
||||
row.forEach(item => {
|
||||
item.style.height = height;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createDayCard(day) {
|
||||
@@ -302,16 +289,16 @@ export function createDayCard(day) {
|
||||
let statusBadge = '';
|
||||
if (item.available) {
|
||||
statusBadge = item.amountTracking
|
||||
? `<span class="badge available">Verfügbar (${item.availableAmount})</span>`
|
||||
: `<span class="badge available">Verfügbar</span>`;
|
||||
? `<span class="badge available">${t('available')} (${item.availableAmount})</span>`
|
||||
: `<span class="badge available">${t('available')}</span>`;
|
||||
} else {
|
||||
statusBadge = `<span class="badge sold-out">Ausverkauft</span>`;
|
||||
statusBadge = `<span class="badge sold-out">${t('soldOut')}</span>`;
|
||||
}
|
||||
|
||||
let orderedBadge = '';
|
||||
if (orderCount > 0) {
|
||||
const countBadge = orderCount > 1 ? `<span class="order-count-badge">${orderCount}</span>` : '';
|
||||
orderedBadge = `<span class="badge ordered"><span class="material-icons-round">check_circle</span> Bestellt${countBadge}</span>`;
|
||||
orderedBadge = `<span class="badge ordered"><span class="material-icons-round">check_circle</span> ${t('ordered')}${countBadge}</span>`;
|
||||
itemEl.classList.add('ordered');
|
||||
if (new Date(day.date).toDateString() === now.toDateString()) {
|
||||
itemEl.classList.add('today-ordered');
|
||||
@@ -336,22 +323,22 @@ export function createDayCard(day) {
|
||||
if (authToken && !isPastCutoff) {
|
||||
const flagIcon = isFlagged ? 'notifications_active' : 'notifications_none';
|
||||
const flagClass = isFlagged ? 'btn-flag active' : 'btn-flag';
|
||||
const flagTitle = isFlagged ? 'Benachrichtigung deaktivieren' : 'Benachrichtigen wenn verfügbar';
|
||||
const flagTitle = isFlagged ? t('flagDeactivate') : t('flagActivate');
|
||||
if (!item.available || isFlagged) {
|
||||
flagButton = `<button class="${flagClass}" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" data-cutoff="${day.orderCutoff}" title="${flagTitle}"><span class="material-icons-round">${flagIcon}</span></button>`;
|
||||
}
|
||||
|
||||
if (item.available) {
|
||||
if (orderCount > 0) {
|
||||
orderButton = `<button class="btn-order btn-order-compact" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" data-price="${item.price}" data-desc="${escapeHtml(item.description || '')}" title="${escapeHtml(item.name)} nochmal bestellen"><span class="material-icons-round">add</span></button>`;
|
||||
orderButton = `<button class="btn-order btn-order-compact" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" data-price="${item.price}" data-desc="${escapeHtml(item.description || '')}" title="${escapeHtml(item.name)} – ${t('orderAgainTooltip')}"><span class="material-icons-round">add</span></button>`;
|
||||
} else {
|
||||
orderButton = `<button class="btn-order" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" data-price="${item.price}" data-desc="${escapeHtml(item.description || '')}" title="${escapeHtml(item.name)} bestellen"><span class="material-icons-round">add_shopping_cart</span> Bestellen</button>`;
|
||||
orderButton = `<button class="btn-order" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" data-price="${item.price}" data-desc="${escapeHtml(item.description || '')}" title="${escapeHtml(item.name)} – ${t('orderTooltip')}"><span class="material-icons-round">add_shopping_cart</span> ${t('orderButton')}</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (orderCount > 0) {
|
||||
const cancelIcon = orderCount === 1 ? 'close' : 'remove';
|
||||
const cancelTitle = orderCount === 1 ? 'Bestellung stornieren' : 'Eine Bestellung stornieren';
|
||||
const cancelTitle = orderCount === 1 ? t('cancelOrder') : t('cancelOneOrder');
|
||||
cancelButton = `<button class="btn-cancel" data-date="${day.date}" data-article="${articleId}" data-name="${escapeHtml(item.name)}" title="${cancelTitle}"><span class="material-icons-round">${cancelIcon}</span></button>`;
|
||||
}
|
||||
}
|
||||
@@ -443,13 +430,13 @@ export async function fetchVersions(devMode) {
|
||||
|
||||
export async function checkForUpdates() {
|
||||
const currentVersion = '{{VERSION}}';
|
||||
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
|
||||
const devMode = localStorage.getItem(LS.DEV_MODE) === 'true';
|
||||
|
||||
try {
|
||||
const versions = await fetchVersions(devMode);
|
||||
if (!versions.length) return;
|
||||
|
||||
localStorage.setItem('kantine_version_cache', JSON.stringify({
|
||||
localStorage.setItem(LS.VERSION_CACHE, JSON.stringify({
|
||||
timestamp: Date.now(), devMode, versions
|
||||
}));
|
||||
|
||||
@@ -485,7 +472,7 @@ export function openVersionMenu() {
|
||||
const cur = document.getElementById('version-current');
|
||||
if (cur) cur.textContent = currentVersion;
|
||||
|
||||
const devMode = localStorage.getItem('kantine_dev_mode') === 'true';
|
||||
const devMode = localStorage.getItem(LS.DEV_MODE) === 'true';
|
||||
devToggle.checked = devMode;
|
||||
|
||||
async function loadVersions(forceRefresh) {
|
||||
@@ -528,7 +515,7 @@ export function openVersionMenu() {
|
||||
}
|
||||
|
||||
try {
|
||||
const cachedRaw = localStorage.getItem('kantine_version_cache');
|
||||
const cachedRaw = localStorage.getItem(LS.VERSION_CACHE);
|
||||
let cached = null;
|
||||
if (cachedRaw) {
|
||||
try { cached = JSON.parse(cachedRaw); } catch (e) { }
|
||||
@@ -544,7 +531,7 @@ export function openVersionMenu() {
|
||||
const cachedVersionsStr = cached ? JSON.stringify(cached.versions) : '';
|
||||
|
||||
if (liveVersionsStr !== cachedVersionsStr) {
|
||||
localStorage.setItem('kantine_version_cache', JSON.stringify({
|
||||
localStorage.setItem(LS.VERSION_CACHE, JSON.stringify({
|
||||
timestamp: Date.now(), devMode: dm, versions: liveVersions
|
||||
}));
|
||||
renderVersionsList(liveVersions);
|
||||
@@ -558,8 +545,8 @@ export function openVersionMenu() {
|
||||
loadVersions(false);
|
||||
|
||||
devToggle.onchange = () => {
|
||||
localStorage.setItem('kantine_dev_mode', devToggle.checked);
|
||||
localStorage.removeItem('kantine_version_cache');
|
||||
localStorage.setItem(LS.DEV_MODE, devToggle.checked);
|
||||
localStorage.removeItem(LS.VERSION_CACHE);
|
||||
loadVersions(true);
|
||||
};
|
||||
}
|
||||
@@ -615,7 +602,7 @@ export function updateCountdown() {
|
||||
headerCenter.insertBefore(countdownEl, headerCenter.firstChild);
|
||||
}
|
||||
|
||||
countdownEl.innerHTML = `<span>Bestellschluss:</span> <strong>${diffHrs}h ${diffMins}m</strong>`;
|
||||
countdownEl.innerHTML = `<span>${t('orderDeadline')}:</span> <strong>${diffHrs}h ${diffMins}m</strong>`;
|
||||
|
||||
if (diff < 3600000) {
|
||||
countdownEl.classList.add('urgent');
|
||||
@@ -645,56 +632,79 @@ export function removeCountdown() {
|
||||
setInterval(updateCountdown, 60000);
|
||||
setTimeout(updateCountdown, 1000);
|
||||
|
||||
export function showErrorModal(title, htmlContent, btnText, url) {
|
||||
export function showErrorModal(title, message, details, btnText, url) {
|
||||
const modalId = 'error-modal';
|
||||
let modal = document.getElementById(modalId);
|
||||
if (modal) modal.remove();
|
||||
|
||||
modal = document.createElement('div');
|
||||
modal.id = modalId;
|
||||
modal.className = 'modal hidden';
|
||||
modal.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 style="color: var(--error-color); display: flex; align-items: center; gap: 10px;">
|
||||
<span class="material-icons-round">signal_wifi_off</span>
|
||||
${escapeHtml(title)}
|
||||
</h2>
|
||||
</div>
|
||||
<div style="padding: 20px;">
|
||||
<p style="margin-bottom: 15px; color: var(--text-primary);">${htmlContent}</p>
|
||||
<div style="margin-top: 20px; display: flex; justify-content: center;">
|
||||
<button id="btn-error-redirect" style="
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
transition: transform 0.1s;
|
||||
">
|
||||
${escapeHtml(btnText)}
|
||||
<span class="material-icons-round">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
modal.className = 'modal'; // Removed hidden because we are showing it now
|
||||
|
||||
const content = document.createElement('div');
|
||||
content.className = 'modal-content';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'modal-header';
|
||||
const h2 = document.createElement('h2');
|
||||
h2.style.cssText = 'color: var(--error-color); display: flex; align-items: center; gap: 10px;';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'material-icons-round';
|
||||
icon.textContent = 'signal_wifi_off';
|
||||
h2.appendChild(icon);
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.textContent = title;
|
||||
h2.appendChild(titleSpan);
|
||||
|
||||
header.appendChild(h2);
|
||||
content.appendChild(header);
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.style.padding = '20px';
|
||||
|
||||
const p = document.createElement('p');
|
||||
p.style.cssText = 'margin-bottom: 15px; color: var(--text-primary);';
|
||||
p.textContent = message;
|
||||
body.appendChild(p);
|
||||
|
||||
if (details) {
|
||||
const small = document.createElement('small');
|
||||
small.style.cssText = 'display: block; margin-top: 10px; color: var(--text-secondary);';
|
||||
small.textContent = details;
|
||||
body.appendChild(small);
|
||||
}
|
||||
|
||||
const footer = document.createElement('div');
|
||||
footer.style.cssText = 'margin-top: 20px; display: flex; justify-content: center;';
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.style.cssText = `
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 4px 12px rgba(233, 69, 96, 0.3);
|
||||
`;
|
||||
btn.textContent = btnText || 'Zur Original-Seite';
|
||||
btn.onclick = () => {
|
||||
window.open(url || 'https://web.bessa.app/knapp-kantine', '_blank');
|
||||
modal.classList.add('hidden');
|
||||
};
|
||||
|
||||
footer.appendChild(btn);
|
||||
body.appendChild(footer);
|
||||
content.appendChild(body);
|
||||
modal.appendChild(content);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
document.getElementById('btn-error-redirect').addEventListener('click', () => {
|
||||
window.location.href = url;
|
||||
});
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
modal.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAlarmBell() {
|
||||
@@ -729,8 +739,8 @@ export function updateAlarmBell() {
|
||||
if (anyAvailable) break;
|
||||
}
|
||||
|
||||
const lastCheckedStr = localStorage.getItem('kantine_last_checked');
|
||||
const flaggedLastCheckedStr = localStorage.getItem('kantine_flagged_items_last_checked');
|
||||
const lastCheckedStr = localStorage.getItem(LS.LAST_CHECKED);
|
||||
const flaggedLastCheckedStr = localStorage.getItem(LS.FLAGGED_LAST_CHECKED);
|
||||
|
||||
let latestTime = 0;
|
||||
if (lastCheckedStr) latestTime = Math.max(latestTime, new Date(lastCheckedStr).getTime());
|
||||
@@ -739,13 +749,13 @@ export function updateAlarmBell() {
|
||||
let timeStr = 'gerade eben';
|
||||
if (latestTime === 0) {
|
||||
const now = new Date().toISOString();
|
||||
localStorage.setItem('kantine_last_checked', now);
|
||||
localStorage.setItem(LS.LAST_CHECKED, now);
|
||||
latestTime = new Date(now).getTime();
|
||||
}
|
||||
|
||||
timeStr = getRelativeTime(new Date(latestTime));
|
||||
|
||||
bellBtn.title = `Zuletzt geprüft: ${timeStr}`;
|
||||
bellBtn.title = `${t('alarmLastChecked')}: ${timeStr}`;
|
||||
|
||||
if (anyAvailable) {
|
||||
bellIcon.style.color = '#10b981';
|
||||
|
||||
19
src/utils.js
19
src/utils.js
@@ -14,7 +14,14 @@ export function getWeekYear(d) {
|
||||
return date.getFullYear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates an English day name to the UI language.
|
||||
* Returns German by default; returns English when langMode is 'en'.
|
||||
* @param {string} englishDay - Day name in English (e.g. 'Monday')
|
||||
* @returns {string} Translated day name
|
||||
*/
|
||||
export function translateDay(englishDay) {
|
||||
if (langMode === 'en') return englishDay;
|
||||
const map = { Monday: 'Montag', Tuesday: 'Dienstag', Wednesday: 'Mittwoch', Thursday: 'Donnerstag', Friday: 'Freitag', Saturday: 'Samstag', Sunday: 'Sonntag' };
|
||||
return map[englishDay] || englishDay;
|
||||
}
|
||||
@@ -239,3 +246,15 @@ export function getLocalizedText(text) {
|
||||
if (langMode === 'en') return split.en || split.raw;
|
||||
return split.de || split.raw;
|
||||
}
|
||||
|
||||
export function debounce(func, wait) {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
}
|
||||
|
||||
203
style.css
203
style.css
@@ -6,8 +6,8 @@
|
||||
--text-primary: #334155;
|
||||
/* Slate 700 */
|
||||
--text-secondary: #64748b;
|
||||
--accent-color: #0f172a;
|
||||
/* Slate 900 (High contrast) */
|
||||
--accent-color: #2563eb;
|
||||
/* Blue 600 – visible accent, distinguishable from text */
|
||||
--border-color: #cbd5e1;
|
||||
/* Slate 300 */
|
||||
--banner-bg: #e2e8f0;
|
||||
@@ -15,7 +15,8 @@
|
||||
--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);
|
||||
/* Reduced opacity for visible glassmorphism blur effect */
|
||||
--header-bg: rgba(255, 255, 255, 0.72);
|
||||
--header-border: 1px solid rgba(203, 213, 225, 0.6);
|
||||
}
|
||||
|
||||
@@ -23,19 +24,20 @@
|
||||
/* Premium Slate/Gray-Blue Palette - Dark Mode */
|
||||
--bg-body: #1e293b;
|
||||
/* Deep Slate Gray (Requested) */
|
||||
--bg-card: #334155;
|
||||
/* Slate 700 */
|
||||
--bg-card: #283548;
|
||||
/* Darker than Slate 700 → more layer contrast vs bg-body */
|
||||
--text-primary: #f8fafc;
|
||||
/* Slate 50 */
|
||||
--text-secondary: #cbd5e1;
|
||||
/* Slate 300 */
|
||||
--accent-color: #60a5fa;
|
||||
/* Blue 400 */
|
||||
--border-color: #475569;
|
||||
/* Slate 600 */
|
||||
--border-color: #526377;
|
||||
/* Slightly lighter → visible border on darker card bg */
|
||||
--banner-bg: #475569;
|
||||
--banner-text: #e2e8f0;
|
||||
--header-bg: rgba(30, 41, 59, 0.9);
|
||||
/* Reduced opacity for visible glassmorphism blur effect */
|
||||
--header-bg: rgba(30, 41, 59, 0.72);
|
||||
--header-border: 1px solid rgba(71, 85, 105, 0.6);
|
||||
--card-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
@@ -404,23 +406,6 @@ body {
|
||||
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 - flex column, full width so child scrollbar is at edge */
|
||||
.container {
|
||||
@@ -470,6 +455,64 @@ body {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Language Toggle */
|
||||
.lang-toggle-dropdown {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#btn-lang-toggle {
|
||||
padding: 0;
|
||||
min-width: 42px;
|
||||
}
|
||||
|
||||
.lang-dropdown-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
background: var(--bg-card);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
z-index: 1001;
|
||||
min-width: 120px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
animation: modalSlide 0.2s ease-out;
|
||||
}
|
||||
|
||||
.lang-dropdown-menu .lang-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lang-dropdown-menu .lang-btn:hover {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.lang-dropdown-menu .lang-btn.active {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--accent-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.icon-btn-small {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -829,8 +872,8 @@ body {
|
||||
|
||||
.days-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
/* This is the scroll container at the window edge */
|
||||
@@ -883,7 +926,9 @@ body {
|
||||
box-shadow: 0 0 30px rgba(139, 92, 246, 0.6);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 0 -1rem 1.5rem -1rem;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
@@ -930,7 +975,8 @@ body {
|
||||
.card-body {
|
||||
padding: 1.25rem;
|
||||
display: grid;
|
||||
grid-template-rows: auto;
|
||||
grid-template-columns: 1fr;
|
||||
row-gap: 1.5rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
@@ -940,8 +986,11 @@ body {
|
||||
}
|
||||
|
||||
.day-date {
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.75;
|
||||
/* Visually subordinate to day-name */
|
||||
}
|
||||
|
||||
|
||||
@@ -954,9 +1003,12 @@ body {
|
||||
|
||||
/* Menu Items */
|
||||
.menu-item {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
/* Spacing now handled by .card-body grid gap */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Subtle separator between items */
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.menu-item:last-child {
|
||||
@@ -976,7 +1028,8 @@ body {
|
||||
.item-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-size: 1rem;
|
||||
font-size: 0.95rem;
|
||||
/* Slightly smaller to reduce visual competition with day header */
|
||||
}
|
||||
|
||||
.item-price {
|
||||
@@ -988,7 +1041,8 @@ body {
|
||||
.item-desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
line-height: 1.5;
|
||||
/* Consistent with body line-height */
|
||||
margin-bottom: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -997,6 +1051,7 @@ body {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.item-status-row {
|
||||
@@ -1004,6 +1059,7 @@ body {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -1013,7 +1069,8 @@ body {
|
||||
height: 24px;
|
||||
font-size: 0.75rem;
|
||||
padding: 0 10px;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
/* Unified radius matching buttons and tag-badge-small */
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
@@ -1107,6 +1164,11 @@ body {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-order:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.btn-order:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
@@ -1150,6 +1212,11 @@ body {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-cancel:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
.btn-cancel:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
@@ -1229,9 +1296,12 @@ body {
|
||||
}
|
||||
|
||||
/* === Mobile Responsiveness === */
|
||||
@media (max-width: 600px) {
|
||||
/* 768px covers tablets (e.g. iPad Mini); 600px was too narrow-only */
|
||||
@media (max-width: 768px) {
|
||||
.header-content {
|
||||
flex-direction: column;
|
||||
display: grid;
|
||||
/* Ensure grid is active, prevents flex-only fallback */
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
@@ -1254,8 +1324,9 @@ body {
|
||||
}
|
||||
|
||||
.days-grid {
|
||||
display: grid;
|
||||
/* Explicit grid declaration to prevent flex-context override */
|
||||
grid-template-columns: 1fr;
|
||||
/* Force single column */
|
||||
}
|
||||
|
||||
.main-content {
|
||||
@@ -1280,6 +1351,43 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Tighter layout for high column counts (e.g., 5-day landscape) */
|
||||
@media (min-width: 1024px) {
|
||||
.card-body {
|
||||
padding: 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Accessibility: Respect prefers-reduced-motion === */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* Disable all decorative pulse/glow animations */
|
||||
.menu-item.today-ordered,
|
||||
.menu-item.flagged-sold-out,
|
||||
.menu-item.flagged-available,
|
||||
.menu-item.highlight-glow,
|
||||
.nav-btn.new-week-available,
|
||||
.update-icon,
|
||||
#order-countdown.urgent {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Keep functional animations (modal slide, spinner) */
|
||||
.toast {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Focus Visibility (A11y: Keyboard Navigation) === */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* === Flagging & Notification Styles === */
|
||||
|
||||
.btn-flag {
|
||||
@@ -1311,6 +1419,10 @@ body {
|
||||
border-color: #eab308;
|
||||
}
|
||||
|
||||
.btn-flag:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn-flag .material-icons-round {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
@@ -1321,7 +1433,9 @@ body {
|
||||
box-shadow: 0 0 10px rgba(234, 179, 8, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 0 -1rem 1.5rem -1rem;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
@@ -1348,7 +1462,9 @@ body {
|
||||
box-shadow: 0 0 15px rgba(16, 185, 129, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 0 -1rem 1.5rem -1rem;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
@@ -1519,7 +1635,9 @@ body {
|
||||
box-shadow: 0 0 20px rgba(59, 130, 246, 0.4);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin: 0 -1rem 1.5rem -1rem;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
@@ -1653,7 +1771,8 @@ body {
|
||||
align-items: center;
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
/* Unified with .badge and button border-radius */
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
|
||||
@@ -8,12 +8,15 @@ console.log("=== Running API Unit Tests ===");
|
||||
const apiPath = path.join(__dirname, '..', 'src', 'api.js');
|
||||
const constantsPath = path.join(__dirname, '..', 'src', 'constants.js');
|
||||
|
||||
// Load version from version.txt for placeholder replacement
|
||||
const versionSnippet = fs.readFileSync(path.join(__dirname, '..', 'version.txt'), 'utf8').trim();
|
||||
|
||||
let apiCode = fs.readFileSync(apiPath, 'utf8');
|
||||
let constantsCode = fs.readFileSync(constantsPath, 'utf8');
|
||||
|
||||
// Strip exports and imports for VM
|
||||
apiCode = apiCode.replace(/export /g, '').replace(/import .*? from .*?;/g, '');
|
||||
constantsCode = constantsCode.replace(/export /g, '');
|
||||
constantsCode = constantsCode.replace(/export /g, '').replace(/{{VERSION}}/g, versionSnippet);
|
||||
|
||||
// 2. Setup Mock Environment
|
||||
const sandbox = {
|
||||
@@ -45,12 +48,11 @@ try {
|
||||
throw new Error(`Expected Authorization header 'Token ${token}', but got '${headersWithToken['Authorization']}'`);
|
||||
}
|
||||
|
||||
// Test without token (should use GUEST_TOKEN)
|
||||
// Test without token (should NOT have Authorization header)
|
||||
const headersWithoutToken = sandbox.apiHeaders();
|
||||
console.log("Without token:", JSON.stringify(headersWithoutToken));
|
||||
const guestToken = vm.runInContext('GUEST_TOKEN', sandbox);
|
||||
if (headersWithoutToken['Authorization'] !== `Token ${guestToken}`) {
|
||||
throw new Error(`Expected Authorization header 'Token ${guestToken}', but got '${headersWithoutToken['Authorization']}'`);
|
||||
if (headersWithoutToken['Authorization']) {
|
||||
throw new Error(`Expected NO Authorization header when token is missing, but got '${headersWithoutToken['Authorization']}'`);
|
||||
}
|
||||
|
||||
if (headersWithoutToken['Accept'] !== 'application/json') {
|
||||
|
||||
@@ -63,9 +63,14 @@ const html = `
|
||||
<button id="btn-next-week">Next Week</button>
|
||||
|
||||
<!-- Mocks for Language Toggle -->
|
||||
<button class="lang-btn" data-lang="de">DE</button>
|
||||
<button class="lang-btn" data-lang="en">EN</button>
|
||||
<button class="lang-btn" data-lang="all">ALL</button>
|
||||
<div id="lang-toggle">
|
||||
<button id="btn-lang-toggle"><span class="material-icons-round">translate</span></button>
|
||||
<div id="lang-dropdown" class="hidden">
|
||||
<button class="lang-btn" data-lang="de">🇦🇹 DE</button>
|
||||
<button class="lang-btn" data-lang="en">🇬🇧 EN</button>
|
||||
<button class="lang-btn" data-lang="all">🌐 ALL</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button id="btn-refresh">Refresh</button>
|
||||
<button id="btn-logout">Logout</button>
|
||||
@@ -187,6 +192,44 @@ const testCode = `
|
||||
if (!window.__RELOAD_CALLED) throw new Error("Clear cache did not reload the page");
|
||||
console.log("✅ Clear Cache Button Test Passed");
|
||||
|
||||
console.log("--- Testing Bug 2: renderTagsList() called on modal open ---");
|
||||
// Close the modal first (it may still be open from earlier test)
|
||||
document.getElementById('btn-highlights-close').click();
|
||||
const hlModalBug2 = document.getElementById('highlights-modal');
|
||||
if (!hlModalBug2.classList.contains('hidden')) {
|
||||
hlModalBug2.classList.add('hidden');
|
||||
}
|
||||
// Open the modal — this should call renderTagsList() without throwing
|
||||
let bug2Error = null;
|
||||
try {
|
||||
document.getElementById('btn-highlights').click();
|
||||
} catch(e) {
|
||||
bug2Error = e;
|
||||
}
|
||||
if (bug2Error) throw new Error("Bug 2: Opening highlights modal threw an error: " + bug2Error.message);
|
||||
// After click the modal should be visible (i.e., the handler completed)
|
||||
if (hlModalBug2.classList.contains('hidden')) throw new Error("Bug 2: Highlights modal did not open – renderTagsList may have thrown");
|
||||
console.log("✅ Bug 2: renderTagsList() called on modal open without error – Test Passed");
|
||||
|
||||
console.log("--- Testing Feature 3: Next-week button has tooltip, no numeric spans ---");
|
||||
const nextWeekBtn = document.getElementById('btn-next-week');
|
||||
// The button should not contain any .nav-badge elements with numbers
|
||||
const navBadge = nextWeekBtn.querySelector('.nav-badge');
|
||||
if (navBadge) throw new Error("Feature 3: .nav-badge should not be present in next-week button");
|
||||
console.log("✅ Feature 3: No numeric badge in next-week button – Test Passed");
|
||||
|
||||
console.log("--- Testing Feature 4: Language toggle updates UI labels ---");
|
||||
const enBtn = document.querySelector('.lang-btn[data-lang="en"]');
|
||||
if (enBtn) {
|
||||
enBtn.click();
|
||||
// After EN click, btn-this-week should be in English
|
||||
const thisWeekBtn = document.getElementById('btn-this-week');
|
||||
// Check that textContent is not the original German default "Diese Woche" or "This Week" (either is fine – just that the handler ran)
|
||||
console.log("✅ Feature 4: Language toggle click handler ran without error – Test Passed");
|
||||
} else {
|
||||
throw new Error("Feature 4: EN language button not found");
|
||||
}
|
||||
|
||||
window.__TEST_PASSED = true;
|
||||
`;
|
||||
|
||||
|
||||
306
tests/test_security.js
Normal file
306
tests/test_security.js
Normal file
@@ -0,0 +1,306 @@
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const path = require('path');
|
||||
|
||||
console.log("=== Running Security Enhancement Verification Tests ===");
|
||||
|
||||
// Helper to check for XSS patterns in strings
|
||||
function containsXSS(str) {
|
||||
const malicious = [
|
||||
'<img',
|
||||
'onerror',
|
||||
'alert(1)',
|
||||
'<script',
|
||||
'javascript:'
|
||||
];
|
||||
return malicious.some(m => String(str).toLowerCase().includes(m));
|
||||
}
|
||||
|
||||
// Mock DOM
|
||||
const createMockElement = (id = 'mock') => {
|
||||
const el = {
|
||||
id,
|
||||
classList: {
|
||||
add: () => { },
|
||||
remove: () => { },
|
||||
contains: () => false,
|
||||
toggle: () => { }
|
||||
},
|
||||
_innerHTML: '',
|
||||
get innerHTML() { return this._innerHTML; },
|
||||
set innerHTML(val) {
|
||||
this._innerHTML = val;
|
||||
if (containsXSS(val)) {
|
||||
console.error(`❌ SECURITY VULNERABILITY: XSS payload detected in innerHTML of element "${id}"!`);
|
||||
console.error(`Payload: ${val}`);
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
_textContent: '',
|
||||
get textContent() { return this._textContent; },
|
||||
set textContent(val) {
|
||||
this._textContent = val;
|
||||
// textContent is safe, so we don't crash here even if it contains payload
|
||||
if (containsXSS(val)) {
|
||||
console.log(`✅ Safe textContent usage detected in element "${id}" (Payload neutralized)`);
|
||||
}
|
||||
},
|
||||
value: '',
|
||||
style: { cssText: '', display: '' },
|
||||
_listeners: {},
|
||||
addEventListener: function(type, cb) {
|
||||
this._listeners[type] = cb;
|
||||
// Also assign to on[type] for easier testing
|
||||
this['on' + type] = cb;
|
||||
},
|
||||
removeEventListener: function(type) { delete this._listeners[type]; },
|
||||
appendChild: function(child) {
|
||||
if (this.id === 'tags-list' || this.id === 'toast-container') {
|
||||
// Check children for XSS
|
||||
if (child._innerHTML && containsXSS(child._innerHTML)) {
|
||||
console.error(`❌ SECURITY VULNERABILITY: Malicious child appended to "${this.id}"!`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
removeChild: () => { },
|
||||
querySelector: (sel) => createMockElement(sel),
|
||||
querySelectorAll: () => [createMockElement()],
|
||||
getAttribute: () => '',
|
||||
setAttribute: () => { },
|
||||
remove: () => { },
|
||||
dataset: {},
|
||||
forEach: (cb) => [].forEach(cb) // for querySelectorAll
|
||||
};
|
||||
return el;
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
console: console,
|
||||
document: {
|
||||
_elements: {},
|
||||
body: createMockElement('body'),
|
||||
documentElement: createMockElement('html'),
|
||||
createElement: (tag) => createMockElement(tag),
|
||||
getElementById: function(id) {
|
||||
if (!this._elements[id]) this._elements[id] = createMockElement(id);
|
||||
return this._elements[id];
|
||||
},
|
||||
querySelector: (sel) => createMockElement(sel),
|
||||
querySelectorAll: (sel) => [createMockElement(sel)],
|
||||
},
|
||||
localStorage: new Proxy({
|
||||
_data: {},
|
||||
getItem: function(key) { return this._data[key] || null; },
|
||||
setItem: function(key, val) { this._data[key] = String(val); },
|
||||
removeItem: function(key) { delete this._data[key]; },
|
||||
clear: function() { this._data = {}; }
|
||||
}, {
|
||||
get(target, prop) {
|
||||
if (prop in target) return target[prop];
|
||||
return target._data[prop] || null;
|
||||
},
|
||||
set(target, prop, value) {
|
||||
if (prop === '_data') { target._data = value; return true; }
|
||||
target._data[prop] = String(value);
|
||||
return true;
|
||||
},
|
||||
deleteProperty(target, prop) {
|
||||
delete target._data[prop];
|
||||
return true;
|
||||
},
|
||||
ownKeys(target) {
|
||||
return Object.keys(target._data);
|
||||
},
|
||||
getOwnPropertyDescriptor(target, prop) {
|
||||
if (prop in target._data) {
|
||||
return { enumerable: true, configurable: true, value: target._data[prop], writable: true };
|
||||
}
|
||||
}
|
||||
}),
|
||||
fetch: () => Promise.reject(new Error('Network error')),
|
||||
setTimeout: (cb) => cb(),
|
||||
setInterval: () => { },
|
||||
requestAnimationFrame: (cb) => cb(),
|
||||
Date: Date,
|
||||
Notification: { permission: 'denied', requestPermission: () => { } },
|
||||
window: {
|
||||
location: { href: '' },
|
||||
open: () => {},
|
||||
crypto: { randomUUID: () => '1234' },
|
||||
matchMedia: () => ({ matches: false }),
|
||||
addEventListener: function(type, cb) { this['on' + type] = cb; },
|
||||
confirm: () => true
|
||||
},
|
||||
crypto: { randomUUID: () => '1234' }
|
||||
};
|
||||
|
||||
// Load source files
|
||||
const files = [
|
||||
'../src/utils.js',
|
||||
'../src/constants.js',
|
||||
'../src/api.js',
|
||||
'../src/ui_helpers.js',
|
||||
'../src/actions.js',
|
||||
'../src/events.js'
|
||||
];
|
||||
|
||||
const versionSnippet = fs.readFileSync(path.join(__dirname, '..', 'version.txt'), 'utf8').trim();
|
||||
|
||||
vm.createContext(sandbox);
|
||||
|
||||
// Helper to load and wrap ESM-like files into CJS for VM
|
||||
function loadFile(relPath) {
|
||||
let code = fs.readFileSync(path.join(__dirname, relPath), 'utf8');
|
||||
// Simple regex replacements for imports/exports
|
||||
code = code.replace(/export /g, '');
|
||||
code = code.replace(/import .*? from .*?;/g, (match) => {
|
||||
// We handle dependencies manually in this narrow test context
|
||||
return `// ${match}`;
|
||||
});
|
||||
// Replace version placeholder
|
||||
code = code.replace(/{{VERSION}}/g, versionSnippet);
|
||||
return code;
|
||||
}
|
||||
|
||||
// Initial state mock
|
||||
vm.runInContext(`
|
||||
var authToken = null;
|
||||
var currentUser = null;
|
||||
var orderMap = new Map();
|
||||
var userFlags = new Set();
|
||||
var pollIntervalId = null;
|
||||
var highlightTags = [];
|
||||
var allWeeks = [];
|
||||
var currentWeekNumber = 1;
|
||||
var currentYear = 2024;
|
||||
var displayMode = 'this-week';
|
||||
var langMode = 'de';
|
||||
|
||||
// State setters
|
||||
function setAuthToken(v) { authToken = v; }
|
||||
function setCurrentUser(v) { currentUser = v; }
|
||||
function setHighlightTags(v) { highlightTags = v; }
|
||||
function setAllWeeks(v) { allWeeks = v; }
|
||||
function setCurrentWeekNumber(v) { currentWeekNumber = v; }
|
||||
function setCurrentYear(v) { currentYear = v; }
|
||||
function setOrderMap(v) { orderMap = v; }
|
||||
function setUserFlags(v) { userFlags = v; }
|
||||
function setPollIntervalId(v) { pollIntervalId = v; }
|
||||
`, sandbox);
|
||||
|
||||
files.forEach(f => vm.runInContext(loadFile(f), sandbox));
|
||||
|
||||
// i18n mock
|
||||
vm.runInContext(`
|
||||
function t(key) { return key; }
|
||||
// Initialize events
|
||||
bindEvents();
|
||||
`, sandbox);
|
||||
|
||||
async function runTests() {
|
||||
console.log("--- Test 1: GUEST_TOKEN Removal ---");
|
||||
const headers = sandbox.apiHeaders(null);
|
||||
if (headers['Authorization']) {
|
||||
console.error("❌ FAIL: Authorization header present for null token!");
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log("✅ PASS: No Authorization header for unauthenticated calls.");
|
||||
}
|
||||
|
||||
console.log("--- Test 2: XSS in renderTagsList ---");
|
||||
sandbox.highlightTags = ['<img src=x onerror=alert(1)>'];
|
||||
// This should NOT crash the test because it uses textContent now
|
||||
sandbox.renderTagsList();
|
||||
console.log("✅ PASS: renderTagsList handled malicious tag safely.");
|
||||
|
||||
console.log("--- Test 3: showErrorModal Security ---");
|
||||
// New signature: title, message, details, btnText, url
|
||||
sandbox.showErrorModal(
|
||||
'<img src=x onerror=alert(1)>',
|
||||
'<img src=x onerror=alert(1)>',
|
||||
'<img src=x onerror=alert(1)>',
|
||||
'Safe Btn',
|
||||
'javascript:alert(1)'
|
||||
);
|
||||
console.log("✅ PASS: showErrorModal handled malicious payloads safely.");
|
||||
|
||||
console.log("--- Test 4: addHighlightTag Validation ---");
|
||||
const invalidInputs = [
|
||||
'<script>alert(1)</script>',
|
||||
'a', // too short (min 2)
|
||||
'verylongtagnameover20characterslong', // too long (max 20)
|
||||
'invalid;char' // invalid chars
|
||||
];
|
||||
|
||||
invalidInputs.forEach(input => {
|
||||
const result = sandbox.addHighlightTag(input);
|
||||
if (result === true) {
|
||||
console.error(`❌ FAIL: Invalid input "${input}" was accepted!`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const validInputs = ['short', 'Bio', 'Vegg-I', 'Menü 1'];
|
||||
validInputs.forEach(input => {
|
||||
const result = sandbox.addHighlightTag(input);
|
||||
if (result === false && !sandbox.highlightTags.includes(input)) {
|
||||
console.error(`❌ FAIL: Valid input "${input}" was rejected!`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
console.log("✅ PASS: addHighlightTag correctly rejected malicious/invalid inputs.");
|
||||
|
||||
console.log("--- Test 5: Auth Guards in Actions ---");
|
||||
let fetchCalled = false;
|
||||
sandbox.fetch = () => {
|
||||
fetchCalled = true;
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve({ results: [] }) });
|
||||
};
|
||||
|
||||
sandbox.authToken = null;
|
||||
await sandbox.loadMenuDataFromAPI();
|
||||
if (fetchCalled) {
|
||||
console.error("❌ FAIL: loadMenuDataFromAPI attempted fetch without token!");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
fetchCalled = false;
|
||||
await sandbox.refreshFlaggedItems();
|
||||
if (fetchCalled) {
|
||||
console.error("❌ FAIL: refreshFlaggedItems attempted fetch without token!");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("✅ PASS: Auth guards prevented unauthenticated API calls.");
|
||||
|
||||
console.log("--- Test 6: Secure Logout (FR-006) ---");
|
||||
sandbox.localStorage.setItem('kantine_token', 'secret');
|
||||
sandbox.localStorage.setItem('kantine_history', 'orders');
|
||||
sandbox.localStorage.setItem('other_app_data', 'keep_me');
|
||||
|
||||
// Trigger logout
|
||||
const btnLogout = sandbox.document.getElementById('btn-logout');
|
||||
if (btnLogout.onclick) {
|
||||
btnLogout.onclick();
|
||||
} else {
|
||||
console.error("❌ FAIL: Logout button has no click listener!");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (sandbox.localStorage.getItem('kantine_token') || sandbox.localStorage.getItem('kantine_history')) {
|
||||
console.error("❌ FAIL: Logout did not clear all kantine_ keys!");
|
||||
process.exit(1);
|
||||
}
|
||||
if (sandbox.localStorage.getItem('other_app_data') !== 'keep_me') {
|
||||
console.error("❌ FAIL: Logout cleared non-kantine keys!");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("✅ PASS: Secure logout cleared all app-related data while preserving other data.");
|
||||
|
||||
console.log("\n✨ ALL SECURITY TESTS PASSED! ✨");
|
||||
}
|
||||
|
||||
runTests().catch(err => {
|
||||
console.error("Test execution failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1 +1 @@
|
||||
v1.6.14
|
||||
v1.7.2
|
||||
|
||||
Reference in New Issue
Block a user