Kundenbereich: neues Design mit Seitenleiste (Design "Kundenbereich Neu")

- Kopfzeile mit Firmenname, Kundennr., Initialen-Avatar und Abmelden
- Seitenleiste: Navigation Projekte/Fortschritt/Rechnungen/Chat mit Zaehlern
  (offene Rechnungen, ungelesene Nachrichten), "Dein Ansprechpartner"
  (Mitarbeiter des juengsten zugewiesenen Tickets, sonst WEBklar-Team) und
  Wartungs-Abo-Karte (Angebot bzw. Status, Stunden, naechste Abbuchung,
  Abo verwalten, Uebernahmegutschrift, Widerruf, Mindestlaufzeit, Kuendigen)
- Projektkarte: Vorschau mit "Website oeffnen", Status, Vorschau-Adresse,
  Live-Domain, letzte Aenderung, Schnellzugriffe, Projektdetails
- Abo-Bestellung und Stundenkonto/Verlauf sowie Uebernahmegutschrift als
  eigene Ansichten; Direktlinks #abo/#gutschrift (auch ueber den Login)
- Pflichtlinks (Kuendigungsbutton, Widerruf, Bedingungen, Datenschutz,
  Impressum) als Fusszeile; Systemschriften statt externer Fonts
- Mobil: Navigation im 2x2-Raster, kein horizontales Scrollen
- API: /api/contact, updatedAt in /api/projects; Mail-Links auf Ansichten

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-25 21:03:14 +00:00
1 parent bb2f46e191
commit 4593065f79
7 files changed
+802 -438

No files matched your search

