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() } // Links aus Admin-Mails (z. B. /admin.html?gutschrift=) nach dem Login wieder aufnehmen function adminLoginTarget() { const next = getQueryParam('next') return /^\/admin\.html(\?[A-Za-z0-9=&_-]*)?$/.test(next) ? next : '/admin.html' } /* -------------------------------------------------------------- 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 = adminLoginTarget() 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 = adminLoginTarget() 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 // Individuelles Angebot (vom WEBklar-Team angelegt), z. B. erste 2 Monate je 25 € const offer = data.offer const offerPrice = offer ? (offer.monthlyPriceCents % 100 === 0 ? `${offer.monthlyPriceCents / 100} €` : fmtEuro(offer.monthlyPriceCents / 100)) : '' card.innerHTML = `

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

Wartungs-Abo

${offer ? `
${offerPrice}${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50 € · 12 Monate Mindestlaufzeit
` : '
50 €pro Monat · 12 Monate Mindestlaufzeit
'}
${offer ? `
Ihr persönliches Angebot

${escapeHtml(offer.text)}. Sie sparen ${fmtEuro(offer.savingsCents / 100)}.

${offer.note ? `

${escapeHtml(offer.note)}

` : ''}

Gültig bis ${fmtDateShort(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt

` : ''} ${aboFeaturesHtml()} ${available ? '

Schon bei einem anderen Anbieter für Domain, Hosting oder E-Mail bezahlt? Nach dem Abschluss können Sie eine Übernahmegutschrift beantragen – wir rechnen die doppelt bezahlte Zeit anteilig an.

' : ''}
${available ? 'Mindestlaufzeit 12 Monate, danach monatlich kündbar. 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, offerId: offer?.id || '' }), }) 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.cancelAt || 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.minTermEnd && !sub.cancelAtPeriodEnd ? ` Mindestlaufzeit bis ${fmtDateShort(sub.minTermEnd)}, danach monatlich kündbar.` : ''}

${sub.offerText ? `

Ihr Angebot: ${escapeHtml(sub.offerText)}.

` : ''}
${sub.withdrawalOpen ? `Vertrag widerrufenmöglich bis ${fmtDateShort(sub.withdrawalUntil)}` : ''} Leistungen & Bedingungen ${sub.cancelAtPeriodEnd ? '' : 'Kündigen'}
${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') loadTakeoverCard({ abo: data }) } catch (err) { console.warn('Abo konnte nicht geladen werden:', err.message) } } // --------------------------------------------------------------------------- // Übernahmegutschrift: Antrag stellen, Stand sehen, Rückfragen beantworten // --------------------------------------------------------------------------- const takeoverState = { abo: null, customer: null } const TAKEOVER_STATUS = { offen: ['pill-warn', 'In Prüfung'], rueckfrage: ['pill-amber', 'Rückfrage'], genehmigt: ['pill-ok', 'Genehmigt'], abgelehnt: ['pill-muted', 'Nicht genehmigt'], } // Bei Textänderungen config.takeover.conditionsVersion auf dem Server hochziehen const TAKEOVER_CONDITIONS = [ 'Die Übernahmegutschrift gibt es für Leistungen, die das Wartungs-Abo ersetzt (Domain, Hosting bzw. Betrieb der Website, E-Mail-Postfach) und die Sie bei Ihrem bisherigen Anbieter bereits im Voraus bezahlt haben.', 'Wir schreiben den Anteil der Rechnung gut, der auf die Zeit ab Leistungsbeginn Ihres Abos bis zum Ende des bezahlten Zeitraums entfällt, tagesgenau berechnet.', 'Nachweis ist die Rechnung des bisherigen Anbieters, ausgestellt auf Sie, mit Leistung, Zeitraum und Betrag. Wir können weitere Nachweise anfordern, etwa die Kündigungsbestätigung. Erstattet Ihr bisheriger Anbieter Beträge für diesen Zeitraum, teilen Sie uns das bitte mit.', 'Die Gutschrift wird mit Ihren nächsten Monatsgebühren verrechnet. Sie wird nicht ausgezahlt und ist nicht übertragbar; ein bei Vertragsende noch nicht verrechneter Rest entfällt.', 'Die Übernahmegutschrift ist eine freiwillige Leistung von WEBklar. Über Ihren Antrag entscheiden wir nach Prüfung und antworten Ihnen per E-Mail.', ] const TAKEOVER_MONTHS = [[1, '1 Monat'], [3, '3 Monate'], [6, '6 Monate'], [12, '12 Monate (Jahresrechnung)'], [24, '24 Monate'], [36, '36 Monate']] function takeoverStatusPill(status) { const [cls, label] = TAKEOVER_STATUS[status] || ['pill-muted', status] return `${label}` } function renderTakeoverItem(r) { const facts = [ `eingereicht am ${fmtDateShort(r.createdAt)}`, `bisher bei ${escapeHtml(r.provider)}${r.domain ? ` (${escapeHtml(r.domain)})` : ''}`, `${fmtEuro(r.invoiceAmountCents / 100)} für ${r.billingMonths === 1 ? '1 Monat' : `${r.billingMonths} Monate`}, bezahlt bis ${fmtDateShort(r.paidUntil)}`, ] let body if (r.status === 'genehmigt') { body = `

Gutschrift: ${fmtEuro(r.amountCents / 100)} – wird mit Ihren nächsten Monatsgebühren verrechnet.

${r.decisionNote ? `

${escapeHtml(r.decisionNote)}

` : ''}` } else if (r.status === 'abgelehnt') { body = `

${escapeHtml(r.decisionNote)}

` } else if (r.status === 'rueckfrage') { body = `
Unsere Rückfrage:
${escapeHtml(r.question)}
` } else { body = '

Wir prüfen Ihre Angaben und antworten Ihnen per E-Mail.

' } return `
Antrag ${escapeHtml(r.ref)} · ${escapeHtml(r.services)}${takeoverStatusPill(r.status)}
${facts.join(' · ')}
${body}
` } function takeoverFormHtml(limits) { const email = takeoverState.customer?.email || '' const maxMb = Math.round(limits.maxTotalBytes / 1024 / 1024) return ` ` } function readFileAsBase64(file) { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(String(reader.result).split(',')[1] || '') reader.onerror = () => reject(new Error(`„${file.name}“ konnte nicht gelesen werden.`)) reader.readAsDataURL(file) }) } async function collectTakeoverFiles(input, limits, { required }) { const files = [...(input?.files || [])] const mb = (bytes) => Math.round(bytes / 1024 / 1024) if (required && !files.length) throw new Error('Bitte laden Sie die Rechnung Ihres bisherigen Anbieters hoch.') if (files.length > limits.maxFiles) throw new Error(`Bitte höchstens ${limits.maxFiles} Dateien auf einmal hochladen.`) const tooBig = files.find((f) => f.size > limits.maxFileBytes) if (tooBig) throw new Error(`„${tooBig.name}“ ist größer als ${mb(limits.maxFileBytes)} MB.`) if (files.reduce((s, f) => s + f.size, 0) > limits.maxTotalBytes) { throw new Error(`Die Dateien sind zusammen größer als ${mb(limits.maxTotalBytes)} MB.`) } return Promise.all(files.map(async (f) => ({ name: f.name, data: await readFileAsBase64(f) }))) } async function submitTakeoverForm(form, limits) { const errEl = form.querySelector('#tk-error') const btn = form.querySelector('#tk-submit') hideError(errEl) const value = (id) => form.querySelector(id).value.trim() const payload = { services: [...form.querySelectorAll('input[name="services"]:checked')].map((i) => i.value), provider: value('#tk-provider'), domain: value('#tk-domain'), invoiceAmount: value('#tk-amount'), billingMonths: Number(value('#tk-months')), paidUntil: value('#tk-paid-until'), message: value('#tk-message'), email: value('#tk-email'), consent: form.querySelector('#tk-consent').checked, } try { if (!payload.services.length) throw new Error('Bitte wählen Sie, welche Leistung Sie bisher woanders bezahlt haben.') if (!payload.provider) throw new Error('Bitte nennen Sie Ihren bisherigen Anbieter.') if (!payload.invoiceAmount || !payload.billingMonths || !payload.paidUntil) { throw new Error('Bitte geben Sie Rechnungsbetrag, Zeitraum und „Bezahlt bis“ an.') } if (!payload.consent) throw new Error('Bitte bestätigen Sie Ihre Angaben und die Bedingungen.') btn.disabled = true btn.textContent = 'Wird gesendet…' payload.files = await collectTakeoverFiles(form.querySelector('#tk-files'), limits, { required: true }) await api('/api/abo/takeover-credits', { method: 'POST', body: JSON.stringify(payload) }) await loadTakeoverCard({ notice: `Vielen Dank! Ihr Antrag ist eingegangen. Die Eingangsbestätigung haben wir an ${payload.email} geschickt; unsere Antwort kommt ebenfalls per E-Mail.`, }) } catch (err) { showError(errEl, err.message) btn.disabled = false btn.textContent = 'Antrag absenden' } } async function submitTakeoverReply(form, limits) { const errEl = form.querySelector('.error') const btn = form.querySelector('button[type="submit"]') hideError(errEl) btn.disabled = true try { const message = form.querySelector('textarea').value.trim() const files = await collectTakeoverFiles(form.querySelector('input[type="file"]'), limits, { required: false }) if (!message && !files.length) throw new Error('Bitte schreiben Sie eine Antwort oder laden Sie einen Nachweis hoch.') await api(`/api/abo/takeover-credits/${encodeURIComponent(form.dataset.replyFor)}/reply`, { method: 'POST', body: JSON.stringify({ message, files }), }) await loadTakeoverCard({ notice: 'Danke für Ihre Antwort! Wir prüfen Ihren Antrag weiter und melden uns per E-Mail.' }) } catch (err) { showError(errEl, err.message) btn.disabled = false } } async function loadTakeoverCard({ abo, notice = '' } = {}) { const card = document.getElementById('takeover-card') if (!card) return if (abo) takeoverState.abo = abo const active = Boolean(takeoverState.abo?.subscription?.active) if (!takeoverState.abo?.subscription) { card.classList.add('hidden') return } let data try { data = await api('/api/abo/takeover-credits') } catch (err) { console.warn('Übernahmegutschrift konnte nicht geladen werden:', err.message) return } const requests = data.requests || [] if (!active && !requests.length) { card.classList.add('hidden') return } card.innerHTML = `

Wechsel ohne doppelte Kosten

Übernahmegutschrift

Sie haben Domain, Hosting oder E-Mail bei Ihrem bisherigen Anbieter schon im Voraus bezahlt? Den Anteil, den Sie ab Beginn Ihres Abos doppelt zahlen, schreiben wir Ihnen gut und verrechnen ihn mit Ihren nächsten Monatsgebühren. Schicken Sie uns dazu einfach die Rechnung.

${notice ? `
${escapeHtml(notice)}
` : ''} ${active ? `
${chatState.viewAs ? 'Im Ansichtsmodus kann kein Antrag gestellt werden.' : ''}
${takeoverFormHtml(data.limits)}` : ''} ${requests.length ? `
${requests.map(renderTakeoverItem).join('')}
` : ''}` card.classList.remove('hidden') const form = card.querySelector('#tk-form') const startBtn = card.querySelector('#tk-start') startBtn?.addEventListener('click', () => { form.classList.remove('hidden') startBtn.classList.add('hidden') form.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) }) card.querySelector('#tk-cancel')?.addEventListener('click', () => { form.classList.add('hidden') startBtn?.classList.remove('hidden') }) form?.addEventListener('submit', (ev) => { ev.preventDefault() submitTakeoverForm(form, data.limits) }) card.querySelectorAll('form[data-reply-for]').forEach((f) => f.addEventListener('submit', (ev) => { ev.preventDefault() submitTakeoverReply(f, data.limits) })) } 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) takeoverState.customer = customer 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