Die Detailzeile umbrach bei mehreren Websites neben der Pille auf drei Zeilen. Der Abo-Ende-Text mit dem Löschtag bleibt unverändert. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
342 lines
17 KiB
JavaScript
342 lines
17 KiB
JavaScript
/* Kundenansicht neu (P3): Bereich „Funktionen“ mit den Reitern Meine und Entdecken.
|
||
Der erste Teil ist rein (Modell aus der Antwort von GET /api/funktionen im Abo-Modell, V5); die Ansicht darunter zeichnet es.
|
||
Keine Preise: Der Katalog trägt keinen. Fremdtext (Funktions- und Websitenamen) wird nur als Text gesetzt. */
|
||
|
||
import { api as apiStandard } from './api.js'
|
||
import { h, leeren } from './dom.js'
|
||
import { baueReiter } from './reiter.js'
|
||
import { geheZu as geheZuStandard, oeffneOverlay as oeffneOverlayStandard, beendeOverlay as beendeOverlayStandard } from './router.js'
|
||
import { ladeOffeneRechnungen, rechnungOffenZeile } from './rechnung-offen.js'
|
||
import { ETAGE_FUER, TEXTE, anfrageStatusVon, einschaltWeg, navigiere, nimmMeldung, renderEinschalten } from './funktion-einschalten.js'
|
||
|
||
const ANFRAGE_SICHTBAR = ['offen', 'angenommen', 'abgelehnt'] // zurückgezogen zählt nicht
|
||
|
||
/** Zeitpunkt oder 'JJJJ-MM-TT' → 'TT.MM.JJJJ' (Europe/Berlin); unlesbar ergibt ''. Ein Tagestext wird als Tag um 12:00 UTC gelesen. */
|
||
export function datumDe(wert) {
|
||
if (typeof wert !== 'string' || wert === '') return ''
|
||
const ms = Date.parse(/^\d{4}-\d{2}-\d{2}$/.test(wert) ? `${wert}T12:00:00Z` : wert)
|
||
if (Number.isNaN(ms)) return ''
|
||
return new Date(ms).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||
}
|
||
|
||
const verfuegbare = (d) => (Array.isArray(d?.katalog) ? d.katalog.filter((e) => e && typeof e === 'object' && e.verfuegbar === true && typeof e.key === 'string') : [])
|
||
const websites = (d) => (Array.isArray(d?.projects) ? d.projects.filter((p) => p && typeof p === 'object' && typeof p.projectId === 'string') : [])
|
||
const zustandVon = (projekt, key) => (key === 'analyse' ? projekt.analyse?.state : projekt.features?.[key]?.state)
|
||
|
||
/** Hat diese Website die Funktion schon (Zeile in Meine)? Eine Zeile ist Zustand, Entwurf des Fragebogens oder sichtbare Anfrage. */
|
||
function istInMeine(eintrag, projekt) {
|
||
if (eintrag.key === 'analyse') return ['active', 'disabled'].includes(projekt.analyse?.state)
|
||
const f = projekt.features?.[eintrag.key]
|
||
if (!f || typeof f !== 'object') return false
|
||
if (f.state !== 'off') return typeof f.state === 'string'
|
||
if (eintrag.ablauf === 'fragebogen') return Boolean(f.siteKey)
|
||
if (eintrag.ablauf === 'anfrage') return ANFRAGE_SICHTBAR.includes(anfrageStatusVon(projekt, eintrag.key))
|
||
return false
|
||
}
|
||
|
||
/** „Dein Abo ist beendet.“ mit dem Löschtag, wenn einer da ist. */
|
||
function aboEndeText(f) {
|
||
const tag = datumDe(f.loeschungAm)
|
||
return tag ? `Dein Abo ist beendet. Deine Daten bleiben bis ${tag} gespeichert.` : 'Dein Abo ist beendet.'
|
||
}
|
||
|
||
const KURZINFO_MAX = 80 // wie der Server (kurzinfo.js): länger ist kein Detail mehr, sondern ein Fehler
|
||
|
||
/** Kurzinfo einer aktiven Funktion aus der Antwort des Servers: nur ein nicht leerer, kurzer Text; sonst '' (die Zeile zeigt dann „Aktiv“). */
|
||
function kurzinfoVon(quelle) {
|
||
const text = typeof quelle?.kurzinfo === 'string' ? quelle.kurzinfo.trim() : ''
|
||
return text !== '' && text.length <= KURZINFO_MAX ? text : ''
|
||
}
|
||
|
||
/**
|
||
* Pille und Detailzeile einer Funktion an einer Website (nur für Websites, bei denen istInMeine gilt). Läuft die Funktion, gibt es keine
|
||
* Pille: die Detailzeile ist die Kurzinfo des Servers („3 Termine heute · nächster 10:30“) oder „Aktiv“. Alles andere trägt eine Pille.
|
||
*/
|
||
function statusVon(eintrag, projekt) {
|
||
if (eintrag.key === 'analyse') {
|
||
const a = projekt.analyse
|
||
if (a.state === 'active') return { pille: null, status: kurzinfoVon(a) || 'Aktiv' }
|
||
return { pille: { text: 'Gesperrt', ton: 'grau' }, status: a.disabledReason === 'abo_ende' ? aboEndeText(a) : 'Schreib uns im Chat' }
|
||
}
|
||
const f = projekt.features[eintrag.key]
|
||
switch (f.state) {
|
||
case 'setup':
|
||
return { pille: { text: 'In Einrichtung', ton: 'blau' }, status: 'Wir richten sie gerade ein' }
|
||
case 'ordered':
|
||
return { pille: { text: 'Zahlung offen', ton: 'orange' }, status: 'Wir warten auf deine Zahlung' }
|
||
case 'active':
|
||
return { pille: null, status: kurzinfoVon(f) || 'Aktiv' }
|
||
case 'disabled':
|
||
return { pille: { text: 'Abgeschaltet', ton: 'grau' }, status: f.disabledReason === 'abo_ende' ? aboEndeText(f) : 'Im Chat wieder anfragen' }
|
||
default: {
|
||
if (eintrag.ablauf === 'fragebogen') return { pille: { text: 'Entwurf', ton: 'grau' }, status: 'Fragebogen fortsetzen' }
|
||
const anfrage = anfrageStatusVon(projekt, eintrag.key)
|
||
if (anfrage === 'angenommen') return { pille: { text: 'Angenommen', ton: 'blau' }, status: 'Jetzt einschalten' }
|
||
if (anfrage === 'abgelehnt') return { pille: { text: 'Abgelehnt', ton: 'rot' }, status: 'Anfrage abgelehnt · Im Chat nachfragen' }
|
||
return { pille: { text: 'Angefragt', ton: 'violett' }, status: 'Wir prüfen deine Anfrage' }
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Die Zeilen von „Meine“: je Funktion und Website eine, in Katalogreihenfolge, dann Websitereihenfolge. Funktionen mit
|
||
* verfuegbar !== true fehlen (C9). pille ist null, solange die Funktion läuft; status ist die Detailzeile (Kurzinfo oder Zustandstext),
|
||
* zeile2 ist bei mehreren Websites „Websitename · status“, sonst der Status allein.
|
||
* @returns {Array<{ key: string, name: string, projectId: string, projektName: string, zustand: string, anfrageStatus: string, pille: { text: string, ton: string }|null, status: string, zeile2: string }>}
|
||
*/
|
||
export function meineZeilen(daten) {
|
||
const seiten = websites(daten)
|
||
const mehrere = seiten.length > 1
|
||
const zeilen = []
|
||
for (const eintrag of verfuegbare(daten)) {
|
||
for (const projekt of seiten) {
|
||
if (!istInMeine(eintrag, projekt)) continue
|
||
const { pille, status } = statusVon(eintrag, projekt)
|
||
const projektName = String(projekt.projectName ?? '')
|
||
zeilen.push({
|
||
key: eintrag.key,
|
||
name: String(eintrag.name ?? ''),
|
||
projectId: projekt.projectId,
|
||
projektName,
|
||
zustand: String(zustandVon(projekt, eintrag.key) ?? ''),
|
||
anfrageStatus: eintrag.key === 'analyse' ? '' : anfrageStatusVon(projekt, eintrag.key),
|
||
pille,
|
||
status,
|
||
zeile2: mehrere && projektName !== '' ? `${projektName} · ${status}` : status,
|
||
})
|
||
}
|
||
}
|
||
return zeilen
|
||
}
|
||
|
||
/**
|
||
* Die Einträge von „Entdecken“ in Katalogreihenfolge: solange eine Website die Funktion nicht hat oder es keine Website gibt.
|
||
* kandidaten sind die Websites ohne die Funktion (Analyse: nur mit Zustand off; unknown und comingSoon sind kein Kandidat).
|
||
* @returns {Array<{ eintrag: object, kandidaten: Array<{ projectId: string, projektName: string }> }>}
|
||
*/
|
||
export function entdeckenEintraege(daten) {
|
||
const seiten = websites(daten)
|
||
const ergebnis = []
|
||
for (const eintrag of verfuegbare(daten)) {
|
||
const ohne = seiten.filter((p) => !istInMeine(eintrag, p))
|
||
if (seiten.length > 0 && ohne.length === 0) continue
|
||
const kandidaten = (eintrag.key === 'analyse' ? ohne.filter((p) => p.analyse?.state === 'off') : ohne)
|
||
.map((p) => ({ projectId: p.projectId, projektName: String(p.projectName ?? '') }))
|
||
ergebnis.push({ eintrag, kandidaten })
|
||
}
|
||
return ergebnis
|
||
}
|
||
|
||
/** Tipp auf eine Zeile in Meine: eine angenommene Anfrage geht zur Bestätigung, alles andere in die Etage der Funktion. */
|
||
export function zeilenWeg(zeile) {
|
||
if (zeile.zustand === 'off' && zeile.anfrageStatus === 'angenommen') return { art: 'seite' }
|
||
return { art: 'etage', etage: ETAGE_FUER[zeile.key] }
|
||
}
|
||
|
||
/* ------------------------------------------------------------------------------------------------ Ansicht --- */
|
||
|
||
const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs: eine ältere Antwort zeichnet nichts mehr
|
||
let reiterGewaehlt = false // Tipp auf „Meine“: der nächste Aufruf zeigt den Leerzustand statt zu Entdecken zu springen
|
||
let vorgabe = null // nach einer eigenen Navigation (Reiter, Sprung zu Entdecken) nimmt der nächste Aufruf die geladenen Antworten
|
||
const wechselErledigt = new Set() // 'key:projectId' der Funktionen, in die die Rückkehr vom Wechsel schon geführt hat (je Seitenaufruf einmal)
|
||
|
||
/** Setzt die Merker des Moduls zurück (nur für Tests). */
|
||
export function vergissMerker() {
|
||
reiterGewaehlt = false
|
||
vorgabe = null
|
||
wechselErledigt.clear()
|
||
}
|
||
|
||
const BILD = /^\/neu\/bilder\/[a-z0-9-]+\.webp$/
|
||
|
||
/** Stück für die Pille einer Zeile. */
|
||
const pille = (p) => h('span', { class: `kn-fn-pille kn-fn-pille--${p.ton}` }, p.text)
|
||
|
||
export async function zeigeFunktionen(wurzel, { route, me, nav } = {}, abh = {}) {
|
||
const A = {
|
||
api: abh.api ?? apiStandard,
|
||
geheZu: abh.geheZu ?? geheZuStandard,
|
||
oeffneOverlay: abh.oeffneOverlay ?? oeffneOverlayStandard,
|
||
beendeOverlay: abh.beendeOverlay ?? beendeOverlayStandard,
|
||
speicher: abh.speicher,
|
||
jetzt: abh.jetzt ?? (() => new Date()),
|
||
}
|
||
const nr = (laeufe.get(wurzel) ?? 0) + 1
|
||
laeufe.set(wurzel, nr)
|
||
const aktuell = () => laeufe.get(wurzel) === nr
|
||
const nochmal = () => zeigeFunktionen(wurzel, { route, me, nav }, abh)
|
||
const reiterWahl = route?.reiter === 'entdecken' ? 'entdecken' : 'meine'
|
||
const ausdruecklich = reiterGewaehlt
|
||
reiterGewaehlt = false
|
||
const mitgebracht = vorgabe && vorgabe.wurzel === wurzel && vorgabe.bis > Date.now() ? vorgabe : null
|
||
vorgabe = null
|
||
|
||
let daten
|
||
let offen
|
||
const frisch = !mitgebracht
|
||
if (mitgebracht) {
|
||
({ daten, offen } = mitgebracht)
|
||
} else {
|
||
leeren(wurzel).append(h('p', { class: 'kn-leer' }, 'Wird geladen …'))
|
||
try {
|
||
[daten, offen] = await Promise.all([A.api('/api/funktionen'), ladeOffeneRechnungen(A.api)])
|
||
} catch {
|
||
if (!aktuell()) return
|
||
leeren(wurzel).append(
|
||
h('p', { class: 'kn-fehler', role: 'alert' }, 'Die Funktionen konnten gerade nicht geladen werden.'),
|
||
h('button', { type: 'button', class: 'kn-fn-knopf kn-fn-knopf--zweit', 'data-aktion': 'nochmal', on: { click: () => nochmal() } }, 'Nochmal versuchen'))
|
||
return
|
||
}
|
||
if (!aktuell()) return
|
||
}
|
||
if (daten?.modell !== 'abo') {
|
||
leeren(wurzel).append(h('p', { class: 'kn-fehler', role: 'alert' }, 'Die Funktionen sind gerade nicht verfügbar. Lade die Seite neu.'))
|
||
return
|
||
}
|
||
|
||
const kundenart = me?.customer?.kundenartWirksam ?? daten.kundenart
|
||
const viewAs = me?.viewAs === true
|
||
const zeilen = meineZeilen(daten)
|
||
const projektVon = (projectId) => daten.projects.find((p) => p?.projectId === projectId)
|
||
const weiter = (weg, ziel) => navigiere(weg, ziel, { geheZu: A.geheZu, speicher: A.speicher })
|
||
let gezeigteMeldung = ''
|
||
|
||
/** Die Bestätigungsseite für diese Funktion und Website als Overlay: die Zurück-Geste und „Abbrechen“ bringen die Liste ohne neuen Abruf zurück. */
|
||
function oeffneSeite(key, projectId) {
|
||
A.oeffneOverlay({ schliesse: () => { if (aktuell()) zeichneListe() } })
|
||
renderEinschalten(wurzel, {
|
||
key, projectId, daten, kundenartWirksam: kundenart, viewAs, jetzt: A.jetzt, api: A.api, geheZu: A.geheZu, speicher: A.speicher,
|
||
bei: { abbrechen: () => A.beendeOverlay() },
|
||
})
|
||
}
|
||
|
||
/** „Einschalten“ für eine Website: die Vorprüfung wählt den Ort; eine Meldung steht im Eintrag. */
|
||
function einschaltTipp(eintrag, kandidat, meldungEl) {
|
||
const weg = einschaltWeg({ eintrag, projekt: projektVon(kandidat.projectId), abo: daten.abo, kundenart })
|
||
if (weg.art === 'meldung') { meldungEl.textContent = weg.text; return }
|
||
meldungEl.textContent = ''
|
||
if (weg.art === 'seite') oeffneSeite(eintrag.key, kandidat.projectId)
|
||
else weiter(weg, { key: eintrag.key, projectId: kandidat.projectId })
|
||
}
|
||
|
||
/**
|
||
* Rückkehr vom Wechsel ins Wartungs-Abo (V11): hat der Kunde den Wechsel für eine Funktion begonnen und hat die Website sie noch nicht,
|
||
* öffnet die Ansicht ihren Ablauf, je Seitenaufruf einmal. Jeder Fehler und jede ungültige Angabe wird ignoriert.
|
||
* @returns {Promise<boolean>} true, wenn ein Ablauf geöffnet wurde
|
||
*/
|
||
async function wechselRueckkehr() {
|
||
if (daten.abo?.produkt !== 'wartung' || daten.abo.laeuft !== true) return false
|
||
let abo
|
||
try { abo = await A.api('/api/abo') } catch { return false }
|
||
if (!aktuell()) return false
|
||
const f = abo?.subscription?.wechselFunktion
|
||
if (!f || typeof f.key !== 'string' || typeof f.projectId !== 'string') return false
|
||
const merker = `${f.key}:${f.projectId}`
|
||
const eintrag = verfuegbare(daten).find((e) => e.key === f.key)
|
||
const projekt = projektVon(f.projectId)
|
||
if (!eintrag || !projekt || wechselErledigt.has(merker) || istInMeine(eintrag, projekt)) return false
|
||
const weg = einschaltWeg({ eintrag, projekt, abo: daten.abo, kundenart })
|
||
if (weg.art !== 'seite' && weg.art !== 'etage') return false
|
||
wechselErledigt.add(merker)
|
||
if (weg.art === 'seite') oeffneSeite(f.key, f.projectId)
|
||
else weiter(weg, { key: f.key, projectId: f.projectId })
|
||
return true
|
||
}
|
||
|
||
if (frisch && await wechselRueckkehr()) return
|
||
if (!aktuell()) return
|
||
if (reiterWahl === 'meine' && zeilen.length === 0 && !ausdruecklich) {
|
||
vorgabe = { wurzel, daten, offen, bis: Date.now() + 5000 }
|
||
A.geheZu('#entdecken', { ersetzen: true })
|
||
return
|
||
}
|
||
|
||
function meineListe() {
|
||
if (zeilen.length === 0) {
|
||
return [
|
||
h('p', { class: 'kn-leer' }, 'Du hast noch keine Funktion eingeschaltet.'),
|
||
h('button', { type: 'button', class: 'kn-fn-knopf', 'data-aktion': 'entdecken', on: { click: () => wechsleReiter('entdecken') } }, 'Entdecken'),
|
||
]
|
||
}
|
||
return h('ul', { class: 'kn-fn-liste' }, zeilen.map((z) => h('li', { class: 'kn-fn-zeile', 'data-fn': z.key, 'data-projekt': z.projectId },
|
||
h('button', {
|
||
type: 'button',
|
||
class: 'kn-fn-zeile-knopf',
|
||
on: {
|
||
click: () => {
|
||
const weg = zeilenWeg(z)
|
||
if (weg.art === 'seite') oeffneSeite(z.key, z.projectId)
|
||
else weiter(weg, { key: z.key, projectId: z.projectId })
|
||
},
|
||
},
|
||
},
|
||
h('span', { class: 'kn-fn-text' }, h('span', { class: 'kn-fn-name' }, z.name), h('span', { class: 'kn-fn-zeile2' }, z.zeile2)),
|
||
h('span', { class: 'kn-fn-rechts' }, z.pille ? pille(z.pille) : null, h('span', { class: 'kn-fn-pfeil', 'aria-hidden': 'true' }, '›'))))))
|
||
}
|
||
|
||
function eintragKarte({ eintrag, kandidaten }) {
|
||
const nurBetriebHinweis = eintrag.nurBetrieb === true && kundenart !== 'unternehmen'
|
||
const meldungEl = h('p', { class: 'kn-fn-meldung', role: 'alert' })
|
||
let wahl = null
|
||
const knopf = h('button', {
|
||
type: 'button',
|
||
class: 'kn-fn-knopf',
|
||
'data-aktion': 'einschalten',
|
||
on: {
|
||
click: () => {
|
||
if (kandidaten.length === 1) { einschaltTipp(eintrag, kandidaten[0], meldungEl); return }
|
||
if (wahl) { wahl.remove(); wahl = null; return }
|
||
wahl = h('div', { class: 'kn-fn-wahl', role: 'group', 'aria-label': 'Für welche Website?' },
|
||
kandidaten.map((k) => h('button', { type: 'button', class: 'kn-fn-knopf kn-fn-knopf--zweit', on: { click: () => einschaltTipp(eintrag, k, meldungEl) } }, k.projektName)))
|
||
knopf.parentNode.insertBefore(wahl, meldungEl)
|
||
},
|
||
},
|
||
}, 'Einschalten')
|
||
if (viewAs) knopf.disabled = true // Mitarbeiter-Kundenansicht: gesperrt statt erst nach dem Tipp ein 403-Text
|
||
const bild = typeof eintrag.bild === 'string' && BILD.test(eintrag.bild)
|
||
? h('img', { class: 'kn-fn-bild', src: eintrag.bild, alt: `Beispiel: ${eintrag.name}`, width: 960, height: 600, loading: 'lazy', on: { error: (ev) => ev.target.remove() } })
|
||
: null
|
||
return h('section', { class: 'kn-fn-eintrag', 'data-fn': eintrag.key },
|
||
bild,
|
||
h('div', { class: 'kn-fn-eintrag-text' },
|
||
h('h3', null, String(eintrag.name ?? '')),
|
||
h('p', { class: 'kn-fn-beschreibung' }, String(eintrag.beschreibung ?? '')),
|
||
h('p', { class: 'kn-fn-enthalten' }, eintrag.enthaltenIn === 'hosting' ? 'Im Hosting-Abo enthalten' : 'Im Wartungs-Abo enthalten'),
|
||
nurBetriebHinweis ? h('p', { class: 'kn-fn-hinweis' }, TEXTE.nurBetrieb(String(eintrag.name ?? ''))) : (kandidaten.length ? knopf : null),
|
||
!nurBetriebHinweis && kandidaten.length && viewAs ? h('p', { class: 'kn-fn-info' }, TEXTE.kundenansichtKnopf) : null,
|
||
meldungEl))
|
||
}
|
||
|
||
function entdeckenListe() {
|
||
const eintraege = entdeckenEintraege(daten)
|
||
if (eintraege.length === 0) return h('p', { class: 'kn-leer' }, 'Du hast schon alles eingeschaltet, was es gibt.')
|
||
const ohneWebsite = daten.projects.length === 0
|
||
return [
|
||
ohneWebsite ? h('p', { class: 'kn-fn-info' }, 'Sobald deine Website angelegt ist, kannst du Funktionen einschalten.') : null,
|
||
...eintraege.map(eintragKarte),
|
||
]
|
||
}
|
||
|
||
/** Reiterwechsel: Meine oder Entdecken; die geladenen Antworten bleiben für den folgenden Aufruf des Routers. */
|
||
function wechsleReiter(key) {
|
||
if (key === 'meine') reiterGewaehlt = true
|
||
vorgabe = { wurzel, daten, offen, bis: Date.now() + 5000 }
|
||
A.geheZu(key === 'meine' ? '#funktionen' : '#entdecken', { ersetzen: true })
|
||
}
|
||
|
||
/** Reiter, Rechnungszeile und Liste aus den geladenen Antworten zeichnen (auch nach dem Schließen der Bestätigung, ohne Abruf). */
|
||
function zeichneListe() {
|
||
const reiter = baueReiter({ eintraege: [{ key: 'meine', label: 'Meine' }, { key: 'entdecken', label: 'Entdecken' }], aktiv: reiterWahl, art: 'unterstrich', ariaLabel: 'Funktionen', bei: wechsleReiter })
|
||
leeren(wurzel).append(
|
||
...[
|
||
gezeigteMeldung ? h('p', { class: 'kn-meldung kn-meldung-ok', role: 'status' }, gezeigteMeldung) : null,
|
||
rechnungOffenZeile(offen),
|
||
reiter,
|
||
h('div', { class: 'kn-reiter-inhalt' }, reiterWahl === 'meine' ? meineListe() : entdeckenListe()),
|
||
].filter(Boolean))
|
||
}
|
||
|
||
gezeigteMeldung = nimmMeldung() // genau einmal; nach dem Schließen der Bestätigung bleibt sie stehen
|
||
zeichneListe()
|
||
}
|