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 const PREVIEW_BASE_WIDTH = 1280
function setupPreviewFrame(frame) { function setupPreviewFrame(frame) {
const view = frame.querySelector('.preview-frame__view') const view = frame.querySelector('iframe')
if (!view) return if (!view) return
const apply = () => { const apply = () => {
const w = frame.clientWidth const w = frame.clientWidth
@@ -99,38 +99,72 @@ function setupPreviewFrame(frame) {
apply() apply()
} }
function fmtDateTimeLong(value) {
if (!value) return '–'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return escapeHtml(value)
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
// "autohaus-meier.project.webklar.com" -> Subdomain hervorheben, Rest gedimmt
function previewHostHtml(url) {
try {
const host = new URL(url).hostname
const dot = host.indexOf('.')
return dot > 0
? `${escapeHtml(host.slice(0, dot))}<span class="kb-dim">${escapeHtml(host.slice(dot))}</span>`
: escapeHtml(host)
} catch {
return '<span class="kb-dim">–</span>'
}
}
function projectState(project) {
if (isProjectPreviewReady(project)) return ['kb-state-ok', 'Vorschau online']
const s = String(project.provisioningStatus || project.status || '').toLowerCase()
if (s.includes('fail')) return ['kb-state-off', 'Vorschau gestört']
if (project.previewUrl) return ['kb-state-wait', 'Vorschau wird vorbereitet']
return ['kb-state-off', 'Noch keine Website']
}
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen' }
function renderProjectCard(project, features) { function renderProjectCard(project, features) {
const li = document.createElement('li') const li = document.createElement('li')
li.className = 'project-card' li.className = 'kb-project'
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>`
: ''
const ready = isProjectPreviewReady(project) const ready = isProjectPreviewReady(project)
const url = project.previewUrl ? escapeAttr(project.previewUrl) : '' const url = project.previewUrl ? escapeAttr(project.previewUrl) : ''
const previewHtml = ready && url const [stateClass, stateText] = projectState(project)
? ` const extraChips = featuresForProject(features, project.id)
<a class="preview-frame" href="${url}" target="_blank" rel="noreferrer" data-preview-frame aria-label="Projekt-Webseite in neuem Tab öffnen"> .map((f) => FEATURE_LABELS[f.featureKey] || f.featureKey)
<iframe class="preview-frame__view" src="${url}" loading="lazy" scrolling="no" tabindex="-1" aria-hidden="true"></iframe> .filter((label) => label && !['Vorschau', 'Chat', 'Rechnungen'].includes(label))
<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>`
li.innerHTML = ` li.innerHTML = `
${previewHtml} ${ready && url
<h2>${escapeHtml(project.projectName || project.subdomain || 'Projekt')}</h2> ? `<a class="kb-preview" href="${url}" target="_blank" rel="noreferrer" data-preview-frame aria-label="Website in neuem Tab öffnen">
<dl> <iframe src="${url}" loading="lazy" scrolling="no" tabindex="-1" aria-hidden="true"></iframe>
<dt>Subdomain</dt><dd>${escapeHtml(project.subdomain || '–')}</dd> <span class="kb-open">Website öffnen ↗</span>
<dt>Live-Domain</dt><dd>${project.liveDomain ? escapeHtml(project.liveDomain) : '–'}</dd> </a>`
<dt>Status</dt><dd>${escapeHtml(project.status || '–')}</dd> : `<div class="kb-preview kb-preview-empty">${project.previewUrl ? 'Vorschau wird vorbereitet …' : 'Noch keine Website veröffentlicht'}</div>`}
</dl> <div class="kb-project-body">
${ready && url ? `<p class="preview-open"><a class="btn btn-green" href="${url}" target="_blank" rel="noreferrer">Projekt-Webseite öffnen ↗</a></p>` : ''} <div class="kb-project-top">
${featureHtml} <h2>${escapeHtml(project.projectName || project.subdomain || 'Projekt')}</h2>
<div class="project-details-toggle"> <span class="kb-state ${stateClass}">${stateText}</span>
<button type="button" class="portal-btn-outline" data-details-btn>Details anzeigen</button> </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>
<div class="project-details hidden" data-details-box></div>
` `
const frame = li.querySelector('[data-preview-frame]') const frame = li.querySelector('[data-preview-frame]')
@@ -142,7 +176,7 @@ function renderProjectCard(project, features) {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
const isHidden = box.classList.contains('hidden') const isHidden = box.classList.contains('hidden')
box.classList.toggle('hidden', !isHidden) box.classList.toggle('hidden', !isHidden)
btn.textContent = isHidden ? 'Details ausblenden' : 'Details anzeigen' btn.textContent = isHidden ? 'Projektdetails ausblenden' : 'Projektdetails anzeigen'
if (isHidden && !loaded) { if (isHidden && !loaded) {
loaded = true loaded = true
await loadProjectDetails(project, box) await loadProjectDetails(project, box)
@@ -271,7 +305,10 @@ async function canAccessPreviewUrl(url) {
} }
async function resolvePostLoginTarget() { 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))) { if (next && (await canAccessPreviewUrl(next))) {
return next return next
} }
@@ -388,7 +425,7 @@ function renderViewAsBanner(me) {
banner.classList.remove('hidden') banner.classList.remove('hidden')
banner.innerHTML = ` banner.innerHTML = `
<span>Admin-Ansicht: Du siehst das Portal als <strong>${escapeHtml(me.customer?.name || 'Kunde')}</strong>.</span> <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 () => { document.getElementById('exit-viewas-btn')?.addEventListener('click', async () => {
await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {}) await api('/api/portal-admin/exit-view-as', { method: 'POST' }).catch(() => {})
@@ -402,6 +439,13 @@ async function loadCustomerInvoices() {
if (!body) return if (!body) return
try { try {
const data = await api('/api/invoices') 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) { if (!data.linked) {
body.innerHTML = '<p class="muted">Für dein Konto sind noch keine Rechnungen hinterlegt.</p>' body.innerHTML = '<p class="muted">Für dein Konto sind noch keine Rechnungen hinterlegt.</p>'
return return
@@ -411,7 +455,7 @@ async function loadCustomerInvoices() {
return return
} }
body.innerHTML = ` 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> <thead><tr><th>Nr.</th><th>Datum</th><th>Betrag</th><th>Offen</th><th>Status</th><th></th></tr></thead>
<tbody> <tbody>
${data.invoices.map((i) => ` ${data.invoices.map((i) => `
@@ -427,7 +471,7 @@ async function loadCustomerInvoices() {
</td> </td>
</tr>`).join('')} </tr>`).join('')}
</tbody> </tbody>
</table>` </table></div>`
} catch (err) { } catch (err) {
body.innerHTML = `<p class="muted">Rechnungen konnten nicht geladen werden: ${escapeHtml(err.message)}</p>` body.innerHTML = `<p class="muted">Rechnungen konnten nicht geladen werden: ${escapeHtml(err.message)}</p>`
} }
@@ -515,7 +559,7 @@ async function pollChatMessages() {
} }
function isChatTabActive() { 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() { async function sendChatMessage() {
@@ -581,7 +625,7 @@ async function pollChatBadge() {
/* -------------------------------------------------------------- Dashboard --- */ /* -------------------------------------------------------------- Dashboard --- */
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Wartungs-Abo (Startseite): Angebot + Bestellung bzw. Stundenkonto // Wartungs-Abo: Karte in der Seitenleiste + Ansicht (Bestellung bzw. Stundenkonto)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const ABO_FEATURES = [ const ABO_FEATURES = [
@@ -591,6 +635,9 @@ const ABO_FEATURES = [
['1 Stunde Arbeitszeit pro Monat', 'nicht genutzte Zeit bleibt 12 Monate verfügbar'], ['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) { function fmtHours(min) {
const n = Math.max(0, Number(min || 0)) const n = Math.max(0, Number(min || 0))
const h = Math.floor(n / 60) const h = Math.floor(n / 60)
@@ -598,6 +645,15 @@ function fmtHours(min) {
return m ? `${h}:${String(m).padStart(2, '0')} h` : `${h} h` 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() { function aboFeaturesHtml() {
return `<ul class="abo-features">${ABO_FEATURES.map( return `<ul class="abo-features">${ABO_FEATURES.map(
([t, d]) => `<li>${t}<small>${d}</small></li>` ([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' }) return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
} }
function renderAboOffer(card, data, { ended } = {}) { // Laufzeit-Hinweis unter der Abo-Karte (§ 8 der Bedingungen)
const available = data.available function aboTermText(sub) {
// Individuelles Angebot (vom WEBklar-Team angelegt), z. B. erste 2 Monate je 25 € 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 offer = data.offer
const offerPrice = offer
? (offer.monthlyPriceCents % 100 === 0 ? `${offer.monthlyPriceCents / 100} €` : fmtEuro(offer.monthlyPriceCents / 100))
: ''
card.innerHTML = ` card.innerHTML = `
<div class="abo-head"> <p class="kb-label">${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}</p>
<div> <h3>Wartungs-Abo</h3>
<p class="abo-kicker">${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}</p> ${offer ? '<span class="kb-offer-tag">Ihr persönliches Angebot</span>' : ''}
<h2 class="abo-title">Wartungs-Abo</h2> <p class="kb-price">${offer
</div> ? `<strong>${euroShort(offer.monthlyPriceCents)}</strong><span>${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50 €</span>`
${offer : '<strong>50 €</strong><span>pro Monat</span>'}</p>
? `<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>` <ul class="kb-checks">${ABO_SIDE_FEATURES.map((f) => `<li>${escapeHtml(f)}</li>`).join('')}</ul>
: '<div class="abo-price"><strong>50 €</strong><span>pro Monat · 12 Monate Mindestlaufzeit</span></div>'} <button type="button" class="kb-btn-primary kb-btn-block" data-view="abo"${data.available ? '' : ' disabled'}>Abo abschließen</button>
</div> <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 ? ` ${offer ? `
<div class="abo-offer-box"> <div class="abo-offer-box">
<strong>Ihr persönliches Angebot</strong> <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>` : ''} ${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> <p class="abo-note">Gültig bis ${fmtDateShort(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt</p>
</div>` : ''} </div>` : ''}
${aboFeaturesHtml()} <div class="kb-panel">
${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>' : ''} ${aboFeaturesHtml()}
<div class="abo-actions"> <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>
<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> </div>
<div class="abo-order hidden" id="abo-order"> ${available ? `
<div class="kb-panel" id="abo-order">
<h3>Ihre Bestellung</h3> <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>` : ''} ${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"> <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> <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> </div>
<p class="error hidden" id="abo-error"></p> <p class="error hidden" id="abo-error"></p>
<div class="abo-actions"> <div class="kb-actions">
<button type="button" class="abo-btn" id="abo-buy">Zahlungspflichtig bestellen</button> <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> <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>` : `
<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') view.querySelector('#abo-buy')?.addEventListener('click', async (ev) => {
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) => {
const btn = ev.currentTarget const btn = ev.currentTarget
const errEl = card.querySelector('#abo-error') const errEl = view.querySelector('#abo-error')
hideError(errEl) hideError(errEl)
const acceptTerms = card.querySelector('#abo-terms').checked const acceptTerms = view.querySelector('#abo-terms').checked
const startChoice = card.querySelector('input[name="abo-start-choice"]:checked')?.value || '' const startChoice = view.querySelector('input[name="abo-start-choice"]:checked')?.value || ''
if (!acceptTerms) { if (!acceptTerms) {
showError(errEl, 'Bitte stimmen Sie den Vertragsbedingungen zu.') showError(errEl, 'Bitte stimmen Sie den Vertragsbedingungen zu.')
return 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 sub = data.subscription
const l = data.ledger || { balanceMinutes: 0, credits: [], usage: [] } const l = data.ledger || { balanceMinutes: 0, usedMinutes: 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 usageRows = (l.usage || []).map((u) => ` const usageRows = (l.usage || []).map((u) => `
<tr><td>${fmtDateShort(u.date)}</td> <tr><td>${fmtDateShort(u.date)}</td>
<td>${escapeHtml(u.topic || u.details || 'Arbeitszeit')}${u.woid ? ` <span class="muted">#${escapeHtml(u.woid)}</span>` : ''}</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> <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('') <td>+${fmtHours(c.minutes)} <span class="muted">${c.expired ? 'abgelaufen' : `nutzbar bis ${fmtDateShort(c.expiresAt)}`}</span></td></tr>`).join('')
card.innerHTML = ` view.innerHTML = `
<div class="abo-head"> <header class="kb-head">
<div> <h1>Wartungs-Abo</h1>
<p class="abo-kicker">Ihr Wartungs-Abo</p> <p>Ihr Stundenkontingent, der Verlauf und Ihr Vertrag.</p>
<h2 class="abo-title">Stundenkontingent</h2> </header>
</div> ${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>' : ''}
<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>' : ''}
${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>` : ''} ${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="kb-stats">
<div class="abo-stat"><strong>${fmtHours(l.balanceMinutes)}</strong><span>verfügbare Zeit</span></div> <div class="kb-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> <div class="kb-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>` : ''} ${l.nextExpiry ? `<div class="kb-stat"><strong>${fmtHours(l.nextExpiry.minutes)}</strong><span>verfallen am ${fmtDateShort(l.nextExpiry.at)}</span></div>` : ''}
</div> </div>
<p class="abo-note">Jeden bezahlten Monat kommt 1 Stunde dazu. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst. <div class="kb-panel">
Aufträge schicken Sie uns einfach über den Chat.${sub.minTermEnd && !sub.cancelAtPeriodEnd ? ` Mindestlaufzeit bis ${fmtDateShort(sub.minTermEnd)}, danach monatlich kündbar.` : ''}</p> <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>` : ''} ${sub.offerText ? `<p class="abo-note">Ihr Angebot: ${escapeHtml(sub.offerText)}.</p>` : ''}
<div class="abo-actions" style="margin-top:14px"> <p class="abo-note">${aboTermText(sub)}${sub.cancelAtPeriodEnd ? '' : ' <a href="/kuendigen.html">Vertrag hier kündigen</a>'}</p>
<button type="button" class="portal-btn-outline" id="abo-manage">${sub.paymentProblem ? 'Zahlungsart aktualisieren' : 'Abo verwalten · Rechnungen'}</button> <p class="abo-note" style="margin-bottom:0"><a href="/abo-bedingungen.html" target="_blank" rel="noopener">Leistungen &amp; Bedingungen</a></p>
${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> </div>
${usageRows || creditRows ? ` <div class="kb-panel">
<details class="abo-history"> <h3>Verlauf</h3>
<summary>Verlauf anzeigen</summary> ${usageRows || creditRows ? `
<table class="simple-table"> <div class="kb-table-wrap"><table class="simple-table">
<thead><tr><th>Datum</th><th>Vorgang</th><th>Zeit</th></tr></thead> <thead><tr><th>Datum</th><th>Vorgang</th><th>Zeit</th></tr></thead>
<tbody>${usageRows}${creditRows}</tbody> <tbody>${usageRows}${creditRows}</tbody>
</table> </table></div>` : '<p class="kb-empty" style="margin:0">Noch keine Buchungen.</p>'}
</details>` : ''} </div>`
<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
}
})
} }
const aboState = { data: null }
async function loadAboCard({ afterCheckout = false } = {}) { async function loadAboCard({ afterCheckout = false } = {}) {
const card = document.getElementById('abo-card') const card = document.getElementById('abo-card')
if (!card) return const view = document.getElementById('view-abo')
if (!card || !view) return
try { try {
let data = await api('/api/abo') let data = await api('/api/abo')
// Direkt nach dem Checkout kann der Stripe-Webhook noch unterwegs sein // Direkt nach dem Checkout kann der Stripe-Webhook noch unterwegs sein
for (let i = 0; afterCheckout && !data.subscription?.active && i < 10; i += 1) { for (let i = 0; afterCheckout && !data.subscription?.active && i < 10; i += 1) {
card.classList.remove('hidden') 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)) await new Promise((r) => setTimeout(r, 2000))
data = await api('/api/abo') data = await api('/api/abo')
} }
aboState.data = data
if (data.subscription?.active) { if (data.subscription?.active) {
renderAboActive(card, data) renderAboSideActive(card, data)
renderAboAccountView(view, data)
} else { } else {
renderAboOffer(card, data, { ended: Boolean(data.subscription) }) renderAboSideOffer(card, data, { ended: Boolean(data.subscription) })
renderAboOrderView(view, data)
} }
card.classList.remove('hidden') card.classList.remove('hidden')
document.getElementById('invoices-abo-note')?.classList.toggle('hidden', !data.subscription)
loadTakeoverCard({ abo: data }) loadTakeoverCard({ abo: data })
} catch (err) { } catch (err) {
console.warn('Abo konnte nicht geladen werden:', err.message) console.warn('Abo konnte nicht geladen werden:', err.message)
@@ -914,7 +1002,7 @@ function takeoverFormHtml(limits) {
<p class="error hidden" id="tk-error"></p> <p class="error hidden" id="tk-error"></p>
<div class="abo-actions"> <div class="abo-actions">
<button type="submit" class="abo-btn" id="tk-submit">Antrag absenden</button> <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> </div>
</form>` </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 = '' } = {}) { async function loadTakeoverCard({ abo, notice = '' } = {}) {
const card = document.getElementById('takeover-card') const card = document.getElementById('takeover-card')
if (!card) return if (!card) return
if (abo) takeoverState.abo = abo if (abo) takeoverState.abo = abo
const active = Boolean(takeoverState.abo?.subscription?.active) const active = Boolean(takeoverState.abo?.subscription?.active)
if (!takeoverState.abo?.subscription) { let data = { requests: [], limits: null }
card.classList.add('hidden') if (takeoverState.abo?.subscription) {
return try {
} data = await api('/api/abo/takeover-credits')
let data } catch (err) {
try { card.innerHTML = `<div class="kb-panel kb-empty">Übernahmegutschrift konnte nicht geladen werden: ${escapeHtml(err.message)}</div>`
data = await api('/api/abo/takeover-credits') return
} catch (err) { }
console.warn('Übernahmegutschrift konnte nicht geladen werden:', err.message)
return
} }
const requests = data.requests || [] const requests = data.requests || []
updateTakeoverBadge(requests)
if (!active && !requests.length) { 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 return
} }
card.innerHTML = ` card.innerHTML = `
<div class="abo-head"> <div class="kb-panel">
<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>
<p class="abo-kicker">Wechsel ohne doppelte Kosten</p> ${notice ? `<div class="tk-notice">${escapeHtml(notice)}</div>` : ''}
<h2 class="abo-title">Übernahmegutschrift</h2> ${active ? `
</div> <div class="abo-actions">
</div> <button type="button" class="abo-btn" id="tk-start"${chatState.viewAs ? ' disabled' : ''}>Gutschrift beantragen</button>
<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> ${chatState.viewAs ? '<span class="abo-note">Im Ansichtsmodus kann kein Antrag gestellt werden.</span>' : ''}
${notice ? `<div class="tk-notice">${escapeHtml(notice)}</div>` : ''} </div>
${active ? ` ${takeoverFormHtml(data.limits)}` : ''}
<div class="abo-actions"> ${requests.length ? `<div class="tk-list">${requests.map(renderTakeoverItem).join('')}</div>` : ''}
<button type="button" class="abo-btn" id="tk-start"${chatState.viewAs ? ' disabled' : ''}>Gutschrift beantragen</button> </div>`
${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')
const form = card.querySelector('#tk-form') const form = card.querySelector('#tk-form')
const startBtn = card.querySelector('#tk-start') 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() { async function initDashboardPage() {
const meta = document.getElementById('customer-meta')
const list = document.getElementById('projects') const list = document.getElementById('projects')
const loading = document.getElementById('loading') const loading = document.getElementById('loading')
const empty = document.getElementById('empty') const empty = document.getElementById('empty')
@@ -1073,36 +1200,24 @@ async function initDashboardPage() {
window.location.href = '/login.html' window.location.href = '/login.html'
}) })
// Tabs (Muster wie Admin-Dashboard) // Seitenleiste, Chips in den Projektkarten und Links im Inhalt schalten die Ansicht um
const loadedTabs = new Set(['projects']) document.addEventListener('click', (ev) => {
const lazyLoaders = { const target = ev.target.closest('[data-view]')
activity: loadCustomerActivity, if (!target || target.disabled) return
invoices: loadCustomerInvoices, ev.preventDefault()
chat: initChat, showView(target.dataset.view)
}
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)
}
})
}) })
// 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 { try {
const me = await api('/api/auth/me') const me = await api('/api/auth/me')
if (!me.authenticated) { if (!me.authenticated) {
window.location.href = '/login.html' window.location.href = loginUrl
return return
} }
if (me.role === 'admin' && !me.customer) { if (me.role === 'admin' && !me.customer) {
@@ -1110,7 +1225,7 @@ async function initDashboardPage() {
return return
} }
if (!me.customer) { if (!me.customer) {
window.location.href = '/login.html' window.location.href = loginUrl
return return
} }
@@ -1119,12 +1234,26 @@ async function initDashboardPage() {
takeoverState.customer = customer takeoverState.customer = customer
renderViewAsBanner(me) renderViewAsBanner(me)
const codeSuffix = customer.code ? ` · Kundennr. ${customer.code}` : '' const displayName = customer.companyName || customer.name || customer.email
meta.textContent = (customer.name ? `${customer.name} (${customer.email})` : customer.email) + codeSuffix 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') 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' }) loadAboCard({ afterCheckout: aboParam === 'success' })
loadCustomerInvoices()
const initialView = viewFromHash()
if (initialView) showView(initialView, { updateHash: false })
const [{ projects }, { features }] = await Promise.all([ const [{ projects }, { features }] = await Promise.all([
api('/api/projects'), api('/api/projects'),
@@ -1132,6 +1261,10 @@ async function initDashboardPage() {
]) ])
loading.classList.add('hidden') 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) { if (projects.length) {
list.classList.remove('hidden') list.classList.remove('hidden')
@@ -1143,13 +1276,13 @@ async function initDashboardPage() {
empty.classList.remove('hidden') 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() pollChatBadge()
chatState.badgeTimer = setInterval(pollChatBadge, 45000) chatState.badgeTimer = setInterval(pollChatBadge, 45000)
} catch (err) { } catch (err) {
loading.classList.add('hidden') loading.classList.add('hidden')
if (err.status === 401 || err.message.includes('Nicht angemeldet')) { if (err.status === 401 || err.message.includes('Nicht angemeldet')) {
window.location.href = '/login.html' window.location.href = loginUrl
return return
} }
showError(loadError, err.message) showError(loadError, err.message)
+97 -247
View File
@@ -3,272 +3,122 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kundenbereich – Webklar</title> <title>Kundenbereich – WEBklar</title>
<link rel="stylesheet" href="/login.css"> <link rel="stylesheet" href="/login.css">
<style> <link rel="stylesheet" href="/kundenbereich.css">
.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>
</head> </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-background" aria-hidden="true">
<div class="dot-pattern"></div> <div class="dot-pattern"></div>
<div class="dot-fade"></div> <div class="dot-fade"></div>
</div> </div>
<div class="kb-glow" aria-hidden="true"></div>
<header class="portal-header"> <header class="kb-header">
<a class="portal-logo" href="/dashboard.html">Webklar</a> <a class="kb-logo" href="/dashboard.html">WEBKLAR</a>
<div class="portal-header-actions"> <div class="kb-account">
<span class="portal-meta" id="customer-meta"></span> <div class="kb-account-text">
<button type="button" class="portal-btn-outline" id="logout-btn">Abmelden</button> <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> </div>
</header> </header>
<main class="portal-main dashboard-content-reveal"> <div class="kb-shell dashboard-content-reveal">
<div id="viewas-banner" class="viewas-banner hidden"></div> <!-- 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> <section class="kb-card kb-contact" aria-label="Ansprechpartner">
<p id="load-error" class="error hidden"></p> <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 --> <!-- Wartungs-Abo: Angebot bzw. Status (app.js) -->
<section id="abo-card" class="abo-card hidden" aria-live="polite"></section> <section class="kb-abo hidden" id="abo-card" aria-live="polite"></section>
</aside>
<!-- Übernahmegutschrift: beim bisherigen Anbieter schon bezahlte Zeit anrechnen --> <main class="kb-main">
<section id="takeover-card" class="takeover-card hidden" aria-live="polite"></section> <div id="viewas-banner" class="viewas-banner hidden"></div>
<p id="load-error" class="error hidden"></p>
<div class="portal-tabs"> <!-- Projekte -->
<button class="portal-tab active" data-tab="projects">Projekte</button> <section class="kb-view active" id="view-projects">
<button class="portal-tab" data-tab="activity">Was wurde gemacht</button> <header class="kb-head">
<button class="portal-tab" data-tab="invoices">Rechnungen</button> <h1>Projekte</h1>
<button class="portal-tab" data-tab="chat">Chat <span id="chat-badge" class="badge-pill hidden"></span></button> <p id="projects-subtitle">Deine Website – Vorschau, Status und Details.</p>
</div> </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 --> <!-- Fortschritt -->
<section class="portal-section active" id="tab-projects"> <section class="kb-view" id="view-activity">
<p id="loading" class="muted portal-status">Projekte werden geladen…</p> <header class="kb-head">
<ul id="projects" class="projects hidden"></ul> <h1>Fortschritt</h1>
<p id="empty" class="portal-card hidden portal-status">Noch keine Website-Projekte zugewiesen.</p> <p>Was wir an deinen Projekten erledigt haben.</p>
</section> </header>
<div class="kb-panel" id="activity-body"><p class="kb-empty">Lade…</p></div>
</section>
<!-- Was wurde gemacht --> <!-- Rechnungen -->
<section class="portal-section" id="tab-activity"> <section class="kb-view" id="view-invoices">
<h2 class="portal-section-title">Was wurde gemacht</h2> <header class="kb-head">
<div class="portal-card" id="activity-body"><p class="muted">Lade…</p></div> <h1>Rechnungen</h1>
</section> <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 --> <!-- Chat -->
<section class="portal-section" id="tab-invoices"> <section class="kb-view" id="view-chat">
<h2 class="portal-section-title">Meine Rechnungen</h2> <header class="kb-head">
<div class="portal-card" id="invoices-body"><p class="muted">Lade…</p></div> <h1>Chat</h1>
</section> <p>Schreib uns – wir antworten meist innerhalb eines Werktags.</p>
</header>
<!-- Chat --> <div class="kb-panel chat-box">
<section class="portal-section" id="tab-chat"> <div class="chat-messages" id="chat-messages"></div>
<h2 class="portal-section-title">Chat mit dem Webklar-Team</h2> <div class="chat-input-row">
<div class="portal-card chat-box"> <textarea id="chat-input" maxlength="2000" placeholder="Nachricht an das WEBklar-Team…"></textarea>
<div class="chat-messages" id="chat-messages"></div> <button type="button" class="chat-send" id="chat-send">Senden</button>
<div class="chat-input-row"> </div>
<textarea id="chat-input" maxlength="2000" placeholder="Nachricht an das Webklar-Team…"></textarea> <p class="chat-hint">Enter = Senden, Shift+Enter = Zeilenumbruch.</p>
<button type="button" class="chat-send" id="chat-send">Senden</button>
</div> </div>
<p class="chat-hint">Enter = Senden, Shift+Enter = Zeilenumbruch. Wir antworten in der Regel innerhalb eines Werktags.</p> </section>
</div>
</section>
<footer class="portal-legal"> <!-- Wartungs-Abo: Bestellung bzw. Stundenkonto (app.js) -->
<a class="legal-strong" href="/kuendigen.html">Verträge hier kündigen</a> <section class="kb-view" id="view-abo" aria-live="polite"></section>
<a class="legal-strong" href="/widerrufen.html">Vertrag widerrufen</a>
<a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen</a> <!-- Übernahmegutschrift (app.js) -->
<a href="/datenschutz.html" target="_blank" rel="noopener">Datenschutz</a> <section class="kb-view" id="view-takeover">
<a href="https://webklar.com/impressum" target="_blank" rel="noopener">Impressum</a> <header class="kb-head">
</footer> <h1>Übernahmegutschrift</h1>
</main> <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 src="/app.js"></script>
<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 { Router } from 'express'
import { config } from '../config.js' import { config } from '../config.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.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 { getCustomerActivity } from '../services/customerActivity.js'
import { fetchInvoicePdf, getInvoiceRaw, listInvoicesForClient } from '../services/invoiceNinja.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 export default router
+1
View File
@@ -43,6 +43,7 @@ router.get('/', requireSession, async (req, res) => {
templateName: p.templateName || '', templateName: p.templateName || '',
giteaRepoUrl: p.giteaRepoUrl || '', giteaRepoUrl: p.giteaRepoUrl || '',
repoFullName: p.repoFullName || '', repoFullName: p.repoFullName || '',
updatedAt: p.updatedAt || p.$updatedAt || '',
})) }))
return res.json({ projects: sanitized }) 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.'] ? ['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:`, `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', 'Es gelten unsere Vertragsbedingungen (Mindestlaufzeit 12 Monate, danach monatlich kündbar; gemäß § 19 UStG',
`ohne Umsatzsteuer): ${config.publicBaseUrl}/abo-bedingungen.html`, `ohne Umsatzsteuer): ${config.publicBaseUrl}/abo-bedingungen.html`,
+2 -2
View File
@@ -448,7 +448,7 @@ export async function createTakeoverRequest({ customer, input, uploads, consentI
...summary, ...summary,
'', '',
'Wir prüfen Ihre Angaben und den Nachweis und antworten Ihnen per E-Mail an diese Adresse.', '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', 'Viele Grüße',
'Ihr WEBklar-Team', 'Ihr WEBklar-Team',
@@ -851,7 +851,7 @@ async function mailDecision(doc, customer, action) {
'', '',
question, 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.', 'auch weitere Nachweise hochladen. Alternativ antworten Sie einfach auf diese E-Mail.',
...footer, ...footer,
]) ])