Aktivität konnte nicht geladen werden: ${escapeHtml(err.message)}
`
}
}
/* ------------------------------------------------------------------ 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 `
${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' })
}
// 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 = `
${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}
Wartungs-Abo
${offer ? 'Ihr persönliches Angebot' : ''}
${offer
? `${euroShort(offer.monthlyPriceCents)}${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50 €`
: '50 €pro Monat'}
${ABO_SIDE_FEATURES.map((f) => `
${escapeHtml(f)}
`).join('')}
${data.available
? '12 Monate Mindestlaufzeit. Gemäß § 19 UStG ohne Umsatzsteuer.'
: 'Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'}
`
}
/* 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 ? 'gekündigt' : ''
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 = `
Ihre Wartung
${status}
${dateLine}
${sub.paymentProblem ? '
Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.
Betrieb, Pflege und Support für Ihre Website – ${offer ? 'mit Ihrem persönlichen Angebot' : 'für 50 € im Monat'}.
${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()}
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 ? `
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('')
view.innerHTML = `
Wartungs-Abo
Ihr Stundenkontingent, der Verlauf und Ihr Vertrag.
${sub.paymentProblem ? '
Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart über „Zahlungsart aktualisieren“ in der Seitenleiste, 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.
`
}
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 = '
Vielen Dank!
Ihr Abo wird aktiviert…
Das dauert nur einen Moment.
'
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 })
} 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.
Die Übernahmegutschrift können Sie beantragen, sobald Ihr Wartungs-Abo läuft. Zum Wartungs-Abo
'
return
}
card.innerHTML = `
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.' : ''}