Module: im Abo-Modell Reiter mit Kurzlabels an die Etage melden (C2)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 18:37:12 +00:00
1 parent b91ba1d5cb
commit a50c5ef6c9
6 files changed
+366 -17

No files matched your search

+20 -6
View File
@@ -7,6 +7,7 @@
import { fehlerKarte, h, infoKarte, request } from './shop-dom.js' import { fehlerKarte, h, infoKarte, request } from './shop-dom.js'
import { statusKarte } from './funktion-bestellen.js' import { statusKarte } from './funktion-bestellen.js'
import { anfrageKarte, renderAnfrageFormular } from './buchungen-anfrage.js' import { anfrageKarte, renderAnfrageFormular } from './buchungen-anfrage.js'
import { meldeReiter, reiterEintraege } from './fn-reiter.js'
/** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */ /** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */
export const REITER = [ export const REITER = [
@@ -27,6 +28,10 @@ export function vergissAuswahl() {
let S = null // aktuelle Sitzung; eine alte Antwort darf eine neuere nicht ueberschreiben let S = null // aktuelle Sitzung; eine alte Antwort darf eine neuere nicht ueberschreiben
const textOr = (x) => (typeof x === 'string' && x.trim() ? x : '') const textOr = (x) => (typeof x === 'string' && x.trim() ? x : '')
// Abo-Modell (Antwort von GET /api/funktionen, strikt): die Reiter zeichnet die Etage, die Ansicht meldet sie (fn-reiter.js)
const istAbo = (s) => s.modell === 'abo'
/** Eine Ansicht ohne Reiter meldet die leere Liste, damit die Etage nicht die Reiter der vorigen stehen laesst. */
const keineReiter = (s) => { if (istAbo(s)) meldeReiter(s.root, { eintraege: [] }) }
const zustandVon = (projekt) => projekt?.features?.unterkunft?.state || 'off' const zustandVon = (projekt) => projekt?.features?.unterkunft?.state || 'off'
export function initBuchungen(root, { projectId } = {}) { export function initBuchungen(root, { projectId } = {}) {
@@ -38,7 +43,7 @@ export function initBuchungen(root, { projectId } = {}) {
async function starte(root, projectId) { async function starte(root, projectId) {
if (projectId) auswahl.projectId = projectId if (projectId) auswahl.projectId = projectId
const s = { root, projects: [], projekt: null, status: null, lauf: 0, buehne: null, meldungEl: null } const s = { root, projects: [], modell: undefined, projekt: null, status: null, lauf: 0, buehne: null, meldungEl: null }
S = s S = s
let funktionen let funktionen
try { try {
@@ -48,12 +53,15 @@ async function starte(root, projectId) {
return return
} }
if (S !== s) return if (S !== s) return
s.modell = funktionen?.modell
if (!funktionen?.unterkunft?.verfuegbar) { if (!funktionen?.unterkunft?.verfuegbar) {
keineReiter(s)
root.replaceChildren(infoKarte(NICHT_VERFUEGBAR, 'Sobald er für dich bereit ist, findest du ihn hier.')) root.replaceChildren(infoKarte(NICHT_VERFUEGBAR, 'Sobald er für dich bereit ist, findest du ihn hier.'))
return return
} }
s.projects = Array.isArray(funktionen.projects) ? funktionen.projects : [] s.projects = Array.isArray(funktionen.projects) ? funktionen.projects : []
if (!s.projects.length) { if (!s.projects.length) {
keineReiter(s)
root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.')) root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.'))
return return
} }
@@ -99,6 +107,7 @@ const ladeStatus = (projectId) => request(`/api/unterkunft/status?projectId=${en
async function zeigeProjekt(s) { async function zeigeProjekt(s) {
const lauf = ++s.lauf const lauf = ++s.lauf
const aktuell = () => S === s && s.lauf === lauf const aktuell = () => S === s && s.lauf === lauf
keineReiter(s)
s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let status let status
try { try {
@@ -113,6 +122,7 @@ async function zeigeProjekt(s) {
function zeichneAnsicht(s, { status, aktuell }) { function zeichneAnsicht(s, { status, aktuell }) {
s.status = status s.status = status
keineReiter(s) // die Ansicht mit Reitern (zeigeReiter) meldet ihre Liste gleich danach
const zustand = status?.state || zustandVon(s.projekt) const zustand = status?.state || zustandVon(s.projekt)
if (zustand === 'setup' || zustand === 'active' || zustand === 'disabled') zeigeReiter(s, { status, aktuell, zustand }) if (zustand === 'setup' || zustand === 'active' || zustand === 'disabled') zeigeReiter(s, { status, aktuell, zustand })
else if (zustand === 'ordered') zeigeBestellung(s) else if (zustand === 'ordered') zeigeBestellung(s)
@@ -171,15 +181,16 @@ function zeigeReiter(s, { status, aktuell, zustand }) {
zeigeMeldung: (meldung, art) => { if (aktuell()) zeigeMeldung(s, meldung, art) }, zeigeMeldung: (meldung, art) => { if (aktuell()) zeigeMeldung(s, meldung, art) },
neuLaden, neuLaden,
} }
const abo = istAbo(s) // Abo-Modell: keine eigene Leiste, die Etage zeichnet sie (kn:reiter)
const panel = h('div', { class: 'sh-panel', role: 'tabpanel', id: 'bk-panel', tabindex: '0' }) const panel = h('div', { class: 'sh-panel', role: 'tabpanel', id: 'bk-panel', tabindex: '0' })
let aktiv = null let aktiv = null
let zaehler = 0 let zaehler = 0
const knoepfe = REITER.map((r) => h('button', { const knoepfe = abo ? [] : REITER.map((r) => h('button', {
type: 'button', role: 'tab', class: 'sh-reiter', id: `bk-tab-${r.key}`, 'aria-controls': 'bk-panel', 'data-reiter': r.key, type: 'button', role: 'tab', class: 'sh-reiter', id: `bk-tab-${r.key}`, 'aria-controls': 'bk-panel', 'data-reiter': r.key,
onclick: () => waehleReiter(r.key), onclick: () => waehleReiter(r.key),
}, r.label)) }, r.label))
const leiste = h('div', { class: 'sh-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Buchungskalenders' }, knoepfe) const leiste = abo ? null : h('div', { class: 'sh-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Buchungskalenders' }, knoepfe)
leiste.addEventListener('keydown', (ev) => { leiste?.addEventListener('keydown', (ev) => {
const i = REITER.findIndex((r) => r.key === aktiv) const i = REITER.findIndex((r) => r.key === aktiv)
const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key] const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key]
if (ziel === undefined) return if (ziel === undefined) return
@@ -197,7 +208,10 @@ function zeigeReiter(s, { status, aktuell, zustand }) {
k.setAttribute('aria-selected', String(an)) k.setAttribute('aria-selected', String(an))
k.tabIndex = an ? 0 : -1 k.tabIndex = an ? 0 : -1
} }
panel.setAttribute('aria-labelledby', `bk-tab-${reiter.key}`) if (abo) {
panel.setAttribute('aria-label', reiter.label)
meldeReiter(s.root, { eintraege: reiterEintraege('buchungen', REITER), aktiv: reiter.key, bei: (neu) => waehleReiter(neu) })
} else panel.setAttribute('aria-labelledby', `bk-tab-${reiter.key}`)
zeigeMeldung(s, '') zeigeMeldung(s, '')
panel.replaceChildren() panel.replaceChildren()
reiter.laden() reiter.laden()
@@ -207,6 +221,6 @@ function zeigeReiter(s, { status, aktuell, zustand }) {
const hinweis = zustand === 'setup' ? 'Wir bauen deinen Buchungskalender gerade ein. Du kannst schon Unterkünfte, Preise und Einstellungen vorbereiten.' const hinweis = zustand === 'setup' ? 'Wir bauen deinen Buchungskalender gerade ein. Du kannst schon Unterkünfte, Preise und Einstellungen vorbereiten.'
: zustand === 'disabled' ? 'Dein Buchungskalender ist abgeschaltet. Du kannst bestehende Buchungen noch ansehen und bearbeiten.' : '' : zustand === 'disabled' ? 'Dein Buchungskalender ist abgeschaltet. Du kannst bestehende Buchungen noch ansehen und bearbeiten.' : ''
const hinweisBox = h('div', { class: 'sh-hinweise' }, hinweis ? h('div', { class: `sh-box ${zustand === 'disabled' ? 'sh-box-warn' : 'sh-box-info'}`, role: 'note', 'data-hinweis': zustand }, hinweis) : null) const hinweisBox = h('div', { class: 'sh-hinweise' }, hinweis ? h('div', { class: `sh-box ${zustand === 'disabled' ? 'sh-box-warn' : 'sh-box-info'}`, role: 'note', 'data-hinweis': zustand }, hinweis) : null)
s.buehne.replaceChildren(hinweisBox, leiste, panel) s.buehne.replaceChildren(...[hinweisBox, leiste, panel].filter(Boolean))
waehleReiter(auswahl.reiter) waehleReiter(auswahl.reiter)
} }
+24
View File
@@ -0,0 +1,24 @@
/* Reiter der Funktionsmodule in der neuen Ansicht (Abo-Modell). Im Abo-Modell zeichnen Terminplaner, Webshop, Buchungskalender und
Analyse keine eigene Leiste, sondern melden ihre Reiter an die Etage 2 (public/neu/etage.js): CustomEvent 'kn:reiter' mit
bubbles und detail { eintraege: [{ key, label, kurz? }], aktiv, bei(key) }; eine leere Liste heisst „keine Reiter“.
Dieses Modul hat keine Importe und kennt weder DOM noch die Huelle. */
/** Kurzlabels fuer schmale Zeilen (C2): modul → { reiterKey: kurzes Wort }. Die Analyse braucht keine (Übersicht, Heatmap, Einstellungen). */
export const KURZLABELS = {
termine: { team: 'Team' },
shop: { uebersicht: 'Start', rechtliches: 'Recht' },
buchungen: { unterkuenfte: 'Preise' },
}
/** [{ key, label, … }] des Moduls → [{ key, label, kurz? }]; `kurz` nur mit Eintrag in KURZLABELS, alle anderen Felder fallen weg. */
export function reiterEintraege(modul, reiterListe) {
const kurz = Object.hasOwn(KURZLABELS, modul) ? KURZLABELS[modul] : {}
return reiterListe.map(({ key, label }) => (Object.hasOwn(kurz, key) ? { key, label, kurz: kurz[key] } : { key, label }))
}
/** Sendet 'kn:reiter' aus el. Ohne CustomEvent oder ohne el.dispatchEvent: false, sonst true. */
export function meldeReiter(el, { eintraege, aktiv = '', bei = () => {} } = {}) {
if (typeof CustomEvent !== 'function' || typeof el?.dispatchEvent !== 'function') return false
el.dispatchEvent(new CustomEvent('kn:reiter', { bubbles: true, detail: { eintraege, aktiv, bei } }))
return true
}
+18 -5
View File
@@ -16,6 +16,7 @@
import { fehlerKarte, h, infoKarte, request } from './shop-dom.js' import { fehlerKarte, h, infoKarte, request } from './shop-dom.js'
import { anfrageKarte, anfrageZeilen, renderAnfrageFormular } from './shop-anfrage.js' import { anfrageKarte, anfrageZeilen, renderAnfrageFormular } from './shop-anfrage.js'
import { renderBestellen, statusKarte } from './funktion-bestellen.js' import { renderBestellen, statusKarte } from './funktion-bestellen.js'
import { meldeReiter, reiterEintraege } from './fn-reiter.js'
/** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */ /** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */
export const REITER = [ export const REITER = [
@@ -48,6 +49,10 @@ let S = null // aktuelle Sitzung der Ansicht; eine alte Antwort darf eine neuere
const zustandVon = (projekt) => projekt?.features?.shop?.state || 'off' const zustandVon = (projekt) => projekt?.features?.shop?.state || 'off'
const text = (wert) => (typeof wert === 'string' ? wert : '') const text = (wert) => (typeof wert === 'string' ? wert : '')
// Abo-Modell (Antwort von GET /api/funktionen, strikt): die Reiter zeichnet die Etage, die Ansicht meldet sie (fn-reiter.js)
const istAbo = (s) => s.funktionen?.modell === 'abo'
/** Eine Ansicht ohne Reiter meldet die leere Liste, damit die Etage nicht die Reiter der vorigen stehen laesst. */
const keineReiter = (s) => { if (istAbo(s)) meldeReiter(s.root, { eintraege: [] }) }
/* --------------------------------------------------------- Einstieg --- */ /* --------------------------------------------------------- Einstieg --- */
/** Rueckkehr aus der Adresse lesen und diese bereinigen: #webshop?bestellt=1&projekt=<id> | #webshop?stripe=zurueck|neu&projekt=<id>. */ /** Rueckkehr aus der Adresse lesen und diese bereinigen: #webshop?bestellt=1&projekt=<id> | #webshop?stripe=zurueck|neu&projekt=<id>. */
@@ -81,10 +86,12 @@ async function starte(root, projectId) {
} }
if (S !== s) return if (S !== s) return
if (!s.funktionen?.shop?.verfuegbar) { if (!s.funktionen?.shop?.verfuegbar) {
keineReiter(s)
root.replaceChildren(infoKarte(NICHT_VERFUEGBAR, 'Sobald er für dich bereit ist, findest du ihn hier.')) root.replaceChildren(infoKarte(NICHT_VERFUEGBAR, 'Sobald er für dich bereit ist, findest du ihn hier.'))
return return
} }
if (!s.projects.length) { if (!s.projects.length) {
keineReiter(s)
root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.')) root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.'))
return return
} }
@@ -151,6 +158,7 @@ async function zeigeProjekt(s, optionen = {}) {
const projekt = s.projekt const projekt = s.projekt
const lauf = ++s.lauf const lauf = ++s.lauf
const aktuell = () => S === s && s.lauf === lauf const aktuell = () => S === s && s.lauf === lauf
keineReiter(s)
s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let status let status
try { try {
@@ -179,6 +187,7 @@ async function ladeNeu(s, optionen = {}) {
/** Die Ansicht zum Zustand aus dem frisch geladenen Stand (status.state). */ /** Die Ansicht zum Zustand aus dem frisch geladenen Stand (status.state). */
async function zeichneAnsicht(s, { status, aktuell, bestellformular = false, meldung = null }) { async function zeichneAnsicht(s, { status, aktuell, bestellformular = false, meldung = null }) {
s.status = status s.status = status
keineReiter(s) // die Ansicht mit Reitern (zeigeReiter) meldet ihre Liste gleich danach
if (!status.verfuegbar) { if (!status.verfuegbar) {
s.buehne.replaceChildren(infoKarte(NICHT_VERFUEGBAR, 'Sobald er für dich bereit ist, findest du ihn hier.')) s.buehne.replaceChildren(infoKarte(NICHT_VERFUEGBAR, 'Sobald er für dich bereit ist, findest du ihn hier.'))
return return
@@ -318,15 +327,16 @@ function zeigeReiter(s, { status, aktuell, zustand }) {
neuLaden, neuLaden,
} }
const abo = istAbo(s) // Abo-Modell: keine eigene Leiste, die Etage zeichnet sie (kn:reiter)
const panel = h('div', { class: 'sh-panel', role: 'tabpanel', id: 'sh-panel', tabindex: '0' }) const panel = h('div', { class: 'sh-panel', role: 'tabpanel', id: 'sh-panel', tabindex: '0' })
let aktiv = null // Schluessel des gezeigten Reiters let aktiv = null // Schluessel des gezeigten Reiters
let zaehler = 0 // nur der zuletzt gewaehlte Reiter zeichnet in das Panel let zaehler = 0 // nur der zuletzt gewaehlte Reiter zeichnet in das Panel
const knoepfe = REITER.map((r) => h('button', { const knoepfe = abo ? [] : REITER.map((r) => h('button', {
type: 'button', role: 'tab', class: 'sh-reiter', id: `sh-tab-${r.key}`, 'aria-controls': 'sh-panel', 'data-reiter': r.key, type: 'button', role: 'tab', class: 'sh-reiter', id: `sh-tab-${r.key}`, 'aria-controls': 'sh-panel', 'data-reiter': r.key,
onclick: () => waehleReiter(r.key), onclick: () => waehleReiter(r.key),
}, r.label)) }, r.label))
const leiste = h('div', { class: 'sh-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Webshops' }, knoepfe) const leiste = abo ? null : h('div', { class: 'sh-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Webshops' }, knoepfe)
leiste.addEventListener('keydown', (ev) => { leiste?.addEventListener('keydown', (ev) => {
const i = REITER.findIndex((r) => r.key === aktiv) const i = REITER.findIndex((r) => r.key === aktiv)
const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key] const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key]
if (ziel === undefined) return if (ziel === undefined) return
@@ -345,7 +355,10 @@ function zeigeReiter(s, { status, aktuell, zustand }) {
k.setAttribute('aria-selected', String(an)) k.setAttribute('aria-selected', String(an))
k.tabIndex = an ? 0 : -1 k.tabIndex = an ? 0 : -1
} }
panel.setAttribute('aria-labelledby', `sh-tab-${reiter.key}`) if (abo) {
panel.setAttribute('aria-label', reiter.label)
meldeReiter(s.root, { eintraege: reiterEintraege('shop', REITER), aktiv: reiter.key, bei: (neu) => waehleReiter(neu) })
} else panel.setAttribute('aria-labelledby', `sh-tab-${reiter.key}`)
zeigeMeldung(s, '') zeigeMeldung(s, '')
panel.replaceChildren() panel.replaceChildren()
reiter.laden() reiter.laden()
@@ -353,7 +366,7 @@ function zeigeReiter(s, { status, aktuell, zustand }) {
.catch((err) => { if (nr === zaehler && aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) }) .catch((err) => { if (nr === zaehler && aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) })
} }
s.buehne.replaceChildren(hinweisBox, leiste, panel) s.buehne.replaceChildren(...[hinweisBox, leiste, panel].filter(Boolean))
// vom Onboarding bei Stripe zurueck: die Uebersicht zeigt den Stand der Stripe-Karte // vom Onboarding bei Stripe zurueck: die Uebersicht zeigt den Stand der Stripe-Karte
waehleReiter(s.stripe && zustand !== 'disabled' ? 'uebersicht' : auswahl.reiter, { merken: false }) waehleReiter(s.stripe && zustand !== 'disabled' ? 'uebersicht' : auswahl.reiter, { merken: false })
if (s.danke && zustand === 'setup') zeigeMeldung(s, DANKE_SETUP) if (s.danke && zustand === 'setup') zeigeMeldung(s, DANKE_SETUP)
+21 -6
View File
@@ -23,6 +23,7 @@ import { render as renderEinstellungen } from './termine-tab-einstellungen.js'
import { renderFragebogen } from './termine-fragebogen.js' import { renderFragebogen } from './termine-fragebogen.js'
import { renderBestellen, statusKarte } from './funktion-bestellen.js' import { renderBestellen, statusKarte } from './funktion-bestellen.js'
import { ZU_VERTRAEGE } from './vertraege-link.js' import { ZU_VERTRAEGE } from './vertraege-link.js'
import { meldeReiter, reiterEintraege } from './fn-reiter.js'
// Auswahl bleibt beim Wechsel zwischen Funktionen und Terminplaner erhalten (der Tag nicht: er beginnt immer bei heute) // Auswahl bleibt beim Wechsel zwischen Funktionen und Terminplaner erhalten (der Tag nicht: er beginnt immer bei heute)
const auswahl = { projectId: '', reiter: 'termine', ansicht: 'tag', filter: 'alle' } const auswahl = { projectId: '', reiter: 'termine', ansicht: 'tag', filter: 'alle' }
@@ -41,6 +42,11 @@ function leseRueckkehr() {
return { bestellt: query.get('bestellt') === '1', projekt: query.get('projekt') || '' } return { bestellt: query.get('bestellt') === '1', projekt: query.get('projekt') || '' }
} }
// Abo-Modell (Antwort von GET /api/funktionen, strikt): die Reiter zeichnet die Etage, die Ansicht meldet sie (fn-reiter.js)
const istAbo = (s) => s.modell === 'abo'
/** Eine Ansicht ohne Reiter meldet die leere Liste, damit die Etage nicht die Reiter der vorigen stehen laesst. */
const keineReiter = (s) => { if (istAbo(s)) meldeReiter(s.root, { eintraege: [] }) }
const zustandVon = (projekt) => projekt?.features?.termine?.state || 'off' const zustandVon = (projekt) => projekt?.features?.termine?.state || 'off'
const istBestellt = (zustand) => zustand === 'ordered' || zustand === 'setup' const istBestellt = (zustand) => zustand === 'ordered' || zustand === 'setup'
// Entwurf: Funktionsdokument im Zustand Aus mit siteKey (wie die Kachel in funktionen.js) // Entwurf: Funktionsdokument im Zustand Aus mit siteKey (wie die Kachel in funktionen.js)
@@ -52,14 +58,16 @@ export async function initTermine(root, { projectId } = {}) {
const wunsch = rueckkehr.projekt || projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab const wunsch = rueckkehr.projekt || projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
delete root.dataset.wunschProjekt delete root.dataset.wunschProjekt
if (wunsch) auswahl.projectId = wunsch if (wunsch) auswahl.projectId = wunsch
const { projects = [], termine } = await request('/api/funktionen') const { projects = [], termine, modell } = await request('/api/funktionen')
const s = { root, projects, termine: termine || null, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null, danke: false } const s = { root, projects, termine: termine || null, modell, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null, danke: false }
S = s S = s
if (!termine?.verfuegbar) { if (!termine?.verfuegbar) {
keineReiter(s)
root.replaceChildren(infoKarte('Der Terminplaner ist noch nicht verfügbar', 'Sobald er für dich bereit ist, findest du ihn hier.')) root.replaceChildren(infoKarte('Der Terminplaner ist noch nicht verfügbar', 'Sobald er für dich bereit ist, findest du ihn hier.'))
return return
} }
if (!projects.length) { if (!projects.length) {
keineReiter(s)
root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.')) root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.'))
return return
} }
@@ -102,6 +110,7 @@ async function ladeFunktionen(s) {
const projects = Array.isArray(antwort.projects) ? antwort.projects : [] const projects = Array.isArray(antwort.projects) ? antwort.projects : []
s.projects = projects s.projects = projects
s.termine = antwort.termine || null s.termine = antwort.termine || null
s.modell = antwort.modell
s.projekt = projects.find((p) => p.projectId === s.projekt?.projectId) || s.projekt s.projekt = projects.find((p) => p.projectId === s.projekt?.projectId) || s.projekt
} }
@@ -123,6 +132,7 @@ async function zeigeProjekt(s, optionen = {}) {
const projekt = s.projekt const projekt = s.projekt
const lauf = ++s.lauf const lauf = ++s.lauf
const aktuell = () => S === s && s.lauf === lauf const aktuell = () => S === s && s.lauf === lauf
keineReiter(s)
s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…'))
let daten let daten
try { try {
@@ -155,6 +165,7 @@ async function zeichneAnsicht(s, { daten, aktuell, bestellformular = false, meld
s.daten = daten s.daten = daten
const zustand = daten.state || zustandVon(projekt) const zustand = daten.state || zustandVon(projekt)
s.nurLesen = zustand !== 'active' && zustand !== 'off' s.nurLesen = zustand !== 'active' && zustand !== 'off'
keineReiter(s) // die Ansichten mit Reitern (zeigeReiter) melden ihre Liste gleich danach
if (zustand === 'off') { if (zustand === 'off') {
if (daten.abo?.ok === true) zeigeFragebogen(s, { projekt, aktuell }) if (daten.abo?.ok === true) zeigeFragebogen(s, { projekt, aktuell })
else await zeigeAboHinweis(s, { aktuell }) else await zeigeAboHinweis(s, { aktuell })
@@ -386,14 +397,15 @@ function zeigeReiter(s, { projekt, aktuell, zustand = s.nurLesen ? 'disabled' :
return return
} }
const abo = istAbo(s) // Abo-Modell: keine eigene Leiste, die Etage zeichnet sie (kn:reiter)
const panel = h('div', { class: 'tm-panel', role: 'tabpanel', id: 'tm-panel', tabindex: '0' }) const panel = h('div', { class: 'tm-panel', role: 'tabpanel', id: 'tm-panel', tabindex: '0' })
let aktiv = null // Schluessel des gezeigten Reiters let aktiv = null // Schluessel des gezeigten Reiters
const knoepfe = reiterListe.map((r) => h('button', { const knoepfe = abo ? [] : reiterListe.map((r) => h('button', {
type: 'button', role: 'tab', class: 'tm-reiter', id: `tm-tab-${r.key}`, 'aria-controls': 'tm-panel', 'data-reiter': r.key, type: 'button', role: 'tab', class: 'tm-reiter', id: `tm-tab-${r.key}`, 'aria-controls': 'tm-panel', 'data-reiter': r.key,
onclick: () => waehleReiter(r.key), onclick: () => waehleReiter(r.key),
}, r.label)) }, r.label))
const leiste = h('div', { class: 'tm-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Terminplaners' }, knoepfe) const leiste = abo ? null : h('div', { class: 'tm-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Terminplaners' }, knoepfe)
leiste.addEventListener('keydown', (ev) => { leiste?.addEventListener('keydown', (ev) => {
const i = reiterListe.findIndex((r) => r.key === aktiv) const i = reiterListe.findIndex((r) => r.key === aktiv)
const ziel = { ArrowRight: (i + 1) % reiterListe.length, ArrowLeft: (i + reiterListe.length - 1) % reiterListe.length, Home: 0, End: reiterListe.length - 1 }[ev.key] const ziel = { ArrowRight: (i + 1) % reiterListe.length, ArrowLeft: (i + reiterListe.length - 1) % reiterListe.length, Home: 0, End: reiterListe.length - 1 }[ev.key]
if (ziel === undefined) return if (ziel === undefined) return
@@ -412,7 +424,10 @@ function zeigeReiter(s, { projekt, aktuell, zustand = s.nurLesen ? 'disabled' :
k.setAttribute('aria-selected', String(an)) k.setAttribute('aria-selected', String(an))
k.tabIndex = an ? 0 : -1 k.tabIndex = an ? 0 : -1
} }
panel.setAttribute('aria-labelledby', `tm-tab-${reiter.key}`) if (abo) {
panel.setAttribute('aria-label', reiter.label)
meldeReiter(s.root, { eintraege: reiterEintraege('termine', reiterListe), aktiv: reiter.key, bei: (neu) => waehleReiter(neu) })
} else panel.setAttribute('aria-labelledby', `tm-tab-${reiter.key}`)
zeigeMeldung(s, '') zeigeMeldung(s, '')
panel.replaceChildren() panel.replaceChildren()
Promise.resolve() Promise.resolve()
+135
View File
@@ -0,0 +1,135 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { KURZLABELS, meldeReiter, reiterEintraege } from '../public/fn-reiter.js'
import { h } from '../public/termine-dom.js'
import { initTermine } from '../public/termine.js'
import { initShop, REITER as SHOP_REITER, vergissAuswahl as vergissShop } from '../public/shop.js'
import { initBuchungen, REITER as BUCHUNGEN_REITER, vergissAuswahl as vergissBuchungen } from '../public/buchungen.js'
import { hoereAufKn, mitDom, routen } from './support/modulAbo.js'
// Reiter der Funktionsmodule im Abo-Modell (P4 Task 1, Spec 5.2/5.4, C2): keine eigene Leiste, kn:reiter an die Etage, Kurzlabels.
// Ohne modell === 'abo' bleibt die eigene Leiste mit den langen Labels und es gibt kein Ereignis.
test('KURZLABELS: genau diese Einträge (C2)', () => {
assert.deepEqual(KURZLABELS, { termine: { team: 'Team' }, shop: { uebersicht: 'Start', rechtliches: 'Recht' }, buchungen: { unterkuenfte: 'Preise' } })
})
test('reiterEintraege: Kurzlabel nur mit Eintrag, fremde Felder fallen weg', () => {
const liste = [{ key: 'termine', label: 'Termine', render() {} }, { key: 'team', label: 'Team & Plätze', render() {} }]
assert.deepEqual(reiterEintraege('termine', liste), [{ key: 'termine', label: 'Termine' }, { key: 'team', label: 'Team & Plätze', kurz: 'Team' }])
assert.deepEqual(reiterEintraege('unbekannt', liste.slice(1)), [{ key: 'team', label: 'Team & Plätze' }])
assert.deepEqual(reiterEintraege('analyse', [{ key: 'heatmap', label: 'Heatmap' }]), [{ key: 'heatmap', label: 'Heatmap' }], 'Analyse: kein Kurzlabel nötig')
assert.deepEqual(reiterEintraege('constructor', [{ key: 'toString', label: 'X' }]), [{ key: 'toString', label: 'X' }], 'keine Treffer aus dem Prototyp')
})
test('meldeReiter: das Ereignis steigt auf und trägt Einträge, aktiv und bei; ohne Element tut es nichts', mitDom(async (fx) => {
const k = hoereAufKn(fx)
const el = h('div')
document.body.append(el)
const bei = () => {}
assert.equal(meldeReiter(el, { eintraege: [{ key: 'a', label: 'A' }], aktiv: 'a', bei }), true)
assert.deepEqual(k.reiter, [{ eintraege: [{ key: 'a', label: 'A' }], aktiv: 'a', bei }])
assert.equal(meldeReiter(null, { eintraege: [] }), false)
assert.equal(meldeReiter({}, { eintraege: [] }), false, 'ohne dispatchEvent')
}))
// ----------------------------------------------------------------------------------------------------- die drei Module
const projekt = (features) => ({ projectId: 'p1', projectName: 'Mein Laden', features })
const funktionen = (modell, kopf, features) => ({ body: { ...(modell === undefined ? {} : { modell }), projects: [projekt(features)], ...kopf } })
const FAELLE = [
{
name: 'Terminplaner',
init: initTermine,
vorher: () => {},
tabelle: (modell) => ({
'GET /api/funktionen': funktionen(modell, { termine: { verfuegbar: true } }, { termine: { state: 'active', siteKey: 'abcdefghijklmnop' } }),
'GET /api/termine/einstellungen?projectId=p1': { body: { state: 'active', einstellung: {}, fehlt: [] } },
}),
alt: ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen'],
kurz: ['Termine', 'Leistungen', 'Team', 'Einstellungen'],
},
{
name: 'Webshop',
init: initShop,
vorher: vergissShop,
tabelle: (modell) => ({
'GET /api/funktionen': funktionen(modell, { shop: { verfuegbar: true }, kundenart: 'unternehmen' }, { shop: {} }),
'GET /api/shop/status?projectId=p1': { body: { verfuegbar: true, state: 'active', anfrage: null } },
}),
alt: ['Übersicht', 'Artikel', 'Bestellungen', 'Einstellungen', 'Rechtliches'],
kurz: ['Start', 'Artikel', 'Bestellungen', 'Einstellungen', 'Recht'],
},
{
name: 'Buchungskalender',
init: initBuchungen,
vorher: vergissBuchungen,
tabelle: (modell) => ({
'GET /api/funktionen': funktionen(modell, { unterkunft: { verfuegbar: true } }, { unterkunft: { state: 'active' } }),
'GET /api/unterkunft/status?projectId=p1': { body: { state: 'active' } },
}),
alt: ['Anfragen', 'Kalender', 'Unterkünfte & Preise', 'Rechnungen', 'Einstellungen'],
kurz: ['Anfragen', 'Kalender', 'Preise', 'Rechnungen', 'Einstellungen'],
},
]
async function oeffne(fx, f, modell) {
f.vorher?.()
routen(fx, f.tabelle(modell))
const root = h('div')
document.body.append(root)
await f.init(root, { projectId: 'p1' })
await fx.ruhe()
return root
}
for (const f of FAELLE) {
test(`${f.name}, Abo: keine eigene Leiste, kn:reiter mit Kurzlabels, bei() wechselt`, mitDom(async (fx) => {
const k = hoereAufKn(fx)
const root = await oeffne(fx, f, 'abo')
assert.equal(fx.finde(root, '[role="tablist"]'), null)
assert.equal(fx.finde(root, '[role="tab"]'), null)
const panel = fx.finde(root, '[role="tabpanel"]')
assert.ok(panel, 'das Panel bleibt')
assert.equal(panel.hasAttribute('aria-labelledby'), false)
assert.ok(panel.getAttribute('aria-label'), 'das Panel trägt aria-label')
const erst = k.reiter.at(-1)
assert.deepEqual(erst.eintraege.map((e) => e.label), f.alt)
assert.deepEqual(erst.eintraege.map((e) => e.kurz ?? e.label), f.kurz)
const andere = erst.eintraege.find((e) => e.key !== erst.aktiv).key
erst.bei(andere)
await fx.ruhe()
assert.equal(k.reiter.at(-1).aktiv, andere)
assert.equal(panel.getAttribute('aria-label'), erst.eintraege.find((e) => e.key === andere).label)
}))
test(`${f.name}, ohne modell (fehlt, „Abo“, „abo “): eigene Leiste mit langen Labels, kein Ereignis`, mitDom(async (fx) => {
for (const modell of [undefined, 'Abo', 'abo ']) {
const k = hoereAufKn(fx)
const root = await oeffne(fx, f, modell)
assert.deepEqual(fx.alle(root, '[role="tab"]').map((t) => fx.text(t)), f.alt, String(modell))
assert.ok(fx.finde(root, '[role="tablist"]'))
assert.equal(k.reiter.length, 0, String(modell))
}
}))
}
test('die langen Labels der Reiterlisten bleiben unverändert (Pins buchungenUi und shopView)', () => {
assert.deepEqual(SHOP_REITER.map((r) => r.label), FAELLE[1].alt)
assert.deepEqual(BUCHUNGEN_REITER.map((r) => r.label), FAELLE[2].alt)
})
test('Webshop im Abo-Modell, Zustand off: die Reiterliste ist leer', mitDom(async (fx) => {
const k = hoereAufKn(fx)
vergissShop()
routen(fx, {
'GET /api/funktionen': funktionen('abo', { shop: { verfuegbar: true }, kundenart: 'unternehmen' }, { shop: { state: 'off' } }),
'GET /api/shop/status?projectId=p1': { body: { verfuegbar: true, state: 'off', anfrage: null } },
})
const root = h('div')
document.body.append(root)
await initShop(root, { projectId: 'p1' })
await fx.ruhe()
assert.ok(k.reiter.length > 0, 'die Ansicht meldet sich')
assert.deepEqual(k.reiter.at(-1).eintraege, [])
}))
+148
View File
@@ -0,0 +1,148 @@
// Testhilfen fuer die Funktionsmodule im Abo-Modell (P4 „Funktionsmodule“): Fake-DOM mit CustomEvent, Antworten nach Adresse, Attrappe fuer den
// Einschalt-Ablauf (zeigeEinschalten aus public/neu/funktion-einschalten.js), Wurzeln fuer die innerHTML-Module (News, Analyse).
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { installFakeDom } from './fakeDom.js'
/** Wörter, die im Abo-Modell auf keiner Oberfläche der Funktionen stehen dürfen (Spec: keine Preise für Funktionen). */
export const KEIN_PREIS = /€|einmalig|Preis|Zahlungspflichtig|Bestellseite|Behalten/
/** CustomEvent fuer fakeDom: type, detail, bubbles, target, ergebnisse, preventDefault, stopPropagation. */
class FakeCustomEvent {
constructor(type, init = {}) {
this.type = type
this.detail = init.detail ?? null
this.bubbles = init.bubbles === true
this.target = null
this.currentTarget = null
this.defaultPrevented = false
this.stopped = false
this.ergebnisse = []
}
preventDefault() { this.defaultPrevented = true }
stopPropagation() { this.stopped = true }
}
/** Testrumpf: Fake-DOM an, CustomEvent der Fake-Klasse, nach dem Test alles zurück. fn(fx). */
export const mitDom = (fn) => async () => {
const fx = installFakeDom()
const davor = Object.getOwnPropertyDescriptor(globalThis, 'CustomEvent')
Object.defineProperty(globalThis, 'CustomEvent', { value: FakeCustomEvent, writable: true, configurable: true, enumerable: true })
try {
await fn(fx)
await fx.ruhe()
} finally {
fx.restore()
if (davor) Object.defineProperty(globalThis, 'CustomEvent', davor)
else delete globalThis.CustomEvent
}
}
/** Lauscht am document.body auf kn:reiter. => { reiter: [detail, …] } (in der Reihenfolge des Eintreffens). */
export function hoereAufKn(fx) {
const k = { reiter: [] }
fx.document.body.addEventListener('kn:reiter', (ev) => { k.reiter.push(ev.detail) })
return k
}
/**
* Ersetzt fetch: Antworten nach 'METHODE /pfad?query'. Wert: { status?, body } | Array (der Reihe nach, der letzte gilt weiter) |
* (aufruf) => Wert. Unbekannte Adressen: 404 und Eintrag in fx.unbekannt. Jeder Aufruf steht in fx.fetchLog.
*/
export function routen(fx, tabelle) {
const felder = new Map(Object.entries(tabelle).map(([schluessel, wert]) => [schluessel, Array.isArray(wert) ? [...wert] : wert]))
fx.unbekannt = []
globalThis.fetch = async (url, optionen = {}) => {
const aufruf = { method: optionen.method || 'GET', url: String(url), body: optionen.body ? JSON.parse(optionen.body) : undefined }
fx.fetchLog.push(aufruf)
const schluessel = `${aufruf.method} ${aufruf.url}`
let eintrag = felder.get(schluessel)
if (Array.isArray(eintrag)) eintrag = eintrag.length > 1 ? eintrag.shift() : eintrag[0]
if (typeof eintrag === 'function') eintrag = eintrag(aufruf)
if (eintrag === undefined) {
fx.unbekannt.push(schluessel)
eintrag = { status: 404, body: { error: `nicht vorgesehen: ${schluessel}` } }
}
const { status = 200, body = {} } = eintrag
return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) }
}
}
/**
* Attrappe fuer zeigeEinschalten. fn(platz, { key, projectId, viewAs, onFertig }) zeichnet nichts und merkt den Aufruf.
* fertig(i, ergebnis) ruft onFertig des i-ten Aufrufs.
*/
export function einschaltenAttrappe() {
const aufrufe = []
const fn = (wurzel, o = {}) => {
aufrufe.push({ wurzel, key: o.key, projectId: o.projectId, viewAs: o.viewAs, onFertig: o.onFertig })
return Promise.resolve()
}
const fertig = (i = 0, ergebnis = { feature: { state: 'setup' }, ticketWoid: null }) => aufrufe[i].onFertig(ergebnis)
return { fn, aufrufe, fertig }
}
function attrappe() {
const handlers = {}
return {
innerHTML: '',
textContent: '',
disabled: false,
checked: false,
value: '',
dataset: {},
classList: { add() {}, remove() {}, toggle() {} },
handlers,
addEventListener(typ, fn) { handlers[typ] = fn },
querySelector: () => null,
querySelectorAll: () => [],
}
}
/**
* Wurzel fuer die innerHTML-Module (News, Analyse): { root, stage, kn }. root.querySelector(stageSelektor) liefert stage, '.an' die Attrappe `an`.
* stage.els['#id'] entsteht beim ersten querySelector('#id'). root.dispatchEvent legt die Meldungen 'kn:reiter' (ev.detail) in kn.reiter ab.
*/
export function htmlWurzel(stageSelektor) {
const els = {}
const attribute = {}
const stage = {
els,
attribute,
innerHTML: '',
querySelector: (selektor) => (els[selektor] ||= attrappe()),
querySelectorAll: () => [],
setAttribute: (name, wert) => { attribute[name] = String(wert) },
getAttribute: (name) => (Object.hasOwn(attribute, name) ? attribute[name] : null),
}
const an = attrappe()
const kn = { reiter: [] }
const root = {
innerHTML: '',
dataset: {},
querySelector: (selektor) => (selektor === stageSelektor ? stage : selektor === '.an' ? an : null),
dispatchEvent(ev) {
if (ev?.type === 'kn:reiter') kn.reiter.push(ev.detail)
return true
},
}
return { root, stage, kn }
}
/**
* public/funktionen.js laden, alle statischen Importe (absolut /x.js oder relativ ./x.js) auf die Dateien in public/ umbiegen
* (Muster funktionenZeitenTile.test.js). => das Modul.
*/
export async function ladeFunktionenModul() {
const dir = mkdtempSync(join(tmpdir(), 'fn-modul-abo-'))
const dateiUrl = (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}'${dateiUrl(name)}'`)
const datei = join(dir, 'funktionen.mjs')
writeFileSync(datei, src)
return import(pathToFileURL(datei).href)
}