async function api(path, options = {}) { const response = await fetch(path, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }, ...options, }) const data = await response.json().catch(() => ({})) if (!response.ok) { const fallback = response.status === 502 ? 'Server nicht erreichbar (502). Bitte kurz warten und Seite neu laden – ggf. läuft ein Update auf project.webklar.com.' : `Fehler ${response.status}` const err = new Error(data.error || fallback) err.status = response.status if (data.retryAfterSeconds) err.retryAfterSeconds = data.retryAfterSeconds throw err } return data } function showError(el, message) { if (!el) return el.textContent = message el.classList.remove('hidden') } function hideError(el) { if (el) el.classList.add('hidden') } function escapeHtml(str) { return String(str ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function escapeAttr(str) { return escapeHtml(str).replace(/'/g, ''') } function getQueryParam(name) { return new URLSearchParams(window.location.search).get(name) || '' } function fmtDateShort(value) { if (!value) return '–' if (/^\d{2}\.\d{2}\.\d{4}$/.test(String(value))) return escapeHtml(value) const d = new Date(value) if (Number.isNaN(d.getTime())) return escapeHtml(value) return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) } function fmtDateTime(value) { if (!value) return '–' const d = new Date(value) if (Number.isNaN(d.getTime())) return escapeHtml(value) return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) } function fmtEuro(value) { return Number(value || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }) } function fmtBytes(bytes) { const n = Number(bytes || 0) if (n >= 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB` if (n >= 1024) return `${(n / 1024).toFixed(1)} KB` return `${n} B` } function fmtMinutes(min) { const n = Number(min || 0) if (!n) return '' const h = Math.floor(n / 60) const m = n % 60 return h ? `${h} h ${m} min` : `${m} min` } /* -------------------------------------------------------------- Projekte --- */ function featuresForProject(features, projectId) { return features.filter((f) => !f.projectId || f.projectId === projectId) } const PREVIEW_BASE_WIDTH = 1280 function setupPreviewFrame(frame) { const view = frame.querySelector('.preview-frame__view') if (!view) return const apply = () => { const w = frame.clientWidth if (w) view.style.transform = `scale(${w / PREVIEW_BASE_WIDTH})` } if (typeof ResizeObserver !== 'undefined') { new ResizeObserver(apply).observe(frame) } else { window.addEventListener('resize', apply) } apply() } function renderProjectCard(project, features) { const li = document.createElement('li') li.className = 'project-card' const projectFeatures = featuresForProject(features, project.id) const featureHtml = projectFeatures.length ? `
${projectFeatures.map((f) => `${escapeHtml(f.featureKey)}`).join('')}
` : '' const ready = isProjectPreviewReady(project) const url = project.previewUrl ? escapeAttr(project.previewUrl) : '' const previewHtml = ready && url ? ` Webseite öffnen ↗ ` : `
${project.previewUrl ? 'Vorschau wird vorbereitet …' : 'Noch keine Webseite veröffentlicht'}
` li.innerHTML = ` ${previewHtml}

${escapeHtml(project.projectName || project.subdomain || 'Projekt')}

Subdomain
${escapeHtml(project.subdomain || '–')}
Live-Domain
${project.liveDomain ? escapeHtml(project.liveDomain) : '–'}
Status
${escapeHtml(project.status || '–')}
${ready && url ? `

Projekt-Webseite öffnen ↗

` : ''} ${featureHtml}
` const frame = li.querySelector('[data-preview-frame]') if (frame) setupPreviewFrame(frame) const btn = li.querySelector('[data-details-btn]') const box = li.querySelector('[data-details-box]') let loaded = false btn.addEventListener('click', async () => { const isHidden = box.classList.contains('hidden') box.classList.toggle('hidden', !isHidden) btn.textContent = isHidden ? 'Details ausblenden' : 'Details anzeigen' if (isHidden && !loaded) { loaded = true await loadProjectDetails(project, box) } }) return li } function renderCommits(git) { const sizeInfo = git.repo ? `

Projektgröße: ${fmtBytes((git.repo.sizeKb || 0) * 1024)} · Branch: ${escapeHtml(git.repo.defaultBranch || 'main')}

` : '' if (!git.commits || !git.commits.length) { return `${sizeInfo}

Noch keine Änderungen vorhanden.

` } return sizeInfo + git.commits.map((c) => { const [title, ...body] = String(c.message || '').trim().split('\n') const bodyText = body.map((l) => l.trim()).filter(Boolean).join('\n') return `
${escapeHtml(title)} ${bodyText ? `
${escapeHtml(bodyText)}
` : ''}
${escapeHtml(c.shortSha)} · ${escapeHtml(c.authorName)} · ${fmtDateTime(c.date)}
` }).join('') } function renderFileOverview(files) { if (!files || !files.totalFiles) { return '

Keine Datei-Informationen verfügbar.

' } const rows = (files.topLevel || []).map((entry) => ` ${entry.type === 'dir' ? '📁' : '📄'} ${escapeHtml(entry.path)} ${entry.fileCount} ${fmtBytes(entry.sizeBytes)} `).join('') return `

${files.totalFiles} Dateien · ${fmtBytes(files.totalSizeBytes)}${files.truncated ? ' (Übersicht unvollständig – sehr großes Projekt)' : ''}

${rows}
Ordner / DateiDateienGröße
` } function renderWorkEntries(work) { if (!work.ticket) { return '

Für dieses Projekt gibt es noch kein Ticket.

' } const head = `

Ticket #${escapeHtml(work.ticket.woid)} „${escapeHtml(work.ticket.topic)}" · Status: ${escapeHtml(work.ticket.status)}

` if (!work.worksheets.length) { return `${head}

Noch keine dokumentierten Arbeiten.

` } return head + work.worksheets.map((ws) => `
${ws.isGit ? 'Git-Push' : `${escapeHtml(ws.serviceType || 'Arbeit')}`} ${escapeHtml(ws.employeeName)} · ${fmtDateShort(ws.date)} ${ws.totalTime ? `${fmtMinutes(ws.totalTime)}` : ''} ${ws.pending ? 'Zeiterfassung folgt' : ''}
${escapeHtml(ws.details)}
`).join('') } async function loadProjectDetails(project, box) { box.innerHTML = '

Lade Details…

' const [git, files, work] = await Promise.all([ api(`/api/projects/${encodeURIComponent(project.id)}/git`).catch(() => null), api(`/api/projects/${encodeURIComponent(project.id)}/files`).catch(() => null), api(`/api/projects/${encodeURIComponent(project.id)}/work`).catch(() => null), ]) box.innerHTML = `

Letzte Änderungen

${git ? renderCommits(git) : '

Git-Daten derzeit nicht verfügbar.

'}

Datei-Übersicht

${files ? renderFileOverview(files) : '

Datei-Übersicht derzeit nicht verfügbar.

'}

Arbeiten am Projekt

${work ? renderWorkEntries(work) : '

Keine Ticket-Daten verfügbar.

'} ` } /* -------------------------------------------------------- Login-Redirects --- */ function safeRedirectUrl(url) { try { const u = new URL(url) if (u.hostname.endsWith('.project.webklar.com') || u.hostname === 'project.webklar.com') { return u.toString() } } catch { /* ignore invalid URL */ } return null } function isProjectPreviewReady(project) { const status = (project?.provisioningStatus || project?.status || '').toLowerCase() return ['ready', 'deployed'].includes(status) && Boolean(project?.previewUrl) } function previewSubdomainFromUrl(url) { try { const host = new URL(url).hostname.toLowerCase() const match = host.match(/^([a-z0-9-]+)\.project\.webklar\.com$/) return match ? match[1] : '' } catch { return '' } } async function canAccessPreviewUrl(url) { const subdomain = previewSubdomainFromUrl(url) if (!subdomain) return false try { const access = await fetch( `/api/auth/preview-access?subdomain=${encodeURIComponent(subdomain)}`, { credentials: 'same-origin' } ).then((r) => r.json()) return Boolean(access.allowed) } catch { return false } } async function resolvePostLoginTarget() { const next = safeRedirectUrl(getQueryParam('next')) if (next && (await canAccessPreviewUrl(next))) { return next } return '/dashboard.html' } async function postLoginRedirect() { window.location.href = await resolvePostLoginTarget() } /* -------------------------------------------------------------- Login-Page --- */ async function initLoginPage() { const form = document.getElementById('login-form') const errorEl = document.getElementById('login-error') const btn = document.getElementById('login-btn') const errorFromQuery = getQueryParam('error') if (errorFromQuery) { showError(errorEl, errorFromQuery) } async function applyCooldown(seconds) { if (seconds <= 0) return showError( errorEl, `Zu viele Anmeldeversuche. Bitte warte noch ${seconds} Sekunden (ca. ${Math.ceil(seconds / 60)} Min.).` ) btn.disabled = true const tick = setInterval(async () => { const status = await fetch('/api/auth/login-status').then((r) => r.json()).catch(() => ({})) const left = status.retryAfterSeconds || 0 if (left <= 0) { clearInterval(tick) hideError(errorEl) btn.disabled = false return } showError( errorEl, `Zu viele Anmeldeversuche. Bitte warte noch ${left} Sekunden (ca. ${Math.ceil(left / 60)} Min.).` ) }, 1000) } try { const status = await fetch('/api/auth/login-status').then((r) => r.json()) if (status.blocked) { await applyCooldown(status.retryAfterSeconds) } } catch { /* ignore */ } try { const me = await api('/api/auth/me') if (me.authenticated && me.role === 'admin' && !me.viewAs) { window.location.href = '/admin.html' return } if (me.authenticated && me.customer) { await postLoginRedirect() return } } catch { /* not logged in */ } form?.addEventListener('submit', async (e) => { e.preventDefault() if (btn.disabled) return errorEl.classList.add('hidden') btn.disabled = true btn.classList.add('is-loading') btn.setAttribute('aria-busy', 'true') let cooldownActive = false try { const result = await api('/api/auth/login', { method: 'POST', body: JSON.stringify({ email: document.getElementById('email').value, password: document.getElementById('password').value, }), }) if (result.role === 'admin') { window.location.href = '/admin.html' return } await postLoginRedirect() return } catch (err) { showError(errorEl, err.message) if (err.status === 429) { cooldownActive = true await applyCooldown(err.retryAfterSeconds || 900) } } finally { btn.classList.remove('is-loading') btn.removeAttribute('aria-busy') if (!cooldownActive) btn.disabled = false } }) } /* --------------------------------------------------------------- View-as --- */ function renderViewAsBanner(me) { const banner = document.getElementById('viewas-banner') if (!banner || !me.viewAs) return banner.classList.remove('hidden') banner.innerHTML = ` Admin-Ansicht: Du siehst das Portal als ${escapeHtml(me.customer?.name || 'Kunde')}. ` document.getElementById('exit-viewas-btn')?.addEventListener('click', async () => { await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {}) window.location.href = '/admin.html' }) } /* ------------------------------------------------------------- Rechnungen --- */ async function loadCustomerInvoices() { const body = document.getElementById('invoices-body') if (!body) return try { const data = await api('/api/invoices') if (!data.linked) { body.innerHTML = '

Für dein Konto sind noch keine Rechnungen hinterlegt.

' return } if (!data.invoices.length) { body.innerHTML = '

Es liegen noch keine Rechnungen vor.

' return } body.innerHTML = ` ${data.invoices.map((i) => ` `).join('')}
Nr.DatumBetragOffenStatus
${escapeHtml(i.number)} ${fmtDateShort(i.date)} ${fmtEuro(i.amount)} ${fmtEuro(i.balance)} ${escapeHtml(i.status)} ${i.portalUrl ? `Ansehen / Zahlen · ` : ''} PDF
` } catch (err) { body.innerHTML = `

Rechnungen konnten nicht geladen werden: ${escapeHtml(err.message)}

` } } /* -------------------------------------------------------------- Aktivität --- */ async function loadCustomerActivity() { const body = document.getElementById('activity-body') if (!body) return try { const { tickets } = await api('/api/activity') if (!tickets || !tickets.length) { body.innerHTML = '

Es wurden noch keine Arbeiten dokumentiert.

' return } body.innerHTML = tickets.map((t) => `
${escapeHtml(t.woid ? `#${t.woid} ` : '')}${escapeHtml(t.topic || '–')} ${escapeHtml(t.status || '–')} · ${fmtDateShort(t.createdAt)}
${(t.worksheets || []).map((w) => `
${w.isGit ? 'Git-Push' : `${escapeHtml(w.serviceType || 'Arbeit')}`} ${w.employeeName ? ` ${escapeHtml(w.employeeName)}` : ''} · ${fmtDateShort(w.date)} ${Number(w.totalTime) ? `${fmtMinutes(w.totalTime)}` : ''} ${w.pending ? 'Zeiterfassung folgt' : ''} ${w.details ? `
${escapeHtml(w.details)}
` : ''}
`).join('')}
`).join('') } catch (err) { body.innerHTML = `

Aktivität konnte nicht geladen werden: ${escapeHtml(err.message)}

` } } /* ------------------------------------------------------------------ Chat --- */ const chatState = { lastCreatedAt: '', pollTimer: null, badgeTimer: null, initialized: false, viewAs: false, } function appendChatMessages(messages) { const box = document.getElementById('chat-messages') if (!box || !messages.length) return for (const msg of messages) { const div = document.createElement('div') div.className = `chat-bubble ${msg.sender === 'customer' ? 'mine' : 'theirs'}` div.innerHTML = `${escapeHtml(msg.text)}${msg.sender === 'customer' ? 'Du' : escapeHtml(msg.senderName || 'Webklar-Team')} · ${fmtDateTime(msg.createdAt)}` box.appendChild(div) if (msg.createdAt > chatState.lastCreatedAt) chatState.lastCreatedAt = msg.createdAt } box.scrollTop = box.scrollHeight } function updateChatBadge(unread) { const badge = document.getElementById('chat-badge') if (!badge) return if (unread > 0) { badge.textContent = unread badge.classList.remove('hidden') } else { badge.classList.add('hidden') } } async function pollChatMessages() { if (document.hidden) return try { const q = chatState.lastCreatedAt ? `?since=${encodeURIComponent(chatState.lastCreatedAt)}` : '' const data = await api(`/api/chat/messages${q}`) appendChatMessages(data.messages || []) if (isChatTabActive() && (data.unread || 0) > 0) { await api('/api/chat/read', { method: 'POST' }).catch(() => {}) updateChatBadge(0) } } catch { /* naechster Poll versucht es erneut */ } } function isChatTabActive() { return document.querySelector('.portal-tab[data-tab="chat"]')?.classList.contains('active') } async function sendChatMessage() { const input = document.getElementById('chat-input') const btn = document.getElementById('chat-send') const text = input.value.trim() if (!text) return btn.disabled = true try { const { message } = await api('/api/chat/messages', { method: 'POST', body: JSON.stringify({ text }), }) input.value = '' appendChatMessages([message]) } catch (err) { alert(err.status === 403 ? err.message : 'Nachricht konnte nicht gesendet werden: ' + err.message) } finally { btn.disabled = false input.focus() } } async function initChat() { if (chatState.initialized) return chatState.initialized = true const input = document.getElementById('chat-input') const btn = document.getElementById('chat-send') if (chatState.viewAs) { input.disabled = true btn.disabled = true input.placeholder = 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.' } else { btn.addEventListener('click', sendChatMessage) input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() sendChatMessage() } }) } await pollChatMessages() await api('/api/chat/read', { method: 'POST' }).catch(() => {}) updateChatBadge(0) chatState.pollTimer = setInterval(() => { if (isChatTabActive()) pollChatMessages() }, 10000) } async function pollChatBadge() { if (document.hidden || isChatTabActive()) return try { const { unread } = await api('/api/chat/unread-count') updateChatBadge(unread) } catch { /* ignore */ } } /* -------------------------------------------------------------- Dashboard --- */ // --------------------------------------------------------------------------- // Wartungs-Abo (Startseite): Angebot + Bestellung bzw. Stundenkonto // --------------------------------------------------------------------------- const ABO_FEATURES = [ ['Betrieb & Pflege Ihrer Website/Anwendung', 'Server in Deutschland, Updates, Sicherheit, tägliche Datensicherung, Überwachung'], ['1 Domain inklusive', 'Registrierung auf Ihren Namen, DNS- und Zertifikatsverwaltung'], ['1 E-Mail-Postfach', 'mit Ihrer eigenen Domain, 5 GB Speicher'], ['1 Stunde Arbeitszeit pro Monat', 'nicht genutzte Zeit bleibt 12 Monate verfügbar'], ] function fmtHours(min) { const n = Math.max(0, Number(min || 0)) const h = Math.floor(n / 60) const m = n % 60 return m ? `${h}:${String(m).padStart(2, '0')} h` : `${h} h` } function aboFeaturesHtml() { return `` } function lateStartDate() { // Beginn nach der 14-tägigen Widerrufsfrist = 15 Tage nach heute const d = new Date() d.setDate(d.getDate() + 15) return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) } function renderAboOffer(card, data, { ended } = {}) { const available = data.available card.innerHTML = `

${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}

Wartungs-Abo

50 €pro Monat · monatlich kündbar
${aboFeaturesHtml()}
${available ? 'Keine Mindestlaufzeit. Zahlung per Kreditkarte oder SEPA-Lastschrift. Gemäß § 19 UStG ohne Umsatzsteuer.' : 'Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'}
` const startBtn = card.querySelector('#abo-start') const order = card.querySelector('#abo-order') startBtn?.addEventListener('click', () => { order.classList.remove('hidden') startBtn.classList.add('hidden') order.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) }) card.querySelector('#abo-buy')?.addEventListener('click', async (ev) => { const btn = ev.currentTarget const errEl = card.querySelector('#abo-error') hideError(errEl) const acceptTerms = card.querySelector('#abo-terms').checked const startChoice = card.querySelector('input[name="abo-start-choice"]:checked')?.value || '' if (!acceptTerms) { showError(errEl, 'Bitte stimmen Sie den Vertragsbedingungen zu.') return } if (!startChoice) { showError(errEl, 'Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.') return } btn.disabled = true btn.textContent = 'Weiter zur Zahlung…' try { const { url } = await api('/api/abo/checkout', { method: 'POST', body: JSON.stringify({ acceptTerms, startChoice, termsVersion: data.termsVersion }), }) window.location.href = url } catch (err) { showError(errEl, err.message) btn.disabled = false btn.textContent = 'Zahlungspflichtig bestellen' } }) } function renderAboActive(card, data) { const sub = data.subscription const l = data.ledger || { balanceMinutes: 0, credits: [], usage: [] } let statusLine if (sub.serviceStartsAt) { statusLine = `beginnt am ${fmtDateShort(sub.serviceStartsAt)}` } else if (sub.cancelAtPeriodEnd) { statusLine = `gekündigt zum ${fmtDateShort(sub.currentPeriodEnd)}` } else if (sub.paymentProblem) { statusLine = 'Zahlung offen' } else { statusLine = `aktiv nächste Abbuchung ${fmtDateShort(sub.currentPeriodEnd)}` } const usageRows = (l.usage || []).map((u) => ` ${fmtDateShort(u.date)} ${escapeHtml(u.topic || u.details || 'Arbeitszeit')}${u.woid ? ` #${escapeHtml(u.woid)}` : ''} −${fmtHours(u.minutes)}${u.overageMinutes ? ` +${fmtHours(u.overageMinutes)} extra` : ''}`).join('') const creditRows = (l.credits || []).slice().reverse().map((c) => ` ${fmtDateShort(c.grantedAt)}Monatskontingent +${fmtHours(c.minutes)} ${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDateShort(c.expiresAt)}`}`).join('') card.innerHTML = `

Ihr Wartungs-Abo

Stundenkontingent

${statusLine}
${sub.paymentProblem ? '
Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.
' : ''} ${sub.serviceStartsAt ? `
Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist am ${fmtDateShort(sub.serviceStartsAt)}. Dann wird auch zum ersten Mal abgebucht, und Ihre erste Stunde steht bereit.
` : ''}
${fmtHours(l.balanceMinutes)}verfügbare Zeit
${fmtHours(l.usedMinutes)}bisher genutzt
${l.nextExpiry ? `
${fmtHours(l.nextExpiry.minutes)}verfallen am ${fmtDateShort(l.nextExpiry.at)}
` : ''}

Jeden bezahlten Monat kommt 1 Stunde dazu. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst. Aufträge schicken Sie uns einfach über den Chat.

${sub.withdrawalOpen ? `Vertrag widerrufenmöglich bis ${fmtDateShort(sub.withdrawalUntil)}` : ''} Leistungen & Bedingungen
${usageRows || creditRows ? `
Verlauf anzeigen ${usageRows}${creditRows}
DatumVorgangZeit
` : ''} ` card.querySelector('#abo-manage')?.addEventListener('click', async (ev) => { const btn = ev.currentTarget btn.disabled = true try { const { url } = await api('/api/abo/portal', { method: 'POST' }) window.location.href = url } catch (err) { showError(card.querySelector('#abo-error'), err.message) btn.disabled = false } }) } async function loadAboCard({ afterCheckout = false } = {}) { const card = document.getElementById('abo-card') if (!card) return try { let data = await api('/api/abo') // Direkt nach dem Checkout kann der Stripe-Webhook noch unterwegs sein for (let i = 0; afterCheckout && !data.subscription?.active && i < 10; i += 1) { card.classList.remove('hidden') card.innerHTML = '

Vielen Dank!

Ihr Abo wird aktiviert…

Das dauert nur einen Moment.

' await new Promise((r) => setTimeout(r, 2000)) data = await api('/api/abo') } if (data.subscription?.active) { renderAboActive(card, data) } else { renderAboOffer(card, data, { ended: Boolean(data.subscription) }) } card.classList.remove('hidden') } catch (err) { console.warn('Abo konnte nicht geladen werden:', err.message) } } async function initDashboardPage() { const meta = document.getElementById('customer-meta') const list = document.getElementById('projects') const loading = document.getElementById('loading') const empty = document.getElementById('empty') const loadError = document.getElementById('load-error') const logoutBtn = document.getElementById('logout-btn') logoutBtn?.addEventListener('click', async () => { await api('/api/auth/logout', { method: 'POST' }) window.location.href = '/login.html' }) // Tabs (Muster wie Admin-Dashboard) const loadedTabs = new Set(['projects']) const lazyLoaders = { activity: loadCustomerActivity, invoices: loadCustomerInvoices, chat: initChat, } document.querySelectorAll('.portal-tab').forEach((tab) => { tab.addEventListener('click', () => { document.querySelectorAll('.portal-tab').forEach((t) => t.classList.remove('active')) document.querySelectorAll('.portal-section').forEach((s) => s.classList.remove('active')) tab.classList.add('active') document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active') const name = tab.dataset.tab if (!loadedTabs.has(name) && lazyLoaders[name]) { loadedTabs.add(name) lazyLoaders[name]() } if (name === 'chat') { api('/api/chat/read', { method: 'POST' }).catch(() => {}) updateChatBadge(0) } }) }) try { const me = await api('/api/auth/me') if (!me.authenticated) { window.location.href = '/login.html' return } if (me.role === 'admin' && !me.customer) { window.location.href = '/admin.html' return } if (!me.customer) { window.location.href = '/login.html' return } const customer = me.customer chatState.viewAs = Boolean(me.viewAs) renderViewAsBanner(me) const codeSuffix = customer.code ? ` · Kundennr. ${customer.code}` : '' meta.textContent = (customer.name ? `${customer.name} (${customer.email})` : customer.email) + codeSuffix const aboParam = getQueryParam('abo') if (aboParam) window.history.replaceState(null, '', '/dashboard.html') loadAboCard({ afterCheckout: aboParam === 'success' }) const [{ projects }, { features }] = await Promise.all([ api('/api/projects'), api('/api/features'), ]) loading.classList.add('hidden') if (projects.length) { list.classList.remove('hidden') list.innerHTML = '' for (const project of projects) { list.appendChild(renderProjectCard(project, features)) } } else { empty.classList.remove('hidden') } // Chat-Badge global pollen (auch wenn Chat-Tab nicht offen ist) pollChatBadge() chatState.badgeTimer = setInterval(pollChatBadge, 45000) } catch (err) { loading.classList.add('hidden') if (err.status === 401 || err.message.includes('Nicht angemeldet')) { window.location.href = '/login.html' return } showError(loadError, err.message) } } window.initLoginPage = initLoginPage window.initDashboardPage = initDashboardPage