- Bestellung auf der Portal-Startseite: Zusammenfassung, AGB- und Vorab-Leistungs-Zustimmung, Button 'Zahlungspflichtig bestellen', danach Stripe Checkout (Abo, Kleinunternehmer-Fusszeile) - Stripe-Webhook /api/abo/webhook spiegelt Abos nach Appwrite (subscriptions) und schreibt je bezahlter Rechnung 60 min gut (hourCredits, idempotent ueber Invoice-ID, 12 Monate gueltig) - Stundenkonto: FIFO-Verbrauch aus Worksheets mit countsToPlan, 15-Minuten-Takt, Mehraufwand separat ausgewiesen - Stripe-Kundenportal fuer Zahlungsart/Rechnungen/Kuendigung - Oeffentlicher Kuendigungsbutton (§ 312k) und Widerrufsbutton (§ 356a) mit Eingangsbestaetigung per Mail - Abo-Bedingungen inkl. Leistungsbeschreibung und Widerrufsbelehrung, als Anhang der Vertragsbestaetigung Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
851 lines
32 KiB
JavaScript
851 lines
32 KiB
JavaScript
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, '>')
|
||
.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
|
||
? `<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 & Pflege Ihrer Website/Anwendung', 'Server, Updates, Sicherheit, Backups, Monitoring – damit sie online bleibt'],
|
||
['1 Domain inklusive', 'Registrierung auf Ihren Namen, DNS- und Zertifikatsverwaltung'],
|
||
['1 E-Mail-Postfach', 'mit Ihrer eigenen Domain'],
|
||
['1 Stunde Arbeitszeit pro Monat', 'sammelt sich an, jede Stunde ist 12 Monate gültig'],
|
||
]
|
||
|
||
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 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. 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>
|
||
<table class="abo-summary">
|
||
<tr><td>Webklar Wartungs-Abo<br><span class="muted">Leistungen laut <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Leistungsbeschreibung</a></span></td><td>50,00 € / Monat</td></tr>
|
||
<tr><td>Laufzeit</td><td>unbestimmt, monatlich kündbar</td></tr>
|
||
<tr><td>Zahlung</td><td>monatlich im Voraus (Karte, SEPA u. a. über Stripe)</td></tr>
|
||
<tr><td><strong>Gesamt heute</strong><br><span class="muted">Kein Umsatzsteuerausweis (§ 19 UStG)</span></td><td><strong>50,00 €</strong></td></tr>
|
||
</table>
|
||
<label class="abo-check"><input type="checkbox" id="abo-terms">
|
||
<span>Ich habe die <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Abo-Bedingungen inkl. Leistungsbeschreibung</a> und die <a href="/abo-bedingungen.html#widerruf" target="_blank" rel="noopener">Widerrufsbelehrung</a> gelesen und stimme ihnen zu.</span></label>
|
||
<label class="abo-check"><input type="checkbox" id="abo-early">
|
||
<span>Ich verlange ausdrücklich, dass Webklar vor Ablauf der Widerrufsfrist mit der Leistung beginnt. Mir ist bekannt, dass ich bei einem Widerruf Wertersatz für die bis dahin erbrachten Leistungen schulde.</span></label>
|
||
<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.</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 earlyStart = card.querySelector('#abo-early').checked
|
||
if (!acceptTerms || !earlyStart) {
|
||
showError(errEl, 'Bitte bestätigen Sie beide Punkte, um zu bestellen.')
|
||
return
|
||
}
|
||
btn.disabled = true
|
||
btn.textContent = 'Weiter zur Zahlung…'
|
||
try {
|
||
const { url } = await api('/api/abo/checkout', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ acceptTerms, earlyStart, 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: [] }
|
||
const statusLine = sub.cancelAtPeriodEnd
|
||
? `<span class="pill pill-muted">gekündigt zum ${fmtDateShort(sub.currentPeriodEnd)}</span>`
|
||
: sub.paymentProblem
|
||
? '<span class="pill pill-red">Zahlung offen</span>'
|
||
: `<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 Guthaben hinaus">+${fmtHours(u.overageMinutes)} extra</span>` : ''}</td></tr>`).join('')
|
||
const creditRows = (l.credits || []).slice().reverse().map((c) => `
|
||
<tr><td>${fmtDateShort(c.grantedAt)}</td><td>Monatliche Gutschrift</td>
|
||
<td>+${fmtHours(c.minutes)} <span class="muted">${c.expired ? 'abgelaufen' : `gültig 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">Stundenkonto</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>' : ''}
|
||
<div class="abo-balance">
|
||
<div class="abo-stat"><strong>${fmtHours(l.balanceMinutes)}</strong><span>verfügbares Guthaben</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 Monat kommt 1 Stunde dazu. Abgerechnet 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>
|
||
<a class="abo-note" href="/abo-bedingungen.html" target="_blank" rel="noopener">Leistungen & 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>Stunden</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
|