diff --git a/public/funktionen.js b/public/funktionen.js
index 50abfed..b75d396 100644
--- a/public/funktionen.js
+++ b/public/funktionen.js
@@ -151,34 +151,72 @@ function analyseTile(projects) {
}
/* Terminplaner: Kachel nur, wenn der Schalter es erlaubt (termine.verfuegbar), sonst "Kommt bald". Die Ansicht selbst
- (termine.js) wird von app.js geladen; hier steht nur die Kachel, damit funktionen.js keinen weiteren Import braucht. */
+ (termine.js) wird von app.js geladen; hier steht nur die Kachel, damit funktionen.js keinen weiteren Import braucht.
+ termine (Antwort von /api/funktionen) traegt bei verfuegbar: true bestellbar (true | false | null = Abo nicht lesbar),
+ grund (kein_abo | gekuendigt | nicht_aktiv | null), preis und einrichtung. Das Abo betrifft nur die Bestellung: ohne laufendes
+ Abo gibt es im Zustand Aus keinen Weg in die Ansicht; wer schon bestellt hat oder den Terminplaner nutzt, behaelt seinen. */
const TERMINE_TEXT = 'Deine Kundschaft bucht Termine direkt über deine Website.'
const termineOf = (project) => project.features?.termine || { state: 'off' }
// Doppelt zu anfragenText in termine-view.js: funktionen.js darf dort nichts importieren (die Kachel testet test/funktionenTiles.test.js
// mit einem umgebogenen Import). Aenderungen an beiden Stellen machen.
const anfragenText = (n) => (n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`)
-function pickTermineProject(projects) {
- return projects.find((p) => termineOf(p).state === 'active') || projects.find((p) => termineOf(p).state === 'disabled') || projects[0]
+// Abo-Hinweise der Kachel. Kopie von aboMeldung (server/services/featureOrder.js, einzige Quelle der Abo-Hinweistexte) fuer den
+// Terminplaner; test/funktionenTiles.test.js haelt beide gleich. kein_abo steht wie bei der Analyse im Wortlaut der Kachel.
+const TERMINE_ABO_TEXTE = {
+ gekuendigt: 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.',
+ nicht_aktiv: 'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.',
+ unbekannt: 'Das Abo konnte gerade nicht geprüft werden.',
+}
+const KEIN_ABO_HINWEIS = 'Im Wartungs-Abo enthalten. Zu Verträge '
+
+function termineAboHinweis(angebot) {
+ if (angebot.bestellbar !== false) return TERMINE_ABO_TEXTE.unbekannt // null (Lesefehler) oder Feld fehlt: nicht geprueft
+ if (angebot.grund === 'kein_abo') return KEIN_ABO_HINWEIS
+ return Object.hasOwn(TERMINE_ABO_TEXTE, angebot.grund) && angebot.grund !== 'unbekannt' ? TERMINE_ABO_TEXTE[angebot.grund] : TERMINE_ABO_TEXTE.unbekannt
}
-function termineTile(projects) {
- const states = projects.map((p) => termineOf(p).state)
- const active = states.filter((s) => s === 'active').length
+// Entwurf: Funktionsdokument im Zustand Aus mit siteKey (entsteht beim ersten Speichern des Fragebogens)
+const hatEntwurf = (project) => termineOf(project).state === 'off' && Boolean(termineOf(project).siteKey)
+
+// Zielprojekt: aktiv, dann ordered/setup, dann abgeschaltet, dann Entwurf, dann das erste
+function pickTermineProject(projects) {
+ return projects.find((p) => termineOf(p).state === 'active')
+ || projects.find((p) => isSetup(termineOf(p).state))
+ || projects.find((p) => termineOf(p).state === 'disabled')
+ || projects.find(hatEntwurf)
+ || projects[0]
+}
+
+function termineTile(projects, angebot = {}) {
+ const active = projects.filter((p) => termineOf(p).state === 'active').length
const target = pickTermineProject(projects)
- const disabled = !active && states.length > 0 && states.every((s) => s === 'disabled')
- const pill = active ? ['Aktiv', 'on'] : disabled ? ['Abgeschaltet', 'off'] : ['Aus', 'off']
- let extra
- let action
- if (active) {
+ const state = target ? termineOf(target).state : 'off'
+ const knopf = (text) => `${text} `
+ let pill = ['Aus', 'off']
+ let extra = ''
+ let action = ''
+ if (state === 'active') {
+ pill = ['Aktiv', 'on']
extra = '
'
- action = `Termine öffnen `
- } else if (disabled) {
+ action = knopf('Termine öffnen')
+ } else if (state === 'ordered') {
+ pill = ['Zahlung offen', 'setup']
+ action = knopf('Status ansehen')
+ } else if (state === 'setup') {
+ pill = ['In Einrichtung', 'setup']
+ action = knopf('Status ansehen')
+ } else if (state === 'disabled') {
+ pill = ['Abgeschaltet', 'off']
extra = 'Der Terminplaner ist für diese Website abgeschaltet. Deine Termine kannst du noch ansehen und exportieren.
'
- action = `Termine ansehen `
+ action = knopf('Termine ansehen')
+ } else if (!target) {
+ extra = 'Sobald deine Website angelegt ist, kannst du den Terminplaner einrichten.
'
+ } else if (angebot.bestellbar === true) {
+ const preis = typeof angebot.preis === 'string' && angebot.preis.trim() ? ` · ${esc(angebot.preis)}` : ''
+ action = knopf(hatEntwurf(target) ? 'Fragebogen fortsetzen' : `Terminplaner einrichten${preis}`)
} else {
- extra = 'Wir schalten den Terminplaner für dich frei.
'
- action = 'Chat öffnen '
+ extra = `${termineAboHinweis(angebot)}
`
}
const count = projects.length > 1 && active ? `aktiv für ${active} von ${projects.length} Websites
` : ''
return `
@@ -217,7 +255,7 @@ export async function initFunktionen(root) {
root.innerHTML = `
${newsTile(projects, newsOffer(features).price)}
- ${termine?.verfuegbar ? termineTile(projects) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
+ ${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
${analyseTile(projects)}
`
root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js
index 160c52a..9c862ff 100644
--- a/server/routes/funktionen.js
+++ b/server/routes/funktionen.js
@@ -16,6 +16,7 @@ import {
} from '../services/siteFeatures.js'
import { nachweisTexte, speichereZustimmung, zustimmungFuerSitzung, zustimmungsfassungen } from '../services/featureConsents.js'
import { termineFuerKunde } from '../services/termine/schalter.js'
+import { aboMemo } from '../services/aboStatus.js'
import { featureConfig, featureHaken, featureKeys, featureSperre, oeffentlicheAnsicht } from '../services/featureRegistry.js'
import { bestellFehler, istFortsetzen, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js'
@@ -88,6 +89,29 @@ function analyseProjectId(value) {
return value
}
+const ABO_GRUENDE = ['kein_abo', 'gekuendigt', 'nicht_aktiv']
+
+/**
+ * Terminplaner-Teil der Antwort von GET /api/funktionen. Nicht verfuegbar (Schalter, Pilotliste): genau { verfuegbar: false },
+ * das Abo wird dann nicht gelesen. Verfuegbar: dazu bestellbar (true | false | null), grund (kein_abo | gekuendigt | nicht_aktiv |
+ * null), preis und einrichtung aus dem Register. Ein Lesefehler im Abo ergibt bestellbar: null und kostet die Liste nichts; im
+ * Log stehen nur Art und Status, nie der Text (Appwrite-Texte koennten Namen oder IDs enthalten).
+ */
+async function termineAngebot(email, customerId) {
+ if (!termineFuerKunde(email)) return { verfuegbar: false }
+ const register = featureConfig('termine')
+ let bestellbar = null
+ let grund = null
+ try {
+ const abo = await aboMemo().bestellbar(customerId)
+ bestellbar = abo?.ok === true
+ grund = bestellbar || !ABO_GRUENDE.includes(abo?.grund) ? null : abo.grund
+ } catch (err) {
+ console.error(`[funktionen] Abo für den Terminplaner nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
+ }
+ return { verfuegbar: true, bestellbar, grund, preis: register?.priceLabel ?? '', einrichtung: register?.setupEstimate ?? '' }
+}
+
// Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet
router.get('/', requireSession, async (req, res) => {
try {
@@ -114,8 +138,9 @@ router.get('/', requireSession, async (req, res) => {
ustHinweis: config.ustHinweis,
setupEstimate: config.funktionen.setupEstimate,
consentText: config.funktionen.consentText,
- // Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel
- termine: { verfuegbar: termineFuerKunde(req.session.email) },
+ // Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann auch
+ // mit dem Abo des Kunden (bestellbar, grund) und dem Preis des Registers
+ termine: await termineAngebot(req.session.email, customerId),
})
} catch (err) {
fail(res, err, 'Die Funktionen konnten gerade nicht geladen werden. Bitte lade die Seite gleich noch einmal.')
diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js
index 59469f0..a4ee08b 100644
--- a/test/funktionenTermine.test.js
+++ b/test/funktionenTermine.test.js
@@ -148,20 +148,93 @@ const termineMit = (mode, pilotEmails = []) => {
config.termine.pilotEmails = pilotEmails
}
after(() => termineMit(MODUS.mode, MODUS.pilotEmails))
-const verfuegbar = async (c = cookie) => (await get('/api/funktionen', { cookie: c })).termine
+const termineAntwort = async (c = cookie) => (await get('/api/funktionen', { cookie: c })).termine
+const verfuegbar = async (c = cookie) => (await termineAntwort(c)).verfuegbar
-test('GET /api/funktionen: termine.verfuegbar je Modus und Pilot-E-Mail', async () => {
+test('GET /api/funktionen: termine.verfuegbar je Modus und Pilot-E-Mail; false bleibt exakt { verfuegbar: false }', async () => {
termineMit('off')
- assert.deepEqual(await verfuegbar(), { verfuegbar: false })
+ assert.deepEqual(await termineAntwort(), { verfuegbar: false })
termineMit('pilot', ['k@example.invalid'])
- assert.deepEqual(await verfuegbar(), { verfuegbar: true })
- assert.deepEqual(await verfuegbar(customerCookie('c2')), { verfuegbar: false }, 'Kunde nicht in der Pilotliste')
+ assert.equal(await verfuegbar(), true)
+ assert.deepEqual(await termineAntwort(customerCookie('c2')), { verfuegbar: false }, 'Kunde nicht in der Pilotliste')
termineMit('pilot', [])
- assert.deepEqual(await verfuegbar(), { verfuegbar: false })
+ assert.deepEqual(await termineAntwort(), { verfuegbar: false })
termineMit('pilot', ['*'])
- assert.deepEqual(await verfuegbar(customerCookie('c2')), { verfuegbar: true })
+ assert.equal(await verfuegbar(customerCookie('c2')), true)
termineMit('on')
- assert.deepEqual(await verfuegbar(customerCookie('c2')), { verfuegbar: true })
+ assert.equal(await verfuegbar(customerCookie('c2')), true)
+})
+
+// ---- Abo-Felder der Kachel (C10a): bestellbar, grund, preis, einrichtung nur bei verfuegbar: true
+const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch }))
+const angebot = (bestellbar, grund) => ({
+ verfuegbar: true,
+ bestellbar,
+ grund,
+ preis: config.features.termine.priceLabel,
+ einrichtung: config.features.termine.setupEstimate,
+})
+
+test('GET /api/funktionen: Kunde mit Wartungs-Abo => bestellbar true, grund null, Preis und Einrichtung aus dem Register', async (t) => {
+ t.after(() => aw.seed('subscriptions', hostingDoc))
+ termineMit('pilot', ['*'])
+ wartung()
+ const termine = await termineAntwort()
+ assert.deepEqual(termine, angebot(true, null))
+ assert.ok(termine.preis && termine.einrichtung, 'das Register liefert beide Werte')
+ assert.deepEqual(Object.keys(termine), ['verfuegbar', 'bestellbar', 'grund', 'preis', 'einrichtung'])
+})
+
+test('GET /api/funktionen: ohne Wartungs-Abo (nur Hosting, gar kein Abo, beendet) => bestellbar false, grund kein_abo', async (t) => {
+ t.after(() => aw.seed('subscriptions', hostingDoc))
+ termineMit('pilot', ['*'])
+ assert.deepEqual(await termineAntwort(), angebot(false, 'kein_abo'), 'c1 hat seit seedFixtures nur Hosting')
+ assert.deepEqual(await termineAntwort(customerCookie('c3')), angebot(false, 'kein_abo'), 'c3 hat gar kein Abo')
+ wartung({ status: 'canceled', endedAt: '2026-09-01T00:00:00.000Z' })
+ assert.deepEqual(await termineAntwort(), angebot(false, 'kein_abo'), 'beendetes Wartungs-Abo')
+})
+
+test('GET /api/funktionen: Kuendigung vorgemerkt => grund gekuendigt; past_due und trialing => nicht_aktiv', async (t) => {
+ t.after(() => aw.seed('subscriptions', hostingDoc))
+ termineMit('pilot', ['*'])
+ wartung({ cancelAtPeriodEnd: true })
+ assert.deepEqual(await termineAntwort(), angebot(false, 'gekuendigt'))
+ wartung({ cancelAt: '2026-11-30T00:00:00.000Z' })
+ assert.deepEqual(await termineAntwort(), angebot(false, 'gekuendigt'))
+ wartung({ status: 'past_due' })
+ assert.deepEqual(await termineAntwort(), angebot(false, 'nicht_aktiv'))
+ wartung({ status: 'trialing' })
+ assert.deepEqual(await termineAntwort(), angebot(false, 'nicht_aktiv'))
+})
+
+test('GET /api/funktionen: Lesefehler im Abo => bestellbar null, grund null, die Projekte kommen trotzdem; nichts Technisches nach aussen', async (t) => {
+ t.after(() => aw.seed('subscriptions', hostingDoc))
+ const fehler = t.mock.method(console, 'error', () => {})
+ termineMit('pilot', ['*'])
+ wartung()
+ aw.failNext({ collection: 'subscriptions', status: 500, message: 'geheimer Appwrite-Text' })
+ const res = await get('/api/funktionen', { cookie })
+ assert.deepEqual(res.termine, angebot(null, null))
+ for (const id of ['pt1', 'pt2']) assert.ok(res.projects.some((p) => p.projectId === id), `Projekt ${id} ist in der Liste`)
+ assert.equal(featuresOf({ body: res }, 'pt1').termine.state, 'active', 'die Zustaende der Projekte bleiben')
+ assert.ok(!JSON.stringify(res).includes('geheimer'))
+ assert.ok(fehler.mock.calls.every((aufruf) => !String(aufruf.arguments[0]).includes('geheimer')), 'auch nicht im Log')
+ assert.ok(fehler.mock.calls.length >= 1, 'der Fehler steht im Log')
+ // der naechste Aufruf liest wieder, nichts wird gemerkt
+ assert.deepEqual(await termineAntwort(), angebot(true, null))
+})
+
+test('GET /api/funktionen: ist der Terminplaner nicht verfuegbar, wird das Abo nicht gelesen', async (t) => {
+ const echt = globalThis.fetch
+ const abfragen = []
+ globalThis.fetch = async (url, optionen) => {
+ if (/\/collections\/subscriptions\/documents/.test(String(url))) abfragen.push(String(url))
+ return echt(url, optionen)
+ }
+ t.after(() => (globalThis.fetch = echt))
+ termineMit('off')
+ assert.deepEqual(await termineAntwort(), { verfuegbar: false })
+ assert.equal(abfragen.length, 0, abfragen.join(' | '))
})
test('GET /api/funktionen: Projekte tragen features.termine und News wie bisher', async () => {
diff --git a/test/funktionenTiles.test.js b/test/funktionenTiles.test.js
index a73f019..ac7dc23 100644
--- a/test/funktionenTiles.test.js
+++ b/test/funktionenTiles.test.js
@@ -4,6 +4,8 @@ import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
+import { aboMeldung } from '../server/services/featureOrder.js'
+import { featureConfig } from '../server/services/featureRegistry.js'
// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js werden auf die Dateien umgeleitet)
const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-'))
@@ -144,9 +146,11 @@ test('statisch: News postet weiter consent: true an /api/funktionen/news/checkou
})
// ---- Terminplaner-Kachel: nur mit Schalter (termine.verfuegbar), Zustand je Projekt aus features.termine
-const withTermine = (id, state) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news, termine: { state } } })
+const withTermine = (id, state, extra = {}) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news, termine: { state, ...extra } } })
const noAnalyse = { comingSoon: true }
-async function renderTermine(projects, { verfuegbar, anfragen = {} } = {}) {
+// Standardantwort bei verfuegbar: true wie GET /api/funktionen (Kunde mit Wartungs-Abo); abo ueberschreibt einzelne Felder
+const ABO_OK = { bestellbar: true, grund: null, preis: '149 €', einrichtung: 'meist innerhalb von 1–2 Wochen' }
+async function renderTermine(projects, { verfuegbar, anfragen = {}, abo = {} } = {}) {
const calls = []
globalThis.fetch = async (url) => {
calls.push(String(url))
@@ -154,7 +158,8 @@ async function renderTermine(projects, { verfuegbar, anfragen = {} } = {}) {
const id = new URL(url, 'http://x').searchParams.get('projectId')
return { ok: true, status: 200, json: async () => ({ anfragenOffen: anfragen[id] ?? 0 }) }
}
- return { ok: true, status: 200, json: async () => ({ projects, ...(verfuegbar === undefined ? {} : { termine: { verfuegbar } }) }) }
+ const termine = verfuegbar === undefined ? {} : { termine: verfuegbar ? { verfuegbar, ...ABO_OK, ...abo } : { verfuegbar } }
+ return { ok: true, status: 200, json: async () => ({ projects, ...termine }) }
}
const count = { textContent: '', hidden: true }
const root = { innerHTML: '', querySelector: (sel) => (sel === '[data-termine-count]' ? count : null) }
@@ -173,18 +178,106 @@ test('Terminplaner-Kachel: fehlt termine.verfuegbar oder ist false ⇒ „Kommt
}
})
-test('Terminplaner-Kachel: Aus ⇒ „Wir schalten den Terminplaner für dich frei.“ + Chat', async () => {
- for (const projects of [[{ ...project('a', noAnalyse), features: { news } }], [withTermine('a', 'off')], [withTermine('a', 'ordered')]]) {
- const { html } = await renderTermine(projects, { verfuegbar: true })
+// Die Abo-Hinweise der Kachel kommen aus aboMeldung (server/services/featureOrder.js, einzige Quelle der Abo-Hinweistexte);
+// die Kachel steht im Browser und kennt den Text nur als Kopie: dieser Test haelt beide gleich.
+const TERMINE_REGISTER = featureConfig('termine')
+const KEIN_ABO_HINWEIS = /Im Wartungs-Abo enthalten\. Zu Verträge<\/button>/
+const knopfDerKachel = (kachel) => kachel.match(/]*fn-tile-cta[^>]*>([^<]*)<\/button>/)
+
+test('Terminplaner-Kachel, Tabelle: jede Zeile mit Pille, Pillenklasse, Knopftext und Ziel', async () => {
+ const zeilen = [
+ // [Name, Projekte, Abo-Felder, Pillenklasse, Pille, Knopf, data-view]
+ ['Aus mit Abo', [withTermine('a', 'off')], {}, 'off', 'Aus', 'Terminplaner einrichten · 149 €', 'termine'],
+ ['Aus mit Abo, Entwurf (Dokument und siteKey)', [withTermine('a', 'off', { siteKey: 'abcdefghijklmnop' })], {}, 'off', 'Aus', 'Fragebogen fortsetzen', 'termine'],
+ ['Aus, Projekt ohne Funktionsdokument (kein features.termine)', [{ ...project('a', noAnalyse), features: { news } }], {}, 'off', 'Aus', 'Terminplaner einrichten · 149 €', 'termine'],
+ ['Zahlung offen', [withTermine('a', 'ordered')], {}, 'setup', 'Zahlung offen', 'Status ansehen', 'termine'],
+ ['In Einrichtung', [withTermine('a', 'setup')], {}, 'setup', 'In Einrichtung', 'Status ansehen', 'termine'],
+ ['Aktiv', [withTermine('a', 'active')], {}, 'on', 'Aktiv', 'Termine öffnen', 'termine'],
+ ['Abgeschaltet', [withTermine('a', 'disabled')], {}, 'off', 'Abgeschaltet', 'Termine ansehen', 'termine'],
+ // das Abo betrifft nur die Bestellung: wer schon bestellt hat oder nutzt, behaelt seinen Weg in die Ansicht
+ ['Zahlung offen, Abo gekündigt', [withTermine('a', 'ordered')], { bestellbar: false, grund: 'gekuendigt' }, 'setup', 'Zahlung offen', 'Status ansehen', 'termine'],
+ ['In Einrichtung, Abo nicht lesbar', [withTermine('a', 'setup')], { bestellbar: null, grund: null }, 'setup', 'In Einrichtung', 'Status ansehen', 'termine'],
+ ['Aktiv, Abo gekündigt', [withTermine('a', 'active')], { bestellbar: false, grund: 'gekuendigt' }, 'on', 'Aktiv', 'Termine öffnen', 'termine'],
+ ['Abgeschaltet, Abo nicht aktiv', [withTermine('a', 'disabled')], { bestellbar: false, grund: 'nicht_aktiv' }, 'off', 'Abgeschaltet', 'Termine ansehen', 'termine'],
+ ]
+ for (const [name, projects, abo, klasse, pille, knopfText, ziel] of zeilen) {
+ const { html } = await renderTermine(projects, { verfuegbar: true, abo })
const kachel = tile(html, 'termine')
- assert.match(kachel, /Terminplaner/)
- assert.match(kachel, /fn-pill-off">Aus)
- assert.match(kachel, /Wir schalten den Terminplaner für dich frei\./)
- assert.match(kachel, /data-view="chat"[^>]*>Chat öffnen)
- assert.doesNotMatch(kachel, /data-view="termine"/)
+ assert.ok(kachel.includes(`${pille} `), `${name}: Pille ${pille} (${klasse}) in ${kachel}`)
+ const knopf = knopfDerKachel(kachel)
+ assert.ok(knopf, `${name}: Knopf`)
+ assert.equal(knopf[1], knopfText, name)
+ assert.ok(knopf[0].includes(`data-view="${ziel}" data-project="a"`), `${name}: Ziel ${knopf[0]}`)
+ assert.doesNotMatch(kachel, /Wir schalten den Terminplaner für dich frei/, name)
+ assert.doesNotMatch(kachel, /data-view="chat"/, `${name}: kein Chat-Knopf mehr`)
}
})
+test('Terminplaner-Kachel: Aus ohne Abo ⇒ kein Knopf und kein Weg in die Ansicht, Hinweis je Grund', async () => {
+ const hinweise = [
+ ['kein_abo', { bestellbar: false, grund: 'kein_abo' }, KEIN_ABO_HINWEIS],
+ ['gekuendigt', { bestellbar: false, grund: 'gekuendigt' }, new RegExp(`${aboMeldung(TERMINE_REGISTER, 'gekuendigt')}`)],
+ ['nicht_aktiv', { bestellbar: false, grund: 'nicht_aktiv' }, new RegExp(aboMeldung(TERMINE_REGISTER, 'nicht_aktiv').replace(/[()]/g, '\\$&'))],
+ ['null (Lesefehler)', { bestellbar: null, grund: null }, /Das Abo konnte gerade nicht geprüft werden\./],
+ ['Feld fehlt (ältere Antwort)', { bestellbar: undefined, grund: undefined }, /Das Abo konnte gerade nicht geprüft werden\./],
+ ['unbekannter Grund', { bestellbar: false, grund: 'etwas_neues' }, /Das Abo konnte gerade nicht geprüft werden\./],
+ ]
+ // mit und ohne Entwurf: auch ein vorhandener Entwurf oeffnet ohne Abo keinen Weg
+ for (const projekte of [[withTermine('a', 'off')], [withTermine('a', 'off', { siteKey: 'abcdefghijklmnop' })]]) {
+ for (const [name, abo, hinweis] of hinweise) {
+ const { html, calls } = await renderTermine(projekte, { verfuegbar: true, abo })
+ const kachel = tile(html, 'termine')
+ assert.match(kachel, /fn-pill-off">Aus, name)
+ assert.match(kachel, hinweis, `${name}: ${kachel}`)
+ assert.doesNotMatch(kachel, /data-view="termine"/, name)
+ assert.doesNotMatch(kachel, /fn-tile-cta/, `${name}: kein Knopf`)
+ assert.doesNotMatch(html, /data-view="termine"/, `${name}: nirgends ein Weg in die Ansicht`)
+ assert.deepEqual(calls, ['/api/funktionen'], name)
+ }
+ }
+ // nur kein_abo verweist auf die Vertraege; die uebrigen Hinweise sind reiner Text
+ const gekuendigt = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund: 'gekuendigt' } })).html, 'termine')
+ assert.doesNotMatch(gekuendigt, /data-view=/)
+})
+
+test('Terminplaner-Kachel: die Abo-Hinweise stimmen wortgleich mit aboMeldung überein (gekündigt, nicht aktiv, nicht lesbar)', async () => {
+ for (const grund of ['gekuendigt', 'nicht_aktiv']) {
+ const { html } = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund } })
+ assert.ok(tile(html, 'termine').includes(`${aboMeldung(TERMINE_REGISTER, grund)}
`), grund)
+ }
+ const { html } = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: null, grund: null } })
+ assert.ok(tile(html, 'termine').includes(`${aboMeldung(TERMINE_REGISTER, 'unbekannt')}
`))
+ assert.equal(aboMeldung(TERMINE_REGISTER, 'unbekannt'), 'Das Abo konnte gerade nicht geprüft werden.')
+})
+
+test('Terminplaner-Kachel: ordered und setup fragen /api/termine nicht nach einer Zahl (nur aktive Websites)', async () => {
+ for (const state of ['ordered', 'setup']) {
+ const { html, calls, count } = await renderTermine([withTermine('a', state)], { verfuegbar: true })
+ assert.deepEqual(calls, ['/api/funktionen'], state)
+ assert.doesNotMatch(tile(html, 'termine'), /data-termine-count/, state)
+ assert.equal(count.textContent, '')
+ }
+})
+
+test('Terminplaner-Kachel: Preis kommt aus der Antwort und maskiert; ohne Preis steht der Knopf ohne Zahl', async () => {
+ const mit = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { preis: '150 €' } })
+ assert.equal(knopfDerKachel(tile(mit.html, 'termine'))[1], 'Terminplaner einrichten · 150 €')
+ const bose = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { preis: ' ' } })
+ assert.doesNotMatch(bose.html, / {
+ const { html } = await renderTermine([], { verfuegbar: true })
+ const kachel = tile(html, 'termine')
+ assert.match(kachel, /fn-pill-off">Aus)
+ assert.doesNotMatch(kachel, /fn-tile-cta|data-view=/)
+ assert.match(kachel, /Sobald deine Website angelegt ist/)
+})
+
test('Terminplaner-Kachel: Aktiv ⇒ „Termine öffnen“ mit Projekt, Anfragen aus anfragenOffen', async () => {
const { html, count, calls } = await renderTermine([withTermine('a', 'off'), withTermine('b', 'active')], { verfuegbar: true, anfragen: { b: 3 } })
const kachel = tile(html, 'termine')
@@ -234,10 +327,43 @@ test('Terminplaner-Kachel: mehrere Websites, Hinweis „aktiv für n von m“, A
assert.match(kachel, /data-project="c"/)
})
+test('Terminplaner-Kachel: Zielprojekt = aktiv, dann ordered/setup, dann abgeschaltet, dann Entwurf, dann das erste', async () => {
+ const entwurf = { siteKey: 'abcdefghijklmnop' }
+ const faelle = [
+ ['aktiv vor allem', [withTermine('a', 'off', entwurf), withTermine('b', 'disabled'), withTermine('c', 'setup'), withTermine('d', 'ordered'), withTermine('e', 'active')], 'e', 'Aktiv', 'Termine öffnen'],
+ ['ordered/setup vor abgeschaltet und Entwurf (setup zuerst in der Liste)', [withTermine('a', 'off', entwurf), withTermine('b', 'disabled'), withTermine('c', 'setup'), withTermine('d', 'ordered')], 'c', 'In Einrichtung', 'Status ansehen'],
+ ['ordered/setup vor abgeschaltet und Entwurf (ordered zuerst in der Liste)', [withTermine('a', 'off', entwurf), withTermine('b', 'disabled'), withTermine('c', 'ordered'), withTermine('d', 'setup')], 'c', 'Zahlung offen', 'Status ansehen'],
+ ['abgeschaltet vor Entwurf', [withTermine('a', 'off', entwurf), withTermine('b', 'off'), withTermine('c', 'disabled')], 'c', 'Abgeschaltet', 'Termine ansehen'],
+ ['Entwurf vor dem ersten', [withTermine('a', 'off'), withTermine('b', 'off'), withTermine('c', 'off', entwurf)], 'c', 'Aus', 'Fragebogen fortsetzen'],
+ ['sonst das erste', [withTermine('a', 'off'), withTermine('b', 'off')], 'a', 'Aus', 'Terminplaner einrichten · 149 €'],
+ ]
+ for (const [name, projekte, ziel, pille, knopf] of faelle) {
+ const { html } = await renderTermine(projekte, { verfuegbar: true })
+ const kachel = tile(html, 'termine')
+ assert.ok(kachel.includes(`data-view="termine" data-project="${ziel}"`), `${name}: Ziel ${ziel} in ${kachel}`)
+ assert.match(kachel, new RegExp(`${pille} `), name)
+ assert.equal(knopfDerKachel(kachel)[1], knopf, name)
+ }
+})
+
+test('Terminplaner-Kachel: mehrere Websites ohne Abo ⇒ die Pille bleibt „Aus“, nirgends ein Knopf (auch mit Entwurf)', async () => {
+ const { html } = await renderTermine([withTermine('a', 'off'), withTermine('b', 'off', { siteKey: 'abcdefghijklmnop' })], { verfuegbar: true, abo: { bestellbar: false, grund: 'kein_abo' } })
+ const kachel = tile(html, 'termine')
+ assert.match(kachel, /fn-pill-off">Aus)
+ assert.doesNotMatch(kachel, /fn-tile-cta|data-view="termine"/)
+ assert.match(kachel, KEIN_ABO_HINWEIS)
+})
+
test('Terminplaner-Kachel: Projektname und Zustand kommen nur maskiert in die Kachel', async () => {
const { html } = await renderTermine([{ ...withTermine('a', 'active'), projectName: ' ' }], { verfuegbar: true })
assert.doesNotMatch(html, / ', state, { siteKey: 'abcdefghijklmnop' }), projectName: ' ' }], { verfuegbar: true })
+ assert.doesNotMatch(bose.html, / { setTimeout(fertig, fx.verzoegerung[projekt]) })
@@ -298,15 +305,67 @@ async function pruefungen() {
await check('fx_kachel_aus_aktiv_abgeschaltet', async (page) => {
const fx = page.fx
- // Aus
+ // Aus mit Abo: Weg in die Bestellung
fx.projekte[0].features.termine = { state: 'off' }
await oeffne(page, '#funktionen')
let kachel = page.locator('[data-fn-tile="termine"]')
await kachel.waitFor()
let t = await textVon(kachel)
- assert(t.includes('Aus') && t.includes('Wir schalten den Terminplaner für dich frei.'), `Aus: ${t}`)
- await knopf(kachel, 'Chat öffnen').waitFor()
- eq(await kachel.locator('[data-view="termine"]').count(), 0, 'Aus: kein Weg in die Ansicht')
+ assert(t.includes('Aus') && t.includes('Terminplaner einrichten · 149 €'), `Aus: ${t}`)
+ assert(!t.includes('Wir schalten den Terminplaner für dich frei.'), 'der alte Text ist weg')
+ await knopf(kachel, 'Terminplaner einrichten · 149 €').waitFor()
+ eq(await kachel.locator('[data-view="termine"]').getAttribute('data-project'), 'p1', 'Aus: Knopf fuehrt in die Ansicht')
+ eq(await kachel.locator('[data-view="chat"]').count(), 0, 'Aus: kein Chat-Knopf')
+ await shot(page, 'kachel-aus')
+ // Entwurf vorhanden (Funktionsdokument mit siteKey)
+ fx.projekte[0].features.termine = { state: 'off', siteKey: 'abcdefghijklmnop' }
+ await page.reload()
+ kachel = page.locator('[data-fn-tile="termine"]')
+ await knopf(kachel, 'Fragebogen fortsetzen').waitFor()
+ // Aus ohne Abo: kein Knopf, Hinweis; die Hinweise der uebrigen Gruende
+ const faelle = [
+ ['kein_abo', 'Im Wartungs-Abo enthalten.'],
+ ['gekuendigt', 'Dein Wartungs-Abo ist gekündigt.'],
+ ['nicht_aktiv', 'Dein Wartungs-Abo ist gerade nicht aktiv'],
+ ]
+ for (const [grund, text] of faelle) {
+ fx.bestellbar = false
+ fx.aboGrund = grund
+ await page.reload()
+ kachel = page.locator('[data-fn-tile="termine"]')
+ await kachel.waitFor()
+ t = await textVon(kachel)
+ assert(t.includes('Aus') && t.includes(text), `Aus ohne Abo (${grund}): ${t}`)
+ eq(await kachel.locator('.fn-tile-cta').count(), 0, `${grund}: kein Knopf`)
+ eq(await kachel.locator('[data-view="termine"]').count(), 0, `${grund}: kein Weg in die Ansicht`)
+ }
+ eq(await kachel.locator('[data-view="vertraege"]').count(), 0, 'nur kein_abo verweist auf die Vertraege')
+ fx.aboGrund = 'kein_abo'
+ await page.reload()
+ kachel = page.locator('[data-fn-tile="termine"]')
+ await knopf(kachel, 'Zu Verträge').waitFor()
+ await shot(page, 'kachel-ohne-abo')
+ fx.bestellbar = null
+ fx.aboGrund = null
+ await page.reload()
+ kachel = page.locator('[data-fn-tile="termine"]')
+ await kachel.getByText('Das Abo konnte gerade nicht geprüft werden.').waitFor()
+ eq(await kachel.locator('.fn-tile-cta').count(), 0, 'nicht lesbar: kein Knopf')
+ // bestellt: Zahlung offen und in Einrichtung (das Abo spielt dann keine Rolle mehr)
+ fx.bestellbar = false
+ fx.aboGrund = 'gekuendigt'
+ for (const [state, pille, text] of [['ordered', 'Zahlung offen', 'Status ansehen'], ['setup', 'In Einrichtung', 'Status ansehen']]) {
+ fx.projekte[0].features.termine = { state }
+ await page.reload()
+ kachel = page.locator('[data-fn-tile="termine"]')
+ await kachel.waitFor()
+ t = await textVon(kachel)
+ assert(t.includes(pille) && t.includes(text), `${state}: ${t}`)
+ eq(await kachel.locator('[data-view="termine"]').getAttribute('data-project'), 'p1', `${state}: Knopf fuehrt in die Ansicht`)
+ }
+ await shot(page, 'kachel-in-einrichtung')
+ fx.bestellbar = true
+ fx.aboGrund = null
// Aktiv mit zwei Anfragen
fx.projekte[0].features.termine = { state: 'active' }
fx.buchungen = alleStatus().map((b) => (b.id === 'bkunft' ? { ...b, status: 'angefragt' } : b))