Terminplaner TP2: Ansicht nach Zustand und Rueckkehr von Stripe

Die Ansicht #termine folgt dem Zustand aus GET /api/termine/einstellungen:
off mit laufendem Abo zeigt den Fragebogen samt Bestellseite, ohne Abo den
Hinweis des Servers (termine.hinweis, R111) und bei kein_abo den Link zu
Vertraege aus einer gemeinsamen Quelle (vertraege-link.js); ordered und setup
zeigen die Statuskarte und die Reiter Leistungen, Team & Plaetze und
Einstellungen nur zum Lesen (ohne Reiter Termine); active und disabled
bleiben wie bisher. Rueckkehr von Stripe: #termine?bestellt=1&projekt=<id>
und #termine?projekt=<id> waehlen das Projekt, "Danke!" nur mit bestellt=1,
die Adresse wird einmal gelesen und bereinigt.

R110: GET /einstellungen liefert bestellung und offen auch in ordered (ohne
Abo-Punkt, abo bleibt null); scheitert "Zahlung fortsetzen", zeichnet die
Huelle Liste und Daten neu (bei 409 zustimmung_fehlt mit dem
Bestellformular, sonst nach dem neuen Zustand, die Meldung bleibt am Knopf,
solange die Bestellung offen ist).

Dazu: "null" als Text im lesbaren Reiter Leistungen entfernt, Export und
Status der Einstellungen erst ab der Freischaltung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 23:10:54 +00:00
1 parent 52456dac76
commit 87d267e51a
13 files changed
+1077 -71

No files matched your search

