This commit is contained in:
2026-02-26 09:09:56 +01:00
commit 7862273342
6 changed files with 1335 additions and 0 deletions

405
WebApp/app.js Normal file
View File

@@ -0,0 +1,405 @@
/**
* Truck & Country Festival Guide | app.js v3
* Spec: config-driven · session management · n8n webhooks · XSS-safe
*/
'use strict';
// ─── Global State (#5.1) ─────────────────────────────────────
window.FESTIVAL = {
session_id: null,
entry: null, // 'truck' | 'family'
sponsor: null,
};
let CFG = {};
let IS_SENDING = false; // #7: max 1 concurrent request
// ─── Utilities ───────────────────────────────────────────────
function uuidV4() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}
/** Safe text setter never innerHTML with dynamic data */
function setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text || '';
}
function setAttr(id, attr, val) {
const el = document.getElementById(id);
if (el && val) el.setAttribute(attr, val);
}
// ─── Session Management (#5.1) ───────────────────────────────
/**
* Get or create a persistent session ID.
* Stored in localStorage so returning visitors keep their ID.
*/
function getOrCreateSessionId() {
const KEY = 'festival_session_id';
try {
let id = localStorage.getItem(KEY);
if (!id) {
id = uuidV4();
localStorage.setItem(KEY, id);
}
return id;
} catch (_) {
// localStorage blocked (private mode / Safari ITP) use in-memory
return uuidV4();
}
}
// ─── Config Loader ───────────────────────────────────────────
async function loadConfig() {
// Try embedded config first (for file:// protocol)
if (window.EMBEDDED_CONFIG) {
return window.EMBEDDED_CONFIG;
}
// Fallback to fetching config.json (for http:// protocol)
const res = await fetch('config.json', { cache: 'no-store' });
if (!res.ok) throw new Error(`config.json HTTP ${res.status}`);
return res.json();
}
// ─── Apply config → DOM ──────────────────────────────────────
function applyCSSVars(cfg) {
const r = document.documentElement;
const map = {
'--c-bg': cfg.primary_color || '#1A1208',
'--c-bg2': cfg.secondary_color || '#2C2010',
'--c-accent': cfg.accent_color || '#D4820A',
'--c-accent-light': cfg.accent_light || '#F5A623',
'--c-text': cfg.text_light || '#F5F0E8',
'--c-muted': cfg.text_muted || '#A89880',
};
for (const [k, v] of Object.entries(map)) r.style.setProperty(k, v);
}
function applyImages(cfg) {
const t = document.getElementById('half-img-truck');
const f = document.getElementById('half-img-family');
if (t && cfg.hero_truck_image) t.style.backgroundImage = `url('${cfg.hero_truck_image}')`;
if (f && cfg.hero_family_image) f.style.backgroundImage = `url('${cfg.hero_family_image}')`;
}
function applyHeader(cfg) {
const logo = document.getElementById('hdr-logo');
if (logo) { logo.src = cfg.logo_path || ''; logo.alt = `${cfg.event_name || 'Event'} Logo`; }
setText('hdr-event-name', cfg.event_name);
setText('hdr-event-dates', cfg.event_dates);
setText('hdr-presented-by', `Presented by ${cfg.sponsor_name || ''}`);
// #4: hook text
setText('hdr-hook', cfg.header_hook || '');
const sLogo = document.getElementById('hdr-sponsor-logo');
if (sLogo) { sLogo.src = cfg.sponsor_logo_path || ''; sLogo.alt = `${cfg.sponsor_name || 'Sponsor'} Logo`; }
if (cfg.event_name) document.title = `${cfg.event_name} Festival Guide`;
}
function applyHero(cfg) {
setText('cta-truck-label', cfg.cta_primary_label);
setText('cta-family-label', cfg.cta_secondary_label);
// Event strip
setText('strip-event-name', cfg.event_name);
setText('strip-dates', cfg.event_dates);
setText('strip-location', cfg.event_location);
}
function applyBenefits(cfg) {
const list = document.getElementById('benefits-list');
if (!list || !Array.isArray(cfg.benefits)) return;
cfg.benefits.forEach(b => {
const li = document.createElement('li'); li.className = 'benefit-card';
const icon = document.createElement('span'); icon.className = 'benefit-icon'; icon.setAttribute('aria-hidden','true'); icon.textContent = b.icon || '';
const title = document.createElement('h3'); title.className = 'benefit-title'; title.textContent = b.title || '';
const text = document.createElement('p'); text.className = 'benefit-text'; text.textContent = b.text || '';
li.append(icon, title, text);
list.appendChild(li);
});
}
/** #8: Guide preview cards */
function applyGuidePreview(cfg) {
const grid = document.getElementById('guide-preview-grid');
if (!grid) return;
const examples = cfg.guide_examples || [
{ q: 'Wann macht der Einlass auf?', a: 'Der Einlass startet Samstag um 8 Uhr. Du kannst auch schon am Freitagabend anreisen und direkt campen.' },
{ q: 'Wo findet die Truck Trial EM statt?', a: 'Im Offroad-Gelände des DEKRA Lausitzrings direkt einsehbar vom Hauptpublikumsbereich.' },
{ q: 'Gibt es Parken und Camping vor Ort?', a: 'Ja! Großparkplatz am Gelände, Camping im Truck & Country Camp beides direkt am Veranstaltungsort.' },
];
examples.forEach(ex => {
const card = document.createElement('div'); card.className = 'guide-card';
const q = document.createElement('p'); q.className = 'guide-card-q'; q.textContent = ex.q;
const a = document.createElement('p'); a.className = 'guide-card-a'; a.textContent = ex.a;
card.append(q, a);
grid.appendChild(card);
});
}
function applyOfficialSection(cfg) {
setText('official-headline', cfg.official_section_headline);
setText('official-text', cfg.official_section_text);
const evBtn = document.getElementById('official-event-btn');
if (evBtn) { evBtn.textContent = cfg.official_event_button_label || 'Zur Eventseite'; if (cfg.official_event_url) evBtn.href = cfg.official_event_url; }
const hlBtn = document.getElementById('official-highlights-btn');
if (hlBtn) {
if (cfg.official_highlights_url) {
hlBtn.textContent = cfg.official_highlights_button_label || 'Highlights';
hlBtn.href = cfg.official_highlights_url;
hlBtn.style.display = '';
} else {
hlBtn.style.display = 'none';
}
}
}
function applyFooter(cfg) {
setAttr('footer-imprint', 'href', cfg.legal_imprint_url);
setAttr('footer-privacy', 'href', cfg.legal_privacy_url);
}
function applyChatUI(cfg) {
const ticketBtn = document.getElementById('chat-ticket-btn');
if (ticketBtn) { ticketBtn.href = cfg.ticket_link || cfg.official_event_url || '#'; ticketBtn.textContent = cfg.chat_ticket_label || 'Tickets'; }
const infoLink = document.getElementById('chat-official-link');
if (infoLink) { infoLink.href = cfg.official_event_url || '#'; infoLink.textContent = cfg.chat_info_label || 'Offizielle Infos ↗'; }
const input = document.getElementById('chat-input');
if (input) input.placeholder = cfg.chat_input_placeholder || 'Deine Frage...';
}
// ─── Chat UI helpers ──────────────────────────────────────────
function appendBubble(type, text, actions) {
const msgs = document.getElementById('chat-messages');
if (!msgs) return;
const div = document.createElement('div');
div.className = `chat-bubble chat-bubble--${type}`;
div.setAttribute('role', 'article');
div.textContent = text; // textContent only no XSS (#6)
if (actions?.length) {
const row = document.createElement('div'); row.className = 'chat-fallback-actions';
actions.forEach(({ label, href }) => {
if (!href) return;
const a = document.createElement('a');
a.className = 'chat-fallback-link';
a.href = href; a.target = '_blank'; a.rel = 'noopener noreferrer';
a.textContent = label;
row.appendChild(a);
});
div.appendChild(row);
}
msgs.appendChild(div);
msgs.scrollTop = msgs.scrollHeight; // #7: auto-scroll
return div;
}
function showTyping() {
const msgs = document.getElementById('chat-messages');
if (!msgs) return null;
const el = document.createElement('div'); el.className = 'chat-typing'; el.setAttribute('aria-label','schreibt...');
el.innerHTML = '<span></span><span></span><span></span>';
msgs.appendChild(el);
msgs.scrollTop = msgs.scrollHeight;
return el;
}
function showFallback() {
appendBubble('error',
CFG.chat_fallback_message || 'Kurz hakt es gerade. Hier sind Tickets + offizielle Infos.',
[
{ label: '🎟 Tickets', href: CFG.ticket_link || CFG.official_event_url },
{ label: '↗ Offizielle Infos', href: CFG.official_event_url },
]
);
}
// ─── n8n Webhook (#5.2, #6) ──────────────────────────────────
async function postWebhook(path, body) {
const base = (CFG.webhook_base || '').replace(/\/$/, '');
const res = await fetch(`${base}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(12000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
async function startSession(entry) {
const payload = {
entry,
sponsor: window.FESTIVAL.sponsor,
session_id: window.FESTIVAL.session_id,
timestamp: new Date().toISOString(),
page_url: location.href,
user_agent: navigator.userAgent, // #5.2
};
const typing = showTyping();
try {
const data = await postWebhook('/festival/start', payload);
if (typing) typing.remove();
const reply = (data?.reply && typeof data.reply === 'string')
? data.reply
: (entry === 'truck' ? CFG.chat_welcome_truck : CFG.chat_welcome_family)
|| 'Hi! Ich bin dein Festival-Guide. Sag mir kurz: Tagesbesuch oder Wochenende?'; // #5.2 fallback
appendBubble('bot', reply);
} catch (_) {
if (typing) typing.remove();
const welcome = entry === 'truck' ? CFG.chat_welcome_truck : CFG.chat_welcome_family;
if (welcome) appendBubble('bot', welcome);
showFallback();
}
}
async function sendMessage(text) {
if (!text.trim() || IS_SENDING) return; // #7: queue verhindern
IS_SENDING = true;
const input = document.getElementById('chat-input');
const sendBtn = document.getElementById('chat-send-btn');
if (input) { input.disabled = true; input.value = ''; }
if (sendBtn) sendBtn.disabled = true;
appendBubble('user', text);
const payload = {
session_id: window.FESTIVAL.session_id,
entry: window.FESTIVAL.entry,
sponsor: window.FESTIVAL.sponsor,
message: text,
timestamp: new Date().toISOString(),
};
const typing = showTyping();
try {
const data = await postWebhook('/festival/message', payload);
if (typing) typing.remove();
const reply = (data?.reply && typeof data.reply === 'string' && data.reply.trim())
? data.reply : '…';
appendBubble('bot', reply);
} catch (_) {
if (typing) typing.remove();
showFallback();
} finally {
IS_SENDING = false;
if (input) { input.disabled = false; input.focus(); }
if (sendBtn) sendBtn.disabled = false;
}
}
// ─── Modal open / close ──────────────────────────────────────
function openChat(entry) {
// #5.1: set global state
window.FESTIVAL.entry = entry;
window.FESTIVAL.session_id = getOrCreateSessionId();
window.FESTIVAL.sponsor = CFG.sponsor_name || '';
const modal = document.getElementById('chat-modal');
const msgs = document.getElementById('chat-messages');
const input = document.getElementById('chat-input');
if (!modal) return;
// Clear previous
while (msgs?.firstChild) msgs.removeChild(msgs.firstChild);
if (input) { input.value = ''; input.disabled = false; }
const sb = document.getElementById('chat-send-btn');
if (sb) sb.disabled = false;
IS_SENDING = false;
modal.hidden = false;
document.body.style.overflow = 'hidden';
setTimeout(() => input?.focus(), 350);
startSession(entry);
}
function closeChat() {
const modal = document.getElementById('chat-modal');
if (!modal) return;
modal.hidden = true;
document.body.style.overflow = '';
}
// ─── Events ──────────────────────────────────────────────────
function bindEvents() {
document.getElementById('cta-truck')?.addEventListener('click', () => openChat('truck'));
document.getElementById('cta-family')?.addEventListener('click', () => openChat('family'));
document.getElementById('chat-close-btn')?.addEventListener('click', closeChat);
document.getElementById('chat-backdrop')?.addEventListener('click', closeChat);
// #7: Send on click
document.getElementById('chat-send-btn')?.addEventListener('click', () => {
const val = document.getElementById('chat-input')?.value?.trim();
if (val) sendMessage(val);
});
// #7: Enter = send, Shift+Enter = (no-op, single-line input anyway)
document.getElementById('chat-input')?.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const val = e.target.value.trim();
if (val) sendMessage(val);
}
});
// Close on Escape
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
const m = document.getElementById('chat-modal');
if (m && !m.hidden) closeChat();
}
});
}
// ─── Init ────────────────────────────────────────────────────
async function init() {
try {
CFG = await loadConfig();
} catch (err) {
console.error('[FestivalGuide] config.json failed:', err);
document.body.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;
font-family:sans-serif;color:#F5F0E8;background:#1A1208;padding:2rem;text-align:center;">
<div>
<p style="font-size:1.1rem;margin-bottom:1rem;">Festival Guide konnte nicht geladen werden.</p>
<a href="https://dekra-lausitzring.de/event/truck-country-wochenende/"
target="_blank" rel="noopener noreferrer" style="color:#D4820A;text-decoration:underline;">
Direkt zur offiziellen Eventseite →
</a>
</div>
</div>`;
return;
}
applyCSSVars(CFG);
applyImages(CFG);
applyHeader(CFG);
applyHero(CFG);
applyBenefits(CFG);
applyGuidePreview(CFG);
applyOfficialSection(CFG);
applyFooter(CFG);
applyChatUI(CFG);
bindEvents();
document.documentElement.classList.add('loaded');
}
document.readyState === 'loading'
? document.addEventListener('DOMContentLoaded', init)
: init();