Auf dem Handy (unter 880 px und Handys quer) sieht der Kundenbereich aus wie eine App; der Desktop bleibt pixelgleich. - Kopfzeile als App-Leiste: Logo, beim Scrollen der Seitentitel, Avatar - Navigation als Tab-Leiste unten mit Symbolen und Zaehlern; neuer Tab "Wartung" mit der Abo-Karte, Stundenkonto/Bestellung und Uebernahmegutschrift als Unterseiten mit Zurueck (auch Android-Geste) - Konto als Bottom-Sheet (Ansprechpartner, Vertraege kuendigen/widerrufen, Rechtliches, Abmelden), am Desktop als Popover unter dem Avatar - Rechnungen als Karten, Fortschritt als Zeitleiste, Chat fuellt den Bildschirm; bei offener Tastatur verschwindet die Tab-Leiste (kommt auch nach der Android-Zurueck-Geste wieder) - Installierbar: Manifest, App-Icons (WEBklar-Logo), Favicon, Hinweis "Zum Home-Bildschirm"; Service Worker nur fuer die Offline-Seite, Kundendaten werden nicht zwischengespeichert - Installierte App: Kuendigen/Widerrufen mit Link zurueck in den Kundenbereich (nur dort sichtbar, Texte unveraendert); Login-Links zu Bedingungen/Datenschutz/Impressum oeffnen wie im Dashboard ein neues Fenster - Login mit App-Icon und Abstaenden fuer Statusleiste/Home-Balken - Hinweise "in der Seitenleiste" haben auf dem Handy eine eigene Fassung Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1664 lines
73 KiB
JavaScript
1664 lines
73 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('iframe')
|
||
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 fmtDateTimeLong(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: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||
}
|
||
|
||
// "autohaus-meier.project.webklar.com" -> Subdomain hervorheben, Rest gedimmt
|
||
function previewHostHtml(url) {
|
||
try {
|
||
const host = new URL(url).hostname
|
||
const dot = host.indexOf('.')
|
||
return dot > 0
|
||
? `${escapeHtml(host.slice(0, dot))}<span class="kb-dim">${escapeHtml(host.slice(dot))}</span>`
|
||
: escapeHtml(host)
|
||
} catch {
|
||
return '<span class="kb-dim">–</span>'
|
||
}
|
||
}
|
||
|
||
function projectState(project) {
|
||
if (isProjectPreviewReady(project)) return ['kb-state-ok', 'Vorschau online']
|
||
const s = String(project.provisioningStatus || project.status || '').toLowerCase()
|
||
if (s.includes('fail')) return ['kb-state-off', 'Vorschau gestört']
|
||
if (project.previewUrl) return ['kb-state-wait', 'Vorschau wird vorbereitet']
|
||
return ['kb-state-off', 'Noch keine Website']
|
||
}
|
||
|
||
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen' }
|
||
|
||
function renderProjectCard(project, features) {
|
||
const li = document.createElement('li')
|
||
li.className = 'kb-project'
|
||
const ready = isProjectPreviewReady(project)
|
||
const url = project.previewUrl ? escapeAttr(project.previewUrl) : ''
|
||
const [stateClass, stateText] = projectState(project)
|
||
const extraChips = featuresForProject(features, project.id)
|
||
.map((f) => FEATURE_LABELS[f.featureKey] || f.featureKey)
|
||
.filter((label) => label && !['Vorschau', 'Chat', 'Rechnungen'].includes(label))
|
||
|
||
li.innerHTML = `
|
||
${ready && url
|
||
? `<a class="kb-preview" href="${url}" target="_blank" rel="noreferrer" data-preview-frame aria-label="Website in neuem Tab öffnen">
|
||
<iframe src="${url}" loading="lazy" scrolling="no" tabindex="-1" aria-hidden="true"></iframe>
|
||
<span class="kb-open">Website öffnen ↗</span>
|
||
</a>`
|
||
: `<div class="kb-preview kb-preview-empty">${project.previewUrl ? 'Vorschau wird vorbereitet …' : 'Noch keine Website veröffentlicht'}</div>`}
|
||
<div class="kb-project-body">
|
||
<div class="kb-project-top">
|
||
<h2>${escapeHtml(project.projectName || project.subdomain || 'Projekt')}</h2>
|
||
<span class="kb-state ${stateClass}">${stateText}</span>
|
||
</div>
|
||
<dl class="kb-facts">
|
||
<div><dt>Vorschau-Adresse</dt><dd>${project.previewUrl ? previewHostHtml(project.previewUrl) : '<span class="kb-dim">–</span>'}</dd></div>
|
||
<div><dt>Live-Domain</dt><dd>${project.liveDomain ? escapeHtml(project.liveDomain) : '<span class="kb-dim">noch nicht verbunden</span>'}</dd></div>
|
||
<div><dt>Letzte Änderung</dt><dd>${project.updatedAt ? fmtDateTimeLong(project.updatedAt) : '<span class="kb-dim">–</span>'}</dd></div>
|
||
</dl>
|
||
<div class="kb-chips">
|
||
${ready && url ? `<a class="kb-chip" href="${url}" target="_blank" rel="noreferrer">Vorschau</a>` : ''}
|
||
<button type="button" class="kb-chip" data-view="chat">Chat</button>
|
||
<button type="button" class="kb-chip" data-view="invoices">Rechnungen</button>
|
||
${extraChips.map((label) => `<span class="kb-chip">${escapeHtml(label)}</span>`).join('')}
|
||
</div>
|
||
<button type="button" class="kb-btn-outline" data-details-btn>Projektdetails anzeigen</button>
|
||
<div class="kb-details hidden" data-details-box></div>
|
||
</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 ? 'Projektdetails ausblenden' : 'Projektdetails 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() {
|
||
// Direktlink auf eine Ansicht im Kundenbereich (z. B. /dashboard.html#gutschrift)
|
||
const raw = getQueryParam('next')
|
||
if (/^\/dashboard\.html(#[a-z]+)?$/.test(raw)) return raw
|
||
const next = safeRedirectUrl(raw)
|
||
if (next && (await canAccessPreviewUrl(next))) {
|
||
return next
|
||
}
|
||
return '/dashboard.html'
|
||
}
|
||
|
||
async function postLoginRedirect() {
|
||
window.location.href = await resolvePostLoginTarget()
|
||
}
|
||
|
||
// Links aus Admin-Mails (z. B. /admin.html?gutschrift=<id>) nach dem Login wieder aufnehmen
|
||
function adminLoginTarget() {
|
||
const next = getQueryParam('next')
|
||
return /^\/admin\.html(\?[A-Za-z0-9=&_-]*)?$/.test(next) ? next : '/admin.html'
|
||
}
|
||
|
||
/* -------------------------------------------------------------- Login-Page --- */
|
||
async function initLoginPage() {
|
||
const form = document.getElementById('login-form')
|
||
const errorEl = document.getElementById('login-error')
|
||
const btn = document.getElementById('login-btn')
|
||
|
||
const errorFromQuery = getQueryParam('error')
|
||
if (errorFromQuery) {
|
||
showError(errorEl, errorFromQuery)
|
||
}
|
||
|
||
async function applyCooldown(seconds) {
|
||
if (seconds <= 0) return
|
||
showError(
|
||
errorEl,
|
||
`Zu viele Anmeldeversuche. Bitte warte noch ${seconds} Sekunden (ca. ${Math.ceil(seconds / 60)} Min.).`
|
||
)
|
||
btn.disabled = true
|
||
const tick = setInterval(async () => {
|
||
const status = await fetch('/api/auth/login-status').then((r) => r.json()).catch(() => ({}))
|
||
const left = status.retryAfterSeconds || 0
|
||
if (left <= 0) {
|
||
clearInterval(tick)
|
||
hideError(errorEl)
|
||
btn.disabled = false
|
||
return
|
||
}
|
||
showError(
|
||
errorEl,
|
||
`Zu viele Anmeldeversuche. Bitte warte noch ${left} Sekunden (ca. ${Math.ceil(left / 60)} Min.).`
|
||
)
|
||
}, 1000)
|
||
}
|
||
|
||
try {
|
||
const status = await fetch('/api/auth/login-status').then((r) => r.json())
|
||
if (status.blocked) {
|
||
await applyCooldown(status.retryAfterSeconds)
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
|
||
try {
|
||
const me = await api('/api/auth/me')
|
||
if (me.authenticated && me.role === 'admin' && !me.viewAs) {
|
||
window.location.href = adminLoginTarget()
|
||
return
|
||
}
|
||
if (me.authenticated && me.customer) {
|
||
await postLoginRedirect()
|
||
return
|
||
}
|
||
} catch {
|
||
/* not logged in */
|
||
}
|
||
|
||
form?.addEventListener('submit', async (e) => {
|
||
e.preventDefault()
|
||
if (btn.disabled) return
|
||
errorEl.classList.add('hidden')
|
||
btn.disabled = true
|
||
btn.classList.add('is-loading')
|
||
btn.setAttribute('aria-busy', 'true')
|
||
let cooldownActive = false
|
||
try {
|
||
const result = await api('/api/auth/login', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
email: document.getElementById('email').value,
|
||
password: document.getElementById('password').value,
|
||
}),
|
||
})
|
||
if (result.role === 'admin') {
|
||
window.location.href = adminLoginTarget()
|
||
return
|
||
}
|
||
await postLoginRedirect()
|
||
return
|
||
} catch (err) {
|
||
showError(errorEl, err.message)
|
||
if (err.status === 429) {
|
||
cooldownActive = true
|
||
await applyCooldown(err.retryAfterSeconds || 900)
|
||
}
|
||
} finally {
|
||
btn.classList.remove('is-loading')
|
||
btn.removeAttribute('aria-busy')
|
||
if (!cooldownActive) btn.disabled = false
|
||
}
|
||
})
|
||
}
|
||
|
||
/* --------------------------------------------------------------- View-as --- */
|
||
function renderViewAsBanner(me) {
|
||
const banner = document.getElementById('viewas-banner')
|
||
if (!banner || !me.viewAs) return
|
||
banner.classList.remove('hidden')
|
||
banner.innerHTML = `
|
||
<span>Admin-Ansicht: Du siehst das Portal als <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong>.</span>
|
||
<button type="button" 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')
|
||
// Zaehler in der Seitenleiste: Rechnungen mit offenem Betrag
|
||
const openCount = (data.invoices || []).filter((i) => Number(i.balance) > 0).length
|
||
const badge = document.getElementById('invoices-badge')
|
||
if (badge) {
|
||
badge.textContent = openCount
|
||
badge.classList.toggle('hidden', !openCount)
|
||
}
|
||
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
|
||
}
|
||
// Klassen je Spalte: in der App-Ansicht wird jede Zeile zur Karte (kundenbereich.css)
|
||
body.innerHTML = `
|
||
<div class="kb-table-wrap"><table class="simple-table kb-invoices">
|
||
<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 class="${Number(i.balance) > 0 ? 'is-open' : 'is-paid'}">
|
||
<td class="inv-no">${escapeHtml(i.number)}</td>
|
||
<td class="inv-date">${fmtDateShort(i.date)}</td>
|
||
<td class="inv-amount">${fmtEuro(i.amount)}</td>
|
||
<td class="inv-open">${fmtEuro(i.balance)}</td>
|
||
<td class="inv-status"><span class="pill ${i.overdue ? 'pill-red' : i.statusId === 4 ? 'pill-ok' : 'pill-warn'}">${escapeHtml(i.status)}</span></td>
|
||
<td class="inv-actions" style="white-space:nowrap">
|
||
${i.portalUrl ? `<a class="btn-link" href="${escapeAttr(i.portalUrl)}" target="_blank" rel="noreferrer">Ansehen / Zahlen</a><span class="inv-sep" aria-hidden="true"> · </span>` : ''}
|
||
<a class="btn-link" href="/api/invoices/${escapeAttr(i.id)}/pdf">PDF</a>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table></div>`
|
||
} 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,
|
||
shownIds: new Set(),
|
||
composer: null,
|
||
}
|
||
|
||
const chatFileUrl = (att) => `/api/chat/attachments/${encodeURIComponent(att.id)}`
|
||
|
||
function appendChatMessages(messages) {
|
||
const box = document.getElementById('chat-messages')
|
||
if (!box || !messages.length) return
|
||
for (const msg of messages) {
|
||
// Poll und Antwort auf das Senden koennen dieselbe Nachricht liefern
|
||
if (chatState.shownIds.has(msg.id)) continue
|
||
chatState.shownIds.add(msg.id)
|
||
const mine = msg.sender === 'customer'
|
||
box.appendChild(ChatComposer.renderMessage(msg, {
|
||
mine,
|
||
meta: `${mine ? 'Du' : msg.senderName || 'Webklar-Team'} · ${fmtDateTime(msg.createdAt)}`,
|
||
fileUrl: chatFileUrl,
|
||
}))
|
||
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 Boolean(document.getElementById('view-chat')?.classList.contains('active'))
|
||
}
|
||
|
||
async function initChat() {
|
||
if (chatState.initialized) return
|
||
chatState.initialized = true
|
||
|
||
ChatComposer.trackScroll(document.getElementById('chat-messages'))
|
||
// Text, Bilder, Kamerafoto, Dateien und Sprachnachrichten (public/chat-composer.js)
|
||
chatState.composer = ChatComposer.create({
|
||
root: document.getElementById('chat-composer'),
|
||
dropZone: document.querySelector('#view-chat .chat-box'),
|
||
uploadUrl: '/api/chat/attachments',
|
||
placeholder: 'Nachricht an das WEBklar-Team…',
|
||
placeholderShort: 'Nachricht…',
|
||
disabled: chatState.viewAs,
|
||
disabledText: 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.',
|
||
send: async ({ text, attachmentIds }) => {
|
||
const { message } = await api('/api/chat/messages', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ text, attachmentIds }),
|
||
})
|
||
return message
|
||
},
|
||
onSent: (message) => appendChatMessages([message]),
|
||
})
|
||
|
||
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: Karte in der Seitenleiste + Ansicht (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'],
|
||
]
|
||
|
||
// Kurzfassung fuer die Karte in der Seitenleiste
|
||
const ABO_SIDE_FEATURES = ['Betrieb & Pflege, Server in Deutschland', '1 Domain inklusive', '1 E-Mail-Postfach, 5 GB', '1 Std. Arbeitszeit pro Monat']
|
||
|
||
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 euroShort(cents) {
|
||
return cents % 100 === 0 ? `${cents / 100} €` : fmtEuro(cents / 100)
|
||
}
|
||
|
||
function fmtDayMonth(value) {
|
||
const d = new Date(value)
|
||
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
|
||
}
|
||
|
||
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' })
|
||
}
|
||
|
||
// Laufzeit-Hinweis unter der Abo-Karte (§ 8 der Bedingungen)
|
||
function aboTermText(sub) {
|
||
if (sub.cancelAtPeriodEnd) {
|
||
return `Gekündigt zum ${fmtDateShort(sub.cancelAt || sub.currentPeriodEnd)}. Bis dahin bleiben alle Leistungen verfügbar.`
|
||
}
|
||
if (sub.minTermEnd && new Date(sub.minTermEnd) > new Date()) {
|
||
return `Mindestlaufzeit bis ${fmtDateShort(sub.minTermEnd)}. Kündigung frühestens zu diesem Termin, mit einem Monat Frist.`
|
||
}
|
||
return 'Monatlich kündbar zum Ende des Abrechnungsmonats.'
|
||
}
|
||
|
||
/* Seitenleiste: Angebot (noch kein Abo) */
|
||
function renderAboSideOffer(card, data, { ended }) {
|
||
const offer = data.offer
|
||
card.innerHTML = `
|
||
<p class="kb-label">${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}</p>
|
||
<h3>Wartungs-Abo</h3>
|
||
${offer ? '<span class="kb-offer-tag">Ihr persönliches Angebot</span>' : ''}
|
||
<p class="kb-price">${offer
|
||
? `<strong>${euroShort(offer.monthlyPriceCents)}</strong><span>${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50 €</span>`
|
||
: '<strong>50 €</strong><span>pro Monat</span>'}</p>
|
||
<ul class="kb-checks">${ABO_SIDE_FEATURES.map((f) => `<li>${escapeHtml(f)}</li>`).join('')}</ul>
|
||
<button type="button" class="kb-btn-primary kb-btn-block" data-view="abo"${data.available ? '' : ' disabled'}>Abo abschließen</button>
|
||
<p class="kb-fine">${data.available
|
||
? '12 Monate Mindestlaufzeit. Gemäß § 19 UStG ohne Umsatzsteuer.'
|
||
: 'Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'}</p>`
|
||
}
|
||
|
||
/* Seitenleiste: laufendes Abo */
|
||
function renderAboSideActive(card, data) {
|
||
const sub = data.subscription
|
||
const l = data.ledger || { balanceMinutes: 0 }
|
||
// Status nur bei Kündigung; Zahlungsproblem (Hinweis unten) und späterer Beginn (Datumszeile) stehen im Text
|
||
const status = sub.cancelAtPeriodEnd ? '<span class="kb-status kb-status-off">gekündigt</span>' : ''
|
||
const dateLine = sub.cancelAtPeriodEnd
|
||
? `Leistungen bis ${fmtDateShort(sub.cancelAt || sub.currentPeriodEnd)}`
|
||
: sub.serviceStartsAt
|
||
? `Beginn und erste Abbuchung am ${fmtDateShort(sub.serviceStartsAt)}`
|
||
: `nächste Abbuchung ${fmtDateShort(sub.currentPeriodEnd)}`
|
||
|
||
card.innerHTML = `
|
||
<p class="kb-label">Ihre Wartung</p>
|
||
${status}
|
||
<button type="button" class="kb-hours" data-view="abo" title="Stundenkonto und Verlauf ansehen" aria-label="${fmtHours(l.balanceMinutes)} Arbeitszeit verfügbar – Stundenkonto und Verlauf ansehen">
|
||
<strong>${fmtHours(l.balanceMinutes)}</strong><span>Arbeitszeit verfügbar</span><em aria-hidden="true">›</em>
|
||
</button>
|
||
<p class="kb-sub">${dateLine}</p>
|
||
${sub.paymentProblem ? '<p class="kb-side-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.</p>' : ''}
|
||
<button type="button" class="kb-side-btn" id="abo-manage">${sub.paymentProblem ? 'Zahlungsart aktualisieren ↗' : 'Abo verwalten & Rechnungen ↗'}</button>
|
||
<button type="button" class="kb-side-btn" data-view="takeover">Übernahmegutschrift beantragen <span class="kb-badge hidden" id="takeover-badge" title="Rückfrage offen"></span></button>
|
||
${sub.withdrawalOpen ? `<a class="kb-side-btn" href="/widerrufen.html">Vertrag widerrufen <small>· bis ${fmtDayMonth(sub.withdrawalUntil)}</small></a>` : ''}
|
||
<div class="kb-divider"></div>
|
||
<p class="kb-fine" style="margin-top:0">${aboTermText(sub)}</p>
|
||
${sub.cancelAtPeriodEnd ? '' : '<p class="kb-fine"><a href="/kuendigen.html">Vertrag hier kündigen</a></p>'}
|
||
<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
|
||
}
|
||
})
|
||
}
|
||
|
||
/* Ansicht "Wartungs-Abo": Bestellung (Bestellübersicht direkt über dem Bestellbutton, § 312j BGB) */
|
||
function renderAboOrderView(view, data) {
|
||
const available = data.available
|
||
const offer = data.offer
|
||
view.innerHTML = `
|
||
<header class="kb-head">
|
||
<h1>Wartungs-Abo</h1>
|
||
<p>Betrieb, Pflege und Support für Ihre Website – ${offer ? 'mit Ihrem persönlichen Angebot' : 'für 50 € im Monat'}.</p>
|
||
</header>
|
||
${offer ? `
|
||
<div class="abo-offer-box">
|
||
<strong>Ihr persönliches Angebot</strong>
|
||
<p>${escapeHtml(offer.text)}. Sie sparen ${fmtEuro(offer.savingsCents / 100)}.</p>
|
||
${offer.note ? `<p class="abo-note">${escapeHtml(offer.note)}</p>` : ''}
|
||
<p class="abo-note">Gültig bis ${fmtDateShort(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt</p>
|
||
</div>` : ''}
|
||
<div class="kb-panel">
|
||
${aboFeaturesHtml()}
|
||
<p class="abo-note" style="margin:0">Schon bei einem anderen Anbieter für Domain, Hosting oder E-Mail bezahlt? Nach dem Abschluss können Sie eine Übernahmegutschrift beantragen – wir rechnen die doppelt bezahlte Zeit anteilig an.</p>
|
||
</div>
|
||
${available ? `
|
||
<div class="kb-panel" 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.${offer ? ' Das Angebot wird nach dem Abschluss zusätzlich ans Abo gehängt.' : ''}</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 & 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>${offer
|
||
? `<strong>${escapeHtml(offer.text)}</strong> (individuelles Angebot; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten`
|
||
: '<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><strong>Mindestlaufzeit 12 Monate</strong> ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach unbefristet und 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 · support@webklar.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="kb-actions">
|
||
<button type="button" class="kb-btn-primary" 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>` : `
|
||
<div class="kb-panel"><p class="abo-note" style="margin:0">Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.</p></div>`}`
|
||
|
||
view.querySelector('#abo-buy')?.addEventListener('click', async (ev) => {
|
||
const btn = ev.currentTarget
|
||
const errEl = view.querySelector('#abo-error')
|
||
hideError(errEl)
|
||
const acceptTerms = view.querySelector('#abo-terms').checked
|
||
const startChoice = view.querySelector('input[name="abo-start-choice"]:checked')?.value || ''
|
||
if (!acceptTerms) {
|
||
showError(errEl, 'Bitte stimmen Sie den Vertragsbedingungen zu.')
|
||
return
|
||
}
|
||
if (!startChoice) {
|
||
showError(errEl, 'Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.')
|
||
return
|
||
}
|
||
btn.disabled = true
|
||
btn.textContent = 'Weiter zur Zahlung…'
|
||
try {
|
||
const { url } = await api('/api/abo/checkout', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ acceptTerms, startChoice, termsVersion: data.termsVersion, offerId: offer?.id || '' }),
|
||
})
|
||
window.location.href = url
|
||
} catch (err) {
|
||
showError(errEl, err.message)
|
||
btn.disabled = false
|
||
btn.textContent = 'Zahlungspflichtig bestellen'
|
||
}
|
||
})
|
||
}
|
||
|
||
/* Ansicht "Wartungs-Abo": Stundenkonto, Verlauf, Vertrag (Bedingungen § 4: jederzeit im Portal einsehbar) */
|
||
function renderAboAccountView(view, data) {
|
||
const sub = data.subscription
|
||
const l = data.ledger || { balanceMinutes: 0, usedMinutes: 0, credits: [], usage: [] }
|
||
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('')
|
||
|
||
view.innerHTML = `
|
||
<header class="kb-head">
|
||
<h1>Wartungs-Abo</h1>
|
||
<p>Ihr Stundenkontingent, der Verlauf und Ihr Vertrag.</p>
|
||
</header>
|
||
${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart über „Zahlungsart aktualisieren“ <span class="kb-desk-only">in der Seitenleiste</span><span class="kb-app-only">unter <a href="#wartung" data-view="wartung">Wartung</a></span>, 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="kb-stats">
|
||
<div class="kb-stat"><strong>${fmtHours(l.balanceMinutes)}</strong><span>verfügbare Zeit</span></div>
|
||
<div class="kb-stat"><strong>${fmtHours(l.usedMinutes)}</strong><span>bisher genutzt</span></div>
|
||
${l.nextExpiry ? `<div class="kb-stat"><strong>${fmtHours(l.nextExpiry.minutes)}</strong><span>verfallen am ${fmtDateShort(l.nextExpiry.at)}</span></div>` : ''}
|
||
</div>
|
||
<div class="kb-panel">
|
||
<p class="abo-note" style="margin-top:0">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 <a href="#chat" data-view="chat">Chat</a>.</p>
|
||
${sub.offerText ? `<p class="abo-note">Ihr Angebot: ${escapeHtml(sub.offerText)}.</p>` : ''}
|
||
<p class="abo-note">${aboTermText(sub)}${sub.cancelAtPeriodEnd ? '' : ' <a href="/kuendigen.html">Vertrag hier kündigen</a>'}</p>
|
||
<p class="abo-note" style="margin-bottom:0"><a href="/abo-bedingungen.html" target="_blank" rel="noopener">Leistungen & Bedingungen</a></p>
|
||
</div>
|
||
<div class="kb-panel">
|
||
<h3>Verlauf</h3>
|
||
${usageRows || creditRows ? `
|
||
<div class="kb-table-wrap"><table class="simple-table">
|
||
<thead><tr><th>Datum</th><th>Vorgang</th><th>Zeit</th></tr></thead>
|
||
<tbody>${usageRows}${creditRows}</tbody>
|
||
</table></div>` : '<p class="kb-empty" style="margin:0">Noch keine Buchungen.</p>'}
|
||
</div>`
|
||
}
|
||
|
||
const aboState = { data: null }
|
||
|
||
async function loadAboCard({ afterCheckout = false } = {}) {
|
||
const card = document.getElementById('abo-card')
|
||
const view = document.getElementById('view-abo')
|
||
if (!card || !view) 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="kb-label">Vielen Dank!</p><h3>Ihr Abo wird aktiviert…</h3><p class="kb-fine">Das dauert nur einen Moment.</p>'
|
||
await new Promise((r) => setTimeout(r, 2000))
|
||
data = await api('/api/abo')
|
||
}
|
||
aboState.data = data
|
||
if (data.subscription?.active) {
|
||
renderAboSideActive(card, data)
|
||
renderAboAccountView(view, data)
|
||
} else {
|
||
renderAboSideOffer(card, data, { ended: Boolean(data.subscription) })
|
||
renderAboOrderView(view, data)
|
||
}
|
||
card.classList.remove('hidden')
|
||
document.getElementById('invoices-abo-note')?.classList.toggle('hidden', !data.subscription)
|
||
loadTakeoverCard({ abo: data })
|
||
document.dispatchEvent(new CustomEvent('kb:abo', { detail: data }))
|
||
} catch (err) {
|
||
console.warn('Abo konnte nicht geladen werden:', err.message)
|
||
document.dispatchEvent(new CustomEvent('kb:abo', { detail: null }))
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Übernahmegutschrift: Antrag stellen, Stand sehen, Rückfragen beantworten
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const takeoverState = { abo: null, customer: null }
|
||
|
||
const TAKEOVER_STATUS = {
|
||
offen: ['pill-warn', 'In Prüfung'],
|
||
rueckfrage: ['pill-amber', 'Rückfrage'],
|
||
genehmigt: ['pill-ok', 'Genehmigt'],
|
||
abgelehnt: ['pill-muted', 'Nicht genehmigt'],
|
||
}
|
||
|
||
// Bei Textänderungen config.takeover.conditionsVersion auf dem Server hochziehen
|
||
const TAKEOVER_CONDITIONS = [
|
||
'Die Übernahmegutschrift gibt es für Leistungen, die das Wartungs-Abo ersetzt (Domain, Hosting bzw. Betrieb der Website, E-Mail-Postfach) und die Sie bei Ihrem bisherigen Anbieter bereits im Voraus bezahlt haben.',
|
||
'Wir schreiben den Anteil der Rechnung gut, der auf die Zeit ab Leistungsbeginn Ihres Abos bis zum Ende des bezahlten Zeitraums entfällt, tagesgenau berechnet.',
|
||
'Nachweis ist die Rechnung des bisherigen Anbieters, ausgestellt auf Sie, mit Leistung, Zeitraum und Betrag. Wir können weitere Nachweise anfordern, etwa die Kündigungsbestätigung. Erstattet Ihr bisheriger Anbieter Beträge für diesen Zeitraum, teilen Sie uns das bitte mit.',
|
||
'Die Gutschrift wird mit Ihren nächsten Monatsgebühren verrechnet. Sie wird nicht ausgezahlt und ist nicht übertragbar; ein bei Vertragsende noch nicht verrechneter Rest entfällt.',
|
||
'Die Übernahmegutschrift ist eine freiwillige Leistung von WEBklar. Über Ihren Antrag entscheiden wir nach Prüfung und antworten Ihnen per E-Mail.',
|
||
]
|
||
|
||
const TAKEOVER_MONTHS = [[1, '1 Monat'], [3, '3 Monate'], [6, '6 Monate'], [12, '12 Monate (Jahresrechnung)'], [24, '24 Monate'], [36, '36 Monate']]
|
||
|
||
function takeoverStatusPill(status) {
|
||
const [cls, label] = TAKEOVER_STATUS[status] || ['pill-muted', status]
|
||
return `<span class="pill ${cls}">${label}</span>`
|
||
}
|
||
|
||
function renderTakeoverItem(r) {
|
||
const facts = [
|
||
`eingereicht am ${fmtDateShort(r.createdAt)}`,
|
||
`bisher bei ${escapeHtml(r.provider)}${r.domain ? ` (${escapeHtml(r.domain)})` : ''}`,
|
||
`${fmtEuro(r.invoiceAmountCents / 100)} für ${r.billingMonths === 1 ? '1 Monat' : `${r.billingMonths} Monate`}, bezahlt bis ${fmtDateShort(r.paidUntil)}`,
|
||
]
|
||
let body
|
||
if (r.status === 'genehmigt') {
|
||
body = `<p>Gutschrift: <strong>${fmtEuro(r.amountCents / 100)}</strong> – wird mit Ihren nächsten Monatsgebühren verrechnet.</p>
|
||
${r.decisionNote ? `<p class="abo-note" style="white-space:pre-wrap">${escapeHtml(r.decisionNote)}</p>` : ''}`
|
||
} else if (r.status === 'abgelehnt') {
|
||
body = `<p class="abo-note" style="white-space:pre-wrap">${escapeHtml(r.decisionNote)}</p>`
|
||
} else if (r.status === 'rueckfrage') {
|
||
body = `
|
||
<div class="tk-question"><strong>Unsere Rückfrage:</strong><br>${escapeHtml(r.question)}</div>
|
||
<form class="tk-reply" data-reply-for="${escapeAttr(r.id)}" novalidate>
|
||
<textarea name="message" maxlength="4000" placeholder="Ihre Antwort…"></textarea>
|
||
<input type="file" name="files" accept="application/pdf,image/jpeg,image/png" multiple aria-label="Weitere Nachweise">
|
||
<p class="error hidden"></p>
|
||
<div class="abo-actions"><button type="submit" class="abo-btn"${chatState.viewAs ? ' disabled' : ''}>Antwort senden</button></div>
|
||
</form>`
|
||
} else {
|
||
body = '<p class="abo-note">Wir prüfen Ihre Angaben und antworten Ihnen per E-Mail.</p>'
|
||
}
|
||
return `<div class="tk-item">
|
||
<div class="tk-item-head"><strong>Antrag ${escapeHtml(r.ref)} · ${escapeHtml(r.services)}</strong>${takeoverStatusPill(r.status)}</div>
|
||
<div class="abo-note">${facts.join(' · ')}</div>
|
||
${body}
|
||
</div>`
|
||
}
|
||
|
||
function takeoverFormHtml(limits) {
|
||
const email = takeoverState.customer?.email || ''
|
||
const maxMb = Math.round(limits.maxTotalBytes / 1024 / 1024)
|
||
return `
|
||
<form id="tk-form" class="tk-form hidden" novalidate>
|
||
<fieldset class="tk-field">
|
||
<legend>Welche Leistung haben Sie bisher woanders bezahlt?</legend>
|
||
<div class="tk-services">
|
||
<label><input type="checkbox" name="services" value="domain"> Domain</label>
|
||
<label><input type="checkbox" name="services" value="hosting"> Hosting / Website-Betrieb</label>
|
||
<label><input type="checkbox" name="services" value="email"> E-Mail-Postfach</label>
|
||
<label><input type="checkbox" name="services" value="sonstiges"> Sonstiges</label>
|
||
</div>
|
||
</fieldset>
|
||
<div class="tk-row">
|
||
<div class="tk-field"><label for="tk-provider">Bisheriger Anbieter</label>
|
||
<input id="tk-provider" maxlength="200" placeholder="z. B. IONOS, STRATO"></div>
|
||
<div class="tk-field"><label for="tk-domain">Domain <small>(falls vorhanden)</small></label>
|
||
<input id="tk-domain" maxlength="255" placeholder="meine-firma.de" autocapitalize="off" spellcheck="false"></div>
|
||
</div>
|
||
<div class="tk-row">
|
||
<div class="tk-field"><label for="tk-amount">Rechnungsbetrag <small>(brutto, in €)</small></label>
|
||
<input id="tk-amount" type="number" min="0.01" step="0.01" inputmode="decimal" placeholder="119,88"></div>
|
||
<div class="tk-field"><label for="tk-months">Die Rechnung gilt für</label>
|
||
<select id="tk-months"><option value="">bitte wählen</option>
|
||
${TAKEOVER_MONTHS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></div>
|
||
<div class="tk-field"><label for="tk-paid-until">Bezahlt bis <small>(letzter Tag)</small></label>
|
||
<input id="tk-paid-until" type="date"></div>
|
||
</div>
|
||
<div class="tk-field"><label for="tk-message">Grund / Nachricht an uns</label>
|
||
<textarea id="tk-message" maxlength="4000" placeholder="z. B.: Domain und Postfach liegen noch bis Ende März bei IONOS, der Vertrag dort ist gekündigt."></textarea></div>
|
||
<div class="tk-field"><label for="tk-files">Rechnungsnachweis <small>(PDF, JPG oder PNG · bis zu ${limits.maxFiles} Dateien · zusammen max. ${maxMb} MB)</small></label>
|
||
<input id="tk-files" type="file" accept="application/pdf,image/jpeg,image/png" multiple></div>
|
||
<p class="abo-note" style="margin-top:-6px">Tipp: Laden Sie auch die Kündigungsbestätigung Ihres bisherigen Anbieters hoch – daraus sehen wir, wann der Vertrag dort endet.</p>
|
||
<div class="tk-field"><label for="tk-email">E-Mail-Adresse für unsere Antwort</label>
|
||
<input id="tk-email" type="email" maxlength="255" autocomplete="email" value="${escapeAttr(email)}"></div>
|
||
<details class="tk-conditions">
|
||
<summary>Bedingungen der Übernahmegutschrift</summary>
|
||
<ol>${TAKEOVER_CONDITIONS.map((c) => `<li>${c}</li>`).join('')}</ol>
|
||
</details>
|
||
<label class="abo-check"><input type="checkbox" id="tk-consent">
|
||
<span>Ich versichere, dass meine Angaben stimmen und die Rechnung auf mich bzw. mein Unternehmen ausgestellt ist. Die Bedingungen der Übernahmegutschrift habe ich gelesen. Angaben und Nachweise verwendet WEBklar zur Prüfung und Abwicklung des Antrags (<a href="/datenschutz.html#uebernahmegutschrift" target="_blank" rel="noopener">Datenschutz</a>).</span></label>
|
||
<p class="error hidden" id="tk-error"></p>
|
||
<div class="abo-actions">
|
||
<button type="submit" class="abo-btn" id="tk-submit">Antrag absenden</button>
|
||
<button type="button" class="kb-btn-outline" id="tk-cancel">Abbrechen</button>
|
||
</div>
|
||
</form>`
|
||
}
|
||
|
||
function readFileAsBase64(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader()
|
||
reader.onload = () => resolve(String(reader.result).split(',')[1] || '')
|
||
reader.onerror = () => reject(new Error(`„${file.name}“ konnte nicht gelesen werden.`))
|
||
reader.readAsDataURL(file)
|
||
})
|
||
}
|
||
|
||
async function collectTakeoverFiles(input, limits, { required }) {
|
||
const files = [...(input?.files || [])]
|
||
const mb = (bytes) => Math.round(bytes / 1024 / 1024)
|
||
if (required && !files.length) throw new Error('Bitte laden Sie die Rechnung Ihres bisherigen Anbieters hoch.')
|
||
if (files.length > limits.maxFiles) throw new Error(`Bitte höchstens ${limits.maxFiles} Dateien auf einmal hochladen.`)
|
||
const tooBig = files.find((f) => f.size > limits.maxFileBytes)
|
||
if (tooBig) throw new Error(`„${tooBig.name}“ ist größer als ${mb(limits.maxFileBytes)} MB.`)
|
||
if (files.reduce((s, f) => s + f.size, 0) > limits.maxTotalBytes) {
|
||
throw new Error(`Die Dateien sind zusammen größer als ${mb(limits.maxTotalBytes)} MB.`)
|
||
}
|
||
return Promise.all(files.map(async (f) => ({ name: f.name, data: await readFileAsBase64(f) })))
|
||
}
|
||
|
||
async function submitTakeoverForm(form, limits) {
|
||
const errEl = form.querySelector('#tk-error')
|
||
const btn = form.querySelector('#tk-submit')
|
||
hideError(errEl)
|
||
const value = (id) => form.querySelector(id).value.trim()
|
||
const payload = {
|
||
services: [...form.querySelectorAll('input[name="services"]:checked')].map((i) => i.value),
|
||
provider: value('#tk-provider'),
|
||
domain: value('#tk-domain'),
|
||
invoiceAmount: value('#tk-amount'),
|
||
billingMonths: Number(value('#tk-months')),
|
||
paidUntil: value('#tk-paid-until'),
|
||
message: value('#tk-message'),
|
||
email: value('#tk-email'),
|
||
consent: form.querySelector('#tk-consent').checked,
|
||
}
|
||
try {
|
||
if (!payload.services.length) throw new Error('Bitte wählen Sie, welche Leistung Sie bisher woanders bezahlt haben.')
|
||
if (!payload.provider) throw new Error('Bitte nennen Sie Ihren bisherigen Anbieter.')
|
||
if (!payload.invoiceAmount || !payload.billingMonths || !payload.paidUntil) {
|
||
throw new Error('Bitte geben Sie Rechnungsbetrag, Zeitraum und „Bezahlt bis“ an.')
|
||
}
|
||
if (!payload.consent) throw new Error('Bitte bestätigen Sie Ihre Angaben und die Bedingungen.')
|
||
btn.disabled = true
|
||
btn.textContent = 'Wird gesendet…'
|
||
payload.files = await collectTakeoverFiles(form.querySelector('#tk-files'), limits, { required: true })
|
||
await api('/api/abo/takeover-credits', { method: 'POST', body: JSON.stringify(payload) })
|
||
await loadTakeoverCard({
|
||
notice: `Vielen Dank! Ihr Antrag ist eingegangen. Die Eingangsbestätigung haben wir an ${payload.email} geschickt; unsere Antwort kommt ebenfalls per E-Mail.`,
|
||
})
|
||
} catch (err) {
|
||
showError(errEl, err.message)
|
||
btn.disabled = false
|
||
btn.textContent = 'Antrag absenden'
|
||
}
|
||
}
|
||
|
||
async function submitTakeoverReply(form, limits) {
|
||
const errEl = form.querySelector('.error')
|
||
const btn = form.querySelector('button[type="submit"]')
|
||
hideError(errEl)
|
||
btn.disabled = true
|
||
try {
|
||
const message = form.querySelector('textarea').value.trim()
|
||
const files = await collectTakeoverFiles(form.querySelector('input[type="file"]'), limits, { required: false })
|
||
if (!message && !files.length) throw new Error('Bitte schreiben Sie eine Antwort oder laden Sie einen Nachweis hoch.')
|
||
await api(`/api/abo/takeover-credits/${encodeURIComponent(form.dataset.replyFor)}/reply`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ message, files }),
|
||
})
|
||
await loadTakeoverCard({ notice: 'Danke für Ihre Antwort! Wir prüfen Ihren Antrag weiter und melden uns per E-Mail.' })
|
||
} catch (err) {
|
||
showError(errEl, err.message)
|
||
btn.disabled = false
|
||
}
|
||
}
|
||
|
||
function updateTakeoverBadge(requests) {
|
||
const badge = document.getElementById('takeover-badge')
|
||
if (!badge) return
|
||
const open = requests.filter((r) => r.status === 'rueckfrage').length
|
||
badge.textContent = open
|
||
badge.classList.toggle('hidden', !open)
|
||
}
|
||
|
||
async function loadTakeoverCard({ abo, notice = '' } = {}) {
|
||
const card = document.getElementById('takeover-card')
|
||
if (!card) return
|
||
if (abo) takeoverState.abo = abo
|
||
const active = Boolean(takeoverState.abo?.subscription?.active)
|
||
let data = { requests: [], limits: null }
|
||
if (takeoverState.abo?.subscription) {
|
||
try {
|
||
data = await api('/api/abo/takeover-credits')
|
||
} catch (err) {
|
||
card.innerHTML = `<div class="kb-panel kb-empty">Übernahmegutschrift konnte nicht geladen werden: ${escapeHtml(err.message)}</div>`
|
||
return
|
||
}
|
||
}
|
||
const requests = data.requests || []
|
||
updateTakeoverBadge(requests)
|
||
if (!active && !requests.length) {
|
||
card.innerHTML = '<div class="kb-panel"><p class="abo-note" style="margin:0">Die Übernahmegutschrift können Sie beantragen, sobald Ihr Wartungs-Abo läuft. <a href="#abo" data-view="abo">Zum Wartungs-Abo</a></p></div>'
|
||
return
|
||
}
|
||
|
||
card.innerHTML = `
|
||
<div class="kb-panel">
|
||
<p class="abo-note tk-intro">Sie haben Domain, Hosting oder E-Mail bei Ihrem bisherigen Anbieter schon im Voraus bezahlt? Den Anteil, den Sie ab Beginn Ihres Abos doppelt zahlen, schreiben wir Ihnen gut und verrechnen ihn mit Ihren nächsten Monatsgebühren. Schicken Sie uns dazu einfach die Rechnung.</p>
|
||
${notice ? `<div class="tk-notice">${escapeHtml(notice)}</div>` : ''}
|
||
${active ? `
|
||
<div class="abo-actions">
|
||
<button type="button" class="abo-btn" id="tk-start"${chatState.viewAs ? ' disabled' : ''}>Gutschrift beantragen</button>
|
||
${chatState.viewAs ? '<span class="abo-note">Im Ansichtsmodus kann kein Antrag gestellt werden.</span>' : ''}
|
||
</div>
|
||
${takeoverFormHtml(data.limits)}` : ''}
|
||
${requests.length ? `<div class="tk-list">${requests.map(renderTakeoverItem).join('')}</div>` : ''}
|
||
</div>`
|
||
|
||
const form = card.querySelector('#tk-form')
|
||
const startBtn = card.querySelector('#tk-start')
|
||
startBtn?.addEventListener('click', () => {
|
||
form.classList.remove('hidden')
|
||
startBtn.classList.add('hidden')
|
||
form.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||
})
|
||
card.querySelector('#tk-cancel')?.addEventListener('click', () => {
|
||
form.classList.add('hidden')
|
||
startBtn?.classList.remove('hidden')
|
||
})
|
||
form?.addEventListener('submit', (ev) => {
|
||
ev.preventDefault()
|
||
submitTakeoverForm(form, data.limits)
|
||
})
|
||
card.querySelectorAll('form[data-reply-for]').forEach((f) =>
|
||
f.addEventListener('submit', (ev) => {
|
||
ev.preventDefault()
|
||
submitTakeoverReply(f, data.limits)
|
||
}))
|
||
}
|
||
|
||
/* ---------------------------------------------------------- Navigation --- */
|
||
// Ansichten im Inhaltsbereich; Hash fuer Direktlinks (z. B. aus Mails: #abo, #gutschrift).
|
||
// "wartung" gibt es nur in der App-Ansicht (Tab), am Desktop zeigt sie das Wartungs-Abo.
|
||
const VIEW_HASHES = { projects: '', activity: 'fortschritt', invoices: 'rechnungen', chat: 'chat', wartung: 'wartung', abo: 'abo', takeover: 'gutschrift' }
|
||
const viewLoaders = { activity: loadCustomerActivity, chat: initChat }
|
||
const loadedViews = new Set()
|
||
|
||
function viewFromHash() {
|
||
const hash = window.location.hash.replace(/^#/, '')
|
||
return Object.keys(VIEW_HASHES).find((v) => VIEW_HASHES[v] && VIEW_HASHES[v] === hash) || ''
|
||
}
|
||
|
||
function showView(name, { updateHash = true } = {}) {
|
||
const view = document.getElementById(`view-${name}`)
|
||
if (!view) return
|
||
document.querySelectorAll('.kb-view').forEach((v) => v.classList.toggle('active', v === view))
|
||
document.querySelectorAll('.kb-nav-item').forEach((b) => b.classList.toggle('active', b.dataset.view === name))
|
||
if (!loadedViews.has(name) && viewLoaders[name]) {
|
||
loadedViews.add(name)
|
||
viewLoaders[name]()
|
||
}
|
||
if (name === 'chat') {
|
||
api('/api/chat/read', { method: 'POST' }).catch(() => {})
|
||
updateChatBadge(0)
|
||
}
|
||
if (updateHash) {
|
||
const hash = VIEW_HASHES[name]
|
||
window.history.replaceState(null, '', hash ? `#${hash}` : window.location.pathname)
|
||
}
|
||
// App-Ansicht: Titel, Zurueck-Pfeil, Tab und Scrollposition nachziehen (initAppShell)
|
||
document.dispatchEvent(new CustomEvent('kb:view', { detail: { name } }))
|
||
}
|
||
|
||
function initialsOf(name) {
|
||
const words = String(name || '').split(/\s+/).filter((w) => /^[\p{L}\p{N}]/u.test(w))
|
||
const letters = words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '').slice(0, 2)
|
||
return letters.toUpperCase()
|
||
}
|
||
|
||
async function initDashboardPage() {
|
||
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')
|
||
|
||
initAppShell()
|
||
|
||
logoutBtn?.addEventListener('click', async () => {
|
||
await api('/api/auth/logout', { method: 'POST' })
|
||
window.location.href = '/login.html'
|
||
})
|
||
|
||
// Seitenleiste, Chips in den Projektkarten und Links im Inhalt schalten die Ansicht um
|
||
document.addEventListener('click', (ev) => {
|
||
const target = ev.target.closest('[data-view]')
|
||
if (!target || target.disabled) return
|
||
ev.preventDefault()
|
||
showView(target.dataset.view)
|
||
})
|
||
|
||
// Nach dem Login zurueck zur aufgerufenen Ansicht (z. B. #gutschrift aus einer Mail)
|
||
const loginUrl = window.location.hash
|
||
? `/login.html?next=${encodeURIComponent(`/dashboard.html${window.location.hash}`)}`
|
||
: '/login.html'
|
||
|
||
try {
|
||
const me = await api('/api/auth/me')
|
||
|
||
if (!me.authenticated) {
|
||
window.location.href = loginUrl
|
||
return
|
||
}
|
||
if (me.role === 'admin' && !me.customer) {
|
||
window.location.href = '/admin.html'
|
||
return
|
||
}
|
||
if (!me.customer) {
|
||
window.location.href = loginUrl
|
||
return
|
||
}
|
||
|
||
const customer = me.customer
|
||
chatState.viewAs = Boolean(me.viewAs)
|
||
takeoverState.customer = customer
|
||
renderViewAsBanner(me)
|
||
|
||
const displayName = customer.companyName || customer.name || customer.email
|
||
document.getElementById('customer-name').textContent = displayName
|
||
document.getElementById('customer-meta').textContent =
|
||
[customer.code ? `Kundennr. ${customer.code}` : '', customer.email].filter(Boolean).join(' · ')
|
||
document.getElementById('customer-avatar').textContent = initialsOf(displayName)
|
||
|
||
api('/api/contact')
|
||
.then((c) => {
|
||
document.getElementById('contact-name').textContent = c.name
|
||
document.getElementById('contact-response').textContent = c.responseTime
|
||
})
|
||
.catch(() => {})
|
||
|
||
const aboParam = getQueryParam('abo')
|
||
if (aboParam) window.history.replaceState(null, '', `/dashboard.html${window.location.hash}`)
|
||
loadAboCard({ afterCheckout: aboParam === 'success' })
|
||
loadCustomerInvoices()
|
||
|
||
const initialView = viewFromHash()
|
||
if (initialView) showView(initialView, { updateHash: false })
|
||
|
||
const [{ projects }, { features }] = await Promise.all([
|
||
api('/api/projects'),
|
||
api('/api/features'),
|
||
])
|
||
|
||
loading.classList.add('hidden')
|
||
document.getElementById('projects-count').textContent = projects.length || ''
|
||
if (projects.length > 1) {
|
||
document.getElementById('projects-subtitle').textContent = 'Deine Websites – Vorschau, Status und Details.'
|
||
}
|
||
|
||
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 der Chat 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 = loginUrl
|
||
return
|
||
}
|
||
showError(loadError, err.message)
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------- App-Ansicht (Handy) --- */
|
||
// Auf dem Handy wird der Kundenbereich zur App: die Navigation ist die Tab-Leiste unten, die
|
||
// Kopfzeile zeigt beim Scrollen den Seitentitel, Stundenkonto/Bestellung und Gutschrift sind
|
||
// Unterseiten der Wartung (mit Zurueck), das Konto ist ein Bottom-Sheet. Styles: kundenbereich.css.
|
||
// Gleiche Bedingung wie der Abschnitt "App-Ansicht" in kundenbereich.css (auch Handys quer)
|
||
const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (max-height: 500px)')
|
||
const coarsePointer = window.matchMedia('(pointer: coarse)')
|
||
// Unterseite -> Tab, zu dem sie gehoert
|
||
const APP_PARENT = { abo: 'wartung', takeover: 'wartung' }
|
||
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
|
||
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
|
||
|
||
function isAppLayout() {
|
||
return appLayout.matches
|
||
}
|
||
|
||
function onMediaChange(mq, fn) {
|
||
if (mq.addEventListener) mq.addEventListener('change', fn)
|
||
else mq.addListener(fn)
|
||
}
|
||
|
||
function isStandaloneApp() {
|
||
return window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true
|
||
}
|
||
|
||
function isIosDevice() {
|
||
return /iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
|
||
}
|
||
|
||
// Karten der Seitenleiste: App-Ansicht -> Ansprechpartner ins Konto-Sheet, Abo-Karte in den Tab "Wartung"
|
||
function placeSideCards() {
|
||
for (const { el, slot, anchor } of appShell.sideCards) {
|
||
if (isAppLayout()) {
|
||
if (el.parentNode !== slot) slot.append(el)
|
||
} else if (anchor.nextSibling !== el) {
|
||
anchor.after(el)
|
||
}
|
||
}
|
||
}
|
||
|
||
function currentViewTitle() {
|
||
return document.querySelector(`#view-${appShell.view} .kb-head h1`)
|
||
}
|
||
|
||
// Kopfzeile: Hintergrund nach dem Scrollen, Seitentitel sobald die grosse Ueberschrift darunter verschwindet
|
||
function updateBarState() {
|
||
const header = document.querySelector('.kb-header')
|
||
const h1 = currentViewTitle()
|
||
const barBottom = header ? header.getBoundingClientRect().bottom : 0
|
||
document.body.classList.toggle('kb-scrolled', window.scrollY > 4)
|
||
document.body.classList.toggle('kb-titled', Boolean(h1 && h1.getBoundingClientRect().bottom < barBottom + 4))
|
||
}
|
||
|
||
function updateAppBar() {
|
||
const sub = isAppLayout() && Boolean(APP_PARENT[appShell.view])
|
||
document.getElementById('kb-back')?.classList.toggle('hidden', !sub)
|
||
document.body.classList.toggle('kb-subpage', sub)
|
||
const title = document.getElementById('kb-app-title')
|
||
if (title) title.textContent = currentViewTitle()?.textContent.trim() || ''
|
||
updateBarState()
|
||
}
|
||
|
||
function onAppViewShown(name) {
|
||
// Den Tab "Wartung" gibt es nur in der App-Ansicht; am Desktop direkt das Wartungs-Abo zeigen
|
||
if (name === 'wartung' && !isAppLayout()) {
|
||
showView('abo', { updateHash: !appShell.fromHistory })
|
||
return
|
||
}
|
||
const prev = appShell.view
|
||
const parent = APP_PARENT[name]
|
||
const nav = name === prev ? 'same' : parent === prev ? 'push' : APP_PARENT[prev] === name ? 'back' : 'tab'
|
||
appShell.view = name
|
||
document.body.dataset.kbView = name
|
||
document.body.dataset.kbNav = nav
|
||
|
||
// Unterseiten markieren ihren Tab
|
||
document.querySelectorAll('.kb-nav-item').forEach((b) => {
|
||
const on = b.dataset.view === (parent || name)
|
||
b.classList.toggle('active', on)
|
||
if (on) b.setAttribute('aria-current', 'page')
|
||
else b.removeAttribute('aria-current')
|
||
})
|
||
|
||
if (isAppLayout()) {
|
||
// Unterseite mit eigenem Verlaufseintrag: "Zurück" (auch die Android-Geste) führt zur Wartung
|
||
if (nav === 'push' && !appShell.fromHistory) {
|
||
window.history.replaceState(null, '', `#${VIEW_HASHES[parent]}`)
|
||
window.history.pushState({ kbSub: name }, '', `#${VIEW_HASHES[name]}`)
|
||
}
|
||
// Tabs merken sich ihre Scrollposition, Unterseiten beginnen oben, erneutes Tippen scrollt nach oben
|
||
if (nav === 'same') window.scrollTo({ top: 0, behavior: 'smooth' })
|
||
else window.scrollTo(0, nav === 'push' ? 0 : appShell.scroll[name] || 0)
|
||
}
|
||
updateAppBar()
|
||
}
|
||
|
||
/* Wartungs-Tab: Hinweis bei fehlgeschlagener Zahlung oder offener Rückfrage zur Gutschrift */
|
||
function updateWartungBadge() {
|
||
const card = document.getElementById('abo-card')
|
||
const badge = document.getElementById('kb-tab-wartung-badge')
|
||
if (!card || !badge) return
|
||
const paymentProblem = Boolean(card.querySelector('.kb-side-alert'))
|
||
const questions = Number(card.querySelector('#takeover-badge:not(.hidden)')?.textContent || 0)
|
||
badge.textContent = paymentProblem ? '!' : questions || ''
|
||
badge.title = paymentProblem ? 'Zahlung fehlgeschlagen' : questions ? 'Rückfrage offen' : ''
|
||
badge.classList.toggle('kb-badge-alert', paymentProblem)
|
||
badge.classList.toggle('hidden', !paymentProblem && !questions)
|
||
}
|
||
|
||
/* "Als App installieren": Chrome/Android per Installationsdialog, iPhone über Teilen -> Home-Bildschirm */
|
||
function renderInstallOffers() {
|
||
let dismissed = false
|
||
try {
|
||
dismissed = localStorage.getItem('kb-install-dismissed') === '1'
|
||
} catch {
|
||
/* ohne Speicher einfach wieder anzeigen */
|
||
}
|
||
const canPrompt = Boolean(appShell.installPrompt)
|
||
const ios = isIosDevice()
|
||
const offer = isAppLayout() && !isStandaloneApp() && (canPrompt || ios)
|
||
const shareIcon = '<svg class="kb-inline-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15V3M8 7l4-4 4 4"/><path d="M6 11H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-1"/></svg>'
|
||
document.querySelectorAll('[data-install]').forEach((box) => {
|
||
const dismissible = box.hasAttribute('data-install-dismissible')
|
||
const show = offer && !(dismissible && dismissed)
|
||
box.classList.toggle('hidden', !show)
|
||
if (!show) return
|
||
box.innerHTML = `
|
||
<img class="kb-install-icon" src="/img/app-icon-192.png" alt="" width="44" height="44">
|
||
<div class="kb-install-text">
|
||
<strong>WEBklar als App</strong>
|
||
<span>${canPrompt
|
||
? 'Mit einem Tipp auf deinem Homescreen – ohne App-Store.'
|
||
: `Tippe auf ${shareIcon} „Teilen“ und dann auf „Zum Home-Bildschirm“.`}</span>
|
||
</div>
|
||
${canPrompt ? '<button type="button" class="kb-install-go" data-install-go>Installieren</button>' : ''}
|
||
${dismissible ? '<button type="button" class="kb-install-x" data-install-dismiss aria-label="Hinweis ausblenden"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button>' : ''}`
|
||
})
|
||
}
|
||
|
||
/* Konto: Bottom-Sheet (Handy) bzw. Popover (Desktop) mit Ansprechpartner, Rechtlichem und Abmelden */
|
||
function initAccountSheet() {
|
||
const sheet = document.getElementById('kb-sheet')
|
||
const avatar = document.getElementById('customer-avatar')
|
||
if (!sheet || !avatar) return
|
||
if (typeof sheet.showModal !== 'function') {
|
||
// Sehr alte Browser ohne <dialog>: Abmelden bleibt in der Kopfzeile
|
||
document.body.classList.add('kb-no-sheet')
|
||
avatar.disabled = true
|
||
return
|
||
}
|
||
const panel = sheet.querySelector('.kb-sheet-panel')
|
||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||
// Zaehler gegen verspaetetes Schliessen, wenn das Sheet gleich wieder geoeffnet wurde
|
||
let closeToken = 0
|
||
|
||
const open = () => {
|
||
if (sheet.open) return
|
||
closeToken += 1
|
||
document.getElementById('kb-sheet-name').textContent = document.getElementById('customer-name')?.textContent || 'Mein Konto'
|
||
document.getElementById('kb-sheet-meta').textContent = document.getElementById('customer-meta')?.textContent || ''
|
||
document.getElementById('kb-sheet-avatar').textContent = avatar.textContent
|
||
renderInstallOffers()
|
||
sheet.classList.remove('is-closing')
|
||
panel.style.transform = ''
|
||
sheet.showModal()
|
||
document.documentElement.classList.add('kb-sheet-open')
|
||
}
|
||
const finish = () => {
|
||
sheet.classList.remove('is-closing')
|
||
panel.style.transform = ''
|
||
if (sheet.open) sheet.close()
|
||
}
|
||
const close = () => {
|
||
if (!sheet.open || sheet.classList.contains('is-closing')) return
|
||
if (reducedMotion.matches) return finish()
|
||
const token = ++closeToken
|
||
const done = () => {
|
||
if (token === closeToken) finish()
|
||
}
|
||
sheet.classList.add('is-closing')
|
||
panel.style.transform = ''
|
||
panel.addEventListener('transitionend', done, { once: true })
|
||
setTimeout(done, 400)
|
||
}
|
||
|
||
avatar.addEventListener('click', open)
|
||
// Escape bzw. Android-Zurück: mit Animation schließen
|
||
sheet.addEventListener('cancel', (ev) => {
|
||
ev.preventDefault()
|
||
close()
|
||
})
|
||
sheet.addEventListener('close', () => {
|
||
sheet.classList.remove('is-closing')
|
||
document.documentElement.classList.remove('kb-sheet-open')
|
||
avatar.focus({ preventScroll: true })
|
||
})
|
||
// Klick auf den Hintergrund, das X oder eine Ansicht (z. B. "Nachricht schreiben") schließt
|
||
sheet.addEventListener('click', (ev) => {
|
||
if (ev.target === sheet || ev.target.closest('[data-sheet-close], [data-view]')) close()
|
||
})
|
||
document.getElementById('kb-sheet-logout')?.addEventListener('click', () => document.getElementById('logout-btn')?.click())
|
||
|
||
// Nach unten wischen schließt das Sheet (Griff und Kopf des Sheets)
|
||
let drag = null
|
||
panel.addEventListener('pointerdown', (ev) => {
|
||
if (!isAppLayout() || !ev.target.closest('.kb-sheet-grip, .kb-sheet-me') || ev.target.closest('button')) return
|
||
drag = { y: ev.clientY, t: ev.timeStamp, dy: 0 }
|
||
panel.setPointerCapture(ev.pointerId)
|
||
panel.classList.add('is-dragging')
|
||
})
|
||
panel.addEventListener('pointermove', (ev) => {
|
||
if (!drag) return
|
||
drag.dy = Math.max(0, ev.clientY - drag.y)
|
||
panel.style.transform = `translateY(${drag.dy}px)`
|
||
})
|
||
const endDrag = (ev) => {
|
||
if (!drag) return
|
||
const { dy, t } = drag
|
||
drag = null
|
||
panel.classList.remove('is-dragging')
|
||
if (dy > 90 || dy / Math.max(1, ev.timeStamp - t) > 0.6) close()
|
||
else panel.style.transform = ''
|
||
}
|
||
panel.addEventListener('pointerup', endDrag)
|
||
panel.addEventListener('pointercancel', endDrag)
|
||
}
|
||
|
||
function initAppShell() {
|
||
// Karten der Seitenleiste mit Anker merken, damit sie am Desktop an ihren Platz zurueckkehren
|
||
for (const [selector, slotId] of [['.kb-side .kb-contact', 'kb-contact-slot'], ['#abo-card', 'kb-abo-slot']]) {
|
||
const el = document.querySelector(selector)
|
||
const slot = document.getElementById(slotId)
|
||
if (!el || !slot) continue
|
||
const anchor = document.createComment(`Platz: ${selector}`)
|
||
el.before(anchor)
|
||
appShell.sideCards.push({ el, slot, anchor })
|
||
}
|
||
placeSideCards()
|
||
document.body.dataset.kbView = appShell.view
|
||
|
||
document.addEventListener('kb:view', (ev) => onAppViewShown(ev.detail.name))
|
||
document.addEventListener('kb:abo', (ev) => {
|
||
document.getElementById('kb-tab-wartung')?.classList.toggle('hidden', !ev.detail)
|
||
const slot = document.getElementById('kb-abo-slot')
|
||
slot?.querySelector(':scope > .kb-empty')?.remove()
|
||
if (!ev.detail) slot?.insertAdjacentHTML('afterbegin', '<p class="kb-empty">Die Wartung konnte gerade nicht geladen werden. Bitte später noch einmal versuchen.</p>')
|
||
updateWartungBadge()
|
||
updateAppBar()
|
||
})
|
||
const aboCard = document.getElementById('abo-card')
|
||
if (aboCard) new MutationObserver(updateWartungBadge).observe(aboCard, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] })
|
||
|
||
// Zurueck: ueber den Verlauf, falls die Unterseite ihn angelegt hat, sonst direkt zur Wartung
|
||
document.getElementById('kb-back')?.addEventListener('click', () => {
|
||
if (window.history.state?.kbSub) window.history.back()
|
||
else showView(APP_PARENT[appShell.view] || 'projects')
|
||
})
|
||
window.addEventListener('popstate', () => {
|
||
const name = viewFromHash() || 'projects'
|
||
if (name === appShell.view) return
|
||
appShell.fromHistory = true
|
||
try {
|
||
showView(name, { updateHash: false })
|
||
} finally {
|
||
appShell.fromHistory = false
|
||
}
|
||
})
|
||
|
||
// Scrollposition je Ansicht und Zustand der Kopfzeile (einmal pro Frame)
|
||
let scrollQueued = false
|
||
window.addEventListener('scroll', () => {
|
||
if (scrollQueued) return
|
||
scrollQueued = true
|
||
requestAnimationFrame(() => {
|
||
scrollQueued = false
|
||
appShell.scroll[appShell.view] = window.scrollY
|
||
updateBarState()
|
||
})
|
||
}, { passive: true })
|
||
|
||
// Tastatur offen (Touch): Tab-Leiste ausblenden, damit Eingabe und Chat Platz haben
|
||
document.addEventListener('focusin', (ev) => {
|
||
if (coarsePointer.matches && ev.target.matches?.(TEXT_INPUT)) document.body.classList.add('kb-typing')
|
||
})
|
||
document.addEventListener('focusout', () => {
|
||
setTimeout(() => {
|
||
if (!document.activeElement?.matches?.(TEXT_INPUT)) document.body.classList.remove('kb-typing')
|
||
}, 80)
|
||
})
|
||
|
||
// Sichtbare Hoehe (ohne Tastatur) fuer den Chat; neueste Nachricht bleibt sichtbar.
|
||
// Schliesst die Tastatur, ohne dass das Feld den Fokus verliert (Android-Zurueck-Geste),
|
||
// kommt die Tab-Leiste zurueck; tippt man erneut hinein, verschwindet sie wieder.
|
||
const vv = window.visualViewport
|
||
if (vv) {
|
||
let fullHeight = vv.height
|
||
let width = vv.width
|
||
let keyboardOpen = false
|
||
const syncHeight = () => {
|
||
document.documentElement.style.setProperty('--kb-vvh', `${Math.round(vv.height)}px`)
|
||
if (Math.abs(vv.width - width) > 1) {
|
||
width = vv.width
|
||
fullHeight = vv.height
|
||
}
|
||
fullHeight = Math.max(fullHeight, vv.height)
|
||
const open = vv.height < fullHeight - 150
|
||
if (open !== keyboardOpen) {
|
||
keyboardOpen = open
|
||
if (!open) document.body.classList.remove('kb-typing')
|
||
else if (coarsePointer.matches && document.activeElement?.matches?.(TEXT_INPUT)) document.body.classList.add('kb-typing')
|
||
}
|
||
// Chat mit Tastatur: iOS schiebt die Seite hoch; App-Leiste, Verlauf und Eingabe passen genau hinein
|
||
if (keyboardOpen && appShell.view === 'chat' && isAppLayout()) window.scrollTo(0, 0)
|
||
}
|
||
vv.addEventListener('resize', syncHeight)
|
||
syncHeight()
|
||
}
|
||
const messages = document.getElementById('chat-messages')
|
||
if (messages && typeof ResizeObserver !== 'undefined') {
|
||
let atBottom = true
|
||
messages.addEventListener('scroll', () => {
|
||
atBottom = messages.scrollHeight - messages.scrollTop - messages.clientHeight < 48
|
||
}, { passive: true })
|
||
new ResizeObserver(() => {
|
||
if (atBottom) messages.scrollTop = messages.scrollHeight
|
||
}).observe(messages)
|
||
}
|
||
|
||
// Installations-Hinweis
|
||
window.addEventListener('beforeinstallprompt', (ev) => {
|
||
ev.preventDefault()
|
||
appShell.installPrompt = ev
|
||
renderInstallOffers()
|
||
})
|
||
window.addEventListener('appinstalled', () => {
|
||
appShell.installPrompt = null
|
||
renderInstallOffers()
|
||
})
|
||
document.addEventListener('click', async (ev) => {
|
||
if (ev.target.closest('[data-install-dismiss]')) {
|
||
try {
|
||
localStorage.setItem('kb-install-dismissed', '1')
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
renderInstallOffers()
|
||
} else if (ev.target.closest('[data-install-go]') && appShell.installPrompt) {
|
||
const prompt = appShell.installPrompt
|
||
appShell.installPrompt = null
|
||
await prompt.prompt()
|
||
renderInstallOffers()
|
||
}
|
||
})
|
||
|
||
onMediaChange(appLayout, () => {
|
||
placeSideCards()
|
||
if (!isAppLayout() && appShell.view === 'wartung') showView('abo')
|
||
updateAppBar()
|
||
renderInstallOffers()
|
||
})
|
||
|
||
initAccountSheet()
|
||
renderInstallOffers()
|
||
updateAppBar()
|
||
|
||
// Nach der Bestellung bei Stripe: Bestaetigung steht auf der Abo-Karte im Tab "Wartung"
|
||
if (isAppLayout() && getQueryParam('abo') === 'success') showView('wartung')
|
||
}
|
||
|
||
// App-Installation (Homescreen) und Offline-Seite; zwischengespeichert wird nur die Offline-Seite (sw.js)
|
||
if ('serviceWorker' in navigator && window.isSecureContext) {
|
||
window.addEventListener('load', () => {
|
||
navigator.serviceWorker.register('/sw.js').catch((err) => console.warn('Service Worker:', err.message))
|
||
})
|
||
}
|
||
|
||
window.initLoginPage = initLoginPage
|
||
window.initDashboardPage = initDashboardPage
|