diff --git a/public/neu/ansicht-vertraege.js b/public/neu/ansicht-vertraege.js index db9335a..558f477 100644 --- a/public/neu/ansicht-vertraege.js +++ b/public/neu/ansicht-vertraege.js @@ -1,7 +1,114 @@ -/* Kundenansicht neu: Platzhalter, den das nächste Paket füllt (Stub aus P1). */ +/* Kundenansicht neu (P2): Bereich Verträge mit den Reitern Rechnungen, Abo, Stunden und E-Mail. Der Rahmen lädt Abo und Rechnungen + parallel (jede Quelle mit eigenem Fehlerweg), wählt den Reiter allein aus route.reiter und gibt die schon geladenen Antworten an + die Reiter-Inhalte weiter. Bestellen, Wechsel und Gutschrift sind Etagen, die die Hülle direkt ruft. */ -import { h } from './dom.js' +import { api } from './api.js' +import { h, leeren } from './dom.js' +import { baueReiter } from './reiter.js' +import { aktuelleRoute } from './router.js' +import { K, ersetze, ladeAbo, wechselFunktionAus, navigiere, nimmHinweis, ABO_NICHT_GELADEN } from './ansicht-hilfen.js' +import { offeneRechnungen } from './rechnung-offen.js' +import { zeigeRechnungen } from './vertraege-rechnungen.js' +import { zeigeAbo } from './vertraege-abo.js' +import { zeigeStunden } from './ansicht-stunden.js' +import { zeigeSpeicher } from './ansicht-speicher.js' -export async function zeigeVertraege(wurzel, { route, me, nav }) { - wurzel.replaceChildren(h('p', { class: 'kn-leer' }, 'Verträge (folgt)')) +/** Nach ?abo=success: so oft und in diesem Abstand wird auf ein laufendes Abo gewartet (Tests verkürzen es). */ +export const AUF_ABO_WARTEN = { versuche: 10, pauseMs: 2000 } + +const LABEL = { rechnungen: 'Rechnungen', abo: 'Abo', stunden: 'Stunden', email: 'E-Mail' } +// Reiterklicks navigieren mit den ausdrücklichen Hashes (P1 schreibt #wartung und #abo kanonisch um) +const HASH = { rechnungen: '#rechnungen', abo: '#abo', stunden: '#stunden', email: '#speicher' } +const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs +let reiterWechsel = null // Reiterklick mit laufendem Router: der nächste Aufruf nimmt die schon geladenen Antworten + +/** Sichtbare Reiter: ohne laufendes Abo Rechnungen und Abo; Hosting dazu E-Mail; Wartung dazu Stunden und E-Mail. */ +export function sichtbareReiter(daten) { + const sub = daten?.subscription + const reiter = ['rechnungen', 'abo'] + if (!sub?.active) return reiter + if (sub.productKey === 'hosting') return [...reiter, 'email'] + return [...reiter, 'stunden', 'email'] +} + +/** Reiterwahl allein aus route.reiter (routeReiter) und den Daten, nie aus der Adressleiste. Nur der Bereichs-Einstieg (ohne Reiter) springt auf Rechnungen. */ +export function waehleReiter({ routeReiter, sichtbar, offeneRechnungen: offen }) { + if (routeReiter) return sichtbar.includes(routeReiter) ? routeReiter : 'abo' + return offen > 0 ? 'rechnungen' : 'abo' +} + +const pause = (ms) => new Promise((r) => setTimeout(r, ms)) +const laden = (api_) => api_('/api/invoices').catch(() => null) + +export async function zeigeVertraege(wurzel, { route, me, nav } = {}) { + const nr = (laeufe.get(wurzel) ?? 0) + 1 + laeufe.set(wurzel, nr) + const aktuell = () => laeufe.get(wurzel) === nr + const vorgabe = reiterWechsel && reiterWechsel.wurzel === wurzel && reiterWechsel.bis > Date.now() ? reiterWechsel : null + reiterWechsel = null + const suche = new URLSearchParams(globalThis.window?.location?.search ?? '') + const aboParam = suche.get('abo') + const nachStripe = aboParam === 'success' || aboParam === 'cancel' + + let daten + let rechnungen + if (vorgabe) { + ({ daten, rechnungen } = vorgabe) + } else { + leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …')) + ;[daten, rechnungen] = await Promise.all([ladeAbo(), laden(api)]) + if (!aktuell()) return + } + + // Rückkehr von Stripe: auf das laufende Abo warten (der Webhook kann noch unterwegs sein) + if (aboParam === 'success') { + for (let i = 0; i < AUF_ABO_WARTEN.versuche && !daten?.subscription?.active; i += 1) { + ersetze(wurzel, h('p', { class: K.meldung }, 'Vielen Dank! Dein Abo wird aktiviert …'), h('p', { class: 'kn-hinweis' }, 'Das dauert nur einen Moment.')) + await pause(AUF_ABO_WARTEN.pauseMs) + if (!aktuell()) return + daten = (await ladeAbo()) ?? daten + if (!aktuell()) return + } + } + if (nachStripe) window.history.replaceState(window.history.state, '', window.location.pathname + window.location.hash) + if (aboParam === 'success' && wechselFunktionAus(daten?.subscription)) { + navigiere('#funktionen', { ersetzen: true }) + return + } + + const sichtbar = sichtbareReiter(daten) + const offen = offeneRechnungen(rechnungen).length + const gewaehlt = waehleReiter({ routeReiter: route?.reiter, sichtbar, offeneRechnungen: offen }) + const stundenOhneWartung = route?.reiter === 'stunden' && !sichtbar.includes('stunden') + const hinweis = nimmHinweis() + + const inhalt = h('div', { class: 'kn-reiter-inhalt' }) + const zeichneReiter = (key) => { + leeren(inhalt) + if (key === 'rechnungen') return zeigeRechnungen(inhalt, { me, antwort: rechnungen ?? undefined }) + if (!daten) return Promise.resolve() + if (key === 'abo') { + if (stundenOhneWartung) inhalt.append(h('p', { class: 'kn-hinweis' }, 'Ein Stundenkonto gibt es im Wartungs-Abo.')) + const platz = h('div', { class: 'kn-reiter-abo' }) + inhalt.append(platz) + return zeigeAbo(platz, { daten, me }) + } + if (key === 'stunden') return zeigeStunden(inhalt, { route, me, nav, daten }) + return zeigeSpeicher(inhalt, { route, me, nav, daten }) + } + + const bei = (key) => { + if (aktuelleRoute() !== null) reiterWechsel = { wurzel, daten, rechnungen, bis: Date.now() + 5000 } // der Router ruft uns gleich neu + else zeichneReiter(key) + navigiere(HASH[key], { ersetzen: true }) + } + const reiterZeile = baueReiter({ eintraege: sichtbar.map((key) => ({ key, label: LABEL[key] })), aktiv: gewaehlt, art: 'unterstrich', ariaLabel: 'Verträge', bei }) + + ersetze(wurzel, + hinweis ? h('p', { class: `${K.meldung} kn-meldung-ok`, role: 'status' }, hinweis) : null, + daten ? null : h('p', { class: K.fehler }, ABO_NICHT_GELADEN), + reiterZeile, + inhalt, + h('p', { class: 'kn-kuendigen' }, h('a', { href: '/kuendigen.html' }, 'Verträge hier kündigen'))) + await zeichneReiter(gewaehlt) } diff --git a/test/knAnsichtVertraege.test.js b/test/knAnsichtVertraege.test.js new file mode 100644 index 0000000..9a3aefc --- /dev/null +++ b/test/knAnsichtVertraege.test.js @@ -0,0 +1,79 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { zeigeVertraege, sichtbareReiter, waehleReiter, AUF_ABO_WARTEN } from '../public/neu/ansicht-vertraege.js' +import { merkeHinweis } from '../public/neu/ansicht-hilfen.js' +import { routeAusHash } from '../public/neu/routen.js' +import { mit, me, abo, aboAntwort, rechnung, neueWurzel, LIMITS } from './support/knDaten.js' + +const VIER = ['rechnungen', 'abo', 'stunden', 'email'] +test('sichtbareReiter nach Abo', () => { + assert.deepEqual([null, aboAntwort(), aboAntwort({ subscription: abo({ active: false }) })].map((d) => sichtbareReiter(d)), [['rechnungen', 'abo'], ['rechnungen', 'abo'], ['rechnungen', 'abo']]) + assert.deepEqual(sichtbareReiter(aboAntwort({ subscription: abo({ produkt: 'hosting' }) })), ['rechnungen', 'abo', 'email']) + assert.deepEqual(sichtbareReiter(aboAntwort({ subscription: abo() })), VIER) +}) +test('Vorbedingung (VF8): der Bereichs-Hash hat keinen Reiter, die Reiter-Hashes haben einen', () => { + assert.equal(routeAusHash('#vertraege').reiter, undefined) + for (const [hash, reiter] of [['#abo', 'abo'], ['#wartung', 'abo'], ['#rechnungen', 'rechnungen'], ['#stunden', 'stunden'], ['#speicher', 'email']]) assert.equal(routeAusHash(hash).reiter, reiter, hash) +}) +test('waehleReiter: nur der Bereichs-Einstieg springt, jede ausdrückliche Wahl bleibt (Review Focus 2)', () => { + const w = (o) => waehleReiter({ routeReiter: undefined, sichtbar: VIER, offeneRechnungen: 1, ...o }) + assert.equal(w({}), 'rechnungen'); assert.equal(w({ offeneRechnungen: 0 }), 'abo') + assert.equal(w({ routeReiter: 'abo' }), 'abo') // #abo, #wartung und der Klick auf „Abo“ trotz offener Rechnung + assert.equal(w({ routeReiter: 'stunden' }), 'stunden'); assert.equal(w({ routeReiter: 'email' }), 'email'); assert.equal(w({ routeReiter: 'rechnungen', offeneRechnungen: 0 }), 'rechnungen') + assert.equal(w({ routeReiter: 'stunden', sichtbar: ['rechnungen', 'abo'] }), 'abo') +}) +const SERVER = { 'GET /api/abo': aboAntwort(), 'GET /api/invoices': { invoices: [rechnung({ balance: 50 })], incomplete: false }, 'GET /api/auftraege': { aktiv: false }, 'GET /api/abo/takeover-credits': { requests: [], limits: LIMITS } } +const zeige = async (fx, route = {}) => { const w = neueWurzel(fx); await zeigeVertraege(w, { route: { bereich: 'vertraege', query: '', ...route }, me: me(), nav: 'tab' }); await fx.ruhe(); return w } +const aktiv = (fx, w) => fx.finde(w, '[role="tab"][aria-selected="true"]').textContent +const WOHIN = (fx) => { fx.window.history.replaceState = (_s, _t, url) => { fx.window.location.hash = `#${String(url).split('#')[1] ?? ''}` }; fx.window.history.pushState = fx.window.history.replaceState } +test('Verträge: Reiter in fester Reihenfolge, Kündigen-Link unter jedem Reiter, ausdrückliche Reiterwahl bleibt trotz offener Rechnung', mit(SERVER, async (fx) => { + for (const [route, gewaehlt] of [[{ reiter: 'abo' }, 'Abo'], [{ reiter: 'rechnungen' }, 'Rechnungen'], [{}, 'Rechnungen']]) { // {} = Bereichs-Einstieg + const w = await zeige(fx, route) + assert.deepEqual(fx.alle(w, '[role="tab"]').map((t) => t.textContent), ['Rechnungen', 'Abo']) + assert.equal(aktiv(fx, w), gewaehlt) + assert.equal(fx.finde(w, 'a[href="/kuendigen.html"]').textContent, 'Verträge hier kündigen') + } +})) +test('Verträge: die Route entscheidet, die Adressleiste nicht (keine zweite Wahrheit)', mit(SERVER, async (fx) => { + fx.window.location.hash = '#vertraege'; assert.equal(aktiv(fx, await zeige(fx, { reiter: 'abo' })), 'Abo') // widerspricht der Route absichtlich + fx.window.location.hash = '#abo'; assert.equal(aktiv(fx, await zeige(fx, {})), 'Rechnungen') +})) +test('Reiterklick navigiert ausdrücklich und ersetzt: #rechnungen, #abo, #stunden, #speicher', mit({ ...SERVER, 'GET /api/abo': aboAntwort({ subscription: abo() }) }, async (fx) => { + WOHIN(fx); const w = await zeige(fx, { reiter: 'rechnungen' }) + for (const [label, hash] of [['Abo', '#abo'], ['Stunden', '#stunden'], ['E-Mail', '#speicher'], ['Rechnungen', '#rechnungen']]) { + await fx.klick(fx.finde(w, (e) => e.getAttribute?.('role') === 'tab' && e.textContent === label)); assert.equal(fx.window.location.hash, hash, label) + } +})) +test('Verträge: /api/abo fällt aus, Reiter, Rechnungen und Kündigen bleiben', mit({ ...SERVER, 'GET /api/abo': () => ({ status: 500, body: { error: 'x' } }) }, async (fx) => { + const w = await zeige(fx, { reiter: 'rechnungen' }) + assert.match(fx.text(w), /Deine Verträge konnten gerade nicht geladen werden\./); assert.ok(fx.finde(w, 'a[href="/kuendigen.html"]')); assert.ok(fx.finde(w, 'tbody tr')) +})) +test('route.reiter stunden ohne Wartung: Abo-Reiter mit Hinweis', mit({ ...SERVER, 'GET /api/abo': aboAntwort({ subscription: abo({ produkt: 'hosting' }) }) }, async (fx) => { + const w = await zeige(fx, { reiter: 'stunden' }) + assert.equal(aktiv(fx, w), 'Abo'); assert.match(fx.text(w), /Ein Stundenkonto gibt es im Wartungs-Abo\./) +})) +test('Hinweis aus einer Aktion steht genau einmal über den Reitern', mit(SERVER, async (fx) => { + merkeHinweis('Dein Wechsel ist abgeschlossen.'); assert.match(fx.text(await zeige(fx, { reiter: 'abo' })), /Dein Wechsel ist abgeschlossen\./) + assert.doesNotMatch(fx.text(await zeige(fx, { reiter: 'abo' })), /Dein Wechsel ist abgeschlossen\./) +})) +test('Rückkehr von Stripe wartet auf das Abo und entfernt ?abo= aus der Adresse, der Verlaufsstempel bleibt', mit({ ...SERVER, 'GET /api/abo': (() => { let n = 0; return () => ({ body: n++ < 2 ? aboAntwort() : aboAntwort({ subscription: abo() }) }) })() }, async (fx) => { + Object.assign(AUF_ABO_WARTEN, { versuche: 5, pauseMs: 0 }); fx.window.location.search = '?abo=success'; fx.window.location.hash = '#abo'; fx.window.history.state = { kn: { tiefe: 0 } } + let ersetzt = null; fx.window.history.replaceState = (s, _t, url) => { ersetzt = { s, url } } + const w = await zeige(fx, { reiter: 'abo' }) + assert.match(fx.text(w), /Mein Vertrag/); assert.deepEqual(ersetzt, { s: { kn: { tiefe: 0 } }, url: '/dashboard.html#abo' }); assert.ok(fx.fetchLog.filter((a) => a.url === '/api/abo').length >= 3) +})) +let letztes = null +test('Rückkehr von Stripe: mit zurückgekommener wechselFunktion geht es mit ersetzen zu #funktionen, ohne sie bleibt es in Verträge (E14, C5)', mit({ ...SERVER, 'GET /api/abo': () => ({ body: letztes }) }, async (fx) => { + Object.assign(AUF_ABO_WARTEN, { versuche: 5, pauseMs: 0 }); fx.window.location.search = '?abo=success'; WOHIN(fx) + letztes = aboAntwort({ subscription: abo({ wechselFunktion: { key: 'termine', projectId: 'p1' } }) }); fx.window.location.hash = '#vertraege' + await zeige(fx, {}); assert.equal(fx.window.location.hash, '#funktionen') + letztes = aboAntwort({ subscription: abo({ wechselFunktion: null }) }); fx.window.location.hash = '#vertraege' + await zeige(fx, {}); assert.equal(fx.window.location.hash, '#vertraege') +})) +test('Verträge: ?abo=cancel räumt die Adresse ohne zu warten; Ladezustand ist Text; Rechnungen fallen aus, Abo bleibt', mit({ ...SERVER, 'GET /api/invoices': () => ({ status: 500, body: { error: 'Rechnungen kaputt' } }) }, async (fx) => { + fx.window.location.search = '?abo=cancel'; let ersetzt = 0; fx.window.history.replaceState = () => { ersetzt += 1 } + const w = neueWurzel(fx); const lauf = zeigeVertraege(w, { route: { bereich: 'vertraege', reiter: 'rechnungen', query: '' }, me: me(), nav: 'tab' }) + assert.match(fx.text(w), /Wird geladen …/); await lauf; await fx.ruhe() + assert.equal(ersetzt, 1); assert.ok(!fx.fetchLog.some((a) => a.url === '/api/abo' && a.method !== 'GET')); assert.equal(fx.fetchLog.filter((a) => a.url === '/api/abo').length, 1) + assert.match(fx.text(w), /Rechnungen konnten nicht geladen werden: Rechnungen kaputt/) +}))