+201 -5
View File
@@ -68,6 +68,21 @@ function portalHilfe(p, json) {
return json({})
}
// Branchen und Bestellsicht, wie GET /api/termine/einstellungen sie liefert (Server: BRANCHEN, bestellungAnsicht); feste Texte der Fixture
const FX_BRANCHEN = [
{ key: 'salon', name: 'Friseur und Salon', gesundheit: false },
{ key: 'kosmetik', name: 'Kosmetik und Wellness', gesundheit: false },
{ key: 'gesundheit', name: 'Gesundheitsbereich', gesundheit: true },
]
const FX_BESTELLUNG = {
preis: '149 €',
preisHinweis: 'ohne Umsatzsteuer gemäß § 19 UStG',
einrichtung: 'meist innerhalb von 1–2 Wochen',
consent: { version: 'fx-1', knopf: 'Zahlungspflichtig bestellen · 149 €', items: [{ id: 'verfuegbar', text: 'Ich bestelle den Terminplaner verbindlich.', haken: true }] },
dokumente: [],
belehrung: 'Widerrufsbelehrung der Fixture. Du hast das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.',
}
/** Fixture: Daten, die page.route statt der Schnittstellen /api/funktionen und /api/termine liefert. */
function neueFixture() {
const fx = {
@@ -82,14 +97,25 @@ function neueFixture() {
{ key: 'beratung', name: 'Beratung und Coaching', beschreibung: 'Gespräche vor Ort oder online.' },
],
fehlt: [],
fragebogen: { schema: 1, branche: 'salon', bestaetigt: [], bundeslandGewaehlt: false, preiseGeprueft: false, einbau: { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: '' } },
offen: [],
checkout: { status: 200, body: { url: 'https://checkout.stripe.test/c/pay/cs_test_fx' } }, // Antwort auf POST /api/funktionen/termine/checkout
anfragenOffen: 0,
buchungen: [],
aufrufe: [],
}
// GET /api/termine/einstellungen. off: Entwurf mit Abo (abo), offen und Bestellsicht; ordered: Bestellsicht und offen, aber kein abo (R110)
fx.daten = (state = fx.projekte[0].features.termine.state) => ({
state, aktivSeit: '2026-10-01T09:00:00.000Z', einstellung: fx.einstellung, version: fx.einstellung ? 3 : 0, vorlagen: fx.vorlagen, feeds: {}, siteKey: KEY,
einbau: { snippet: '' }, anfragenOffen: fx.buchungen.filter((b) => b.status === 'angefragt').length || fx.anfragenOffen, fehlt: fx.fehlt,
abgeschaltet: state === 'disabled' ? fx.abgeschaltet ?? { grund: null, seit: null, loeschungAm: null } : null,
branchen: FX_BRANCHEN,
fragebogen: fx.fragebogen,
kundenansicht: false,
...(fx.einstellung ? {} : { betriebVorschlag: { name: 'Salon Müller', adresse: 'Hauptstr. 1, 67722 Winnweiler', email: 'info@salon-mueller.example', telefon: '', websiteUrl: '' } }),
abo: state === 'off' ? (fx.bestellbar === true ? { ok: true } : fx.bestellbar === false ? { ok: false, grund: fx.aboGrund } : null) : null,
offen: state === 'off' || state === 'ordered' ? fx.offen : null,
bestellung: state === 'off' || state === 'ordered' ? FX_BESTELLUNG : null,
})
return fx
}
@@ -152,10 +178,14 @@ async function fixtureAntwort(fx, route, req, url, json) {
: { verfuegbar: false }
return json({ projects: fx.projekte, termine })
}
if (p === '/api/funktionen/termine/checkout' && m === 'POST') {
fx.aufrufe.push(['checkout', req.postDataJSON()])
return json(fx.checkout.body, fx.checkout.status)
}
if (p === '/api/termine/einstellungen' && m === 'GET') {
const projekt = q.get('projectId')
if (fx.verzoegerung?.[projekt]) await new Promise((fertig) => { setTimeout(fertig, fx.verzoegerung[projekt]) })
const daten = fx.daten()
const daten = fx.daten(fx.projekte.find((p) => p.projectId === projekt)?.features?.termine?.state)
if (fx.ohneEinstellungFuer?.includes(projekt)) Object.assign(daten, { einstellung: null, version: 0 })
return json(daten)
}
@@ -268,7 +298,7 @@ const formular = (page, name) => page.getByRole('form', { name })
const fehlerAm = (loc, pfad) => loc.locator(`[data-fehler="${pfad}"]`)
const warteAnsicht = async (page) => {
await page.locator('#view-termine.active').waitFor()
await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info').first().waitFor()
await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info, #termine-body .tm-fragebogen, #termine-body .tm-statuskarte').first().waitFor()
}
// ---- Gaeste ueber die oeffentliche Schnittstelle buchen (echte Pruefungen): Link-Token aus der Mail
@@ -448,11 +478,12 @@ async function pruefungen() {
await warteAnsicht(page)
await page.locator('.tm-reiterleiste').waitFor()
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl: die Website der Kachel')
// andere Website ohne Terminplaner: Hinweis statt Reiter
// andere Website, Terminplaner nicht bestellt (Aus, das Abo läuft): der Fragebogen statt der Reiter (C10b ersetzt den Hinweis „noch nicht freigeschaltet“)
await page.locator('#termine-project').selectOption('p1')
await page.getByText('noch nicht freigeschaltet').waitFor()
await page.locator('#termine-body .tm-fragebogen').waitFor()
eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter')
await knopf(page.locator('#termine-body'), 'Chat öffnen').waitFor()
assert(!(await textVon(page.locator('#termine-body'))).includes('noch nicht freigeschaltet'), 'der alte Text ist weg')
eq(await page.locator('#termine-body [data-view="chat"]').count(), 0, 'kein Chat-Knopf der alten Ansicht')
await shot(page, 'projektwahl')
await page.locator('#termine-project').selectOption('p2')
await page.locator('.tm-reiterleiste').waitFor()
@@ -473,6 +504,171 @@ async function pruefungen() {
})(),
})
// ------------------------------------------------------------ Ansicht nach Zustand (TP2 C10b)
/** Fixture mit einer Website im Zustand state (Eintrag der Liste wie GET /api/funktionen ihn liefert). */
const imZustand = (state, extra = {}) => {
const fx = neueFixture()
fx.projekte[0].features.termine = { state, ...extra }
return fx
}
const GEORDERT = { orderedAt: '2026-10-04T08:00:00.000Z', ticketWoid: null, invoiceUrl: null }
await check('fx_ansicht_aus_fragebogen_und_hinweis', async (page) => {
const fx = page.fx
// das Abo läuft: der Fragebogen mit Fortschrittsleiste, keine Reiter
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('#termine-body nav.tm-fortschritt').waitFor()
eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter')
eq(await page.locator('[data-hinweis="abo"]').count(), 0, 'kein Hinweis')
eq(await page.locator('li.tm-schritt[aria-current="step"]').getAttribute('data-schritt'), 'branche', 'Start beim ersten offenen Schritt')
await shot(page, 'ansicht-fragebogen')
// ohne Abo: nur der Hinweis des Servers, bei kein_abo mit dem Link zu den Verträgen; kein Fragebogen
fx.bestellbar = false
for (const [grund, mitLink] of [['kein_abo', true], ['gekuendigt', false], ['nicht_aktiv', false], ['unbekannt', false]]) {
fx.aboGrund = grund === 'unbekannt' ? null : grund
fx.bestellbar = grund === 'unbekannt' ? null : false
await page.reload()
await page.locator('[data-hinweis="abo"]').waitFor()
const hinweis = page.locator('[data-hinweis="abo"]')
assert((await textVon(hinweis)).includes(FX_ABO_HINWEISE[grund]), `${grund}: Text des Servers in ${await textVon(hinweis)}`)
eq(await hinweis.locator('[data-view="vertraege"]').count(), mitLink ? 1 : 0, `${grund}: Link zu Verträge`)
eq(await page.locator('#termine-body nav.tm-fortschritt').count(), 0, `${grund}: kein Fragebogen`)
eq(await page.locator('#termine-body input').count(), 0, `${grund}: keine Felder`)
eq(await page.locator('.tm-reiterleiste').count(), 0, `${grund}: keine Reiter`)
if (grund === 'kein_abo') await shot(page, 'ansicht-ohne-abo')
}
// der Link führt zu den Verträgen
fx.bestellbar = false
fx.aboGrund = 'kein_abo'
await page.reload()
await knopf(page.locator('[data-hinweis="abo"]'), 'Zu Verträge').click()
await page.locator('#view-vertraege.active').waitFor()
}, { fixture: imZustand('off') })
for (const [state, pille, extra] of [
['ordered', 'Zahlung offen', GEORDERT],
['setup', 'In Einrichtung', { ...GEORDERT, ticketWoid: 100342, invoiceUrl: 'https://invoice.stripe.test/i/in_1' }],
]) {
await check(`fx_ansicht_${state}`, async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
const karte = page.locator('.tm-statuskarte')
await karte.waitFor()
eq(await textVon(karte.locator('.tm-pille')), pille, 'Pille')
assert((await textVon(karte)).includes('Bestellt am'), 'Bestelldatum')
if (state === 'setup') assert((await textVon(karte)).includes('#100342'), 'Ticketnummer')
else eq(await karte.locator('[data-aktion="fortsetzen"]').count(), 1, 'Zahlung fortsetzen')
eq(await page.getByRole('tab').allInnerTexts(), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'drei Reiter, keiner für Termine')
eq(await page.locator('#tm-tab-termine').count(), 0, 'kein Reiter Termine')
eq(await page.locator('.tm-hinweise .tm-box').count(), 0, 'weder Banner der Abschaltung noch „Noch nicht buchbar“')
await shot(page, `ansicht-${state}`)
// schreibgeschützt: Leistungen ohne „+ Leistung anlegen“, Einstellungen gesperrt, ohne Speichern, ohne „Aktiv“-Status und Export
await page.locator('#tm-tab-leistungen').click()
await page.locator('#tm-panel .tm-tab').waitFor()
eq(await page.locator('[data-werkzeug="leistung-neu"]').count(), 0, 'Leistungen: nichts zum Anlegen')
assert(!(await textVon(page.locator('#tm-panel'))).split('\n').some((zeile) => zeile.trim() === 'null'), 'Leistungen: kein Text „null“')
await page.locator('#tm-tab-einstellungen').click()
await page.locator('#tm-panel .tm-tab').waitFor()
eq(await page.locator('#tm-panel fieldset[disabled]').count() > 0, true, 'Einstellungen gesperrt')
eq(await page.locator('#tm-panel button[type="submit"]').count(), 0, 'Einstellungen: kein Speichern')
eq(await page.locator('#tm-panel .tm-status').count(), 0, 'kein „Aktiv“-Status vor der Freischaltung')
eq(await page.locator('#tm-panel a[download]').count(), 0, 'kein Export vor der Freischaltung')
eq(page.fx.aufrufe.filter((a) => a[0] === 'liste').length, 0, 'der Reiter Termine wird nicht gelesen')
}, { fixture: imZustand(state, extra) })
}
await check('fx_ansicht_bestellt_390', async (page) => {
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('.tm-statuskarte').waitFor()
eq(await passtInBreite(page), true, 'kein Streifen rechts')
const kleine = await page.evaluate(() => [...document.querySelectorAll('#view-termine button')]
.filter((e) => e.offsetParent !== null && e.getBoundingClientRect().height < 43.5).map((e) => `${e.textContent.trim().slice(0, 24)} ${Math.round(e.getBoundingClientRect().height)}`))
eq(kleine, [], 'Knöpfe mindestens 44 px hoch')
await shot(page, '390-ansicht-bestellt')
}, { fixture: imZustand('ordered', GEORDERT), context: { viewport: { width: 390, height: 844 } } })
await check('fx_rueckkehr_zahlung', async (page) => {
await oeffne(page, '#termine?bestellt=1&projekt=p2')
await warteAnsicht(page)
await page.locator('.tm-statuskarte [data-danke]').waitFor()
eq(await textVon(page.locator('[data-danke]')), 'Danke! Sobald die Zahlung da ist, legen wir los.', 'Danke')
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Website der Zahlung')
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt')
await shot(page, 'rueckkehr-zahlung')
// neu geladen: kein Danke mehr, die Auswahl bleibt nicht (Seite neu), aber die Adresse ist sauber
await page.reload()
await warteAnsicht(page)
eq(await page.locator('[data-danke]').count(), 0, 'kein Danke nach dem Neuladen')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte = [
{ projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
{ projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'ordered', ...GEORDERT } } },
]
return fx
})(),
})
await check('fx_rueckkehr_abbruch', async (page) => {
await oeffne(page, '#termine?projekt=p2')
await warteAnsicht(page)
await page.locator('.tm-statuskarte').waitFor()
eq(await page.locator('[data-danke]').count(), 0, 'kein Danke nach dem Abbruch')
eq(await page.locator('#termine-project').inputValue(), 'p2', 'Website der Bestellung')
eq(await page.locator('.tm-statuskarte [data-aktion="fortsetzen"]').count(), 1, 'Zahlung fortsetzen')
eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt')
}, {
fixture: (() => {
const fx = neueFixture()
fx.projekte = [
{ projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } },
{ projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'ordered', ...GEORDERT } } },
]
return fx
})(),
})
await check('fx_zahlung_fortsetzen_zustimmung_fehlt', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('.tm-statuskarte').waitFor()
await knopf(page.locator('.tm-statuskarte'), 'Zahlung fortsetzen').click()
// die Karte weicht dem Bestellformular (R110), die Reiter bleiben
await page.locator('.tm-bestellen').waitFor()
eq(await page.locator('.tm-statuskarte').count(), 0, 'keine Karte mehr')
eq(fx.aufrufe.filter((a) => a[0] === 'checkout').map((a) => a[1]), [{ projectId: 'p1', fortsetzen: true }], 'Zahlung fortsetzen ohne Zustimmung')
assert((await textVon(meldung(page))).includes('Bitte stimme der Erklärung zu'), 'Meldung der Hülle')
const bestellen = page.locator('[data-aktion="bestellen"]')
eq(await bestellen.isDisabled(), true, 'gesperrt bis zum Haken')
await page.locator('.tm-bestellen input[type="checkbox"]').check()
eq(await bestellen.isDisabled(), false, 'frei nach dem Haken')
eq(await page.getByRole('tab').allInnerTexts(), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'Reiter bleiben')
await shot(page, 'zahlung-fortsetzen-bestellformular')
}, {
fixture: Object.assign(imZustand('ordered', GEORDERT), { checkout: { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }),
})
await check('fx_zahlung_fortsetzen_inzwischen_bezahlt', async (page) => {
const fx = page.fx
await oeffne(page, '#termine')
await warteAnsicht(page)
await page.locator('.tm-statuskarte').waitFor()
eq(await textVon(page.locator('.tm-statuskarte .tm-pille')), 'Zahlung offen', 'zuerst offen')
// inzwischen kam die Zahlung an (Webhook): der Server kennt keine offene Bestellung mehr (400)
fx.projekte[0].features.termine = { state: 'setup', ...GEORDERT, ticketWoid: 100343 }
await knopf(page.locator('.tm-statuskarte'), 'Zahlung fortsetzen').click()
await page.locator('.tm-statuskarte .tm-pille', { hasText: 'In Einrichtung' }).waitFor()
assert((await textVon(page.locator('.tm-statuskarte'))).includes('#100343'), 'Ticketnummer der frischen Liste')
eq(await page.locator('[data-aktion="fortsetzen"]').count(), 0, 'kein Knopf mehr')
eq(await page.locator('.tm-bestellen').count(), 0, 'kein Bestellformular')
}, {
fixture: Object.assign(imZustand('ordered', GEORDERT), { checkout: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }),
})
// ------------------------------------------------------------ Reiter-Geruest
await check('fx_reiter_geruest', async (page) => {
await oeffne(page, '#termine')