+320 -187
View File
@@ -85,7 +85,7 @@ function featuresForProject(features, projectId) {
const PREVIEW_BASE_WIDTH = 1280
function setupPreviewFrame(frame) {
const view = frame.querySelector('.preview-frame__view')
const view = frame.querySelector('iframe')
if (!view) return
const apply = () => {
const w = frame.clientWidth
@@ -99,38 +99,72 @@ function setupPreviewFrame(frame) {
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 = 'project-card'
const projectFeatures = featuresForProject(features, project.id)
const featureHtml = projectFeatures.length
? `<div class="feature-tags">${projectFeatures.map((f) => `<span class="feature-tag">${escapeHtml(f.featureKey)}</span>`).join('')}</div>`
: ''
li.className = 'kb-project'
const ready = isProjectPreviewReady(project)
const url = project.previewUrl ? escapeAttr(project.previewUrl) : ''
const previewHtml = ready && url
? `
<a class="preview-frame" href="${url}" target="_blank" rel="noreferrer" data-preview-frame aria-label="Projekt-Webseite in neuem Tab öffnen">
<iframe class="preview-frame__view" src="${url}" loading="lazy" scrolling="no" tabindex="-1" aria-hidden="true"></iframe>
<span class="preview-frame__hint">Webseite öffnen ↗</span>
</a>`
: `<div class="preview-frame preview-frame--empty"><span>${project.previewUrl ? 'Vorschau wird vorbereitet …' : 'Noch keine Webseite veröffentlicht'}</span></div>`
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 = `
${previewHtml}
<h2>${escapeHtml(project.projectName || project.subdomain || 'Projekt')}</h2>
<dl>
<dt>Subdomain</dt><dd>${escapeHtml(project.subdomain || '–')}</dd>
<dt>Live-Domain</dt><dd>${project.liveDomain ? escapeHtml(project.liveDomain) : '–'}</dd>
<dt>Status</dt><dd>${escapeHtml(project.status || '–')}</dd>
</dl>
${ready && url ? `<p class="preview-open"><a class="btn btn-green" href="${url}" target="_blank" rel="noreferrer">Projekt-Webseite öffnen ↗</a></p>` : ''}
${featureHtml}
<div class="project-details-toggle">
<button type="button" class="portal-btn-outline" data-details-btn>Details anzeigen</button>
${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" data-details-btn>Projektdetails anzeigen</button>
<div class="kb-details hidden" data-details-box></div>
</div>
<div class="project-details hidden" data-details-box></div>
`
const frame = li.querySelector('[data-preview-frame]')
@@ -142,7 +176,7 @@ function renderProjectCard(project, features) {
btn.addEventListener('click', async () => {
const isHidden = box.classList.contains('hidden')
box.classList.toggle('hidden', !isHidden)
btn.textContent = isHidden ? 'Details ausblenden' : 'Details anzeigen'
btn.textContent = isHidden ? 'Projektdetails ausblenden' : 'Projektdetails anzeigen'
if (isHidden && !loaded) {
loaded = true
await loadProjectDetails(project, box)
@@ -271,7 +305,10 @@ async function canAccessPreviewUrl(url) {
}
async function resolvePostLoginTarget() {
const next = safeRedirectUrl(getQueryParam('next'))
// 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
}
@@ -388,7 +425,7 @@ function renderViewAsBanner(me) {
banner.classList.remove('hidden')
banner.innerHTML = `
<span>Admin-Ansicht: Du siehst das Portal als <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong>.</span>
<button type="button" class="portal-btn-outline" id="exit-viewas-btn">Zurück zur Admin-Übersicht</button>
<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(() => {})
@@ -402,6 +439,13 @@ async function loadCustomerInvoices() {
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
@@ -411,7 +455,7 @@ async function loadCustomerInvoices() {
return
}
body.innerHTML = `
<table class="simple-table">
<div class="kb-table-wrap"><table class="simple-table">
<thead><tr><th>Nr.</th><th>Datum</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr></thead>
<tbody>
${data.invoices.map((i) => `
@@ -427,7 +471,7 @@ async function loadCustomerInvoices() {
</td>
</tr>`).join('')}
</tbody>
</table>`
</table></div>`
} catch (err) {
body.innerHTML = `<p class="muted">Rechnungen konnten nicht geladen werden: ${escapeHtml(err.message)}</p>`
}
@@ -515,7 +559,7 @@ async function pollChatMessages() {
}
function isChatTabActive() {
return document.querySelector('.portal-tab[data-tab="chat"]')?.classList.contains('active')
return Boolean(document.getElementById('view-chat')?.classList.contains('active'))
}
async function sendChatMessage() {
@@ -581,7 +625,7 @@ async function pollChatBadge() {
/* -------------------------------------------------------------- Dashboard --- */
// ---------------------------------------------------------------------------
// Wartungs-Abo (Startseite): Angebot + Bestellung bzw. Stundenkonto
// Wartungs-Abo: Karte in der Seitenleiste + Ansicht (Bestellung bzw. Stundenkonto)
// ---------------------------------------------------------------------------
const ABO_FEATURES = [
@@ -591,6 +635,9 @@ const ABO_FEATURES = [
['1 Stunde Arbeitszeit pro Monat', 'nicht genutzte Zeit bleibt 12 Monate verfügbar'],
]
// Kurzfassung fuer die Karte in der Seitenleiste
const ABO_SIDE_FEATURES = ['Betrieb & Pflege, Server in Deutschland', '1 Domain inklusive', '1 E-Mail-Postfach, 5 GB', '1 Std. Arbeitszeit pro Monat']
function fmtHours(min) {
const n = Math.max(0, Number(min || 0))
const h = Math.floor(n / 60)
@@ -598,6 +645,15 @@ function fmtHours(min) {
return m ? `${h}:${String(m).padStart(2, '0')} h` : `${h} h`
}
function euroShort(cents) {
return cents % 100 === 0 ? `${cents / 100} €` : fmtEuro(cents / 100)
}
function fmtDayMonth(value) {
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
}
function aboFeaturesHtml() {
return `<ul class="abo-features">${ABO_FEATURES.map(
([t, d]) => `<li>${t}<small>${d}</small></li>`
@@ -611,23 +667,87 @@ function lateStartDate() {
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
}
function renderAboOffer(card, data, { ended } = {}) {
const available = data.available
// Individuelles Angebot (vom WEBklar-Team angelegt), z. B. erste 2 Monate je 25 €
// Laufzeit-Hinweis unter der Abo-Karte (§ 8 der Bedingungen)
function aboTermText(sub) {
if (sub.cancelAtPeriodEnd) {
return `Gekündigt zum ${fmtDateShort(sub.cancelAt || sub.currentPeriodEnd)}. Bis dahin bleiben alle Leistungen verfügbar.`
}
if (sub.minTermEnd && new Date(sub.minTermEnd) > new Date()) {
return `Mindestlaufzeit bis ${fmtDateShort(sub.minTermEnd)}. Kündigung frühestens zu diesem Termin, mit einem Monat Frist.`
}
return 'Monatlich kündbar zum Ende des Abrechnungsmonats.'
}
/* Seitenleiste: Angebot (noch kein Abo) */
function renderAboSideOffer(card, data, { ended }) {
const offer = data.offer
const offerPrice = offer
? (offer.monthlyPriceCents % 100 === 0 ? `${offer.monthlyPriceCents / 100} €` : fmtEuro(offer.monthlyPriceCents / 100))
: ''
card.innerHTML = `
<div class="abo-head">
<div>
<p class="abo-kicker">${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}</p>
<h2 class="abo-title">Wartungs-Abo</h2>
</div>
${offer
? `<div class="abo-price"><strong>${offerPrice}</strong><span>${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50 € · 12 Monate Mindestlaufzeit</span></div>`
: '<div class="abo-price"><strong>50 €</strong><span>pro Monat · 12 Monate Mindestlaufzeit</span></div>'}
</div>
<p class="kb-label">${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}</p>
<h3>Wartungs-Abo</h3>
${offer ? '<span class="kb-offer-tag">Ihr persönliches Angebot</span>' : ''}
<p class="kb-price">${offer
? `<strong>${euroShort(offer.monthlyPriceCents)}</strong><span>${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50 €</span>`
: '<strong>50 €</strong><span>pro Monat</span>'}</p>
<ul class="kb-checks">${ABO_SIDE_FEATURES.map((f) => `<li>${escapeHtml(f)}</li>`).join('')}</ul>
<button type="button" class="kb-btn-primary kb-btn-block" data-view="abo"${data.available ? '' : ' disabled'}>Abo abschließen</button>
<p class="kb-fine">${data.available
? '12 Monate Mindestlaufzeit. Gemäß § 19 UStG ohne Umsatzsteuer.'
: 'Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'}</p>`
}
/* Seitenleiste: laufendes Abo */
function renderAboSideActive(card, data) {
const sub = data.subscription
const l = data.ledger || { balanceMinutes: 0 }
let status
if (sub.serviceStartsAt) status = `<span class="kb-status kb-status-wait">beginnt am ${fmtDateShort(sub.serviceStartsAt)}</span>`
else if (sub.paymentProblem) status = '<span class="kb-status kb-status-bad">Zahlung offen</span>'
else if (sub.cancelAtPeriodEnd) status = '<span class="kb-status kb-status-off">gekündigt</span>'
else status = '<span class="kb-status kb-status-ok">aktiv</span>'
const dateLine = sub.serviceStartsAt
? `erste Abbuchung ${fmtDateShort(sub.serviceStartsAt)}`
: sub.cancelAtPeriodEnd
? `Leistungen bis ${fmtDateShort(sub.cancelAt || sub.currentPeriodEnd)}`
: `nächste Abbuchung ${fmtDateShort(sub.currentPeriodEnd)}`
card.innerHTML = `
<p class="kb-label">Ihr Wartungs-Abo</p>
${status}
<button type="button" class="kb-hours" data-view="abo" 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-sub">${dateLine}</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">${sub.paymentProblem ? 'Zahlungsart aktualisieren ↗' : 'Abo verwalten &amp; Rechnungen ↗'}</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" 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>
${sub.cancelAtPeriodEnd ? '' : '<p class="kb-fine"><a href="/kuendigen.html">Vertrag hier kündigen</a></p>'}
<p class="error hidden" id="abo-error"></p>`
card.querySelector('#abo-manage')?.addEventListener('click', async (ev) => {
const btn = ev.currentTarget
btn.disabled = true
try {
const { url } = await api('/api/abo/portal', { method: 'POST' })
window.location.href = url
} catch (err) {
showError(card.querySelector('#abo-error'), err.message)
btn.disabled = false
}
})
}
/* Ansicht "Wartungs-Abo": Bestellung (Bestellübersicht direkt über dem Bestellbutton, § 312j BGB) */
function renderAboOrderView(view, data) {
const available = data.available
const offer = data.offer
view.innerHTML = `
<header class="kb-head">
<h1>Wartungs-Abo</h1>
<p>Betrieb, Pflege und Support für Ihre Website – ${offer ? 'mit Ihrem persönlichen Angebot' : 'für 50 € im Monat'}.</p>
</header>
${offer ? `
<div class="abo-offer-box">
<strong>Ihr persönliches Angebot</strong>
@@ -635,15 +755,12 @@ function renderAboOffer(card, data, { ended } = {}) {
${offer.note ? `<p class="abo-note">${escapeHtml(offer.note)}</p>` : ''}
<p class="abo-note">Gültig bis ${fmtDateShort(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt</p>
</div>` : ''}
${aboFeaturesHtml()}
${available ? '<p class="abo-note" style="margin:-6px 0 16px">Schon bei einem anderen Anbieter für Domain, Hosting oder E-Mail bezahlt? Nach dem Abschluss können Sie eine Übernahmegutschrift beantragen – wir rechnen die doppelt bezahlte Zeit anteilig an.</p>' : ''}
<div class="abo-actions">
<button type="button" class="abo-btn" id="abo-start" ${available ? '' : 'disabled'}>Abo abschließen</button>
<span class="abo-note">${available
? 'Mindestlaufzeit 12 Monate, danach monatlich kündbar. Zahlung per Kreditkarte oder SEPA-Lastschrift. Gemäß § 19 UStG ohne Umsatzsteuer.'
: 'Die Online-Bestellung wird gerade eingerichtet – schreiben Sie uns gern im Chat.'}</span>
<div class="kb-panel">
${aboFeaturesHtml()}
<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="abo-order hidden" id="abo-order">
${available ? `
<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">
@@ -672,26 +789,19 @@ function renderAboOffer(card, data, { ended } = {}) {
<p class="abo-note">Es gilt das gesetzliche Mängelhaftungsrecht. Einzelheiten: <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen</a> · <a href="/datenschutz.html" target="_blank" rel="noopener">Datenschutz</a></p>
</div>
<p class="error hidden" id="abo-error"></p>
<div class="abo-actions">
<button type="button" class="abo-btn" id="abo-buy">Zahlungspflichtig bestellen</button>
<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>`
</div>` : `
<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>`}`
const startBtn = card.querySelector('#abo-start')
const order = card.querySelector('#abo-order')
startBtn?.addEventListener('click', () => {
order.classList.remove('hidden')
startBtn.classList.add('hidden')
order.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
card.querySelector('#abo-buy')?.addEventListener('click', async (ev) => {
view.querySelector('#abo-buy')?.addEventListener('click', async (ev) => {
const btn = ev.currentTarget
const errEl = card.querySelector('#abo-error')
const errEl = view.querySelector('#abo-error')
hideError(errEl)
const acceptTerms = card.querySelector('#abo-terms').checked
const startChoice = card.querySelector('input[name="abo-start-choice"]:checked')?.value || ''
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
@@ -716,20 +826,10 @@ function renderAboOffer(card, data, { ended } = {}) {
})
}
function renderAboActive(card, data) {
/* Ansicht "Wartungs-Abo": Stundenkonto, Verlauf, Vertrag (Bedingungen § 4: jederzeit im Portal einsehbar) */
function renderAboAccountView(view, data) {
const sub = data.subscription
const l = data.ledger || { balanceMinutes: 0, credits: [], usage: [] }
let statusLine
if (sub.serviceStartsAt) {
statusLine = `<span class="pill pill-muted">beginnt am ${fmtDateShort(sub.serviceStartsAt)}</span>`
} else if (sub.cancelAtPeriodEnd) {
statusLine = `<span class="pill pill-muted">gekündigt zum ${fmtDateShort(sub.cancelAt || sub.currentPeriodEnd)}</span>`
} else if (sub.paymentProblem) {
statusLine = '<span class="pill pill-red">Zahlung offen</span>'
} else {
statusLine = `<span class="pill pill-ok">aktiv</span> <span class="abo-note">nächste Abbuchung ${fmtDateShort(sub.currentPeriodEnd)}</span>`
}
const 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>
@@ -738,71 +838,59 @@ function renderAboActive(card, data) {
<tr><td>${fmtDateShort(c.grantedAt)}</td><td>Monatskontingent</td>
<td>+${fmtHours(c.minutes)} <span class="muted">${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDateShort(c.expiresAt)}`}</span></td></tr>`).join('')
card.innerHTML = `
<div class="abo-head">
<div>
<p class="abo-kicker">Ihr Wartungs-Abo</p>
<h2 class="abo-title">Stundenkontingent</h2>
</div>
<div>${statusLine}</div>
</div>
${sub.paymentProblem ? '<div class="abo-alert">Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisieren Sie Ihre Zahlungsart, damit Ihre Website online bleibt.</div>' : ''}
view.innerHTML = `
<header class="kb-head">
<h1>Wartungs-Abo</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“ in der Seitenleiste, damit Ihre Website online bleibt.</div>' : ''}
${sub.serviceStartsAt ? `<div class="abo-alert">Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist am ${fmtDateShort(sub.serviceStartsAt)}. Dann wird auch zum ersten Mal abgebucht, und Ihre erste Stunde steht bereit.</div>` : ''}
<div class="abo-balance">
<div class="abo-stat"><strong>${fmtHours(l.balanceMinutes)}</strong><span>verfügbare Zeit</span></div>
<div class="abo-stat"><strong>${fmtHours(l.usedMinutes)}</strong><span>bisher genutzt</span></div>
${l.nextExpiry ? `<div class="abo-stat"><strong>${fmtHours(l.nextExpiry.minutes)}</strong><span>verfallen am ${fmtDateShort(l.nextExpiry.at)}</span></div>` : ''}
<div 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>
<p class="abo-note">Jeden bezahlten Monat kommt 1 Stunde dazu. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst.
Aufträge schicken Sie uns einfach über den Chat.${sub.minTermEnd && !sub.cancelAtPeriodEnd ? ` Mindestlaufzeit bis ${fmtDateShort(sub.minTermEnd)}, danach monatlich kündbar.` : ''}</p>
${sub.offerText ? `<p class="abo-note">Ihr Angebot: ${escapeHtml(sub.offerText)}.</p>` : ''}
<div class="abo-actions" style="margin-top:14px">
<button type="button" class="portal-btn-outline" id="abo-manage">${sub.paymentProblem ? 'Zahlungsart aktualisieren' : 'Abo verwalten · Rechnungen'}</button>
${sub.withdrawalOpen ? `<a class="abo-withdraw" href="/widerrufen.html">Vertrag widerrufen</a><span class="abo-note">möglich bis ${fmtDateShort(sub.withdrawalUntil)}</span>` : ''}
<a class="abo-note" href="/abo-bedingungen.html" target="_blank" rel="noopener">Leistungen &amp; Bedingungen</a>
${sub.cancelAtPeriodEnd ? '' : '<a class="abo-note" href="/kuendigen.html">Kündigen</a>'}
<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)}${sub.cancelAtPeriodEnd ? '' : ' <a href="/kuendigen.html">Vertrag hier kündigen</a>'}</p>
<p class="abo-note" style="margin-bottom:0"><a href="/abo-bedingungen.html" target="_blank" rel="noopener">Leistungen &amp; Bedingungen</a></p>
</div>
${usageRows || creditRows ? `
<details class="abo-history">
<summary>Verlauf anzeigen</summary>
<table class="simple-table">
<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>
</details>` : ''}
<p class="error hidden" id="abo-error"></p>`
card.querySelector('#abo-manage')?.addEventListener('click', async (ev) => {
const btn = ev.currentTarget
btn.disabled = true
try {
const { url } = await api('/api/abo/portal', { method: 'POST' })
window.location.href = url
} catch (err) {
showError(card.querySelector('#abo-error'), err.message)
btn.disabled = false
}
})
</table></div>` : '<p class="kb-empty" style="margin:0">Noch keine Buchungen.</p>'}
</div>`
}
const aboState = { data: null }
async function loadAboCard({ afterCheckout = false } = {}) {
const card = document.getElementById('abo-card')
if (!card) return
const view = document.getElementById('view-abo')
if (!card || !view) return
try {
let data = await api('/api/abo')
// Direkt nach dem Checkout kann der Stripe-Webhook noch unterwegs sein
for (let i = 0; afterCheckout && !data.subscription?.active && i < 10; i += 1) {
card.classList.remove('hidden')
card.innerHTML = '<p class="abo-kicker">Vielen Dank!</p><h2 class="abo-title">Ihr Abo wird aktiviert…</h2><p class="abo-note">Das dauert nur einen Moment.</p>'
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
if (data.subscription?.active) {
renderAboActive(card, data)
renderAboSideActive(card, data)
renderAboAccountView(view, data)
} else {
renderAboOffer(card, data, { ended: Boolean(data.subscription) })
renderAboSideOffer(card, data, { ended: Boolean(data.subscription) })
renderAboOrderView(view, data)
}
card.classList.remove('hidden')
document.getElementById('invoices-abo-note')?.classList.toggle('hidden', !data.subscription)
loadTakeoverCard({ abo: data })
} catch (err) {
console.warn('Abo konnte nicht geladen werden:', err.message)
@@ -914,7 +1002,7 @@ function takeoverFormHtml(limits) {
<p class="error hidden" id="tk-error"></p>
<div class="abo-actions">
<button type="submit" class="abo-btn" id="tk-submit">Antrag absenden</button>
<button type="button" class="portal-btn-outline" id="tk-cancel">Abbrechen</button>
<button type="button" class="kb-btn-outline" id="tk-cancel">Abbrechen</button>
</div>
</form>`
}
@@ -998,45 +1086,47 @@ async function submitTakeoverReply(form, limits) {
}
}
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)
if (!takeoverState.abo?.subscription) {
card.classList.add('hidden')
return
}
let data
try {
data = await api('/api/abo/takeover-credits')
} catch (err) {
console.warn('Übernahmegutschrift konnte nicht geladen werden:', err.message)
return
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.classList.add('hidden')
card.innerHTML = '<div class="kb-panel"><p class="abo-note" style="margin:0">Die Übernahmegutschrift können Sie beantragen, sobald Ihr Wartungs-Abo läuft. <a href="#abo" data-view="abo">Zum Wartungs-Abo</a></p></div>'
return
}
card.innerHTML = `
<div class="abo-head">
<div>
<p class="abo-kicker">Wechsel ohne doppelte Kosten</p>
<h2 class="abo-title">Übernahmegutschrift</h2>
</div>
</div>
<p class="abo-note tk-intro">Sie haben Domain, Hosting oder E-Mail bei Ihrem bisherigen Anbieter schon im Voraus bezahlt? Den Anteil, den Sie ab Beginn Ihres Abos doppelt zahlen, schreiben wir Ihnen gut und verrechnen ihn mit Ihren nächsten Monatsgebühren. Schicken Sie uns dazu einfach die Rechnung.</p>
${notice ? `<div class="tk-notice">${escapeHtml(notice)}</div>` : ''}
${active ? `
<div class="abo-actions">
<button type="button" class="abo-btn" id="tk-start"${chatState.viewAs ? ' disabled' : ''}>Gutschrift beantragen</button>
${chatState.viewAs ? '<span class="abo-note">Im Ansichtsmodus kann kein Antrag gestellt werden.</span>' : ''}
</div>
${takeoverFormHtml(data.limits)}` : ''}
${requests.length ? `<div class="tk-list">${requests.map(renderTakeoverItem).join('')}</div>` : ''}`
card.classList.remove('hidden')
<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')
@@ -1060,8 +1150,45 @@ async function loadTakeoverCard({ abo, notice = '' } = {}) {
}))
}
/* ---------------------------------------------------------- Navigation --- */
// Ansichten im Inhaltsbereich; Hash fuer Direktlinks (z. B. aus Mails: #abo, #gutschrift)
const VIEW_HASHES = { projects: '', activity: 'fortschritt', invoices: 'rechnungen', chat: 'chat', abo: 'abo', takeover: 'gutschrift' }
const viewLoaders = { activity: loadCustomerActivity, chat: initChat }
const loadedViews = new Set()
function viewFromHash() {
const hash = window.location.hash.replace(/^#/, '')
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)
}
// Mobil liegt die Seitenleiste ueber dem Inhalt -> zum Inhalt springen
if (window.innerWidth <= 880) document.querySelector('.kb-main')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
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 meta = document.getElementById('customer-meta')
const list = document.getElementById('projects')
const loading = document.getElementById('loading')
const empty = document.getElementById('empty')
@@ -1073,36 +1200,24 @@ async function initDashboardPage() {
window.location.href = '/login.html'
})
// Tabs (Muster wie Admin-Dashboard)
const loadedTabs = new Set(['projects'])
const lazyLoaders = {
activity: loadCustomerActivity,
invoices: loadCustomerInvoices,
chat: initChat,
}
document.querySelectorAll('.portal-tab').forEach((tab) => {
tab.addEventListener('click', () => {
document.querySelectorAll('.portal-tab').forEach((t) => t.classList.remove('active'))
document.querySelectorAll('.portal-section').forEach((s) => s.classList.remove('active'))
tab.classList.add('active')
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active')
const name = tab.dataset.tab
if (!loadedTabs.has(name) && lazyLoaders[name]) {
loadedTabs.add(name)
lazyLoaders[name]()
}
if (name === 'chat') {
api('/api/chat/read', { method: 'POST' }).catch(() => {})
updateChatBadge(0)
}
})
// 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) {
window.location.href = '/login.html'
window.location.href = loginUrl
return
}
if (me.role === 'admin' && !me.customer) {
@@ -1110,7 +1225,7 @@ async function initDashboardPage() {
return
}
if (!me.customer) {
window.location.href = '/login.html'
window.location.href = loginUrl
return
}
@@ -1119,12 +1234,26 @@ async function initDashboardPage() {
takeoverState.customer = customer
renderViewAsBanner(me)
const codeSuffix = customer.code ? ` · Kundennr. ${customer.code}` : ''
meta.textContent = (customer.name ? `${customer.name} (${customer.email})` : customer.email) + codeSuffix
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')
if (aboParam) window.history.replaceState(null, '', `/dashboard.html${window.location.hash}`)
loadAboCard({ afterCheckout: aboParam === 'success' })
loadCustomerInvoices()
const initialView = viewFromHash()
if (initialView) showView(initialView, { updateHash: false })
const [{ projects }, { features }] = await Promise.all([
api('/api/projects'),
@@ -1132,6 +1261,10 @@ async function initDashboardPage() {
])
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')
@@ -1143,13 +1276,13 @@ async function initDashboardPage() {
empty.classList.remove('hidden')
}
// Chat-Badge global pollen (auch wenn Chat-Tab nicht offen ist)
// 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 = '/login.html'
window.location.href = loginUrl
return
}
showError(loadError, err.message)