a
This commit is contained in:
405
WebApp/app.js
Normal file
405
WebApp/app.js
Normal 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();
|
||||
Reference in New Issue
Block a user