From cf9f9b30a091bb1468101abf71734df28f79c923 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:42:20 +0000 Subject: [PATCH] Funktionen-Seite: Preis und Bestelltexte aus dem Register GET /api/funktionen liefert features.news und features.termine (oeffentlicheAnsicht, auch wenn der Terminplaner noch nicht freigegeben ist) und ustHinweis; setupEstimate und consentText bleiben als News-Alias. Die News-Kachel und die Buchungsseite lesen Preis, Knopftext und Zustimmungstext aus features.news und fallen ohne features auf die bisherigen Werte zurueck. News sendet weiter consent: true an /api/funktionen/news/checkout. Co-Authored-By: Claude Sonnet 5.5 --- public/funktionen.js | 36 +++++++++---- server/routes/funktionen.js | 7 ++- test/funktionenRoutes.test.js | 60 ++++++++++++++++++++++ test/funktionenTiles.test.js | 96 ++++++++++++++++++++++++++++++++++- 4 files changed, 186 insertions(+), 13 deletions(-) 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)}

    ` : ''} -
    +
    ` - 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 € 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 € 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 €', 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 }