- Bestellseite: Pflichtangaben direkt ueber dem Button, Zahlungsarten, Wahl des Leistungsbeginns (sofort / nach Widerrufsfrist via 15 Tage Stripe-Probezeit), keine Vorauswahl - Zustimmungsnachweis: Zeitpunkt, Version, Wahl, IP, Browser in Stripe- Metadaten und Appwrite (neue Attribute, Setup-Skript ergaenzt) - Checkout: Karte + SEPA (Fallback nur Karte), korrigierter Hinweistext - Vertragsbestaetigung mit PDF der Vertragsunterlagen statt HTML - Kuendigen/Widerrufen: Formular direkt (keine Zwischenseite), Wunsch- termin, Druck/PDF der Bestaetigung, Vorbefuellung bei Login - Widerrufsbutton im Abo waehrend der Frist, 'beginnt am'-Status - Pilotbetrieb per ABO_PILOT_EMAILS - Vertragsbedingungen v2, Datenschutzerklaerung, Kalkulator ohne MwSt und mit 100/80 EUR Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
882 lines
34 KiB
JavaScript
882 lines
34 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 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>
|
||
<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 & 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 & 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
|