` : ''}
${steps}${hint}`
}
// Lange Listen: nur die ersten Eintraege zeigen, "3 weitere … anzeigen" klappt den Rest auf (initPdToggles)
function pdMoreButton(total, shown, [one, many]) {
const rest = total - shown
if (rest <= 0) return ''
const text = `${rest} ${rest === 1 ? one : many} anzeigen`
return ``
}
function renderChange(c, extra) {
const d = new Date(c.date)
const time = Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
return `
${escapeHtml(c.title)}
${time ? `` : ''}
${c.details?.length ? `
${c.details.map((t) => `
${escapeHtml(t)}
`).join('')}
` : ''}
`
}
// Zeitleiste der letzten Aenderungen, nach Tagen gruppiert
function renderChanges(changes) {
if (!changes.length) return '
Noch keine Änderungen an der Website.
'
const groups = []
changes.forEach((c, i) => {
const d = new Date(c.date)
const day = Number.isNaN(d.getTime()) ? '' : changeDayLabel(d)
if (!groups.length || groups[groups.length - 1].day !== day) groups.push({ day, first: i, items: [] })
groups[groups.length - 1].items.push(renderChange(c, i >= PD_CHANGES_SHOWN))
})
return `
`
}
function pdSection(icon, title, body) {
return `
${pdIcon(icon)}${title}
${body}`
}
// stillWanted: die Handy-Seite teilt sich einen Container fuer alle Projekte; eine spaete Antwort fuer
// ein inzwischen verlassenes Projekt darf die Seite des naechsten nicht ueberschreiben
async function loadProjectDetails(project, box, { stillWanted = () => true } = {}) {
box.innerHTML = '
Die Änderungen können gerade nicht geladen werden.
')}
${order || !work ? pdSection('work', 'Arbeiten am Projekt', work
? renderWorkEntries(work.entries || [], 'Noch keine Arbeitszeit eingetragen. Was sich an der Website geändert hat, steht unter „Letzte Änderungen“.')
: '
Rechnungen konnten nicht geladen werden: ${escapeHtml(err.message)}
`
}
}
/* -------------------------------------------------------------- Aktivität --- */
// Abschnitt "Was wir zuletzt erledigt haben" unter Projekte: je Auftrag eine Karte mit Status, Fortschrittsleiste
// und Arbeiten (Bausteine der Projektdetails; Texte kommen aufbereitet vom Server). Zuerst die drei neuesten,
// "Alle anzeigen" zeigt den Rest.
const ACTIVITY_SHOWN = 3
async function loadCustomerActivity() {
const body = document.getElementById('activity-body')
if (!body) return
try {
const { tickets: all } = await api('/api/activity')
if (!all || !all.length) {
body.innerHTML = '
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')
}
}
// Nachrichten als gelesen markieren; in der Kundenansicht nicht (der Kunde hat sie ja nicht gelesen)
function markChatRead() {
if (chatState.viewAs) return Promise.resolve()
return api('/api/chat/read', { method: 'POST' }).catch(() => {})
}
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 markChatRead()
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 markChatRead()
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)
// ---------------------------------------------------------------------------
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)
}
// Fristen gelten nach deutschem Kalender, auch wenn der Kunde gerade im Ausland ist
function fmtDayMonth(value) {
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit' })
}
// Leistungen eines Produkts aus dem Katalog der Server-Antwort (data.products[].features = [Titel, Detail])
function productOf(data, key) {
return (data?.products || []).find((p) => p.key === key) || null
}
function productLabelOf(data, key) {
const product = productOf(data, key)
return (product?.name || (key === 'hosting' ? 'Hosting-Abo' : 'Wartungs-Abo')).replace(/^WEBklar\s+/, '')
}
function aboFeaturesHtml(features) {
return `
${(features || []).map(
([t, d]) => `
${escapeHtml(t)}${escapeHtml(d)}
`
).join('')}
`
}
// 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: noch kein Abo (Auswahl steht in der Ansicht "Verträge") */
function renderAboSideOffer(card, data, { ended }) {
const cheapest = Math.min(...(data.products || []).map((p) => p.amountCents).filter(Number.isFinite), 2000)
card.classList.add('kb-abo-offer')
card.innerHTML = `
Ihr Vertrag
${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}
ab ${euroShort(cheapest)}pro Monat
Hosting-Abo oder Wartungs-Abo, 12 Monate Mindestlaufzeit.
${data.available
? 'Gemäß § 19 UStG ohne Umsatzsteuer.'
: 'Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'}
`
}
function aboIsCancelled(sub) {
return Boolean(sub.cancelAtPeriodEnd || sub.cancelAt)
}
function aboDateLine(sub) {
return aboIsCancelled(sub)
? `Leistungen bis ${fmtDateShort(sub.cancelAt || sub.currentPeriodEnd)}`
: sub.serviceStartsAt
? `Beginn und erste Abbuchung am ${fmtDateShort(sub.serviceStartsAt)}`
: `nächste Abbuchung ${fmtDateShort(sub.currentPeriodEnd)}`
}
/* Seitenleiste: laufendes Abo ("Ihr Vertrag") */
function renderAboSideActive(card, data) {
const sub = data.subscription
const wartung = sub.productKey === 'wartung'
const l = data.ledger || { balanceMinutes: 0 }
// Status nur bei Kündigung; Zahlungsproblem (Hinweis unten) und späterer Beginn (Datumszeile) stehen im Text
const status = aboIsCancelled(sub) ? 'gekündigt' : ''
card.classList.remove('kb-abo-offer')
card.innerHTML = `
${isWartung ? 'Betrieb, Pflege und Support für Ihre Website' : 'Ihre Website sicher und schnell online'} – ${offer ? 'mit Ihrem persönlichen Angebot' : `für ${escapeHtml(price)} 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(product.features)}
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.
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.' : ''}
${offer
? `${hasOfferNumbers ? `${escapeHtml(fmtEuro(offerMonthly / 100))} pro Monat für die ersten ${escapeHtml(String(offerMonths))} Monate, danach ${escapeHtml(fmtEuro(offerRegular / 100))} pro Monat` : ''} (individuelles Angebot: ${escapeHtml(offer.text)}; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten`
: `${escapeHtml(price)} pro Monat (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten`}
Abrechnung
monatlich im Voraus, erstmals zum Leistungsbeginn
Fällig
Bitte oben wählen: sofort oder nach Ablauf der 14-tägigen Widerrufsfrist
Leistungsbeginn
Bitte oben wählen: sofort oder nach Ablauf der 14-tägigen Widerrufsfrist${lateStart ? ` (am ${lateStart})` : ''}
Laufzeit
Mindestlaufzeit ${escapeHtml(String(product.minTermMonths))} 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
${escapeHtml(fmtEuro(monthly / 100))} pro Monat (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer); enthalten sind Pflege Ihrer Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat
`)
if (units > 0) {
rows.push(`
Zusatzspeicher
${units} Paket${units === 1 ? '' : 'e'} (+${escapeHtml(String(units * (Number(mail.gbPerUnit) || 0)))} GB) bleiben gebucht: ${escapeHtml(fmtEuro(units * unitCents / 100))} pro Monat zusätzlich (gemäß § 19 UStG ohne Umsatzsteuer), insgesamt ${escapeHtml(fmtEuro((monthly + units * unitCents) / 100))} pro Monat
`)
}
if ((Number(p.creditCents) || 0) > 0) {
rows.push(`
Gutschrift für nicht genutzte Tage
−${escapeHtml(fmtEuro(p.creditCents / 100))} aus dem bereits bezahlten Hosting-Abo (gemäß § 19 UStG ohne Umsatzsteuer)
`)
}
rows.push(`
Heute fällig
${escapeHtml(fmtEuro(p.amountDueCents / 100))} (gemäß § 19 UStG ohne Umsatzsteuer)
${nextDay ? `Ab dem Wechsel jeweils am ${escapeHtml(nextDay)}. eines Monats; ` : ''}nächste Abbuchung am ${fmtDateShort(p.nextChargeAt)}
`)
rows.push(`
Laufzeit
Mindestlaufzeit bis ${fmtDateShort(p.minTermEnd)} (12 Monate ab dem Wechsel), danach monatlich kündbar
`)
rows.push('
Beginn
Der Wechsel wird erst mit der Zahlung wirksam. Bei Kreditkarte, sobald die Zahlung erfolgreich ist (ggf. nach Bestätigung bei Ihrer Bank); bei SEPA-Lastschrift sofort mit der Bestellung, wird die Lastschrift nicht eingelöst, gilt der Wechsel als nicht erfolgt. Einen späteren Beginn gibt es nicht.
Zusätzlich zum Hosting: Pflege Ihrer Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat. Das Hosting-Abo endet mit dem Wechsel im gegenseitigen Einvernehmen; Domain, Postfächer und gebuchter Speicher bleiben.
+${fmtHours(c.minutes)} ${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDateShort(c.expiresAt)}`}
`).join('')
view.innerHTML = `
Stundenkonto
Ihr Stundenkontingent, der Verlauf und Ihr Vertrag.
${sub.paymentProblem ? '
Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart über „Zahlungsart aktualisieren“ in der Seitenleisteunter Verträge, 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 }
/* Seitenleiste bzw. Karte in der App-Ansicht: "Ihr Vertrag" */
function renderSideCard(card, data) {
if (data.subscription?.active) renderAboSideActive(card, data)
else renderAboSideOffer(card, data, { ended: Boolean(data.subscription) })
}
/* Zusatzspeicher: Plus/Minus mit Vorschau vom Server, Bestellübersicht (§ 312j BGB) und Speichern */
const storageUi = { notice: '' }
function renderStoragePanel(container, data) {
const sub = data?.subscription
const conf = data?.mailStorage || {}
container.dataset.units = String(sub?.storage?.units ?? 0)
if (!sub?.active) {
container.innerHTML = ''
return
}
const includedGb = Number(conf.includedGb) || 0
const gbPer = Number(conf.gbPerUnit) || 0
const unitCents = Number(conf.unitPriceCents) || 0
const maxUnits = Number(conf.maxUnits) || 10
const gbOf = (u) => includedGb + u * gbPer
const storage = sub.storage || {}
const pendingReduction = storage.pendingReduction || null
const canChange = sub.canChangeStorage === true
const current = Number(storage.units || 0)
const start = pendingReduction && Number.isInteger(pendingReduction.units) ? pendingReduction.units : current
const state = { target: start, preview: null, previewError: '', loading: start !== current, busy: false, error: '', token: 0 }
const lockedReason = sub.switchPending ? 'Wechsel wartet auf die Zahlung'
: aboIsCancelled(sub) ? 'Abo gekündigt'
: sub.paymentProblem ? 'Zahlung offen' : 'bei Fragen schreiben Sie uns gern im Chat'
const notice = storageUi.notice
storageUi.notice = ''
const paint = () => {
// Kosten, Beschriftung und Zustimmung richten sich nach den gebuchten Paketen; start ist nur die Anfangsstellung
const delta = state.target - current
const p = state.preview
let summary = ''
if (canChange && delta !== 0) {
const plus = delta > 0
const totalCents = state.target * unitCents
const facts = []
const proBad = plus && p && !state.loading && !(p.prorationCents != null && p.prorationCents !== '' && Number.isFinite(Number(p.prorationCents)))
{
facts.push(`
Neue Gesamtgröße
${gbOf(state.target)} GB
`)
facts.push(`
Preis
${fmtEuro(totalCents / 100)} pro Monat für ${state.target} Paket${state.target === 1 ? '' : 'e'} (${plus ? 'Mehrpreis' : 'Ersparnis'} ${fmtEuro(Math.abs(delta) * unitCents / 100)} pro Monat; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer)
`)
if (state.previewError || proBad) {
facts.push(`
Vorschau
${escapeHtml(state.previewError || 'Der Betrag konnte nicht berechnet werden. Bitte versuchen Sie es später erneut.')}
`)
} else if (!p || state.loading) {
facts.push('
Vorschau
wird berechnet …
')
} else if (plus) {
const pro = Number(p.prorationCents) || 0
facts.push(`
Zusätzlich jetzt
${pro > 0
? `${fmtEuro(pro / 100)} anteilig für den Rest des laufenden Abrechnungsmonats (gemäß § 19 UStG ohne Umsatzsteuer)`
: 'keine zusätzlichen Kosten, die Pakete sind für den laufenden Abrechnungsmonat schon bezahlt'}
`)
facts.push(`
Nächste Abbuchung
${fmtDateShort(p.nextChargeAt)}, danach monatlich ${fmtEuro(totalCents / 100)}
`)
facts.push(pro === 0 && pendingReduction
? '
Geplante Reduzierung
wird aufgehoben oder angepasst; es ist nichts neu einzurichten
'
: '
Einrichtung
in der Regel innerhalb eines Werktags; bis dahin steht „wird eingerichtet“ da
')
} else {
facts.push(`
Wirkt zum
${fmtDateShort(p.effectiveAt)}, zum Ende des Abrechnungsmonats
`)
}
facts.push('
Laufzeit
monatlich, zum Ende jedes Abrechnungsmonats kündbar, endet spätestens mit dem Abo
')
}
const hint = plus
? ``
: `
Wichtig: Passen die gespeicherten E-Mails nach der Verkleinerung nicht mehr in den verbleibenden Speicher, nehmen die Postfächer keine neuen E-Mails mehr an. Bitte räumen Sie vorher auf. Mehr dazu in den Regeln zum Zusatzspeicher und in der Widerrufsbelehrung.
`
summary = `
${facts.join('')}
${hint}
`
}
const status = []
if (storage.pendingSetup) status.push('
Ihr zusätzlicher Speicher wird eingerichtet. Das dauert in der Regel bis zum nächsten Werktag.
')
if (pendingReduction) status.push(`
Geplant: ab ${fmtDayMonth(pendingReduction.effectiveAt)} ${gbOf(Number(pendingReduction.units) || 0)} GB.
`)
container.innerHTML = `
E-Mail-Speicher
${includedGb} GB inklusive. Jedes Zusatzpaket bringt ${gbPer} GB für ${fmtEuro(unitCents / 100)} im Monat (gemäß § 19 UStG ohne Umsatzsteuer), bis zu ${maxUnits} Pakete.
${notice ? `
${escapeHtml(notice)}
` : ''}
${!canChange ? `
Änderungen sind derzeit nicht möglich – ${lockedReason}.
${status.join('')}
${summary}`
container.querySelector('#storage-minus')?.addEventListener('click', () => step(-1))
container.querySelector('#storage-plus')?.addEventListener('click', () => step(1))
container.querySelector('#storage-submit')?.addEventListener('click', submit)
}
const loadPreview = async (token) => {
try {
const p = await api('/api/abo/storage/preview', { method: 'POST', body: JSON.stringify({ units: state.target }) })
if (token !== state.token) return
state.preview = p
} catch (err) {
if (token !== state.token) return
state.previewError = err.message
}
state.loading = false
paint()
}
const step = (d) => {
const next = state.target + d
if (state.busy || next < 0 || next > maxUnits) return
state.target = next
state.preview = null
state.previewError = ''
state.loading = next !== current
const token = ++state.token
paint()
if (next !== current) loadPreview(token)
}
const submit = async () => {
const errEl = container.querySelector('#storage-error')
hideError(errEl)
const plus = state.target > current
const terms = container.querySelector('#storage-terms')
if (plus && !terms?.checked) {
showError(errEl, 'Bitte stimmen Sie den Regeln zum Zusatzspeicher zu.')
return
}
const body = { units: state.target }
if (plus) Object.assign(body, { acceptTerms: true, termsVersion: data.termsVersion })
const btn = container.querySelector('#storage-submit')
state.busy = true
btn.disabled = true
container.querySelectorAll('#storage-minus, #storage-plus, #storage-terms').forEach((el) => { el.disabled = true })
btn.textContent = 'Wird gespeichert …'
try {
const res = await api('/api/abo/storage', { method: 'POST', body: JSON.stringify(body) })
const ch = res.change || {}
storageUi.notice = plus
? (ch.task === false ? 'Gespeichert. Es entstehen keine zusätzlichen Kosten, die Pakete waren schon bezahlt.' : 'Danke, Ihre Bestellung ist eingegangen. Der Speicher wird in der Regel innerhalb eines Werktags eingerichtet.')
: `Gespeichert. Die Verkleinerung wirkt zum ${fmtDateShort(ch.effectiveAt)}.`
if (res.subscription && aboState.data) {
aboState.data.subscription = res.subscription
const card = document.getElementById('abo-card')
const contracts = document.getElementById('kb-contracts')
if (card) renderSideCard(card, aboState.data)
if (contracts) renderContractsView(contracts, aboState.data)
return
}
loadContracts()
} catch (err) {
state.busy = false
paint()
const again = container.querySelector('#storage-error')
showError(again, err.status === 409 ? `${err.message} Bitte laden Sie die Seite neu, falls sich der Stand geändert hat.` : err.message)
if (plus) { const c = container.querySelector('#storage-terms'); if (c) c.checked = true }
}
}
paint()
if (start !== current && canChange) {
loadPreview(++state.token)
}
}
/* "Postfach anfragen": öffnet den Chat mit vorbereiteter Nachricht (Eingabefeld wird nicht automatisch gesendet) */
function requestMailbox() {
showView('chat')
const box = document.querySelector('#chat-composer textarea')
if (!box) return
box.value = 'Bitte legen Sie ein neues Postfach an: '
box.dispatchEvent(new Event('input', { bubbles: true }))
box.focus()
box.setSelectionRange(box.value.length, box.value.length)
}
/* Vergleich der beiden Abos (kein laufendes Abo) */
function renderCompareCard(data, key) {
const product = productOf(data, key)
if (!product) return ''
const offer = data.offers?.[key]
return `
${escapeHtml(productLabelOf(data, key))}
${offer ? `Ihr persönliches Angebot
${escapeHtml(offer.text)}${offer.validUntil ? ` · gültig bis ${fmtDateShort(offer.validUntil)}` : ''}
` : ''}
${fmtEuro(product.amountCents / 100)}pro Monat
Gemäß § 19 UStG ohne Umsatzsteuer. ${product.minTermMonths} Monate Mindestlaufzeit, danach monatlich kündbar.
${aboFeaturesHtml(product.features)}
`
}
/* Wechsel-Karte (nur Hosting) */
function renderSwitchCard(data) {
const sub = data.subscription
if (sub.productKey !== 'hosting') return ''
const wartung = productOf(data, 'wartung')
if (sub.switchPending) {
const url = sub.switchPending.paymentUrl
return `
Ihr Wechsel wartet auf die Zahlung
Ihr Wechsel zum Wartungs-Abo ist erst abgeschlossen, wenn die Zahlung bestätigt ist.${url ? '' : ' Schreiben Sie uns gern im Chat, wenn Sie Hilfe brauchen.'}
` : ''}
${renderSwitchCard(data)}
`
view.querySelector('#contract-manage')?.addEventListener('click', (ev) => openBillingPortal(ev.currentTarget, view.querySelector('#contract-error')))
view.querySelector('#contract-mailbox')?.addEventListener('click', requestMailbox)
renderStoragePanel(view.querySelector('#storage-panel'), data)
}
async function loadContracts({ afterCheckout = false } = {}) {
const card = document.getElementById('abo-card')
const view = document.getElementById('view-stunden')
const contracts = document.getElementById('kb-contracts')
if (!card || !view || !contracts) 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
renderSideCard(card, data)
renderContractsView(contracts, data)
for (const key of ['hosting', 'wartung']) {
const orderView = document.getElementById(`view-bestellen-${key}`)
if (orderView) renderOrderView(orderView, data, key)
}
const switchView = document.getElementById('view-wechsel')
if (switchView) renderSwitchView(switchView, data)
if (data.subscription?.active && data.subscription.productKey === 'wartung') renderHoursView(view, data)
else view.innerHTML = '
Stundenkonto
Ein Stundenkonto gibt es im Wartungs-Abo. Ihre Verträge finden Sie unter Verträge.
'
card.classList.remove('hidden')
document.getElementById('invoices-abo-note')?.classList.toggle('hidden', !data.subscription)
document.getElementById('kb-sheet-abo')?.classList.toggle('hidden', !data.subscription)
// Vertragsbedingungen im Konto-Menue: die Fassung, die fuer das Abo des Kunden gilt
document.getElementById('kb-sheet-terms')?.setAttribute('href', data.subscription?.termsUrl || '/abo-bedingungen.html')
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 Ihr Abo ersetzt (Domain, Hosting bzw. Betrieb der Website, E-Mail-Postfächer) 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, auch nach einem Wechsel in ein anderes Abo. Sie wird nicht ausgezahlt und ist nicht übertragbar; endet Ihr Abo ohne einen solchen Wechsel, entfällt ein noch nicht verrechneter Rest.',
'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 Abo läuft. Zum 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.' : ''}
`
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)
}))
}
/* -------------------------------------------------------- Passwort ändern --- */
// Seite "Passwort ändern" aus dem Konto-Menü. Dieselben Regeln prüft der Server
// (server/services/customerPassword.js); danach sind andere Geräte abgemeldet.
function passwordProblem(current, next, repeat, email) {
if (!current) return ['pw-current', 'Bitte gib dein aktuelles Passwort ein.']
if (!next) return ['pw-new', 'Bitte gib ein neues Passwort ein.']
if ([...next].length < 8) return ['pw-new', 'Das neue Passwort muss mindestens 8 Zeichen haben.']
if (new TextEncoder().encode(next).length > 256) return ['pw-new', 'Das neue Passwort ist zu lang (höchstens 256 Zeichen).']
if (!next.trim()) return ['pw-new', 'Das neue Passwort darf nicht nur aus Leerzeichen bestehen.']
if (next === current) return ['pw-new', 'Das neue Passwort muss sich vom aktuellen unterscheiden.']
if (email && next.trim().toLowerCase() === email.trim().toLowerCase()) {
return ['pw-new', 'Bitte nimm nicht deine E-Mail-Adresse als Passwort.']
}
if (next !== repeat) return ['pw-repeat', 'Die beiden neuen Passwörter stimmen nicht überein.']
return null
}
const passwordState = { email: '', ready: false, viewAs: false }
// Kunde bekannt (/api/auth/me): Zugang für Passwort-Manager, im Ansichtsmodus gesperrt
function setPasswordAccount(customer, viewAs) {
passwordState.email = customer.email || ''
passwordState.viewAs = viewAs
passwordState.ready = true
document.getElementById('pw-username').value = passwordState.email
if (!viewAs) return
const submit = document.getElementById('pw-submit')
document.querySelectorAll('#pw-form input').forEach((f) => { f.disabled = true })
submit.disabled = true
submit.insertAdjacentHTML('afterend', 'Im Ansichtsmodus kann das Passwort nicht geändert werden.')
}
// Gleich beim Start: das Formular darf nie ohne Script-Handler abgeschickt werden
function initPasswordView() {
const form = document.getElementById('pw-form')
if (!form) return
const done = document.getElementById('pw-done')
const errorEl = document.getElementById('pw-error')
const submit = document.getElementById('pw-submit')
const show = document.getElementById('pw-show')
const fields = ['pw-current', 'pw-new', 'pw-repeat'].map((id) => document.getElementById(id))
const [current, next, repeat] = fields
let returnTo = 'projects'
const clearFields = () => {
fields.forEach((f) => { f.value = ''; f.type = 'password' })
show.checked = false
hideError(errorEl)
}
const showDone = (on) => {
form.classList.toggle('hidden', on)
done.classList.toggle('hidden', !on)
}
// Wer die Seite verlässt, findet beim nächsten Mal ein leeres Formular (keine Passwörter im DOM)
document.addEventListener('kb:view', (ev) => {
if (ev.detail.name !== 'passwort') {
returnTo = ev.detail.name
clearFields()
showDone(false)
} else if (!passwordState.viewAs && !coarsePointer.matches) {
// Am Desktop gleich lostippen; auf dem Handy nicht ungefragt die Tastatur öffnen
current.focus({ preventScroll: true })
}
})
show.addEventListener('change', () => {
fields.forEach((f) => { f.type = show.checked ? 'text' : 'password' })
})
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (submit.disabled || !passwordState.ready || passwordState.viewAs) return
hideError(errorEl)
const problem = passwordProblem(current.value, next.value, repeat.value, passwordState.email)
if (problem) {
showError(errorEl, problem[1])
document.getElementById(problem[0]).focus()
return
}
submit.disabled = true
submit.textContent = 'Wird gespeichert…'
try {
await api('/api/auth/password', {
method: 'POST',
body: JSON.stringify({ currentPassword: current.value, newPassword: next.value }),
})
clearFields()
showDone(true)
done.focus()
} catch (err) {
showError(errorEl, err.message)
if (err.code === 'wrong_password' || err.code === 'current_missing') {
current.focus()
current.select()
} else if (err.code === 'new_invalid') {
next.focus()
}
} finally {
submit.disabled = false
submit.textContent = 'Passwort ändern'
}
})
// Fertig: zurück, woher man kam (App-Ansicht über den Verlauf wie der Zurück-Pfeil)
document.getElementById('pw-done-btn')?.addEventListener('click', () => {
if (window.history.state?.kbSub === 'passwort') window.history.back()
else showView(APP_PARENT.passwort || returnTo)
})
}
/* ---------------------------------------------------------- Navigation --- */
// Ansichten im Inhaltsbereich; Hash fuer Direktlinks (z. B. aus Mails: #vertraege, #gutschrift).
// Die alten Hashes #wartung und #abo fuehren zu #vertraege (HASH_ALIASES).
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
// "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt
// "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe).
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort', webshop: 'webshop', buchungen: 'buchungen', zeiten: 'zeiten' }
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
const viewLoaders = { chat: initChat }
// Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden
const viewRefreshers = {
funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))),
news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))),
analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))),
webshop: () => import('/shop.js').then((m) => m.initShop(document.getElementById('webshop-body'))),
buchungen: () => import('/buchungen.js').then((m) => m.initBuchungen(document.getElementById('buchungen-body'))),
// baut sich bei gleichem Projekt und Stand nicht neu auf, solange der Editor steht (ungespeicherte Eingaben bleiben; zeiten.js, initZeiten)
zeiten: () => import('/zeiten.js').then((m) => m.initZeiten(document.getElementById('zeiten-body'))),
}
const loadedViews = new Set()
function currentHash() {
return window.location.hash.replace(/^#/, '').split('?')[0]
}
// Direktlink #fortschritt: nach dem Laden der Projekte zum Abschnitt scrollen
let activityScrollPending = false
function scrollToActivity() {
if (!activityScrollPending) return
document.getElementById('pj-activity')?.scrollIntoView({ block: 'start' })
}
function viewFromHash() {
const hash = currentHash()
if (hash === 'fortschritt') return 'projects'
if (HASH_ALIASES[hash]) return HASH_ALIASES[hash]
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]()
}
viewRefreshers[name]?.().catch((err) => {
const box = document.getElementById(`${name}-body`)
if (box) box.innerHTML = `
${escapeHtml(err.message || 'Konnte nicht geladen werden.')}
`
})
if (name === 'chat') {
markChatRead()
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 } }))
}
/* Profilbild (DiceBear Big Smile vom eigenen Server); die Initialen bleiben stehen, bis das Bild geladen ist,
und falls es nicht laedt. Die Kunden-ID im Query sorgt dafuer, dass die Kundenansicht nicht das gecachte Bild zeigt. */
function setAvatar(el, initials, seed) {
if (!el) return
el.textContent = initials
el.dataset.initials = initials
if (!seed) return
const img = new Image()
img.alt = ''
img.decoding = 'async'
img.onload = () => { el.textContent = ''; el.appendChild(img); el.classList.add('has-img') }
img.src = `/api/avatar.svg?c=${encodeURIComponent(seed)}`
}
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')
initAppShell()
initPasswordView()
// Konto-Menü (Profilbild): Abmelden und "Abo verwalten & Rechnungen" gibt es nur dort
document.getElementById('kb-sheet-logout')?.addEventListener('click', async () => {
await api('/api/auth/logout', { method: 'POST' })
window.location.href = '/login.html'
})
document.getElementById('kb-sheet-abo')?.addEventListener('click', (ev) =>
openBillingPortal(ev.currentTarget, document.getElementById('kb-sheet-error')))
// 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) {
// Alte Mitarbeiter-Sitzung (Mitarbeiter arbeiten im Ticketsystem): dorthin weiter
if (me.reason === 'staff' && me.redirect) {
window.location.href = me.redirect
return
}
const target = new URL(loginUrl, window.location.origin)
// Passwort auf einem anderen Gerät geändert oder Kundenansicht abgelaufen: Hinweis auf der Anmeldeseite
if ((me.reason === 'password_changed' || me.reason === 'view_expired') && me.message) target.searchParams.set('error', me.message)
window.location.href = target.pathname + target.search
return
}
if (!me.customer) {
window.location.href = loginUrl
return
}
const customer = me.customer
// Kundenansicht: ab jetzt traegt jede /api-Anfrage die Kennung dieser Ansicht (public/kundenansicht.js)
if (me.viewId) window.KbView?.setId(me.viewId)
chatState.viewAs = Boolean(me.viewAs)
takeoverState.customer = customer
renderViewAsBanner(me)
setPasswordAccount(customer, chatState.viewAs)
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(' · ')
setAvatar(document.getElementById('customer-avatar'), initialsOf(displayName), customer.id)
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}`)
loadContracts({ afterCheckout: aboParam === 'success' })
loadCustomerInvoices()
activityScrollPending = currentHash() === 'fortschritt'
loadCustomerActivity().then(scrollToActivity)
const initialView = viewFromHash()
// Alte Hashes (#wartung, #abo) gleich auf den neuen (#vertraege) umschreiben
if (initialView) showView(initialView, { updateHash: Boolean(HASH_ALIASES[currentHash()]) })
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')
}
scrollToActivity()
// 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 (und Text neben dem Zurueck-Pfeil).
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects', webshop: 'funktionen', buchungen: 'funktionen', zeiten: 'funktionen' }
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' }
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 "Verträge"
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])
const back = document.getElementById('kb-back')
back?.classList.toggle('hidden', !sub)
if (back && sub) {
const label = APP_PARENT_LABEL[APP_PARENT[appShell.view]] || 'Zurück'
back.querySelector('span').textContent = label
back.setAttribute('aria-label', `Zurück zu ${label}`)
}
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) {
// Projektdetails als Seite nur in der App-Ansicht und nur mit gewaehltem Projekt (nicht per Direktlink)
if (name === 'projekt' && (!isAppLayout() || !projectPage.project)) {
showView('projects', { updateHash: !appShell.fromHistory })
return
}
const prev = appShell.view
// "Passwort ändern" (Konto-Menü) ist in der App-Ansicht eine Unterseite der Ansicht, in der man gerade war
if (name === 'passwort' && prev !== 'passwort') APP_PARENT.passwort = isAppLayout() ? prev : undefined
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 (Passwort aus einer Unterseite: deren Tab)
const tab = APP_PARENT[parent] || parent || name
document.querySelectorAll('.kb-nav-item').forEach((b) => {
const on = b.dataset.view === tab
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 zum Tab darüber (Verträge bzw. Projekte)
if (nav === 'push' && !appShell.fromHistory) {
window.history.replaceState(null, '', VIEW_HASHES[parent] ? `#${VIEW_HASHES[parent]}` : window.location.pathname)
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()
}
/* Chat-Eingabe ueber der Tastatur halten: nur nachschieben, wenn sie wirklich verdeckt ist, gemessen am
sichtbaren Bereich (visualViewport). Die Seite nicht nach oben pinnen – das kaempft auf iOS gegen
Safaris eigenes Hochschieben und laesst die Eingabe aus dem Bild springen. */
function revealChatComposer() {
const composer = document.getElementById('chat-composer')
const vv = window.visualViewport
if (!composer || !vv || !isAppLayout() || !composer.contains(document.activeElement)) return
const rect = composer.getBoundingClientRect()
const visibleTop = vv.offsetTop
const visibleBottom = vv.offsetTop + vv.height
let delta = 0
if (rect.bottom > visibleBottom - 8) delta = rect.bottom - visibleBottom + 8
else if (rect.top < visibleTop + 8) delta = rect.top - visibleTop - 8
if (Number.isFinite(delta) && Math.abs(delta) > 1) window.scrollBy(0, delta)
}
/* Tab "Verträge": Hinweis bei fehlgeschlagener Zahlung oder offener Rückfrage zur Gutschrift */
function updateVertraegeBadge() {
const card = document.getElementById('abo-card')
const badge = document.getElementById('kb-tab-vertraege-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 = ''
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 = `
WEBklar als App${canPrompt
? 'Mit einem Tipp auf deinem Homescreen – ohne App-Store.'
: `Tippe auf ${shareIcon} „Teilen“ und dann auf „Zum Home-Bildschirm“.`}
${canPrompt ? '' : ''}
${dismissible ? '' : ''}`
})
}
/* Konto: Bottom-Sheet (Handy) bzw. Popover (Desktop) mit Ansprechpartner, Verträgen, Rechtlichem und Abmelden.
Geöffnet über das Profilbild oder Verweise mit data-open-account (z. B. Hinweis unter den Rechnungen). */
function initAccountSheet() {
const sheet = document.getElementById('kb-sheet')
const avatar = document.getElementById('customer-avatar')
if (!sheet || !avatar) return
const fill = () => {
document.getElementById('kb-sheet-name').textContent = document.getElementById('customer-name')?.textContent || 'Mein Konto'
document.getElementById('kb-sheet-meta').textContent = document.getElementById('customer-meta')?.textContent || ''
const sheetAvatar = document.getElementById('kb-sheet-avatar')
const img = avatar.querySelector('img')
sheetAvatar.textContent = img ? '' : avatar.textContent
sheetAvatar.classList.toggle('has-img', Boolean(img))
if (img) sheetAvatar.appendChild(img.cloneNode())
renderInstallOffers()
}
if (typeof sheet.showModal !== 'function') {
// Sehr alte Browser ohne