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)
+97 -247
View File
@@ -3,272 +3,122 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kundenbereich – Webklar</title>
<title>Kundenbereich – WEBklar</title>
<link rel="stylesheet" href="/login.css">
<style>
.viewas-banner {
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
padding: 10px 16px; margin-bottom: 16px; border-radius: 12px;
background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.45);
}
/* Tabs (Muster aus Admin, violett) */
.portal-tabs { display: flex; gap: 8px; margin: 14px 0 22px; flex-wrap: wrap; }
.portal-tab {
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14);
color: inherit; padding: 9px 18px; border-radius: 999px; cursor: pointer; font: inherit;
display: inline-flex; align-items: center; gap: 8px;
}
.portal-tab.active { background: rgba(157,61,254,0.22); border-color: rgba(157,61,254,0.65); }
.portal-section { display: none; }
.portal-section.active { display: block; }
.portal-section-title { margin: 0 0 14px; font-size: 20px; }
.badge-pill {
display: inline-flex; min-width: 20px; height: 20px; padding: 0 6px;
align-items: center; justify-content: center; border-radius: 999px;
background: #9d3dfe; color: #fff; font-size: 12px; font-weight: 700;
}
.simple-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.simple-table th, .simple-table td { text-align: left; padding: 8px 10px; border-bottom: 1px solid rgba(255,255,255,0.08); }
.simple-table th { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.65; }
.simple-table tr:last-child td { border-bottom: none; }
.btn-link { color: #7dd3fc; text-decoration: none; }
.btn-link:hover { text-decoration: underline; }
.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; border: 1px solid transparent; white-space: nowrap; }
.pill-ok { background: rgba(74,222,128,0.15); border-color: rgba(74,222,128,0.4); }
.pill-warn { background: rgba(157,61,254,0.18); border-color: rgba(157,61,254,0.5); }
.pill-red { background: rgba(248,113,113,0.15); border-color: rgba(248,113,113,0.45); }
.pill-muted { background: rgba(255,255,255,0.07); border-color: rgba(255,255,255,0.16); opacity: 0.85; }
.pill-git { background: rgba(251,146,60,0.16); border-color: rgba(251,146,60,0.5); }
.activity-item { padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.08); }
.activity-item:last-child { border-bottom: none; }
.activity-status {
display: inline-block; margin-left: 6px; padding: 1px 9px; border-radius: 999px; font-size: 12px;
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.16);
}
.activity-ws { font-size: 13px; opacity: 0.85; margin: 4px 0 4px 14px; white-space: pre-wrap; }
/* Projekt-Details */
.project-details-toggle { margin-top: 10px; }
.project-details { margin-top: 14px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 12px; }
.project-details h3 { margin: 12px 0 8px; font-size: 15px; }
.commit-item { padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.06); font-size: 14px; }
.commit-item:last-child { border-bottom: none; }
.commit-meta { font-size: 12px; opacity: 0.6; }
.commit-body { font-size: 13px; opacity: 0.8; white-space: pre-wrap; margin: 2px 0 0 8px; }
/* Projekt-Vorschaufenster */
.preview-frame {
position: relative; display: block; width: 100%; aspect-ratio: 16 / 10;
overflow: hidden; border-radius: 12px; border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.04); margin-bottom: 1rem; text-decoration: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.preview-frame:hover { border-color: rgba(157,61,254,0.7); box-shadow: 0 0 0 1px rgba(157,61,254,0.35); }
.preview-frame__view {
position: absolute; top: 0; left: 0; width: 1280px; height: 800px; border: 0;
transform-origin: 0 0; pointer-events: none; background: #fff;
}
.preview-frame__hint {
position: absolute; left: 0; right: 0; bottom: 0; padding: 10px 14px;
font-size: 13px; font-weight: 600; color: #fff;
background: linear-gradient(to top, rgba(0,0,0,0.6), rgba(0,0,0,0));
opacity: 0; transition: opacity 0.2s ease; pointer-events: none;
}
.preview-frame:hover .preview-frame__hint { opacity: 1; }
.preview-frame--empty {
display: flex; align-items: center; justify-content: center;
color: rgba(255,255,255,0.5); font-size: 14px; aspect-ratio: 16 / 10;
}
.preview-open { margin: 0.75rem 0 0; }
/* Chat */
.chat-box { display: flex; flex-direction: column; height: 480px; }
.chat-messages { flex: 1; overflow-y: auto; padding: 6px 4px; display: flex; flex-direction: column; gap: 10px; }
.chat-bubble { max-width: 78%; padding: 10px 14px; border-radius: 16px; font-size: 14px; white-space: pre-wrap; word-break: break-word; }
.chat-bubble .chat-meta { display: block; font-size: 11px; opacity: 0.6; margin-top: 4px; }
.chat-bubble.mine { align-self: flex-end; background: rgba(157,61,254,0.3); border: 1px solid rgba(157,61,254,0.5); border-bottom-right-radius: 4px; }
.chat-bubble.theirs { align-self: flex-start; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.14); border-bottom-left-radius: 4px; }
.chat-input-row { display: flex; gap: 10px; margin-top: 12px; align-items: flex-end; }
.chat-input-row textarea {
flex: 1; resize: none; min-height: 46px; max-height: 130px; padding: 12px 14px;
border-radius: 14px; border: 1px solid rgba(255,255,255,0.18);
background: rgba(255,255,255,0.06); color: inherit; font: inherit;
}
.chat-send {
background: #9d3dfe; border: none; color: #fff; font: inherit; font-weight: 600;
padding: 12px 20px; border-radius: 14px; cursor: pointer;
}
.chat-send:disabled { opacity: 0.5; cursor: default; }
.chat-hint { font-size: 12px; opacity: 0.55; margin-top: 6px; }
/* Wartungs-Abo (Startseite) */
.abo-card {
position: relative; margin: 6px 0 26px; padding: 26px 28px; border-radius: 22px;
background: linear-gradient(135deg, rgba(157,61,254,0.20), rgba(255,255,255,0.06) 60%);
border: 1px solid rgba(157,61,254,0.45);
}
.abo-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; flex-wrap: wrap; }
.abo-kicker { font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.7; margin: 0 0 4px; }
.abo-title { font-size: 24px; margin: 0; }
.abo-price { text-align: right; }
.abo-price strong { font-size: 32px; font-variant-numeric: tabular-nums; }
.abo-price span { display: block; font-size: 13px; opacity: 0.7; }
.abo-features { list-style: none; padding: 0; margin: 18px 0 20px; display: grid; gap: 10px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (max-width: 640px) { .abo-features { grid-template-columns: 1fr; } .abo-price { text-align: left; } }
.abo-features li { padding-left: 26px; position: relative; font-size: 15px; }
.abo-features li::before { content: '✓'; position: absolute; left: 0; color: #c9a2ff; font-weight: 700; }
.abo-features small { display: block; opacity: 0.65; font-size: 13px; }
.abo-actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.abo-btn {
background: #9d3dfe; border: none; color: #fff; font: inherit; font-weight: 700;
padding: 13px 24px; border-radius: 999px; cursor: pointer;
}
.abo-btn:hover { background: #8a25f0; }
.abo-btn:disabled { opacity: 0.5; cursor: default; }
.abo-note { font-size: 13px; opacity: 0.7; }
.abo-order { margin-top: 20px; padding-top: 18px; border-top: 1px solid rgba(255,255,255,0.14); }
.abo-order h3 { margin: 0 0 10px; font-size: 17px; }
.abo-summary { width: 100%; border-collapse: collapse; font-size: 14px; margin-bottom: 14px; }
.abo-summary td { padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,0.08); vertical-align: top; }
.abo-summary td:last-child { text-align: right; }
.abo-check { display: flex; gap: 10px; align-items: flex-start; margin: 10px 0; font-size: 14px; line-height: 1.45; cursor: pointer; }
.abo-check input { margin-top: 3px; width: 18px; height: 18px; accent-color: #9d3dfe; flex: none; }
.abo-card a { color: #d4b4ff; }
.abo-balance { display: flex; gap: 28px; flex-wrap: wrap; margin: 18px 0; }
.abo-stat strong { display: block; font-size: 34px; line-height: 1.1; font-variant-numeric: tabular-nums; }
.abo-stat span { font-size: 13px; opacity: 0.7; }
.abo-alert { padding: 10px 14px; border-radius: 12px; margin: 12px 0; font-size: 14px; background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.45); }
.abo-history { margin-top: 14px; }
.abo-history summary { cursor: pointer; font-size: 14px; opacity: 0.85; }
.abo-history table { margin-top: 10px; }
.portal-legal { margin-top: 40px; font-size: 13px; display: flex; gap: 18px; flex-wrap: wrap; align-items: center; }
.portal-legal a { color: rgba(255,255,255,0.7); }
.portal-legal a.legal-strong { color: #fff; font-weight: 700; text-decoration: underline; }
.abo-start-choice { border: none; padding: 0; margin: 14px 0 8px; }
.abo-start-choice legend { font-weight: 700; margin-bottom: 4px; }
.abo-summary-box { margin: 16px 0 10px; padding: 14px 16px; border-radius: 14px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.14); }
.abo-summary-box .abo-summary { margin-bottom: 6px; }
.abo-summary td:first-child { width: 26%; opacity: 0.75; }
.abo-summary td:last-child { text-align: left; }
.abo-withdraw { color: #fff; font-weight: 700; padding: 9px 16px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.6); text-decoration: none; }
.abo-withdraw:hover { background: #fff; color: #1c1c1c; }
/* Individuelles Angebot */
.abo-offer-box { margin: 16px 0 4px; padding: 14px 16px; border-radius: 14px; background: rgba(74,222,128,0.10); border: 1px solid rgba(74,222,128,0.45); }
.abo-offer-box p { margin: 4px 0 0; }
/* Übernahmegutschrift */
.pill-amber { background: rgba(251,191,36,0.15); border-color: rgba(251,191,36,0.45); }
.takeover-card {
margin: -8px 0 26px; padding: 22px 28px; border-radius: 22px;
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.14);
}
.takeover-card .abo-title { font-size: 20px; }
.takeover-card a { color: #d4b4ff; }
.tk-intro { margin: 8px 0 14px; max-width: 70ch; }
.tk-notice { padding: 10px 14px; border-radius: 12px; margin: 12px 0; font-size: 14px; background: rgba(74,222,128,0.12); border: 1px solid rgba(74,222,128,0.4); }
.tk-form { margin-top: 18px; padding-top: 18px; border-top: 1px solid rgba(255,255,255,0.14); display: grid; gap: 14px; }
.tk-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 14px; }
.tk-field { border: none; padding: 0; margin: 0; min-width: 0; }
.tk-field > label, .tk-field > legend { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
.tk-field small { font-weight: 400; opacity: 0.65; }
.tk-field input:not([type=checkbox]), .tk-field select, .tk-field textarea, .tk-reply textarea, .tk-reply input[type=file] {
width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 12px;
border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.06); color: inherit; font: inherit;
}
.tk-field select option { color: #111; }
.tk-field textarea, .tk-reply textarea { min-height: 90px; resize: vertical; }
.tk-services { display: flex; flex-wrap: wrap; gap: 8px 20px; }
.tk-services label { display: inline-flex; gap: 8px; align-items: center; font-size: 14px; cursor: pointer; }
.tk-services input { width: 17px; height: 17px; accent-color: #9d3dfe; }
.tk-conditions summary { cursor: pointer; font-size: 14px; }
.tk-conditions ol { margin: 8px 0 0; padding-left: 20px; font-size: 13.5px; opacity: 0.85; }
.tk-conditions li { margin: 4px 0; }
.tk-list { margin-top: 16px; display: grid; gap: 10px; }
.tk-item { padding: 12px 16px; border-radius: 14px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.1); font-size: 14px; }
.tk-item p { margin: 8px 0 0; }
.tk-item-head { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; align-items: center; margin-bottom: 4px; }
.tk-question { margin: 10px 0 0; padding: 10px 12px; border-radius: 12px; white-space: pre-wrap; background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.45); }
.tk-reply { margin-top: 10px; display: grid; gap: 8px; }
</style>
<link rel="stylesheet" href="/kundenbereich.css">
</head>
<body class="portal-page dashboard-page">
<body class="portal-page dashboard-page kb-page">
<div class="dot-background" aria-hidden="true">
<div class="dot-pattern"></div>
<div class="dot-fade"></div>
</div>
<div class="kb-glow" aria-hidden="true"></div>
<header class="portal-header">
<a class="portal-logo" href="/dashboard.html">Webklar</a>
<div class="portal-header-actions">
<span class="portal-meta" id="customer-meta"></span>
<button type="button" class="portal-btn-outline" id="logout-btn">Abmelden</button>
<header class="kb-header">
<a class="kb-logo" href="/dashboard.html">WEBKLAR</a>
<div class="kb-account">
<div class="kb-account-text">
<strong id="customer-name"></strong>
<span id="customer-meta"></span>
</div>
<span class="kb-avatar" id="customer-avatar" aria-hidden="true"></span>
<button type="button" class="kb-logout" id="logout-btn">Abmelden</button>
</div>
</header>
<main class="portal-main dashboard-content-reveal">
<div id="viewas-banner" class="viewas-banner hidden"></div>
<div class="kb-shell dashboard-content-reveal">
<!-- Seitenleiste: Navigation, Ansprechpartner, Wartungs-Abo -->
<aside class="kb-side">
<p class="kb-label">Kundenbereich</p>
<nav class="kb-nav" aria-label="Kundenbereich">
<button type="button" class="kb-nav-item active" data-view="projects">Projekte <span class="kb-count" id="projects-count"></span></button>
<button type="button" class="kb-nav-item" data-view="activity">Fortschritt</button>
<button type="button" class="kb-nav-item" data-view="invoices">Rechnungen <span class="kb-badge kb-badge-red hidden" id="invoices-badge" title="offene Rechnungen"></span></button>
<button type="button" class="kb-nav-item" data-view="chat">Chat <span class="kb-badge hidden" id="chat-badge" title="ungelesene Nachrichten"></span></button>
</nav>
<h1 class="portal-title">Kundenbereich</h1>
<p id="load-error" class="error hidden"></p>
<section class="kb-card kb-contact" aria-label="Ansprechpartner">
<p class="kb-label">Dein Ansprechpartner</p>
<strong id="contact-name">WEBklar-Team</strong>
<span id="contact-response">Antwort meist &lt; 1 Werktag</span>
<button type="button" data-view="chat">Nachricht schreiben</button>
</section>
<!-- Wartungs-Abo: Angebot bzw. Stundenkonto -->
<section id="abo-card" class="abo-card hidden" aria-live="polite"></section>
<!-- Wartungs-Abo: Angebot bzw. Status (app.js) -->
<section class="kb-abo hidden" id="abo-card" aria-live="polite"></section>
</aside>
<!-- Übernahmegutschrift: beim bisherigen Anbieter schon bezahlte Zeit anrechnen -->
<section id="takeover-card" class="takeover-card hidden" aria-live="polite"></section>
<main class="kb-main">
<div id="viewas-banner" class="viewas-banner hidden"></div>
<p id="load-error" class="error hidden"></p>
<div class="portal-tabs">
<button class="portal-tab active" data-tab="projects">Projekte</button>
<button class="portal-tab" data-tab="activity">Was wurde gemacht</button>
<button class="portal-tab" data-tab="invoices">Rechnungen</button>
<button class="portal-tab" data-tab="chat">Chat <span id="chat-badge" class="badge-pill hidden"></span></button>
</div>
<!-- Projekte -->
<section class="kb-view active" id="view-projects">
<header class="kb-head">
<h1>Projekte</h1>
<p id="projects-subtitle">Deine Website – Vorschau, Status und Details.</p>
</header>
<p id="loading" class="kb-empty">Projekte werden geladen…</p>
<ul id="projects" class="kb-projects hidden"></ul>
<div id="empty" class="kb-panel kb-empty hidden">Noch keine Website-Projekte zugewiesen.</div>
</section>
<!-- Projekte -->
<section class="portal-section active" id="tab-projects">
<p id="loading" class="muted portal-status">Projekte werden geladen…</p>
<ul id="projects" class="projects hidden"></ul>
<p id="empty" class="portal-card hidden portal-status">Noch keine Website-Projekte zugewiesen.</p>
</section>
<!-- Fortschritt -->
<section class="kb-view" id="view-activity">
<header class="kb-head">
<h1>Fortschritt</h1>
<p>Was wir an deinen Projekten erledigt haben.</p>
</header>
<div class="kb-panel" id="activity-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Was wurde gemacht -->
<section class="portal-section" id="tab-activity">
<h2 class="portal-section-title">Was wurde gemacht</h2>
<div class="portal-card" id="activity-body"><p class="muted">Lade…</p></div>
</section>
<!-- Rechnungen -->
<section class="kb-view" id="view-invoices">
<header class="kb-head">
<h1>Rechnungen</h1>
<p>Deine Rechnungen ansehen, bezahlen und herunterladen.</p>
</header>
<div class="kb-panel" id="invoices-body"><p class="kb-empty">Lade…</p></div>
<p class="kb-note hidden" id="invoices-abo-note">Rechnungen zum Wartungs-Abo findest du unter „Abo verwalten &amp; Rechnungen“ in der Seitenleiste.</p>
</section>
<!-- Rechnungen -->
<section class="portal-section" id="tab-invoices">
<h2 class="portal-section-title">Meine Rechnungen</h2>
<div class="portal-card" id="invoices-body"><p class="muted">Lade…</p></div>
</section>
<!-- Chat -->
<section class="portal-section" id="tab-chat">
<h2 class="portal-section-title">Chat mit dem Webklar-Team</h2>
<div class="portal-card chat-box">
<div class="chat-messages" id="chat-messages"></div>
<div class="chat-input-row">
<textarea id="chat-input" maxlength="2000" placeholder="Nachricht an das Webklar-Team…"></textarea>
<button type="button" class="chat-send" id="chat-send">Senden</button>
<!-- Chat -->
<section class="kb-view" id="view-chat">
<header class="kb-head">
<h1>Chat</h1>
<p>Schreib uns – wir antworten meist innerhalb eines Werktags.</p>
</header>
<div class="kb-panel chat-box">
<div class="chat-messages" id="chat-messages"></div>
<div class="chat-input-row">
<textarea id="chat-input" maxlength="2000" placeholder="Nachricht an das WEBklar-Team…"></textarea>
<button type="button" class="chat-send" id="chat-send">Senden</button>
</div>
<p class="chat-hint">Enter = Senden, Shift+Enter = Zeilenumbruch.</p>
</div>
<p class="chat-hint">Enter = Senden, Shift+Enter = Zeilenumbruch. Wir antworten in der Regel innerhalb eines Werktags.</p>
</div>
</section>
</section>
<footer class="portal-legal">
<a class="legal-strong" href="/kuendigen.html">Verträge hier kündigen</a>
<a class="legal-strong" href="/widerrufen.html">Vertrag widerrufen</a>
<a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen</a>
<a href="/datenschutz.html" target="_blank" rel="noopener">Datenschutz</a>
<a href="https://webklar.com/impressum" target="_blank" rel="noopener">Impressum</a>
</footer>
</main>
<!-- Wartungs-Abo: Bestellung bzw. Stundenkonto (app.js) -->
<section class="kb-view" id="view-abo" aria-live="polite"></section>
<!-- Übernahmegutschrift (app.js) -->
<section class="kb-view" id="view-takeover">
<header class="kb-head">
<h1>Übernahmegutschrift</h1>
<p>Schon woanders bezahlt? Wir rechnen die doppelt bezahlte Zeit an.</p>
</header>
<div id="takeover-card" aria-live="polite"><p class="kb-empty">Lade…</p></div>
</section>
<footer class="kb-legal">
<a class="legal-strong" href="/kuendigen.html">Verträge hier kündigen</a>
<a class="legal-strong" href="/widerrufen.html">Vertrag widerrufen</a>
<a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen</a>
<a href="/datenschutz.html" target="_blank" rel="noopener">Datenschutz</a>
<a href="https://webklar.com/impressum" target="_blank" rel="noopener">Impressum</a>
</footer>
</main>
</div>
<script src="/app.js"></script>
<script>
+356
View File
@@ -0,0 +1,356 @@
/* Kundenbereich (Design "Kundenbereich Neu"): Kopfzeile, Seitenleiste mit Navigation,
Ansprechpartner und Wartungs-Abo, Inhalt rechts. Basis (Hintergrund, Schrift) aus login.css. */
.kb-page {
--kb-surface: rgba(12, 12, 14, 0.9);
--kb-border: rgba(255, 255, 255, 0.09);
--kb-border-strong: rgba(255, 255, 255, 0.16);
--kb-muted: rgba(255, 255, 255, 0.6);
--kb-faint: rgba(255, 255, 255, 0.42);
--kb-purple: #9d3dfe;
--kb-purple-hover: #8a25f0;
--kb-purple-soft: rgba(157, 61, 254, 0.14);
--kb-purple-line: rgba(157, 61, 254, 0.5);
--kb-lilac: #c9a2ff;
--kb-radius: 14px;
}
.kb-page a { color: #d4b4ff; }
.kb-glow {
position: absolute;
top: 0;
left: 50%;
z-index: 0;
width: min(1200px, 100%);
height: 460px;
transform: translateX(-50%);
background: radial-gradient(ellipse at 50% 0%, rgba(157, 61, 254, 0.2), transparent 68%);
pointer-events: none;
}
/* ------------------------------------------------------------ Kopfzeile --- */
.kb-header {
position: sticky;
top: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 16px clamp(16px, 3vw, 36px);
background: rgba(0, 0, 0, 0.72);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--kb-border);
}
.kb-logo { color: #fff !important; text-decoration: none; font-weight: 800; font-size: 15px; letter-spacing: 0.24em; }
.kb-account { display: flex; align-items: center; gap: 14px; min-width: 0; }
.kb-account-text { text-align: right; line-height: 1.35; min-width: 0; }
.kb-account-text strong { display: block; font-size: 14px; }
.kb-account-text span { display: block; font-size: 12px; color: var(--kb-muted); overflow-wrap: anywhere; }
.kb-avatar {
flex: none; width: 36px; height: 36px; border-radius: 50%; display: grid; place-items: center;
font-size: 13px; font-weight: 700; background: #26124a; border: 1px solid var(--kb-purple-line);
}
.kb-logout {
flex: none; background: transparent; color: #fff; border: 1px solid var(--kb-border-strong); border-radius: 999px;
padding: 9px 18px; font: inherit; font-size: 11px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; cursor: pointer;
}
.kb-logout:hover { background: #fff; color: #111; }
/* ---------------------------------------------------------------- Raster --- */
.kb-shell {
position: relative;
z-index: 1;
max-width: 1140px;
margin: 0 auto;
padding: 36px clamp(16px, 3vw, 36px) 56px;
display: grid;
grid-template-columns: 236px minmax(0, 1fr);
gap: 36px;
align-items: start;
}
.kb-side { display: grid; gap: 14px; }
.kb-main { min-width: 0; }
.kb-label {
margin: 0 0 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--kb-faint);
}
.kb-side > .kb-label { margin: 0 0 -4px 12px; }
/* ------------------------------------------------------------ Navigation --- */
.kb-nav { display: grid; gap: 4px; }
.kb-nav-item {
display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 12px;
border-radius: 10px; border: 1px solid transparent; background: transparent;
color: rgba(255, 255, 255, 0.72); font: inherit; font-size: 14px; text-align: left; cursor: pointer;
}
.kb-nav-item::before { content: ''; flex: none; width: 6px; height: 6px; border-radius: 50%; background: rgba(255, 255, 255, 0.3); }
.kb-nav-item:hover { background: rgba(255, 255, 255, 0.04); color: #fff; }
.kb-nav-item.active { background: var(--kb-purple-soft); border-color: var(--kb-purple-line); color: #fff; font-weight: 700; }
.kb-nav-item.active::before { background: var(--kb-purple); box-shadow: 0 0 8px var(--kb-purple); }
.kb-count { margin-left: auto; font-size: 12px; font-weight: 600; color: var(--kb-faint); }
.kb-badge {
margin-left: auto; min-width: 24px; height: 19px; padding: 0 7px; border-radius: 999px;
display: inline-grid; place-items: center; font-size: 11px; font-weight: 700; background: var(--kb-purple); color: #fff;
}
.kb-badge-red { background: rgba(248, 113, 113, 0.12); border: 1px solid rgba(248, 113, 113, 0.5); color: #fca5a5; }
/* -------------------------------------------------------- Seitenkarten --- */
.kb-card { padding: 16px; border-radius: var(--kb-radius); background: var(--kb-surface); border: 1px solid var(--kb-border); }
.kb-card .kb-label, .kb-abo .kb-label { margin-bottom: 8px; }
.kb-contact strong { display: block; font-size: 15px; font-weight: 600; }
.kb-contact span { display: block; margin-top: 2px; font-size: 13px; color: var(--kb-muted); }
.kb-contact button { margin-top: 10px; padding: 0; border: none; background: none; color: #d4b4ff; font: inherit; font-size: 13px; cursor: pointer; }
.kb-contact button:hover { text-decoration: underline; }
.kb-abo {
padding: 18px 16px 16px; border-radius: var(--kb-radius); border: 1px solid rgba(157, 61, 254, 0.45);
background: linear-gradient(180deg, rgba(157, 61, 254, 0.16), rgba(157, 61, 254, 0.04) 55%, rgba(12, 12, 14, 0.92));
}
.kb-abo h3 { margin: 2px 0 10px; font-size: 19px; }
.kb-price { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px; margin: 0 0 12px; }
.kb-price strong { font-size: 26px; font-variant-numeric: tabular-nums; }
.kb-price span { font-size: 13px; color: var(--kb-muted); }
.kb-price s { color: var(--kb-faint); font-size: 14px; }
.kb-checks { list-style: none; padding: 0; margin: 0 0 16px; display: grid; gap: 7px; font-size: 13px; line-height: 1.4; }
.kb-checks li { position: relative; padding-left: 20px; }
.kb-checks li::before { content: '✓'; position: absolute; left: 0; color: var(--kb-lilac); font-weight: 700; }
.kb-offer-tag {
display: inline-block; margin: 0 0 10px; padding: 3px 9px; border-radius: 999px; font-size: 11.5px; font-weight: 700;
color: #86efac; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4);
}
.kb-fine { margin: 10px 0 0; font-size: 11.5px; line-height: 1.5; color: var(--kb-faint); }
.kb-fine a { color: #fff; font-weight: 700; }
.kb-status { display: inline-block; padding: 3px 11px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.kb-status-ok { color: #86efac; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
.kb-status-wait { color: #fcd34d; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.4); }
.kb-status-bad { color: #fca5a5; background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.45); }
.kb-status-off { color: var(--kb-muted); background: rgba(255, 255, 255, 0.05); border: 1px solid var(--kb-border-strong); }
.kb-hours {
display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; margin: 12px 0 2px; padding: 0; border: none;
background: none; color: inherit; font: inherit; text-align: left; cursor: pointer;
}
.kb-hours strong { font-size: 26px; font-variant-numeric: tabular-nums; }
.kb-hours span { font-size: 13px; color: var(--kb-muted); }
.kb-hours em { font-style: normal; font-size: 16px; line-height: 1; color: #d4b4ff; }
.kb-hours:hover span { color: #fff; text-decoration: underline; }
.kb-sub { margin: 0 0 12px; font-size: 12.5px; color: var(--kb-muted); }
.kb-side-btn {
display: block; width: 100%; box-sizing: border-box; margin-top: 8px; padding: 10px 12px; border-radius: 10px;
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.02); color: #fff !important;
font: inherit; font-size: 13px; font-weight: 600; line-height: 1.35; text-align: left; text-decoration: none; cursor: pointer;
}
.kb-side-btn:hover { background: rgba(255, 255, 255, 0.07); }
.kb-side-btn small { font-size: 12px; font-weight: 400; color: var(--kb-muted); }
.kb-side-btn .kb-badge { float: right; margin: 0 0 0 6px; }
.kb-side-alert {
margin: 10px 0 0; padding: 8px 10px; border-radius: 10px; font-size: 12.5px; line-height: 1.45;
background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.4);
}
.kb-divider { border-top: 1px solid var(--kb-border); margin: 14px 0 10px; }
/* --------------------------------------------------------- Inhaltsbereich --- */
.kb-view { display: none; }
.kb-view.active { display: block; }
.kb-head { padding-bottom: 18px; margin-bottom: 22px; border-bottom: 1px solid var(--kb-border); }
.kb-head h1 { margin: 0; font-size: 30px; font-weight: 750; letter-spacing: -0.01em; }
.kb-head p { margin: 6px 0 0; font-size: 15px; color: var(--kb-muted); }
.kb-panel { padding: 20px 22px; border-radius: 16px; background: var(--kb-surface); border: 1px solid var(--kb-border); }
.kb-panel + .kb-panel { margin-top: 16px; }
.kb-panel h3 { margin: 0 0 10px; font-size: 16px; }
.kb-empty { color: var(--kb-muted); }
.kb-note { font-size: 13px; color: var(--kb-muted); }
.viewas-banner {
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
padding: 10px 16px; margin-bottom: 18px; border-radius: 12px;
background: rgba(251, 191, 36, 0.12); border: 1px solid rgba(251, 191, 36, 0.45);
}
.viewas-banner button { background: transparent; color: #fff; border: 1px solid var(--kb-border-strong); border-radius: 999px; padding: 7px 14px; font: inherit; font-size: 13px; cursor: pointer; }
/* Buttons */
.kb-btn-primary {
display: inline-block; padding: 12px 22px; border-radius: 999px; border: none; background: var(--kb-purple);
color: #fff !important; font: inherit; font-weight: 700; font-size: 14px; text-align: center; text-decoration: none; cursor: pointer;
}
.kb-btn-primary:hover { background: var(--kb-purple-hover); }
.kb-btn-primary:disabled { opacity: 0.5; cursor: default; }
.kb-btn-block { display: block; width: 100%; }
/* Buttons im Gutschrift-Formular (app.js) */
.abo-btn {
padding: 12px 22px; border-radius: 999px; border: none; background: var(--kb-purple); color: #fff;
font: inherit; font-weight: 700; font-size: 14px; cursor: pointer;
}
.abo-btn:hover { background: var(--kb-purple-hover); }
.abo-btn:disabled { opacity: 0.5; cursor: default; }
.abo-actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.kb-btn-outline {
display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px; border-radius: 999px;
border: 1px solid var(--kb-border-strong); background: transparent; color: #fff !important;
font: inherit; font-size: 13px; font-weight: 700; text-decoration: none; cursor: pointer;
}
.kb-btn-outline:hover { background: rgba(255, 255, 255, 0.07); }
.kb-actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
/* ---------------------------------------------------------------- Projekte --- */
.kb-projects { list-style: none; margin: 0; padding: 0; display: grid; gap: 22px; }
.kb-project { border-radius: 18px; border: 1px solid var(--kb-border); background: rgba(11, 11, 13, 0.94); overflow: hidden; }
.kb-preview {
position: relative; display: block; aspect-ratio: 16 / 9; overflow: hidden; border-bottom: 1px solid var(--kb-border);
background: repeating-linear-gradient(135deg, rgba(255, 255, 255, 0.035) 0 12px, transparent 12px 24px), #0e0e11;
}
.kb-preview iframe {
position: absolute; top: 0; left: 0; width: 1280px; height: 720px; border: 0;
transform-origin: 0 0; pointer-events: none; background: #fff;
}
.kb-preview-empty {
display: grid; place-items: center; padding: 0 16px; text-align: center;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; color: var(--kb-faint);
}
.kb-open {
position: absolute; right: 14px; bottom: 14px; padding: 8px 14px; border-radius: 999px;
background: rgba(0, 0, 0, 0.8); border: 1px solid rgba(255, 255, 255, 0.2); color: #fff !important;
font-size: 13px; font-weight: 700; text-decoration: none;
}
.kb-preview:hover .kb-open { background: #fff; color: #111 !important; }
.kb-project-body { padding: 22px 22px 20px; }
.kb-project-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
.kb-project-top h2 { margin: 0; font-size: 20px; }
.kb-state { display: inline-flex; align-items: center; gap: 7px; padding: 4px 11px; border-radius: 999px; font-size: 12.5px; font-weight: 700; white-space: nowrap; }
.kb-state::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.kb-state-ok { color: #86efac; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
.kb-state-wait { color: #fcd34d; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.4); }
.kb-state-off { color: var(--kb-muted); background: rgba(255, 255, 255, 0.05); border: 1px solid var(--kb-border-strong); }
.kb-facts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0 0 14px; border: 1px solid var(--kb-border); border-radius: 12px; }
.kb-facts > div { padding: 12px 14px; min-width: 0; }
.kb-facts > div + div { border-left: 1px solid var(--kb-border); }
.kb-facts dt { margin: 0 0 6px; font-size: 10.5px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--kb-faint); }
.kb-facts dd { margin: 0; font-size: 14px; overflow-wrap: anywhere; }
.kb-facts .kb-dim { color: var(--kb-muted); }
.kb-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; }
.kb-chip {
padding: 5px 10px; border-radius: 8px; font: inherit; font-size: 12.5px; color: #d8b4fe !important; text-decoration: none;
background: rgba(157, 61, 254, 0.14); border: 1px solid rgba(157, 61, 254, 0.4); cursor: pointer;
}
.kb-chip:hover { background: rgba(157, 61, 254, 0.24); }
.kb-details { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--kb-border); }
.kb-details h3 { margin: 16px 0 8px; font-size: 14px; letter-spacing: 0.02em; }
.kb-details h3:first-child { margin-top: 0; }
.commit-item { padding: 7px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.06); font-size: 14px; }
.commit-item:last-child { border-bottom: none; }
.commit-meta { font-size: 12px; color: var(--kb-faint); }
.commit-body { margin: 2px 0 0 8px; font-size: 13px; color: var(--kb-muted); white-space: pre-wrap; }
/* ------------------------------------------ Tabellen, Pills, Aktivitaet --- */
.simple-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.simple-table th, .simple-table td { padding: 10px 10px; text-align: left; border-bottom: 1px solid var(--kb-border); }
.simple-table th { font-size: 10.5px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--kb-faint); }
.simple-table tr:last-child td { border-bottom: none; }
.kb-table-wrap { overflow-x: auto; }
.btn-link { color: #d4b4ff; text-decoration: none; }
.btn-link:hover { text-decoration: underline; }
.pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; border: 1px solid transparent; white-space: nowrap; }
.pill-ok { color: #86efac; background: rgba(74, 222, 128, 0.1); border-color: rgba(74, 222, 128, 0.4); }
.pill-warn { color: #d8b4fe; background: rgba(157, 61, 254, 0.16); border-color: rgba(157, 61, 254, 0.5); }
.pill-red { color: #fca5a5; background: rgba(248, 113, 113, 0.12); border-color: rgba(248, 113, 113, 0.45); }
.pill-muted { color: var(--kb-muted); background: rgba(255, 255, 255, 0.06); border-color: var(--kb-border-strong); }
.pill-git { color: #fdba74; background: rgba(251, 146, 60, 0.12); border-color: rgba(251, 146, 60, 0.45); }
.pill-amber { color: #fcd34d; background: rgba(251, 191, 36, 0.12); border-color: rgba(251, 191, 36, 0.45); }
.activity-item { padding: 14px 0; border-bottom: 1px solid var(--kb-border); }
.activity-item:first-child { padding-top: 0; }
.activity-item:last-child { border-bottom: none; padding-bottom: 0; }
.activity-status { display: inline-block; margin-left: 6px; padding: 1px 9px; border-radius: 999px; font-size: 12px; background: rgba(255, 255, 255, 0.06); border: 1px solid var(--kb-border-strong); }
.activity-ws { margin: 4px 0 4px 14px; font-size: 13px; color: rgba(255, 255, 255, 0.78); white-space: pre-wrap; }
/* ------------------------------------------------------------------- Chat --- */
.chat-box { display: flex; flex-direction: column; height: 520px; }
.chat-messages { flex: 1; overflow-y: auto; padding: 6px 4px; display: flex; flex-direction: column; gap: 10px; }
.chat-bubble { max-width: 78%; padding: 10px 14px; border-radius: 16px; font-size: 14px; white-space: pre-wrap; word-break: break-word; }
.chat-bubble .chat-meta { display: block; margin-top: 4px; font-size: 11px; opacity: 0.6; }
.chat-bubble.mine { align-self: flex-end; background: rgba(157, 61, 254, 0.26); border: 1px solid rgba(157, 61, 254, 0.5); border-bottom-right-radius: 4px; }
.chat-bubble.theirs { align-self: flex-start; background: rgba(255, 255, 255, 0.06); border: 1px solid var(--kb-border-strong); border-bottom-left-radius: 4px; }
.chat-input-row { display: flex; gap: 10px; margin-top: 12px; align-items: flex-end; }
.chat-input-row textarea {
flex: 1; resize: none; min-height: 46px; max-height: 130px; padding: 12px 14px; border-radius: 14px;
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit;
}
.chat-send { padding: 12px 20px; border: none; border-radius: 999px; background: var(--kb-purple); color: #fff; font: inherit; font-weight: 700; cursor: pointer; }
.chat-send:disabled { opacity: 0.5; cursor: default; }
.chat-hint { margin-top: 6px; font-size: 12px; color: var(--kb-faint); }
/* ------------------------------------------------ Wartungs-Abo (Inhalt) --- */
.abo-features { list-style: none; padding: 0; margin: 0 0 18px; display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.abo-features li { position: relative; padding-left: 24px; font-size: 15px; }
.abo-features li::before { content: '✓'; position: absolute; left: 0; color: var(--kb-lilac); font-weight: 700; }
.abo-features small { display: block; font-size: 13px; color: var(--kb-muted); }
.abo-note { font-size: 13px; color: var(--kb-muted); }
.abo-check { display: flex; gap: 10px; align-items: flex-start; margin: 10px 0; font-size: 14px; line-height: 1.45; cursor: pointer; }
.abo-check input { flex: none; width: 18px; height: 18px; margin-top: 3px; accent-color: var(--kb-purple); }
.abo-start-choice { border: none; padding: 0; margin: 14px 0 8px; }
.abo-start-choice legend { margin-bottom: 4px; font-weight: 700; }
.abo-summary-box { margin: 16px 0 12px; padding: 14px 16px; border-radius: 14px; background: rgba(255, 255, 255, 0.04); border: 1px solid var(--kb-border-strong); }
.abo-summary { width: 100%; border-collapse: collapse; font-size: 14px; margin-bottom: 6px; }
.abo-summary td { padding: 7px 0; border-bottom: 1px solid var(--kb-border); vertical-align: top; }
.abo-summary td:first-child { width: 26%; padding-right: 12px; color: var(--kb-muted); }
.abo-alert { margin: 12px 0; padding: 10px 14px; border-radius: 12px; font-size: 14px; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.4); }
.abo-offer-box { margin: 0 0 18px; padding: 14px 16px; border-radius: 14px; background: rgba(74, 222, 128, 0.08); border: 1px solid rgba(74, 222, 128, 0.4); }
.abo-offer-box p { margin: 4px 0 0; }
.kb-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin: 0 0 16px; }
.kb-stat { padding: 14px 16px; border-radius: 14px; background: var(--kb-surface); border: 1px solid var(--kb-border); }
.kb-stat strong { display: block; font-size: 28px; line-height: 1.15; font-variant-numeric: tabular-nums; }
.kb-stat span { font-size: 13px; color: var(--kb-muted); }
/* --------------------------------------------------- Uebernahmegutschrift --- */
.tk-intro { margin: 0 0 14px; max-width: 70ch; }
.tk-notice { margin: 12px 0; padding: 10px 14px; border-radius: 12px; font-size: 14px; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); }
.tk-form { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--kb-border); display: grid; gap: 14px; }
.tk-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 14px; }
.tk-field { border: none; padding: 0; margin: 0; min-width: 0; }
.tk-field > label, .tk-field > legend { display: block; margin-bottom: 6px; font-size: 13px; font-weight: 600; }
.tk-field small { font-weight: 400; color: var(--kb-muted); }
.tk-field input:not([type=checkbox]), .tk-field select, .tk-field textarea, .tk-reply textarea, .tk-reply input[type=file] {
width: 100%; box-sizing: border-box; padding: 10px 12px; border-radius: 12px;
border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit;
}
.tk-field select option { color: #111; }
.tk-field textarea, .tk-reply textarea { min-height: 90px; resize: vertical; }
.tk-services { display: flex; flex-wrap: wrap; gap: 8px 20px; }
.tk-services label { display: inline-flex; gap: 8px; align-items: center; font-size: 14px; cursor: pointer; }
.tk-services input { width: 17px; height: 17px; accent-color: var(--kb-purple); }
.tk-conditions summary { cursor: pointer; font-size: 14px; }
.tk-conditions ol { margin: 8px 0 0; padding-left: 20px; font-size: 13.5px; color: rgba(255, 255, 255, 0.8); }
.tk-conditions li { margin: 4px 0; }
.tk-list { margin-top: 16px; display: grid; gap: 10px; }
.tk-item { padding: 12px 16px; border-radius: 14px; background: rgba(255, 255, 255, 0.03); border: 1px solid var(--kb-border); font-size: 14px; }
.tk-item p { margin: 8px 0 0; }
.tk-item-head { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; align-items: center; margin-bottom: 4px; }
.tk-question { margin: 10px 0 0; padding: 10px 12px; border-radius: 12px; white-space: pre-wrap; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.4); }
.tk-reply { margin-top: 10px; display: grid; gap: 8px; }
/* ---------------------------------------------------------- Pflichtlinks --- */
.kb-legal { margin-top: 40px; padding-top: 18px; border-top: 1px solid var(--kb-border); display: flex; flex-wrap: wrap; gap: 8px 18px; font-size: 13px; }
.kb-legal a { color: var(--kb-muted); }
.kb-legal a.legal-strong { color: #fff; font-weight: 700; text-decoration: underline; }
/* ----------------------------------------------------------------- Mobil --- */
@media (max-width: 880px) {
.kb-shell { grid-template-columns: 1fr; gap: 22px; padding-top: 22px; }
.kb-side > .kb-label { display: none; }
.kb-nav { grid-template-columns: 1fr 1fr; gap: 6px; }
.kb-account-text { display: none; }
}
@media (max-width: 640px) {
.kb-facts { grid-template-columns: 1fr; }
.kb-facts > div + div { border-left: none; border-top: 1px solid var(--kb-border); }
.abo-features { grid-template-columns: 1fr; }
.kb-head h1 { font-size: 26px; }
.kb-project-body, .kb-panel { padding: 16px; }
}
+25 -1
View File
@@ -1,7 +1,7 @@
import { Router } from 'express'
import { config } from '../config.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { getDocument, listDocuments, Query } from '../services/appwriteAdmin.js'
import { getCustomerActivity } from '../services/customerActivity.js'
import { fetchInvoicePdf, getInvoiceRaw, listInvoicesForClient } from '../services/invoiceNinja.js'
@@ -75,4 +75,28 @@ router.get('/activity', requireSession, async (req, res) => {
}
})
/**
* "Dein Ansprechpartner" in der Seitenleiste: der Mitarbeiter, dem das juengste zugewiesene
* Ticket des Kunden gehoert (workorders.assignedTo -> employees). Sonst das Team.
*/
router.get('/contact', requireSession, async (req, res) => {
const fallback = { name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' }
try {
const [tickets, employees] = await Promise.all([
listDocuments(config.collections.workorders, [
Query.equal('customerId', getSessionCustomerId(req)),
Query.orderDesc('$createdAt'),
Query.limit(50),
]),
listDocuments(config.collections.employees, [Query.limit(100)]),
])
const nameOf = new Map(employees.flatMap((e) => [[e.userId, e.displayName], [e.$id, e.displayName]]))
const assignee = (t) => nameOf.get(String(t.assignedTo || '')) || t.assignedName || ''
const ticket = tickets.find((t) => assignee(t))
return res.json({ ...fallback, name: ticket ? assignee(ticket) : fallback.name })
} catch {
return res.json(fallback)
}
})
export default router
+1
View File
@@ -43,6 +43,7 @@ router.get('/', requireSession, async (req, res) => {
templateName: p.templateName || '',
giteaRepoUrl: p.giteaRepoUrl || '',
repoFullName: p.repoFullName || '',
updatedAt: p.updatedAt || p.$updatedAt || '',
}))
return res.json({ projects: sanitized })
+1 -1
View File
@@ -238,7 +238,7 @@ async function mailOffer(doc, customer, sub) {
? ['Der Rabatt wird bei den Abbuchungen automatisch berücksichtigt und erscheint auf Ihren Rechnungen.']
: [
`Sie können das Abo zu diesem Preis bis zum ${fmtDate(doc.validUntil)} im Kundenportal abschließen:`,
`${config.publicBaseUrl}/dashboard.html`,
`${config.publicBaseUrl}/dashboard.html#abo`,
'',
'Es gelten unsere Vertragsbedingungen (Mindestlaufzeit 12 Monate, danach monatlich kündbar; gemäß § 19 UStG',
`ohne Umsatzsteuer): ${config.publicBaseUrl}/abo-bedingungen.html`,
+2 -2
View File
@@ -448,7 +448,7 @@ export async function createTakeoverRequest({ customer, input, uploads, consentI
...summary,
'',
'Wir prüfen Ihre Angaben und den Nachweis und antworten Ihnen per E-Mail an diese Adresse.',
`Den Stand Ihres Antrags sehen Sie auch im Kundenportal: ${config.publicBaseUrl}/dashboard.html`,
`Den Stand Ihres Antrags sehen Sie auch im Kundenportal: ${config.publicBaseUrl}/dashboard.html#gutschrift`,
'',
'Viele Grüße',
'Ihr WEBklar-Team',
@@ -851,7 +851,7 @@ async function mailDecision(doc, customer, action) {
'',
question,
'',
`Bitte antworten Sie im Kundenportal unter ${config.publicBaseUrl}/dashboard.html – dort können Sie`,
`Bitte antworten Sie im Kundenportal unter ${config.publicBaseUrl}/dashboard.html#gutschrift – dort können Sie`,
'auch weitere Nachweise hochladen. Alternativ antworten Sie einfach auf diese E-Mail.',
...footer,
])