/** * 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', '--c-truck-bg': cfg.truck_bg_color || '#0A1F09', '--truck-bg-gradient': cfg.truck_bg_gradient || 'linear-gradient(160deg, #0A1F09 0%, #1E4A1A 50%, #2A6E22 100%)', '--truck-overlay': cfg.truck_overlay_gradient || 'linear-gradient(160deg, rgba(0,0,0,0.42) 0%, rgba(10,31,9,0.38) 50%, rgba(0,0,0,0.52) 100%)', '--truck-tag-bg': cfg.truck_tag_bg || 'rgba(74,138,66,0.22)', '--truck-tag-border': cfg.truck_tag_border || 'rgba(74,138,66,0.4)', '--truck-tag-color': cfg.truck_tag_color || '#A8E0A0', '--truck-title-color': cfg.truck_title_color || '#C8F0C0', '--truck-cta-bg': cfg.truck_cta_bg || '#5AAA50', '--truck-cta-color': cfg.truck_cta_color || '#061206', '--truck-bullet-color': cfg.truck_bullet_color || '#5AAA50', '--c-family-bg': cfg.family_bg_color || '#2A1200', '--family-bg-gradient': cfg.family_bg_gradient || 'linear-gradient(200deg, #2A1200 0%, #5C2E00 50%, #8B4500 100%)', '--family-overlay': cfg.family_overlay_gradient || 'linear-gradient(200deg, rgba(0,0,0,0.42) 0%, rgba(42,18,0,0.38) 50%, rgba(0,0,0,0.52) 100%)', '--family-tag-bg': cfg.family_tag_bg || 'rgba(196,122,30,0.22)', '--family-tag-border': cfg.family_tag_border || 'rgba(196,122,30,0.45)', '--family-tag-color': cfg.family_tag_color || '#F5C878', '--family-title-color': cfg.family_title_color || '#FFE4B0', '--family-cta-bg': cfg.family_cta_bg || '#D4820A', '--family-cta-color': cfg.family_cta_color || '#1F1005', '--family-bullet-color': cfg.family_bullet_color || '#D4820A', '--font-heading': cfg.font_heading || "'Bebas Neue', sans-serif", '--font-body': cfg.font_body || "'Barlow', 'Segoe UI', sans-serif", '--font-condensed': cfg.font_condensed || "'Barlow Condensed', sans-serif", '--radius': cfg.border_radius || '4px', '--radius-lg': cfg.border_radius_lg || '10px', }; 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'); // #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 ────────────────────────────────────────── /** Sanitise bot HTML: allow only …, escape everything else */ function sanitizeBotHTML(raw) { // 1. Escape the entire string first const esc = raw .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); // 2. Re-enable only safe tags return esc.replace( /<a\s+href="(https?:\/\/[^&"<>]+)"(?:\s+target="(_blank)")?>(.+?)<\/a>/gi, (_, href, target, label) => `${label}` ); } 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'); // Bot messages: render safe links; user/error messages: plain text only if (type === 'bot' || type === 'error') { div.innerHTML = sanitizeBotHTML(text); } else { div.textContent = text; } 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 = ''; 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) ────────────────────────────────── /** Extract reply text from various n8n response formats */ function extractReply(data) { if (!data) return null; // Handle array responses (n8n sometimes returns [{ ... }]) const obj = Array.isArray(data) ? data[0] : data; if (!obj || typeof obj !== 'object') return typeof data === 'string' ? data : null; // Check common field names const keys = ['reply', 'output', 'text', 'message', 'response', 'answer', 'content']; for (const k of keys) { if (obj[k] && typeof obj[k] === 'string' && obj[k].trim()) return obj[k]; } // Check nested: obj.data.text, obj.result.text, etc. for (const wrapper of ['data', 'result', 'body']) { if (obj[wrapper] && typeof obj[wrapper] === 'object') { for (const k of keys) { if (obj[wrapper][k] && typeof obj[wrapper][k] === 'string' && obj[wrapper][k].trim()) return obj[wrapper][k]; } } } return null; } async function postWebhook(body) { const url = (CFG.webhook_base || '').replace(/\/$/, ''); const res = await fetch(url, { 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 = { action: 'start', 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(payload); console.log('[FestivalGuide] start response:', JSON.stringify(data)); if (typing) typing.remove(); const reply = extractReply(data) || (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 = { action: 'message', 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(payload); console.log('[FestivalGuide] message response:', JSON.stringify(data)); if (typing) typing.remove(); const reply = extractReply(data) || '…'; 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 = ` Festival Guide konnte nicht geladen werden. Direkt zur offiziellen Eventseite → `; 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'); // Auto-open chat if URL contains ?open_chat parameter // Usage: dekra.optki.de/?open_chat=true (defaults to truck) // dekra.optki.de/?open_chat=truck // dekra.optki.de/?open_chat=family const urlParams = new URLSearchParams(window.location.search); const openChatParam = urlParams.get('open_chat'); if (openChatParam) { const entry = (openChatParam === 'family') ? 'family' : 'truck'; setTimeout(() => openChat(entry), 300); } } document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init();
Festival Guide konnte nicht geladen werden.