diff --git a/public/funktionen.js b/public/funktionen.js
index 8ef3dbb..50abfed 100644
--- a/public/funktionen.js
+++ b/public/funktionen.js
@@ -6,7 +6,7 @@
import { bannerColor, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js'
import { HEAT_DEVICES, deviceHtml, heatCaption } from '/geraete.js'
-const PRICE = '30 €'
+const PRICE = '30 €' // Rueckfall, wenn die Antwort von /api/funktionen kein features.news (Register) enthaelt
const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html'
const PHOTO_ACCEPT = 'image/jpeg,image/png,image/webp' // iOS wandelt HEIC dann selbst in JPEG um
const PHOTO_MAX_BYTES = 10 * 1024 * 1024
@@ -63,6 +63,20 @@ async function request(path, { method = 'GET', body, raw } = {}) {
const selection = { projectId: '' }
const newsOf = (project) => project.features?.news || { state: 'off' }
+
+// Preis, Knopf und Zustimmungstext der News-Buchung aus dem Register (features.news der Antwort von /api/funktionen);
+// fehlt das Register oder ein Wert, gelten die bisherigen Werte (PRICE und der consentText der Antwort)
+const textOr = (value, fallback) => (typeof value === 'string' && value.trim() ? value : fallback)
+function newsOffer(features, consentFallback = '') {
+ const reg = features?.news
+ const price = textOr(reg?.priceLabel, PRICE)
+ return {
+ price,
+ knopf: textOr(reg?.consent?.knopf, `Zahlungspflichtig bestellen · ${price}`),
+ consentText: textOr(reg?.consent?.items?.[0]?.text, consentFallback),
+ }
+}
+
const isSetup = (state) => state === 'ordered' || state === 'setup'
// Vorauswahl: zuerst ein aktives Projekt, dann eins in Einrichtung, sonst das erste
@@ -78,11 +92,11 @@ const ICONS = {
}
const svg = (name) => ``
-function newsTile(projects) {
+function newsTile(projects, price = PRICE) {
const states = projects.map((p) => newsOf(p).state)
const active = states.filter((s) => s === 'active').length
let pill = ['Aus', 'off']
- let cta = `Einrichten lassen · ${PRICE}`
+ let cta = `Einrichten lassen · ${esc(price)}`
if (active) { pill = ['Aktiv', 'on']; cta = 'Beiträge schreiben' } else if (states.some(isSetup)) { pill = ['In Einrichtung', 'setup']; cta = 'Status ansehen' } else if (states.length && states.every((s) => s === 'disabled')) { pill = ['Abgeschaltet', 'off']; cta = 'Status ansehen' }
const target = pickProject(projects)
const count = projects.length > 1 && active ? `
aktiv für ${active} von ${projects.length} Websites
` : ''
@@ -199,10 +213,10 @@ const soonTile = (icon, title, text) => `
export async function initFunktionen(root) {
if (!root) return
- const { projects = [], termine } = await request('/api/funktionen')
+ const { projects = [], termine, features } = await request('/api/funktionen')
root.innerHTML = `
- ${newsTile(projects)}
+ ${newsTile(projects, newsOffer(features).price)}
${termine?.verfuegbar ? termineTile(projects) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
${analyseTile(projects)}
`
@@ -220,7 +234,7 @@ export async function initFunktionen(root) {
export async function initNews(root, { projectId } = {}) {
if (!root) return
if (projectId) selection.projectId = projectId
- const { projects = [], setupEstimate = '', consentText = '' } = await request('/api/funktionen')
+ const { projects = [], setupEstimate = '', consentText = '', features } = await request('/api/funktionen')
if (!projects.length) {
root.innerHTML = 'Noch keine Website-Projekte zugewiesen.
'
return
@@ -255,10 +269,10 @@ export async function initNews(root, { projectId } = {}) {
if (feature.state === 'active') renderEditor(stage, project)
else if (isSetup(feature.state)) stage.innerHTML = thanks + statusCard(feature, setupEstimate)
else if (feature.state === 'disabled') stage.innerHTML = 'News-Pinnwand ist abgeschaltet
Für diese Website ist die Pinnwand im Moment nicht aktiv. , dann klären wir das.
'
- else renderBooking(stage, project, { setupEstimate, consentText, thanks })
+ else renderBooking(stage, project, { setupEstimate, offer: newsOffer(features, consentText), thanks })
}
-function renderBooking(stage, project, { setupEstimate, consentText, thanks }) {
+function renderBooking(stage, project, { setupEstimate, offer, thanks }) {
stage.innerHTML = `
${thanks}
@@ -269,14 +283,14 @@ function renderBooking(stage, project, { setupEstimate, consentText, thanks }) {
Beiträge jederzeit selbst schreiben, ändern und abnehmen
Nutzung danach kostenlos, kein Abo
-
${PRICE} einmalig, inklusive Mehrwertsteuer
+
${esc(offer.price)} einmalig, inklusive Mehrwertsteuer
${setupEstimate ? `
Einrichtung: ${esc(setupEstimate)}
` : ''}
Leistungsbeschreibung
-
+
`
- stage.querySelector('#news-consent-text').textContent = consentText
+ stage.querySelector('#news-consent-text').textContent = offer.consentText
const box = stage.querySelector('#news-consent')
const button = stage.querySelector('#news-order')
const error = stage.querySelector('#news-order-error')
diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js
index e9a0cf2..160c52a 100644
--- a/server/routes/funktionen.js
+++ b/server/routes/funktionen.js
@@ -16,7 +16,7 @@ import {
} from '../services/siteFeatures.js'
import { nachweisTexte, speichereZustimmung, zustimmungFuerSitzung, zustimmungsfassungen } from '../services/featureConsents.js'
import { termineFuerKunde } from '../services/termine/schalter.js'
-import { featureConfig, featureHaken, featureSperre } from '../services/featureRegistry.js'
+import { featureConfig, featureHaken, featureKeys, featureSperre, oeffentlicheAnsicht } from '../services/featureRegistry.js'
import { bestellFehler, istFortsetzen, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js'
import {
@@ -107,6 +107,11 @@ router.get('/', requireSession, async (req, res) => {
)
res.json({
projects,
+ // Preise und Bestelltexte je Funktion aus dem Register (nur die oeffentliche Ansicht, auch wenn der Terminplaner
+ // dem Kunden noch nicht freigegeben ist); setupEstimate und consentText bleiben als News-Alias fuer aeltere Clients
+ features: Object.fromEntries(featureKeys().map((key) => [key, oeffentlicheAnsicht(key)])),
+ // Preishinweis fuer alle Bestellseiten: die einzige Stelle, an der er in die Oberflaeche gelangt
+ 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
diff --git a/test/funktionenRoutes.test.js b/test/funktionenRoutes.test.js
index bb38958..ee12332 100644
--- a/test/funktionenRoutes.test.js
+++ b/test/funktionenRoutes.test.js
@@ -67,6 +67,66 @@ test('liste_zustaende', async () => {
// Terminplaner: zweite Funktion je Projekt, Schalter TERMINE_ENABLED (Vorgabe off) - Einzelheiten in funktionenTermine.test.js
for (const p of body.projects) assert.equal(p.features.termine.state, 'off')
assert.deepEqual(body.termine, { verfuegbar: false })
+
+ // Register-Ansicht (B12): Preise und Bestelltexte beider Funktionen aus config.features, auch bei termine.verfuegbar: false
+ assert.deepEqual(Object.keys(body.features).sort(), ['news', 'termine'])
+ assert.equal(body.features.news.priceLabel, '30 €')
+ assert.equal(body.features.termine.priceLabel, '149 €')
+ assert.equal(body.ustHinweis, config.ustHinweis)
+ assert.ok(typeof body.ustHinweis === 'string' && body.ustHinweis.length > 0)
+ // Items vollstaendig: Kennung, Wortlaut und Haken jedes Registerpunkts, dazu Fassung und Knopf
+ for (const key of ['news', 'termine']) {
+ const eintrag = config.features[key]
+ const ansicht = body.features[key]
+ assert.equal(ansicht.key, key)
+ assert.equal(ansicht.name, eintrag.label.name)
+ assert.equal(ansicht.setupEstimate, eintrag.setupEstimate)
+ assert.equal(ansicht.consent.version, eintrag.consent.version)
+ assert.equal(ansicht.consent.knopf, eintrag.consent.knopf)
+ assert.ok(eintrag.consent.items.length > 0)
+ assert.deepEqual(
+ ansicht.consent.items,
+ eintrag.consent.items.map((item) => ({ id: item.id, text: item.text, haken: item.haken === true })),
+ )
+ assert.equal(ansicht.requiresWartungsAbo, eintrag.requiresWartungsAbo)
+ assert.equal(ansicht.returnHash, eintrag.returnHash)
+ // nichts Internes: nur die oeffentlichen Felder (kein ticket, keine mails, keine Haken oder Meldungen)
+ assert.deepEqual(Object.keys(ansicht).sort(), [
+ 'avv', 'consent', 'key', 'kurz', 'leistungsbeschreibung', 'name', 'priceLabel', 'requiresWartungsAbo', 'returnHash', 'setupEstimate', 'widerruf',
+ ])
+ assert.deepEqual(Object.keys(ansicht.consent).sort(), ['items', 'knopf', 'version'])
+ }
+ assert.deepEqual(body.features.termine.consent.items.map((item) => item.id), ['unterlagen', 'gesundheit', 'beginn', 'information'])
+ assert.deepEqual(body.features.news.consent.items.map((item) => item.id), ['beginn'])
+ const flach = JSON.stringify(body.features)
+ assert.equal(flach.includes('"ticket"') || flach.includes('"mails"'), false)
+ // Alias: setupEstimate und consentText bleiben die News-Werte (Rueckfall aelterer Clients)
+ assert.equal(body.setupEstimate, body.features.news.setupEstimate)
+ assert.equal(body.consentText, body.features.news.consent.items[0].text)
+})
+
+test('liste_register_ohne_freigabe: features.termine steht auch bei Schalter off und Pilot ohne Teilnahme', async () => {
+ const vorher = { mode: config.termine.mode, pilotEmails: config.termine.pilotEmails }
+ try {
+ for (const [mode, pilotEmails] of [['off', []], ['pilot', ['andere@example.test']]]) {
+ config.termine.mode = mode
+ config.termine.pilotEmails = pilotEmails
+ const body = await get('/api/funktionen', { cookie })
+ assert.equal(body.termine.verfuegbar, false, mode)
+ assert.equal(body.features.termine.priceLabel, '149 €', mode)
+ assert.equal(body.features.termine.consent.items.length, config.features.termine.consent.items.length, mode)
+ assert.equal(body.features.news.priceLabel, '30 €', mode)
+ }
+ // mit Freigabe dieselbe Ansicht
+ config.termine.mode = 'pilot'
+ config.termine.pilotEmails = ['*']
+ const frei = await get('/api/funktionen', { cookie })
+ assert.equal(frei.termine.verfuegbar, true)
+ assert.equal(frei.features.termine.priceLabel, '149 €')
+ } finally {
+ config.termine.mode = vorher.mode
+ config.termine.pilotEmails = vorher.pilotEmails
+ }
})
test('checkout_ohne_zustimmung_400', async () => {
diff --git a/test/funktionenTiles.test.js b/test/funktionenTiles.test.js
index df39ae5..a73f019 100644
--- a/test/funktionenTiles.test.js
+++ b/test/funktionenTiles.test.js
@@ -13,7 +13,7 @@ const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'u
.replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`)
const file = join(dir, 'funktionen.mjs')
writeFileSync(file, src)
-const { initFunktionen, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href)
+const { initFunktionen, initNews, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href)
const news = { state: 'off' }
const project = (id, analyse) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news }, analyse })
@@ -49,6 +49,100 @@ test('Kacheln rendern ohne Fehler: News, Analyse comingSoon/aus/aktiv/gesperrt',
assert.doesNotMatch(disabled, /Dev-Modus/)
})
+// ---- News-Kachel und -Buchungsseite: Preis und Zustimmungstext aus dem Register (features.news), Rückfall ohne features
+async function renderMit(projects, extra) {
+ globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ projects, ...extra }) })
+ const root = { innerHTML: '', querySelector: () => null }
+ await initFunktionen(root)
+ return root.innerHTML
+}
+
+test('News-Kachel: Rückfall ohne features ⇒ bisheriger Preis 30 €', async () => {
+ const html = await render([project('a', { comingSoon: true })])
+ assert.match(tile(html, 'news'), /Einrichten lassen · 30 €/)
+ // auch bei leerem oder unvollständigem features-Objekt
+ for (const features of [{}, { news: {} }, { news: { priceLabel: '' } }, { news: { priceLabel: 31 } }, { termine: { priceLabel: '149 €' } }]) {
+ const rueck = await renderMit([project('a', { comingSoon: true })], { features })
+ assert.match(tile(rueck, 'news'), /Einrichten lassen · 30 €/, JSON.stringify(features))
+ }
+})
+
+test('News-Kachel: Preis aus dem Register ⇒ „Einrichten lassen · 31 €“', async () => {
+ const html = await renderMit([project('a', { comingSoon: true })], { features: { news: { priceLabel: '31 €' } } })
+ assert.match(tile(html, 'news'), /Einrichten lassen · 31 €/)
+ assert.doesNotMatch(tile(html, 'news'), /30 €/)
+ // der Preis kommt maskiert in die Kachel
+ const bose = await renderMit([project('a', { comingSoon: true })], { features: { news: { priceLabel: '
' } } })
+ assert.doesNotMatch(bose, /
{
+ calls.push({ url: String(url), method: opts?.method, body: opts?.body ? JSON.parse(opts.body) : undefined })
+ if (String(url) === '/api/funktionen/news/checkout') return { ok: true, status: 200, json: async () => ({ url: 'https://checkout.example.test/s1' }) }
+ return { ok: true, status: 200, json: async () => antwort }
+ }
+ const stage = fakeStage()
+ const root = fakeEl()
+ root.querySelector = (sel) => (sel === '#news-stage' ? stage : null)
+ try {
+ await initNews(root, { projectId: 'p1' })
+ // Haken setzen und bestellen
+ const box = stage.els['#news-consent']
+ box.checked = true
+ box.handlers.change()
+ const button = stage.els['#news-order']
+ const knopfAktiv = button.disabled === false
+ await button.handlers.click()
+ return { stage, calls, knopfAktiv, href: globalThis.window.location.href }
+ } finally {
+ delete globalThis.window
+ }
+}
+
+test('News-Buchungsseite: Preis, Knopf und Zustimmungstext aus features.news, sendet weiter consent: true', async () => {
+ const register = { priceLabel: '31 €', consent: { version: 'v', knopf: 'Jetzt kaufen · 31 €', items: [{ id: 'beginn', text: 'Registertext der Zustimmung', haken: true }] } }
+ const { stage, calls, knopfAktiv, href } = await renderBuchung({
+ projects: [freiesProjekt],
+ setupEstimate: 'meist innerhalb von 1–2 Wochen',
+ consentText: 'Alter Text',
+ features: { news: register },
+ })
+ assert.match(stage.innerHTML, /31 €<\/strong>/)
+ assert.match(stage.innerHTML, /inklusive Mehrwertsteuer/, 'der Wortlaut bleibt bis Block F')
+ assert.match(stage.innerHTML, /id="news-order" disabled>Jetzt kaufen · 31 €)
+ assert.equal(stage.els['#news-consent-text'].textContent, 'Registertext der Zustimmung')
+ assert.equal(knopfAktiv, true)
+ const bestellung = calls.filter((c) => c.url === '/api/funktionen/news/checkout')
+ assert.equal(bestellung.length, 1)
+ assert.equal(bestellung[0].method, 'POST')
+ assert.deepEqual(bestellung[0].body, { projectId: 'p1', consent: true })
+ assert.equal(href, 'https://checkout.example.test/s1')
+})
+
+test('News-Buchungsseite: Rückfall ohne features ⇒ 30 €, Standardknopf, consentText der Antwort', async () => {
+ const { stage, calls } = await renderBuchung({ projects: [freiesProjekt], setupEstimate: '', consentText: 'Alter Text' })
+ assert.match(stage.innerHTML, /30 €<\/strong>/)
+ assert.match(stage.innerHTML, /id="news-order" disabled>Zahlungspflichtig bestellen · 30 €)
+ assert.equal(stage.els['#news-consent-text'].textContent, 'Alter Text')
+ assert.deepEqual(calls.find((c) => c.url === '/api/funktionen/news/checkout').body, { projectId: 'p1', consent: true })
+})
+
+test('News-Buchungsseite: Preis ohne Knopftext ⇒ Standardknopf mit Registerpreis, Knopftext kommt maskiert', async () => {
+ const nurPreis = await renderBuchung({ projects: [freiesProjekt], consentText: 'Alter Text', features: { news: { priceLabel: '31 €' } } })
+ assert.match(nurPreis.stage.innerHTML, /id="news-order" disabled>Zahlungspflichtig bestellen · 31 €)
+ assert.equal(nurPreis.stage.els['#news-consent-text'].textContent, 'Alter Text')
+ const bose = await renderBuchung({ projects: [freiesProjekt], features: { news: { priceLabel: '31 €', consent: { knopf: '
', items: [] } } } })
+ assert.doesNotMatch(bose.stage.innerHTML, /
{
+ assert.match(src, /request\('\/api\/funktionen\/news\/checkout',\s*\{[^}]*body:\s*\{[^}]*consent: true/)
+})
+
// ---- 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 noAnalyse = { comingSoon: true }