Files
Webklar-Kundenbereich/public/app.js
T

883 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function escapeAttr(str) {
return escapeHtml(str).replace(/'/g, '&#39;')
}
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
? `<div class="feature-tags">${projectFeatures.map((f) => `<span class="feature-tag">${escapeHtml(f.featureKey)}</span>`).join('')}</div>`
: ''
const ready = isProjectPreviewReady(project)
const url = project.previewUrl ? escapeAttr(project.previewUrl) : ''
const previewHtml = ready && url
? `
<a class="preview-frame" href="${url}" target="_blank" rel="noreferrer" data-preview-frame aria-label="Projekt-Webseite in neuem Tab öffnen">
<iframe class="preview-frame__view" src="${url}" loading="lazy" scrolling="no" tabindex="-1" aria-hidden="true"></iframe>
<span class="preview-frame__hint">Webseite öffnen ↗</span>
</a>`
: `<div class="preview-frame preview-frame--empty"><span>${project.previewUrl ? 'Vorschau wird vorbereitet …' : 'Noch keine Webseite veröffentlicht'}</span></div>`
li.innerHTML = `
${previewHtml}
<h2>${escapeHtml(project.projectName || project.subdomain || 'Projekt')}</h2>
<dl>
<dt>Subdomain</dt><dd>${escapeHtml(project.subdomain || '–')}</dd>
<dt>Live-Domain</dt><dd>${project.liveDomain ? escapeHtml(project.liveDomain) : '–'}</dd>
<dt>Status</dt><dd>${escapeHtml(project.status || '–')}</dd>
</dl>
${ready && url ? `<p class="preview-open"><a class="btn btn-green" href="${url}" target="_blank" rel="noreferrer">Projekt-Webseite öffnen ↗</a></p>` : ''}
${featureHtml}
<div class="project-details-toggle">
<button type="button" class="portal-btn-outline" data-details-btn>Details anzeigen</button>
</div>
<div class="project-details hidden" data-details-box></div>
`
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
? `<p class="muted">Projektgröße: ${fmtBytes((git.repo.sizeKb || 0) * 1024)} · Branch: ${escapeHtml(git.repo.defaultBranch || 'main')}</p>`
: ''
if (!git.commits || !git.commits.length) {
return `${sizeInfo}<p class="muted">Noch keine Änderungen vorhanden.</p>`
}
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 `
<div class="commit-item">
<strong>${escapeHtml(title)}</strong>
${bodyText ? `<div class="commit-body">${escapeHtml(bodyText)}</div>` : ''}
<div class="commit-meta">${escapeHtml(c.shortSha)} · ${escapeHtml(c.authorName)} · ${fmtDateTime(c.date)}</div>
</div>`
}).join('')
}
function renderFileOverview(files) {
if (!files || !files.totalFiles) {
return '<p class="muted">Keine Datei-Informationen verfügbar.</p>'
}
const rows = (files.topLevel || []).map((entry) => `
<tr>
<td>${entry.type === 'dir' ? '📁' : '📄'} ${escapeHtml(entry.path)}</td>
<td>${entry.fileCount}</td>
<td>${fmtBytes(entry.sizeBytes)}</td>
</tr>`).join('')
return `
<p class="muted">${files.totalFiles} Dateien · ${fmtBytes(files.totalSizeBytes)}${files.truncated ? ' (Übersicht unvollständig – sehr großes Projekt)' : ''}</p>
<table class="simple-table">
<thead><tr><th>Ordner / Datei</th><th>Dateien</th><th>Größe</th></tr></thead>
<tbody>${rows}</tbody>
</table>`
}
function renderWorkEntries(work) {
if (!work.ticket) {
return '<p class="muted">Für dieses Projekt gibt es noch kein Ticket.</p>'
}
const head = `<p class="muted">Ticket #${escapeHtml(work.ticket.woid)} „${escapeHtml(work.ticket.topic)}" · Status: ${escapeHtml(work.ticket.status)}</p>`
if (!work.worksheets.length) {
return `${head}<p class="muted">Noch keine dokumentierten Arbeiten.</p>`
}
return head + work.worksheets.map((ws) => `
<div class="activity-item">
<div>
${ws.isGit ? '<span class="pill pill-git">Git-Push</span>' : `<span class="pill pill-muted">${escapeHtml(ws.serviceType || 'Arbeit')}</span>`}
<strong> ${escapeHtml(ws.employeeName)}</strong>
<span class="muted"> · ${fmtDateShort(ws.date)}</span>
${ws.totalTime ? `<span class="pill pill-ok">${fmtMinutes(ws.totalTime)}</span>` : ''}
${ws.pending ? '<span class="pill pill-muted">Zeiterfassung folgt</span>' : ''}
</div>
<div class="activity-ws">${escapeHtml(ws.details)}</div>
</div>`).join('')
}
async function loadProjectDetails(project, box) {
box.innerHTML = '<p class="muted">Lade Details…</p>'
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 = `
<h3>Letzte Änderungen</h3>
${git ? renderCommits(git) : '<p class="muted">Git-Daten derzeit nicht verfügbar.</p>'}
<h3>Datei-Übersicht</h3>
${files ? renderFileOverview(files) : '<p class="muted">Datei-Übersicht derzeit nicht verfügbar.</p>'}
<h3>Arbeiten am Projekt</h3>
${work ? renderWorkEntries(work) : '<p class="muted">Keine Ticket-Daten verfügbar.</p>'}
`
}
/* -------------------------------------------------------- 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 = `
<span>Admin-Ansicht: Du siehst das Portal als <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong>.</span>
<button type="button" class="portal-btn-outline" id="exit-viewas-btn">Zurück zur Admin-Übersicht</button>
`
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 = '<p class="muted">Für dein Konto sind noch keine Rechnungen hinterlegt.</p>'
return
}
if (!data.invoices.length) {
body.innerHTML = '<p class="muted">Es liegen noch keine Rechnungen vor.</p>'
return
}
body.innerHTML = `
<table class="simple-table">
<thead><tr><th>Nr.</th><th>Datum</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr></thead>
<tbody>
${data.invoices.map((i) => `
<tr>
<td>${escapeHtml(i.number)}</td>
<td>${fmtDateShort(i.date)}</td>
<td>${fmtEuro(i.amount)}</td>
<td>${fmtEuro(i.balance)}</td>
<td><span class="pill ${i.overdue ? 'pill-red' : i.statusId === 4 ? 'pill-ok' : 'pill-warn'}">${escapeHtml(i.status)}</span></td>
<td style="white-space:nowrap">
${i.portalUrl ? `<a class="btn-link" href="${escapeAttr(i.portalUrl)}" target="_blank" rel="noreferrer">Ansehen / Zahlen</a> · ` : ''}
<a class="btn-link" href="/api/invoices/${escapeAttr(i.id)}/pdf">PDF</a>
</td>
</tr>`).join('')}
</tbody>
</table>`
} catch (err) {
body.innerHTML = `<p class="muted">Rechnungen konnten nicht geladen werden: ${escapeHtml(err.message)}</p>`
}
}
/* -------------------------------------------------------------- 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 = '<p class="muted">Es wurden noch keine Arbeiten dokumentiert.</p>'
return
}
body.innerHTML = tickets.map((t) => `
<div class="activity-item">
<div>
<strong>${escapeHtml(t.woid ? `#${t.woid} ` : '')}${escapeHtml(t.topic || '–')}</strong>
<span class="activity-status">${escapeHtml(t.status || '–')}</span>
<span class="muted"> · ${fmtDateShort(t.createdAt)}</span>
</div>
${(t.worksheets || []).map((w) => `
<div style="margin: 6px 0 0 14px;">
${w.isGit ? '<span class="pill pill-git">Git-Push</span>' : `<span class="pill pill-muted">${escapeHtml(w.serviceType || 'Arbeit')}</span>`}
${w.employeeName ? ` <strong>${escapeHtml(w.employeeName)}</strong>` : ''}
<span class="muted"> · ${fmtDateShort(w.date)}</span>
${Number(w.totalTime) ? `<span class="pill pill-ok">${fmtMinutes(w.totalTime)}</span>` : ''}
${w.pending ? '<span class="pill pill-muted">Zeiterfassung folgt</span>' : ''}
${w.details ? `<div class="activity-ws">${escapeHtml(w.details)}</div>` : ''}
</div>
`).join('')}
</div>`).join('')
} catch (err) {
body.innerHTML = `<p class="muted">Aktivität konnte nicht geladen werden: ${escapeHtml(err.message)}</p>`
}
}
/* ------------------------------------------------------------------ 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)}<span class="chat-meta">${msg.sender === 'customer' ? 'Du' : escapeHtml(msg.senderName || 'Webklar-Team')} · ${fmtDateTime(msg.createdAt)}</span>`
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 &amp; 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 `<ul class="abo-features">${ABO_FEATURES.map(
([t, d]) => `<li>${t}<small>${d}</small></li>`
).join('')}</ul>`
}
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 = `
<div class="abo-head">
<div>
<p class="abo-kicker">${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}</p>
<h2 class="abo-title">Wartungs-Abo</h2>
</div>
<div class="abo-price"><strong>50 €</strong><span>pro Monat · monatlich kündbar</span></div>
</div>
${aboFeaturesHtml()}
<div class="abo-actions">
<button type="button" class="abo-btn" id="abo-start" ${available ? '' : 'disabled'}>Abo abschließen</button>
<span class="abo-note">${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.'}</span>
</div>
<div class="abo-order hidden" id="abo-order">
<h3>Ihre Bestellung</h3>
${data.testAccount ? '<div class="abo-alert">Testkonto: Bei Stripe wird ein Test-Rabatt verrechnet, abgebucht werden nur 0,50 € pro Monat.</div>' : ''}
<label class="abo-check"><input type="checkbox" id="abo-terms">
<span>Ich habe die <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen mit Leistungsbeschreibung und Auftragsverarbeitungsvertrag</a> gelesen und stimme ihnen zu. Die <a href="/abo-bedingungen.html#widerruf" target="_blank" rel="noopener">Widerrufsbelehrung</a> habe ich zur Kenntnis genommen.</span></label>
<fieldset class="abo-start-choice">
<legend>Wann sollen wir mit der Leistung beginnen?</legend>
<label class="abo-check"><input type="radio" name="abo-start-choice" value="sofort">
<span><strong>Sofort.</strong> Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Leistung beginnt. Mir ist bekannt, dass ich bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahle.</span></label>
<label class="abo-check"><input type="radio" name="abo-start-choice" value="nach_frist">
<span><strong>Nach Ablauf der 14-tägigen Widerrufsfrist</strong> (am ${lateStartDate()}). Bis dahin erbringt WEBklar keine Leistungen und bucht nichts ab.</span></label>
<p class="abo-note">Firmenkunden haben kein Widerrufsrecht und wählen am besten „Sofort“.</p>
</fieldset>
<div class="abo-summary-box">
<table class="abo-summary">
<tr><td>Leistung</td><td>WEBklar Wartungs-Abo: Betrieb &amp; Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach (5 GB), 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)</td></tr>
<tr><td>Preis</td><td><strong>50,00 € pro Monat</strong> (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten</td></tr>
<tr><td>Abrechnung</td><td>monatlich im Voraus, erstmals zum Leistungsbeginn</td></tr>
<tr><td>Laufzeit</td><td>unbestimmt, keine Mindestlaufzeit, jederzeit zum Ende des Abrechnungsmonats kündbar</td></tr>
<tr><td>Zahlung</td><td>Kreditkarte oder SEPA-Lastschrift über Stripe</td></tr>
<tr><td>Anbieter</td><td>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · Telefon 0176 23726355 · kenso.gri@gmail.com</td></tr>
</table>
<p class="abo-note">Es gilt das gesetzliche Mängelhaftungsrecht. Einzelheiten: <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen</a> · <a href="/datenschutz.html" target="_blank" rel="noopener">Datenschutz</a></p>
</div>
<p class="error hidden" id="abo-error"></p>
<div class="abo-actions">
<button type="button" class="abo-btn" id="abo-buy">Zahlungspflichtig bestellen</button>
<span class="abo-note">Danach hinterlegen Sie bei unserem Zahlungsdienstleister Stripe Ihre Zahlungsart. Bis zum Abschluss dort können Sie jederzeit abbrechen.</span>
</div>
</div>`
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 = `<span class="pill pill-muted">beginnt am ${fmtDateShort(sub.serviceStartsAt)}</span>`
} else if (sub.cancelAtPeriodEnd) {
statusLine = `<span class="pill pill-muted">gekündigt zum ${fmtDateShort(sub.currentPeriodEnd)}</span>`
} else if (sub.paymentProblem) {
statusLine = '<span class="pill pill-red">Zahlung offen</span>'
} else {
statusLine = `<span class="pill pill-ok">aktiv</span> <span class="abo-note">nächste Abbuchung ${fmtDateShort(sub.currentPeriodEnd)}</span>`
}
const usageRows = (l.usage || []).map((u) => `
<tr><td>${fmtDateShort(u.date)}</td>
<td>${escapeHtml(u.topic || u.details || 'Arbeitszeit')}${u.woid ? ` <span class="muted">#${escapeHtml(u.woid)}</span>` : ''}</td>
<td>−${fmtHours(u.minutes)}${u.overageMinutes ? ` <span class="pill pill-warn" title="Über das Kontingent hinaus">+${fmtHours(u.overageMinutes)} extra</span>` : ''}</td></tr>`).join('')
const creditRows = (l.credits || []).slice().reverse().map((c) => `
<tr><td>${fmtDateShort(c.grantedAt)}</td><td>Monatskontingent</td>
<td>+${fmtHours(c.minutes)} <span class="muted">${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDateShort(c.expiresAt)}`}</span></td></tr>`).join('')
card.innerHTML = `
<div class="abo-head">
<div>
<p class="abo-kicker">Ihr Wartungs-Abo</p>
<h2 class="abo-title">Stundenkontingent</h2>
</div>
<div>${statusLine}</div>
</div>
${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.</div>' : ''}
${sub.serviceStartsAt ? `<div class="abo-alert">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.</div>` : ''}
<div class="abo-balance">
<div class="abo-stat"><strong>${fmtHours(l.balanceMinutes)}</strong><span>verfügbare Zeit</span></div>
<div class="abo-stat"><strong>${fmtHours(l.usedMinutes)}</strong><span>bisher genutzt</span></div>
${l.nextExpiry ? `<div class="abo-stat"><strong>${fmtHours(l.nextExpiry.minutes)}</strong><span>verfallen am ${fmtDateShort(l.nextExpiry.at)}</span></div>` : ''}
</div>
<p class="abo-note">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.</p>
<div class="abo-actions" style="margin-top:14px">
<button type="button" class="portal-btn-outline" id="abo-manage">${sub.paymentProblem ? 'Zahlungsart aktualisieren' : 'Abo verwalten · Rechnungen'}</button>
${sub.withdrawalOpen ? `<a class="abo-withdraw" href="/widerrufen.html">Vertrag widerrufen</a><span class="abo-note">möglich bis ${fmtDateShort(sub.withdrawalUntil)}</span>` : ''}
<a class="abo-note" href="/abo-bedingungen.html" target="_blank" rel="noopener">Leistungen &amp; Bedingungen</a>
</div>
${usageRows || creditRows ? `
<details class="abo-history">
<summary>Verlauf anzeigen</summary>
<table class="simple-table">
<thead><tr><th>Datum</th><th>Vorgang</th><th>Zeit</th></tr></thead>
<tbody>${usageRows}${creditRows}</tbody>
</table>
</details>` : ''}
<p class="error hidden" id="abo-error"></p>`
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 = '<p class="abo-kicker">Vielen Dank!</p><h2 class="abo-title">Ihr Abo wird aktiviert…</h2><p class="abo-note">Das dauert nur einen Moment.</p>'
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