- server/config.js: amountCents 2000, lookup_key webklar_hosting_monatlich_2000 - ensurePrice haengt einen neuen Preis an das vorhandene Stripe-Produkt (metadata.webklar), statt ein zweites "WEBklar Hosting-Abo" anzulegen - abo-bedingungen.html (Uebersicht, Preise, Leistungsbeschreibung) + PDF, Leitfaden + PDF - Tests auf 20 € umgestellt (399/399) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2470 lines
119 KiB
JavaScript
2470 lines
119 KiB
JavaScript
// Sitzung beendet (Passwort auf einem anderen Gerät geändert): nur einmal zur Anmeldung wechseln
|
||
let sessionEndRedirected = false
|
||
|
||
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.code) err.code = data.code
|
||
if (data.retryAfterSeconds) err.retryAfterSeconds = data.retryAfterSeconds
|
||
// Passwort auf einem anderen Gerät geändert: diese Sitzung gilt nicht mehr
|
||
if (data.code === 'session_revoked' && document.body.classList.contains('dashboard-page') && !sessionEndRedirected) {
|
||
sessionEndRedirected = true
|
||
window.location.href = `/login.html?error=${encodeURIComponent(err.message)}`
|
||
}
|
||
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', { timeZone: 'Europe/Berlin', 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
|
||
if (!h) return `${m} min`
|
||
return m ? `${h} h ${m} min` : `${h} h`
|
||
}
|
||
|
||
/* -------------------------------------------------------------- Projekte --- */
|
||
function featuresForProject(features, projectId) {
|
||
return features.filter((f) => !f.projectId || f.projectId === projectId)
|
||
}
|
||
|
||
const PREVIEW_BASE_WIDTH = 1280
|
||
function setupPreviewFrame(frame) {
|
||
const view = frame.querySelector('iframe')
|
||
if (!view) return
|
||
const apply = () => {
|
||
const w = frame.clientWidth
|
||
if (w) view.style.transform = `scale(${w / PREVIEW_BASE_WIDTH})`
|
||
}
|
||
if (typeof ResizeObserver !== 'undefined') {
|
||
new ResizeObserver(apply).observe(frame)
|
||
} else {
|
||
window.addEventListener('resize', apply)
|
||
}
|
||
apply()
|
||
}
|
||
|
||
function fmtDateTimeLong(value) {
|
||
if (!value) return '–'
|
||
const d = new Date(value)
|
||
if (Number.isNaN(d.getTime())) return escapeHtml(value)
|
||
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||
}
|
||
|
||
// "autohaus-meier.project.webklar.com" -> Subdomain hervorheben, Rest gedimmt
|
||
function previewHostHtml(url) {
|
||
try {
|
||
const host = new URL(url).hostname
|
||
const dot = host.indexOf('.')
|
||
return dot > 0
|
||
? `${escapeHtml(host.slice(0, dot))}<span class="kb-dim">${escapeHtml(host.slice(dot))}</span>`
|
||
: escapeHtml(host)
|
||
} catch {
|
||
return '<span class="kb-dim">–</span>'
|
||
}
|
||
}
|
||
|
||
function projectState(project) {
|
||
if (isProjectPreviewReady(project)) return ['kb-state-ok', 'Vorschau online']
|
||
const s = String(project.provisioningStatus || project.status || '').toLowerCase()
|
||
if (s.includes('fail')) return ['kb-state-off', 'Vorschau gestört']
|
||
if (project.previewUrl) return ['kb-state-wait', 'Vorschau wird vorbereitet']
|
||
return ['kb-state-off', 'Noch keine Website']
|
||
}
|
||
|
||
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen' }
|
||
|
||
function renderProjectCard(project, features) {
|
||
const li = document.createElement('li')
|
||
li.className = 'kb-project'
|
||
const ready = isProjectPreviewReady(project)
|
||
const url = project.previewUrl ? escapeAttr(project.previewUrl) : ''
|
||
const [stateClass, stateText] = projectState(project)
|
||
const extraChips = featuresForProject(features, project.id)
|
||
.map((f) => FEATURE_LABELS[f.featureKey] || f.featureKey)
|
||
.filter((label) => label && !['Vorschau', 'Chat', 'Rechnungen'].includes(label))
|
||
|
||
li.innerHTML = `
|
||
${ready && url
|
||
? `<a class="kb-preview" href="${url}" target="_blank" rel="noreferrer" data-preview-frame aria-label="Website in neuem Tab öffnen">
|
||
<iframe src="${url}" loading="lazy" scrolling="no" tabindex="-1" aria-hidden="true"></iframe>
|
||
<span class="kb-open">Website öffnen ↗</span>
|
||
</a>`
|
||
: `<div class="kb-preview kb-preview-empty">${project.previewUrl ? 'Vorschau wird vorbereitet …' : 'Noch keine Website veröffentlicht'}</div>`}
|
||
<div class="kb-project-body">
|
||
<div class="kb-project-top">
|
||
<h2>${escapeHtml(project.projectName || project.subdomain || 'Projekt')}</h2>
|
||
<span class="kb-state ${stateClass}">${stateText}</span>
|
||
</div>
|
||
<dl class="kb-facts">
|
||
<div><dt>Vorschau-Adresse</dt><dd>${project.previewUrl ? previewHostHtml(project.previewUrl) : '<span class="kb-dim">–</span>'}</dd></div>
|
||
<div><dt>Live-Domain</dt><dd>${project.liveDomain ? escapeHtml(project.liveDomain) : '<span class="kb-dim">noch nicht verbunden</span>'}</dd></div>
|
||
<div><dt>Letzte Änderung</dt><dd>${project.updatedAt ? fmtDateTimeLong(project.updatedAt) : '<span class="kb-dim">–</span>'}</dd></div>
|
||
</dl>
|
||
<div class="kb-chips">
|
||
${ready && url ? `<a class="kb-chip" href="${url}" target="_blank" rel="noreferrer">Vorschau</a>` : ''}
|
||
<button type="button" class="kb-chip" data-view="chat">Chat</button>
|
||
<button type="button" class="kb-chip" data-view="invoices">Rechnungen</button>
|
||
${extraChips.map((label) => `<span class="kb-chip">${escapeHtml(label)}</span>`).join('')}
|
||
</div>
|
||
<button type="button" class="kb-btn-outline kb-details-btn" data-details-btn aria-expanded="false">
|
||
<span data-details-label>Projektdetails anzeigen</span>
|
||
<svg class="kb-details-chev" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
|
||
</button>
|
||
<div class="kb-details hidden" data-details-box></div>
|
||
</div>
|
||
`
|
||
|
||
const frame = li.querySelector('[data-preview-frame]')
|
||
if (frame) setupPreviewFrame(frame)
|
||
|
||
// Am Desktop klappen die Details in der Karte auf; in der App-Ansicht (Handy) oeffnen sie sich
|
||
// als eigene Seite mit Zurueck-Pfeil (openProjectPage)
|
||
const btn = li.querySelector('[data-details-btn]')
|
||
const label = li.querySelector('[data-details-label]')
|
||
const box = li.querySelector('[data-details-box]')
|
||
let loaded = false
|
||
btn.addEventListener('click', () => {
|
||
if (isAppLayout()) {
|
||
openProjectPage(project)
|
||
return
|
||
}
|
||
const opening = box.classList.contains('hidden')
|
||
box.classList.toggle('hidden', !opening)
|
||
btn.setAttribute('aria-expanded', String(opening))
|
||
label.textContent = opening ? 'Projektdetails ausblenden' : 'Projektdetails anzeigen'
|
||
if (opening && !loaded) {
|
||
loaded = true
|
||
loadProjectDetails(project, box)
|
||
}
|
||
})
|
||
|
||
return li
|
||
}
|
||
|
||
/* --------------------------------------------------------- Projektdetails --- */
|
||
// Die Daten kommen fertig fuer Kunden aufbereitet vom Server (customerProjectView.js): keine
|
||
// Commit-Kuerzel, Kontonamen oder KI-Hinweise, Status und Arbeitsarten auf Deutsch.
|
||
// Dieselben Bausteine nutzt der Tab "Fortschritt" (loadCustomerActivity).
|
||
|
||
const PD_ICONS = {
|
||
update: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="M13 7l4 4"/>',
|
||
online: '<rect x="4" y="5" width="16" height="11" rx="2"/><path d="M2 19h20"/>',
|
||
onsite: '<path d="M12 21s7-6.2 7-11.5a7 7 0 0 0-14 0C5 14.8 12 21 12 21z"/><circle cx="12" cy="9.5" r="2.5"/>',
|
||
office: '<path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 10h2a2 2 0 0 1 2 2v9M9 7h3M9 11h3M9 15h3M3 21h18"/>',
|
||
work: '<path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18l3 3 6.3-6.3a4 4 0 0 0 5.4-5.4l-2.5 2.5-2.4-.6-.6-2.4z"/>',
|
||
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||
changes: '<path d="M3 12a9 9 0 1 0 2.6-6.4L3 8"/><path d="M3 3v5h5M12 7.5V12l3 2"/>',
|
||
files: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
||
}
|
||
const ORDER_STEPS = ['Eingegangen', 'In Arbeit', 'Prüfung', 'Erledigt']
|
||
const PD_CHANGES_SHOWN = 5
|
||
const PD_ENTRIES_SHOWN = 4
|
||
|
||
function pdIcon(name, cls = 'pd-ico') {
|
||
return `<svg class="${cls}" viewBox="0 0 24 24" aria-hidden="true">${PD_ICONS[name] || PD_ICONS.work}</svg>`
|
||
}
|
||
|
||
// "2026-09-20" -> "20.09.2026" (reines Datum, ohne Zeitzonen-Umrechnung)
|
||
function fmtIsoDay(day) {
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(day || ''))
|
||
return m ? `${m[3]}.${m[2]}.${m[1]}` : ''
|
||
}
|
||
|
||
// Ueberschrift je Tag in der Zeitleiste: "Heute", "Gestern", sonst "Mittwoch, 24. September"
|
||
function changeDayLabel(date) {
|
||
const today = new Date()
|
||
const dayStart = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
|
||
const diff = Math.round((dayStart(today) - dayStart(date)) / 86400000)
|
||
if (diff === 0) return 'Heute'
|
||
if (diff === 1) return 'Gestern'
|
||
return date.toLocaleDateString('de-DE', {
|
||
weekday: 'long', day: 'numeric', month: 'long', ...(date.getFullYear() === today.getFullYear() ? {} : { year: 'numeric' }),
|
||
})
|
||
}
|
||
|
||
// Auftrag: Nummer, Betreff, Status und Fortschrittsleiste Eingegangen -> In Arbeit -> Prüfung -> Erledigt
|
||
function renderOrder(order) {
|
||
const { status } = order
|
||
const steps = status.step > 0
|
||
? `<ol class="pd-steps" aria-label="Fortschritt des Auftrags">${ORDER_STEPS.map((label, i) => {
|
||
const n = i + 1
|
||
const state = n < status.step ? 'is-done' : n === status.step ? 'is-current' : ''
|
||
return `<li class="${state}"${n === status.step ? ' aria-current="step"' : ''}>${label}</li>`
|
||
}).join('')}</ol>`
|
||
: ''
|
||
const hint = status.tone === 'waiting'
|
||
? '<p class="pd-order-hint">Wir brauchen noch eine Info von dir. <button type="button" class="kb-link" data-view="chat">Im Chat antworten</button></p>'
|
||
: ''
|
||
return `
|
||
<div class="pd-order-head">
|
||
<div class="pd-order-name">
|
||
${order.number ? `<span class="pd-order-no">Auftrag ${escapeHtml(order.number)}</span>` : ''}
|
||
<h3>${escapeHtml(order.topic || 'Dein Auftrag')}</h3>
|
||
</div>
|
||
<span class="pd-status">${escapeHtml(status.label)}</span>
|
||
</div>
|
||
${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 `<button type="button" class="pd-more" data-pd-more data-more-label="${escapeAttr(text)}">${escapeHtml(text)}</button>`
|
||
}
|
||
|
||
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 `
|
||
<li class="pd-change"${extra ? ' data-extra' : ''}>
|
||
<div class="pd-change-head">
|
||
<strong>${escapeHtml(c.title)}</strong>
|
||
${time ? `<time datetime="${escapeAttr(c.date)}">${time}</time>` : ''}
|
||
</div>
|
||
${c.details?.length ? `<ul class="pd-change-details">${c.details.map((t) => `<li>${escapeHtml(t)}</li>`).join('')}</ul>` : ''}
|
||
</li>`
|
||
}
|
||
|
||
// Zeitleiste der letzten Aenderungen, nach Tagen gruppiert
|
||
function renderChanges(changes) {
|
||
if (!changes.length) return '<p class="pd-empty">Noch keine Änderungen an der Website.</p>'
|
||
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 `
|
||
<div class="pd-collapsible">
|
||
<div class="pd-timeline">
|
||
${groups.map((g) => `
|
||
<div class="pd-day"${g.first >= PD_CHANGES_SHOWN ? ' data-extra' : ''}>
|
||
${g.day ? `<p class="pd-day-label">${escapeHtml(g.day)}</p>` : ''}
|
||
<ul class="pd-changes">${g.items.join('')}</ul>
|
||
</div>`).join('')}
|
||
</div>
|
||
${pdMoreButton(changes.length, PD_CHANGES_SHOWN, ['weitere Änderung', 'weitere Änderungen'])}
|
||
</div>`
|
||
}
|
||
|
||
function renderWorkEntry(e, extra) {
|
||
const meta = [fmtIsoDay(e.date), e.person].filter(Boolean).map(escapeHtml).join(' · ')
|
||
const body = e.changes?.length
|
||
? `<ul class="pd-entry-changes">${e.changes.map((t) => `<li>${escapeHtml(t)}</li>`).join('')}</ul>`
|
||
: e.text ? `<p class="pd-entry-text">${escapeHtml(e.text)}</p>` : ''
|
||
return `
|
||
<li class="pd-entry pd-kind-${escapeAttr(e.kind)}"${extra ? ' data-extra' : ''}>
|
||
<span class="pd-entry-ico">${pdIcon(e.kind)}</span>
|
||
<div class="pd-entry-main">
|
||
<div class="pd-entry-head">
|
||
<strong>${escapeHtml(e.label)}</strong>
|
||
${meta ? `<span class="pd-entry-meta">${meta}</span>` : ''}
|
||
</div>
|
||
${body}
|
||
</div>
|
||
${e.minutes ? `<span class="pd-time" title="Arbeitszeit">${pdIcon('clock', 'pd-time-ico')}${fmtMinutes(e.minutes)}</span>` : ''}
|
||
</li>`
|
||
}
|
||
|
||
function renderWorkEntries(entries, emptyText) {
|
||
if (!entries.length) return `<p class="pd-empty">${emptyText}</p>`
|
||
return `
|
||
<div class="pd-collapsible">
|
||
<ul class="pd-work">${entries.map((e, i) => renderWorkEntry(e, i >= PD_ENTRIES_SHOWN)).join('')}</ul>
|
||
${pdMoreButton(entries.length, PD_ENTRIES_SHOWN, ['weiteren Eintrag', 'weitere Einträge'])}
|
||
</div>`
|
||
}
|
||
|
||
function initPdToggles(root) {
|
||
root.querySelectorAll('[data-pd-more]').forEach((btn) => btn.addEventListener('click', () => {
|
||
const wrap = btn.closest('.pd-collapsible')
|
||
const open = wrap.classList.toggle('is-open')
|
||
btn.textContent = open ? 'Weniger anzeigen' : btn.dataset.moreLabel
|
||
}))
|
||
}
|
||
|
||
function renderFileOverview(files) {
|
||
if (!files || !files.totalFiles) {
|
||
return '<p class="pd-empty">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="pd-note">${files.totalFiles} Dateien · ${fmtBytes(files.totalSizeBytes)}${files.truncated ? ' (Übersicht unvollständig – sehr großes Projekt)' : ''}</p>
|
||
<div class="kb-table-wrap"><table class="simple-table pd-files">
|
||
<thead><tr><th>Ordner / Datei</th><th>Dateien</th><th>Größe</th></tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table></div>`
|
||
}
|
||
|
||
function pdSection(icon, title, body) {
|
||
return `<section class="pd-section"><h3 class="pd-title">${pdIcon(icon)}${title}</h3>${body}</section>`
|
||
}
|
||
|
||
// 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 = '<p class="pd-empty">Details werden geladen…</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),
|
||
])
|
||
if (!stillWanted()) return
|
||
const order = work?.order
|
||
|
||
box.innerHTML = `
|
||
${order ? `<section class="pd-order pd-tone-${escapeAttr(order.status.tone)}">${renderOrder(order)}</section>` : ''}
|
||
${pdSection('changes', 'Letzte Änderungen', git
|
||
? renderChanges(git.changes || [])
|
||
: '<p class="pd-empty">Die Änderungen können gerade nicht geladen werden.</p>')}
|
||
${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“.')
|
||
: '<p class="pd-empty">Die Arbeiten können gerade nicht geladen werden.</p>') : ''}
|
||
${pdSection('files', 'Dateien', files ? renderFileOverview(files) : '<p class="pd-empty">Die Datei-Übersicht kann gerade nicht geladen werden.</p>')}`
|
||
initPdToggles(box)
|
||
}
|
||
|
||
// App-Ansicht: Projektdetails als Unterseite von "Projekte" (Zurueck-Pfeil, eigener Verlaufseintrag).
|
||
// Dasselbe Projekt erneut oeffnen zeigt die schon geladenen Details.
|
||
const projectPage = { project: null }
|
||
|
||
function openProjectPage(project) {
|
||
const view = document.getElementById('view-projekt')
|
||
if (!view) return
|
||
const reload = projectPage.project !== project
|
||
projectPage.project = project
|
||
view.querySelector('.kb-head h1').textContent = project.projectName || project.subdomain || 'Projekt'
|
||
showView('projekt')
|
||
if (reload) loadProjectDetails(project, view.querySelector('.kb-details'), { stillWanted: () => projectPage.project === project })
|
||
}
|
||
|
||
/* -------------------------------------------------------- Login-Redirects --- */
|
||
function safeRedirectUrl(url) {
|
||
try {
|
||
const u = new URL(url)
|
||
if (u.hostname.endsWith('.project.webklar.com') || u.hostname === 'project.webklar.com') {
|
||
return u.toString()
|
||
}
|
||
} catch {
|
||
/* ignore invalid URL */
|
||
}
|
||
return null
|
||
}
|
||
|
||
function isProjectPreviewReady(project) {
|
||
const status = (project?.provisioningStatus || project?.status || '').toLowerCase()
|
||
return ['ready', 'deployed'].includes(status) && Boolean(project?.previewUrl)
|
||
}
|
||
|
||
function previewSubdomainFromUrl(url) {
|
||
try {
|
||
const host = new URL(url).hostname.toLowerCase()
|
||
const match = host.match(/^([a-z0-9-]+)\.project\.webklar\.com$/)
|
||
return match ? match[1] : ''
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
async function canAccessPreviewUrl(url) {
|
||
const subdomain = previewSubdomainFromUrl(url)
|
||
if (!subdomain) return false
|
||
try {
|
||
const access = await fetch(
|
||
`/api/auth/preview-access?subdomain=${encodeURIComponent(subdomain)}`,
|
||
{ credentials: 'same-origin' }
|
||
).then((r) => r.json())
|
||
return Boolean(access.allowed)
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
async function resolvePostLoginTarget() {
|
||
// Direktlink auf eine Ansicht im Kundenbereich (z. B. /dashboard.html#gutschrift)
|
||
const raw = getQueryParam('next')
|
||
if (/^\/dashboard\.html(#[a-z]+)?$/.test(raw)) return raw
|
||
const next = safeRedirectUrl(raw)
|
||
if (next && (await canAccessPreviewUrl(next))) {
|
||
return next
|
||
}
|
||
return '/dashboard.html'
|
||
}
|
||
|
||
async function postLoginRedirect() {
|
||
window.location.href = await resolvePostLoginTarget()
|
||
}
|
||
|
||
// Links aus Admin-Mails (z. B. /admin.html?gutschrift=<id>) nach dem Login wieder aufnehmen
|
||
function adminLoginTarget() {
|
||
const next = getQueryParam('next')
|
||
return /^\/admin\.html(\?[A-Za-z0-9=&_-]*)?$/.test(next) ? next : '/admin.html'
|
||
}
|
||
|
||
/* -------------------------------------------------------------- Login-Page --- */
|
||
async function initLoginPage() {
|
||
const form = document.getElementById('login-form')
|
||
const errorEl = document.getElementById('login-error')
|
||
const btn = document.getElementById('login-btn')
|
||
|
||
const errorFromQuery = getQueryParam('error')
|
||
if (errorFromQuery) {
|
||
showError(errorEl, errorFromQuery)
|
||
}
|
||
|
||
async function applyCooldown(seconds) {
|
||
if (seconds <= 0) return
|
||
showError(
|
||
errorEl,
|
||
`Zu viele Anmeldeversuche. Bitte warte noch ${seconds} Sekunden (ca. ${Math.ceil(seconds / 60)} Min.).`
|
||
)
|
||
btn.disabled = true
|
||
const tick = setInterval(async () => {
|
||
const status = await fetch('/api/auth/login-status').then((r) => r.json()).catch(() => ({}))
|
||
const left = status.retryAfterSeconds || 0
|
||
if (left <= 0) {
|
||
clearInterval(tick)
|
||
hideError(errorEl)
|
||
btn.disabled = false
|
||
return
|
||
}
|
||
showError(
|
||
errorEl,
|
||
`Zu viele Anmeldeversuche. Bitte warte noch ${left} Sekunden (ca. ${Math.ceil(left / 60)} Min.).`
|
||
)
|
||
}, 1000)
|
||
}
|
||
|
||
try {
|
||
const status = await fetch('/api/auth/login-status').then((r) => r.json())
|
||
if (status.blocked) {
|
||
await applyCooldown(status.retryAfterSeconds)
|
||
}
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
|
||
try {
|
||
const me = await api('/api/auth/me')
|
||
if (me.authenticated && me.role === 'admin' && !me.viewAs) {
|
||
window.location.href = adminLoginTarget()
|
||
return
|
||
}
|
||
if (me.authenticated && me.customer) {
|
||
await postLoginRedirect()
|
||
return
|
||
}
|
||
} catch {
|
||
/* not logged in */
|
||
}
|
||
|
||
form?.addEventListener('submit', async (e) => {
|
||
e.preventDefault()
|
||
if (btn.disabled) return
|
||
errorEl.classList.add('hidden')
|
||
btn.disabled = true
|
||
btn.classList.add('is-loading')
|
||
btn.setAttribute('aria-busy', 'true')
|
||
let cooldownActive = false
|
||
try {
|
||
const result = await api('/api/auth/login', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
email: document.getElementById('email').value,
|
||
password: document.getElementById('password').value,
|
||
}),
|
||
})
|
||
if (result.role === 'admin') {
|
||
window.location.href = adminLoginTarget()
|
||
return
|
||
}
|
||
await postLoginRedirect()
|
||
return
|
||
} catch (err) {
|
||
showError(errorEl, err.message)
|
||
if (err.status === 429) {
|
||
cooldownActive = true
|
||
await applyCooldown(err.retryAfterSeconds || 900)
|
||
}
|
||
} finally {
|
||
btn.classList.remove('is-loading')
|
||
btn.removeAttribute('aria-busy')
|
||
if (!cooldownActive) btn.disabled = false
|
||
}
|
||
})
|
||
}
|
||
|
||
/* --------------------------------------------------------------- View-as --- */
|
||
function renderViewAsBanner(me) {
|
||
const banner = document.getElementById('viewas-banner')
|
||
if (!banner || !me.viewAs) return
|
||
banner.classList.remove('hidden')
|
||
banner.innerHTML = `
|
||
<span>Admin-Ansicht: Du siehst das Portal als <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong>.</span>
|
||
<button type="button" id="exit-viewas-btn">Zurück zur Admin-Übersicht</button>
|
||
`
|
||
document.getElementById('exit-viewas-btn')?.addEventListener('click', async () => {
|
||
await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {})
|
||
window.location.href = '/admin.html'
|
||
})
|
||
}
|
||
|
||
/* ------------------------------------------------------------- Rechnungen --- */
|
||
async function loadCustomerInvoices() {
|
||
const body = document.getElementById('invoices-body')
|
||
if (!body) return
|
||
try {
|
||
const data = await api('/api/invoices')
|
||
// Zaehler in der Seitenleiste: Rechnungen mit offenem Betrag
|
||
const openCount = (data.invoices || []).filter((i) => Number(i.balance) > 0).length
|
||
const badge = document.getElementById('invoices-badge')
|
||
if (badge) {
|
||
badge.textContent = openCount
|
||
badge.classList.toggle('hidden', !openCount)
|
||
}
|
||
if (!data.linked) {
|
||
body.innerHTML = '<p class="muted">Für dein Konto sind noch keine Rechnungen hinterlegt.</p>'
|
||
return
|
||
}
|
||
if (!data.invoices.length) {
|
||
body.innerHTML = '<p class="muted">Es liegen noch keine Rechnungen vor.</p>'
|
||
return
|
||
}
|
||
// Klassen je Spalte: in der App-Ansicht wird jede Zeile zur Karte (kundenbereich.css)
|
||
body.innerHTML = `
|
||
<div class="kb-table-wrap"><table class="simple-table kb-invoices">
|
||
<thead><tr><th>Nr.</th><th>Datum</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr></thead>
|
||
<tbody>
|
||
${data.invoices.map((i) => `
|
||
<tr class="${Number(i.balance) > 0 ? 'is-open' : 'is-paid'}">
|
||
<td class="inv-no">${escapeHtml(i.number)}</td>
|
||
<td class="inv-date">${fmtDateShort(i.date)}</td>
|
||
<td class="inv-amount">${fmtEuro(i.amount)}</td>
|
||
<td class="inv-open">${fmtEuro(i.balance)}</td>
|
||
<td class="inv-status"><span class="pill ${i.overdue ? 'pill-red' : i.statusId === 4 ? 'pill-ok' : 'pill-warn'}">${escapeHtml(i.status)}</span></td>
|
||
<td class="inv-actions" style="white-space:nowrap">
|
||
${i.portalUrl ? `<a class="btn-link" href="${escapeAttr(i.portalUrl)}" target="_blank" rel="noreferrer">Ansehen / Zahlen</a><span class="inv-sep" aria-hidden="true"> · </span>` : ''}
|
||
<a class="btn-link" href="/api/invoices/${escapeAttr(i.id)}/pdf">PDF</a>
|
||
</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table></div>`
|
||
} catch (err) {
|
||
body.innerHTML = `<p class="muted">Rechnungen konnten nicht geladen werden: ${escapeHtml(err.message)}</p>`
|
||
}
|
||
}
|
||
|
||
/* -------------------------------------------------------------- Aktivität --- */
|
||
// Tab "Fortschritt": je Auftrag eine Karte mit Status, Fortschrittsleiste und Arbeiten
|
||
// (Bausteine der Projektdetails; Texte kommen aufbereitet vom Server)
|
||
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) => `
|
||
<article class="pd-order pd-order-card pd-tone-${escapeAttr(t.status.tone)}">
|
||
${renderOrder(t)}
|
||
${renderWorkEntries(t.entries || [], 'Noch keine Arbeiten dokumentiert.')}
|
||
</article>`).join('')
|
||
initPdToggles(body)
|
||
} catch (err) {
|
||
body.innerHTML = `<p class="muted">Aktivität konnte nicht geladen werden: ${escapeHtml(err.message)}</p>`
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ Chat --- */
|
||
const chatState = {
|
||
lastCreatedAt: '',
|
||
pollTimer: null,
|
||
badgeTimer: null,
|
||
initialized: false,
|
||
viewAs: false,
|
||
shownIds: new Set(),
|
||
composer: null,
|
||
}
|
||
|
||
const chatFileUrl = (att) => `/api/chat/attachments/${encodeURIComponent(att.id)}`
|
||
|
||
function appendChatMessages(messages) {
|
||
const box = document.getElementById('chat-messages')
|
||
if (!box || !messages.length) return
|
||
for (const msg of messages) {
|
||
// Poll und Antwort auf das Senden koennen dieselbe Nachricht liefern
|
||
if (chatState.shownIds.has(msg.id)) continue
|
||
chatState.shownIds.add(msg.id)
|
||
const mine = msg.sender === 'customer'
|
||
box.appendChild(ChatComposer.renderMessage(msg, {
|
||
mine,
|
||
meta: `${mine ? 'Du' : msg.senderName || 'Webklar-Team'} · ${fmtDateTime(msg.createdAt)}`,
|
||
fileUrl: chatFileUrl,
|
||
}))
|
||
if (msg.createdAt > chatState.lastCreatedAt) chatState.lastCreatedAt = msg.createdAt
|
||
}
|
||
box.scrollTop = box.scrollHeight
|
||
}
|
||
|
||
function updateChatBadge(unread) {
|
||
const badge = document.getElementById('chat-badge')
|
||
if (!badge) return
|
||
if (unread > 0) {
|
||
badge.textContent = unread
|
||
badge.classList.remove('hidden')
|
||
} else {
|
||
badge.classList.add('hidden')
|
||
}
|
||
}
|
||
|
||
async function pollChatMessages() {
|
||
if (document.hidden) return
|
||
try {
|
||
const q = chatState.lastCreatedAt ? `?since=${encodeURIComponent(chatState.lastCreatedAt)}` : ''
|
||
const data = await api(`/api/chat/messages${q}`)
|
||
appendChatMessages(data.messages || [])
|
||
if (isChatTabActive() && (data.unread || 0) > 0) {
|
||
await api('/api/chat/read', { method: 'POST' }).catch(() => {})
|
||
updateChatBadge(0)
|
||
}
|
||
} catch {
|
||
/* naechster Poll versucht es erneut */
|
||
}
|
||
}
|
||
|
||
function isChatTabActive() {
|
||
return Boolean(document.getElementById('view-chat')?.classList.contains('active'))
|
||
}
|
||
|
||
async function initChat() {
|
||
if (chatState.initialized) return
|
||
chatState.initialized = true
|
||
|
||
ChatComposer.trackScroll(document.getElementById('chat-messages'))
|
||
// Text, Bilder, Kamerafoto, Dateien und Sprachnachrichten (public/chat-composer.js)
|
||
chatState.composer = ChatComposer.create({
|
||
root: document.getElementById('chat-composer'),
|
||
dropZone: document.querySelector('#view-chat .chat-box'),
|
||
uploadUrl: '/api/chat/attachments',
|
||
placeholder: 'Nachricht an das WEBklar-Team…',
|
||
placeholderShort: 'Nachricht…',
|
||
disabled: chatState.viewAs,
|
||
disabledText: 'Im Ansichtsmodus kann nicht als Kunde geschrieben werden.',
|
||
send: async ({ text, attachmentIds }) => {
|
||
const { message } = await api('/api/chat/messages', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ text, attachmentIds }),
|
||
})
|
||
return message
|
||
},
|
||
onSent: (message) => appendChatMessages([message]),
|
||
})
|
||
|
||
await pollChatMessages()
|
||
await api('/api/chat/read', { method: 'POST' }).catch(() => {})
|
||
updateChatBadge(0)
|
||
chatState.pollTimer = setInterval(() => {
|
||
if (isChatTabActive()) pollChatMessages()
|
||
}, 10000)
|
||
}
|
||
|
||
async function pollChatBadge() {
|
||
if (document.hidden || isChatTabActive()) return
|
||
try {
|
||
const { unread } = await api('/api/chat/unread-count')
|
||
updateChatBadge(unread)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
/* -------------------------------------------------------------- Dashboard --- */
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Wartungs-Abo: Karte in der Seitenleiste + Ansicht (Bestellung bzw. Stundenkonto)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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 `<ul class="abo-features">${(features || []).map(
|
||
([t, d]) => `<li>${escapeHtml(t)}<small>${escapeHtml(d)}</small></li>`
|
||
).join('')}</ul>`
|
||
}
|
||
|
||
// 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 = `
|
||
<p class="kb-label">Ihr Vertrag</p>
|
||
<h3>${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}</h3>
|
||
<p class="kb-price"><strong>ab ${euroShort(cheapest)}</strong><span>pro Monat</span></p>
|
||
<p class="kb-sub">Hosting-Abo oder Wartungs-Abo, 12 Monate Mindestlaufzeit.</p>
|
||
<button type="button" class="kb-btn-primary kb-btn-block" data-view="vertraege">Abos ansehen</button>
|
||
<p class="kb-fine">${data.available
|
||
? 'Gemäß § 19 UStG ohne Umsatzsteuer.'
|
||
: 'Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'}</p>`
|
||
}
|
||
|
||
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) ? '<span class="kb-status kb-status-off">gekündigt</span>' : ''
|
||
card.classList.remove('kb-abo-offer')
|
||
card.innerHTML = `
|
||
<p class="kb-label">Ihr Vertrag</p>
|
||
${status}
|
||
<h3>${escapeHtml(productLabelOf(data, sub.productKey))}</h3>
|
||
${wartung ? `
|
||
<button type="button" class="kb-hours" data-view="stunden" title="Stundenkonto und Verlauf ansehen" aria-label="${fmtHours(l.balanceMinutes)} Arbeitszeit verfügbar – Stundenkonto und Verlauf ansehen">
|
||
<strong>${fmtHours(l.balanceMinutes)}</strong><span>Arbeitszeit verfügbar</span><em aria-hidden="true">›</em>
|
||
</button>` : `
|
||
<p class="kb-price"><strong>${fmtEuro(sub.priceAmount / 100)}</strong><span>pro Monat, gemäß § 19 UStG ohne Umsatzsteuer</span></p>`}
|
||
<p class="kb-sub">${aboDateLine(sub)}</p>
|
||
${sub.switchPending ? '<p class="kb-side-alert">Ihr Wechsel wartet auf die Zahlung.</p>' : ''}
|
||
${sub.paymentProblem ? `
|
||
<p class="kb-side-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.</p>
|
||
<button type="button" class="kb-side-btn" id="abo-manage">Zahlungsart aktualisieren ↗</button>` : ''}
|
||
<button type="button" class="kb-side-btn" data-view="takeover">Übernahmegutschrift beantragen <span class="kb-badge hidden" id="takeover-badge" title="Rückfrage offen"></span></button>
|
||
${sub.withdrawalOpen ? `<a class="kb-side-btn kb-withdraw" href="/widerrufen.html">Vertrag widerrufen <small>· bis ${fmtDayMonth(sub.withdrawalUntil)}</small></a>` : ''}
|
||
<div class="kb-divider"></div>
|
||
<p class="kb-fine" style="margin-top:0">${aboTermText(sub)}</p>
|
||
<p class="error hidden" id="abo-error"></p>`
|
||
|
||
card.querySelector('#abo-manage')?.addEventListener('click', (ev) => openBillingPortal(ev.currentTarget, card.querySelector('#abo-error')))
|
||
}
|
||
|
||
/* Stripe-Kundenportal (Zahlungsart, Rechnungen, Abo): "Abo verwalten & Rechnungen" im Konto-Menü,
|
||
in der Wartungskarte nur bei fehlgeschlagener Abbuchung ("Zahlungsart aktualisieren") */
|
||
async function openBillingPortal(btn, errorEl) {
|
||
btn.disabled = true
|
||
hideError(errorEl)
|
||
try {
|
||
const { url } = await api('/api/abo/portal', { method: 'POST' })
|
||
window.location.href = url
|
||
} catch (err) {
|
||
showError(errorEl, err.message)
|
||
btn.disabled = false
|
||
}
|
||
}
|
||
|
||
/* Bestellseite je Produkt (#bestellen-hosting, #bestellen-wartung): Bestellübersicht direkt über dem Bestellbutton (§ 312j BGB) */
|
||
function renderOrderView(view, data, productKey) {
|
||
const product = productOf(data, productKey)
|
||
const offer = data.offers?.[productKey] || null
|
||
const label = productLabelOf(data, productKey)
|
||
if (!product || !data.available) {
|
||
view.innerHTML = `
|
||
<header class="kb-head"><h1>${escapeHtml(label)}</h1></header>
|
||
<div class="kb-panel"><p class="abo-note" style="margin:0">Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.</p></div>`
|
||
return
|
||
}
|
||
const price = fmtEuro(product.amountCents / 100)
|
||
const lateStart = data.lateStartAt ? fmtDateShort(data.lateStartAt) : ''
|
||
const isWartung = productKey === 'wartung'
|
||
// Betrag der ersten Abbuchung: Angebotspreis während des Rabatts, sonst Katalogpreis
|
||
const offerMonthly = Number(offer?.monthlyPriceCents)
|
||
const offerRegular = Number(offer?.regularPriceCents) || product.amountCents
|
||
const offerMonths = Number(offer?.months)
|
||
const hasOfferNumbers = Boolean(offer) && Number.isFinite(offerMonthly) && offer.monthlyPriceCents != null && offerMonths > 0
|
||
const firstCents = hasOfferNumbers ? offerMonthly : product.amountCents
|
||
const firstAmount = fmtEuro(firstCents / 100)
|
||
view.innerHTML = `
|
||
<header class="kb-head">
|
||
<h1>${escapeHtml(label)}</h1>
|
||
<p>${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`}.</p>
|
||
</header>
|
||
${offer ? `
|
||
<div class="abo-offer-box">
|
||
<strong>Ihr persönliches Angebot</strong>
|
||
<p>${escapeHtml(offer.text)}. Sie sparen ${fmtEuro(offer.savingsCents / 100)}.</p>
|
||
${offer.note ? `<p class="abo-note">${escapeHtml(offer.note)}</p>` : ''}
|
||
<p class="abo-note">Gültig bis ${fmtDateShort(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt</p>
|
||
</div>` : ''}
|
||
<div class="kb-panel">
|
||
${aboFeaturesHtml(product.features)}
|
||
<p class="abo-note" style="margin:0">Schon bei einem anderen Anbieter für Domain, Hosting oder E-Mail bezahlt? Nach dem Abschluss können Sie eine Übernahmegutschrift beantragen – wir rechnen die doppelt bezahlte Zeit anteilig an.</p>
|
||
</div>
|
||
<div class="kb-panel" id="abo-order">
|
||
<h3>Ihre Bestellung</h3>
|
||
${data.testAccount ? `<div class="abo-alert">Testkonto: Bei Stripe wird ein Test-Rabatt verrechnet, abgebucht werden nur 0,50 € pro Monat.${offer ? ' Das Angebot wird nach dem Abschluss zusätzlich ans Abo gehängt.' : ''}</div>` : ''}
|
||
<label class="abo-check"><input type="checkbox" id="abo-terms">
|
||
<span>Ich habe die <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen mit Leistungsbeschreibung und Auftragsverarbeitungsvertrag</a> gelesen und stimme ihnen zu. Die <a href="/abo-bedingungen.html#widerruf" target="_blank" rel="noopener">Widerrufsbelehrung</a> habe ich zur Kenntnis genommen.</span></label>
|
||
|
||
<fieldset class="abo-start-choice">
|
||
<legend>Wann sollen wir mit der Leistung beginnen?</legend>
|
||
<label class="abo-check"><input type="radio" name="abo-start-choice" value="sofort">
|
||
<span><strong>Sofort.</strong> Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Leistung beginnt. Mir ist bekannt, dass ich bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahle.</span></label>
|
||
<label class="abo-check"><input type="radio" name="abo-start-choice" value="nach_frist">
|
||
<span><strong>Nach Ablauf der 14-tägigen Widerrufsfrist</strong>${lateStart ? ` (am ${lateStart})` : ''}. 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>${escapeHtml(product.name)}: ${(product.features || []).map(([t]) => escapeHtml(t)).join(', ')}</td></tr>
|
||
<tr><td>Preis</td><td>${offer
|
||
? `${hasOfferNumbers ? `<strong>${escapeHtml(fmtEuro(offerMonthly / 100))} pro Monat für die ersten ${escapeHtml(String(offerMonths))} Monate</strong>, danach ${escapeHtml(fmtEuro(offerRegular / 100))} pro Monat` : ''} <span>(individuelles Angebot: ${escapeHtml(offer.text)}; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer)</span>, keine weiteren Kosten`
|
||
: `<strong>${escapeHtml(price)} 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>Fällig</td><td id="abo-due-summary">Bitte oben wählen: sofort oder nach Ablauf der 14-tägigen Widerrufsfrist</td></tr>
|
||
<tr><td>Leistungsbeginn</td><td id="abo-start-summary">Bitte oben wählen: sofort oder nach Ablauf der 14-tägigen Widerrufsfrist${lateStart ? ` (am ${lateStart})` : ''}</td></tr>
|
||
<tr><td>Laufzeit</td><td><strong>Mindestlaufzeit ${escapeHtml(String(product.minTermMonths))} Monate</strong> ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach unbefristet und jederzeit zum Ende des Abrechnungsmonats kündbar</td></tr>
|
||
<tr><td>Zahlung</td><td>Kreditkarte oder SEPA-Lastschrift über Stripe</td></tr>
|
||
<tr><td>Anbieter</td><td>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler · Telefon 0176 23726355 · support@webklar.com</td></tr>
|
||
</table>
|
||
<p class="abo-note">Es gilt das gesetzliche Mängelhaftungsrecht. Einzelheiten: <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen</a> · <a href="/datenschutz.html" target="_blank" rel="noopener">Datenschutz</a></p>
|
||
</div>
|
||
<p class="error hidden" id="abo-error"></p>
|
||
<div class="kb-actions">
|
||
<button type="button" class="kb-btn-primary" id="abo-buy">Zahlungspflichtig bestellen</button>
|
||
<span class="abo-note">Danach hinterlegen Sie bei unserem Zahlungsdienstleister Stripe Ihre Zahlungsart. Bis zum Abschluss dort können Sie jederzeit abbrechen.</span>
|
||
</div>
|
||
</div>`
|
||
|
||
view.querySelectorAll('input[name="abo-start-choice"]').forEach((radio) => radio.addEventListener('change', () => {
|
||
const el = view.querySelector('#abo-start-summary')
|
||
const due = view.querySelector('#abo-due-summary')
|
||
if (due) due.textContent = radio.value === 'sofort'
|
||
? `Heute fällig: ${firstAmount} (gemäß § 19 UStG ohne Umsatzsteuer)`
|
||
: `Erste Abbuchung am ${lateStart || 'Ende der 14-tägigen Widerrufsfrist'}: ${firstAmount} (gemäß § 19 UStG ohne Umsatzsteuer); heute wird nichts abgebucht`
|
||
if (el) el.textContent = radio.value === 'sofort'
|
||
? 'sofort, auf Ihren ausdrücklichen Wunsch vor Ablauf der Widerrufsfrist'
|
||
: `nach Ablauf der 14-tägigen Widerrufsfrist${lateStart ? ` (am ${lateStart})` : ''}; bis dahin keine Leistung und keine Abbuchung`
|
||
}))
|
||
|
||
view.querySelector('#abo-buy')?.addEventListener('click', async (ev) => {
|
||
const btn = ev.currentTarget
|
||
const errEl = view.querySelector('#abo-error')
|
||
hideError(errEl)
|
||
const acceptTerms = view.querySelector('#abo-terms').checked
|
||
const startChoice = view.querySelector('input[name="abo-start-choice"]:checked')?.value || ''
|
||
if (!acceptTerms) {
|
||
showError(errEl, 'Bitte stimmen Sie den Vertragsbedingungen zu.')
|
||
return
|
||
}
|
||
if (!startChoice) {
|
||
showError(errEl, 'Bitte wählen Sie, wann wir mit der Leistung beginnen sollen.')
|
||
return
|
||
}
|
||
btn.disabled = true
|
||
btn.textContent = 'Weiter zur Zahlung…'
|
||
try {
|
||
const { url } = await api('/api/abo/checkout', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ productKey, acceptTerms, startChoice, termsVersion: data.termsVersion, offerId: offer?.id || '', lateStartAt: data.lateStartAt || '' }),
|
||
})
|
||
window.location.href = url
|
||
} catch (err) {
|
||
showError(errEl, err.message)
|
||
btn.disabled = false
|
||
btn.textContent = 'Zahlungspflichtig bestellen'
|
||
}
|
||
})
|
||
}
|
||
|
||
/* Wechsel ins Wartungs-Abo (#wechsel): Vorschau vom Server, Bestellübersicht direkt über dem Knopf (§ 312j BGB), Zustimmung, Bestellung.
|
||
Der Wechsel wirkt erst mit der Zahlung der Wechselrechnung (Bedingungen § 9 Abs. 3). */
|
||
const switchUi = { notice: '', loadedAt: 0, busy: false }
|
||
// Zurück aus dem Bfcache (z. B. von der Zahlungsseite): eine hängende Bestellung nicht gesperrt lassen
|
||
window.addEventListener('pageshow', () => {
|
||
if (!switchUi.busy) return
|
||
switchUi.busy = false
|
||
const view = document.getElementById('view-wechsel')
|
||
if (view && aboState.data) renderSwitchView(view, aboState.data)
|
||
})
|
||
|
||
function renderSwitchView(view, data) {
|
||
const sub = data.subscription
|
||
const wartung = productOf(data, 'wartung')
|
||
const head = '<header class="kb-head"><h1>Wechsel ins Wartungs-Abo</h1><p>Aus dem Hosting-Abo in das Wartungs-Abo wechseln.</p></header>'
|
||
if (!sub?.active || sub.productKey !== 'hosting' || !sub.canSwitch || !wartung) {
|
||
switchUi.loadedAt = 0
|
||
view.innerHTML = `${head}<div class="kb-panel"><p class="abo-note" style="margin:0">Ein Wechsel ist derzeit nicht möglich. Details finden Sie unter <a href="#vertraege" data-view="vertraege">Verträge</a>.</p></div>`
|
||
if (view.classList.contains('active')) showView('vertraege')
|
||
return
|
||
}
|
||
const units = Number(sub.storage?.units) || 0
|
||
const mail = data.mailStorage || {}
|
||
const unitCents = Number(mail.unitPriceCents) || 0
|
||
const state = { preview: null, error: '', loading: true, token: 0 }
|
||
|
||
const paint = () => {
|
||
const p = state.preview
|
||
const rows = []
|
||
if (p) {
|
||
const monthly = Number.isFinite(Number(p.monthlyCents)) && p.monthlyCents != null ? Number(p.monthlyCents) : wartung.amountCents
|
||
rows.push(`<tr><td>Wartungs-Abo</td><td><strong>${escapeHtml(fmtEuro(monthly / 100))} pro Monat</strong> (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer); enthalten sind Pflege Ihrer Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat</td></tr>`)
|
||
if (units > 0) {
|
||
rows.push(`<tr><td>Zusatzspeicher</td><td>${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</td></tr>`)
|
||
}
|
||
if ((Number(p.creditCents) || 0) > 0) {
|
||
rows.push(`<tr><td>Gutschrift für nicht genutzte Tage</td><td>−${escapeHtml(fmtEuro(p.creditCents / 100))} aus dem bereits bezahlten Hosting-Abo (gemäß § 19 UStG ohne Umsatzsteuer)</td></tr>`)
|
||
}
|
||
rows.push(`<tr><td>Heute fällig</td><td><strong>${escapeHtml(fmtEuro(p.amountDueCents / 100))}</strong> (gemäß § 19 UStG ohne Umsatzsteuer)</td></tr>`)
|
||
const nd = new Date(p.nextChargeAt)
|
||
const nextDay = Number.isNaN(nd.getTime()) ? '' : nd.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: 'numeric' })
|
||
rows.push(`<tr><td>Neuer Abbuchungstag</td><td>${nextDay ? `Ab dem Wechsel jeweils am ${escapeHtml(nextDay)}. eines Monats; ` : ''}nächste Abbuchung am ${fmtDateShort(p.nextChargeAt)}</td></tr>`)
|
||
rows.push(`<tr><td>Laufzeit</td><td><strong>Mindestlaufzeit bis ${fmtDateShort(p.minTermEnd)}</strong> (12 Monate ab dem Wechsel), danach monatlich kündbar</td></tr>`)
|
||
rows.push('<tr><td>Beginn</td><td>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.</td></tr>')
|
||
}
|
||
const summary = state.error
|
||
? `<p class="kb-storage-error">${escapeHtml(state.error)}</p>`
|
||
: state.loading || !p ? '<p class="abo-note">Ihre Übersicht wird berechnet …</p>'
|
||
: `<table class="abo-summary">${rows.join('')}</table>`
|
||
const checked = (id) => view.querySelector(id)?.checked ? ' checked' : ''
|
||
const prevErr = view.querySelector('#switch-error')
|
||
const errText = prevErr && !prevErr.classList.contains('hidden') ? prevErr.textContent : ''
|
||
const busy = switchUi.busy
|
||
view.innerHTML = `${head}
|
||
<div class="kb-panel" id="switch-order">
|
||
<h3>Ihre Bestellung</h3>
|
||
<p class="abo-note" style="margin-top:0">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.</p>
|
||
<label class="abo-check"><input type="checkbox" id="switch-terms"${checked('#switch-terms')}${busy ? ' disabled' : ''}>
|
||
<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>
|
||
<label class="abo-check"><input type="checkbox" id="switch-early"${checked('#switch-early')}${busy ? ' disabled' : ''}>
|
||
<span><strong>Sofortbeginn.</strong> Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Wartungs-Abo beginnt. Mir ist bekannt, dass ich bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachten Leistungen zahle (§ 357a Abs. 2 BGB; siehe Hinweise zum Widerruf). Firmenkunden haben kein Widerrufsrecht.</span></label>
|
||
<div class="abo-summary-box">
|
||
${summary}
|
||
<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${errText ? '' : ' hidden'}" id="switch-error">${escapeHtml(errText)}</p>
|
||
<div class="kb-actions">
|
||
<button type="button" class="kb-btn-primary" id="switch-buy"${busy || state.loading || !p ? ' disabled' : ''}>${busy ? 'Wird bestellt …' : 'Zahlungspflichtig bestellen'}</button>
|
||
<span class="abo-note">Bezahlt wird mit der bei Stripe hinterlegten Zahlungsart. Bis zum Klick können Sie abbrechen.</span>
|
||
</div>
|
||
</div>`
|
||
view.querySelector('#switch-buy')?.addEventListener('click', submit)
|
||
}
|
||
|
||
const loadPreview = async () => {
|
||
const token = ++state.token
|
||
state.loading = true
|
||
state.error = ''
|
||
paint()
|
||
try {
|
||
const p = await api('/api/abo/switch/preview', { method: 'POST', body: '{}' })
|
||
if (token !== state.token) return
|
||
state.preview = p
|
||
switchUi.loadedAt = Date.now()
|
||
} catch (err) {
|
||
if (token !== state.token) return
|
||
state.preview = null
|
||
state.error = err.message
|
||
}
|
||
state.loading = false
|
||
paint()
|
||
}
|
||
|
||
const submit = async () => {
|
||
if (switchUi.busy || !state.preview) return
|
||
const errEl = view.querySelector('#switch-error')
|
||
hideError(errEl)
|
||
if (!view.querySelector('#switch-terms').checked) {
|
||
showError(errEl, 'Bitte stimmen Sie den Vertragsbedingungen zu.')
|
||
return
|
||
}
|
||
if (!view.querySelector('#switch-early').checked) {
|
||
showError(errEl, 'Bitte bestätigen Sie den Sofortbeginn, sonst können wir den Wechsel nicht ausführen.')
|
||
return
|
||
}
|
||
const p = state.preview
|
||
switchUi.busy = true
|
||
paint()
|
||
try {
|
||
const res = await api('/api/abo/switch', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ acceptTerms: true, earlyStart: true, termsVersion: data.termsVersion, prorationDate: p.prorationDate, amountDueCents: p.amountDueCents }),
|
||
})
|
||
if (res.paymentUrl) {
|
||
view.querySelector('#switch-buy').textContent = 'Weiter zur Zahlung …'
|
||
view.querySelector('#switch-error').classList.remove('hidden')
|
||
view.querySelector('#switch-error').classList.remove('error')
|
||
view.querySelector('#switch-error').textContent = 'Ihr Wechsel wartet auf die Zahlung. Wir leiten Sie zur Zahlung weiter.'
|
||
window.location.href = res.paymentUrl
|
||
return
|
||
}
|
||
switchUi.busy = false
|
||
const sepa = Boolean(res.subscription?.switchPending)
|
||
switchUi.notice = sepa
|
||
? 'Ihr Wechsel ins Wartungs-Abo gilt ab sofort. Die Lastschrift wird noch eingezogen; wir bestätigen Ihnen den Wechsel per E-Mail.'
|
||
: 'Ihr Wechsel ins Wartungs-Abo ist abgeschlossen. Wir bestätigen ihn per E-Mail.'
|
||
if (res.subscription && aboState.data) aboState.data.subscription = res.subscription
|
||
const card = document.getElementById('abo-card')
|
||
const contracts = document.getElementById('kb-contracts')
|
||
if (aboState.data) {
|
||
if (card) renderSideCard(card, aboState.data)
|
||
if (contracts) renderContractsView(contracts, aboState.data)
|
||
renderSwitchView(view, aboState.data)
|
||
}
|
||
showView('vertraege')
|
||
contracts?.insertAdjacentHTML('afterbegin', `<div class="abo-alert" role="status">${escapeHtml(switchUi.notice)}</div>`)
|
||
switchUi.notice = ''
|
||
} catch (err) {
|
||
switchUi.busy = false
|
||
paint()
|
||
showError(view.querySelector('#switch-error'), err.message)
|
||
// Betrag oder Stichtag haben sich geändert: neue Vorschau laden, Fehlerhinweis bleibt stehen
|
||
if (err.status === 409) {
|
||
const msg = err.message
|
||
await loadPreview()
|
||
showError(view.querySelector('#switch-error'), msg)
|
||
}
|
||
}
|
||
}
|
||
|
||
view._switchReload = () => { if (!switchUi.busy) loadPreview() }
|
||
loadPreview()
|
||
}
|
||
|
||
/* Wer #wechsel öffnet, ohne wechseln zu können, landet bei "Verträge"; eine alte Vorschau (> 5 Minuten) wird neu berechnet */
|
||
document.addEventListener('kb:view', (ev) => {
|
||
if (ev.detail.name !== 'wechsel') return
|
||
const view = document.getElementById('view-wechsel')
|
||
const sub = aboState.data?.subscription
|
||
if (aboState.data && (!sub?.active || sub.productKey !== 'hosting' || !sub.canSwitch)) { showView('vertraege'); return }
|
||
if (view?._switchReload && Date.now() - switchUi.loadedAt > 5 * 60 * 1000) view._switchReload()
|
||
})
|
||
|
||
/* Ansicht "Wartungs-Abo": Stundenkonto, Verlauf, Vertrag (Bedingungen § 4: jederzeit im Portal einsehbar) */
|
||
function renderHoursView(view, data) {
|
||
const sub = data.subscription
|
||
const l = data.ledger || { balanceMinutes: 0, usedMinutes: 0, credits: [], usage: [] }
|
||
const usageRows = (l.usage || []).map((u) => `
|
||
<tr><td>${fmtDateShort(u.date)}</td>
|
||
<td>${escapeHtml(u.topic || u.details || 'Arbeitszeit')}${u.woid ? ` <span class="muted">#${escapeHtml(u.woid)}</span>` : ''}</td>
|
||
<td>−${fmtHours(u.minutes)}${u.overageMinutes ? ` <span class="pill pill-warn" title="Über das Kontingent hinaus">+${fmtHours(u.overageMinutes)} extra</span>` : ''}</td></tr>`).join('')
|
||
const creditRows = (l.credits || []).slice().reverse().map((c) => `
|
||
<tr><td>${fmtDateShort(c.grantedAt)}</td><td>Monatskontingent</td>
|
||
<td>+${fmtHours(c.minutes)} <span class="muted">${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDateShort(c.expiresAt)}`}</span></td></tr>`).join('')
|
||
|
||
view.innerHTML = `
|
||
<header class="kb-head">
|
||
<h1>Stundenkonto</h1>
|
||
<p>Ihr Stundenkontingent, der Verlauf und Ihr Vertrag.</p>
|
||
</header>
|
||
${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart über „Zahlungsart aktualisieren“ <span class="kb-desk-only">in der Seitenleiste</span><span class="kb-app-only">unter <a href="#vertraege" data-view="vertraege">Verträge</a></span>, damit Ihre Website online bleibt.</div>' : ''}
|
||
${sub.serviceStartsAt ? `<div class="abo-alert">Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist am ${fmtDateShort(sub.serviceStartsAt)}. Dann wird auch zum ersten Mal abgebucht, und Ihre erste Stunde steht bereit.</div>` : ''}
|
||
<div class="kb-stats">
|
||
<div class="kb-stat"><strong>${fmtHours(l.balanceMinutes)}</strong><span>verfügbare Zeit</span></div>
|
||
<div class="kb-stat"><strong>${fmtHours(l.usedMinutes)}</strong><span>bisher genutzt</span></div>
|
||
${l.nextExpiry ? `<div class="kb-stat"><strong>${fmtHours(l.nextExpiry.minutes)}</strong><span>verfallen am ${fmtDateShort(l.nextExpiry.at)}</span></div>` : ''}
|
||
</div>
|
||
<div class="kb-panel">
|
||
<p class="abo-note" style="margin-top:0">Jeden bezahlten Monat kommt 1 Stunde dazu. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst. Aufträge schicken Sie uns einfach über den <a href="#chat" data-view="chat">Chat</a>.</p>
|
||
${sub.offerText ? `<p class="abo-note">Ihr Angebot: ${escapeHtml(sub.offerText)}.</p>` : ''}
|
||
<p class="abo-note">${aboTermText(sub)}</p>
|
||
<p class="abo-note" style="margin-bottom:0"><a href="${escapeAttr(sub.termsUrl || '/abo-bedingungen.html')}" target="_blank" rel="noopener">Leistungen & Bedingungen</a></p>
|
||
</div>
|
||
<div class="kb-panel">
|
||
<h3>Verlauf</h3>
|
||
${usageRows || creditRows ? `
|
||
<div class="kb-table-wrap"><table class="simple-table">
|
||
<thead><tr><th>Datum</th><th>Vorgang</th><th>Zeit</th></tr></thead>
|
||
<tbody>${usageRows}${creditRows}</tbody>
|
||
</table></div>` : '<p class="kb-empty" style="margin:0">Noch keine Buchungen.</p>'}
|
||
</div>`
|
||
}
|
||
|
||
const aboState = { data: null }
|
||
|
||
/* 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(`<div><dt>Neue Gesamtgröße</dt><dd>${gbOf(state.target)} GB</dd></div>`)
|
||
facts.push(`<div><dt>Preis</dt><dd>${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)</dd></div>`)
|
||
if (state.previewError || proBad) {
|
||
facts.push(`<div><dt>Vorschau</dt><dd class="kb-storage-error">${escapeHtml(state.previewError || 'Der Betrag konnte nicht berechnet werden. Bitte versuchen Sie es später erneut.')}</dd></div>`)
|
||
} else if (!p || state.loading) {
|
||
facts.push('<div><dt>Vorschau</dt><dd>wird berechnet …</dd></div>')
|
||
} else if (plus) {
|
||
const pro = Number(p.prorationCents) || 0
|
||
facts.push(`<div><dt>Zusätzlich jetzt</dt><dd>${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'}</dd></div>`)
|
||
facts.push(`<div><dt>Nächste Abbuchung</dt><dd>${fmtDateShort(p.nextChargeAt)}, danach monatlich ${fmtEuro(totalCents / 100)}</dd></div>`)
|
||
facts.push(pro === 0 && pendingReduction
|
||
? '<div><dt>Geplante Reduzierung</dt><dd>wird aufgehoben oder angepasst; es ist nichts neu einzurichten</dd></div>'
|
||
: '<div><dt>Einrichtung</dt><dd>in der Regel innerhalb eines Werktags; bis dahin steht „wird eingerichtet“ da</dd></div>')
|
||
} else {
|
||
facts.push(`<div><dt>Wirkt zum</dt><dd>${fmtDateShort(p.effectiveAt)}, zum Ende des Abrechnungsmonats</dd></div>`)
|
||
}
|
||
facts.push('<div><dt>Laufzeit</dt><dd>monatlich, zum Ende jedes Abrechnungsmonats kündbar, endet spätestens mit dem Abo</dd></div>')
|
||
}
|
||
const hint = plus
|
||
? `<label class="abo-check"><input type="checkbox" id="storage-terms"${state.busy ? ' disabled' : ''}>
|
||
<span>Ich habe die <a href="/abo-bedingungen.html#zusatzspeicher" target="_blank" rel="noopener">Regeln zum Zusatzspeicher</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>`
|
||
: `<p class="abo-alert" style="margin:10px 0">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 <a href="/abo-bedingungen.html#zusatzspeicher" target="_blank" rel="noopener">Regeln zum Zusatzspeicher</a> und in der <a href="/abo-bedingungen.html#widerruf" target="_blank" rel="noopener">Widerrufsbelehrung</a>.</p>`
|
||
summary = `
|
||
<div class="kb-storage-summary">
|
||
<dl class="kb-contract-facts">${facts.join('')}</dl>
|
||
${hint}
|
||
<p class="error hidden" id="storage-error"></p>
|
||
<button type="button" class="kb-btn-primary" id="storage-submit"${state.busy || (state.loading || !p || state.previewError || proBad) ? ' disabled' : ''}>${state.busy ? 'Wird gespeichert …' : plus ? 'Zahlungspflichtig bestellen' : 'Speichern'}</button>
|
||
</div>`
|
||
}
|
||
const status = []
|
||
if (storage.pendingSetup) status.push('<p class="abo-alert" style="margin:12px 0 0">Ihr zusätzlicher Speicher wird eingerichtet. Das dauert in der Regel bis zum nächsten Werktag.</p>')
|
||
if (pendingReduction) status.push(`<p class="abo-note" style="margin:12px 0 0">Geplant: ab ${fmtDayMonth(pendingReduction.effectiveAt)} ${gbOf(Number(pendingReduction.units) || 0)} GB.</p>`)
|
||
container.innerHTML = `
|
||
<h3>E-Mail-Speicher</h3>
|
||
<p class="abo-note" style="margin-top:0">${includedGb} GB inklusive. Jedes Zusatzpaket bringt ${gbPer} GB für ${fmtEuro(unitCents / 100)} im Monat (gemäß § 19 UStG ohne Umsatzsteuer), bis zu ${maxUnits} Pakete.</p>
|
||
${notice ? `<p class="abo-alert kb-storage-notice" role="status">${escapeHtml(notice)}</p>` : ''}
|
||
${!canChange ? `<p class="abo-note kb-storage-locked" id="storage-locked">Änderungen sind derzeit nicht möglich – ${lockedReason}.</p>` : ''}
|
||
${canChange ? `
|
||
<div class="kb-storage-stepper">
|
||
<button type="button" class="kb-side-btn" id="storage-minus" aria-label="Ein Paket weniger"${state.busy || state.target <= 0 ? ' disabled' : ''}>−</button>
|
||
<span class="kb-storage-count" aria-live="polite"><strong>${state.target}</strong> Paket${state.target === 1 ? '' : 'e'}</span>
|
||
<button type="button" class="kb-side-btn" id="storage-plus" aria-label="Ein Paket mehr"${state.busy || state.target >= maxUnits ? ' disabled' : ''}>+</button>
|
||
</div>` : `<p class="kb-storage-count"><strong>${state.target}</strong> Paket${state.target === 1 ? '' : 'e'}</p>`}
|
||
<p class="kb-storage-total">= ${gbOf(state.target)} GB gesamt · + ${fmtEuro(state.target * unitCents / 100)} im Monat</p>
|
||
${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 `
|
||
<article class="kb-panel kb-compare-card" data-product="${escapeAttr(key)}">
|
||
<h3>${escapeHtml(productLabelOf(data, key))}</h3>
|
||
${offer ? `<span class="kb-offer-tag">Ihr persönliches Angebot</span><p class="abo-note" style="margin:0 0 8px">${escapeHtml(offer.text)}${offer.validUntil ? ` · gültig bis ${fmtDateShort(offer.validUntil)}` : ''}</p>` : ''}
|
||
<p class="kb-price"><strong>${fmtEuro(product.amountCents / 100)}</strong><span>pro Monat</span></p>
|
||
<p class="abo-note">Gemäß § 19 UStG ohne Umsatzsteuer. ${product.minTermMonths} Monate Mindestlaufzeit, danach monatlich kündbar.</p>
|
||
${aboFeaturesHtml(product.features)}
|
||
<button type="button" class="kb-btn-primary kb-btn-block" data-view="bestellen-${escapeAttr(key)}"${data.available ? '' : ' disabled'}>Abo abschließen</button>
|
||
</article>`
|
||
}
|
||
|
||
/* 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 `
|
||
<div class="kb-panel kb-switch-card" id="switch-card">
|
||
<h3>Ihr Wechsel wartet auf die Zahlung</h3>
|
||
<p class="abo-note" style="margin-top:0">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.'}</p>
|
||
${url ? `<a class="kb-btn-primary" href="${escapeAttr(url)}">Jetzt bezahlen</a>` : ''}
|
||
</div>`
|
||
}
|
||
if (aboIsCancelled(sub)) {
|
||
return `
|
||
<div class="kb-panel kb-switch-card" id="switch-card">
|
||
<h3>Wartungs-Abo</h3>
|
||
<p class="abo-note" style="margin:0">Ihr Abo ist gekündigt. Bitte schreiben Sie uns im Chat, wenn Sie zum Wartungs-Abo wechseln möchten.</p>
|
||
</div>`
|
||
}
|
||
if (!sub.canSwitch || !wartung) return ''
|
||
return `
|
||
<div class="kb-panel kb-switch-card" id="switch-card">
|
||
<h3>Mehr als Hosting? Das Wartungs-Abo</h3>
|
||
<p class="kb-price"><strong>${fmtEuro(wartung.amountCents / 100)}</strong><span>pro Monat</span></p>
|
||
<p class="abo-note">Gemäß § 19 UStG ohne Umsatzsteuer. Zusätzlich zum Hosting: Pflege Ihrer Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat.</p>
|
||
<button type="button" class="kb-btn-primary" data-view="wechsel">Zum Wartungs-Abo wechseln</button>
|
||
</div>`
|
||
}
|
||
|
||
/* Ansicht "Verträge": Mein Vertrag, Wechsel-Karte, Stundenkonto; ohne Abo der Vergleich */
|
||
function renderContractsView(view, data) {
|
||
const sub = data.subscription
|
||
if (!sub?.active) {
|
||
view.innerHTML = `
|
||
${sub ? '<div class="abo-alert">Ihr bisheriges Abo ist beendet. Sie können jederzeit ein neues abschließen.</div>' : ''}
|
||
${data.available ? '' : '<div class="abo-alert">Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.</div>'}
|
||
<div class="kb-compare">${renderCompareCard(data, 'hosting')}${renderCompareCard(data, 'wartung')}</div>
|
||
<p class="abo-note">Alle Preise gemäß § 19 UStG ohne Umsatzsteuer. Es gelten die <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen</a>.</p>`
|
||
return
|
||
}
|
||
const product = productOf(data, sub.productKey)
|
||
const wartung = sub.productKey === 'wartung'
|
||
const l = data.ledger || { balanceMinutes: 0 }
|
||
const units = Number(sub.storage?.units || 0)
|
||
const gb = (Number(data.mailStorage?.includedGb) || 0) + units * (Number(data.mailStorage?.gbPerUnit) || 0)
|
||
const cancelled = aboIsCancelled(sub)
|
||
const pill = cancelled
|
||
? '<span class="kb-status kb-status-off">gekündigt</span>'
|
||
: sub.paymentProblem ? '<span class="kb-status kb-status-warn">Zahlung offen</span>' : '<span class="kb-status kb-status-ok">aktiv</span>'
|
||
view.innerHTML = `
|
||
${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.</div>` : ''}
|
||
<div class="kb-panel" id="contract-card">
|
||
<div class="kb-contract-head"><h2>Mein Vertrag</h2>${pill}</div>
|
||
<h3>${escapeHtml(productLabelOf(data, sub.productKey))}</h3>
|
||
<p class="kb-price"><strong>${fmtEuro(sub.priceAmount / 100)}</strong><span>pro Monat</span></p>
|
||
<p class="abo-note">Gemäß § 19 UStG ohne Umsatzsteuer.</p>
|
||
<dl class="kb-contract-facts">
|
||
<div><dt>${cancelled ? 'Leistungen' : 'Abrechnung'}</dt><dd>${aboDateLine(sub)}</dd></div>
|
||
<div><dt>Laufzeit</dt><dd>${aboTermText(sub)}</dd></div>
|
||
${sub.offerText ? `<div><dt>Angebot</dt><dd>${escapeHtml(sub.offerText)}</dd></div>` : ''}
|
||
<div><dt>E-Mail-Speicher</dt><dd>${gb} GB${units ? ` (${units} Zusatzpaket${units === 1 ? '' : 'e'})` : ''}</dd></div>
|
||
</dl>
|
||
${aboFeaturesHtml(product?.features)}
|
||
<p class="abo-note"><a href="${escapeAttr(sub.termsUrl || '/abo-bedingungen.html')}" target="_blank" rel="noopener">Vertragsbedingungen</a>
|
||
· <a href="${escapeAttr(sub.termsPdfUrl || '/WEBklar-Abo-Vertragsunterlagen.pdf')}" target="_blank" rel="noopener">als PDF</a></p>
|
||
${sub.withdrawalOpen ? `<a class="kb-side-btn kb-withdraw" href="/widerrufen.html">Vertrag widerrufen <small>· bis ${fmtDayMonth(sub.withdrawalUntil)}</small></a>` : ''}
|
||
<div class="kb-contract-actions">
|
||
${sub.paymentProblem ? '<button type="button" class="kb-btn-primary" id="contract-manage">Zahlungsart aktualisieren</button>' : ''}
|
||
<button type="button" class="kb-side-btn" id="contract-mailbox">Postfach anfragen</button>
|
||
<a class="kb-side-btn" href="/kuendigen.html">Verträge hier kündigen</a>
|
||
</div>
|
||
<p class="error hidden" id="contract-error"></p>
|
||
</div>
|
||
${wartung ? `
|
||
<div class="kb-panel" id="hours-card">
|
||
<h3>Stundenkonto</h3>
|
||
<p class="kb-price"><strong>${fmtHours(l.balanceMinutes)}</strong><span>Arbeitszeit verfügbar</span></p>
|
||
<a href="#stunden" data-view="stunden">Stundenkonto</a> mit Verlauf ansehen
|
||
</div>` : ''}
|
||
${renderSwitchCard(data)}
|
||
<div class="kb-panel" id="storage-panel"></div>`
|
||
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 = '<p class="kb-label">Vielen Dank!</p><h3>Ihr Abo wird aktiviert…</h3><p class="kb-fine">Das dauert nur einen Moment.</p>'
|
||
await new Promise((r) => setTimeout(r, 2000))
|
||
data = await api('/api/abo')
|
||
}
|
||
aboState.data = data
|
||
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 = '<header class="kb-head"><h1>Stundenkonto</h1></header><div class="kb-panel"><p class="abo-note" style="margin:0">Ein Stundenkonto gibt es im Wartungs-Abo. Ihre Verträge finden Sie unter <a href="#vertraege" data-view="vertraege">Verträge</a>.</p></div>'
|
||
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 `<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-Postfächer</label>
|
||
<label><input type="checkbox" name="services" value="sonstiges"> Sonstiges</label>
|
||
</div>
|
||
</fieldset>
|
||
<div class="tk-row">
|
||
<div class="tk-field"><label for="tk-provider">Bisheriger Anbieter</label>
|
||
<input id="tk-provider" maxlength="200" placeholder="z. B. IONOS, STRATO"></div>
|
||
<div class="tk-field"><label for="tk-domain">Domain <small>(falls vorhanden)</small></label>
|
||
<input id="tk-domain" maxlength="255" placeholder="meine-firma.de" autocapitalize="off" spellcheck="false"></div>
|
||
</div>
|
||
<div class="tk-row">
|
||
<div class="tk-field"><label for="tk-amount">Rechnungsbetrag <small>(brutto, in €)</small></label>
|
||
<input id="tk-amount" type="number" min="0.01" step="0.01" inputmode="decimal" placeholder="119,88"></div>
|
||
<div class="tk-field"><label for="tk-months">Die Rechnung gilt für</label>
|
||
<select id="tk-months"><option value="">bitte wählen</option>
|
||
${TAKEOVER_MONTHS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></div>
|
||
<div class="tk-field"><label for="tk-paid-until">Bezahlt bis <small>(letzter Tag)</small></label>
|
||
<input id="tk-paid-until" type="date"></div>
|
||
</div>
|
||
<div class="tk-field"><label for="tk-message">Grund / Nachricht an uns</label>
|
||
<textarea id="tk-message" maxlength="4000" placeholder="z. B.: Domain und Postfach liegen noch bis Ende März bei IONOS, der Vertrag dort ist gekündigt."></textarea></div>
|
||
<div class="tk-field"><label for="tk-files">Rechnungsnachweis <small>(PDF, JPG oder PNG · bis zu ${limits.maxFiles} Dateien · zusammen max. ${maxMb} MB)</small></label>
|
||
<input id="tk-files" type="file" accept="application/pdf,image/jpeg,image/png" multiple></div>
|
||
<p class="abo-note" style="margin-top:-6px">Tipp: Laden Sie auch die Kündigungsbestätigung Ihres bisherigen Anbieters hoch – daraus sehen wir, wann der Vertrag dort endet.</p>
|
||
<div class="tk-field"><label for="tk-email">E-Mail-Adresse für unsere Antwort</label>
|
||
<input id="tk-email" type="email" maxlength="255" autocomplete="email" value="${escapeAttr(email)}"></div>
|
||
<details class="tk-conditions">
|
||
<summary>Bedingungen der Übernahmegutschrift</summary>
|
||
<ol>${TAKEOVER_CONDITIONS.map((c) => `<li>${c}</li>`).join('')}</ol>
|
||
</details>
|
||
<label class="abo-check"><input type="checkbox" id="tk-consent">
|
||
<span>Ich versichere, dass meine Angaben stimmen und die Rechnung auf mich bzw. mein Unternehmen ausgestellt ist. Die Bedingungen der Übernahmegutschrift habe ich gelesen. Angaben und Nachweise verwendet WEBklar zur Prüfung und Abwicklung des Antrags (<a href="/datenschutz.html#uebernahmegutschrift" target="_blank" rel="noopener">Datenschutz</a>).</span></label>
|
||
<p class="error hidden" id="tk-error"></p>
|
||
<div class="abo-actions">
|
||
<button type="submit" class="abo-btn" id="tk-submit">Antrag absenden</button>
|
||
<button type="button" class="kb-btn-outline" id="tk-cancel">Abbrechen</button>
|
||
</div>
|
||
</form>`
|
||
}
|
||
|
||
function readFileAsBase64(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader()
|
||
reader.onload = () => resolve(String(reader.result).split(',')[1] || '')
|
||
reader.onerror = () => reject(new Error(`„${file.name}“ konnte nicht gelesen werden.`))
|
||
reader.readAsDataURL(file)
|
||
})
|
||
}
|
||
|
||
async function collectTakeoverFiles(input, limits, { required }) {
|
||
const files = [...(input?.files || [])]
|
||
const mb = (bytes) => Math.round(bytes / 1024 / 1024)
|
||
if (required && !files.length) throw new Error('Bitte laden Sie die Rechnung Ihres bisherigen Anbieters hoch.')
|
||
if (files.length > limits.maxFiles) throw new Error(`Bitte höchstens ${limits.maxFiles} Dateien auf einmal hochladen.`)
|
||
const tooBig = files.find((f) => f.size > limits.maxFileBytes)
|
||
if (tooBig) throw new Error(`„${tooBig.name}“ ist größer als ${mb(limits.maxFileBytes)} MB.`)
|
||
if (files.reduce((s, f) => s + f.size, 0) > limits.maxTotalBytes) {
|
||
throw new Error(`Die Dateien sind zusammen größer als ${mb(limits.maxTotalBytes)} MB.`)
|
||
}
|
||
return Promise.all(files.map(async (f) => ({ name: f.name, data: await readFileAsBase64(f) })))
|
||
}
|
||
|
||
async function submitTakeoverForm(form, limits) {
|
||
const errEl = form.querySelector('#tk-error')
|
||
const btn = form.querySelector('#tk-submit')
|
||
hideError(errEl)
|
||
const value = (id) => form.querySelector(id).value.trim()
|
||
const payload = {
|
||
services: [...form.querySelectorAll('input[name="services"]:checked')].map((i) => i.value),
|
||
provider: value('#tk-provider'),
|
||
domain: value('#tk-domain'),
|
||
invoiceAmount: value('#tk-amount'),
|
||
billingMonths: Number(value('#tk-months')),
|
||
paidUntil: value('#tk-paid-until'),
|
||
message: value('#tk-message'),
|
||
email: value('#tk-email'),
|
||
consent: form.querySelector('#tk-consent').checked,
|
||
}
|
||
try {
|
||
if (!payload.services.length) throw new Error('Bitte wählen Sie, welche Leistung Sie bisher woanders bezahlt haben.')
|
||
if (!payload.provider) throw new Error('Bitte nennen Sie Ihren bisherigen Anbieter.')
|
||
if (!payload.invoiceAmount || !payload.billingMonths || !payload.paidUntil) {
|
||
throw new Error('Bitte geben Sie Rechnungsbetrag, Zeitraum und „Bezahlt bis“ an.')
|
||
}
|
||
if (!payload.consent) throw new Error('Bitte bestätigen Sie Ihre Angaben und die Bedingungen.')
|
||
btn.disabled = true
|
||
btn.textContent = 'Wird gesendet…'
|
||
payload.files = await collectTakeoverFiles(form.querySelector('#tk-files'), limits, { required: true })
|
||
await api('/api/abo/takeover-credits', { method: 'POST', body: JSON.stringify(payload) })
|
||
await loadTakeoverCard({
|
||
notice: `Vielen Dank! Ihr Antrag ist eingegangen. Die Eingangsbestätigung haben wir an ${payload.email} geschickt; unsere Antwort kommt ebenfalls per E-Mail.`,
|
||
})
|
||
} catch (err) {
|
||
showError(errEl, err.message)
|
||
btn.disabled = false
|
||
btn.textContent = 'Antrag absenden'
|
||
}
|
||
}
|
||
|
||
async function submitTakeoverReply(form, limits) {
|
||
const errEl = form.querySelector('.error')
|
||
const btn = form.querySelector('button[type="submit"]')
|
||
hideError(errEl)
|
||
btn.disabled = true
|
||
try {
|
||
const message = form.querySelector('textarea').value.trim()
|
||
const files = await collectTakeoverFiles(form.querySelector('input[type="file"]'), limits, { required: false })
|
||
if (!message && !files.length) throw new Error('Bitte schreiben Sie eine Antwort oder laden Sie einen Nachweis hoch.')
|
||
await api(`/api/abo/takeover-credits/${encodeURIComponent(form.dataset.replyFor)}/reply`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ message, files }),
|
||
})
|
||
await loadTakeoverCard({ notice: 'Danke für Ihre Antwort! Wir prüfen Ihren Antrag weiter und melden uns per E-Mail.' })
|
||
} catch (err) {
|
||
showError(errEl, err.message)
|
||
btn.disabled = false
|
||
}
|
||
}
|
||
|
||
function updateTakeoverBadge(requests) {
|
||
const badge = document.getElementById('takeover-badge')
|
||
if (!badge) return
|
||
const open = requests.filter((r) => r.status === 'rueckfrage').length
|
||
badge.textContent = open
|
||
badge.classList.toggle('hidden', !open)
|
||
}
|
||
|
||
async function loadTakeoverCard({ abo, notice = '' } = {}) {
|
||
const card = document.getElementById('takeover-card')
|
||
if (!card) return
|
||
if (abo) takeoverState.abo = abo
|
||
const active = Boolean(takeoverState.abo?.subscription?.active)
|
||
let data = { requests: [], limits: null }
|
||
if (takeoverState.abo?.subscription) {
|
||
try {
|
||
data = await api('/api/abo/takeover-credits')
|
||
} catch (err) {
|
||
card.innerHTML = `<div class="kb-panel kb-empty">Übernahmegutschrift konnte nicht geladen werden: ${escapeHtml(err.message)}</div>`
|
||
return
|
||
}
|
||
}
|
||
const requests = data.requests || []
|
||
updateTakeoverBadge(requests)
|
||
if (!active && !requests.length) {
|
||
card.innerHTML = '<div class="kb-panel"><p class="abo-note" style="margin:0">Die Übernahmegutschrift können Sie beantragen, sobald Ihr Abo läuft. <a href="#vertraege" data-view="vertraege">Zum Abo</a></p></div>'
|
||
return
|
||
}
|
||
|
||
card.innerHTML = `
|
||
<div class="kb-panel">
|
||
<p class="abo-note tk-intro">Sie haben Domain, Hosting oder E-Mail bei Ihrem bisherigen Anbieter schon im Voraus bezahlt? Den Anteil, den Sie ab Beginn Ihres Abos doppelt zahlen, schreiben wir Ihnen gut und verrechnen ihn mit Ihren nächsten Monatsgebühren. Schicken Sie uns dazu einfach die Rechnung.</p>
|
||
${notice ? `<div class="tk-notice">${escapeHtml(notice)}</div>` : ''}
|
||
${active ? `
|
||
<div class="abo-actions">
|
||
<button type="button" class="abo-btn" id="tk-start"${chatState.viewAs ? ' disabled' : ''}>Gutschrift beantragen</button>
|
||
${chatState.viewAs ? '<span class="abo-note">Im Ansichtsmodus kann kein Antrag gestellt werden.</span>' : ''}
|
||
</div>
|
||
${takeoverFormHtml(data.limits)}` : ''}
|
||
${requests.length ? `<div class="tk-list">${requests.map(renderTakeoverItem).join('')}</div>` : ''}
|
||
</div>`
|
||
|
||
const form = card.querySelector('#tk-form')
|
||
const startBtn = card.querySelector('#tk-start')
|
||
startBtn?.addEventListener('click', () => {
|
||
form.classList.remove('hidden')
|
||
startBtn.classList.add('hidden')
|
||
form.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||
})
|
||
card.querySelector('#tk-cancel')?.addEventListener('click', () => {
|
||
form.classList.add('hidden')
|
||
startBtn?.classList.remove('hidden')
|
||
})
|
||
form?.addEventListener('submit', (ev) => {
|
||
ev.preventDefault()
|
||
submitTakeoverForm(form, data.limits)
|
||
})
|
||
card.querySelectorAll('form[data-reply-for]').forEach((f) =>
|
||
f.addEventListener('submit', (ev) => {
|
||
ev.preventDefault()
|
||
submitTakeoverReply(f, data.limits)
|
||
}))
|
||
}
|
||
|
||
/* -------------------------------------------------------- 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', '<span class="abo-note">Im Ansichtsmodus kann das Passwort nicht geändert werden.</span>')
|
||
}
|
||
|
||
// 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).
|
||
const VIEW_HASHES = { projects: '', activity: 'fortschritt', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
|
||
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
|
||
const viewLoaders = { activity: loadCustomerActivity, chat: initChat }
|
||
const loadedViews = new Set()
|
||
|
||
function viewFromHash() {
|
||
const hash = window.location.hash.replace(/^#/, '')
|
||
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]()
|
||
}
|
||
if (name === 'chat') {
|
||
api('/api/chat/read', { method: 'POST' }).catch(() => {})
|
||
updateChatBadge(0)
|
||
}
|
||
if (updateHash) {
|
||
const hash = VIEW_HASHES[name]
|
||
window.history.replaceState(null, '', hash ? `#${hash}` : window.location.pathname)
|
||
}
|
||
// App-Ansicht: Titel, Zurueck-Pfeil, Tab und Scrollposition nachziehen (initAppShell)
|
||
document.dispatchEvent(new CustomEvent('kb:view', { detail: { name } }))
|
||
}
|
||
|
||
function initialsOf(name) {
|
||
const words = String(name || '').split(/\s+/).filter((w) => /^[\p{L}\p{N}]/u.test(w))
|
||
const letters = words.length > 1 ? words[0][0] + words[1][0] : (words[0] || '').slice(0, 2)
|
||
return letters.toUpperCase()
|
||
}
|
||
|
||
async function initDashboardPage() {
|
||
const list = document.getElementById('projects')
|
||
const loading = document.getElementById('loading')
|
||
const empty = document.getElementById('empty')
|
||
const loadError = document.getElementById('load-error')
|
||
|
||
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) {
|
||
const target = new URL(loginUrl, window.location.origin)
|
||
// Passwort auf einem anderen Gerät geändert: Hinweis auf der Anmeldeseite
|
||
if (me.reason === 'password_changed' && me.message) target.searchParams.set('error', me.message)
|
||
window.location.href = target.pathname + target.search
|
||
return
|
||
}
|
||
if (me.role === 'admin' && !me.customer) {
|
||
window.location.href = '/admin.html'
|
||
return
|
||
}
|
||
if (!me.customer) {
|
||
window.location.href = loginUrl
|
||
return
|
||
}
|
||
|
||
const customer = me.customer
|
||
chatState.viewAs = Boolean(me.viewAs)
|
||
takeoverState.customer = customer
|
||
renderViewAsBanner(me)
|
||
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(' · ')
|
||
document.getElementById('customer-avatar').textContent = initialsOf(displayName)
|
||
|
||
api('/api/contact')
|
||
.then((c) => {
|
||
document.getElementById('contact-name').textContent = c.name
|
||
document.getElementById('contact-response').textContent = c.responseTime
|
||
})
|
||
.catch(() => {})
|
||
|
||
const aboParam = getQueryParam('abo')
|
||
if (aboParam) window.history.replaceState(null, '', `/dashboard.html${window.location.hash}`)
|
||
loadContracts({ afterCheckout: aboParam === 'success' })
|
||
loadCustomerInvoices()
|
||
|
||
const initialView = viewFromHash()
|
||
// Alte Hashes (#wartung, #abo) gleich auf den neuen (#vertraege) umschreiben
|
||
if (initialView) showView(initialView, { updateHash: Boolean(HASH_ALIASES[window.location.hash.replace(/^#/, '')]) })
|
||
|
||
const [{ projects }, { features }] = await Promise.all([
|
||
api('/api/projects'),
|
||
api('/api/features'),
|
||
])
|
||
|
||
loading.classList.add('hidden')
|
||
document.getElementById('projects-count').textContent = projects.length || ''
|
||
if (projects.length > 1) {
|
||
document.getElementById('projects-subtitle').textContent = 'Deine Websites – Vorschau, Status und Details.'
|
||
}
|
||
|
||
if (projects.length) {
|
||
list.classList.remove('hidden')
|
||
list.innerHTML = ''
|
||
for (const project of projects) {
|
||
list.appendChild(renderProjectCard(project, features))
|
||
}
|
||
} else {
|
||
empty.classList.remove('hidden')
|
||
}
|
||
|
||
// Chat-Badge global pollen (auch wenn der Chat nicht offen ist)
|
||
pollChatBadge()
|
||
chatState.badgeTimer = setInterval(pollChatBadge, 45000)
|
||
} catch (err) {
|
||
loading.classList.add('hidden')
|
||
if (err.status === 401 || err.message.includes('Nicht angemeldet')) {
|
||
window.location.href = loginUrl
|
||
return
|
||
}
|
||
showError(loadError, err.message)
|
||
}
|
||
}
|
||
|
||
/* ------------------------------------------------------- App-Ansicht (Handy) --- */
|
||
// Auf dem Handy wird der Kundenbereich zur App: die Navigation ist die Tab-Leiste unten, die
|
||
// Kopfzeile zeigt beim Scrollen den Seitentitel, Stundenkonto/Bestellung und Gutschrift sind
|
||
// Unterseiten der Wartung (mit Zurueck), das Konto ist ein Bottom-Sheet. Styles: kundenbereich.css.
|
||
// Gleiche Bedingung wie der Abschnitt "App-Ansicht" in kundenbereich.css (auch Handys quer)
|
||
const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (max-height: 500px)')
|
||
const coarsePointer = window.matchMedia('(pointer: coarse)')
|
||
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
|
||
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
|
||
const APP_PARENT = { stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
|
||
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', activity: 'Fortschritt', 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 = '<svg class="kb-inline-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15V3M8 7l4-4 4 4"/><path d="M6 11H5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-8a1 1 0 0 0-1-1h-1"/></svg>'
|
||
document.querySelectorAll('[data-install]').forEach((box) => {
|
||
const dismissible = box.hasAttribute('data-install-dismissible')
|
||
const show = offer && !(dismissible && dismissed)
|
||
box.classList.toggle('hidden', !show)
|
||
if (!show) return
|
||
box.innerHTML = `
|
||
<img class="kb-install-icon" src="/img/app-icon-192.png" alt="" width="44" height="44">
|
||
<div class="kb-install-text">
|
||
<strong>WEBklar als App</strong>
|
||
<span>${canPrompt
|
||
? 'Mit einem Tipp auf deinem Homescreen – ohne App-Store.'
|
||
: `Tippe auf ${shareIcon} „Teilen“ und dann auf „Zum Home-Bildschirm“.`}</span>
|
||
</div>
|
||
${canPrompt ? '<button type="button" class="kb-install-go" data-install-go>Installieren</button>' : ''}
|
||
${dismissible ? '<button type="button" class="kb-install-x" data-install-dismiss aria-label="Hinweis ausblenden"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg></button>' : ''}`
|
||
})
|
||
}
|
||
|
||
/* Konto: Bottom-Sheet (Handy) bzw. Popover (Desktop) mit Ansprechpartner, 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 || ''
|
||
document.getElementById('kb-sheet-avatar').textContent = avatar.textContent
|
||
renderInstallOffers()
|
||
}
|
||
if (typeof sheet.showModal !== 'function') {
|
||
// Sehr alte Browser ohne <dialog>: das Menü klappt als einfache Box unter der Kopfzeile auf (kb-no-sheet)
|
||
document.body.classList.add('kb-no-sheet')
|
||
const show = (on) => {
|
||
if (on) fill()
|
||
sheet.toggleAttribute('open', on)
|
||
}
|
||
avatar.addEventListener('click', () => show(!sheet.hasAttribute('open')))
|
||
document.addEventListener('click', (ev) => {
|
||
if (ev.target.closest('[data-open-account]')) show(true)
|
||
else if (ev.target.closest('#kb-sheet [data-sheet-close], #kb-sheet [data-view]')) show(false)
|
||
})
|
||
return
|
||
}
|
||
const panel = sheet.querySelector('.kb-sheet-panel')
|
||
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||
// Zaehler gegen verspaetetes Schliessen, wenn das Sheet gleich wieder geoeffnet wurde
|
||
let closeToken = 0
|
||
|
||
const open = () => {
|
||
if (sheet.open) return
|
||
closeToken += 1
|
||
fill()
|
||
sheet.classList.remove('is-closing')
|
||
panel.style.transform = ''
|
||
sheet.showModal()
|
||
document.documentElement.classList.add('kb-sheet-open')
|
||
}
|
||
const finish = () => {
|
||
sheet.classList.remove('is-closing')
|
||
panel.style.transform = ''
|
||
if (sheet.open) sheet.close()
|
||
}
|
||
const close = () => {
|
||
if (!sheet.open || sheet.classList.contains('is-closing')) return
|
||
if (reducedMotion.matches) return finish()
|
||
const token = ++closeToken
|
||
const done = () => {
|
||
if (token === closeToken) finish()
|
||
}
|
||
sheet.classList.add('is-closing')
|
||
panel.style.transform = ''
|
||
panel.addEventListener('transitionend', done, { once: true })
|
||
setTimeout(done, 400)
|
||
}
|
||
|
||
avatar.addEventListener('click', open)
|
||
document.addEventListener('click', (ev) => {
|
||
if (ev.target.closest('[data-open-account]')) open()
|
||
})
|
||
// Escape bzw. Android-Zurück: mit Animation schließen
|
||
sheet.addEventListener('cancel', (ev) => {
|
||
ev.preventDefault()
|
||
close()
|
||
})
|
||
sheet.addEventListener('close', () => {
|
||
sheet.classList.remove('is-closing')
|
||
document.documentElement.classList.remove('kb-sheet-open')
|
||
// Fokus zurück aufs Profilbild – außer die geöffnete Ansicht hat ihn schon gesetzt ("Passwort ändern")
|
||
const active = document.activeElement
|
||
if (!active || active === document.body || sheet.contains(active)) avatar.focus({ preventScroll: true })
|
||
})
|
||
// Klick auf den Hintergrund, das X oder eine Ansicht (z. B. "Nachricht schreiben") schließt
|
||
sheet.addEventListener('click', (ev) => {
|
||
if (ev.target === sheet || ev.target.closest('[data-sheet-close], [data-view]')) close()
|
||
})
|
||
|
||
// Nach unten wischen schließt das Sheet (Griff und Kopf des Sheets)
|
||
let drag = null
|
||
panel.addEventListener('pointerdown', (ev) => {
|
||
if (!isAppLayout() || !ev.target.closest('.kb-sheet-grip, .kb-sheet-me') || ev.target.closest('button')) return
|
||
drag = { y: ev.clientY, t: ev.timeStamp, dy: 0 }
|
||
panel.setPointerCapture(ev.pointerId)
|
||
panel.classList.add('is-dragging')
|
||
})
|
||
panel.addEventListener('pointermove', (ev) => {
|
||
if (!drag) return
|
||
drag.dy = Math.max(0, ev.clientY - drag.y)
|
||
panel.style.transform = `translateY(${drag.dy}px)`
|
||
})
|
||
const endDrag = (ev) => {
|
||
if (!drag) return
|
||
const { dy, t } = drag
|
||
drag = null
|
||
panel.classList.remove('is-dragging')
|
||
if (dy > 90 || dy / Math.max(1, ev.timeStamp - t) > 0.6) close()
|
||
else panel.style.transform = ''
|
||
}
|
||
panel.addEventListener('pointerup', endDrag)
|
||
panel.addEventListener('pointercancel', endDrag)
|
||
}
|
||
|
||
function initAppShell() {
|
||
// Karten der Seitenleiste mit Anker merken, damit sie am Desktop an ihren Platz zurueckkehren
|
||
for (const [selector, slotId] of [['.kb-side .kb-contact', 'kb-contact-slot'], ['#abo-card', 'kb-abo-slot']]) {
|
||
const el = document.querySelector(selector)
|
||
const slot = document.getElementById(slotId)
|
||
if (!el || !slot) continue
|
||
const anchor = document.createComment(`Platz: ${selector}`)
|
||
el.before(anchor)
|
||
appShell.sideCards.push({ el, slot, anchor })
|
||
}
|
||
placeSideCards()
|
||
document.body.dataset.kbView = appShell.view
|
||
|
||
document.addEventListener('kb:view', (ev) => onAppViewShown(ev.detail.name))
|
||
document.addEventListener('kb:abo', (ev) => {
|
||
const slot = document.getElementById('kb-abo-slot')
|
||
slot?.querySelector(':scope > .kb-empty')?.remove()
|
||
if (!ev.detail) slot?.insertAdjacentHTML('afterbegin', '<p class="kb-empty">Ihre Verträge konnten gerade nicht geladen werden. Bitte später noch einmal versuchen.</p>')
|
||
updateVertraegeBadge()
|
||
updateAppBar()
|
||
})
|
||
const aboCard = document.getElementById('abo-card')
|
||
if (aboCard) new MutationObserver(updateVertraegeBadge).observe(aboCard, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] })
|
||
|
||
// Zurueck: ueber den Verlauf, falls die Unterseite ihn angelegt hat, sonst direkt zu den Verträgen
|
||
document.getElementById('kb-back')?.addEventListener('click', () => {
|
||
if (window.history.state?.kbSub) window.history.back()
|
||
else showView(APP_PARENT[appShell.view] || 'projects')
|
||
})
|
||
window.addEventListener('popstate', () => {
|
||
const name = viewFromHash() || 'projects'
|
||
if (name === appShell.view) return
|
||
appShell.fromHistory = true
|
||
try {
|
||
showView(name, { updateHash: false })
|
||
} finally {
|
||
appShell.fromHistory = false
|
||
}
|
||
})
|
||
|
||
// Scrollposition je Ansicht und Zustand der Kopfzeile (einmal pro Frame)
|
||
let scrollQueued = false
|
||
window.addEventListener('scroll', () => {
|
||
if (scrollQueued) return
|
||
scrollQueued = true
|
||
requestAnimationFrame(() => {
|
||
scrollQueued = false
|
||
appShell.scroll[appShell.view] = window.scrollY
|
||
updateBarState()
|
||
})
|
||
}, { passive: true })
|
||
|
||
// Tastatur offen (Touch): Tab-Leiste ausblenden. Auf Android schrumpft die Seite mit der Tastatur
|
||
// (interactive-widget=resizes-content), dort nutzt der Chat den frei werdenden Platz. Auf iOS liegt
|
||
// die Tastatur ueber der Seite und Safari schiebt das Feld selbst hoch – dort darf sich beim Fokus
|
||
// keine Hoehe aendern (kb-ios, siehe kundenbereich.css), sonst rutscht das Feld wieder aus dem Bild.
|
||
if (isIosDevice()) document.body.classList.add('kb-ios')
|
||
document.addEventListener('focusin', (ev) => {
|
||
if (!coarsePointer.matches || !ev.target.matches?.(TEXT_INPUT)) return
|
||
document.body.classList.add('kb-typing')
|
||
// Nach der Tastatur-Animation (iOS ca. 350 ms) pruefen, ob die Chat-Eingabe sichtbar ist
|
||
if (ev.target.closest('#chat-composer')) setTimeout(revealChatComposer, 350)
|
||
})
|
||
document.addEventListener('focusout', () => {
|
||
setTimeout(() => {
|
||
if (!document.activeElement?.matches?.(TEXT_INPUT)) document.body.classList.remove('kb-typing')
|
||
}, 80)
|
||
})
|
||
|
||
// Schliesst die Tastatur, ohne dass das Feld den Fokus verliert (Android-Zurueck-Geste),
|
||
// kommt die Tab-Leiste zurueck; tippt man erneut hinein, verschwindet sie wieder.
|
||
const vv = window.visualViewport
|
||
if (vv) {
|
||
let fullHeight = vv.height
|
||
let width = vv.width
|
||
let keyboardOpen = false
|
||
vv.addEventListener('resize', () => {
|
||
if (Math.abs(vv.width - width) > 1) {
|
||
width = vv.width
|
||
fullHeight = vv.height
|
||
}
|
||
fullHeight = Math.max(fullHeight, vv.height)
|
||
const open = vv.height < fullHeight - 150
|
||
if (open !== keyboardOpen) {
|
||
keyboardOpen = open
|
||
if (!open) document.body.classList.remove('kb-typing')
|
||
else if (coarsePointer.matches && document.activeElement?.matches?.(TEXT_INPUT)) document.body.classList.add('kb-typing')
|
||
}
|
||
if (open) requestAnimationFrame(() => requestAnimationFrame(revealChatComposer))
|
||
})
|
||
}
|
||
// Wird der Verlauf kleiner (Tastatur, Tab-Leiste), bleibt die neueste Nachricht sichtbar – sofern
|
||
// man unten war (data-stick setzt ChatComposer.trackScroll)
|
||
const messages = document.getElementById('chat-messages')
|
||
if (messages && typeof ResizeObserver !== 'undefined') {
|
||
new ResizeObserver(() => {
|
||
if (messages.dataset.stick !== 'false') messages.scrollTop = messages.scrollHeight
|
||
}).observe(messages)
|
||
}
|
||
|
||
// Installations-Hinweis
|
||
window.addEventListener('beforeinstallprompt', (ev) => {
|
||
ev.preventDefault()
|
||
appShell.installPrompt = ev
|
||
renderInstallOffers()
|
||
})
|
||
window.addEventListener('appinstalled', () => {
|
||
appShell.installPrompt = null
|
||
renderInstallOffers()
|
||
})
|
||
document.addEventListener('click', async (ev) => {
|
||
if (ev.target.closest('[data-install-dismiss]')) {
|
||
try {
|
||
localStorage.setItem('kb-install-dismissed', '1')
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
renderInstallOffers()
|
||
} else if (ev.target.closest('[data-install-go]') && appShell.installPrompt) {
|
||
const prompt = appShell.installPrompt
|
||
appShell.installPrompt = null
|
||
await prompt.prompt()
|
||
renderInstallOffers()
|
||
}
|
||
})
|
||
|
||
onMediaChange(appLayout, () => {
|
||
placeSideCards()
|
||
if (!isAppLayout() && appShell.view === 'projekt') showView('projects')
|
||
updateAppBar()
|
||
renderInstallOffers()
|
||
})
|
||
|
||
initAccountSheet()
|
||
renderInstallOffers()
|
||
updateAppBar()
|
||
|
||
// Nach der Bestellung bei Stripe: Bestaetigung steht in der Ansicht "Verträge" (Handy und Desktop)
|
||
if (getQueryParam('abo') === 'success') showView('vertraege')
|
||
}
|
||
|
||
// iOS zoomt beim Tippen in ein Feld hinein, wenn dessen Schrift effektiv unter 16 px liegt – z. B. bei
|
||
// verkleinertem Seitenzoom in Safari, obwohl die Felder 16 px haben. maximum-scale=1 verhindert nur
|
||
// diesen automatischen Zoom; mit zwei Fingern zoomen bleibt auf iOS moeglich (WebKit ignoriert die
|
||
// Sperre dafuer seit iOS 10). Nur iOS: Android wuerde damit jedes Zoomen sperren.
|
||
function preventIosFocusZoom() {
|
||
const meta = document.querySelector('meta[name="viewport"]')
|
||
if (meta && isIosDevice() && !/maximum-scale/.test(meta.content)) meta.content += ', maximum-scale=1'
|
||
}
|
||
preventIosFocusZoom()
|
||
|
||
// App-Installation (Homescreen) und Offline-Seite; zwischengespeichert wird nur die Offline-Seite (sw.js)
|
||
if ('serviceWorker' in navigator && window.isSecureContext) {
|
||
window.addEventListener('load', () => {
|
||
navigator.serviceWorker.register('/sw.js').catch((err) => console.warn('Service Worker:', err.message))
|
||
})
|
||
}
|
||
|
||
window.initLoginPage = initLoginPage
|
||
window.initDashboardPage = initDashboardPage
|