Ö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:
ClaudeandClaude Opus 5.5 committed 2026-10-05 05:30:23 +00:00
1 parent f20f056c60
commit 7bb918390a
10 files changed
+1878 -8

No files matched your search

+5 -3
View File
@@ -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])'
+10
View File
@@ -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
View File
@@ -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)
}
+377
View File
@@ -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' })
}
+35
View File
@@ -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; } }
+219
View File
@@ -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' })
}