diff --git a/public/neu/ansicht-hilfen.js b/public/neu/ansicht-hilfen.js new file mode 100644 index 0000000..d69694e --- /dev/null +++ b/public/neu/ansicht-hilfen.js @@ -0,0 +1,165 @@ +/* Kundenansicht neu (P2): gemeinsame Hilfen der Ansichten. Formate, Bausteinklassen, Navigation, Ereignisse und kleine Merker. + Kein DOM-Zugriff beim Import; document und window werden erst beim Aufruf gelesen. */ + +import { api } from './api.js' +import { geheZu } from './router.js' +import { hashFuerAnsicht } from './routen.js' +import { safeUrl } from '../fn-util.js' + +/** Klassen der Bausteine aus basis.css. */ +export const K = Object.freeze({ + knopf: 'kn-knopf', + primaer: 'kn-knopf-primaer', + zweit: 'kn-knopf-zweit', + textknopf: 'kn-textknopf', + feld: 'kn-feld', + input: 'kn-input', + select: 'kn-select', + textarea: 'kn-textarea', + check: 'kn-check', + liste: 'kn-liste', + zeile: 'kn-zeile', + abschnitt: 'kn-abschnitt', + meldung: 'kn-meldung', + fehler: 'kn-fehler', + leer: 'kn-leer', + versteckt: 'hidden', + pille: Object.freeze({ + grau: 'kn-pille kn-pille-grau', + blau: 'kn-pille kn-pille-blau', + orange: 'kn-pille kn-pille-orange', + violett: 'kn-pille kn-pille-violett', + gruen: 'kn-pille kn-pille-gruen', + rot: 'kn-pille kn-pille-rot', + }), +}) + +export const ABO_NICHT_GELADEN = 'Deine Verträge konnten gerade nicht geladen werden. Bitte versuche es später noch einmal.' + +/* ---------- Formate ---------- */ + +const ZAHL_2 = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) +const ZAHL_1 = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 1, maximumFractionDigits: 1 }) +const ZAHL_STD = new Intl.NumberFormat('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 2 }) + +/** Teile eines Zeitpunkts in Berliner Zeit; ungültige Werte ergeben null. */ +function berlinTeile(wert, extra = {}) { + const d = wert instanceof Date ? wert : new Date(wert) + if (!wert || Number.isNaN(d.getTime())) return null + const teile = new Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', hourCycle: 'h23', ...extra }).formatToParts(d) + return Object.fromEntries(teile.map((t) => [t.type, t.value])) +} + +/** 50 → '50,00 €' (geschütztes Leerzeichen vor dem Euro-Zeichen). */ +export const fmtEuro = (euro) => `${ZAHL_2.format(Number(euro) || 0)} €` + +/** 'TT.MM.JJJJ' in Berliner Zeit; ein Wert, der schon so aussieht, bleibt; leer oder ungültig '–'. */ +export function fmtDatum(wert) { + if (typeof wert === 'string' && /^\d{2}\.\d{2}\.\d{4}$/.test(wert)) return wert + const t = berlinTeile(wert, { day: '2-digit', month: '2-digit', year: 'numeric' }) + return t ? `${t.day}.${t.month}.${t.year}` : '–' +} + +/** 'TT.MM.JJ, HH:MM' in Berliner Zeit. */ +export function fmtDatumZeit(wert) { + const t = berlinTeile(wert, { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) + return t ? `${t.day}.${t.month}.${t.year}, ${t.hour}:${t.minute}` : '–' +} + +/** 'TT.MM.' in Berliner Zeit; ungültig ''. */ +export function fmtTagMonat(wert) { + const t = berlinTeile(wert, { day: '2-digit', month: '2-digit' }) + return t ? `${t.day}.${t.month}.` : '' +} + +/** Minuten → '2,5 Std.' | '2 Std.' | '0,75 Std.' (Komma, höchstens zwei Stellen). */ +export const fmtStd = (minuten) => `${ZAHL_STD.format(Math.round(((Number(minuten) || 0) / 60) * 100) / 100)} Std.` + +/** Minuten → '' | '45 min' | '2 h' | '2 h 30 min'. */ +export function fmtMinuten(minuten) { + const m = Math.round(Number(minuten) || 0) + if (m <= 0) return '' + const h = Math.floor(m / 60) + const rest = m % 60 + if (!h) return `${rest} min` + return rest ? `${h} h ${rest} min` : `${h} h` +} + +/** Bytes → '12 B' | '1,5 KB' | '5,0 MB'. */ +export function fmtBytes(bytes) { + const b = Number(bytes) || 0 + if (b < 1024) return `${Math.round(b)} B` + if (b < 1024 * 1024) return `${ZAHL_1.format(b / 1024)} KB` + return `${ZAHL_1.format(b / (1024 * 1024))} MB` +} + +/* ---------- Alt-Module, Navigation, Ereignisse ---------- */ + +/** Adapter für Alt-Module (auftrag-portal.js), die den Body als JSON-String schicken: das neue api() kodiert Objekte selbst. */ +export function legacyRequest(apiFn) { + return (pfad, opts = {}) => { + const { body } = opts + if (typeof body !== 'string') return apiFn(pfad, { ...opts }) + let objekt = body + try { objekt = JSON.parse(body) } catch { /* kein JSON: unverändert weitergeben */ } + return apiFn(pfad, { ...opts, body: objekt }) + } +} + +/** Programmatisch navigieren (nur über den Router); wirft der Router, setzt der Hash die Adresse. */ +export function navigiere(hash, { ersetzen = false } = {}) { + try { + geheZu(hash, { ersetzen }) + } catch { + window.location.hash = hash + } +} + +/** Verknüpfung zu einer Ansicht für die Brücke der Hülle: href plus data-view (und data-project). */ +export function zuAnsicht(name, projektId = '') { + const basis = hashFuerAnsicht(name) ?? '#' + if (!projektId) return { href: basis, 'data-view': name } + return { href: `${basis}${basis.includes('?') ? '&' : '?'}projekt=${encodeURIComponent(projektId)}`, 'data-view': name, 'data-project': projektId } +} + +/** Titel der Etage melden (die Hülle zeigt ihn im Kopf). */ +export function meldeTitel(el, text) { + el.dispatchEvent(new CustomEvent('kn:titel', { bubbles: true, detail: { text } })) +} + +/** Zähler neu laden lassen: was = 'chat' | 'vertraege' | 'alle'. Ohne Dokument tut es nichts. */ +export function meldeZaehlerNeu(was) { + if (typeof document === 'undefined' || typeof document.dispatchEvent !== 'function' || typeof CustomEvent === 'undefined') return + document.dispatchEvent(new CustomEvent('kn:zaehler-neu', { bubbles: true, detail: { was } })) +} + +/* ---------- Merker zwischen Ansichten (einmalig) ---------- */ + +let hinweis = '' +export function merkeHinweis(text) { hinweis = String(text ?? '') } +export function nimmHinweis() { const t = hinweis; hinweis = ''; return t } + +let chatVorgabe = '' +export function setzeChatVorgabe(text) { chatVorgabe = String(text ?? '') } +export function nimmChatVorgabe() { const t = chatVorgabe; chatVorgabe = ''; return t } + +/* ---------- Abo ---------- */ + +/** GET /api/abo → Objekt oder null (jeder Fehler). */ +export async function ladeAbo() { + try { + const antwort = await api('/api/abo') + return antwort && typeof antwort === 'object' ? antwort : null + } catch { + return null + } +} + +/** Gemerkte Funktion eines Wechsels (V11): nur wenn key und projectId nichtleere Texte sind. */ +export function wechselFunktionAus(sub) { + const f = sub?.wechselFunktion + if (!f || typeof f.key !== 'string' || !f.key || typeof f.projectId !== 'string' || !f.projectId) return null + return { key: f.key, projectId: f.projectId } +} + +export { safeUrl } diff --git a/test/knAnsichtHilfen.test.js b/test/knAnsichtHilfen.test.js new file mode 100644 index 0000000..5a668d3 --- /dev/null +++ b/test/knAnsichtHilfen.test.js @@ -0,0 +1,87 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + fmtEuro, fmtDatum, fmtDatumZeit, fmtTagMonat, fmtStd, fmtMinuten, fmtBytes, legacyRequest, meldeZaehlerNeu, merkeHinweis, nimmHinweis, + setzeChatVorgabe, nimmChatVorgabe, wechselFunktionAus, zuAnsicht, meldeTitel, K, ABO_NICHT_GELADEN, navigiere, ladeAbo, safeUrl, +} from '../public/neu/ansicht-hilfen.js' +import { mit, mitDom, abo, aboAntwort, me, projekt, rechnung, nachricht, angebot, ledger, anfrageGutschrift, VORSCHAU, VORSCHAU_SPEICHER, LIMITS, neueWurzel } from './support/knDaten.js' + +test('Formate: Euro mit geschütztem Leerzeichen, Daten in Berliner Zeit', () => { + assert.equal(fmtEuro(50), '50,00 €') + assert.equal(fmtDatum('2026-10-17T10:00:00Z'), '17.10.2026') + assert.equal(fmtDatum('17.10.2026'), '17.10.2026') + assert.equal(fmtDatum(''), '–') + assert.equal(fmtDatumZeit('2026-10-17T10:00:00Z'), '17.10.26, 12:00') + assert.equal(fmtTagMonat('2026-10-17T21:59:59Z'), '17.10.') + assert.equal(fmtTagMonat('2026-10-17T22:00:00Z'), '18.10.') + assert.equal(fmtTagMonat('kaputt'), '') +}) + +test('Formate: Stunden, Minuten, Bytes', () => { + assert.deepEqual([150, 120, 45, 0].map(fmtStd), ['2,5 Std.', '2 Std.', '0,75 Std.', '0 Std.']) + assert.deepEqual([0, 45, 120, 150].map(fmtMinuten), ['', '45 min', '2 h', '2 h 30 min']) + assert.deepEqual([12, 1536, 5 * 1024 * 1024].map(fmtBytes), ['12 B', '1,5 KB', '5,0 MB']) +}) + +test('legacyRequest: String-Body wird zum Objekt, Objekt und fehlender Body bleiben', async () => { + const aufrufe = [] + const alt = legacyRequest(async (pfad, opts) => { aufrufe.push([pfad, opts]); return {} }) + await alt('/a', { method: 'POST', body: '{"x":1}' }); await alt('/b', { method: 'POST', body: { y: 2 } }); await alt('/c') + assert.deepEqual(aufrufe, [['/a', { method: 'POST', body: { x: 1 } }], ['/b', { method: 'POST', body: { y: 2 } }], ['/c', {}]]) +}) + +test('meldeZaehlerNeu ohne Dokument tut nichts, Hinweis und Chat-Vorgabe gelten einmal', () => { + assert.doesNotThrow(() => meldeZaehlerNeu('chat')) + merkeHinweis('Gespeichert.'); assert.equal(nimmHinweis(), 'Gespeichert.'); assert.equal(nimmHinweis(), '') + setzeChatVorgabe('Hallo'); assert.equal(nimmChatVorgabe(), 'Hallo'); assert.equal(nimmChatVorgabe(), '') +}) + +test('wechselFunktionAus: nur vollständige Werte zählen (V11)', () => { + assert.deepEqual(wechselFunktionAus({ wechselFunktion: { key: 'termine', projectId: 'p1', extra: 1 } }), { key: 'termine', projectId: 'p1' }) + for (const sub of [undefined, null, {}, { wechselFunktion: null }, { wechselFunktion: { key: 'termine' } }, { wechselFunktion: { key: '', projectId: 'p1' } }, { wechselFunktion: { key: 1, projectId: 'p1' } }]) assert.equal(wechselFunktionAus(sub), null) +}) + +test('zuAnsicht ist die Brücke der Hülle, meldeTitel steigt zu den Eltern auf', () => mitDom((fx) => { + assert.deepEqual(zuAnsicht('takeover'), { href: '#gutschrift', 'data-view': 'takeover' }) + assert.deepEqual(zuAnsicht('projekt', 'p1'), { href: '#projekt?projekt=p1', 'data-view': 'projekt', 'data-project': 'p1' }) + assert.equal(zuAnsicht('invoices').href, '#rechnungen') + const aussen = fx.document.createElement('div'); const innen = fx.document.createElement('p'); aussen.append(innen); const titel = [] + aussen.addEventListener('kn:titel', (e) => titel.push(e.detail.text)); meldeTitel(innen, 'Zwei'); assert.deepEqual(titel, ['Zwei']) +})) + +test('Bausteinklassen K tragen die Klassen aus basis.css', () => { + assert.equal(K.knopf, 'kn-knopf'); assert.equal(K.primaer, 'kn-knopf-primaer'); assert.equal(K.zweit, 'kn-knopf-zweit'); assert.equal(K.textknopf, 'kn-textknopf') + assert.equal(K.versteckt, 'hidden'); assert.equal(K.pille.gruen, 'kn-pille kn-pille-gruen'); assert.equal(K.pille.rot, 'kn-pille kn-pille-rot') + assert.match(ABO_NICHT_GELADEN, /nicht geladen werden/) +}) + +test('ladeAbo liefert das Objekt, navigiere wirft ohne Router nicht', mit({ 'GET /api/abo': aboAntwort() }, async () => { + const antwort = await ladeAbo() + assert.equal(antwort.subscription, null) + assert.equal(antwort.termsVersion, '2026-09-30-v5') + assert.doesNotThrow(() => navigiere('#chat')) +})) + +test('ladeAbo bei Fehler: null', mit({ 'GET /api/abo': () => ({ status: 500, body: { error: 'kaputt' } }) }, async () => { + assert.equal(await ladeAbo(), null) +})) + +test('Testdaten: Bauer liefern die Formen der Schnittstellen', () => { + assert.equal(me().customer.email, 'anna@example.com') + assert.equal(abo().productKey, 'wartung'); assert.equal(abo({ produkt: 'hosting' }).productKey, 'hosting') + assert.equal(abo().status, 'active'); assert.equal(abo().storage.units, 0) + assert.equal(aboAntwort().products.length, 2); assert.equal(aboAntwort().mailStorage.includedGb, 5) + assert.equal(projekt().id.length > 0, true); assert.equal(rechnung().balance, 0); assert.equal(typeof nachricht().text, 'string') + assert.equal(angebot().months > 0, true); assert.equal(ledger().balanceMinutes >= 0, true); assert.equal(typeof anfrageGutschrift().status, 'string') + assert.equal(VORSCHAU.amountDueCents, 3000); assert.equal(VORSCHAU_SPEICHER.direction, 'down'); assert.equal(LIMITS.maxFiles, 3) + assert.equal(safeUrl('javascript:alert(1)'), ''); assert.equal(safeUrl('https://a.de/x'), 'https://a.de/x') +}) + +test('mit(): unbekannte Pfade antworten 404, Funktionen liefern Status und Body, Neuaufbau je Test', mit({ '/api/x': { a: 1 }, 'POST /api/y': (aufruf) => ({ status: 201, body: { echo: aufruf.body } }) }, async (fx) => { + const w = neueWurzel(fx) + assert.equal(w.parentNode, fx.document.body) + const a = await fetch('/api/x'); assert.equal(a.status, 200); assert.deepEqual(await a.json(), { a: 1 }) + const b = await fetch('/api/y', { method: 'POST', body: JSON.stringify({ q: 1 }) }); assert.equal(b.status, 201); assert.deepEqual(await b.json(), { echo: { q: 1 } }) + const c = await fetch('/api/z'); assert.equal(c.status, 404); assert.deepEqual(await c.json(), { error: 'unbekannt' }) + const ev = new CustomEvent('x', { detail: 1 }); assert.equal(ev.detail, 1); ev.target = 'frei'; assert.equal(ev.target, 'frei') +})) diff --git a/test/knAnsichtQuelltext.test.js b/test/knAnsichtQuelltext.test.js new file mode 100644 index 0000000..55ae2cf --- /dev/null +++ b/test/knAnsichtQuelltext.test.js @@ -0,0 +1,41 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const WURZEL = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +const NEU = path.join(WURZEL, 'public', 'neu') +const MODULE = ['ansicht-hilfen', 'rechnung-offen', 'install-hinweis', 'website-details', 'ansicht-website', 'ansicht-chat', 'ansicht-vertraege', 'vertraege-rechnungen', 'vertraege-abo', 'ansicht-stunden', 'ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift', 'ansicht-passwort'] +const WORTLAUT = ['ansicht-speicher', 'ansicht-bestellen', 'ansicht-wechsel', 'ansicht-gutschrift'] +const vorhanden = MODULE.filter((n) => existsSync(path.join(NEU, `${n}.js`))) + +const ohneKommentare = (q) => q.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"`\\])\/\/[^\n]*/g, '$1') +const lies = (n) => ohneKommentare(readFileSync(path.join(NEU, `${n}.js`), 'utf8')) + +function dateienUnter(ordner, muster) { + const aus = [] + for (const name of readdirSync(path.join(WURZEL, ordner))) { + const pfad = path.join(WURZEL, ordner, name) + if (statSync(pfad).isDirectory()) aus.push(...dateienUnter(path.join(ordner, name), muster)) + else if (muster.test(name)) aus.push(pfad) + } + return aus +} + +test('P2-Module: kein HTML-Einsetzen, nur relative Importe, keine Admin-Verweise', () => { + for (const n of vorhanden) { + const q = lies(n) + assert.doesNotMatch(q, /innerHTML|insertAdjacentHTML|outerHTML|document\.write|console\.log/, n) + assert.doesNotMatch(q, /from\s+'\/|import\(\s*'\//, `${n}: absoluter Import`) + assert.doesNotMatch(q, /admin\.html|kalkulation\.html|\/api\/portal-admin/, n) + } +}) + +test('P2-Module: Du-Form, nur die vier Wortlaut-Dateien behalten die Sie-Form (C14)', () => { + for (const n of vorhanden.filter((x) => !WORTLAUT.includes(x))) assert.doesNotMatch(lies(n), /\b(Sie|Ihr(e[nmrs]?)?|Ihnen)\b/, n) +}) + +test('public/neu/: kein Lila in irgendeiner Textdatei (die Prüfung lila liest jedes geladene Stylesheet samt ungenutzter Regeln)', () => { + for (const pfad of dateienUnter('public/neu', /\.(css|html|js|mjs|svg|webmanifest)$/)) assert.doesNotMatch(readFileSync(pfad, 'utf8'), /#9d3dfe|rgba?\(\s*157\s*,\s*61\s*,\s*254/i, pfad) +}) diff --git a/test/support/knDaten.js b/test/support/knDaten.js new file mode 100644 index 0000000..bb65868 --- /dev/null +++ b/test/support/knDaten.js @@ -0,0 +1,244 @@ +// Testdaten und Rahmen für die Ansichten von P2 (Kundenansicht neu). Formen laut server/routes/abo.js (GET /api/abo), +// routes/customerData.js mit services/customerInvoices.js (Rechnungen), routes/projects.js, routes/chat.js und services/takeoverCredits.js. +import { installFakeDom } from './fakeDom.js' + +const ANTWORTEN = 400 // so viele Antwortfunktionen legt der Fake-fetch bereit; danach antwortet fakeDom mit 200 {} + +// fakeDom schreibt target und ergebnisse in das Ereignis; ein echtes CustomEvent hat nur lesbare Felder. +class EreignisAttrappe { + constructor(typ, init = {}) { + this.type = typ + this.detail = init.detail ?? null + this.bubbles = init.bubbles ?? false + this.cancelable = init.cancelable ?? false + this.target = null + this.currentTarget = null + this.defaultPrevented = false + this.stopped = false + this.ergebnisse = [] + } + + preventDefault() { this.defaultPrevented = true } + + stopPropagation() { this.stopped = true } +} + +// fakeDom kennt auf dem Dokument keine Ereignisse; Tests der Zähler-Meldung brauchen sie. +function ereignisseFuerDokument(document) { + const hoerer = new Map() + document.addEventListener = (typ, fn) => { if (!hoerer.has(typ)) hoerer.set(typ, []); hoerer.get(typ).push(fn) } + document.removeEventListener = (typ, fn) => { const l = hoerer.get(typ); if (l?.includes(fn)) l.splice(l.indexOf(fn), 1) } + document.dispatchEvent = (ev) => { + ev.target = document + ev.currentTarget = document + for (const fn of [...(hoerer.get(ev.type) ?? [])]) ev.ergebnisse.push(fn.call(document, ev)) + return !ev.defaultPrevented + } +} + +function mitAttrappen(fx) { + const vorher = Object.getOwnPropertyDescriptor(globalThis, 'CustomEvent') + Object.defineProperty(globalThis, 'CustomEvent', { value: EreignisAttrappe, writable: true, configurable: true, enumerable: true }) + ereignisseFuerDokument(fx.document) + return () => { + if (vorher) Object.defineProperty(globalThis, 'CustomEvent', vorher) + else delete globalThis.CustomEvent + } +} + +const ohneQuery = (url) => String(url).split('?')[0] + +/** Antwort für einen Aufruf nachschlagen: 'METHOD url', url, 'METHOD pfad', pfad; unbekannt → 404. */ +function beantworte(tabelle, aufruf) { + const pfad = ohneQuery(aufruf.url) + const schluessel = [`${aufruf.method} ${aufruf.url}`, aufruf.url, `${aufruf.method} ${pfad}`, pfad].find((k) => Object.hasOwn(tabelle, k)) + if (!schluessel) return { status: 404, body: { error: 'unbekannt' } } + const wert = tabelle[schluessel] + return typeof wert === 'function' ? wert(aufruf) : { status: 200, body: wert } +} + +/** Test mit Fake-DOM und Fake-fetch. tabelle: { 'GET /api/abo': Objekt | (aufruf) => ({ status, body }) }; der Test bekommt fx (siehe fakeDom.js) mit fx.tabelle. */ +export const mit = (tabelle, fn) => async () => { + const antworten = Array.from({ length: ANTWORTEN }, () => (aufruf) => beantworte(tabelle, aufruf)) + const fx = installFakeDom({ antworten }) + fx.tabelle = tabelle + const zurueck = mitAttrappen(fx) + try { + return await fn(fx) + } finally { + zurueck() + fx.restore() + } +} + +/** Wie mit() ohne Antworten und ohne Warten: fn(fx) läuft sofort (für synchrone Tests). */ +export const mitDom = (fn) => { + const fx = installFakeDom({ antworten: [] }) + const zurueck = mitAttrappen(fx) + try { + return fn(fx) + } finally { + zurueck() + fx.restore() + } +} + +/** Leere Wurzel (div) im Dokument. */ +export const neueWurzel = (fx) => { + const wurzel = fx.document.createElement('div') + fx.document.body.append(wurzel) + return wurzel +} + +export const VORSCHAU = { prorationDate: 1760000000, amountDueCents: 3000, creditCents: 1500, monthlyCents: 5000, nextChargeAt: '2026-11-16T00:00:00.000Z', minTermEnd: '2027-10-16T00:00:00.000Z' } +export const VORSCHAU_SPEICHER = { direction: 'down', units: 1, totalGb: 10, monthlyCents: 100, deltaMonthlyCents: -100, prorationCents: 0, nextChargeAt: '2026-11-16T00:00:00.000Z', effectiveAt: '2026-11-01T00:00:00.000Z' } +export const LIMITS = { maxFiles: 3, maxFileBytes: 8388608, maxTotalBytes: 10485760 } + +/** Antwort von GET /api/auth/me für einen Kunden; over.customer ergänzt den Kunden, alles andere (viewAs, viewId …) die Antwort. */ +export const me = ({ customer = {}, ...over } = {}) => ({ + authenticated: true, + role: 'customer', + customer: { id: 'c1', code: 'K-1001', name: 'Anna Beispiel', companyName: '', email: 'anna@example.com', phone: '', location: '', customerStatus: 'customer', portalAccessEnabled: true, ...customer }, + ...over, +}) + +/** Eintrag aus GET /api/projects. */ +export const projekt = (over = {}) => ({ + id: 'p1', + projectName: 'Bäckerei Muster', + subdomain: 'baeckerei', + previewUrl: 'https://baeckerei.project.webklar.com', + liveDomain: '', + status: 'in_progress', + provisioningStatus: 'ready', + updatedAt: '2026-10-14T09:00:00.000Z', + ...over, +}) + +/** Eintrag aus GET /api/invoices (Stripe-Form, Beträge in Euro). */ +export const rechnung = (over = {}) => ({ + id: 'in_1', + source: 'stripe', + number: 'RE-2026-001', + date: '2026-10-01', + dueDate: '2026-10-15', + amount: 50, + balance: 0, + status: 'Bezahlt', + paid: true, + cancelled: false, + overdue: false, + portalUrl: '', + hasPdf: true, + projekt: '', + ...over, +}) + +/** Nachricht aus GET /api/chat/messages. */ +export const nachricht = (over = {}) => ({ + id: 'm1', + sender: 'customer', + senderName: 'Anna Beispiel', + text: 'Hallo, ich hätte eine Frage.', + attachments: [], + createdAt: '2026-10-14T08:30:00.000Z', + ...over, +}) + +/** Angebot (offerView) aus GET /api/abo → offers. */ +export const angebot = (over = {}) => ({ + id: 'o1', + ref: 'A-1', + createdAt: '2026-10-01T10:00:00.000Z', + status: 'offen', + scope: 'abo', + productKey: 'wartung', + months: 2, + monthlyPriceCents: 2500, + regularPriceCents: 5000, + savingsCents: 5000, + text: 'Erste 2 Monate je 25,00 € statt 50,00 €', + validUntil: '2026-11-30T00:00:00.000Z', + note: '', + ...over, +}) + +/** subscription aus GET /api/abo (publicSubscription). produkt: 'wartung' (Vorgabe) oder 'hosting'. */ +export const abo = ({ produkt = 'wartung', ...over } = {}) => ({ + status: 'active', + active: true, + paymentProblem: false, + startedAt: '2026-09-01T10:00:00.000Z', + currentPeriodEnd: '2026-11-01T00:00:00.000Z', + cancelAtPeriodEnd: false, + cancelAt: null, + minTermEnd: produkt === 'hosting' ? '2027-09-01T10:00:00.000Z' : '2027-09-01T10:00:00.000Z', + endedAt: null, + priceAmount: produkt === 'hosting' ? 2000 : 5000, + startChoice: null, + serviceStartsAt: null, + offerText: null, + productKey: produkt, + termsVersion: '2026-09-30-v5', + termStartAt: null, + termsUrl: '/abo-bedingungen.html', + termsPdfUrl: '/WEBklar-Abo-Vertragsunterlagen.pdf', + storage: { units: 0, pendingSetup: false, pendingReduction: null }, + canChangeStorage: true, + canSwitch: produkt === 'hosting', + switchPending: null, + withdrawals: [], + withdrawalOpen: false, + withdrawalUntil: null, + ...over, +}) + +/** Stundenkonto (computeLedger). */ +export const ledger = (over = {}) => ({ + balanceMinutes: 150, + grantedMinutes: 240, + usedMinutes: 90, + overageMinutes: 0, + expiredMinutes: 0, + nextExpiry: { minutes: 150, at: '2027-01-01T00:00:00.000Z' }, + credits: [{ grantedAt: '2026-10-01T00:00:00.000Z', expiresAt: '2027-01-01T00:00:00.000Z', minutes: 240, remaining: 150, expired: false }], + usage: [{ date: '2026-10-10T00:00:00.000Z', minutes: 90, coveredMinutes: 90, overageMinutes: 0, description: 'Textänderungen' }], + ...over, +}) + +const PRODUKTE = [ + { key: 'hosting', name: 'WEBklar Hosting-Abo', amountCents: 2000, minTermMonths: 12, minutesPerMonth: 0, features: [['Hosting', 'Deine Website online']] }, + { key: 'wartung', name: 'WEBklar Wartungs-Abo', amountCents: 5000, minTermMonths: 12, minutesPerMonth: 60, features: [['Wartung', 'Änderungen und Pflege']] }, +] + +/** Ganze Antwort von GET /api/abo. over.subscription ersetzt das Abo (null = kein Abo). */ +export const aboAntwort = (over = {}) => ({ + available: true, + testAccount: false, + termsVersion: '2026-09-30-v5', + lateStartAt: '2026-10-30T00:00:00.000Z', + products: PRODUKTE.map((p) => ({ ...p, features: p.features.map((f) => [...f]) })), + mailStorage: { includedGb: 5, gbPerUnit: 5, unitPriceCents: 100, maxUnits: 10 }, + offers: { hosting: null, wartung: null }, + ledger: null, + subscription: null, + ...over, +}) + +/** Antrag aus GET /api/abo/takeover-credits → requests (customerView). */ +export const anfrageGutschrift = (over = {}) => ({ + id: 'g1', + ref: 'G-1', + createdAt: '2026-10-05T10:00:00.000Z', + services: 'Domain', + provider: 'Anbieter GmbH', + domain: 'beispiel.de', + invoiceAmountCents: 1200, + billingMonths: 12, + paidUntil: '2027-03-01T00:00:00.000Z', + status: 'offen', + question: '', + amountCents: 0, + decisionNote: '', + ...over, +})