Aktivität konnte nicht geladen werden: ${escapeHtml(err.message)}
`
}
}
/* ------------------------------------------------------------------ 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)}${msg.sender === 'customer' ? 'Du' : escapeHtml(msg.senderName || 'Webklar-Team')} · ${fmtDateTime(msg.createdAt)}`
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 `
${ABO_FEATURES.map(
([t, d]) => `
${t}${d}
`
).join('')}
`
}
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 = `
${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}
Wartungs-Abo
${offer
? `
${offerPrice}${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50 € · 12 Monate Mindestlaufzeit
`
: '
50 €pro Monat · 12 Monate Mindestlaufzeit
'}
${offer ? `
Ihr persönliches Angebot
${escapeHtml(offer.text)}. Sie sparen ${fmtEuro(offer.savingsCents / 100)}.
${offer.note ? `
${escapeHtml(offer.note)}
` : ''}
Gültig bis ${fmtDateShort(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt
` : ''}
${aboFeaturesHtml()}
${available ? '
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.
' : ''}
${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.'}
Ihre Bestellung
${data.testAccount ? `
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.' : ''}
` : ''}
Leistung
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)
Preis
${offer
? `${escapeHtml(offer.text)} (individuelles Angebot; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten`
: '50,00 € pro Monat (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten'}
Abrechnung
monatlich im Voraus, erstmals zum Leistungsbeginn
Laufzeit
Mindestlaufzeit 12 Monate ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach unbefristet und jederzeit zum Ende des Abrechnungsmonats kündbar
Zahlung
Kreditkarte oder SEPA-Lastschrift über Stripe
Anbieter
WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com
+${fmtHours(c.minutes)} ${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDateShort(c.expiresAt)}`}
`).join('')
card.innerHTML = `
Ihr Wartungs-Abo
Stundenkontingent
${statusLine}
${sub.paymentProblem ? '
Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.
' : ''}
${sub.serviceStartsAt ? `
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.
` : ''}
${fmtHours(l.balanceMinutes)}verfügbare Zeit
${fmtHours(l.usedMinutes)}bisher genutzt
${l.nextExpiry ? `
${fmtHours(l.nextExpiry.minutes)}verfallen am ${fmtDateShort(l.nextExpiry.at)}
` : ''}
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.` : ''}
` : ''}
`
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 = '
Vielen Dank!
Ihr Abo wird aktiviert…
Das dauert nur einen Moment.
'
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 `${label}`
}
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 = `
Gutschrift: ${fmtEuro(r.amountCents / 100)} – wird mit Ihren nächsten Monatsgebühren verrechnet.
${r.decisionNote ? `
${escapeHtml(r.decisionNote)}
` : ''}`
} else if (r.status === 'abgelehnt') {
body = `
${escapeHtml(r.decisionNote)}
`
} else if (r.status === 'rueckfrage') {
body = `
Unsere Rückfrage: ${escapeHtml(r.question)}
`
} else {
body = '
Wir prüfen Ihre Angaben und antworten Ihnen per E-Mail.
`
}
function takeoverFormHtml(limits) {
const email = takeoverState.customer?.email || ''
const maxMb = Math.round(limits.maxTotalBytes / 1024 / 1024)
return `
`
}
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 = `
Wechsel ohne doppelte Kosten
Übernahmegutschrift
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.
${notice ? `
${escapeHtml(notice)}
` : ''}
${active ? `
${chatState.viewAs ? 'Im Ansichtsmodus kann kein Antrag gestellt werden.' : ''}