Öffnungszeiten: Kachel und Bestell-/Einschaltweg im Portal
Kachel „Öffnungszeiten live“ inline in funktionen.js (keine neuen Importe, keine Zusatzabfrage), Ansicht #zeiten (zeiten.js, zeiten-bestellen.js, zeiten.css) mit Einschalten im Wartungs-Abo, Bestellen für 30 € (Widerrufsbelehrung vor dem Knopf, Ruling R32), Behalten nach dem Abo-Ende, Statuskarte je Zustand, Rückkehr von Stripe und Platz für den Editor (Aufgabe 29). GET /api/funktionen liefert dafür zeiten.belehrung (Haken belehrung der Funktion). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f20f056c60
commit
7bb918390a
10 files changed
+1878
-8
No files matched your search
+5
-3
@@ -139,7 +139,7 @@ function projectState(project) {
|
||||
return ['kb-state-off', 'Noch keine Website']
|
||||
}
|
||||
|
||||
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner' }
|
||||
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner', zeiten: 'Öffnungszeiten' }
|
||||
|
||||
function renderProjectCard(project, features) {
|
||||
const li = document.createElement('li')
|
||||
@@ -1910,7 +1910,7 @@ function initPasswordView() {
|
||||
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
|
||||
// "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt
|
||||
// "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe).
|
||||
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
|
||||
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', zeiten: 'zeiten', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
|
||||
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
|
||||
const viewLoaders = { chat: initChat }
|
||||
// Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden
|
||||
@@ -1919,6 +1919,8 @@ const viewRefreshers = {
|
||||
news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))),
|
||||
analyse: () => import('/funktionen.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
|
||||
termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))),
|
||||
// baut sich bei gleichem Projekt und Stand nicht neu auf, solange der Editor steht (ungespeicherte Eingaben bleiben; zeiten.js, initZeiten)
|
||||
zeiten: () => import('/zeiten.js').then((m) => m.initZeiten(document.getElementById('zeiten-body'))),
|
||||
}
|
||||
const loadedViews = new Set()
|
||||
|
||||
@@ -2111,7 +2113,7 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (
|
||||
const coarsePointer = window.matchMedia('(pointer: coarse)')
|
||||
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
|
||||
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
|
||||
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
|
||||
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', zeiten: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
|
||||
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' }
|
||||
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
|
||||
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<link rel="stylesheet" href="/chat-composer.css">
|
||||
<link rel="stylesheet" href="/funktionen.css">
|
||||
<link rel="stylesheet" href="/termine.css">
|
||||
<link rel="stylesheet" href="/zeiten.css">
|
||||
</head>
|
||||
<body class="portal-page dashboard-page kb-page">
|
||||
<div class="dot-background" aria-hidden="true">
|
||||
@@ -195,6 +196,15 @@
|
||||
<div id="termine-body"><p class="kb-empty">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Öffnungszeiten live: Einschalten oder Bestellen, Status und Editor (zeiten.js, initZeiten); Unterseite von Funktionen. Kein aria-live am Bereich -->
|
||||
<section class="kb-view" id="view-zeiten">
|
||||
<header class="kb-head">
|
||||
<h1>Öffnungszeiten live</h1>
|
||||
<p>Deine Website zeigt immer den aktuellen Stand: geöffnet oder geschlossen, mit Feiertagen, Urlaub und Hinweisen.</p>
|
||||
</header>
|
||||
<div id="zeiten-body"><p class="kb-empty">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Rechnungen -->
|
||||
<section class="kb-view" id="view-invoices">
|
||||
<header class="kb-head">
|
||||
|
||||
+113
-1
@@ -92,6 +92,7 @@ const ICONS = {
|
||||
news: '<path d="M12 17v5"/><path d="M9 10.8a2 2 0 0 1-1.1 1.8l-1.8.9A2 2 0 0 0 5 15.2V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.8a2 2 0 0 0-1.1-1.8l-1.8-.9A2 2 0 0 1 15 10.8V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
|
||||
calendar: '<rect x="3" y="5" width="18" height="16" rx="3"/><path d="M8 3v4M16 3v4M3 10h18"/>',
|
||||
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
|
||||
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||||
}
|
||||
const svg = (name) => `<svg class="fn-ico" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`
|
||||
|
||||
@@ -238,6 +239,107 @@ async function showTermineCount(root, projects) {
|
||||
el.hidden = false
|
||||
}
|
||||
|
||||
/* Oeffnungszeiten live: Kachel nur, wenn der Schalter es erlaubt (zeiten.verfuegbar); sonst gibt es sie gar nicht (auch kein "Kommt bald").
|
||||
Wie bei den anderen Funktionen steht sie inline, die Ansicht #zeiten (zeiten.js) laedt app.js. Alles, was sie zeigt, kommt mit der Liste
|
||||
(GET /api/funktionen): zeiten (Angebot) mit inklusive (true: im laufenden, aktiven Wartungs-Abo enthalten, einschaltbar | false: sonst, dann
|
||||
sagt grund warum: kein_abo | gekuendigt | nicht_aktiv | null: Abo nicht lesbar) und preis; je Projekt features.zeiten (state, einbau, ticketWoid,
|
||||
aboEndeAt, disabledReason, loeschungAm), zeitenStatus ({ offen, kurz, text } des Hauptplans) und zeitenLeer (keine Plaene gespeichert).
|
||||
Ruling R20: nicht_aktiv (Probezeit, Zahlung offen) ist einschaltbar, sobald das Abo aktiv ist, und bietet kein Bezahlen an. */
|
||||
const ZEITEN_TEXT = 'Deine Website zeigt live, ob du gerade geöffnet hast – mit Feiertagen, Urlaub und Hinweisen.'
|
||||
const ZEITEN_ABGESCHALTET = 'Öffnungszeiten live wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du die Funktion wieder möchtest.'
|
||||
const ZEITEN_NICHT_PRUEFBAR = 'Gerade nicht prüfbar – bitte später erneut.'
|
||||
const zeitenOf = (project) => project.features?.zeiten || { state: 'off' }
|
||||
|
||||
// Zielprojekt: aktiv, dann in Einrichtung/Zahlung, dann abgeschaltet, sonst das erste
|
||||
function pickZeitenProject(projects) {
|
||||
return projects.find((p) => zeitenOf(p).state === 'active')
|
||||
|| projects.find((p) => isSetup(zeitenOf(p).state))
|
||||
|| projects.find((p) => zeitenOf(p).state === 'disabled')
|
||||
|| projects[0]
|
||||
}
|
||||
|
||||
// Frist zum Behalten: Berliner Kalendertag des erkannten Abo-Endes plus 30 Tage (wie fristTag in server/services/zeiten/aboAbgleich.js und die Mail K26);
|
||||
// ein unlesbares Datum gibt '' (dann steht kein Hinweis da, nie ein erfundenes Datum). Doppelt zu zeiten.js (funktionen.js importiert dort nichts).
|
||||
function zeitenFrist(aboEndeAt) {
|
||||
const ms = typeof aboEndeAt === 'string' ? Date.parse(aboEndeAt) : NaN
|
||||
if (Number.isNaN(ms)) return ''
|
||||
const [y, m, d] = new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }).split('-').map(Number)
|
||||
return new Date(Date.UTC(y, m - 1, d + 30)).toLocaleDateString('de-DE', { timeZone: 'UTC', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
// 'JJJJ-MM-TT' als TT.MM.JJJJ, sonst ''
|
||||
const zeitenTag = (iso) => {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(typeof iso === 'string' ? iso : '')
|
||||
return m ? `${m[3]}.${m[2]}.${m[1]}` : ''
|
||||
}
|
||||
|
||||
function zeitenTile(projects, angebot = {}) {
|
||||
const target = pickZeitenProject(projects)
|
||||
const f = target ? zeitenOf(target) : { state: 'off' }
|
||||
const preis = textOr(angebot.preis, PRICE)
|
||||
const knopf = (text, { weg = '', klasse = 'kb-btn-primary fn-tile-cta' } = {}) => `<button type="button" class="${klasse}" data-view="zeiten" data-project="${esc(target.projectId)}"${weg ? ` data-zeiten-weg="${weg}"` : ''}>${text}</button>`
|
||||
const chat = '<button type="button" class="kb-btn-primary fn-tile-cta" data-view="chat">Chat öffnen</button>'
|
||||
let pill = ['Aus', 'off']
|
||||
let extra = ''
|
||||
let action = ''
|
||||
if (!target) {
|
||||
extra = '<p class="fn-count">Sobald deine Website angelegt ist, kannst du Öffnungszeiten live einrichten.</p>'
|
||||
} else if (f.state === 'active' || f.state === 'setup') {
|
||||
const aktiv = f.state === 'active'
|
||||
pill = aktiv ? ['Aktiv', 'on'] : ['In Einrichtung', 'setup']
|
||||
const status = typeof target.zeitenStatus?.text === 'string' ? target.zeitenStatus.text : ''
|
||||
if (aktiv && status) extra += `<p class="fn-count" data-zeiten-status>${esc(status)}</p>`
|
||||
if (!aktiv) {
|
||||
extra += f.einbau === 'angestossen'
|
||||
? '<p>Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen (meist in wenigen Minuten).</p>'
|
||||
: `<p>Unser Team baut die Funktion ein${f.ticketWoid ? ` (Ticket #${esc(f.ticketWoid)})` : ''}.</p>`
|
||||
}
|
||||
if (target.zeitenLeer === true) extra += `<p>${aktiv ? 'Trag jetzt deine Zeiten ein.' : 'Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen.'}</p>`
|
||||
action = knopf('Zeiten bearbeiten')
|
||||
// Wartungs-Abo beendet (aboEndeAt): fuer einmalig 30 € behalten; nur wenn sicher ist, dass das Abo nicht wieder laeuft (inklusive false)
|
||||
const frist = f.aboEndeAt && angebot.inklusive === false ? zeitenFrist(f.aboEndeAt) : ''
|
||||
if (frist) {
|
||||
extra += `<p class="fn-count">Dein Wartungs-Abo ist beendet – behalte die Funktion bis ${frist} für einmalig ${esc(preis)}.</p>`
|
||||
action += knopf(`Für ${esc(preis)} behalten`, { weg: 'behalten', klasse: 'kb-btn-outline' })
|
||||
}
|
||||
} else if (f.state === 'ordered') {
|
||||
pill = ['Zahlung offen', 'setup']
|
||||
extra = '<p>Zahlung wird bestätigt …</p>'
|
||||
action = knopf('Status ansehen')
|
||||
} else if (f.state === 'disabled') {
|
||||
pill = ['Abgeschaltet', 'off']
|
||||
const aboEnde = f.disabledReason === 'abo_ende'
|
||||
// Daten da: Plaene gespeichert (zeitenLeer false) und die Loeschfrist (loeschungAm, Berliner Tag) ist nicht vorbei
|
||||
const daten = aboEnde && target.zeitenLeer === false && !(typeof f.loeschungAm === 'string' && f.loeschungAm < berlinIsoDay())
|
||||
extra = `<p>${aboEnde ? 'Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist.' : ZEITEN_ABGESCHALTET}</p>`
|
||||
if (daten && zeitenTag(f.loeschungAm)) extra += `<p class="fn-count">Deine Eingaben bleiben bis ${zeitenTag(f.loeschungAm)} gespeichert.</p>`
|
||||
if (daten && angebot.inklusive === true) extra += '<p class="fn-count">Dein Wartungs-Abo läuft wieder – wir schalten Öffnungszeiten live automatisch wieder ein (meist innerhalb eines Tages).</p>'
|
||||
else if (daten && angebot.inklusive !== false) extra += `<p class="fn-count">${ZEITEN_NICHT_PRUEFBAR}</p>`
|
||||
else action = daten ? knopf(`Für ${esc(preis)} wieder einschalten`, { weg: 'behalten' }) : chat
|
||||
} else if (angebot.inklusive === true) {
|
||||
extra = '<p>Im Wartungs-Abo enthalten.</p>'
|
||||
action = knopf('Einschalten')
|
||||
} else if (angebot.inklusive === false && angebot.grund === 'nicht_aktiv') {
|
||||
extra = '<p class="fn-count">Im Wartungs-Abo enthalten – einschaltbar, sobald dein Abo aktiv ist.</p>'
|
||||
} else if (angebot.inklusive === false) {
|
||||
extra = `<p>${esc(preis)} einmalig</p>`
|
||||
action = knopf(`Öffnungszeiten einrichten · ${esc(preis)}`)
|
||||
} else {
|
||||
extra = `<p class="fn-count">${ZEITEN_NICHT_PRUEFBAR}</p>`
|
||||
}
|
||||
const aktiveAnzahl = projects.filter((p) => zeitenOf(p).state === 'active').length
|
||||
const count = projects.length > 1 && aktiveAnzahl ? `<p class="fn-count">aktiv für ${aktiveAnzahl} von ${projects.length} Websites</p>` : ''
|
||||
return `
|
||||
<article class="fn-tile" data-fn-tile="zeiten">
|
||||
<div class="fn-tile-head">${svg('clock')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
|
||||
<h3>Öffnungszeiten live</h3>
|
||||
<p>${ZEITEN_TEXT}</p>
|
||||
${extra}
|
||||
${count}
|
||||
${action}
|
||||
</article>`
|
||||
}
|
||||
|
||||
const soonTile = (icon, title, text) => `
|
||||
<article class="fn-tile fn-tile-soon">
|
||||
<div class="fn-tile-head">${svg(icon)}<span class="fn-pill fn-pill-soon">Kommt bald</span></div>
|
||||
@@ -247,11 +349,12 @@ const soonTile = (icon, title, text) => `
|
||||
|
||||
export async function initFunktionen(root) {
|
||||
if (!root) return
|
||||
const { projects = [], termine, features } = await request('/api/funktionen')
|
||||
const { projects = [], termine, zeiten, features } = await request('/api/funktionen')
|
||||
root.innerHTML = `
|
||||
<div class="fn-tiles">
|
||||
${newsTile(projects, newsOffer(features).price)}
|
||||
${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
|
||||
${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''}
|
||||
${analyseTile(projects)}
|
||||
</div>`
|
||||
root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
|
||||
@@ -261,6 +364,15 @@ export async function initFunktionen(root) {
|
||||
const body = document.getElementById('termine-body')
|
||||
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
|
||||
})
|
||||
// Die Zeiten-Ansicht liest Projekt und Weg (Behalten) aus ihrem Behaelter (zeiten.js, initZeiten); der Klick steigt danach zum Navigationshandler auf
|
||||
root.querySelector('[data-fn-tile="zeiten"]')?.addEventListener('click', (ev) => {
|
||||
const knopf = ev.target?.closest?.('button[data-view="zeiten"]')
|
||||
const body = knopf && document.getElementById('zeiten-body')
|
||||
if (!body) return
|
||||
body.dataset.wunschProjekt = knopf.dataset.project
|
||||
if (knopf.dataset.zeitenWeg === 'behalten') body.dataset.wunschWeg = 'behalten'
|
||||
else delete body.dataset.wunschWeg
|
||||
})
|
||||
if (termine?.verfuegbar) await showTermineCount(root, projects)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
/* Oeffnungszeiten live im Kundenbereich: Bestell-, Einschalt- und Behalten-Seite und die Statuskarte (ES-Modul, wird relativ von zeiten.js
|
||||
geladen; Spec 3.1 bis 3.3, Server: server/routes/funktionen.js). Alles, was hier steht, kommt vom Server: Preis, Zustimmungen, Knopftexte
|
||||
und Belehrung aus GET /api/funktionen (zeiten = Angebot, features.zeiten = Register-Ansicht der bezahlten Zustimmung), nie aus einer Kopie
|
||||
im Browser. Fremdtext kommt nur ueber textContent in die Seite (h aus termine-dom.js), nie ueber innerHTML.
|
||||
|
||||
renderZeitenBestellen(root, { ctx, angebot, projekt, modus }) zeichnet je nach Abo-Lage (angebot.inklusive, Ruling R20):
|
||||
true Einschalten: Wartungs-Abo laeuft, ein Haken (Leistungsbeschreibung), Knopf "Einschalten", kein Preis und keine
|
||||
Belehrung (es gibt kein Widerrufsverlangen); POST /api/funktionen/zeiten/einschalten
|
||||
false Bestellen: 30 € einmalig, Preis, Widerrufsbelehrung samt Muster-Formular als Text vor dem Knopf (Ruling R32:
|
||||
exakt der Text, den der Server im Nachweis speichert, angebot.belehrung), zwei Haken, Knopf aus dem Register;
|
||||
POST /api/funktionen/zeiten/checkout, dann Weiterleitung zu Stripe
|
||||
false, grund nicht_aktiv Hinweis "einschaltbar, sobald dein Abo aktiv ist", kein Knopf
|
||||
null "Gerade nicht prüfbar – bitte später erneut" mit Knopf "Noch einmal versuchen"
|
||||
modus 'behalten' (nach dem Ende des Wartungs-Abos, Spec 3.3): dieselbe bezahlte Seite, POST /api/funktionen/zeiten/behalten.
|
||||
Fehlt die Belehrung oder die Zustimmung, bestellt die Seite nie (der Nachweis wuerde einen Text behaupten, den der Kunde nicht gesehen hat).
|
||||
|
||||
ctx (baut zeiten.js): { projectId, features, ustHinweis, kundenansicht (Boolean), neuZeichnen({ meldung }), fertig({ danke }), zurueck() }.
|
||||
Bei 409 (inklusive, kein_abo, gekuendigt, nicht_aktiv, fassung_veraltet, schon bestellt …) holt neuZeichnen die Liste neu und zeichnet die Ansicht
|
||||
nach dem neuen Stand mit der Meldung des Servers; fehlt ctx.neuZeichnen oder scheitert es, bleibt die Meldung am Knopf.
|
||||
zeitenStatusKarte({ projekt, feature, angebot, kundenansicht, danke, meldung }, { behalten, weiter }) ist die Karte fuer ordered, setup, active
|
||||
und disabled (Zustand off hat die Seite oben). */
|
||||
|
||||
import { bestellKoerper, sicherLink } from './funktion-bestellen.js'
|
||||
import { fehlerKarte, h, hakenFeld, request } from './termine-dom.js'
|
||||
|
||||
const WEGE = {
|
||||
einschalten: '/api/funktionen/zeiten/einschalten',
|
||||
bestellen: '/api/funktionen/zeiten/checkout',
|
||||
behalten: '/api/funktionen/zeiten/behalten',
|
||||
}
|
||||
const KUNDENANSICHT = {
|
||||
einschalten: 'In der Kundenansicht kann nicht eingeschaltet werden.',
|
||||
bestellen: 'In der Kundenansicht kann nicht bestellt werden.',
|
||||
behalten: 'In der Kundenansicht kann nicht bestellt werden.',
|
||||
}
|
||||
const ZAHLUNGSSEITE = 'Die Zahlungsseite konnte nicht geöffnet werden. Bitte versuch es noch einmal oder schreib uns im Chat.'
|
||||
const NICHT_GELADEN = 'Die Seite konnte gerade nicht geladen werden. Bitte lade sie neu.'
|
||||
const NICHT_PRUEFBAR = 'Gerade nicht prüfbar – bitte später erneut.'
|
||||
const ABGESCHALTET = 'Öffnungszeiten live wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du die Funktion wieder möchtest.'
|
||||
const FEHLER = {
|
||||
einschalten: 'Öffnungszeiten live konnte gerade nicht eingeschaltet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.',
|
||||
bestellen: 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.',
|
||||
behalten: 'Das Behalten konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.',
|
||||
}
|
||||
|
||||
const text = (wert) => (typeof wert === 'string' ? wert : '')
|
||||
const istConsent = (wert) => wert !== null && typeof wert === 'object' && typeof wert.version === 'string' && typeof wert.knopf === 'string' && Array.isArray(wert.items)
|
||||
const chatKnopf = () => h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen')
|
||||
|
||||
/** Adresse einer Unterlage: https oder /rechtliches/ (sicherLink) oder eine Datei unter /vertragsunterlagen/ dieses Portals, sonst ''. */
|
||||
function unterlagenLink(url) {
|
||||
const sicher = sicherLink(url)
|
||||
if (sicher) return sicher
|
||||
return typeof url === 'string' && /^\/vertragsunterlagen\/[A-Za-z0-9._-]+\.(?:html|pdf)$/.test(url) && !url.includes('..') ? url : ''
|
||||
}
|
||||
|
||||
/** Zur Zahlungsseite darf nur eine https-Adresse fuehren, nie ein /rechtliches/-Pfad. */
|
||||
function zahlungsUrl(url) {
|
||||
const sicher = sicherLink(url)
|
||||
return sicher.startsWith('https://') ? sicher : ''
|
||||
}
|
||||
|
||||
/** Datum in Berlin als TT.MM.JJJJ, bei fehlendem oder ungueltigem Wert ''. */
|
||||
function datumTag(wert) {
|
||||
const tag = new Date(wert)
|
||||
if (!wert || Number.isNaN(tag.getTime())) return ''
|
||||
return tag.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Frist zum Behalten: Berliner Kalendertag des erkannten Abo-Endes plus 30 Tage (wie fristTag in server/services/zeiten/aboAbgleich.js und die Mail
|
||||
* K26) als TT.MM.JJJJ; ein unlesbares Datum gibt '' (kein erfundenes Datum). Doppelt zu zeitenFrist in funktionen.js (die Kachel importiert nichts).
|
||||
*/
|
||||
export function behaltenFrist(aboEndeAt) {
|
||||
const ms = typeof aboEndeAt === 'string' ? Date.parse(aboEndeAt) : NaN
|
||||
if (Number.isNaN(ms)) return ''
|
||||
const [jahr, monat, tag] = new Date(ms).toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }).split('-').map(Number)
|
||||
return new Date(Date.UTC(jahr, monat - 1, tag + 30)).toLocaleDateString('de-DE', { timeZone: 'UTC', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
/** 'JJJJ-MM-TT' als TT.MM.JJJJ, sonst ''. */
|
||||
export function tagText(iso) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text(iso))
|
||||
return m ? `${m[3]}.${m[2]}.${m[1]}` : ''
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Bestellseite --- */
|
||||
/**
|
||||
* Bestell-, Einschalt- oder Behalten-Seite in root zeichnen und das Element zurueckgeben (siehe Kopf der Datei).
|
||||
* Der Knopf ist gesperrt, solange ein Haken fehlt, ein Klick laeuft oder die Kundenansicht aktiv ist.
|
||||
*/
|
||||
export function renderZeitenBestellen(root, { ctx = {}, angebot = {}, projekt = {}, modus = 'bestellen' } = {}) {
|
||||
const zeige = (element) => {
|
||||
root.replaceChildren(element)
|
||||
return element
|
||||
}
|
||||
const projectId = text(ctx.projectId) || text(projekt.projectId)
|
||||
const zurueck = typeof ctx.zurueck === 'function' && modus === 'behalten'
|
||||
? h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', onclick: () => ctx.zurueck() }, 'Zurück')
|
||||
: null
|
||||
const nochmal = typeof ctx.neuZeichnen === 'function' ? () => ctx.neuZeichnen({}).catch(() => {}) : undefined
|
||||
|
||||
// Welche Seite? (Abo-Lage laut Server, Ruling R20)
|
||||
let variante
|
||||
if (angebot.inklusive === null || angebot.inklusive === undefined) {
|
||||
return zeige(h('div', { class: 'kb-panel tm-info zt-hinweis', 'data-hinweis': 'nicht-pruefbar', role: 'alert' }, h('p', null, NICHT_PRUEFBAR), nochmal ? h('button', { type: 'button', class: 'kb-btn-outline', onclick: nochmal }, 'Noch einmal versuchen') : null, zurueck))
|
||||
}
|
||||
if (modus === 'behalten' && angebot.inklusive === true) {
|
||||
return zeige(h('div', { class: 'kb-panel tm-info zt-hinweis', 'data-hinweis': 'abo-laeuft' }, h('h3', null, 'Dein Wartungs-Abo läuft wieder'), h('p', null, 'Öffnungszeiten live ist darin enthalten – du musst nichts bezahlen.'), zurueck))
|
||||
}
|
||||
if (modus === 'behalten') variante = 'behalten'
|
||||
else if (angebot.inklusive === true) variante = 'einschalten'
|
||||
else if (angebot.grund === 'nicht_aktiv') {
|
||||
return zeige(h('div', { class: 'kb-panel tm-info zt-hinweis', 'data-hinweis': 'nicht-aktiv' }, h('h3', null, 'Im Wartungs-Abo enthalten'), h('p', null, 'Öffnungszeiten live ist in deinem Wartungs-Abo enthalten – einschaltbar, sobald dein Abo aktiv ist.')))
|
||||
} else variante = 'bestellen'
|
||||
|
||||
const bezahlt = variante !== 'einschalten'
|
||||
const consent = bezahlt ? ctx.features?.zeiten?.consent : angebot.einschalten?.consent
|
||||
const belehrung = text(angebot.belehrung)
|
||||
// Ohne Zustimmung oder (bezahlt) ohne Belehrung wird nie bestellt: der Nachweis wuerde einen Text behaupten, den der Kunde nicht gesehen hat
|
||||
if (!istConsent(consent) || (bezahlt && belehrung.trim() === '')) return zeige(fehlerKarte(NICHT_GELADEN))
|
||||
const eintraege = consent.items.filter((eintrag) => eintrag !== null && typeof eintrag === 'object')
|
||||
const hakenEintraege = eintraege.filter((eintrag) => eintrag.haken === true)
|
||||
const kundenansicht = ctx.kundenansicht === true
|
||||
const preis = text(angebot.preis) || text(ctx.features?.zeiten?.priceLabel) || '30 €'
|
||||
const einrichtung = text(ctx.features?.zeiten?.setupEstimate)
|
||||
const gewaehlt = new Set()
|
||||
let laeuft = false
|
||||
|
||||
const einleitung = {
|
||||
einschalten: 'Öffnungszeiten live ist in deinem Wartungs-Abo ohne Aufpreis enthalten. Du schaltest die Funktion ein, danach trägst du deine Zeiten ein.',
|
||||
bestellen: 'Deine Website zeigt live, ob du gerade geöffnet hast. Die Einrichtung kostet einmalig – danach läuft die Funktion ohne laufende Kosten.',
|
||||
behalten: projekt.features?.zeiten?.state === 'disabled'
|
||||
? `Öffnungszeiten live ist abgeschaltet, deine Eingaben sind noch gespeichert. Für einmalig ${preis} schaltest du die Funktion wieder ein.`
|
||||
: `Dein Wartungs-Abo ist beendet. Für einmalig ${preis} behältst du Öffnungszeiten live – ohne neuen Einbau, an deiner Website ändert sich nichts.`,
|
||||
}[variante]
|
||||
|
||||
// Preis: nur auf dem bezahlten Weg; die Einrichtungsdauer nur bei einer Neubestellung (beim Behalten gibt es keinen neuen Einbau)
|
||||
const preisBlock = bezahlt
|
||||
? h('div', { class: 'tm-bestellen-preis' },
|
||||
h('h4', null, variante === 'bestellen' ? 'Preis und Einrichtung' : 'Preis'),
|
||||
h('p', { class: 'tm-bestellen-betrag' }, h('strong', null, preis), ` einmalig${text(ctx.ustHinweis) ? `, ${ctx.ustHinweis}` : ''}`),
|
||||
variante === 'bestellen' && einrichtung ? h('p', null, `Einrichtung: ${einrichtung}`) : null)
|
||||
: null
|
||||
|
||||
// Widerrufsbelehrung samt Muster-Formular: ein Textbereich zum Scrollen, nie eingeklappt, genau der Text des Nachweises (R32)
|
||||
const belehrungBlock = bezahlt
|
||||
? h('div', { class: 'tm-bestellen-belehrung' },
|
||||
h('h4', null, 'Widerrufsbelehrung'),
|
||||
h('p', { class: 'tm-hinweis' }, 'Lies die Belehrung bitte ganz. Das Muster-Widerrufsformular gehört dazu.'),
|
||||
h('div', { class: 'tm-rechtstext', tabindex: '0', role: 'region', 'aria-label': 'Widerrufsbelehrung mit Muster-Widerrufsformular' }, belehrung))
|
||||
: null
|
||||
|
||||
const fassung = bezahlt ? ctx.features?.zeiten?.leistungsbeschreibung : angebot.einschalten?.leistungsbeschreibung
|
||||
const link = unterlagenLink(fassung?.url)
|
||||
const unterlagenBlock = link === '' ? null : h('div', { class: 'tm-bestellen-unterlagen' },
|
||||
h('h4', null, 'Unterlagen'),
|
||||
h('ul', { class: 'tm-bestellen-dokumente' }, h('li', null, h('a', { href: link, target: '_blank', rel: 'noopener noreferrer' }, 'Leistungsbeschreibung „Öffnungszeiten live“'))))
|
||||
|
||||
// Zustimmungen in Registerreihenfolge: Eintraege mit haken: true sind Haken, die anderen reiner Text
|
||||
const knopf = h('button', { type: 'button', class: 'kb-btn-primary tm-bestellen-knopf', 'data-aktion': variante, disabled: true, onclick: () => absenden() }, consent.knopf)
|
||||
const meldungEl = h('p', { class: 'tm-fehler', role: 'alert', 'data-meldung': 'bestellen' })
|
||||
const hinweisEl = h('p', { class: 'tm-hinweis', 'data-hinweis': 'bestellen' })
|
||||
const hakenZeile = (eintrag) => {
|
||||
const feld = h('input', { type: 'checkbox', 'data-haken': eintrag.id })
|
||||
feld.addEventListener('change', () => {
|
||||
if (feld.checked) gewaehlt.add(eintrag.id)
|
||||
else gewaehlt.delete(eintrag.id)
|
||||
aktualisiere()
|
||||
})
|
||||
return hakenFeld(text(eintrag.id), feld, text(eintrag.text))
|
||||
}
|
||||
const zustimmungBlock = h('div', { class: 'tm-bestellen-zustimmung' },
|
||||
h('h4', null, 'Deine Zustimmung'),
|
||||
h('div', { class: 'tm-bestellen-haken' }, eintraege.map((eintrag) => (eintrag.haken === true ? hakenZeile(eintrag) : h('p', { class: 'tm-bestellen-text' }, text(eintrag.text))))))
|
||||
|
||||
const titel = { einschalten: 'Öffnungszeiten live einschalten', bestellen: 'Öffnungszeiten live bestellen', behalten: 'Öffnungszeiten live behalten' }[variante]
|
||||
const seite = h('section', { class: 'kb-panel tm-bestellen zt-bestellen', 'data-variante': variante },
|
||||
h('h3', null, titel),
|
||||
h('p', { class: 'tm-abschnitt-text tm-hinweis' }, einleitung),
|
||||
preisBlock,
|
||||
belehrungBlock,
|
||||
unterlagenBlock,
|
||||
zustimmungBlock,
|
||||
h('div', { class: 'tm-bestellen-aktion' }, meldungEl, knopf, hinweisEl, zurueck))
|
||||
zeige(seite)
|
||||
|
||||
function meldung(nachricht) {
|
||||
meldungEl.textContent = nachricht
|
||||
}
|
||||
|
||||
function aktualisiere() {
|
||||
const hakenFehlen = hakenEintraege.some((eintrag) => !gewaehlt.has(eintrag.id))
|
||||
knopf.disabled = laeuft || hakenFehlen || kundenansicht
|
||||
if (kundenansicht) hinweisEl.textContent = KUNDENANSICHT[variante]
|
||||
else hinweisEl.textContent = hakenFehlen ? (variante === 'einschalten' ? 'Setze den Haken, um einzuschalten.' : 'Setze alle Haken, um zu bestellen.') : ''
|
||||
}
|
||||
|
||||
async function absenden() {
|
||||
if (knopf.disabled) return
|
||||
laeuft = true
|
||||
aktualisiere()
|
||||
meldung('')
|
||||
let antwort
|
||||
try {
|
||||
antwort = await request(WEGE[variante], { method: 'POST', body: bestellKoerper({ projectId, bestellung: { consent }, gewaehlt }) })
|
||||
} catch (err) {
|
||||
// 409: der Stand hat sich geaendert (Abo, schon bestellt, neue Fassung): die Ansicht neu nach dem Server zeichnen, mit seiner Meldung
|
||||
if (err?.status === 409 && typeof ctx.neuZeichnen === 'function') {
|
||||
try {
|
||||
await ctx.neuZeichnen({ meldung: err.message })
|
||||
return
|
||||
} catch {
|
||||
// die Meldung steht dann am Knopf
|
||||
}
|
||||
}
|
||||
meldung(err?.message || FEHLER[variante])
|
||||
laeuft = false
|
||||
aktualisiere()
|
||||
return
|
||||
}
|
||||
if (variante === 'einschalten') {
|
||||
// eingeschaltet ist eingeschaltet, auch wenn das Neuladen der Ansicht danach scheitert (ein zweiter Klick waere harmlos, ist aber unnoetig)
|
||||
try {
|
||||
if (typeof ctx.fertig === 'function') await ctx.fertig({ danke: 'eingeschaltet', antwort })
|
||||
else meldung('Öffnungszeiten live ist eingeschaltet.')
|
||||
} catch {
|
||||
meldung('Öffnungszeiten live ist eingeschaltet. Lade die Seite neu, dann siehst du den aktuellen Stand.')
|
||||
}
|
||||
return
|
||||
}
|
||||
const ziel = zahlungsUrl(antwort?.url)
|
||||
if (ziel) {
|
||||
window.location.href = ziel // die Seite wird verlassen, der Knopf bleibt gesperrt
|
||||
return
|
||||
}
|
||||
meldung(ZAHLUNGSSEITE)
|
||||
laeuft = false
|
||||
aktualisiere()
|
||||
}
|
||||
|
||||
aktualisiere()
|
||||
return seite
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Statuskarte --- */
|
||||
const PILLE = { ordered: ['Zahlung offen', 'wait'], setup: ['In Einrichtung', 'wait'], active: ['Aktiv', 'ok'], disabled: ['Abgeschaltet', 'off'] }
|
||||
const DANKE = {
|
||||
bestellt: 'Danke! Sobald die Zahlung da ist, legen wir los.',
|
||||
bestelltBezahlt: 'Danke! Deine Zahlung ist bei uns angekommen.',
|
||||
behalten: 'Danke! Sobald die Zahlung da ist, behältst du Öffnungszeiten live. An deiner Website ändert sich nichts.',
|
||||
behaltenBezahlt: 'Danke! Du behältst Öffnungszeiten live.',
|
||||
eingeschaltet: 'Öffnungszeiten live ist eingeschaltet.',
|
||||
}
|
||||
|
||||
/** Danke-Satz zur Rueckkehr: 'bestellt' | 'behalten' | 'eingeschaltet', je nachdem, ob die Zahlung schon gebucht ist; sonst ''. */
|
||||
function dankeText(danke, feature) {
|
||||
if (danke === 'bestellt') return feature?.state === 'ordered' ? DANKE.bestellt : DANKE.bestelltBezahlt
|
||||
if (danke === 'behalten') return feature?.aboEndeAt ? DANKE.behalten : DANKE.behaltenBezahlt
|
||||
return danke === 'eingeschaltet' ? DANKE.eingeschaltet : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Karte fuer ordered („Zahlung offen“), setup („In Einrichtung“), active und disabled.
|
||||
* projekt = Eintrag aus GET /api/funktionen (projectId, zeitenLeer), feature = projekt.features.zeiten (state, einbau, ticketWoid, orderedAt,
|
||||
* invoiceUrl, aboEndeAt, disabledReason, loeschungAm), angebot = zeiten (inklusive, preis), kundenansicht = Sitzung in der Kundenansicht,
|
||||
* danke = 'bestellt' | 'behalten' | 'eingeschaltet' (Rueckkehr von Stripe oder Einschalten), meldung = Text am Knopf.
|
||||
* behalten() oeffnet die Behalten-Seite (Knopf „Für 30 € behalten“ nur, wenn sicher ist, dass das Abo nicht wieder laeuft: inklusive false);
|
||||
* weiter({ bestellen, meldung }) wird gerufen, wenn „Zahlung fortsetzen“ scheitert (bestellen: es gibt keine offene Zahlungsseite mehr, die Ansicht
|
||||
* zeigt wieder die Bestellseite).
|
||||
*/
|
||||
export function zeitenStatusKarte({ projekt, feature, angebot, kundenansicht, danke, meldung } = {}, { behalten, weiter } = {}) {
|
||||
const state = text(feature?.state) || 'off'
|
||||
const [pilleText, pilleArt] = PILLE[state] || PILLE.setup
|
||||
const preis = text(angebot?.preis) || '30 €'
|
||||
const inklusive = angebot?.inklusive
|
||||
const leer = projekt?.zeitenLeer === true
|
||||
const ticket = feature?.ticketWoid
|
||||
const hatTicket = ticket !== null && ticket !== undefined && String(ticket) !== ''
|
||||
const bestelltAm = datumTag(feature?.orderedAt)
|
||||
const rechnung = sicherLink(feature?.invoiceUrl)
|
||||
const zeile = (name, wert) => h('div', null, h('dt', null, name), h('dd', null, wert))
|
||||
const fakten = [
|
||||
state === 'setup' || state === 'active' ? (hatTicket ? zeile('Ticketnummer', `#${ticket}`) : null) : null,
|
||||
bestelltAm ? zeile('Bestellt am', bestelltAm) : null,
|
||||
].filter(Boolean)
|
||||
|
||||
const aboEnde = feature?.disabledReason === 'abo_ende'
|
||||
const daten = state === 'disabled' && aboEnde && projekt?.zeitenLeer === false && !(text(feature?.loeschungAm) !== '' && feature.loeschungAm < heuteBerlin())
|
||||
const frist = (state === 'setup' || state === 'active') && feature?.aboEndeAt ? behaltenFrist(feature.aboEndeAt) : ''
|
||||
|
||||
const titel = {
|
||||
ordered: 'Zahlung wird bestätigt …',
|
||||
setup: 'Wir richten Öffnungszeiten live ein',
|
||||
active: 'Öffnungszeiten live ist aktiv',
|
||||
disabled: 'Öffnungszeiten live ist abgeschaltet',
|
||||
}[state] || 'Öffnungszeiten live'
|
||||
const absatz = {
|
||||
ordered: 'Sobald deine Zahlung bei uns ankommt, bauen wir Öffnungszeiten live ein.',
|
||||
setup: feature?.einbau === 'angestossen'
|
||||
? 'Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen deiner Website (meist in wenigen Minuten).'
|
||||
: 'Unser Team baut die Funktion ein.',
|
||||
active: 'Deine Öffnungszeiten stehen live auf deiner Website. Was du hier änderst, übernimmt die Website automatisch.',
|
||||
disabled: aboEnde ? 'Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist.' : ABGESCHALTET,
|
||||
}[state] || ''
|
||||
|
||||
const meldungEl = h('p', { class: 'tm-fehler', role: 'alert', 'data-meldung': 'karte' }, text(meldung) || null)
|
||||
const sperre = kundenansicht === true
|
||||
|
||||
// Behalten (30 €): in Einrichtung/aktiv nach dem erkannten Abo-Ende, abgeschaltet solange die Eingaben noch da sind
|
||||
const behaltenKnopf = (beschriftung) => h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'behalten', disabled: sperre, onclick: () => { if (typeof behalten === 'function') behalten() } }, beschriftung)
|
||||
const behaltenBox = frist && inklusive === false
|
||||
? h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'behalten' },
|
||||
h('span', null, `Dein Wartungs-Abo ist beendet – behalte die Funktion bis ${frist} für einmalig ${preis}.`),
|
||||
behaltenKnopf(`Für ${preis} behalten`))
|
||||
: null
|
||||
let abgeschaltetBox = null
|
||||
if (daten && inklusive === false) {
|
||||
abgeschaltetBox = h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'wieder-einschalten' },
|
||||
h('span', null, `Deine Eingaben bleiben${tagText(feature?.loeschungAm) ? ` bis ${tagText(feature.loeschungAm)}` : ''} gespeichert. Für einmalig ${preis} schaltest du die Funktion wieder ein.`),
|
||||
behaltenKnopf(`Für ${preis} wieder einschalten`))
|
||||
} else if (daten && inklusive === true) {
|
||||
abgeschaltetBox = h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'abo-laeuft' }, h('span', null, 'Dein Wartungs-Abo läuft wieder – wir schalten Öffnungszeiten live automatisch wieder ein (meist innerhalb eines Tages).'))
|
||||
} else if (daten) {
|
||||
abgeschaltetBox = h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'nicht-pruefbar' }, h('span', null, NICHT_PRUEFBAR))
|
||||
}
|
||||
|
||||
// Zahlung fortsetzen (nur ordered): dieselbe Zahlungsseite, ohne neue Zustimmung; gibt es keine mehr, zeigt die Ansicht wieder die Bestellseite
|
||||
const fortsetzenKnopf = state === 'ordered'
|
||||
? h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'fortsetzen', disabled: sperre, onclick: () => fortsetzen() }, 'Zahlung fortsetzen')
|
||||
: null
|
||||
async function fortsetzen() {
|
||||
if (fortsetzenKnopf.disabled) return
|
||||
fortsetzenKnopf.disabled = true
|
||||
meldungEl.textContent = ''
|
||||
try {
|
||||
const antwort = await request(WEGE.bestellen, { method: 'POST', body: { projectId: projekt?.projectId, fortsetzen: true } })
|
||||
const ziel = zahlungsUrl(antwort?.url)
|
||||
if (ziel) {
|
||||
window.location.href = ziel
|
||||
return
|
||||
}
|
||||
meldungEl.textContent = ZAHLUNGSSEITE
|
||||
} catch (err) {
|
||||
if (typeof weiter === 'function' && err?.code === 'zustimmung_fehlt') {
|
||||
weiter({ bestellen: true, meldung: err.message })
|
||||
return
|
||||
}
|
||||
meldungEl.textContent = err?.message || ZAHLUNGSSEITE
|
||||
}
|
||||
fortsetzenKnopf.disabled = false
|
||||
}
|
||||
|
||||
const dankeSatz = dankeText(danke, feature)
|
||||
return h('section', { class: 'kb-panel tm-statuskarte zt-status', 'data-state': state },
|
||||
dankeSatz ? h('p', { class: 'tm-danke', role: 'status', 'data-danke': danke }, dankeSatz) : null,
|
||||
h('span', { class: `tm-pille tm-pille-${pilleArt}` }, pilleText),
|
||||
h('h3', null, titel),
|
||||
absatz ? h('p', null, absatz) : null,
|
||||
state === 'setup' && leer ? h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'zeiten-eintragen' }, h('span', null, 'Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen.')) : null,
|
||||
state === 'active' && leer ? h('div', { class: 'tm-box tm-box-info', role: 'note', 'data-hinweis': 'zeiten-eintragen' }, h('span', null, 'Trag jetzt deine Zeiten ein.')) : null,
|
||||
fakten.length > 0 ? h('dl', { class: 'tm-fakten' }, fakten) : null,
|
||||
state === 'ordered' ? h('p', { class: 'tm-hinweis' }, 'Zahlst du per SEPA-Lastschrift, kann es einige Tage dauern, bis die Zahlung bei uns ankommt.') : null,
|
||||
behaltenBox,
|
||||
abgeschaltetBox,
|
||||
meldungEl,
|
||||
sperre && (fortsetzenKnopf || behaltenBox || abgeschaltetBox) ? h('p', { class: 'tm-hinweis' }, KUNDENANSICHT.bestellen) : null,
|
||||
h('div', { class: 'tm-aktionen' },
|
||||
fortsetzenKnopf,
|
||||
state === 'setup' && rechnung ? h('a', { class: 'kb-btn-outline', href: rechnung, target: '_blank', rel: 'noopener noreferrer' }, 'Rechnung ansehen') : null,
|
||||
chatKnopf()))
|
||||
}
|
||||
|
||||
/** Heutiger Berliner Kalendertag 'JJJJ-MM-TT' (zum Vergleich mit loeschungAm). */
|
||||
function heuteBerlin() {
|
||||
return new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' })
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Öffnungszeiten live im Kundenbereich (zeiten.js, zeiten-bestellen.js; der Editor kommt mit den Aufgaben 29 und 30 dazu).
|
||||
Farben und Abstände aus den Tokens von kundenbereich.css (.kb-page). Die Bausteine der Bestell- und Statusseiten (tm-bestellen, tm-statuskarte,
|
||||
tm-rechtstext, tm-haken, tm-pille, tm-box, tm-meldung, tm-info …) stehen in termine.css und gelten hier mit; neu sind nur Klassen mit zt-
|
||||
und die Regeln für #view-zeiten. Handy: bis 880px wie die App-Ansicht, Tippflächen mindestens 44px. */
|
||||
|
||||
#view-zeiten .kb-panel { max-width: none; }
|
||||
#view-zeiten .fn-project { margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; }
|
||||
#view-zeiten button:focus-visible, #view-zeiten input:focus-visible, #view-zeiten select:focus-visible, #view-zeiten textarea:focus-visible,
|
||||
#view-zeiten summary:focus-visible, #view-zeiten a:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; }
|
||||
#view-zeiten input, #view-zeiten select, #view-zeiten textarea { color-scheme: dark; } /* Kalender und Auswahllisten der Zeitfelder dunkel */
|
||||
#view-zeiten .kb-btn-outline, #view-zeiten .kb-btn-primary { min-height: 40px; }
|
||||
|
||||
/* Ansicht: oben die Karte oder die Bestellseite, darunter der Platz des Editors */
|
||||
.zt-kopf > .kb-panel { margin-bottom: 16px; }
|
||||
.zt-editor { margin-top: 4px; }
|
||||
.zt-editor[hidden] { display: none; }
|
||||
.zt-editor:empty { display: none; }
|
||||
.zt-hinweis h3 { margin-bottom: 8px; }
|
||||
.zt-hinweis .kb-btn-outline { margin-top: 4px; }
|
||||
.zt-bestellen .tm-bestellen-aktion .kb-btn-outline { justify-self: start; }
|
||||
.zt-status .tm-box { margin-bottom: 0; }
|
||||
.zt-status .tm-box .kb-btn-outline { flex: none; }
|
||||
|
||||
@media (max-width: 880px), (pointer: coarse) {
|
||||
#view-zeiten button, #view-zeiten .kb-btn-outline, #view-zeiten .kb-btn-primary { min-height: 44px; }
|
||||
#view-zeiten .tm-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; }
|
||||
#view-zeiten .tm-haken input { margin-top: 0; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.zt-bestellen .tm-bestellen-aktion { justify-items: stretch; }
|
||||
.zt-status .tm-box .kb-btn-outline { width: 100%; }
|
||||
.zt-status .tm-aktionen > * { flex: 1 1 auto; }
|
||||
#view-zeiten .tm-rechtstext { max-height: 260px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { #view-zeiten * { transition: none !important; animation: none !important; } }
|
||||
@@ -0,0 +1,219 @@
|
||||
/* Oeffnungszeiten live im Kundenbereich: die Ansicht #zeiten (ES-Modul, app.js laedt es beim Oeffnen der Ansicht; Styles: zeiten.css, dazu die
|
||||
gemeinsamen tm-Klassen aus termine.css). Server: server/routes/funktionen.js (Liste, Einschalten, Bestellen, Behalten) und server/routes/zeiten.js
|
||||
(Editor, Aufgaben 29 und 30).
|
||||
initZeiten(rootEl, { projectId }): Projektauswahl (bei mehr als einer Website) und je nach Zustand der Funktion (features.zeiten.state aus
|
||||
GET /api/funktionen):
|
||||
off Einschalten (Wartungs-Abo laeuft), Bestellen (30 €) oder ein Hinweis (Abo noch nicht aktiv, nicht lesbar): renderZeitenBestellen
|
||||
ordered Statuskarte „Zahlung wird bestätigt …“ (Zahlung fortsetzen)
|
||||
setup Statuskarte (Einbau, Ticket, „Trag jetzt deine Zeiten ein“) und darunter der Platz des Editors
|
||||
active Statuskarte und der Platz des Editors
|
||||
disabled Statuskarte mit dem Grund; „Für 30 € wieder einschalten“, solange die Eingaben da sind; der Platz des Editors zum Lesen
|
||||
Das Abo-Ende (aboEndeAt) zeigt in setup und active den Hinweis „behalte die Funktion bis <Datum> für einmalig 30 €“ mit dem Knopf zur Behalten-Seite.
|
||||
Rueckkehr von Stripe: #zeiten?bestellt=1&projekt=<id> (Bestellung) und #zeiten?behalten=1&projekt=<id> (Behalten) waehlen das Projekt und zeigen
|
||||
„Danke!“; #zeiten?projekt=<id> (Abbruch) waehlt nur das Projekt. Beides wird einmal gelesen und aus der Adresse genommen. Die Kachel in Funktionen
|
||||
legt Projekt und Weg in den Behaelter ab (dataset.wunschProjekt, dataset.wunschWeg = 'behalten').
|
||||
Der Editor (Aufgabe 29) haengt in .zt-editor ein (renderEditor(box, ctx) aus zeiten-editor.js). Ruft app.js initZeiten bei jedem Wechsel in die
|
||||
Ansicht, baut sich nichts neu auf, solange Projekt und Stand gleich sind und der Editorplatz steht (Muster initNews): Eingaben im Editor bleiben stehen.
|
||||
Kundenansicht (window.KbView.id() gesetzt): Einschalten, Bestellen und Behalten sind gesperrt, der Server antwortet dort ohnehin mit 403. */
|
||||
|
||||
import { h, infoKarte, request } from './termine-dom.js'
|
||||
import { renderZeitenBestellen, zeitenStatusKarte } from './zeiten-bestellen.js'
|
||||
|
||||
// Auswahl bleibt beim Wechsel zwischen Funktionen und Zeiten erhalten
|
||||
const auswahl = { projectId: '' }
|
||||
let S = null // aktuelle Sitzung der Ansicht; eine alte Antwort darf eine neuere nicht ueberschreiben (S !== s)
|
||||
|
||||
const zustandVon = (projekt) => projekt?.features?.zeiten?.state || 'off'
|
||||
const istBestellt = (zustand) => zustand === 'ordered' || zustand === 'setup'
|
||||
const kundenansicht = () => Boolean(window.KbView?.id?.())
|
||||
|
||||
/** Rueckkehr von Stripe aus der Adresse lesen und die Adresse bereinigen (wie initNews/initTermine). */
|
||||
function leseRueckkehr() {
|
||||
const hash = window.location.hash || ''
|
||||
if (!hash.startsWith('#zeiten?')) return { bestellt: false, behalten: false, projekt: '' }
|
||||
const query = new URLSearchParams(hash.slice(hash.indexOf('?') + 1))
|
||||
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#zeiten`)
|
||||
return { bestellt: query.get('bestellt') === '1', behalten: query.get('behalten') === '1', projekt: query.get('projekt') || '' }
|
||||
}
|
||||
|
||||
// Vorauswahl wie bei der Kachel: aktiv, dann bestellt/in Einrichtung, dann abgeschaltet, sonst die erste
|
||||
function pickProjekt(projects) {
|
||||
return projects.find((p) => zustandVon(p) === 'active')
|
||||
|| projects.find((p) => istBestellt(zustandVon(p)))
|
||||
|| projects.find((p) => zustandVon(p) === 'disabled')
|
||||
|| projects[0]
|
||||
}
|
||||
|
||||
/** Darf der Kunde die Funktion jetzt behalten (30 €)? Nach dem erkannten Abo-Ende in Einrichtung/aktiv, abgeschaltet nur mit gespeicherten Eingaben. */
|
||||
function behaltenMoeglich(projekt) {
|
||||
const feature = projekt?.features?.zeiten
|
||||
if (!feature) return false
|
||||
if (feature.state === 'setup' || feature.state === 'active') return Boolean(feature.aboEndeAt)
|
||||
return feature.state === 'disabled' && feature.disabledReason === 'abo_ende' && projekt.zeitenLeer === false
|
||||
}
|
||||
|
||||
export async function initZeiten(root, { projectId } = {}) {
|
||||
if (!root) return
|
||||
const rueckkehr = leseRueckkehr()
|
||||
const wunsch = rueckkehr.projekt || projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
|
||||
const wunschWeg = root.dataset.wunschWeg === 'behalten' ? 'behalten' : ''
|
||||
delete root.dataset.wunschProjekt
|
||||
delete root.dataset.wunschWeg
|
||||
if (wunsch) auswahl.projectId = wunsch
|
||||
const antwort = await request('/api/funktionen')
|
||||
const projects = Array.isArray(antwort.projects) ? antwort.projects : []
|
||||
const s = {
|
||||
root, projects, angebot: antwort.zeiten || null, features: antwort.features || {}, ustHinweis: typeof antwort.ustHinweis === 'string' ? antwort.ustHinweis : '',
|
||||
projekt: null, lauf: 0, buehne: null, danke: '',
|
||||
}
|
||||
if (!s.angebot?.verfuegbar) {
|
||||
S = s
|
||||
root.dataset.zeitenKey = ''
|
||||
root.replaceChildren(infoKarte('Öffnungszeiten live ist noch nicht verfügbar', 'Sobald es für dich bereit ist, findest du es hier.'))
|
||||
return
|
||||
}
|
||||
if (!projects.length) {
|
||||
S = s
|
||||
root.dataset.zeitenKey = ''
|
||||
root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.'))
|
||||
return
|
||||
}
|
||||
s.projekt = projects.find((p) => p.projectId === auswahl.projectId) || pickProjekt(projects)
|
||||
auswahl.projectId = s.projekt.projectId
|
||||
|
||||
// Nicht neu aufbauen, solange Projekt und Stand gleich sind und der Editorplatz steht: ungespeicherte Eingaben bleiben (Muster initNews).
|
||||
// Ein Weg (Behalten aus der Kachel) oder die Rueckkehr von Stripe zeichnen immer neu; der Wechsel in die Behalten-Seite behaelt den Editor.
|
||||
const key = stand(s.projekt, s.angebot)
|
||||
const editorDa = root.dataset.zeitenKey === key && root.querySelector('.zt-editor') && S?.root === root
|
||||
if (editorDa && !rueckkehr.bestellt && !rueckkehr.behalten) {
|
||||
if (wunschWeg === 'behalten' && behaltenMoeglich(S.projekt)) zeigeBehalten(S)
|
||||
return
|
||||
}
|
||||
S = s
|
||||
s.danke = rueckkehr.bestellt && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) ? 'bestellt'
|
||||
: rueckkehr.behalten && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) ? 'behalten' : ''
|
||||
s.buehne = h('div', { id: 'zeiten-buehne' })
|
||||
root.replaceChildren(...(projects.length > 1 ? [projektwahl(s)] : []), s.buehne)
|
||||
zeichneProjekt(s, { weg: wunschWeg })
|
||||
}
|
||||
|
||||
/** Alles, was die Statuskarte zeigt und der Editorplatz davon abhaengt (auch die Abo-Lage: Behalten gibt es nur ohne Abo); aendert es sich, wird neu aufgebaut. */
|
||||
function stand(projekt, angebot) {
|
||||
const f = projekt?.features?.zeiten || {}
|
||||
return [projekt?.projectId, f.state, f.aboEndeAt, f.einbau, f.ticketWoid, f.disabledReason, f.unlockedBy, projekt?.zeitenLeer, angebot?.inklusive, angebot?.grund].join(':')
|
||||
}
|
||||
|
||||
function projektwahl(s) {
|
||||
const select = h('select', { id: 'zeiten-project' }, s.projects.map((p) => {
|
||||
const option = h('option', { value: p.projectId }, p.projectName)
|
||||
option.selected = p.projectId === s.projekt.projectId
|
||||
return option
|
||||
}))
|
||||
select.addEventListener('change', () => {
|
||||
auswahl.projectId = select.value
|
||||
s.projekt = s.projects.find((p) => p.projectId === select.value)
|
||||
s.danke = '' // der Dank gilt dem Projekt, mit dem man von Stripe zurueckkam
|
||||
zeichneProjekt(s)
|
||||
})
|
||||
return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'zeiten-project' }, 'Website'), select)
|
||||
}
|
||||
|
||||
/** Liste der Funktionen neu holen (Projekte, Angebot, Register-Ansicht). Wirft bei einem Fehler und aendert dann nichts. */
|
||||
async function ladeListe(s) {
|
||||
const antwort = await request('/api/funktionen')
|
||||
const projects = Array.isArray(antwort.projects) ? antwort.projects : []
|
||||
s.projects = projects
|
||||
s.angebot = antwort.zeiten || null
|
||||
s.features = antwort.features || {}
|
||||
s.ustHinweis = typeof antwort.ustHinweis === 'string' ? antwort.ustHinweis : ''
|
||||
s.projekt = projects.find((p) => p.projectId === s.projekt?.projectId) || s.projekt
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach einem Konflikt (409) oder dem Einschalten: Liste frisch holen und die Ansicht nach dem neuen Stand zeichnen (optionen: meldung des Servers,
|
||||
* danke). Scheitert das Laden, wirft die Funktion und die Seite bleibt mit ihrer Meldung stehen. Eine Antwort, die erst nach einem Wechsel der
|
||||
* Website eintrifft, wird verworfen.
|
||||
*/
|
||||
async function neuZeichnen(s, optionen = {}) {
|
||||
const lauf = s.lauf
|
||||
await ladeListe(s)
|
||||
if (S !== s || s.lauf !== lauf) return
|
||||
if (!s.angebot?.verfuegbar) {
|
||||
s.root.dataset.zeitenKey = ''
|
||||
s.root.replaceChildren(infoKarte('Öffnungszeiten live ist noch nicht verfügbar', 'Sobald es für dich bereit ist, findest du es hier.'))
|
||||
return
|
||||
}
|
||||
if (optionen.danke) s.danke = optionen.danke
|
||||
zeichneProjekt(s, { meldung: optionen.meldung })
|
||||
}
|
||||
|
||||
/** Kontext der Seiten in der Huelle (zeiten-bestellen.js): Projekt, Register-Ansicht, Kundenansicht und die Wege, die Ansicht nach dem Server neu zu zeichnen. */
|
||||
function baueCtx(s, projekt) {
|
||||
return {
|
||||
projectId: projekt.projectId,
|
||||
features: s.features,
|
||||
ustHinweis: s.ustHinweis,
|
||||
kundenansicht: kundenansicht(),
|
||||
neuZeichnen: (optionen) => neuZeichnen(s, optionen),
|
||||
fertig: ({ danke } = {}) => neuZeichnen(s, { danke }),
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Ansicht zum Zustand des gewaehlten Projekts zeichnen (siehe Kopf der Datei). Jeder Aufruf zaehlt s.lauf hoch. */
|
||||
function zeichneProjekt(s, { weg = '', meldung = '', bestellformular = false } = {}) {
|
||||
const projekt = s.projekt
|
||||
++s.lauf
|
||||
const ctx = baueCtx(s, projekt)
|
||||
const zustand = zustandVon(projekt)
|
||||
const feature = projekt.features?.zeiten || {}
|
||||
const danke = s.danke
|
||||
s.danke = '' // nur beim ersten Zeichnen nach der Rueckkehr
|
||||
s.root.dataset.zeitenKey = ''
|
||||
const kopf = h('div', { class: 'zt-kopf' })
|
||||
const teile = []
|
||||
const meldungEl = meldung ? h('p', { class: 'tm-meldung tm-meldung-fehler', role: 'alert', tabindex: '-1', 'data-meldung': 'ansicht' }, meldung) : null
|
||||
if (meldungEl) teile.push(meldungEl)
|
||||
teile.push(kopf)
|
||||
|
||||
const editorPlatz = (zustand === 'setup' || zustand === 'active' || (zustand === 'disabled' && projekt.zeitenLeer === false))
|
||||
? h('div', { class: 'zt-editor', 'data-state': zustand })
|
||||
: null
|
||||
if (editorPlatz) teile.push(editorPlatz)
|
||||
s.buehne.replaceChildren(...teile)
|
||||
if (editorPlatz) s.root.dataset.zeitenKey = stand(projekt, s.angebot)
|
||||
// die Meldung des Servers ersetzt die Seite, auf der der Kunde geklickt hat: dorthin schauen, nicht ins Leere
|
||||
if (meldungEl) {
|
||||
meldungEl.focus({ preventScroll: true })
|
||||
meldungEl.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
|
||||
const statusKarte = (zusatz = {}) => zeitenStatusKarte(
|
||||
{ projekt, feature, angebot: s.angebot, kundenansicht: ctx.kundenansicht, danke, ...zusatz },
|
||||
{
|
||||
behalten: () => zeigeBehalten(s),
|
||||
weiter: (fall) => zeichneProjekt(s, { bestellformular: fall?.bestellen === true, meldung: fall?.meldung || '' }),
|
||||
})
|
||||
|
||||
if (zustand === 'off' || (zustand === 'ordered' && bestellformular)) {
|
||||
renderZeitenBestellen(kopf, { ctx, angebot: s.angebot, projekt })
|
||||
return
|
||||
}
|
||||
kopf.replaceChildren(statusKarte())
|
||||
if (weg === 'behalten' && behaltenMoeglich(projekt)) zeigeBehalten(s)
|
||||
}
|
||||
|
||||
/** Behalten-Seite (30 €) statt der Statuskarte; der Editor darunter bleibt, nur versteckt, damit nichts verloren geht. „Zurück“ zeigt wieder die Karte. */
|
||||
function zeigeBehalten(s) {
|
||||
const kopf = s.buehne.querySelector('.zt-kopf')
|
||||
if (!kopf) return
|
||||
const editor = s.buehne.querySelector('.zt-editor')
|
||||
const ctx = baueCtx(s, s.projekt)
|
||||
ctx.zurueck = () => {
|
||||
if (editor) editor.hidden = false
|
||||
kopf.replaceChildren(zeitenStatusKarte(
|
||||
{ projekt: s.projekt, feature: s.projekt.features?.zeiten, angebot: s.angebot, kundenansicht: ctx.kundenansicht },
|
||||
{ behalten: () => zeigeBehalten(s) }))
|
||||
}
|
||||
if (editor) editor.hidden = true
|
||||
renderZeitenBestellen(kopf, { ctx, angebot: s.angebot, projekt: s.projekt, modus: 'behalten' })
|
||||
}
|
||||
@@ -160,8 +160,11 @@ async function zeitenListenfelder(docs, projectId, verfuegbar, jetzt) {
|
||||
* Oeffnungszeiten-Teil der Antwort von GET /api/funktionen (Spec 3.1). Nicht verfuegbar (Schalter, Pilotliste): genau { verfuegbar: false },
|
||||
* das Abo wird dann nicht gelesen. Verfuegbar: inklusive (true: laeuft und ist aktiv, die Funktion ist im Wartungs-Abo enthalten und
|
||||
* einschaltbar; false: sonst, dann sagt grund warum: kein_abo | gekuendigt | nicht_aktiv; null: Abo nicht lesbar), grund (null bei true und
|
||||
* null), preis (Register) und einschalten mit den Texten des Gratisweges (Zustimmung mit einem Haken, Knopf "Einschalten", Fassung der
|
||||
* Leistungsbeschreibung). Ein Lesefehler im Abo kostet die Liste nichts; im Log stehen nur Art und Status, nie der Text.
|
||||
* null), preis (Register), einschalten mit den Texten des Gratisweges (Zustimmung mit einem Haken, Knopf "Einschalten", Fassung der
|
||||
* Leistungsbeschreibung) und belehrung: die Widerrufsbelehrung samt Muster-Formular, die die Bestellseite des bezahlten Weges (30 €, auch
|
||||
* "Behalten") vor dem Knopf zeigt (Haken belehrung der Funktion, derselbe Text wie im Zustimmungsnachweis, Ruling R32; null, wenn der Haken
|
||||
* sie nicht liefern kann: die Seite bestellt dann nicht). Die Zustimmung des bezahlten Weges steht in features.zeiten (Register-Ansicht).
|
||||
* Ein Lesefehler im Abo kostet die Liste nichts; im Log stehen nur Art und Status, nie der Text.
|
||||
*/
|
||||
async function zeitenAngebot(email, customerId, abos) {
|
||||
if (!zeitenFuerKunde(email)) return { verfuegbar: false }
|
||||
@@ -175,6 +178,12 @@ async function zeitenAngebot(email, customerId, abos) {
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Abo für Öffnungszeiten live nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
|
||||
}
|
||||
let belehrung = null
|
||||
try {
|
||||
belehrung = (await featureHaken('zeiten').belehrung?.()) ?? null
|
||||
} catch (err) {
|
||||
console.error(`[funktionen] Widerrufsbelehrung (zeiten) nicht lesbar: ${err?.name ?? 'Fehler'}`)
|
||||
}
|
||||
const { version, url } = register.leistungsbeschreibung
|
||||
return {
|
||||
verfuegbar: true,
|
||||
@@ -182,6 +191,7 @@ async function zeitenAngebot(email, customerId, abos) {
|
||||
grund,
|
||||
preis: register.priceLabel,
|
||||
einschalten: { consent: oeffentlicheConsent(freiweg(register).consent), leistungsbeschreibung: { version, url: url ?? '' } },
|
||||
belehrung,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
// Kachel „Öffnungszeiten live“ in public/funktionen.js (Spec 3.1, Ruling R20). Die Kachel steht inline in funktionen.js (keine neuen Importe,
|
||||
// test/termineView.test.js und test/termineFragebogenView.test.js zählen sie) und macht keine Abfrage über /api/funktionen hinaus.
|
||||
// Eigener Loader: ALLE Importe von funktionen.js (absolut /x.js oder relativ ./x.js) zeigen auf die Dateien in public/, damit ein künftiger
|
||||
// Import die Tests nicht stillschweigend bricht.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'fn-zeiten-tile-'))
|
||||
const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href
|
||||
const quelle = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
|
||||
const src = quelle.replace(/(\bfrom\s+)'(?:\/|\.\/)([^']+)'/g, (_, von, name) => `${von}'${fileUrl(name)}'`)
|
||||
const file = join(dir, 'funktionen.mjs')
|
||||
writeFileSync(file, src)
|
||||
const { initFunktionen } = await import(pathToFileURL(file).href)
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Hilfen
|
||||
const zeitenFeature = (state, extra = {}) => ({
|
||||
state, siteKey: null, ticketWoid: null, orderedAt: null, activatedAt: null, invoiceUrl: null, disabledReason: null, disabledAt: null, loeschungAm: null,
|
||||
unlockedBy: state === 'off' ? null : 'abo', aboEndeAt: null, einbau: null, ...extra,
|
||||
})
|
||||
const projekt = (id, state = 'off', feature = {}, extra = {}) => ({
|
||||
projectId: id,
|
||||
projectName: `Website ${id}`,
|
||||
liveUrl: '',
|
||||
features: { news: { state: 'off' }, zeiten: zeitenFeature(state, feature) },
|
||||
analyse: { comingSoon: true },
|
||||
zeitenStatus: null,
|
||||
zeitenLeer: true,
|
||||
...extra,
|
||||
})
|
||||
const ANGEBOT = (inklusive = true, grund = null) => ({
|
||||
verfuegbar: true,
|
||||
inklusive,
|
||||
grund,
|
||||
preis: '30 €',
|
||||
einschalten: { consent: { version: 'v', knopf: 'Einschalten', items: [{ id: 'leistung', text: 'Haken', haken: true }] }, leistungsbeschreibung: { version: 'v', url: '/vertragsunterlagen/zeiten-leistungsbeschreibung.html' } },
|
||||
})
|
||||
|
||||
async function render(projects, extra = {}) {
|
||||
const aufrufe = []
|
||||
globalThis.fetch = async (url) => {
|
||||
aufrufe.push(String(url))
|
||||
return { ok: true, status: 200, json: async () => ({ projects, ...extra }) }
|
||||
}
|
||||
const root = { innerHTML: '', querySelector: () => null }
|
||||
await initFunktionen(root)
|
||||
return { html: root.innerHTML, aufrufe }
|
||||
}
|
||||
const kachel = (html) => {
|
||||
const von = html.indexOf('data-fn-tile="zeiten"')
|
||||
assert.ok(von >= 0, 'die Kachel fehlt')
|
||||
return html.slice(von, html.indexOf('</article>', von))
|
||||
}
|
||||
const nurText = (html) => html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
const knoepfe = (html) => [...html.matchAll(/<button\b[^>]*>([^<]*)<\/button>/g)].map((m) => m[1].trim())
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Tests
|
||||
test('ohne verfuegbar keine Kachel', async () => {
|
||||
const P = [projekt('a')]
|
||||
const ohne = (await render(P)).html
|
||||
assert.doesNotMatch(ohne, /data-fn-tile="zeiten"/)
|
||||
assert.doesNotMatch(ohne, /Öffnungszeiten/)
|
||||
const aus = (await render(P, { zeiten: { verfuegbar: false } })).html
|
||||
assert.equal(aus, ohne, 'verfuegbar: false sieht aus wie ein Server ohne Zeiten')
|
||||
assert.equal(ohne.match(/Kommt bald/g).length, 2, 'Terminplaner und Analyse, wie bisher')
|
||||
// auch ein beschädigtes Angebot macht keine Kachel
|
||||
for (const kaputt of [null, 'ja', { verfuegbar: 'ja' }, { inklusive: true }]) assert.doesNotMatch((await render(P, { zeiten: kaputt })).html, /data-fn-tile="zeiten"/, JSON.stringify(kaputt))
|
||||
})
|
||||
|
||||
test('Kachel steht zwischen Terminplaner und Analyse, mit Angebot genau eine zusätzliche Kachel', async () => {
|
||||
const { html } = await render([projekt('a')], { zeiten: ANGEBOT(true) })
|
||||
const stellen = ['data-fn-tile="news"', 'Terminplaner', 'data-fn-tile="zeiten"', 'Analyse'].map((s) => html.indexOf(s))
|
||||
assert.ok(stellen.every((s) => s >= 0), stellen.join())
|
||||
assert.deepEqual(stellen, [...stellen].sort((x, y) => x - y))
|
||||
assert.equal(html.match(/class="fn-tile[ "]/g).length, 4, 'News, Terminplaner (Kommt bald), Zeiten, Analyse (Kommt bald)')
|
||||
assert.equal(html.match(/Kommt bald/g).length, 2)
|
||||
assert.match(kachel(html), /<h3>Öffnungszeiten live<\/h3>/)
|
||||
assert.match(kachel(html), /<svg class="fn-ico"/)
|
||||
})
|
||||
|
||||
test('Abo: „Im Wartungs-Abo enthalten“ und Knopf Einschalten', async () => {
|
||||
const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(true) })).html)
|
||||
assert.match(nurText(k), /Im Wartungs-Abo enthalten/)
|
||||
assert.match(k, /fn-pill fn-pill-off">Aus</)
|
||||
assert.deepEqual(knoepfe(k), ['Einschalten'])
|
||||
assert.match(k, /<button type="button" class="kb-btn-primary fn-tile-cta" data-view="zeiten" data-project="p1">Einschalten<\/button>/)
|
||||
assert.doesNotMatch(k, /30 €|Zahlungspflichtig|einmalig/)
|
||||
})
|
||||
|
||||
test('Abo nicht aktiv: „sobald dein Abo aktiv ist“, kein Bezahlen', async () => {
|
||||
const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(false, 'nicht_aktiv') })).html)
|
||||
assert.match(nurText(k), /Im Wartungs-Abo enthalten – einschaltbar, sobald dein Abo aktiv ist/)
|
||||
assert.deepEqual(knoepfe(k), [])
|
||||
assert.doesNotMatch(k, /30 €|Zahlungspflichtig|einmalig|<button/)
|
||||
})
|
||||
|
||||
test('ohne Abo: „30 € einmalig“', async () => {
|
||||
for (const grund of ['kein_abo', 'gekuendigt', null]) {
|
||||
const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(false, grund) })).html)
|
||||
assert.match(nurText(k), /30 € einmalig/, String(grund))
|
||||
assert.deepEqual(knoepfe(k), ['Öffnungszeiten einrichten · 30 €'], String(grund))
|
||||
assert.match(k, /data-view="zeiten" data-project="p1"/)
|
||||
assert.doesNotMatch(k, /Im Wartungs-Abo enthalten/, 'nichts verspricht, was nicht gilt')
|
||||
}
|
||||
// der Preis kommt aus dem Register, maskiert
|
||||
const anderer = kachel((await render([projekt('p1')], { zeiten: { ...ANGEBOT(false, 'kein_abo'), preis: '31 €' } })).html)
|
||||
assert.match(nurText(anderer), /31 € einmalig/)
|
||||
assert.deepEqual(knoepfe(anderer), ['Öffnungszeiten einrichten · 31 €'])
|
||||
assert.doesNotMatch(anderer, /30 €/)
|
||||
const bose = kachel((await render([projekt('p1')], { zeiten: { ...ANGEBOT(false, 'kein_abo'), preis: '<b>31 €</b>' } })).html)
|
||||
assert.doesNotMatch(bose, /<b>/)
|
||||
// ohne Preis im Angebot gilt der Rückfall 30 €
|
||||
assert.match(nurText(kachel((await render([projekt('p1')], { zeiten: { ...ANGEBOT(false, 'kein_abo'), preis: '' } })).html)), /30 € einmalig/)
|
||||
})
|
||||
|
||||
test('Abo-Lesefehler (inklusive null): „Gerade nicht prüfbar – bitte später erneut“', async () => {
|
||||
const k = kachel((await render([projekt('p1')], { zeiten: ANGEBOT(null, null) })).html)
|
||||
assert.match(nurText(k), /Gerade nicht prüfbar – bitte später erneut/)
|
||||
assert.deepEqual(knoepfe(k), [])
|
||||
assert.doesNotMatch(k, /30 €|Im Wartungs-Abo enthalten/)
|
||||
})
|
||||
|
||||
test('setup: Text je nach einbau, Hinweis „Trag jetzt deine Zeiten ein“ bei zeitenLeer', async () => {
|
||||
const zeigen = async (feature, extra) => kachel((await render([projekt('p1', 'setup', feature, extra)], { zeiten: ANGEBOT(true) })).html)
|
||||
|
||||
const angestossen = await zeigen({ einbau: 'angestossen' }, { zeitenLeer: false })
|
||||
assert.match(angestossen, /fn-pill fn-pill-setup">In Einrichtung</)
|
||||
assert.match(nurText(angestossen), /Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen \(meist in wenigen Minuten\)/)
|
||||
assert.doesNotMatch(nurText(angestossen), /Trag jetzt deine Zeiten ein/)
|
||||
assert.deepEqual(knoepfe(angestossen), ['Zeiten bearbeiten'])
|
||||
assert.match(angestossen, /data-view="zeiten" data-project="p1"/)
|
||||
|
||||
const manuell = await zeigen({ einbau: 'manuell', ticketWoid: '4711' }, { zeitenLeer: false })
|
||||
assert.match(nurText(manuell), /Unser Team baut die Funktion ein \(Ticket #4711\)/)
|
||||
assert.doesNotMatch(nurText(manuell), /nach dem nächsten Veröffentlichen/)
|
||||
|
||||
const ohneTicket = await zeigen({ einbau: 'manuell' }, { zeitenLeer: false })
|
||||
assert.match(nurText(ohneTicket), /Unser Team baut die Funktion ein\./)
|
||||
assert.doesNotMatch(ohneTicket, /Ticket #/)
|
||||
|
||||
const leer = await zeigen({ einbau: 'angestossen' }, { zeitenLeer: true })
|
||||
assert.match(nurText(leer), /Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen/)
|
||||
const leerManuell = await zeigen({ einbau: 'manuell', ticketWoid: '1' }, { zeitenLeer: true })
|
||||
assert.match(nurText(leerManuell), /Trag jetzt deine Zeiten ein/)
|
||||
|
||||
// Kauf: der Neubau steht noch aus (einbau leer): der Text nennt weder Veröffentlichen noch ein Ticket, das es nicht gibt
|
||||
const kauf = await zeigen({ unlockedBy: 'kauf', einbau: null }, { zeitenLeer: false })
|
||||
assert.match(nurText(kauf), /Unser Team baut die Funktion ein\./)
|
||||
})
|
||||
|
||||
test('active: Kurzstatus aus zeitenStatus, keine Zusatzabfrage', async () => {
|
||||
const status = { offen: true, kurz: 'Geöffnet', text: 'Jetzt geöffnet · schließt um 18 Uhr' }
|
||||
const { html, aufrufe } = await render([projekt('p1', 'active', { einbau: 'angestossen' }, { zeitenStatus: status, zeitenLeer: false })], { zeiten: ANGEBOT(true) })
|
||||
assert.deepEqual(aufrufe, ['/api/funktionen'], 'der Status kommt in der Liste, die Kachel fragt nichts nach')
|
||||
const k = kachel(html)
|
||||
assert.match(k, /fn-pill fn-pill-on">Aktiv</)
|
||||
assert.match(nurText(k), /Jetzt geöffnet · schließt um 18 Uhr/)
|
||||
assert.deepEqual(knoepfe(k), ['Zeiten bearbeiten'])
|
||||
// ohne gespeicherte Zeiten kein Status; stattdessen der Hinweis
|
||||
const ohne = kachel((await render([projekt('p1', 'active', {}, { zeitenStatus: null, zeitenLeer: true })], { zeiten: ANGEBOT(true) })).html)
|
||||
assert.doesNotMatch(ohne, /Jetzt geöffnet/)
|
||||
assert.match(nurText(ohne), /Trag jetzt deine Zeiten ein/)
|
||||
// Fremdtext nur maskiert
|
||||
const bose = kachel((await render([projekt('p1', 'active', {}, { zeitenStatus: { offen: true, kurz: 'Geöffnet', text: '<img src=x onerror=1>' }, zeitenLeer: false })], { zeiten: ANGEBOT(true) })).html)
|
||||
assert.doesNotMatch(bose, /<img/)
|
||||
assert.match(bose, /<img/)
|
||||
})
|
||||
|
||||
test('mehrere Websites: Zielprojekt ist die aktive, dann die in Einrichtung, dann die abgeschaltete, sonst die erste; „aktiv für 1 von 2 Websites“', async () => {
|
||||
const wahl = async (liste, angebot = ANGEBOT(true)) => kachel((await render(liste, { zeiten: angebot })).html).match(/data-project="([^"]+)"/)?.[1]
|
||||
assert.equal(await wahl([projekt('a'), projekt('b', 'active', {}, { zeitenLeer: false })]), 'b')
|
||||
assert.equal(await wahl([projekt('a'), projekt('b', 'setup'), projekt('c', 'disabled')]), 'b')
|
||||
assert.equal(await wahl([projekt('a'), projekt('b', 'disabled', { disabledReason: 'abo_ende', loeschungAm: '2999-01-01' }, { zeitenLeer: false })], ANGEBOT(false, 'kein_abo')), 'b')
|
||||
assert.equal(await wahl([projekt('a'), projekt('b')]), 'a')
|
||||
assert.match(nurText(kachel((await render([projekt('a'), projekt('b', 'active', {}, { zeitenLeer: false })], { zeiten: ANGEBOT(true) })).html)), /aktiv für 1 von 2 Websites/)
|
||||
// ohne Website: ein Satz statt eines Knopfs
|
||||
const keine = kachel((await render([], { zeiten: ANGEBOT(true) })).html)
|
||||
assert.deepEqual(knoepfe(keine), [])
|
||||
assert.match(nurText(keine), /Sobald deine Website angelegt ist/)
|
||||
})
|
||||
|
||||
test('aboEndeAt: Behalten-Hinweis mit Datum', async () => {
|
||||
const zeigen = async (feature, angebot = ANGEBOT(false, 'kein_abo'), state = 'active') => kachel((await render([projekt('p1', state, feature, { zeitenLeer: false })], { zeiten: angebot })).html)
|
||||
|
||||
const k = await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z', einbau: 'angestossen' })
|
||||
assert.match(nurText(k), /Dein Wartungs-Abo ist beendet – behalte die Funktion bis 31\.10\.2026 für einmalig 30 €/)
|
||||
assert.deepEqual(knoepfe(k), ['Zeiten bearbeiten', 'Für 30 € behalten'])
|
||||
assert.match(k, /<button type="button" class="kb-btn-outline" data-view="zeiten" data-project="p1" data-zeiten-weg="behalten">Für 30 € behalten<\/button>/)
|
||||
|
||||
// Berliner Kalendertag, nicht UTC: 01.10. 23:30 UTC ist schon der 02.10. in Berlin ⇒ Frist 01.11.
|
||||
assert.match(nurText(await zeigen({ aboEndeAt: '2026-10-01T23:30:00.000Z' })), /bis 01\.11\.2026/)
|
||||
// Appwrite liefert +00:00
|
||||
assert.match(nurText(await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000+00:00' })), /bis 31\.10\.2026/)
|
||||
// auch in der Einrichtung
|
||||
assert.match(nurText(await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z' }, ANGEBOT(false, 'kein_abo'), 'setup')), /behalte die Funktion bis 31\.10\.2026/)
|
||||
// läuft das Abo wieder (inklusive true), gibt es nichts zu bezahlen: der Abgleich nimmt das Ende gleich zurück
|
||||
const wieder = await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z' }, ANGEBOT(true))
|
||||
assert.doesNotMatch(wieder, /behalten|Für 30 €/)
|
||||
assert.deepEqual(knoepfe(wieder), ['Zeiten bearbeiten'])
|
||||
// Abo nicht lesbar: kein Bezahlknopf auf Verdacht
|
||||
const unklar = await zeigen({ aboEndeAt: '2026-10-01T10:00:00.000Z' }, ANGEBOT(null, null))
|
||||
assert.deepEqual(knoepfe(unklar), ['Zeiten bearbeiten'])
|
||||
// ohne Datum kein Hinweis
|
||||
assert.doesNotMatch(await zeigen({ aboEndeAt: null }), /Wartungs-Abo ist beendet/)
|
||||
// ein unlesbares Datum erfindet kein Datum
|
||||
const kaputt = await zeigen({ aboEndeAt: 'kein-datum' })
|
||||
assert.doesNotMatch(nurText(kaputt), /bis NaN|Invalid|undefined/)
|
||||
})
|
||||
|
||||
test('ordered: „Zahlung wird bestätigt …“', async () => {
|
||||
const k = kachel((await render([projekt('p1', 'ordered', { unlockedBy: null })], { zeiten: ANGEBOT(false, 'kein_abo') })).html)
|
||||
assert.match(k, /fn-pill fn-pill-setup">Zahlung offen</)
|
||||
assert.match(nurText(k), /Zahlung wird bestätigt …/)
|
||||
assert.deepEqual(knoepfe(k), ['Status ansehen'])
|
||||
assert.match(k, /data-view="zeiten" data-project="p1"/)
|
||||
})
|
||||
|
||||
test('disabled abo_ende mit Daten: Knopf „Für 30 € wieder einschalten“', async () => {
|
||||
const zeigen = async (feature, extra, angebot = ANGEBOT(false, 'kein_abo')) => kachel((await render([projekt('p1', 'disabled', feature, extra)], { zeiten: angebot })).html)
|
||||
|
||||
const k = await zeigen({ disabledReason: 'abo_ende', disabledAt: '2026-10-04T10:00:00.000Z', loeschungAm: '2999-01-01' }, { zeitenLeer: false })
|
||||
assert.match(k, /fn-pill fn-pill-off">Abgeschaltet</)
|
||||
assert.match(nurText(k), /Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist/)
|
||||
assert.match(nurText(k), /Deine Eingaben bleiben bis 01\.01\.2999 gespeichert/)
|
||||
assert.deepEqual(knoepfe(k), ['Für 30 € wieder einschalten'])
|
||||
assert.match(k, /data-view="zeiten" data-project="p1" data-zeiten-weg="behalten"/)
|
||||
|
||||
// keine Daten mehr (gelöscht oder nie eingetragen): nichts zu behalten
|
||||
const leer = await zeigen({ disabledReason: 'abo_ende', loeschungAm: '2999-01-01' }, { zeitenLeer: true })
|
||||
assert.doesNotMatch(leer, /Für 30 €/)
|
||||
assert.deepEqual(knoepfe(leer), ['Chat öffnen'])
|
||||
// die Frist der Löschung ist vorbei
|
||||
const zuSpaet = await zeigen({ disabledReason: 'abo_ende', loeschungAm: '2000-01-01' }, { zeitenLeer: false })
|
||||
assert.doesNotMatch(zuSpaet, /Für 30 €/)
|
||||
// Abschaltung durch das Team: Grundtext aus der Abschaltung, kein Behalten
|
||||
const team = await zeigen({ disabledReason: 'team' }, { zeitenLeer: false })
|
||||
assert.doesNotMatch(team, /Für 30 €/)
|
||||
assert.match(nurText(team), /Öffnungszeiten live wurde für diese Website abgeschaltet\. Schreib uns im Chat, wenn du die Funktion wieder möchtest\./)
|
||||
assert.deepEqual(knoepfe(team), ['Chat öffnen'])
|
||||
// Altbestand ohne Grund
|
||||
assert.deepEqual(knoepfe(await zeigen({}, { zeitenLeer: false })), ['Chat öffnen'])
|
||||
// das Abo läuft wieder: der Abgleich schaltet selbst wieder ein, 30 € wären doppelt bezahlt
|
||||
const wieder = await zeigen({ disabledReason: 'abo_ende', loeschungAm: '2999-01-01' }, { zeitenLeer: false }, ANGEBOT(true))
|
||||
assert.doesNotMatch(wieder, /Für 30 €/)
|
||||
assert.match(nurText(wieder), /Dein Wartungs-Abo läuft wieder/)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Klick
|
||||
test('Knopf: das Projekt (und beim Behalten der Weg) geht an #zeiten-body; nur Knöpfe der Zeiten-Kachel', async () => {
|
||||
const gesetzt = []
|
||||
const gehoert = {}
|
||||
const artikel = { addEventListener: (typ, fn) => { gehoert[typ] = fn } }
|
||||
const body = { dataset: {} }
|
||||
const vorher = globalThis.document
|
||||
globalThis.document = { getElementById: (id) => { gesetzt.push(id); return id === 'zeiten-body' ? body : null } }
|
||||
try {
|
||||
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ projects: [projekt('p1', 'active', { aboEndeAt: '2026-10-01T10:00:00.000Z' }, { zeitenLeer: false })], zeiten: ANGEBOT(false, 'kein_abo') }) })
|
||||
const root = { innerHTML: '', querySelector: (sel) => (sel === '[data-fn-tile="zeiten"]' ? artikel : null) }
|
||||
await initFunktionen(root)
|
||||
assert.equal(typeof gehoert.click, 'function', 'ein Klick-Handler an der Kachel')
|
||||
const knopf = (dataset) => ({ closest: (sel) => (sel === 'button[data-view="zeiten"]' ? { dataset } : null) })
|
||||
|
||||
gehoert.click({ target: knopf({ view: 'zeiten', project: 'p1' }) })
|
||||
assert.equal(body.dataset.wunschProjekt, 'p1')
|
||||
assert.equal(body.dataset.wunschWeg, undefined, 'ohne Weg keiner')
|
||||
|
||||
gehoert.click({ target: knopf({ view: 'zeiten', project: 'p1', zeitenWeg: 'behalten' }) })
|
||||
assert.equal(body.dataset.wunschWeg, 'behalten')
|
||||
// der nächste Klick ohne Weg nimmt den alten zurück
|
||||
gehoert.click({ target: knopf({ view: 'zeiten', project: 'p1' }) })
|
||||
assert.equal(body.dataset.wunschWeg, undefined)
|
||||
gehoert.click({ target: knopf({ view: 'zeiten', project: 'p1', zeitenWeg: 'behalten' }) })
|
||||
assert.equal(body.dataset.wunschWeg, 'behalten')
|
||||
|
||||
// ein Klick auf etwas anderes in der Kachel (Text) legt nichts ab
|
||||
body.dataset = {}
|
||||
gehoert.click({ target: { closest: () => null } })
|
||||
gehoert.click({ target: {} })
|
||||
assert.deepEqual(body.dataset, {})
|
||||
// ein unbekannter Weg wird nicht übernommen
|
||||
gehoert.click({ target: knopf({ view: 'zeiten', project: 'p2', zeitenWeg: 'alles-frei' }) })
|
||||
assert.equal(body.dataset.wunschProjekt, 'p2')
|
||||
assert.equal(body.dataset.wunschWeg, undefined)
|
||||
} finally {
|
||||
if (vorher === undefined) delete globalThis.document
|
||||
else globalThis.document = vorher
|
||||
}
|
||||
})
|
||||
|
||||
test('Projekt-IDs und Namen kommen nur maskiert in die Kachel', async () => {
|
||||
const k = kachel((await render([projekt('"><script>x</script>', 'off')], { zeiten: ANGEBOT(true) })).html)
|
||||
assert.doesNotMatch(k, /<script>/)
|
||||
assert.match(k, /data-project=""><script>x<\/script>"/)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Quelltext
|
||||
test('funktionen.js: keine neuen Importe für die Zeiten (die Kachel steht inline), keine „24 Uhr“, Du-Form', () => {
|
||||
assert.deepEqual(quelle.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"])
|
||||
assert.deepEqual(quelle.match(/from '\.[^']+'/g), ["from './vertraege-link.js'"])
|
||||
assert.doesNotMatch(quelle, /import\s*\(/)
|
||||
assert.doesNotMatch(quelle, /from '[^']*zeiten/, 'kein Zeiten-Modul in funktionen.js')
|
||||
const von = quelle.indexOf('Öffnungszeiten live')
|
||||
assert.ok(von > 0)
|
||||
assert.doesNotMatch(quelle.slice(von), /24 Uhr/)
|
||||
assert.doesNotMatch(quelle, /\bSie\b/)
|
||||
})
|
||||
@@ -0,0 +1,791 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { installFakeDom } from './support/fakeDom.js'
|
||||
import { config } from '../server/config.js'
|
||||
import { featureConfig, featureHaken, freiweg, oeffentlicheAnsicht } from '../server/services/featureRegistry.js'
|
||||
import { oeffentlicheConsent } from '../server/services/featureOrder.js'
|
||||
import { h } from '../public/termine-dom.js'
|
||||
import { initZeiten } from '../public/zeiten.js'
|
||||
import { behaltenFrist, renderZeitenBestellen, tagText, zeitenStatusKarte } from '../public/zeiten-bestellen.js'
|
||||
|
||||
// Einschalt-, Bestell- und Behalten-Weg der Öffnungszeiten live im Portal (public/zeiten.js, public/zeiten-bestellen.js; Spec 3.1 bis 3.3,
|
||||
// Rulings R20 und R32). Fake-DOM und Fake-fetch aus test/support/fakeDom.js: jeder Aufruf steht in fx.fetchLog, die Antworten kommen der Reihe nach
|
||||
// aus fx.antworten. Die Texte der Register (Zustimmungen, Knopf, Belehrung) kommen aus dem echten Register des Servers.
|
||||
|
||||
const REGISTER = featureConfig('zeiten')
|
||||
const BELEHRUNG = await featureHaken('zeiten').belehrung()
|
||||
const STRIPE = 'https://checkout.stripe.test/c/pay/cs_test_1'
|
||||
const KNOPF_BEZAHLT = REGISTER.consent.knopf
|
||||
const HAKEN_BEZAHLT = REGISTER.consent.items.filter((item) => item.haken).map((item) => item.id)
|
||||
|
||||
const mitDom = (optionen, fn) => async () => {
|
||||
const fx = installFakeDom(optionen)
|
||||
try {
|
||||
await fn(fx)
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Fixtures
|
||||
const zeitenFeature = (state = 'off', extra = {}) => ({
|
||||
state, siteKey: null, ticketWoid: null, orderedAt: null, activatedAt: null, invoiceUrl: null, disabledReason: null, disabledAt: null, loeschungAm: null,
|
||||
unlockedBy: state === 'off' ? null : 'abo', aboEndeAt: null, einbau: null, ...extra,
|
||||
})
|
||||
const projekt = (id = 'p1', state = 'off', feature = {}, extra = {}) => ({
|
||||
projectId: id, projectName: `Website ${id}`, liveUrl: '', features: { news: { state: 'off' }, zeiten: zeitenFeature(state, feature) }, zeitenStatus: null, zeitenLeer: true, ...extra,
|
||||
})
|
||||
const angebot = (inklusive, grund = null, extra = {}) => ({
|
||||
verfuegbar: true,
|
||||
inklusive,
|
||||
grund,
|
||||
preis: REGISTER.priceLabel,
|
||||
einschalten: { consent: oeffentlicheConsent(freiweg(REGISTER).consent), leistungsbeschreibung: { version: REGISTER.leistungsbeschreibung.version, url: REGISTER.leistungsbeschreibung.url } },
|
||||
belehrung: BELEHRUNG,
|
||||
...extra,
|
||||
})
|
||||
/** Antwort von GET /api/funktionen. */
|
||||
const liste = (projects, zeiten, extra = {}) => ({
|
||||
status: 200,
|
||||
body: { projects, features: { zeiten: oeffentlicheAnsicht('zeiten') }, ustHinweis: config.ustHinweis, zeiten, ...extra },
|
||||
})
|
||||
const fehler = (status, error, code) => ({ status, body: { error, ...(code ? { code } : {}) } })
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Hilfen
|
||||
/** Die Ansicht in einen Behälter hängen und initZeiten laufen lassen. */
|
||||
async function oeffne(fx, { projectId = 'p1', hash = '', dataset = {} } = {}) {
|
||||
const root = h('div', { id: 'zeiten-body' })
|
||||
document.body.append(root)
|
||||
fx.window.location.hash = hash
|
||||
for (const [name, wert] of Object.entries(dataset)) root.dataset[name] = wert
|
||||
await initZeiten(root, { projectId })
|
||||
return root
|
||||
}
|
||||
const knopf = (fx, root, aktion) => fx.finde(root, `[data-aktion="${aktion}"]`)
|
||||
const haken = (fx, root) => fx.alle(root, 'input[type="checkbox"]')
|
||||
const alleAnhaken = async (fx, root) => { for (const feld of haken(fx, root)) if (!feld.checked) await fx.klick(feld) }
|
||||
const meldung = (fx, root) => fx.text(fx.finde(root, '[data-meldung="bestellen"]'))
|
||||
const posts = (fx) => fx.fetchLog.filter((aufruf) => aufruf.method === 'POST')
|
||||
const lesen = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Einschalten
|
||||
test('Einschalten: Haken schaltet Knopf frei, POST /api/funktionen/zeiten/einschalten', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(
|
||||
liste([projekt()], angebot(true)),
|
||||
{ status: 200, body: { feature: { state: 'setup' }, einbau: 'angestossen' } },
|
||||
liste([projekt('p1', 'setup', { einbau: 'angestossen' })], angebot(true)))
|
||||
const root = await oeffne(fx)
|
||||
const seite = fx.finde(root, '[data-variante="einschalten"]')
|
||||
assert.ok(seite, 'die Einschaltseite')
|
||||
assert.equal(fx.finde(seite, '.tm-rechtstext'), null, 'ohne Preis keine Widerrufsbelehrung: es gibt kein Widerrufsverlangen')
|
||||
assert.doesNotMatch(fx.text(seite), /30 €|Zahlungspflichtig|einmalig|Widerruf/)
|
||||
assert.match(fx.text(seite), /ohne Aufpreis enthalten/)
|
||||
const link = fx.finde(seite, 'a')
|
||||
assert.equal(link.getAttribute('href'), '/vertragsunterlagen/zeiten-leistungsbeschreibung.html')
|
||||
assert.equal(link.getAttribute('rel'), 'noopener noreferrer')
|
||||
|
||||
const button = knopf(fx, root, 'einschalten')
|
||||
assert.equal(fx.text(button), 'Einschalten', 'der Knopf trägt genau den Text des Registers (consentFrei)')
|
||||
assert.equal(fx.text(button), REGISTER.consentFrei.knopf)
|
||||
assert.equal(button.disabled, true, 'ohne Haken gesperrt')
|
||||
assert.equal(haken(fx, root).length, 1)
|
||||
assert.equal(fx.text(fx.finde(seite, '[data-hinweis="bestellen"]')), 'Setze den Haken, um einzuschalten.')
|
||||
|
||||
await fx.klick(button)
|
||||
assert.equal(posts(fx).length, 0, 'ein gesperrter Knopf sendet nichts')
|
||||
await fx.klick(haken(fx, root)[0])
|
||||
assert.equal(button.disabled, false, 'der Haken gibt den Knopf frei')
|
||||
await fx.klick(haken(fx, root)[0])
|
||||
assert.equal(button.disabled, true, 'ohne Haken wieder gesperrt')
|
||||
await fx.klick(haken(fx, root)[0])
|
||||
|
||||
await fx.klick(button)
|
||||
assert.deepEqual(posts(fx), [{
|
||||
method: 'POST',
|
||||
url: '/api/funktionen/zeiten/einschalten',
|
||||
body: { projectId: 'p1', zustimmung: { version: REGISTER.consentFrei.version, haken: ['leistung'] } },
|
||||
}])
|
||||
assert.deepEqual(fx.fetchLog.map((a) => `${a.method} ${a.url}`), ['GET /api/funktionen', 'POST /api/funktionen/zeiten/einschalten', 'GET /api/funktionen'], 'danach wird die Ansicht nach dem neuen Stand gezeichnet')
|
||||
const karte = fx.finde(root, '.zt-status')
|
||||
assert.equal(karte.getAttribute('data-state'), 'setup')
|
||||
assert.match(fx.text(fx.finde(karte, '[data-danke]')), /Öffnungszeiten live ist eingeschaltet/)
|
||||
assert.match(fx.text(karte), /nach dem nächsten Veröffentlichen/)
|
||||
assert.equal(fx.finde(root, '[data-variante]'), null, 'die Einschaltseite ist weg')
|
||||
}))
|
||||
|
||||
test('Einschalten: Fehler 503 und 400 bleiben am Knopf, der Knopf ist danach wieder frei; ein Klick zählt nur einmal', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(
|
||||
liste([projekt()], angebot(true)),
|
||||
fehler(503, 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.'),
|
||||
{ status: 200, body: { feature: { state: 'setup' }, einbau: 'manuell' } },
|
||||
liste([projekt('p1', 'setup', { einbau: 'manuell', ticketWoid: '4711' })], angebot(true)))
|
||||
const root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
const button = knopf(fx, root, 'einschalten')
|
||||
await fx.klick(button)
|
||||
assert.equal(meldung(fx, root), 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.')
|
||||
assert.equal(button.disabled, false, 'nach dem Fehler wieder frei (Haken bleiben)')
|
||||
assert.equal(haken(fx, root)[0].checked, true)
|
||||
await fx.klick(button)
|
||||
assert.equal(posts(fx).length, 2)
|
||||
assert.equal(fx.text(fx.finde(root, '.zt-status')).includes('#4711'), true, 'Ticketnummer in der Karte')
|
||||
}))
|
||||
|
||||
test('Einschalten: 409 (Abo nicht mehr aktiv) zeichnet die Ansicht nach der neuen Liste mit der Meldung des Servers', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(
|
||||
liste([projekt()], angebot(true)),
|
||||
fehler(409, 'Dein Wartungs-Abo läuft nicht mehr. Du kannst Öffnungszeiten live für einmalig 30 € bestellen.', 'gekuendigt'),
|
||||
liste([projekt()], angebot(false, 'gekuendigt')))
|
||||
const root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'einschalten'))
|
||||
assert.equal(fx.alle(root, '[data-variante]').length, 1)
|
||||
assert.ok(fx.finde(root, '[data-variante="bestellen"]'), 'jetzt der bezahlte Weg')
|
||||
assert.match(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), /läuft nicht mehr/)
|
||||
assert.equal(knopf(fx, root, 'bestellen').disabled, true, 'die Haken sind neu, der Knopf gesperrt')
|
||||
assert.equal(haken(fx, root).every((feld) => !feld.checked), true)
|
||||
}))
|
||||
|
||||
test('Einschalten: scheitert das Neuladen nach einem 409, steht die Meldung am Knopf', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(liste([projekt()], angebot(true)), fehler(409, 'Öffnungszeiten live ist für dieses Projekt schon bestellt oder eingeschaltet.'), fehler(500, 'Interner Serverfehler'))
|
||||
const root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'einschalten'))
|
||||
assert.match(meldung(fx, root), /schon bestellt oder eingeschaltet/)
|
||||
assert.equal(knopf(fx, root, 'einschalten').disabled, false)
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Bestellen
|
||||
test('Bestellen: POST /api/funktionen/zeiten/checkout, Weiterleitung zu Stripe', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo')), { status: 200, body: { url: STRIPE } })
|
||||
const root = await oeffne(fx)
|
||||
const seite = fx.finde(root, '[data-variante="bestellen"]')
|
||||
assert.ok(seite)
|
||||
const alle = fx.alle(root, '*')
|
||||
const stelle = (selektor) => {
|
||||
const el = fx.finde(root, selektor)
|
||||
assert.ok(el, `${selektor} fehlt`)
|
||||
return alle.indexOf(el)
|
||||
}
|
||||
const stellen = ['.tm-bestellen-preis', '.tm-rechtstext', '.tm-bestellen-dokumente', '.tm-bestellen-haken input', '[data-aktion="bestellen"]'].map(stelle)
|
||||
assert.deepEqual(stellen, [...stellen].sort((a, b) => a - b), 'Preis, Belehrung, Unterlagen, Haken, Knopf in dieser Reihenfolge')
|
||||
assert.equal(new Set(stellen).size, stellen.length)
|
||||
|
||||
// Ruling R32: genau der Text des Nachweises, unverändert, in einem scrollbaren Bereich, nie eingeklappt
|
||||
const bereich = fx.finde(root, '.tm-rechtstext')
|
||||
assert.equal(bereich.textContent, BELEHRUNG)
|
||||
assert.match(bereich.textContent, /Muster-Widerrufsformular/)
|
||||
assert.equal(bereich.getAttribute('tabindex'), '0')
|
||||
assert.equal(fx.alle(root, 'details').length, 0)
|
||||
assert.equal(fx.alle(root, '[hidden]').length, 0)
|
||||
|
||||
assert.match(fx.text(fx.finde(root, '.tm-bestellen-betrag')), /^30 € einmalig, ohne Umsatzsteuer gemäß § 19 UStG$/)
|
||||
assert.match(fx.text(fx.finde(root, '.tm-bestellen-preis')), /Einrichtung: meist innerhalb weniger Tage/)
|
||||
assert.deepEqual(haken(fx, root).map((feld) => feld.dataset.haken), HAKEN_BEZAHLT, 'die zwei Haken des Registers: Leistungsbeschreibung und Beginn')
|
||||
assert.deepEqual(fx.alle(root, '.tm-haken span').map((el) => fx.text(el)), REGISTER.consent.items.map((item) => item.text), 'die Texte aus dem Register')
|
||||
|
||||
const button = knopf(fx, root, 'bestellen')
|
||||
assert.equal(fx.text(button), KNOPF_BEZAHLT)
|
||||
assert.equal(fx.text(button), 'Zahlungspflichtig bestellen · 30 €')
|
||||
assert.equal(button.disabled, true)
|
||||
await fx.klick(haken(fx, root)[0])
|
||||
assert.equal(button.disabled, true, 'ein Haken von zwei reicht nicht')
|
||||
await fx.klick(haken(fx, root)[1])
|
||||
assert.equal(button.disabled, false)
|
||||
|
||||
await fx.klick(button)
|
||||
assert.deepEqual(posts(fx), [{
|
||||
method: 'POST',
|
||||
url: '/api/funktionen/zeiten/checkout',
|
||||
body: { projectId: 'p1', zustimmung: { version: REGISTER.consent.version, haken: ['leistung', 'beginn'] } },
|
||||
}])
|
||||
assert.equal(fx.window.location.href, STRIPE, 'Weiterleitung zu Stripe')
|
||||
assert.equal(button.disabled, true, 'die Seite wird verlassen, der Knopf bleibt gesperrt')
|
||||
assert.equal(fx.fetchLog.length, 2, 'nach der Weiterleitung keine weitere Abfrage')
|
||||
}))
|
||||
|
||||
test('Bestellen: ohne Abo, bei vorgemerkter Kündigung und ohne Grund erscheint die Bestellseite; mit Preis aus dem Angebot', mitDom({}, async (fx) => {
|
||||
for (const grund of ['kein_abo', 'gekuendigt', null]) {
|
||||
fx.antworten.push(liste([projekt()], angebot(false, grund)))
|
||||
const root = await oeffne(fx)
|
||||
assert.ok(fx.finde(root, '[data-variante="bestellen"]'), String(grund))
|
||||
}
|
||||
}))
|
||||
|
||||
test('Bestellen: eine Adresse, die nicht https ist, wird nie angesteuert', mitDom({}, async (fx) => {
|
||||
for (const url of ['javascript:alert(1)', 'http://stripe.example/x', '/rechtliches/x.html', '//evil.example/x', '', undefined, 5]) {
|
||||
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo')), { status: 200, body: { url } })
|
||||
fx.window.location.href = 'http://localhost/'
|
||||
const root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'bestellen'))
|
||||
assert.equal(fx.window.location.href, 'http://localhost/', `Weiterleitung zu ${String(url)}`)
|
||||
assert.match(meldung(fx, root), /Zahlungsseite konnte nicht geöffnet werden/)
|
||||
assert.equal(knopf(fx, root, 'bestellen').disabled, false, 'der Kunde kann es noch einmal versuchen')
|
||||
}
|
||||
}))
|
||||
|
||||
test('Bestellen: ohne Belehrung oder ohne Zustimmung wird nie bestellt (R32), das Einschalten braucht beides nicht', mitDom({}, async (fx) => {
|
||||
for (const belehrung of [null, '', ' \n', undefined]) {
|
||||
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo', { belehrung })))
|
||||
const root = await oeffne(fx)
|
||||
assert.equal(knopf(fx, root, 'bestellen'), null, `Belehrung ${JSON.stringify(belehrung)}: kein Bestellknopf`)
|
||||
assert.equal(haken(fx, root).length, 0)
|
||||
assert.match(fx.text(root), /konnte gerade nicht geladen werden/)
|
||||
}
|
||||
// Zustimmung des Registers fehlt oder ist beschädigt
|
||||
for (const features of [{}, { zeiten: { consent: null } }, { zeiten: { consent: { version: 'v', items: [] } } }]) {
|
||||
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo'), { features }))
|
||||
const root = await oeffne(fx)
|
||||
assert.equal(knopf(fx, root, 'bestellen'), null, JSON.stringify(features))
|
||||
}
|
||||
// Einschalten ohne Belehrung: geht (kein Widerrufsverlangen)
|
||||
fx.antworten.push(liste([projekt()], angebot(true, null, { belehrung: null })))
|
||||
const root = await oeffne(fx)
|
||||
assert.ok(knopf(fx, root, 'einschalten'))
|
||||
}))
|
||||
|
||||
test('409 inklusive beim Bestellen ⇒ wechselt zur Einschalt-Ansicht', mitDom({}, async (fx) => {
|
||||
const text = 'Im Wartungs-Abo bereits enthalten – einfach einschalten.'
|
||||
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo')), fehler(409, text, 'inklusive'), liste([projekt()], angebot(true)))
|
||||
const root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'bestellen'))
|
||||
assert.deepEqual(fx.fetchLog.map((a) => `${a.method} ${a.url}`), ['GET /api/funktionen', 'POST /api/funktionen/zeiten/checkout', 'GET /api/funktionen'])
|
||||
assert.equal(fx.finde(root, '[data-variante="bestellen"]'), null)
|
||||
assert.ok(fx.finde(root, '[data-variante="einschalten"]'), 'die Einschaltseite')
|
||||
assert.equal(fx.text(knopf(fx, root, 'einschalten')), 'Einschalten')
|
||||
assert.equal(knopf(fx, root, 'einschalten').disabled, true, 'die Zustimmung des Gratisweges ist eine andere, der Haken muss neu gesetzt werden')
|
||||
assert.equal(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), text)
|
||||
assert.equal(fx.finde(root, '.tm-rechtstext'), null)
|
||||
}))
|
||||
|
||||
test('409 fassung_veraltet: die Seite zeigt nach dem Neuladen die neue Fassung mit leeren Haken', mitDom({}, async (fx) => {
|
||||
const neu = oeffentlicheAnsicht('zeiten')
|
||||
neu.consent = { ...neu.consent, version: '2099-01-01', items: neu.consent.items.map((item, i) => (i === 0 ? { ...item, text: 'Neuer Wortlaut des ersten Hakens.' } : item)) }
|
||||
fx.antworten.push(
|
||||
liste([projekt()], angebot(false, 'kein_abo')),
|
||||
fehler(409, 'Die Erklärungen wurden inzwischen geändert. Bitte lies sie noch einmal und stimme neu zu.', 'fassung_veraltet'),
|
||||
liste([projekt()], angebot(false, 'kein_abo'), { features: { zeiten: neu } }),
|
||||
{ status: 200, body: { url: STRIPE } })
|
||||
const root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'bestellen'))
|
||||
assert.equal(fx.text(fx.alle(root, '.tm-haken span')[0]), 'Neuer Wortlaut des ersten Hakens.')
|
||||
assert.equal(haken(fx, root).every((feld) => !feld.checked), true)
|
||||
assert.match(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), /inzwischen geändert/)
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'bestellen'))
|
||||
assert.equal(posts(fx).at(-1).body.zustimmung.version, '2099-01-01', 'gesendet wird die Fassung, die die Seite zeigt')
|
||||
}))
|
||||
|
||||
test('Abo noch nicht aktiv (R20): Hinweis „sobald dein Abo aktiv ist“, kein Knopf, kein Haken; Abo nicht lesbar: Hinweis mit „Noch einmal versuchen“', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(liste([projekt()], angebot(false, 'nicht_aktiv')))
|
||||
let root = await oeffne(fx)
|
||||
assert.match(fx.text(root), /einschaltbar, sobald dein Abo aktiv ist/)
|
||||
assert.equal(fx.alle(root, 'button').length, 0)
|
||||
assert.equal(haken(fx, root).length, 0)
|
||||
assert.doesNotMatch(fx.text(root), /30 €/)
|
||||
|
||||
fx.antworten.push(liste([projekt()], angebot(null, null)), liste([projekt()], angebot(true)))
|
||||
root = await oeffne(fx)
|
||||
assert.match(fx.text(root), /Gerade nicht prüfbar – bitte später erneut/)
|
||||
assert.equal(haken(fx, root).length, 0)
|
||||
const nochmal = fx.alle(root, 'button').find((b) => fx.text(b) === 'Noch einmal versuchen')
|
||||
assert.ok(nochmal)
|
||||
await fx.klick(nochmal)
|
||||
assert.ok(knopf(fx, root, 'einschalten'), 'nach dem erneuten Versuch der Einschaltweg')
|
||||
}))
|
||||
|
||||
test('Kundenansicht: Einschalten/Bestellen gesperrt mit Hinweis', mitDom({}, async (fx) => {
|
||||
fx.window.KbView = { id: () => 'ansicht-1' }
|
||||
fx.antworten.push(liste([projekt()], angebot(true)))
|
||||
let root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
assert.equal(knopf(fx, root, 'einschalten').disabled, true, 'auch mit Haken')
|
||||
assert.equal(fx.text(fx.finde(root, '[data-hinweis="bestellen"]')), 'In der Kundenansicht kann nicht eingeschaltet werden.')
|
||||
await fx.klick(knopf(fx, root, 'einschalten'))
|
||||
|
||||
fx.antworten.push(liste([projekt()], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
assert.equal(knopf(fx, root, 'bestellen').disabled, true)
|
||||
assert.equal(fx.text(fx.finde(root, '[data-hinweis="bestellen"]')), 'In der Kundenansicht kann nicht bestellt werden.')
|
||||
await fx.klick(knopf(fx, root, 'bestellen'))
|
||||
|
||||
// Behalten und Zahlung fortsetzen in der Karte
|
||||
fx.antworten.push(liste([projekt('p1', 'active', { aboEndeAt: '2026-10-01T10:00:00.000Z' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx)
|
||||
assert.equal(knopf(fx, root, 'behalten').disabled, true)
|
||||
assert.match(fx.text(root), /In der Kundenansicht kann nicht bestellt werden/)
|
||||
fx.antworten.push(liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx)
|
||||
assert.equal(knopf(fx, root, 'fortsetzen').disabled, true)
|
||||
assert.equal(posts(fx).length, 0, 'in der Kundenansicht geht keine Anfrage hinaus')
|
||||
|
||||
// ohne Kundenansicht ist nichts gesperrt
|
||||
fx.window.KbView = { id: () => '' }
|
||||
fx.antworten.push(liste([projekt()], angebot(true)))
|
||||
root = await oeffne(fx)
|
||||
await alleAnhaken(fx, root)
|
||||
assert.equal(knopf(fx, root, 'einschalten').disabled, false)
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Rückkehr
|
||||
test('Rückkehr #zeiten?bestellt=1 und ?behalten=1 zeigt Danke-Karte', mitDom({}, async (fx) => {
|
||||
// Bestellung: der Webhook hat die Zahlung noch nicht gebucht (ordered)
|
||||
fx.antworten.push(liste([projekt('p1'), projekt('p2', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')))
|
||||
let root = await oeffne(fx, { projectId: '', hash: '#zeiten?bestellt=1&projekt=p2' })
|
||||
let karte = fx.finde(root, '.zt-status')
|
||||
assert.equal(karte.getAttribute('data-state'), 'ordered')
|
||||
assert.equal(fx.text(fx.finde(karte, '[data-danke="bestellt"]')), 'Danke! Sobald die Zahlung da ist, legen wir los.')
|
||||
assert.equal(fx.window.location.hash, '#zeiten', 'bestellt=1 und projekt stehen nicht mehr in der Adresse')
|
||||
assert.equal(fx.finde(root, '#zeiten-project').value, 'p2', 'das Projekt der Zahlung ist gewählt')
|
||||
// nächster Besuch ohne Flag: kein Dank mehr
|
||||
fx.antworten.push(liste([projekt('p1'), projekt('p2', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx, { projectId: '' })
|
||||
assert.equal(fx.finde(root, '[data-danke]'), null)
|
||||
|
||||
// Bestellung schon gebucht (setup)
|
||||
fx.antworten.push(liste([projekt('p1', 'setup', { unlockedBy: 'kauf', einbau: 'angestossen' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx, { hash: '#zeiten?bestellt=1&projekt=p1' })
|
||||
assert.equal(fx.text(fx.finde(root, '[data-danke="bestellt"]')), 'Danke! Deine Zahlung ist bei uns angekommen.')
|
||||
|
||||
// Behalten: die Zahlung ist unterwegs (aboEndeAt noch gesetzt) bzw. gebucht (aboEndeAt leer, unlockedBy kauf)
|
||||
fx.antworten.push(liste([projekt('p1', 'active', { aboEndeAt: '2026-10-01T10:00:00.000Z' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx, { hash: '#zeiten?behalten=1&projekt=p1' })
|
||||
assert.match(fx.text(fx.finde(root, '[data-danke="behalten"]')), /^Danke! Sobald die Zahlung da ist, behältst du Öffnungszeiten live\./)
|
||||
fx.antworten.push(liste([projekt('p1', 'active', { unlockedBy: 'kauf' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx, { hash: '#zeiten?behalten=1&projekt=p1' })
|
||||
assert.equal(fx.text(fx.finde(root, '[data-danke="behalten"]')), 'Danke! Du behältst Öffnungszeiten live.')
|
||||
assert.equal(fx.window.location.hash, '#zeiten')
|
||||
|
||||
// Abbruch in Stripe (#zeiten?projekt=p2): das Projekt ist gewählt, kein Dank
|
||||
fx.antworten.push(liste([projekt('p1'), projekt('p2')], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx, { projectId: '', hash: '#zeiten?projekt=p2' })
|
||||
assert.equal(fx.finde(root, '[data-danke]'), null)
|
||||
assert.equal(fx.finde(root, '#zeiten-project').value, 'p2')
|
||||
// ein Projekt, das es nicht (mehr) gibt: kein Dank für ein anderes
|
||||
fx.antworten.push(liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx, { projectId: '', hash: '#zeiten?bestellt=1&projekt=unbekannt' })
|
||||
assert.equal(fx.finde(root, '[data-danke]'), null)
|
||||
// andere Hashes werden nicht angefasst
|
||||
fx.antworten.push(liste([projekt('p1')], angebot(true)))
|
||||
await oeffne(fx, { hash: '#zeitenplan' })
|
||||
assert.equal(fx.window.location.hash, '#zeitenplan')
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Behalten
|
||||
const ABO_ENDE = { aboEndeAt: '2026-10-01T10:00:00.000Z', einbau: 'angestossen' }
|
||||
|
||||
test('Behalten: Hinweis mit Datum in der Karte, Behalten-Seite mit Belehrung, POST /api/funktionen/zeiten/behalten, „Zurück“ zeigt die Karte wieder', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')), { status: 200, body: { url: STRIPE } })
|
||||
const root = await oeffne(fx)
|
||||
const karte = fx.finde(root, '.zt-status')
|
||||
assert.equal(karte.getAttribute('data-state'), 'active')
|
||||
assert.match(fx.text(fx.finde(karte, '[data-hinweis="behalten"]')), /Dein Wartungs-Abo ist beendet – behalte die Funktion bis 31\.10\.2026 für einmalig 30 €\./)
|
||||
const editor = fx.finde(root, '.zt-editor')
|
||||
assert.ok(editor, 'der Platz des Editors steht unter der Karte')
|
||||
|
||||
await fx.klick(knopf(fx, root, 'behalten'))
|
||||
const seite = fx.finde(root, '[data-variante="behalten"]')
|
||||
assert.ok(seite)
|
||||
assert.equal(fx.finde(root, '.zt-status'), null, 'die Seite ersetzt die Karte')
|
||||
assert.equal(editor.hidden, true, 'der Editor bleibt, nur versteckt (nichts geht verloren)')
|
||||
assert.equal(fx.finde(root, '.tm-rechtstext').textContent, BELEHRUNG)
|
||||
assert.match(fx.text(seite), /ohne neuen Einbau/)
|
||||
assert.equal(fx.alle(root, '.tm-bestellen-preis h4').map((el) => fx.text(el))[0], 'Preis')
|
||||
assert.doesNotMatch(fx.text(fx.finde(root, '.tm-bestellen-preis')), /Einrichtung:/, 'beim Behalten gibt es keinen neuen Einbau')
|
||||
assert.equal(fx.text(knopf(fx, root, 'behalten')), KNOPF_BEZAHLT)
|
||||
|
||||
// zurück: die Karte ist wieder da, der Editor sichtbar
|
||||
await fx.klick(knopf(fx, root, 'zurueck'))
|
||||
assert.ok(fx.finde(root, '.zt-status'))
|
||||
assert.equal(editor.hidden, false)
|
||||
assert.equal(fx.finde(root, '.zt-editor'), editor, 'derselbe Editorplatz, nicht neu gebaut')
|
||||
|
||||
await fx.klick(knopf(fx, root, 'behalten'))
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'behalten'))
|
||||
assert.deepEqual(posts(fx), [{
|
||||
method: 'POST',
|
||||
url: '/api/funktionen/zeiten/behalten',
|
||||
body: { projectId: 'p1', zustimmung: { version: REGISTER.consent.version, haken: ['leistung', 'beginn'] } },
|
||||
}])
|
||||
assert.equal(fx.window.location.href, STRIPE)
|
||||
}))
|
||||
|
||||
test('Behalten: Frist ist der Berliner Tag des Abo-Endes plus 30 Tage; läuft das Abo wieder, gibt es keinen Behalten-Knopf', mitDom({}, async (fx) => {
|
||||
assert.equal(behaltenFrist('2026-10-01T10:00:00.000Z'), '31.10.2026')
|
||||
assert.equal(behaltenFrist('2026-10-01T23:30:00.000Z'), '01.11.2026', 'in Berlin schon der 2. Oktober')
|
||||
assert.equal(behaltenFrist('2026-10-01T10:00:00.000+00:00'), '31.10.2026')
|
||||
assert.equal(behaltenFrist('2026-03-01T10:00:00.000Z'), '31.03.2026')
|
||||
assert.equal(behaltenFrist('kein-datum'), '')
|
||||
assert.equal(behaltenFrist(null), '')
|
||||
assert.equal(tagText('2026-11-04'), '04.11.2026')
|
||||
assert.equal(tagText('4.11.2026'), '')
|
||||
|
||||
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(true)))
|
||||
let root = await oeffne(fx)
|
||||
assert.equal(knopf(fx, root, 'behalten'), null, 'das Abo läuft wieder: nichts zu bezahlen')
|
||||
assert.doesNotMatch(fx.text(root), /behalte die Funktion/)
|
||||
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(null, null)))
|
||||
root = await oeffne(fx)
|
||||
assert.equal(knopf(fx, root, 'behalten'), null, 'Abo nicht lesbar: kein Bezahlknopf auf Verdacht')
|
||||
}))
|
||||
|
||||
test('Behalten aus der Kachel (dataset.wunschWeg): die Seite öffnet direkt, das Projekt der Kachel ist gewählt; ein Weg ohne Anlass wird ignoriert', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(liste([projekt('p1'), projekt('p2', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
const root = await oeffne(fx, { projectId: '', dataset: { wunschProjekt: 'p2', wunschWeg: 'behalten' } })
|
||||
assert.ok(fx.finde(root, '[data-variante="behalten"]'))
|
||||
assert.equal(fx.finde(root, '#zeiten-project').value, 'p2')
|
||||
assert.equal(root.dataset.wunschProjekt, undefined, 'die Wünsche werden einmal gelesen')
|
||||
assert.equal(root.dataset.wunschWeg, undefined)
|
||||
|
||||
// kein Abo-Ende: kein Behalten, sondern die Karte
|
||||
fx.antworten.push(liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
const ohne = await oeffne(fx, { dataset: { wunschWeg: 'behalten' } })
|
||||
assert.equal(fx.finde(ohne, '[data-variante]'), null)
|
||||
assert.ok(fx.finde(ohne, '.zt-status'))
|
||||
}))
|
||||
|
||||
test('Behalten nach dem Abschalten (abo_ende) mit Daten: „Für 30 € wieder einschalten“; ohne Daten, nach der Löschfrist oder durch das Team nicht', mitDom({}, async (fx) => {
|
||||
const abgeschaltet = (feature, extra) => projekt('p1', 'disabled', { disabledReason: 'abo_ende', disabledAt: '2026-10-04T10:00:00.000Z', ...feature }, extra)
|
||||
fx.antworten.push(liste([abgeschaltet({ loeschungAm: '2999-01-01' }, { zeitenLeer: false })], angebot(false, 'kein_abo')), { status: 200, body: { url: STRIPE } })
|
||||
let root = await oeffne(fx)
|
||||
const karte = fx.finde(root, '.zt-status')
|
||||
assert.equal(karte.getAttribute('data-state'), 'disabled')
|
||||
assert.match(fx.text(karte), /Öffnungszeiten live ist abgeschaltet, weil dein Wartungs-Abo beendet ist/)
|
||||
assert.match(fx.text(fx.finde(karte, '[data-hinweis="wieder-einschalten"]')), /Deine Eingaben bleiben bis 01\.01\.2999 gespeichert\. Für einmalig 30 € schaltest du die Funktion wieder ein\./)
|
||||
assert.equal(fx.text(knopf(fx, root, 'behalten')), 'Für 30 € wieder einschalten')
|
||||
assert.ok(fx.finde(root, '.zt-editor'), 'die Eingaben bleiben lesbar (Platz des Editors)')
|
||||
await fx.klick(knopf(fx, root, 'behalten'))
|
||||
assert.match(fx.text(fx.finde(root, '.zt-bestellen > p')), /abgeschaltet, deine Eingaben sind noch gespeichert\. Für einmalig 30 € schaltest du die Funktion wieder ein\./)
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'behalten'))
|
||||
assert.equal(posts(fx)[0].url, '/api/funktionen/zeiten/behalten')
|
||||
|
||||
for (const [name, p] of [
|
||||
['keine Daten', abgeschaltet({ loeschungAm: '2999-01-01' }, { zeitenLeer: true })],
|
||||
['Löschfrist vorbei', abgeschaltet({ loeschungAm: '2000-01-01' }, { zeitenLeer: false })],
|
||||
['vom Team', projekt('p1', 'disabled', { disabledReason: 'team' }, { zeitenLeer: false })],
|
||||
]) {
|
||||
fx.antworten.push(liste([p], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx)
|
||||
assert.equal(knopf(fx, root, 'behalten'), null, name)
|
||||
}
|
||||
fx.antworten.push(liste([projekt('p1', 'disabled', { disabledReason: 'team' }, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
root = await oeffne(fx)
|
||||
assert.match(fx.text(root), /Öffnungszeiten live wurde für diese Website abgeschaltet\. Schreib uns im Chat, wenn du die Funktion wieder möchtest\./)
|
||||
// das Abo läuft wieder: der Abgleich schaltet selbst ein
|
||||
fx.antworten.push(liste([abgeschaltet({ loeschungAm: '2999-01-01' }, { zeitenLeer: false })], angebot(true)))
|
||||
root = await oeffne(fx)
|
||||
assert.equal(knopf(fx, root, 'behalten'), null)
|
||||
assert.match(fx.text(root), /Dein Wartungs-Abo läuft wieder/)
|
||||
}))
|
||||
|
||||
test('Behalten: 409 „Abo läuft wieder“ zeichnet die Ansicht nach der neuen Liste (kein Behalten mehr) mit der Meldung des Servers', mitDom({}, async (fx) => {
|
||||
const text = 'Dein Wartungs-Abo läuft wieder – Öffnungszeiten live ist darin enthalten.'
|
||||
fx.antworten.push(
|
||||
liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')),
|
||||
fehler(409, text),
|
||||
liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(true)))
|
||||
const root = await oeffne(fx, { dataset: { wunschWeg: 'behalten' } })
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'behalten'))
|
||||
assert.equal(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), text)
|
||||
assert.equal(fx.finde(root, '[data-variante]'), null)
|
||||
assert.equal(knopf(fx, root, 'behalten'), null)
|
||||
assert.ok(fx.finde(root, '.zt-status'))
|
||||
}))
|
||||
|
||||
test('renderZeitenBestellen im Modus behalten: Abo läuft wieder ⇒ Hinweis statt Seite; Abo nicht lesbar ⇒ „Gerade nicht prüfbar“', mitDom({}, (fx) => {
|
||||
const ctx = { projectId: 'p1', features: { zeiten: oeffentlicheAnsicht('zeiten') }, ustHinweis: config.ustHinweis, kundenansicht: false }
|
||||
const wurzel = h('div')
|
||||
renderZeitenBestellen(wurzel, { ctx, angebot: angebot(true), projekt: projekt('p1', 'active', ABO_ENDE), modus: 'behalten' })
|
||||
assert.match(fx.text(wurzel), /Dein Wartungs-Abo läuft wieder/)
|
||||
assert.equal(fx.alle(wurzel, 'input').length, 0)
|
||||
renderZeitenBestellen(wurzel, { ctx, angebot: angebot(null), projekt: projekt('p1', 'active', ABO_ENDE), modus: 'behalten' })
|
||||
assert.match(fx.text(wurzel), /Gerade nicht prüfbar – bitte später erneut/)
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Statuskarte
|
||||
test('Statuskarte: Zahlung offen, In Einrichtung (je nach einbau), Aktiv, Abgeschaltet', mitDom({}, (fx) => {
|
||||
const karte = (state, feature = {}, extra = {}, an = angebot(true)) => zeitenStatusKarte({ projekt: projekt('p1', state, feature, extra), feature: zeitenFeature(state, feature), angebot: an, kundenansicht: false })
|
||||
const ordered = karte('ordered', { unlockedBy: null })
|
||||
assert.equal(ordered.getAttribute('data-state'), 'ordered')
|
||||
assert.match(fx.text(ordered), /Zahlung offen/)
|
||||
assert.match(fx.text(ordered), /Zahlung wird bestätigt …/)
|
||||
assert.match(fx.text(ordered), /SEPA/)
|
||||
assert.ok(fx.finde(ordered, '[data-aktion="fortsetzen"]'))
|
||||
|
||||
const angestossen = karte('setup', { einbau: 'angestossen' }, { zeitenLeer: false })
|
||||
assert.match(fx.text(angestossen), /In Einrichtung/)
|
||||
assert.match(fx.text(angestossen), /Hinweis- und Kontakt-Leiste erscheinen nach dem nächsten Veröffentlichen deiner Website \(meist in wenigen Minuten\)\./)
|
||||
assert.equal(fx.finde(angestossen, '[data-hinweis="zeiten-eintragen"]'), null)
|
||||
assert.equal(fx.finde(angestossen, '[data-aktion="fortsetzen"]'), null)
|
||||
|
||||
const manuell = karte('setup', { einbau: 'manuell', ticketWoid: '4711', orderedAt: '2026-10-04T10:00:00.000Z' }, { zeitenLeer: true })
|
||||
assert.match(fx.text(manuell), /Unser Team baut die Funktion ein\./)
|
||||
assert.doesNotMatch(fx.text(manuell), /nach dem nächsten Veröffentlichen/)
|
||||
assert.match(fx.text(manuell), /Ticketnummer#4711/)
|
||||
assert.match(fx.text(manuell), /Bestellt am04\.10\.2026/)
|
||||
assert.match(fx.text(fx.finde(manuell, '[data-hinweis="zeiten-eintragen"]')), /Trag jetzt deine Zeiten ein – erst dann kann das Team die Tabelle einbauen\./)
|
||||
|
||||
const rechnung = zeitenStatusKarte({ projekt: projekt('p1', 'setup'), feature: zeitenFeature('setup', { invoiceUrl: 'https://invoice.stripe.test/i/1' }), angebot: angebot(false, 'kein_abo') })
|
||||
assert.equal(fx.finde(rechnung, 'a').getAttribute('href'), 'https://invoice.stripe.test/i/1')
|
||||
const boese = zeitenStatusKarte({ projekt: projekt('p1', 'setup'), feature: zeitenFeature('setup', { invoiceUrl: 'javascript:alert(1)' }), angebot: angebot(false, 'kein_abo') })
|
||||
assert.equal(fx.finde(boese, 'a'), null, 'unsichere Adressen werden nie Links')
|
||||
|
||||
const aktiv = karte('active', {}, { zeitenLeer: false })
|
||||
assert.match(fx.text(aktiv), /Aktiv/)
|
||||
assert.match(fx.text(aktiv), /Öffnungszeiten live ist aktiv/)
|
||||
assert.equal(fx.finde(aktiv, '[data-hinweis="zeiten-eintragen"]'), null)
|
||||
assert.match(fx.text(fx.finde(karte('active', {}, { zeitenLeer: true }), '[data-hinweis="zeiten-eintragen"]')), /Trag jetzt deine Zeiten ein\./)
|
||||
|
||||
const aus = karte('disabled', { disabledReason: 'team' }, { zeitenLeer: false })
|
||||
assert.match(fx.text(aus), /Abgeschaltet/)
|
||||
assert.match(fx.text(aus), /Schreib uns im Chat/)
|
||||
assert.ok(fx.finde(aus, '[data-view="chat"]'))
|
||||
}))
|
||||
|
||||
test('Statuskarte: Zahlung fortsetzen führt zur offenen Zahlungsseite; ohne offene Sitzung zeigt die Ansicht wieder die Bestellseite', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')), { status: 200, body: { url: STRIPE } })
|
||||
let root = await oeffne(fx)
|
||||
await fx.klick(knopf(fx, root, 'fortsetzen'))
|
||||
assert.deepEqual(posts(fx), [{ method: 'POST', url: '/api/funktionen/zeiten/checkout', body: { projectId: 'p1', fortsetzen: true } }])
|
||||
assert.equal(fx.window.location.href, STRIPE)
|
||||
|
||||
// keine offene Zahlungsseite mehr (400 zustimmung_fehlt): die Bestellseite mit der Meldung des Servers; Bestellen legt eine neue Sitzung an
|
||||
fx.window.location.href = 'http://localhost/'
|
||||
fx.antworten.push(
|
||||
liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')),
|
||||
{ status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } },
|
||||
{ status: 200, body: { url: STRIPE } })
|
||||
root = await oeffne(fx)
|
||||
await fx.klick(knopf(fx, root, 'fortsetzen'))
|
||||
assert.ok(fx.finde(root, '[data-variante="bestellen"]'))
|
||||
assert.match(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), /stimme der Erklärung zu/)
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'bestellen'))
|
||||
assert.equal(fx.window.location.href, STRIPE)
|
||||
|
||||
// andere Fehler stehen am Knopf, der Knopf ist wieder frei
|
||||
fx.antworten.push(liste([projekt('p1', 'ordered', { unlockedBy: null })], angebot(false, 'kein_abo')), fehler(500, 'Interner Serverfehler'))
|
||||
root = await oeffne(fx)
|
||||
await fx.klick(knopf(fx, root, 'fortsetzen'))
|
||||
assert.equal(fx.text(fx.finde(root, '[data-meldung="karte"]')), 'Interner Serverfehler')
|
||||
assert.equal(knopf(fx, root, 'fortsetzen').disabled, false)
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Ansicht
|
||||
test('Projektauswahl: bei mehreren Websites eine Auswahl; der Wunsch der Kachel gilt einmal; ein Wechsel zeichnet das andere Projekt', mitDom({}, async (fx) => {
|
||||
const projekte = [projekt('p1', 'active', {}, { zeitenLeer: false }), projekt('p2'), projekt('p3', 'setup')]
|
||||
fx.antworten.push(liste(projekte, angebot(true)))
|
||||
let root = await oeffne(fx, { projectId: '', dataset: { wunschProjekt: 'p2' } })
|
||||
const auswahl = fx.finde(root, '#zeiten-project')
|
||||
assert.deepEqual(fx.alle(auswahl, 'option').map((o) => o.getAttribute('value')), ['p1', 'p2', 'p3'])
|
||||
assert.equal(auswahl.value, 'p2')
|
||||
assert.ok(fx.finde(root, '[data-variante="einschalten"]'), 'p2 ist aus')
|
||||
await fx.setze(auswahl, 'p3')
|
||||
assert.equal(fx.finde(root, '[data-variante]'), null)
|
||||
assert.equal(fx.finde(root, '.zt-status').getAttribute('data-state'), 'setup')
|
||||
await fx.setze(auswahl, 'p1')
|
||||
assert.equal(fx.finde(root, '.zt-status').getAttribute('data-state'), 'active')
|
||||
|
||||
// ohne Wunsch: die gemerkte Auswahl, bei neuer Sitzung aktiv vor Einrichtung vor aus
|
||||
fx.antworten.push(liste([projekt('p1'), projekt('p2', 'setup'), projekt('p3', 'active', {}, { zeitenLeer: false })], angebot(true)))
|
||||
root = await oeffne(fx, { projectId: '', dataset: {} })
|
||||
assert.equal(fx.finde(root, '#zeiten-project').value, 'p1', 'die Auswahl von eben bleibt')
|
||||
fx.antworten.push(liste([projekt('p9'), projekt('p8', 'setup'), projekt('p7', 'active', {}, { zeitenLeer: false })], angebot(true)))
|
||||
root = await oeffne(fx, { projectId: '' })
|
||||
assert.equal(fx.finde(root, '#zeiten-project').value, 'p7', 'ein unbekanntes Projekt: das aktive')
|
||||
|
||||
// genau eine Website: keine Auswahl
|
||||
fx.antworten.push(liste([projekt('p1')], angebot(true)))
|
||||
root = await oeffne(fx)
|
||||
assert.equal(fx.finde(root, '#zeiten-project'), null)
|
||||
}))
|
||||
|
||||
test('Ansicht: nicht verfügbar und keine Website', mitDom({}, async (fx) => {
|
||||
fx.antworten.push(liste([projekt()], { verfuegbar: false }))
|
||||
let root = await oeffne(fx)
|
||||
assert.match(fx.text(root), /Öffnungszeiten live ist noch nicht verfügbar/)
|
||||
assert.equal(fx.alle(root, 'input,button').length, 0)
|
||||
fx.antworten.push(liste([projekt()], undefined))
|
||||
root = await oeffne(fx)
|
||||
assert.match(fx.text(root), /noch nicht verfügbar/)
|
||||
fx.antworten.push(liste([], angebot(true)))
|
||||
root = await oeffne(fx)
|
||||
assert.match(fx.text(root), /Noch keine Website-Projekte zugewiesen/)
|
||||
// ein Fehler der Liste kommt bei app.js an (zeigt ihn in der Ansicht)
|
||||
fx.antworten.push(fehler(500, 'Die Funktionen konnten gerade nicht geladen werden. Bitte lade die Seite gleich noch einmal.'))
|
||||
await assert.rejects(oeffne(fx), /konnten gerade nicht geladen werden/)
|
||||
}))
|
||||
|
||||
test('Ansicht: der Editorplatz steht nur in setup, active und (mit Daten) disabled; nie in off und ordered', mitDom({}, async (fx) => {
|
||||
const platz = async (p) => {
|
||||
fx.antworten.push(liste([p], angebot(false, 'kein_abo')))
|
||||
return fx.finde(await oeffne(fx), '.zt-editor')
|
||||
}
|
||||
assert.equal(await platz(projekt('p1', 'off')), null)
|
||||
assert.equal(await platz(projekt('p1', 'ordered', { unlockedBy: null })), null)
|
||||
assert.ok(await platz(projekt('p1', 'setup')))
|
||||
assert.ok(await platz(projekt('p1', 'active')))
|
||||
assert.ok(await platz(projekt('p1', 'disabled', { disabledReason: 'abo_ende' }, { zeitenLeer: false })))
|
||||
assert.equal(await platz(projekt('p1', 'disabled', { disabledReason: 'team' }, { zeitenLeer: true })), null)
|
||||
}))
|
||||
|
||||
test('app.js ruft initZeiten bei jedem Wechsel in die Ansicht: gleiches Projekt und gleicher Stand bauen den Editorplatz nicht neu auf, ein neuer Stand oder die Rückkehr von Stripe schon', mitDom({}, async (fx) => {
|
||||
const aktiv = () => liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(true))
|
||||
fx.antworten.push(aktiv())
|
||||
const root = await oeffne(fx)
|
||||
let editor = fx.finde(root, '.zt-editor')
|
||||
const eingabe = h('input', { type: 'text' })
|
||||
eingabe.value = 'ungespeichert'
|
||||
editor.append(eingabe)
|
||||
|
||||
// zweiter Besuch: nur die Liste wird geholt, nichts neu gezeichnet
|
||||
fx.antworten.push(aktiv())
|
||||
await initZeiten(root, { projectId: 'p1' })
|
||||
assert.equal(fx.finde(root, '.zt-editor'), editor, 'derselbe Editorplatz')
|
||||
assert.equal(editor.firstChild, eingabe)
|
||||
assert.equal(eingabe.value, 'ungespeichert')
|
||||
assert.equal(fx.fetchLog.length, 2)
|
||||
assert.equal(fx.fetchLog.every((a) => a.method === 'GET' && a.url === '/api/funktionen'), true, 'keine Zusatzabfrage')
|
||||
|
||||
// die Kachel schickt dasselbe Projekt: ebenfalls kein Neuaufbau
|
||||
root.dataset.wunschProjekt = 'p1'
|
||||
fx.antworten.push(aktiv())
|
||||
await initZeiten(root)
|
||||
assert.equal(fx.finde(root, '.zt-editor'), editor)
|
||||
|
||||
// die Abo-Lage hat sich geändert (Behalten gibt es nur ohne Abo): die Karte muss stimmen, also neu aufgebaut
|
||||
fx.antworten.push(liste([projekt('p1', 'active', {}, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
await initZeiten(root)
|
||||
assert.notEqual(fx.finde(root, '.zt-editor'), editor)
|
||||
editor = fx.finde(root, '.zt-editor')
|
||||
|
||||
// „Für 30 € behalten“ aus der Kachel: Behalten-Seite; der Stand hat sich geändert (aboEndeAt), also neu aufgebaut
|
||||
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
root.dataset.wunschWeg = 'behalten'
|
||||
await initZeiten(root)
|
||||
assert.notEqual(fx.finde(root, '.zt-editor'), editor)
|
||||
assert.ok(fx.finde(root, '[data-variante="behalten"]'))
|
||||
|
||||
// gleicher neuer Stand noch einmal mit Weg: der Editor bleibt, die Seite öffnet
|
||||
const editor2 = fx.finde(root, '.zt-editor')
|
||||
await fx.klick(knopf(fx, root, 'zurueck'))
|
||||
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
root.dataset.wunschWeg = 'behalten'
|
||||
await initZeiten(root)
|
||||
assert.equal(fx.finde(root, '.zt-editor'), editor2, 'der Editor bleibt')
|
||||
assert.equal(editor2.hidden, true)
|
||||
assert.ok(fx.finde(root, '[data-variante="behalten"]'))
|
||||
|
||||
// Rückkehr von Stripe: immer neu
|
||||
fx.antworten.push(liste([projekt('p1', 'active', ABO_ENDE, { zeitenLeer: false })], angebot(false, 'kein_abo')))
|
||||
fx.window.location.hash = '#zeiten?behalten=1&projekt=p1'
|
||||
await initZeiten(root)
|
||||
assert.notEqual(fx.finde(root, '.zt-editor'), editor2)
|
||||
assert.ok(fx.finde(root, '[data-danke="behalten"]'))
|
||||
}))
|
||||
|
||||
test('Fremdtext (Projektname, Meldung des Servers) kommt nur als Text in die Seite', mitDom({}, async (fx) => {
|
||||
const bose = '<img src=x onerror=alert(1)>'
|
||||
fx.antworten.push(
|
||||
liste([{ ...projekt('p1'), projectName: bose }, projekt('p2')], angebot(true)),
|
||||
fehler(409, bose),
|
||||
liste([{ ...projekt('p1'), projectName: bose }, projekt('p2')], angebot(true)))
|
||||
const root = await oeffne(fx)
|
||||
assert.equal(fx.alle(root, 'img').length, 0)
|
||||
assert.ok(fx.text(fx.finde(root, '#zeiten-project')).includes(bose))
|
||||
await alleAnhaken(fx, root)
|
||||
await fx.klick(knopf(fx, root, 'einschalten'))
|
||||
assert.equal(fx.alle(root, 'img').length, 0)
|
||||
assert.equal(fx.text(fx.finde(root, '[data-meldung="ansicht"]')), bose)
|
||||
}))
|
||||
|
||||
test('veraltete Antwort: ein Wechsel der Website während des Neuladens überschreibt die neuere Ansicht nicht', mitDom({}, async (fx) => {
|
||||
const echtes = globalThis.fetch
|
||||
let freigeben
|
||||
const wartet = new Promise((resolve) => { freigeben = resolve })
|
||||
let listen = 0
|
||||
// die zweite Liste (nach dem 409) kommt erst nach der Freigabe
|
||||
globalThis.fetch = async (url, optionen) => {
|
||||
if (String(url) === '/api/funktionen' && (optionen?.method ?? 'GET') === 'GET' && ++listen === 2) await wartet
|
||||
return echtes(url, optionen)
|
||||
}
|
||||
fx.antworten.push(
|
||||
liste([projekt('p1'), projekt('p2')], angebot(true)),
|
||||
fehler(409, 'Stand geändert'),
|
||||
liste([projekt('p1'), projekt('p2')], angebot(false, 'kein_abo')))
|
||||
const root = await oeffne(fx, { projectId: 'p1' })
|
||||
await alleAnhaken(fx, root)
|
||||
const klick = fx.klick(knopf(fx, root, 'einschalten'))
|
||||
await fx.ruhe()
|
||||
await fx.setze(fx.finde(root, '#zeiten-project'), 'p2') // der Kunde wechselt die Website, während die Liste noch lädt
|
||||
freigeben()
|
||||
await klick
|
||||
assert.ok(fx.finde(root, '[data-variante="einschalten"]'), 'die späte Antwort zeichnet nichts mehr')
|
||||
assert.equal(fx.finde(root, '[data-meldung="ansicht"]'), null)
|
||||
assert.equal(fx.finde(root, '#zeiten-project').value, 'p2')
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------- Quelltext
|
||||
const ohneKommentare = (quelle) => quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '')
|
||||
|
||||
test('Portal: Ansicht #zeiten ist in app.js und dashboard.html verdrahtet', () => {
|
||||
const app = lesen('public/app.js')
|
||||
assert.match(app, /VIEW_HASHES = \{[^}]*termine: 'termine', zeiten: 'zeiten'/)
|
||||
assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', zeiten: 'funktionen'/)
|
||||
assert.match(app, /FEATURE_LABELS = \{[^}]*termine: 'Terminplaner', zeiten: 'Öffnungszeiten'/)
|
||||
assert.match(app, /zeiten: \(\) => import\('\/zeiten\.js'\)\.then\(\(m\) => m\.initZeiten\(document\.getElementById\('zeiten-body'\)\)\)/)
|
||||
const html = lesen('public/dashboard.html')
|
||||
assert.match(html, /<section class="kb-view" id="view-zeiten">[\s\S]*<h1>Öffnungszeiten live<\/h1>[\s\S]*<div id="zeiten-body">/)
|
||||
assert.match(html, /<link rel="stylesheet" href="\/zeiten\.css">/)
|
||||
assert.doesNotMatch(html, /id="view-zeiten"[^>]*aria-live/)
|
||||
assert.doesNotMatch(html.slice(html.indexOf('id="zeiten-body"') - 80, html.indexOf('id="zeiten-body"') + 80), /aria-live/)
|
||||
// die bestehenden Ansichten bleiben, wie sie waren
|
||||
assert.match(app, /termine: \(\) => import\('\/termine\.js'\)\.then\(\(m\) => m\.initTermine\(document\.getElementById\('termine-body'\)\)\)/)
|
||||
})
|
||||
|
||||
test('Quelltext: zeiten.js und zeiten-bestellen.js importieren nur relativ und nur das Nötige; kein HTML aus Daten, kein Speicher, Du-Form', () => {
|
||||
const importe = (datei) => [...ohneKommentare(lesen(`public/${datei}`)).matchAll(/\bfrom\s+'([^']+)'/g)].map((m) => m[1])
|
||||
assert.deepEqual(importe('zeiten.js'), ['./termine-dom.js', './zeiten-bestellen.js'])
|
||||
assert.deepEqual(importe('zeiten-bestellen.js'), ['./funktion-bestellen.js', './termine-dom.js'])
|
||||
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^zeiten.*\.js$/.test(f))
|
||||
for (const datei of ['zeiten.js', 'zeiten-bestellen.js']) {
|
||||
assert.ok(dateien.includes(datei))
|
||||
const quelle = ohneKommentare(lesen(`public/${datei}`))
|
||||
assert.doesNotMatch(quelle, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, `${datei}: Daten nie als HTML`)
|
||||
assert.doesNotMatch(quelle, /\bconfirm\(|\balert\(|\bprompt\(/, `${datei}: Rückfragen stehen in der Seite`)
|
||||
assert.doesNotMatch(quelle, /\bSie\b|\bIhr(e[nmrs]?)?\b/, `${datei}: Portaltexte in der Du-Form`)
|
||||
assert.doesNotMatch(quelle, /localStorage|sessionStorage|document\.cookie/, `${datei}: kein Browserspeicher`)
|
||||
assert.doesNotMatch(quelle, /24 Uhr|\bbrutto\b|inklusive (Mehr|Umsatz)steuer/i, `${datei}: Wortwahl`)
|
||||
assert.doesNotMatch(quelle, /import\s*\(/, `${datei}: kein dynamischer Import`)
|
||||
}
|
||||
// der einzige Link der Bestellseite ist die Leistungsbeschreibung (und die Rechnung in der Karte), jeder geht durch sicherLink bzw. unterlagenLink
|
||||
const bestellen = ohneKommentare(lesen('public/zeiten-bestellen.js'))
|
||||
assert.deepEqual(bestellen.match(/href: [^,}]+/g), ['href: link', 'href: rechnung'])
|
||||
assert.match(bestellen, /const link = unterlagenLink\(/)
|
||||
assert.match(bestellen, /const rechnung = sicherLink\(/)
|
||||
// keine termine*.js und kein funktion-bestellen.js angefasst: sie sind gepinnt
|
||||
assert.doesNotMatch(bestellen, /\btermine-view\b/)
|
||||
})
|
||||
|
||||
test('zeiten.css: die Klassen der Ansicht sind gestaltet, die Bausteine der Bestellseite gibt es in termine.css', () => {
|
||||
const css = lesen('public/zeiten.css')
|
||||
for (const klasse of ['zt-kopf', 'zt-editor', 'zt-bestellen', 'zt-status', 'zt-hinweis']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[>]`), `.${klasse} ist gestaltet`)
|
||||
assert.match(css, /#view-zeiten \.fn-project/)
|
||||
assert.match(css, /prefers-reduced-motion/)
|
||||
assert.match(css, /min-height: 44px/, 'Tippflächen am Handy')
|
||||
const termine = lesen('public/termine.css')
|
||||
for (const klasse of ['tm-bestellen', 'tm-rechtstext', 'tm-statuskarte', 'tm-haken', 'tm-pille', 'tm-box', 'tm-meldung', 'tm-danke', 'tm-fakten', 'tm-info', 'tm-aktionen']) assert.match(termine, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} steht in termine.css`)
|
||||
// jede zt-Klasse, die die Module setzen, ist gestaltet (tm-Klassen sind teils reine Haken der Struktur)
|
||||
const quelle = lesen('public/zeiten.js') + lesen('public/zeiten-bestellen.js')
|
||||
const gesetzt = new Set([...quelle.matchAll(/class: '([^']+)'/g)].flatMap((m) => m[1].split(/\s+/)).filter((k) => /^zt-/.test(k)))
|
||||
assert.ok(gesetzt.size >= 4)
|
||||
for (const klasse of gesetzt) assert.ok(css.includes(`.${klasse}`), `.${klasse} hat keine Gestaltung`)
|
||||
})
|
||||
@@ -526,9 +526,13 @@ test('Angebot: nicht verfügbar ⇒ genau { verfuegbar: false }, das Abo wird ni
|
||||
})
|
||||
|
||||
test('Angebot: inklusive true/false/null je nach Abo-Lage, Preis und Texte aus dem Register', async () => {
|
||||
const erwartet = (inklusive, grund) => ({ verfuegbar: true, inklusive, grund, preis: '30 €', einschalten: FREI() })
|
||||
// Ruling R32: die Belehrung der Bestellseite ist genau der Text, den der Haken belehrung der Zeiten auch in den Nachweis schreibt
|
||||
const { featureHaken } = await import('../server/services/featureRegistry.js')
|
||||
const belehrung = await featureHaken('zeiten').belehrung()
|
||||
assert.match(belehrung, /Muster-Widerrufsformular/)
|
||||
const erwartet = (inklusive, grund) => ({ verfuegbar: true, inklusive, grund, preis: '30 €', einschalten: FREI(), belehrung })
|
||||
assert.deepEqual(await angebot(), erwartet(true, null))
|
||||
assert.deepEqual(Object.keys(await angebot()), ['verfuegbar', 'inklusive', 'grund', 'preis', 'einschalten'])
|
||||
assert.deepEqual(Object.keys(await angebot()), ['verfuegbar', 'inklusive', 'grund', 'preis', 'einschalten', 'belehrung'])
|
||||
|
||||
aw.seed('subscriptions', hostingDoc)
|
||||
assert.deepEqual(await angebot(), erwartet(false, 'kein_abo'), 'nur Hosting')
|
||||
|
||||
Reference in new issue
Block a user