446 lines
18 KiB
JavaScript
446 lines
18 KiB
JavaScript
/**
|
||
* 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');
|
||
|
||
// 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();
|