- Admin-Tab "Abo-Angebote" und Button "Angebot" in der Kundenliste: Preis, Monate, Gueltigkeit, Hinweis fuer den Kunden, interne Notiz, Vorschau vor dem Anlegen - Je Angebot sofort ein Stripe-Coupon (amount_off, repeating ueber die Angebotsmonate, max. 1 Einloesung); danach automatisch wieder Normalpreis - Kunde ohne Abo: Angebot im Portal, Angebotspreis in der Bestelluebersicht, Coupon im Checkout; Angebot schaltet die Bestellung auch ausserhalb der Pilotliste frei; Bestellung prueft, dass das angezeigte Angebot noch gilt - Laufendes Abo: Coupon sofort am Abo fuer die naechsten Abbuchungen - Testkonten: Testrabatt im Checkout, Angebot per Webhook zusaetzlich am Abo - Angebot in Abo-Metadaten, Vertragsbestaetigung, Admin-Mail und Abo-Karte - Zurueckziehen loescht den Coupon; abgelaufene Angebote nicht mehr bestellbar - Stundenkontingent nach invoice.subtotal: auch voll rabattierte oder per Guthaben bezahlte Monate bringen die Stunde, die Probezeit nicht - setup-abo-collections.mjs: aboOffers, subscriptions.offerId/offerText, Attribut-Abfrage mit Limit (sonst Haenger ab 26 Attributen) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1161 lines
50 KiB
JavaScript
1161 lines
50 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()
|
||
}
|
||
|
||
// 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" 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
|
||
// Individuelles Angebot (vom WEBklar-Team angelegt), z. B. erste 2 Monate je 25 €
|
||
const offer = data.offer
|
||
const offerPrice = offer
|
||
? (offer.monthlyPriceCents % 100 === 0 ? `${offer.monthlyPriceCents / 100} €` : fmtEuro(offer.monthlyPriceCents / 100))
|
||
: ''
|
||
card.innerHTML = `
|
||
<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>
|
||
${offer
|
||
? `<div class="abo-price"><strong>${offerPrice}</strong><span>${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50 € · 12 Monate Mindestlaufzeit</span></div>`
|
||
: '<div class="abo-price"><strong>50 €</strong><span>pro Monat · 12 Monate Mindestlaufzeit</span></div>'}
|
||
</div>
|
||
${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>` : ''}
|
||
${aboFeaturesHtml()}
|
||
${available ? '<p class="abo-note" style="margin:-6px 0 16px">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 class="abo-actions">
|
||
<button type="button" class="abo-btn" id="abo-start" ${available ? '' : 'disabled'}>Abo abschließen</button>
|
||
<span class="abo-note">${available
|
||
? 'Mindestlaufzeit 12 Monate, danach monatlich kündbar. Zahlung per Kreditkarte oder SEPA-Lastschrift. Gemäß § 19 UStG ohne Umsatzsteuer.'
|
||
: 'Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'}</span>
|
||
</div>
|
||
<div class="abo-order hidden" id="abo-order">
|
||
<h3>Ihre Bestellung</h3>
|
||
${data.testAccount ? `<div class="abo-alert">Testkonto: Bei Stripe wird ein Test-Rabatt verrechnet, abgebucht werden nur 0,50 € pro Monat.${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="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, offerId: offer?.id || '' }),
|
||
})
|
||
window.location.href = url
|
||
} catch (err) {
|
||
showError(errEl, err.message)
|
||
btn.disabled = false
|
||
btn.textContent = 'Zahlungspflichtig bestellen'
|
||
}
|
||
})
|
||
}
|
||
|
||
function renderAboActive(card, data) {
|
||
const sub = data.subscription
|
||
const l = data.ledger || { balanceMinutes: 0, credits: [], usage: [] }
|
||
let statusLine
|
||
if (sub.serviceStartsAt) {
|
||
statusLine = `<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.cancelAt || 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.${sub.minTermEnd && !sub.cancelAtPeriodEnd ? ` Mindestlaufzeit bis ${fmtDateShort(sub.minTermEnd)}, danach monatlich kündbar.` : ''}</p>
|
||
${sub.offerText ? `<p class="abo-note">Ihr Angebot: ${escapeHtml(sub.offerText)}.</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>
|
||
${sub.cancelAtPeriodEnd ? '' : '<a class="abo-note" href="/kuendigen.html">Kündigen</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')
|
||
loadTakeoverCard({ abo: data })
|
||
} catch (err) {
|
||
console.warn('Abo konnte nicht geladen werden:', err.message)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Übernahmegutschrift: Antrag stellen, Stand sehen, Rückfragen beantworten
|
||
// ---------------------------------------------------------------------------
|
||
|
||
const takeoverState = { abo: null, customer: null }
|
||
|
||
const TAKEOVER_STATUS = {
|
||
offen: ['pill-warn', 'In Prüfung'],
|
||
rueckfrage: ['pill-amber', 'Rückfrage'],
|
||
genehmigt: ['pill-ok', 'Genehmigt'],
|
||
abgelehnt: ['pill-muted', 'Nicht genehmigt'],
|
||
}
|
||
|
||
// Bei Textänderungen config.takeover.conditionsVersion auf dem Server hochziehen
|
||
const TAKEOVER_CONDITIONS = [
|
||
'Die Übernahmegutschrift gibt es für Leistungen, die das Wartungs-Abo ersetzt (Domain, Hosting bzw. Betrieb der Website, E-Mail-Postfach) und die Sie bei Ihrem bisherigen Anbieter bereits im Voraus bezahlt haben.',
|
||
'Wir schreiben den Anteil der Rechnung gut, der auf die Zeit ab Leistungsbeginn Ihres Abos bis zum Ende des bezahlten Zeitraums entfällt, tagesgenau berechnet.',
|
||
'Nachweis ist die Rechnung des bisherigen Anbieters, ausgestellt auf Sie, mit Leistung, Zeitraum und Betrag. Wir können weitere Nachweise anfordern, etwa die Kündigungsbestätigung. Erstattet Ihr bisheriger Anbieter Beträge für diesen Zeitraum, teilen Sie uns das bitte mit.',
|
||
'Die Gutschrift wird mit Ihren nächsten Monatsgebühren verrechnet. Sie wird nicht ausgezahlt und ist nicht übertragbar; ein bei Vertragsende noch nicht verrechneter Rest entfällt.',
|
||
'Die Übernahmegutschrift ist eine freiwillige Leistung von WEBklar. Über Ihren Antrag entscheiden wir nach Prüfung und antworten Ihnen per E-Mail.',
|
||
]
|
||
|
||
const TAKEOVER_MONTHS = [[1, '1 Monat'], [3, '3 Monate'], [6, '6 Monate'], [12, '12 Monate (Jahresrechnung)'], [24, '24 Monate'], [36, '36 Monate']]
|
||
|
||
function takeoverStatusPill(status) {
|
||
const [cls, label] = TAKEOVER_STATUS[status] || ['pill-muted', status]
|
||
return `<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="portal-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
|
||
}
|
||
}
|
||
|
||
async function loadTakeoverCard({ abo, notice = '' } = {}) {
|
||
const card = document.getElementById('takeover-card')
|
||
if (!card) return
|
||
if (abo) takeoverState.abo = abo
|
||
const active = Boolean(takeoverState.abo?.subscription?.active)
|
||
if (!takeoverState.abo?.subscription) {
|
||
card.classList.add('hidden')
|
||
return
|
||
}
|
||
let data
|
||
try {
|
||
data = await api('/api/abo/takeover-credits')
|
||
} catch (err) {
|
||
console.warn('Übernahmegutschrift konnte nicht geladen werden:', err.message)
|
||
return
|
||
}
|
||
const requests = data.requests || []
|
||
if (!active && !requests.length) {
|
||
card.classList.add('hidden')
|
||
return
|
||
}
|
||
|
||
card.innerHTML = `
|
||
<div class="abo-head">
|
||
<div>
|
||
<p class="abo-kicker">Wechsel ohne doppelte Kosten</p>
|
||
<h2 class="abo-title">Übernahmegutschrift</h2>
|
||
</div>
|
||
</div>
|
||
<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>` : ''}`
|
||
card.classList.remove('hidden')
|
||
|
||
const form = card.querySelector('#tk-form')
|
||
const startBtn = card.querySelector('#tk-start')
|
||
startBtn?.addEventListener('click', () => {
|
||
form.classList.remove('hidden')
|
||
startBtn.classList.add('hidden')
|
||
form.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||
})
|
||
card.querySelector('#tk-cancel')?.addEventListener('click', () => {
|
||
form.classList.add('hidden')
|
||
startBtn?.classList.remove('hidden')
|
||
})
|
||
form?.addEventListener('submit', (ev) => {
|
||
ev.preventDefault()
|
||
submitTakeoverForm(form, data.limits)
|
||
})
|
||
card.querySelectorAll('form[data-reply-for]').forEach((f) =>
|
||
f.addEventListener('submit', (ev) => {
|
||
ev.preventDefault()
|
||
submitTakeoverReply(f, data.limits)
|
||
}))
|
||
}
|
||
|
||
async function initDashboardPage() {
|
||
const meta = document.getElementById('customer-meta')
|
||
const list = document.getElementById('projects')
|
||
const loading = document.getElementById('loading')
|
||
const empty = document.getElementById('empty')
|
||
const loadError = document.getElementById('load-error')
|
||
const logoutBtn = document.getElementById('logout-btn')
|
||
|
||
logoutBtn?.addEventListener('click', async () => {
|
||
await api('/api/auth/logout', { method: 'POST' })
|
||
window.location.href = '/login.html'
|
||
})
|
||
|
||
// Tabs (Muster wie Admin-Dashboard)
|
||
const loadedTabs = new Set(['projects'])
|
||
const lazyLoaders = {
|
||
activity: loadCustomerActivity,
|
||
invoices: loadCustomerInvoices,
|
||
chat: initChat,
|
||
}
|
||
document.querySelectorAll('.portal-tab').forEach((tab) => {
|
||
tab.addEventListener('click', () => {
|
||
document.querySelectorAll('.portal-tab').forEach((t) => t.classList.remove('active'))
|
||
document.querySelectorAll('.portal-section').forEach((s) => s.classList.remove('active'))
|
||
tab.classList.add('active')
|
||
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active')
|
||
const name = tab.dataset.tab
|
||
if (!loadedTabs.has(name) && lazyLoaders[name]) {
|
||
loadedTabs.add(name)
|
||
lazyLoaders[name]()
|
||
}
|
||
if (name === 'chat') {
|
||
api('/api/chat/read', { method: 'POST' }).catch(() => {})
|
||
updateChatBadge(0)
|
||
}
|
||
})
|
||
})
|
||
|
||
try {
|
||
const me = await api('/api/auth/me')
|
||
|
||
if (!me.authenticated) {
|
||
window.location.href = '/login.html'
|
||
return
|
||
}
|
||
if (me.role === 'admin' && !me.customer) {
|
||
window.location.href = '/admin.html'
|
||
return
|
||
}
|
||
if (!me.customer) {
|
||
window.location.href = '/login.html'
|
||
return
|
||
}
|
||
|
||
const customer = me.customer
|
||
chatState.viewAs = Boolean(me.viewAs)
|
||
takeoverState.customer = customer
|
||
renderViewAsBanner(me)
|
||
|
||
const codeSuffix = customer.code ? ` · Kundennr. ${customer.code}` : ''
|
||
meta.textContent = (customer.name ? `${customer.name} (${customer.email})` : customer.email) + codeSuffix
|
||
|
||
const aboParam = getQueryParam('abo')
|
||
if (aboParam) window.history.replaceState(null, '', '/dashboard.html')
|
||
loadAboCard({ afterCheckout: aboParam === 'success' })
|
||
|
||
const [{ projects }, { features }] = await Promise.all([
|
||
api('/api/projects'),
|
||
api('/api/features'),
|
||
])
|
||
|
||
loading.classList.add('hidden')
|
||
|
||
if (projects.length) {
|
||
list.classList.remove('hidden')
|
||
list.innerHTML = ''
|
||
for (const project of projects) {
|
||
list.appendChild(renderProjectCard(project, features))
|
||
}
|
||
} else {
|
||
empty.classList.remove('hidden')
|
||
}
|
||
|
||
// Chat-Badge global pollen (auch wenn Chat-Tab nicht offen ist)
|
||
pollChatBadge()
|
||
chatState.badgeTimer = setInterval(pollChatBadge, 45000)
|
||
} catch (err) {
|
||
loading.classList.add('hidden')
|
||
if (err.status === 401 || err.message.includes('Nicht angemeldet')) {
|
||
window.location.href = '/login.html'
|
||
return
|
||
}
|
||
showError(loadError, err.message)
|
||
}
|
||
}
|
||
|
||
window.initLoginPage = initLoginPage
|
||
window.initDashboardPage = initDashboardPage
|