Files

255 lines
14 KiB
JavaScript

/* 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 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.
Aendert sich der Stand (zeitenLeer nach dem ersten Speichern, setup → active, die Abo-Lage), wird nur der Kopf (Statuskarte) neu gezeichnet: der Editorplatz
bleibt mit seinem Editor und den ungespeicherten Eingaben stehen, solange Website und Schreibrecht (abgeschaltet = nur lesen) gleich sind (data-projekt,
data-state); eine andere Website oder der Wechsel in „nur lesen“ bekommt einen frischen Editor. Die Rueckkehr von Stripe zeichnet immer ganz neu.
Kundenansicht (window.KbView.id() gesetzt): Einschalten, Bestellen und Behalten sind gesperrt, der Server antwortet dort ohnehin mit 403. */
import { fehlerKarte, h, infoKarte, request } from './termine-dom.js'
import { behaltenLage, renderZeitenBestellen, renderZeitenEinschalten, zeitenStatusKarte } from './zeiten-bestellen.js'
import { renderEditor } from './zeiten-editor.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,
* und nur ohne Abo (behaltenLage 'bezahlen': inklusive false UND grund kein_abo); laeuft das Abo (nicht_aktiv, gekuendigt, inklusive), gibt es nichts zu bezahlen.
*/
function behaltenMoeglich(projekt, angebot, modell) {
if (modell === 'abo') return false // Abo-Modell: kein Behalten gegen Geld (Z2: Öffnungszeiten live läuft noch bis zur Frist)
const feature = projekt?.features?.zeiten
if (!feature || behaltenLage(angebot) !== 'bezahlen') 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, einschalten } = {}) {
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 : '',
modell: antwort.modell, einschalten, projekt: null, lauf: 0, buehne: null, danke: '', editorAlt: null,
}
// der Editorplatz der bisherigen Ansicht (mit seinem Editor) wird in den neuen Aufbau mitgenommen, ausser nach der Rueckkehr von Stripe
const alterEditor = !rueckkehr.bestellt && !rueckkehr.behalten && S?.root === root ? root.querySelector('.zt-editor') : null
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, S.angebot, S.modell)) zeigeBehalten(S)
return
}
S = s
s.editorAlt = alterEditor
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: s.modell === 'abo' ? '' : 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.modell = antwort.modell
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(),
einschalten: s.einschalten, // Ablauf des Einschaltens im Abo-Modell (Init-Option der Hülle)
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)
// Der Editorplatz steht in setup, active und (mit Daten) disabled. Ein Platz derselben Website mit gleichem Schreibrecht bleibt samt Editor stehen
// (ungespeicherte Eingaben gehen nicht verloren, der Editor laedt nicht noch einmal); sonst gibt es einen frischen.
let editorPlatz = null
let frisch = false
if (zustand === 'setup' || zustand === 'active' || (zustand === 'disabled' && projekt.zeitenLeer === false)) {
const vorhanden = s.editorAlt || s.buehne.querySelector('.zt-editor')
if (vorhanden && vorhanden.dataset.projekt === projekt.projectId && (vorhanden.dataset.state === 'disabled') === (zustand === 'disabled')) {
editorPlatz = vorhanden
editorPlatz.dataset.state = zustand
editorPlatz.hidden = false // die Behalten-Seite versteckt ihn; sie wird bei Bedarf unten wieder geoeffnet
} else {
editorPlatz = h('div', { class: 'zt-editor', 'data-state': zustand, 'data-projekt': projekt.projectId })
frisch = true
}
teile.push(editorPlatz)
}
s.editorAlt = null
s.buehne.replaceChildren(...teile)
if (editorPlatz) s.root.dataset.zeitenKey = stand(projekt, s.angebot)
if (frisch) {
// neuZeichnen gehoert zur jeweils aktuellen Sitzung (S): der Platz kann einen Neuaufbau der Ansicht ueberleben
const editorCtx = { projectId: projekt.projectId, kundenansicht: ctx.kundenansicht, neuZeichnen: (optionen) => (S ? neuZeichnen(S, optionen) : undefined) }
Promise.resolve(renderEditor(editorPlatz, editorCtx)).catch((err) => editorPlatz.replaceChildren(fehlerKarte(err?.message || 'Der Editor konnte nicht gezeichnet werden.')))
}
// 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, modell: s.modell, ...zusatz },
{
behalten: () => zeigeBehalten(s),
weiter: (fall) => zeichneProjekt(s, { bestellformular: fall?.bestellen === true, meldung: fall?.meldung || '' }),
})
if (zustand === 'off' && s.modell === 'abo') {
renderZeitenEinschalten(kopf, { ctx, projekt })
return
}
if (zustand === 'off' || (zustand === 'ordered' && bestellformular)) {
renderZeitenBestellen(kopf, { ctx, angebot: s.angebot, projekt })
return
}
kopf.replaceChildren(statusKarte())
if (weg === 'behalten' && behaltenMoeglich(projekt, s.angebot, s.modell)) 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' })
}