Compare commits

...

6 Commits

15 changed files with 175 additions and 58 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
TM_API_KEY=your_ticketmaster_api_key

4
.gitignore vendored
View File

@ -1 +1,3 @@
node_modules/
node_modules/
.DS_Store
__MACOSX/

View File

@ -45,24 +45,33 @@ cd AISE_FS26-FrontendEntwicklungProjekt
npm install
```
### 3. Umgebungsvariable konfigurieren
### 3. Umgebungsvariablen / Ticketmaster API
Erstelle eine `.env`-Datei im Projektstamm:
Für die Bewertung enthält die Abgabe bereits eine vorkonfigurierte `.env`-Datei, sodass die Anwendung ohne zusätzliche Konfiguration gestartet werden kann.
In einer regulären Installation muss eine eigene `.env`-Datei im Projektstamm erstellt werden:
```env
TM_API_KEY=dein_ticketmaster_api_key
TM_API_KEY=your_ticketmaster_api_key
```
Den API-Key erhältst du kostenlos unter [developer.ticketmaster.com](https://developer.ticketmaster.com).
*(Zum Testen ist im Repository vorübergehend ein Key hinterlegt.)*
Ein kostenloser API-Key kann über das Ticketmaster Developer Portal bezogen werden:
https://developer.ticketmaster.com/
Zusätzlich ist eine `.env.example`-Datei enthalten, welche die erwartete Struktur der Umgebungsvariablen dokumentiert.
### 4. Backend-Server starten
```bash
node server/server.js
npm start
```
Ausgabe: `Server läuft auf http://localhost:3000`
Ausgabe:
```text
Server läuft auf http://localhost:3000
```
### 5. Frontend öffnen
@ -124,27 +133,26 @@ Alle KI-generierten Vorschläge wurden kritisch geprüft, angepasst und manuell
## Selbstreflexion
**Herausforderungen:**
### Herausforderungen
- **Externe API-Integration**: Die Ticketmaster-API hat eine komplexe, verschachtelte JSON-Struktur. Das Mapping auf ein einfaches Datenmodell erforderte sorgfältige Analyse und optionale Chaining (`?.`).
- **Modulstruktur ohne Framework**: Die klare Trennung in `api/`, `services/`, `ui/` ohne ein Framework erforderte bewusste Architekturentscheidungen.
- **Statuscode-Konventionen**: Die korrekte Verwendung von HTTP-Statuscodes (201 für Ressource-Erstellung, 200 für erfolgreichen GET) musste erst explizit reflektiert werden.
- **XSS-Bewusstsein**: Das Risiko von `innerHTML` mit externen Daten war nicht von Anfang an präsent und wurde erst im Code-Review erkannt.
* **Frontend-Backend-Integration:** Die Kommunikation zwischen Frontend, Backend und Ticketmaster API musste zuverlässig koordiniert werden.
* **Verschachtelte Ticketmaster-Daten:** Die komplexe JSON-Struktur der API erforderte eine zusätzliche Service-Schicht zur Datenaufbereitung.
* **Modularisierung:** Mit wachsendem Funktionsumfang wurde eine klare Trennung der Verantwortlichkeiten immer wichtiger.
**Lerneffekte:**
### Lerneffekte
- Tiefes Verständnis für Async/Await und Fehlerbehandlung in JavaScript
- Sensibilisierung für OWASP-Risiken bereits beim Schreiben von Frontend-Code
- Wert von semantischem HTML5 und Accessibility für Barrierefreiheit und SEO
* **Integration erhöht die Komplexität:** Mehrere Systeme müssen konsistent zusammenarbeiten.
* **Kleine Änderungen können grosse Auswirkungen haben:** Sichere Praktiken wie die Verwendung von `textContent` helfen, Fehler und Sicherheitsrisiken zu vermeiden.
* **Modulare Architektur verbessert die Wartbarkeit:** Klare Strukturen erleichtern Entwicklung, Fehlersuche und Erweiterungen.
---
## Gruppenmitglieder und Rollen
| Name | Rolle | Schwerpunkte |
|---|---|---|
| **Nicola Augsburger** | Frontend & Backend | Einladungssystem (Frontend + Backend), Authentifizierung, App-Architektur |
| **Pascal Schieman** | Frontend | Event-Karten-Komponente, CSS-Styling, Responsives Layout |
| **Karolina Thöny-Tyganova** | Frontend & API | Ticketmaster-API-Integration, Suchfunktion, Filterlogik |
| Name | Rolle | Schwerpunkte |
| --------------------------- | ------------------ | ----------------------------------------------------------------------------------------------------------------- |
| **Nicola Augsburger** | Frontend & Backend | Einladungssystem (Frontend + Backend), Authentifizierung, App-Architektur |
| **Karolina Thöny-Tyganova** | Frontend & API | Ticketmaster-API-Integration, Suchfunktion, Filterlogik, Event-Karten-Komponente, CSS-Styling, Responsives Layout |
Die individuelle Beteiligung ist in den Git-Commits auf [gitea.fhgr.ch](https://gitea.fhgr.ch/augsbunicola/AISE_FS26-FrontendEntwicklungProjekt) nachvollziehbar.
Die individuelle Beteiligung ist in den Git-Commits auf [gitea.fhgr.ch](https://gitea.fhgr.ch/augsbunicola/AISE_FS26-FrontendEntwicklungProjekt) nachvollziehbar.

View File

@ -247,22 +247,22 @@
margin-bottom: 0.35rem;
}
.auth-input {
border: 1.5px solid var(--color-border) !important;
border-radius: var(--radius-sm) !important;
.auth-modal-body .form-control.auth-input {
border: 1.5px solid var(--color-border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 0.9rem;
height: 42px;
color: var(--color-text);
background: var(--color-bg) !important;
background: var(--color-bg);
transition: border-color var(--transition), box-shadow var(--transition);
}
.auth-input:focus {
border-color: var(--color-accent) !important;
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.15) !important;
.auth-modal-body .form-control.auth-input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.15);
outline: none;
background: var(--color-surface) !important;
background: var(--color-surface);
}
.auth-error {
@ -348,4 +348,4 @@ select:focus-visible,
a:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}

View File

@ -310,7 +310,10 @@ main {
SECTION (EVENTS / SAVED / INVITATIONS)
========================= */
.events-section {
padding: 2.5rem 0 1rem;
/* Keep content away from the phone edges.
The section also has Bootstrap's .container class, but this rule appears later
and would otherwise overwrite the container's left/right padding with 0. */
padding: 2.5rem 1rem 1rem;
}
.events-section h2 {
@ -336,6 +339,7 @@ main {
flex-direction: column;
gap: 1rem;
max-width: 640px;
width: 100%;
}
/* =========================
@ -408,6 +412,28 @@ main {
margin: 0;
}
/* =========================
MOBILE SPACING FIX
Prevent cards/text from touching the viewport edges
========================= */
@media (max-width: 575.98px) {
.events-section {
padding-left: 1rem;
padding-right: 1rem;
}
.event-card,
.invitation-card {
padding: 1rem;
}
.invitation-card .d-flex,
.event-card .d-flex {
flex-wrap: wrap;
}
}
/* =========================
TABLET (≥768px)
========================= */

View File

@ -27,7 +27,7 @@
<ul class="nav" role="list">
<li class="nav-item">
<a class="nav-link" href="#" id="nav-events" aria-label="Zur Event-Suche">Events</a>
<a class="nav-link d-none" href="#" id="nav-events" aria-label="Zur Event-Suche">Events</a>
</li>
<li class="nav-item">
<a class="nav-link d-none" href="#" id="nav-my-events" aria-label="Meine gespeicherten Events">My Events</a>
@ -212,4 +212,4 @@
</body>
</html>
</html>

BIN
js/.DS_Store vendored Normal file

Binary file not shown.

View File

@ -25,7 +25,6 @@ export async function fetchEvents(city) {
return data._embedded?.events || [];
} catch (error) {
console.error("Fehler beim Laden der Events:", error);
// Fehler nach oben weitergeben, damit app.js ihn dem Nutzer anzeigen kann
throw error;
}

View File

@ -16,6 +16,7 @@ import { createEventCard } from "./ui/eventCard.js";
// =========================
let currentUser = null;
let currentPassword = null;
let lastRenderedEvents = [];
window.currentUser = null;
window.currentPassword = null;
@ -120,8 +121,11 @@ modalLoginBtn.addEventListener("click", async () => {
userArea.classList.remove("d-none");
userNameEl.textContent = `👤 ${username}`;
navEvents.classList.remove("d-none");
navSaved.classList.remove("d-none");
navInv.classList.remove("d-none");
refreshVisibleEventCards();
} else {
loginError.textContent = "Login fehlgeschlagen. Bitte Zugangsdaten prüfen.";
loginError.hidden = false;
@ -129,7 +133,6 @@ modalLoginBtn.addEventListener("click", async () => {
} catch (err) {
loginError.textContent = "Verbindungsfehler. Ist der Server erreichbar?";
loginError.hidden = false;
console.error("Login-Fehler:", err);
} finally {
modalLoginBtn.disabled = false;
modalLoginBtn.textContent = "Anmelden";
@ -188,7 +191,6 @@ modalRegBtn.addEventListener("click", async () => {
registerError.hidden = false;
modalRegBtn.disabled = false;
modalRegBtn.textContent = "Konto erstellen";
console.error("Registrierungs-Fehler:", err);
}
});
@ -203,10 +205,12 @@ logoutBtn.addEventListener("click", () => {
authArea.classList.remove("d-none");
userArea.classList.add("d-none");
navEvents.classList.add("d-none");
navSaved.classList.add("d-none");
navInv.classList.add("d-none");
showSection("events");
refreshVisibleEventCards();
});
// =========================
@ -235,10 +239,10 @@ async function handleSearch() {
try {
const events = await getEvents(city);
const filtered = applyFilters(events, dateFrom, dateTo, category);
lastRenderedEvents = filtered;
renderEventList(filtered, eventListEl);
} catch (err) {
showError(eventListEl, "Fehler beim Laden der Events. Bitte erneut versuchen.");
console.error("Such-Fehler:", err);
}
}
@ -287,6 +291,13 @@ navInv.addEventListener("click", (e) => {
loadInvitations();
});
function refreshVisibleEventCards() {
if (!eventsSection.classList.contains("d-none") && lastRenderedEvents.length > 0) {
renderEventList(lastRenderedEvents, eventListEl);
}
}
function showSection(section) {
searchSection.classList.add("d-none");
eventsSection.classList.add("d-none");
@ -373,7 +384,6 @@ async function loadInvitations() {
} catch (err) {
showError(container, "Fehler beim Laden der Einladungen.");
console.error("Einladungs-Fehler:", err);
}
}
@ -393,6 +403,16 @@ function renderInvitation(inv, container) {
text.append(strong, " hat dich zu ", em, " eingeladen.");
card.appendChild(text);
if (inv.eventUrl) {
const link = document.createElement("a");
link.href = inv.eventUrl;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.className = "btn btn-outline-primary btn-sm mb-2";
link.textContent = "Event öffnen";
card.appendChild(link);
}
if (inv.status !== "pending") {
const status = document.createElement("p");
status.className = inv.status === "accepted" ? "text-success" : "text-muted";
@ -463,7 +483,6 @@ async function respondToInvitation(id, action, card) {
errEl.className = "text-danger small mt-1";
errEl.textContent = "Fehler beim Antworten auf die Einladung.";
card.appendChild(errEl);
console.error("Antwort-Fehler:", err);
}
}
@ -488,7 +507,6 @@ async function deleteInvitation(id, card) {
errEl.className = "text-danger small mt-1";
errEl.textContent = "Fehler beim Löschen der Einladung.";
card.appendChild(errEl);
console.error("Lösch-Fehler:", err);
}
}

View File

@ -8,7 +8,7 @@ import { fetchEvents } from "../api/ticketmaster.js";
/**
* Lädt und transformiert Events für eine gegebene Stadt.
* @param {string} city - Stadtname
* @returns {Promise<Array<{id, name, date, time, venue, category}>>}
* @returns {Promise<Array<{id, name, date, time, venue, category, url}>>}
*/
export async function getEvents(city) {
const events = await fetchEvents(city);
@ -20,9 +20,10 @@ export async function getEvents(city) {
date: event.dates?.start?.localDate || null,
time: event.dates?.start?.localTime || null,
venue: event._embedded?.venues?.[0]?.name || "Unbekannter Ort",
url: event.url || null,
// Kategorie aus dem ersten Klassifizierungs-Segment extrahieren (z.B. "Music", "Sports")
category: event.classifications?.[0]?.segment?.name
? event.classifications[0].segment.name.toLowerCase()
: null
}));
}
}

View File

@ -41,6 +41,21 @@ export function createEventCard(event) {
article.append(title, date, venue);
if (event.url) {
const link = document.createElement("a");
link.href = event.url;
link.target = "_blank";
link.rel = "noopener noreferrer";
link.className = "btn btn-outline-secondary btn-sm mt-2";
link.textContent = "Event ansehen";
article.appendChild(link);
}
// Gäste sehen nur Event-Informationen. Speichern und Einladen sind Login-Funktionen.
if (!window.currentUser) {
return article;
}
// --- BUTTON-GRUPPE ---
const buttonContainer = document.createElement("div");
buttonContainer.className = "d-flex gap-2 mt-2 flex-wrap";
@ -154,7 +169,8 @@ function createInviteForm(event, card) {
body: JSON.stringify({
toUser,
eventId: event.id,
eventName: event.name
eventName: event.name,
eventUrl: event.url || null
})
});
@ -172,7 +188,6 @@ function createInviteForm(event, card) {
sendBtn.disabled = false;
sendBtn.textContent = "Senden";
showCardMessage(card, err.message || "Fehler beim Senden der Einladung.", "danger");
console.error("Einladungs-Fehler:", err);
}
});

View File

@ -1,10 +1,10 @@
{
"name": "aise_fs26-frontendentwicklungprojekt",
"version": "1.0.0",
"description": "Frontend Project for cds-208 at FHGR Collaborators: Pascal Schieman, Nicola Augsburger, Karolina Thöny-Tyganova ## Project Description **Find your next event in seconds.** Encore is a modern event discovery frontend that brings Ticketmaster events directly to users through a clean, intelligent interface. Instead of sifting through cluttered listings, users can instantly browse concerts, sports, theater, and entertainment by location, date, and category—then dive into event details to make informed decisions fast.",
"description": "Frontend Project for cds-208 at FHGR Collaborators: Nicola Augsburger, Karolina Thöny-Tyganova ## Project Description **Find your next event in seconds.** Encore is a modern event discovery frontend that brings Ticketmaster events directly to users through a clean, intelligent interface. Instead of sifting through cluttered listings, users can instantly browse concerts, sports, theater, and entertainment by location, date, and category—then dive into event details to make informed decisions fast.",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"start": "node server/server.js"
},
"repository": {
"type": "git",

View File

View File

@ -52,7 +52,6 @@ app.get("/api/events", async (req, res) => {
res.json(data);
} catch (error) {
console.error("Ticketmaster-Fehler:", error);
res.status(500).json({ message: "Fehler beim Laden der Events" });
}
});
@ -111,9 +110,25 @@ app.get("/api/user", (req, res) => {
let invitations = [];
let idCounter = 1;
/**
* Normalisiert Benutzernamen für Vergleiche.
* Dadurch werden z.B. "Anna" und " anna " als derselbe Benutzer behandelt.
*/
function normalizeUsername(username) {
return String(username || "").trim().toLowerCase();
}
/**
* Erstellt einen stabilen Event-Schlüssel für den Duplikat-Check.
* Ticketmaster liefert normalerweise eine id. Falls diese fehlt, verwenden wir URL oder Namen.
*/
function getEventKey({ eventId, eventUrl, eventName }) {
return String(eventId || eventUrl || eventName || "").trim().toLowerCase();
}
/**
* POST /api/invitation – Einladung senden.
* Erwartet: Header X-Username (Sender), Body { toUser, eventId, eventName }
* Erwartet: Header X-Username (Sender), Body { toUser, eventId, eventName, eventUrl }
* Antwortet mit 201 Created und dem erstellten Einladungsobjekt.
*/
app.post("/api/invitation", (req, res) => {
@ -121,29 +136,60 @@ app.post("/api/invitation", (req, res) => {
return res.status(400).json({ message: "Request-Body fehlt" });
}
const fromUser = req.header("X-Username");
const { toUser, eventId, eventName } = req.body;
const fromUserRaw = req.header("X-Username");
const { toUser: toUserRaw, eventId, eventName, eventUrl } = req.body;
const fromUser = String(fromUserRaw || "").trim();
const toUser = String(toUserRaw || "").trim();
const fromUserKey = normalizeUsername(fromUser);
const toUserKey = normalizeUsername(toUser);
const eventKey = getEventKey({ eventId, eventUrl, eventName });
if (!fromUser || !toUser) {
return res.status(400).json({ message: "Sender und Empfänger sind erforderlich" });
}
// Prüfen ob der Empfänger registriert ist
if (!users.find(u => u.username === toUser)) {
if (!eventKey) {
return res.status(400).json({ message: "Event fehlt. Einladung kann nicht gespeichert werden." });
}
// Prüfen ob der Empfänger registriert ist.
// Case-insensitive, damit "Anna" und "anna" nicht als verschiedene Nutzer behandelt werden.
const recipient = users.find(u => normalizeUsername(u.username) === toUserKey);
if (!recipient) {
return res.status(404).json({ message: `Nutzer "${toUser}" ist nicht registriert.` });
}
// Nicht sich selbst einladen
if (fromUser === toUser) {
if (fromUserKey === toUserKey) {
return res.status(400).json({ message: "Du kannst dich nicht selbst einladen." });
}
// Doppelte Einladung verhindern:
// Derselbe Sender darf denselben Empfänger nicht mehrfach zum selben Event einladen.
// Wichtig: Der Vergleich läuft über normalisierte Keys, nicht über direkte Strings.
const duplicateInvitation = invitations.find(inv =>
inv.fromUserKey === fromUserKey &&
inv.toUserKey === toUserKey &&
inv.eventKey === eventKey
);
if (duplicateInvitation) {
return res.status(409).json({
message: `Du hast "${recipient.username}" bereits zu diesem Event eingeladen.`
});
}
const invitation = {
id: idCounter++,
fromUser,
toUser,
eventId,
fromUserKey,
toUser: recipient.username,
toUserKey,
eventId: eventId || null,
eventKey,
eventName,
eventUrl: eventUrl || null,
status: "pending"
};
@ -163,7 +209,8 @@ app.get("/api/invitation", (req, res) => {
return res.status(400).json({ message: "Benutzername fehlt" });
}
const userInvitations = invitations.filter(inv => inv.toUser === user);
const userKey = normalizeUsername(user);
const userInvitations = invitations.filter(inv => inv.toUserKey === userKey);
res.json(userInvitations);
});

View File