diff --git a/public/neu/funktion-einschalten.js b/public/neu/funktion-einschalten.js index ec8f9f8..54fe2bd 100644 --- a/public/neu/funktion-einschalten.js +++ b/public/neu/funktion-einschalten.js @@ -3,6 +3,10 @@ Die Vorprüfung spiegelt aboDeckt (server/services/aboFunktionen.js) nur, um den richtigen Ort zu wählen; entschieden wird immer vom Server (POST /api/funktionen//einschalten, 409 mit Code). Kundentexte sind Festwerte (TEXTE), Fremdtext kommt nie als HTML in die Seite. */ +import { h, leeren } from './dom.js' +import { api as apiStandard } from './api.js' +import { geheZu as geheZuStandard } from './router.js' + /** Etage 2 je Funktion (Schlüssel der Hülle in FUNKTIONS_MODULE, Hash `#?projekt=`). */ export const ETAGE_FUER = Object.freeze({ news: 'news', termine: 'termine', shop: 'webshop', unterkunft: 'buchungen', zeiten: 'zeiten', analyse: 'analyse' }) @@ -196,3 +200,96 @@ export function fehlerAktion(fehler, { eintrag }) { return meldung(servertext) } } + +/* ------------------------------------------------------------------------------------------- Bestätigungsseite --- */ + +/** + * Die Bestätigungsseite zum Einschalten zeichnen (Aufbau und Zustand von Häkchen und Knopf; Senden steht weiter unten). + * @param {HTMLElement} behaelter Platz, in dem die Seite gezeichnet wird (sein Inhalt wird ersetzt) + * @param {object} o + * key, projectId; daten = Antwort von GET /api/funktionen (Abo-Modell); kundenartWirksam (leer oder unbekannt zählt als privat); + * viewAs (Kundenansicht der Mitarbeiter: nichts einschalten); imModul (ein Funktionsmodul bettet die Seite ein: kein „Abbrechen“); + * jetzt, api, geheZu, speicher (einspeisbar für Tests); bei = { fertig, weiter, abbrechen, neuLaden } (alle optional) + * @returns {HTMLElement} section.kn-fn-einschalten + */ +export function renderEinschalten(behaelter, o) { + const { key, projectId, viewAs = false, imModul = false, jetzt = () => new Date(), bei = {} } = o + let daten = o.daten + const kundenart = () => (o.kundenartWirksam === undefined ? daten?.kundenart : o.kundenartWirksam) + const z = { haken: false, serverAb: '', sperreAb: '', sendet: false, meldung: '' } + const abschnitt = h('section', { class: 'kn-fn-einschalten', 'data-key': key, 'data-projekt': projectId }) + let knopf = null + let meldungEl = null + + const eintragVon = () => (Array.isArray(daten?.katalog) ? daten.katalog.find((e) => e?.key === key) ?? null : null) + const lageJetzt = () => beginnLage({ kundenart: kundenart(), abo: daten?.abo, jetzt: jetzt(), serverAb: z.serverAb }) + + function aktualisiereKnopf() { + if (!knopf) return + const lage = lageJetzt() + const fehltHaken = lage.noetig && !z.haken + const ab = z.sperreAb || (fehltHaken ? lage.ab : '') + knopf.disabled = viewAs || z.sendet || fehltHaken || Boolean(z.sperreAb) + knopf.textContent = ab && datumKurz(ab) ? `Einschalten ab ${datumKurz(ab)}` : 'Einschalten' + } + + function setzeMeldung(text) { + z.meldung = text + if (meldungEl) meldungEl.textContent = text + } + + function fuelle() { + const eintrag = eintragVon() + const consent = consentVon(daten, key) + const items = consent?.items ?? [] + const item = (id) => items.find((i) => i?.id === id) ?? null + const zeile = item('einschalten-zeile') + const hinweis = item('hinweis') + const beginn = item('beginn') + const lage = lageJetzt() + const register = Boolean(eintrag && zeile && (!lage.noetig || beginn)) + + const seiten = Array.isArray(daten?.projects) ? daten.projects : [] + const websiteName = seiten.find((p) => p?.projectId === projectId)?.projectName + const abo = daten?.abo?.produkt ?? eintrag?.enthaltenIn + const fakten = [ + seiten.length > 1 ? ['Website', String(websiteName ?? '')] : null, + ['Leistung', 'Einrichtung durch WEBklar'], + ['Dauer', String(eintrag?.einrichtung ?? '')], + ['Laufzeit', `läuft, solange dein ${abo === 'hosting' ? 'Hosting-Abo' : 'Wartungs-Abo'} läuft`], + ].filter(Boolean) + + const box = h('input', { type: 'checkbox', 'data-haken': 'beginn', on: { change: () => { z.haken = box.checked; aktualisiereKnopf() } } }) + box.checked = z.haken + meldungEl = h('p', { class: 'kn-fn-meldung', role: 'alert', 'data-meldung': true }, z.meldung) + knopf = register ? h('button', { type: 'button', class: 'kn-fn-knopf', 'data-aktion': 'einschalten', on: { click: () => senden() } }, 'Einschalten') : null + + const kinder = [ + h('h2', null, `${String(eintrag?.name ?? key)} einschalten`), + h('dl', { class: 'kn-fn-fakten' }, fakten.map(([name, wert]) => h('div', null, h('dt', null, name), h('dd', null, wert)))), + h('p', { class: 'kn-fn-konto' }, TEXTE.stundenkonto), + register + ? [ + h('p', { class: 'kn-fn-recht' }, linkzeile(zeile.text, eintrag.unterlagen).map((s) => (s.href ? h('a', { href: s.href, target: '_blank', rel: 'noopener noreferrer' }, s.text) : s.text))), + hinweis ? h('p', { class: 'kn-fn-hinweis' }, String(hinweis.text ?? '')) : null, + lage.noetig ? h('label', { class: 'kn-fn-haken' }, box, h('span', null, String(beginn.text ?? ''))) : null, + ] + : h('p', { class: 'kn-fn-info' }, TEXTE.registerFehlt), + viewAs ? h('p', { class: 'kn-fn-info' }, TEXTE.kundenansicht) : null, + meldungEl, + h('div', { class: 'kn-fn-aktionen' }, + knopf ?? h('button', { type: 'button', class: 'kn-fn-knopf', 'data-aktion': 'chat', on: { click: () => (o.geheZu ?? geheZuStandard)('#chat') } }, 'Im Chat schreiben'), + imModul ? null : h('button', { type: 'button', class: 'kn-fn-knopf kn-fn-knopf--zweit', 'data-aktion': 'abbrechen', on: { click: () => bei.abbrechen?.() } }, 'Abbrechen')), + ] + abschnitt.replaceChildren(...kinder.flat(Infinity).filter(Boolean)) + aktualisiereKnopf() + } + + async function senden() { + // folgt in Task 4 + } + + leeren(behaelter).append(abschnitt) + fuelle() + return abschnitt +} diff --git a/test/knEinschaltenSeite.test.js b/test/knEinschaltenSeite.test.js new file mode 100644 index 0000000..1e9f799 --- /dev/null +++ b/test/knEinschaltenSeite.test.js @@ -0,0 +1,96 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { installFakeDom } from './support/fakeDom.js' +import { renderEinschalten } from '../public/neu/funktion-einschalten.js' +import { daten, projekt } from './support/knFunktionenDaten.js' + +const JETZT = () => new Date('2026-10-20T10:00:00Z') +const FRIST = '2026-10-30T22:59:59.999Z' +const OK = { feature: { state: 'setup' }, ticketWoid: '100301' } +const mit = (fn) => async () => { const fx = installFakeDom(); try { await fn(fx) } finally { fx.restore() } } +function seite(fx, { antwort = () => OK, ...opt } = {}) { + const b = fx.document.createElement('div'); fx.document.body.append(b) + const aufrufe = []; const rueck = [] + const api = async (pfad, o = {}) => { const a = { pfad, method: o.method ?? 'GET', body: o.body }; aufrufe.push(a); return antwort(a) } + const bei = { fertig: (a) => rueck.push(['fertig', a]), weiter: (w) => rueck.push(['weiter', w]), abbrechen: () => rueck.push(['abbrechen']) } + const el = renderEinschalten(b, { key: 'news', projectId: 'p1', daten: daten(), kundenartWirksam: 'unternehmen', jetzt: JETZT, api, bei, ...opt }) + return { b, el, aufrufe, rueck } +} +const knopf = (fx, b) => fx.finde(b, '[data-aktion="einschalten"]') +const haken = (fx, b) => fx.alle(b, 'input[type="checkbox"]') + +test('Betrieb: Fakten, Stundenkonto-Satz, Linkzeile, Knopf, kein Häkchen, kein Preis', mit((fx) => { + const { b } = seite(fx) + const t = fx.text(b) + assert.match(t, /News-Pinnwand einschalten/) + assert.match(t, /Leistung.*Einrichtung durch WEBklar/) + assert.match(t, /Dauer.*meist in wenigen Tagen/) + assert.match(t, /Laufzeit.*läuft, solange dein Wartungs-Abo läuft/) + assert.match(t, /Die Einrichtung geht von deinem Stundenkonto ab, auch wenn das Guthaben noch nicht reicht\. Extrakosten entstehen keine\./) + assert.match(t, /Es gelten Leistungsbeschreibung und Auftragsverarbeitung \(Fassung 2026-10-05\)\./) + assert.deepEqual(fx.alle(b, 'a').map((a) => a.getAttribute('href')), ['/rechtliches/leistungsbeschreibung-news-2026-10-05.html', '/rechtliches/avv-news-2026-10-05.html']) + assert.deepEqual(fx.alle(b, 'a').map((a) => [a.getAttribute('target'), a.getAttribute('rel')]), [['_blank', 'noopener noreferrer'], ['_blank', 'noopener noreferrer']]) + assert.equal(haken(fx, b).length, 0) + assert.equal(knopf(fx, b).textContent, 'Einschalten') + assert.ok(fx.finde(b, '[data-aktion="abbrechen"]')) + assert.doesNotMatch(t, /€|Euro/) +})) + +test('Privatkunde in der Frist: Häkchen sichtbar, Knopf nennt das Datum und bleibt gesperrt, bis das Häkchen sitzt', mit(async (fx) => { + const { b } = seite(fx, { kundenartWirksam: 'privat', daten: daten({ abo: { widerrufsfristBis: FRIST } }) }) + const h1 = fx.finde(b, 'input[data-haken="beginn"]') + assert.equal(h1.checked, false) + assert.match(fx.text(b), /Bitte beginnt sofort mit der Einrichtung\./) + assert.deepEqual([knopf(fx, b).textContent, knopf(fx, b).disabled], ['Einschalten ab 31.10.', true]) + await fx.klick(h1) + assert.deepEqual([knopf(fx, b).textContent, knopf(fx, b).disabled], ['Einschalten', false]) + await fx.klick(h1) + assert.equal(knopf(fx, b).disabled, true) +})) + +test('Häkchen nie für Betrieb, nie nach Fristende; leere Kundenart zählt als privat', mit((fx) => { + const d = daten({ abo: { widerrufsfristBis: FRIST } }) + assert.equal(haken(fx, seite(fx, { daten: d, kundenartWirksam: 'unternehmen' }).b).length, 0) + assert.equal(haken(fx, seite(fx, { daten: d, kundenartWirksam: 'privat', jetzt: () => new Date('2026-10-31T09:00:00Z') }).b).length, 0) + assert.equal(haken(fx, seite(fx, { daten: d, kundenartWirksam: '' }).b).length, 1) +})) + +test('Register fehlt: kein Absenden, freundlicher Text', mit((fx) => { + const { b } = seite(fx, { daten: daten({ katalogEdit: { news: { consent: null } } }) }) + assert.match(fx.text(b), /Einschalten ist gerade nicht möglich\. Schreib uns im Chat\./) + assert.equal(knopf(fx, b), null) +})) + +test('Kundenansicht (viewAs): Knopf gesperrt mit Hinweis', mit((fx) => { + const { b } = seite(fx, { viewAs: true }) + assert.equal(knopf(fx, b).disabled, true) + assert.match(fx.text(b), /In der Kundenansicht kann nichts eingeschaltet werden\./) +})) + +test('Fremdtext: HTML in Funktions- und Websitenamen nur als Text; Websitezeile erst ab zwei Websites', mit((fx) => { + const d = daten({ katalogEdit: { news: { name: '' } }, projekte: [projekt('p1', 'Café'), projekt('p2', 'Kiosk')] }) + const { b } = seite(fx, { daten: d }) + assert.equal(fx.alle(b, 'img').length + fx.alle(b, 'b').length, 0) + assert.match(fx.text(b), / einschalten/) + assert.match(fx.text(b), /Website.*Café<\/b>/) + assert.doesNotMatch(fx.text(seite(fx).b), /Website/) +})) + +test('Hinweis ohne Haken (N17): über dem Knopf, nach der Linkzeile; Register ohne beginn-Item sperrt nur, wenn das Häkchen nötig ist', mit((fx) => { + const { b } = seite(fx, { key: 'shop' }) + const hinweis = fx.finde(b, 'p.kn-fn-hinweis') + assert.match(fx.text(hinweis), /^Du verkaufst in eigenem Namen/) + const reihe = fx.alle(b, '*').map((el) => el.className) + assert.ok(reihe.indexOf('kn-fn-recht') < reihe.indexOf('kn-fn-hinweis') && reihe.indexOf('kn-fn-hinweis') < reihe.findIndex((c) => c.includes('kn-fn-knopf'))) + assert.equal(fx.finde(seite(fx).b, 'p.kn-fn-hinweis'), null) + const ohneBeginn = daten({ katalogEdit: { news: { consent: { version: '2026-10-20', knopf: 'Einschalten', items: [{ id: 'einschalten-zeile', text: 'Es gilt die Leistungsbeschreibung (Fassung 2026-10-05).', haken: false }] } } } }) + assert.ok(knopf(fx, seite(fx, { daten: ohneBeginn }).b)) // Betrieb braucht das Item nicht + const privat = seite(fx, { daten: { ...ohneBeginn, abo: { ...ohneBeginn.abo, widerrufsfristBis: FRIST } }, kundenartWirksam: 'privat' }) + assert.equal(knopf(fx, privat.b), null) // Privatkunde in der Frist braucht es + assert.match(fx.text(privat.b), /Einschalten ist gerade nicht möglich/) +})) + +test('Abo: Laufzeit nennt das Abo; Hosting-Abo bei einer Funktion des Hosting-Abos', mit((fx) => { + const d = daten({ abo: { produkt: 'hosting' }, katalogEdit: { news: { enthaltenIn: 'hosting' } } }) + assert.match(fx.text(seite(fx, { daten: d }).b), /Laufzeit.*läuft, solange dein Hosting-Abo läuft/) +}))