Files
DEKRA_sponsor_seite/WebApp/app.js
2026-02-26 09:51:27 +01:00

435 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 ${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();