Funktionen: Register config.features mit News- und Termine-Eintrag

config.features (news, termine) als reine Daten in config.js, config.ustHinweis,
config.funktionen als Alias mit Gettern auf features.news. Neu featureRegistry.js
(featureConfig, featureKeys, featureHaken mit Ueberschreib-Regel, oeffentlicheAnsicht);
FEATURE_KEYS, FEATURE_LABELS und DISABLED_MESSAGE in siteFeatures.js kommen aus dem
Register, Werte unveraendert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 11:48:06 +00:00
1 parent e2f50407a0
commit c584d220c8
4 files changed
+512 -13

No files matched your search

+136 -5
View File
@@ -130,6 +130,131 @@ export function resolveEnableBankingApiBase(value) {
return ENABLE_BANKING_API_BASE
}
// Pflichthinweis zum Preis (Kleinunternehmer); die Funktionen-Register-Texte und die Preisangaben nutzen ihn
const ustHinweis = 'ohne Umsatzsteuer gemäß § 19 UStG'
const ticketProjektName = (project) => project?.projectName || project?.subdomain || ''
/**
* Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner): Preis, Zustimmungen, Rechtsunterlagen,
* Ticket- und Mail-Texte je Funktion; reine Daten, config.js importiert keine Dienste. Die Helfer (featureConfig,
* featureHaken, oeffentlicheAnsicht) stehen in server/services/featureRegistry.js. Haken (verfuegbar,
* bestellpruefung, unterlagenBereit, belehrung, freischaltbar) gehoeren nicht hierher: wer sie in Tests auf dem
* Eintrag setzt, ueberschreibt den Standard aus featureRegistry.js.
* ticket.topic(project) liefert den Ticket-Betreff, ticket.details(ctx) den Text des Einrichtungs-Tickets
* (ctx = { feature, doc, project, customer }: Register-Eintrag, portalFeatures-, websiteProjects- und Kunden-Dokument).
*/
const features = {
news: {
key: 'news',
product: 'newsSetup',
label: { name: 'News-Pinnwand', kurz: 'News', keine: 'keine News-Pinnwand' },
priceLabel: '30 €',
setupEstimate: 'meist innerhalb von 1–2 Wochen',
// Wortlaut der Zustimmung; wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 4 BGB)
consent: {
version: '2026-09-30',
knopf: 'Zahlungspflichtig bestellen · 30 €',
items: [
{
id: 'beginn',
haken: true,
text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).',
},
],
},
leistungsbeschreibung: { version: '2026-09-30', url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' },
avv: null,
widerruf: null,
returnHash: 'news',
returnProjekt: false,
ticket: {
topic: (project) => `News-Pinnwand einbauen – ${ticketProjektName(project)}`.trim(),
details: async (ctx) => {
const { feature, doc, project } = ctx || {}
const siteKey = doc?.siteKey || ''
const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || ''
return [
`Funktion: ${feature?.key || 'news'}`,
`Projekt: ${ticketProjektName(project)}`,
`Domain: ${domain}`,
`siteKey: ${siteKey}`,
`Einbau: <div data-webklar-news="${siteKey}"></div> + https://project.webklar.com/embed/news.js`,
'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)',
].join('\n')
},
},
mails: {
funktion: 'News-Pinnwand',
formen: { dein: 'Deine News-Pinnwand', nom: 'die News-Pinnwand', akk: 'die News-Pinnwand', gen: 'der News-Pinnwand', pron: 'sie' },
knopf: 'Beitrag schreiben',
danach: 'Danach kannst Du Beiträge im Portal schreiben.',
bereit: 'Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.',
preisHinweis: 'brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo',
anleitung: false,
},
requiresWartungsAbo: false,
ticketFreischaltung: true,
disabledMessage:
'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.',
orderedConflictMessage: 'Die News-Pinnwand ist für dieses Projekt schon bestellt.',
},
termine: {
key: 'termine',
product: 'termineSetup',
label: { name: 'Terminplaner', kurz: 'Termine', keine: 'keinen Terminplaner' },
priceLabel: '149 €',
setupEstimate: 'meist innerhalb von 1–2 Wochen',
// Platzhalter bis Block F (Rechtsrunde): Struktur und IDs stehen, Texte und Fassung ersetzt Block F
consent: {
version: 'ENTWURF-2026-10-03',
knopf: 'Zahlungspflichtig bestellen · 149 €',
items: [
{ id: 'unterlagen', haken: true, text: 'ENTWURF (Block F): Zustimmung zu Leistungsbeschreibung und Auftragsverarbeitungsvertrag (AVV) Terminplaner.' },
{ id: 'gesundheit', haken: true, text: 'ENTWURF (Block F): Bestätigung, dass der Terminplaner nicht im Gesundheitsbereich eingesetzt wird.' },
{
id: 'beginn',
haken: true,
text: 'ENTWURF (Block F): Ausdrückliches Verlangen, dass vor Ablauf der Widerrufsfrist mit dem Einbau begonnen wird, mit Hinweis auf das Erlöschen des Widerrufsrechts und den Wertersatz.',
},
{
id: 'information',
haken: true,
text: 'ENTWURF (Block F): Pflichtinformationen zu Leistung, Gesamtpreis, Ende mit dem Wartungs-Abo und Einrichtungsdauer.',
},
],
},
leistungsbeschreibung: { version: 'ENTWURF', url: '' },
avv: { version: 'ENTWURF', url: '' },
widerruf: null,
returnHash: 'termine',
returnProjekt: true,
ticket: {
topic: (project) => `Terminplaner einbauen – ${ticketProjektName(project)}`,
// Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch
details: async (ctx) => {
const { termineTicketDetails } = await import('./services/termine/ticketDetails.js')
return termineTicketDetails(ctx)
},
},
mails: {
funktion: 'Terminplaner',
formen: { dein: 'Dein Terminplaner', nom: 'der Terminplaner', akk: 'den Terminplaner', gen: 'des Terminplaners', pron: 'ihn' },
knopf: 'Termine öffnen',
// Platzhalter: den Wortlaut schreibt der Inhaber in seiner Mailrunde
danach: 'ENTWURF (Mailrunde): Danach kannst Du im Portal Termine und Einstellungen verwalten.',
bereit: 'ENTWURF (Mailrunde): Du kannst ab sofort Termine online buchen lassen.',
preisHinweis: `einmalig, ${ustHinweis}; der Terminplaner läuft, solange Dein Wartungs-Abo läuft`,
anleitung: true,
},
requiresWartungsAbo: true,
ticketFreischaltung: true,
disabledMessage: 'Der Terminplaner ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.',
orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.',
unavailableMessage: 'Der Terminplaner ist noch nicht verfügbar.',
},
}
export const config = {
port: Number(process.env.PORT) || 3000,
sessionSecret: process.env.SESSION_SECRET || '',
@@ -188,12 +313,18 @@ export const config = {
products,
mailStorage,
newsSetup,
// Funktionen-Tab (News-Pinnwand): Zeiteinschaetzung auf der Buchungsseite und Wortlaut der Zustimmung,
// der mit Zeitstempel am Feature-Dokument gespeichert wird (Widerruf, § 356 Abs. 4 BGB)
ustHinweis,
// Register der kaufbaren Funktionen (siehe oben)
features,
// Funktionen-Tab (News-Pinnwand): Alias auf den News-Eintrag des Registers, Zeiteinschaetzung auf der
// Buchungsseite und Wortlaut der Zustimmung (die Getter folgen config.features.news zur Laufzeit)
funktionen: {
setupEstimate: 'meist innerhalb von 1–2 Wochen',
consentText:
'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).',
get setupEstimate() {
return config.features.news.setupEstimate
},
get consentText() {
return config.features.news.consent.items[0].text
},
},
// Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an.
+72
View File
@@ -0,0 +1,72 @@
import { config } from '../config.js'
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner; Daten in server/config.js).
// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch: Standard-Haken
// laden ihre Dienste per dynamischem import() innerhalb der Funktion (sonst entstuenden Importzyklen).
/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen. */
const HAKEN_NAMEN = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar']
/**
* Standard-Haken je Funktion (Funktionen, nicht Daten). Leer in Task B1; es fuellen sie B6 (verfuegbar,
* bestellpruefung, unterlagenBereit), B7 (belehrung) und B11 (freischaltbar). News hat keine Standard-Haken.
*/
const STANDARD_HAKEN = {
news: {},
termine: {},
}
/** Registereintrag oder null; nur eigene Schluessel des Registers ('analyse', '__proto__', 'toString' => null). */
export function featureConfig(key) {
if (typeof key !== 'string' || !Object.hasOwn(config.features, key)) return null
return config.features[key]
}
/** Schluessel der Funktionen im Register, in Registerreihenfolge. */
export function featureKeys() {
return Object.keys(config.features)
}
/**
* Haken einer Funktion: { verfuegbar?, bestellpruefung?, unterlagenBereit?, belehrung?, freischaltbar? }.
* Wer config.features.<key>.<haken> als Funktion setzt (Tests), ueberschreibt den Standard.
*/
export function featureHaken(key) {
const eintrag = featureConfig(key)
if (!eintrag) return {}
const standard = Object.hasOwn(STANDARD_HAKEN, key) ? STANDARD_HAKEN[key] : {}
const haken = {}
for (const name of HAKEN_NAMEN) {
const fn = typeof eintrag[name] === 'function' ? eintrag[name] : standard[name]
if (typeof fn === 'function') haken[name] = fn
}
return haken
}
const fassung = (f) => (f ? { version: f.version, url: f.url ?? '' } : null)
/**
* Sicht fuer den Browser (Bestellseite): JSON-sicher, ohne Funktionen und ohne ticket/mails, eine Kopie
* (Aenderungen daran aendern das Register nicht). Unbekannter Schluessel => null.
*/
export function oeffentlicheAnsicht(key) {
const e = featureConfig(key)
if (!e) return null
return {
key: e.key,
name: e.label.name,
kurz: e.label.kurz,
priceLabel: e.priceLabel,
setupEstimate: e.setupEstimate,
consent: {
version: e.consent.version,
knopf: e.consent.knopf,
items: e.consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) })),
},
leistungsbeschreibung: fassung(e.leistungsbeschreibung),
avv: fassung(e.avv),
widerruf: fassung(e.widerruf),
returnHash: e.returnHash,
requiresWartungsAbo: e.requiresWartungsAbo === true,
}
}
+10 -8
View File
@@ -4,20 +4,22 @@ import { createDocument, getDocument, listDocuments, updateDocument, Query } fro
import { withLocks } from './keyedLock.js'
import { sendAdminNotice, sendCustomerMail } from './mailer.js'
import { getStripe, retrieveCheckoutSession } from './stripeBilling.js'
import { featureConfig, featureKeys } from './featureRegistry.js'
import { createFeatureTicket } from './ticketAdmin.js'
export const FEATURE_KEYS = ['news', 'termine']
/** Bezeichnung (name) und Verneinung im Satz "Für dieses Projekt gibt es noch {keine}" (keine) je Funktion. */
export const FEATURE_LABELS = {
news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' },
termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' },
}
export const FEATURE_KEYS = featureKeys()
/** Bezeichnung (name) und Verneinung im Satz "Für dieses Projekt gibt es noch {keine}" (keine) je Funktion; aus dem Register. */
export const FEATURE_LABELS = Object.fromEntries(
FEATURE_KEYS.map((key) => {
const { name, keine } = featureConfig(key).label
return [key, { name, keine }]
})
)
export const STATES = ['off', 'ordered', 'setup', 'active', 'disabled']
/** Warum eine Funktion abgeschaltet ist (portalFeatures.disabledReason); 'team' ist der Vorgabewert (Abschalten im WOMS, Aufbewahrungsfrist). */
export const DISABLED_REASONS = ['team', 'abo_ende', 'abo_ende_vor_einrichtung', 'kunde_geloescht']
export const DISABLED_MESSAGE =
'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.'
export const DISABLED_MESSAGE = featureConfig('news').disabledMessage
const BASE32 = 'abcdefghijklmnopqrstuvwxyz234567'