Terminplaner: im Abo-Modell Einschalten statt Bestellen, ohne Häkchen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
364ded1662
commit
b6f70a502a
3 files changed
+119
-7
No files matched your search
@@ -21,12 +21,15 @@ import { render as renderLeistungen } from './termine-tab-leistungen.js'
|
|||||||
import { wochenplanFeld } from './termine-tab-team.js'
|
import { wochenplanFeld } from './termine-tab-team.js'
|
||||||
import { abwesenheiten, sonderoeffnungen } from './termine-team-ausnahmen.js'
|
import { abwesenheiten, sonderoeffnungen } from './termine-team-ausnahmen.js'
|
||||||
import { betrieb, darstellung, erinnerung, gastFelder, rechtliches, regeln, storno, zahlung } from './termine-tab-einstellungen.js'
|
import { betrieb, darstellung, erinnerung, gastFelder, rechtliches, regeln, storno, zahlung } from './termine-tab-einstellungen.js'
|
||||||
import { renderBestellen } from './funktion-bestellen.js'
|
import { renderBestellen, renderEinschaltSeite } from './funktion-bestellen.js'
|
||||||
|
|
||||||
const BESTELLEN = 'bestellen'
|
const BESTELLEN = 'bestellen'
|
||||||
const TITEL = new Map([...FRAGEBOGEN_SCHRITTE, [BESTELLEN, 'Prüfen und bestellen']])
|
const TITEL = new Map([...FRAGEBOGEN_SCHRITTE, [BESTELLEN, 'Prüfen und bestellen']])
|
||||||
const AUFBEWAHRUNG = 'Wir speichern deinen Entwurf bis zu 90 Tage nach deiner letzten Änderung.'
|
const AUFBEWAHRUNG = 'Wir speichern deinen Entwurf bis zu 90 Tage nach deiner letzten Änderung.'
|
||||||
const KUNDENANSICHT = 'Du siehst den Fragebogen in der Kundenansicht. Angaben kannst du ändern, Schritte bestätigen und bestellen kann nur der Kunde selbst.'
|
const KUNDENANSICHT = 'Du siehst den Fragebogen in der Kundenansicht. Angaben kannst du ändern, Schritte bestätigen und bestellen kann nur der Kunde selbst.'
|
||||||
|
// Abo-Modell (ctx.s.modell === 'abo'): Einschalten statt Bestellen (Texte E1 und F1)
|
||||||
|
const TITEL_ABO = 'Prüfen und einschalten'
|
||||||
|
const KUNDENANSICHT_ABO = 'Du siehst den Fragebogen in der Kundenansicht. Angaben kannst du ändern, Schritte bestätigen und einschalten kann nur der Kunde selbst.'
|
||||||
const GESUNDHEIT_TEXT = 'Für den Gesundheitsbereich ist der Terminplaner nicht verfügbar. Schon der Name einer Leistung oder ein Terminwunsch kann etwas über die Gesundheit deiner Gäste verraten, '
|
const GESUNDHEIT_TEXT = 'Für den Gesundheitsbereich ist der Terminplaner nicht verfügbar. Schon der Name einer Leistung oder ein Terminwunsch kann etwas über die Gesundheit deiner Gäste verraten, '
|
||||||
+ 'und solche Angaben sind besonders geschützt. Gehört dein Angebot nicht zum Gesundheitsbereich, wähle die Branche, die am besten passt. Fragen dazu beantworten wir dir im Chat.'
|
+ 'und solche Angaben sind besonders geschützt. Gehört dein Angebot nicht zum Gesundheitsbereich, wähle die Branche, die am besten passt. Fragen dazu beantworten wir dir im Chat.'
|
||||||
const WECHSEL_TEXT = 'Das ersetzt deine Leistungen, Zeiten und Regeln durch die Vorschläge der neuen Branche. Alle Bestätigungen gehen zurück, du bestätigst die Schritte danach noch einmal. '
|
const WECHSEL_TEXT = 'Das ersetzt deine Leistungen, Zeiten und Regeln durch die Vorschläge der neuen Branche. Alle Bestätigungen gehen zurück, du bestätigst die Schritte danach noch einmal. '
|
||||||
@@ -73,11 +76,13 @@ const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.
|
|||||||
|
|
||||||
/* ---------------------------------------------------------------- Gerüst --- */
|
/* ---------------------------------------------------------------- Gerüst --- */
|
||||||
export function renderFragebogen(root, ctx) {
|
export function renderFragebogen(root, ctx) {
|
||||||
|
const abo = ctx.s?.modell === 'abo'
|
||||||
|
const titelLetzter = abo ? TITEL_ABO : TITEL.get(BESTELLEN)
|
||||||
const stand = h('p', { class: 'tm-schritt-stand' })
|
const stand = h('p', { class: 'tm-schritt-stand' })
|
||||||
const liste = h('ol', { class: 'tm-schritte' })
|
const liste = h('ol', { class: 'tm-schritte' })
|
||||||
const inhalt = h('div', { class: 'tm-fb-inhalt' })
|
const inhalt = h('div', { class: 'tm-fb-inhalt' })
|
||||||
root.replaceChildren(h('div', { class: 'tm-fragebogen' },
|
root.replaceChildren(h('div', { class: 'tm-fragebogen' },
|
||||||
ctx.daten.kundenansicht === true ? h('div', { class: 'tm-box tm-box-info', role: 'note' }, h('span', null, KUNDENANSICHT)) : null,
|
ctx.daten.kundenansicht === true ? h('div', { class: 'tm-box tm-box-info', role: 'note' }, h('span', null, abo ? KUNDENANSICHT_ABO : KUNDENANSICHT)) : null,
|
||||||
h('nav', { class: 'tm-fortschritt', 'aria-label': 'Fortschritt im Fragebogen' }, stand, liste),
|
h('nav', { class: 'tm-fortschritt', 'aria-label': 'Fortschritt im Fragebogen' }, stand, liste),
|
||||||
inhalt,
|
inhalt,
|
||||||
h('p', { class: 'tm-hinweis tm-fb-aufbewahrung' }, AUFBEWAHRUNG)))
|
h('p', { class: 'tm-hinweis tm-fb-aufbewahrung' }, AUFBEWAHRUNG)))
|
||||||
@@ -87,7 +92,7 @@ export function renderFragebogen(root, ctx) {
|
|||||||
const darfHin = (key) => key === 'branche' || Boolean(ctx.daten.einstellung)
|
const darfHin = (key) => key === 'branche' || Boolean(ctx.daten.einstellung)
|
||||||
|
|
||||||
function zeichneLeiste() {
|
function zeichneLeiste() {
|
||||||
const eintraege = [...schrittStatus(ctx.daten.fragebogen), { key: BESTELLEN, titel: TITEL.get(BESTELLEN), erledigt: false }]
|
const eintraege = [...schrittStatus(ctx.daten.fragebogen), { key: BESTELLEN, titel: titelLetzter, erledigt: false }]
|
||||||
liste.replaceChildren(...eintraege.map(({ key, titel, erledigt }, i) => h('li', {
|
liste.replaceChildren(...eintraege.map(({ key, titel, erledigt }, i) => h('li', {
|
||||||
class: 'tm-schritt', 'data-schritt': key, 'data-erledigt': erledigt ? 'true' : null, 'aria-current': key === angezeigt ? 'step' : null,
|
class: 'tm-schritt', 'data-schritt': key, 'data-erledigt': erledigt ? 'true' : null, 'aria-current': key === angezeigt ? 'step' : null,
|
||||||
}, h('button', { type: 'button', class: 'tm-schritt-knopf', disabled: !darfHin(key), onclick: () => { if (key !== angezeigt) zeige(key) } },
|
}, h('button', { type: 'button', class: 'tm-schritt-knopf', disabled: !darfHin(key), onclick: () => { if (key !== angezeigt) zeige(key) } },
|
||||||
@@ -95,12 +100,13 @@ export function renderFragebogen(root, ctx) {
|
|||||||
h('span', { class: 'tm-schritt-titel' }, titel),
|
h('span', { class: 'tm-schritt-titel' }, titel),
|
||||||
erledigt ? h('span', { class: 'tm-sr' }, ' (erledigt)') : null))))
|
erledigt ? h('span', { class: 'tm-sr' }, ' (erledigt)') : null))))
|
||||||
const nr = FRAGEBOGEN_SCHRITTE.findIndex(([key]) => key === angezeigt)
|
const nr = FRAGEBOGEN_SCHRITTE.findIndex(([key]) => key === angezeigt)
|
||||||
stand.textContent = nr < 0 ? TITEL.get(BESTELLEN) : `Schritt ${nr + 1} von ${FRAGEBOGEN_SCHRITTE.length}`
|
stand.textContent = nr < 0 ? titelLetzter : `Schritt ${nr + 1} von ${FRAGEBOGEN_SCHRITTE.length}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function bestellseite() {
|
function bestellseite() {
|
||||||
const seite = h('div', { class: 'tm-fb-bestellen' })
|
const seite = h('div', { class: 'tm-fb-bestellen' })
|
||||||
renderBestellen(seite, { ctx, daten: ctx.daten, zumSchritt: (key) => zeige(key) })
|
if (abo) renderEinschaltSeite(seite, { ctx, daten: ctx.daten, zumSchritt: (key) => zeige(key), key: 'termine', einschalten: ctx.einschalten, nachEinschalten: () => ctx.fertig() })
|
||||||
|
else renderBestellen(seite, { ctx, daten: ctx.daten, zumSchritt: (key) => zeige(key) })
|
||||||
return seite
|
return seite
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -52,14 +52,14 @@ const istBestellt = (zustand) => zustand === 'ordered' || zustand === 'setup'
|
|||||||
// Entwurf: Funktionsdokument im Zustand Aus mit siteKey (wie die Kachel in funktionen.js)
|
// Entwurf: Funktionsdokument im Zustand Aus mit siteKey (wie die Kachel in funktionen.js)
|
||||||
const hatEntwurf = (projekt) => zustandVon(projekt) === 'off' && Boolean(projekt?.features?.termine?.siteKey)
|
const hatEntwurf = (projekt) => zustandVon(projekt) === 'off' && Boolean(projekt?.features?.termine?.siteKey)
|
||||||
|
|
||||||
export async function initTermine(root, { projectId } = {}) {
|
export async function initTermine(root, { projectId, einschalten } = {}) {
|
||||||
if (!root) return
|
if (!root) return
|
||||||
const rueckkehr = leseRueckkehr()
|
const rueckkehr = leseRueckkehr()
|
||||||
const wunsch = rueckkehr.projekt || projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
|
const wunsch = rueckkehr.projekt || projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
|
||||||
delete root.dataset.wunschProjekt
|
delete root.dataset.wunschProjekt
|
||||||
if (wunsch) auswahl.projectId = wunsch
|
if (wunsch) auswahl.projectId = wunsch
|
||||||
const { projects = [], termine, modell } = await request('/api/funktionen')
|
const { projects = [], termine, modell } = await request('/api/funktionen')
|
||||||
const s = { root, projects, termine: termine || null, modell, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null, danke: false }
|
const s = { root, projects, termine: termine || null, modell, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null, danke: false, einschalten }
|
||||||
S = s
|
S = s
|
||||||
if (!termine?.verfuegbar) {
|
if (!termine?.verfuegbar) {
|
||||||
keineReiter(s)
|
keineReiter(s)
|
||||||
@@ -259,6 +259,7 @@ async function zeigeBestellung(s, { zustand, aktuell, bestellformular, meldung }
|
|||||||
einrichtung: s.termine?.einrichtung,
|
einrichtung: s.termine?.einrichtung,
|
||||||
danke,
|
danke,
|
||||||
kundenansicht: s.daten.kundenansicht === true,
|
kundenansicht: s.daten.kundenansicht === true,
|
||||||
|
modell: s.modell,
|
||||||
meldung: zustand === 'ordered' && meldung ? meldung.text : '', // die Meldung gilt nur, solange die Bestellung noch offen ist
|
meldung: zustand === 'ordered' && meldung ? meldung.text : '', // die Meldung gilt nur, solange die Bestellung noch offen ist
|
||||||
}, { weiter: (fall) => ladeNeu(s, { bestellformular: fall?.bestellen === true, meldung: fall?.meldung ? { text: fall.meldung } : null }) })
|
}, { weiter: (fall) => ladeNeu(s, { bestellformular: fall?.bestellen === true, meldung: fall?.meldung ? { text: fall.meldung } : null }) })
|
||||||
}
|
}
|
||||||
@@ -339,6 +340,8 @@ export function baueCtx(s, { projekt, aktuell, hinweise = () => {}, zumReiter =
|
|||||||
nurLesen: s.nurLesen,
|
nurLesen: s.nurLesen,
|
||||||
auswahl,
|
auswahl,
|
||||||
aktuell,
|
aktuell,
|
||||||
|
einschalten: s.einschalten, // Ablauf des Einschaltens im Abo-Modell (Init-Option der Hülle), der Fragebogen mountet ihn im letzten Schritt
|
||||||
|
fertig: () => ladeNeu(s), // nach dem Einschalten: Ansicht neu zeichnen
|
||||||
api: {
|
api: {
|
||||||
get: (pfad, query = {}) => request(`/api/termine${pfad}?${new URLSearchParams({ projectId, ...query })}`),
|
get: (pfad, query = {}) => request(`/api/termine${pfad}?${new URLSearchParams({ projectId, ...query })}`),
|
||||||
send: (method, pfad, body = {}) => (method === 'DELETE'
|
send: (method, pfad, body = {}) => (method === 'DELETE'
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { h } from '../public/termine-dom.js'
|
||||||
|
import { initTermine } from '../public/termine.js'
|
||||||
|
import { bestellbarerStand } from './support/fragebogenStand.js'
|
||||||
|
import { einschaltenAttrappe, hoereAufKn, mitDom, routen } from './support/modulAbo.js'
|
||||||
|
|
||||||
|
// Terminplaner im Abo-Modell (P4 Task 3): der letzte Schritt des Fragebogens heißt „Prüfen und einschalten“ und mountet den Ablauf von P3
|
||||||
|
// statt der Bestellseite; keine Haken, kein Preisblock, kein Checkout. Ohne modell === 'abo' bleibt die Bestellseite wie heute.
|
||||||
|
|
||||||
|
const STAND = bestellbarerStand({ now: new Date('2026-10-16T08:00:00.000Z') })
|
||||||
|
const daten = (extra) => ({ state: 'off', einstellung: STAND.einstellung, fragebogen: STAND.fragebogen, version: 1, branchen: [], kundenansicht: false, abo: { ok: true }, offen: [], bestellung: null, ...extra })
|
||||||
|
const liste = (modell, state = 'off', feature = {}) => ({
|
||||||
|
body: {
|
||||||
|
...(modell === undefined ? {} : { modell }),
|
||||||
|
projects: [{ projectId: 'p1', projectName: 'Salon', features: { termine: { state, siteKey: 'abcdefghijklmnop', ...feature } } }],
|
||||||
|
termine: { verfuegbar: true, bestellbar: true, grund: null, preis: '149 €', einrichtung: 'meist in 1–2 Wochen', hinweis: null },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const BESTELLUNG = {
|
||||||
|
preis: '149 €',
|
||||||
|
preisHinweis: 'einmalig, ohne Umsatzsteuer gemäß § 19 UStG',
|
||||||
|
einrichtung: '',
|
||||||
|
belehrung: null,
|
||||||
|
dokumente: [],
|
||||||
|
consent: { version: 'v-test', knopf: 'Zahlungspflichtig bestellen · 149 €', items: [{ id: 'beginn', text: 'Ich verlange den sofortigen Beginn.', haken: true }] },
|
||||||
|
}
|
||||||
|
const EINSTELLUNGEN = 'GET /api/termine/einstellungen?projectId=p1'
|
||||||
|
|
||||||
|
async function oeffne(fx, tabelle, optionen) {
|
||||||
|
routen(fx, tabelle)
|
||||||
|
const wurzel = h('div')
|
||||||
|
document.body.append(wurzel)
|
||||||
|
await initTermine(wurzel, { projectId: 'p1', ...optionen })
|
||||||
|
await fx.ruhe()
|
||||||
|
return wurzel
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Abo, alles bestätigt: letzter Schritt E1 mountet den Ablauf, ohne Haken, Preisblock und Checkout; keine Reiter', mitDom(async (fx) => {
|
||||||
|
const a = einschaltenAttrappe()
|
||||||
|
const k = hoereAufKn(fx)
|
||||||
|
const wurzel = await oeffne(fx, { 'GET /api/funktionen': liste('abo'), [EINSTELLUNGEN]: { body: daten() } }, { einschalten: a.fn })
|
||||||
|
assert.equal(fx.text(fx.finde(wurzel, '.tm-fb-bestellen h3')), 'Prüfen und einschalten')
|
||||||
|
assert.match(fx.text(fx.finde(wurzel, '.tm-schritt[aria-current="step"]')), /Prüfen und einschalten/)
|
||||||
|
assert.match(fx.text(fx.finde(wurzel, '.tm-schritt-stand')), /Prüfen und einschalten/)
|
||||||
|
assert.deepEqual(a.aufrufe.map((x) => [x.key, x.projectId, x.viewAs]), [['termine', 'p1', false]])
|
||||||
|
assert.equal(fx.alle(wurzel, 'input[type="checkbox"]').length, 0, 'auch kein Haken „gesundheit“')
|
||||||
|
for (const sel of ['[data-aktion="bestellen"]', '.tm-bestellen-preis', '.tm-rechtstext', '.tm-bestellen-zustimmung']) assert.equal(fx.finde(wurzel, sel), null, sel)
|
||||||
|
assert.equal(fx.fetchLog.filter((x) => x.method === 'POST').length, 0)
|
||||||
|
assert.deepEqual(k.reiter.at(-1).eintraege, [])
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Abo, offene Punkte: kein Ablauf, Hinweis E2, „Zum Schritt“ führt zurück', mitDom(async (fx) => {
|
||||||
|
const a = einschaltenAttrappe()
|
||||||
|
const offen = [{ schritt: 'zeiten', text: 'Bitte wähle das Bundesland deines Betriebs.' }]
|
||||||
|
const wurzel = await oeffne(fx, { 'GET /api/funktionen': liste('abo'), [EINSTELLUNGEN]: { body: daten({ offen }) } }, { einschalten: a.fn })
|
||||||
|
assert.equal(a.aufrufe.length, 0)
|
||||||
|
assert.match(fx.text(wurzel), /Du kannst einschalten, sobald die offenen Punkte erledigt sind\./)
|
||||||
|
await fx.klick(fx.finde(wurzel, '[data-aktion="zum-schritt"][data-schritt="zeiten"]'))
|
||||||
|
assert.equal(fx.finde(wurzel, '.tm-schritt[aria-current="step"]').getAttribute('data-schritt'), 'zeiten')
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Abo: nach dem Einschalten lädt die Ansicht neu und zeigt die Statuskarte „In Einrichtung“ mit „Eingeschaltet am“', mitDom(async (fx) => {
|
||||||
|
const a = einschaltenAttrappe()
|
||||||
|
let n = 0
|
||||||
|
const wurzel = await oeffne(fx, {
|
||||||
|
'GET /api/funktionen': () => (n === 0 ? liste('abo') : liste('abo', 'setup', { orderedAt: '2026-10-16T09:00:00.000Z', ticketWoid: '100150' })),
|
||||||
|
[EINSTELLUNGEN]: () => ({ body: n === 0 ? daten() : daten({ state: 'setup' }) }),
|
||||||
|
}, { einschalten: a.fn })
|
||||||
|
n = 1
|
||||||
|
a.fertig(0)
|
||||||
|
await fx.ruhe()
|
||||||
|
const karte = fx.finde(wurzel, '.tm-statuskarte[data-state="setup"]')
|
||||||
|
assert.match(fx.text(karte), /Eingeschaltet am\s*16\.10\.2026/)
|
||||||
|
assert.doesNotMatch(fx.text(karte), /Bestellt am|Zahlung/)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Abo: ohne Funktion einschalten steht E4; in der Kundenansicht steht F1 und der Ablauf bekommt viewAs true', mitDom(async (fx) => {
|
||||||
|
const ohne = await oeffne(fx, { 'GET /api/funktionen': liste('abo'), [EINSTELLUNGEN]: { body: daten() } })
|
||||||
|
assert.match(fx.text(ohne), /Das Einschalten ist gerade nicht verfügbar\. Bitte lade die Seite neu oder schreib uns im Chat\./)
|
||||||
|
const a = einschaltenAttrappe()
|
||||||
|
const ansicht = await oeffne(fx, { 'GET /api/funktionen': liste('abo'), [EINSTELLUNGEN]: { body: daten({ kundenansicht: true }) } }, { einschalten: a.fn })
|
||||||
|
assert.match(fx.text(ansicht), /Du siehst den Fragebogen in der Kundenansicht\. Angaben kannst du ändern, Schritte bestätigen und einschalten kann nur der Kunde selbst\./)
|
||||||
|
assert.equal(a.aufrufe[0].viewAs, true)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Abo: die Zusammenfassung der Angaben bleibt auf der Einschalt-Seite (Gruppen mit „Ändern“)', mitDom(async (fx) => {
|
||||||
|
const a = einschaltenAttrappe()
|
||||||
|
const wurzel = await oeffne(fx, { 'GET /api/funktionen': liste('abo'), [EINSTELLUNGEN]: { body: daten() } }, { einschalten: a.fn })
|
||||||
|
assert.ok(fx.alle(wurzel, '.tm-bestellen-gruppe').length > 0)
|
||||||
|
assert.ok(fx.finde(wurzel, '[data-aktion="aendern"]'))
|
||||||
|
}))
|
||||||
|
|
||||||
|
test('Ohne modell (fehlt, leer, „Abo“, „abo “): unverändert Bestellseite mit Knopf des Registers, Ablauf wird nie gerufen', mitDom(async (fx) => {
|
||||||
|
for (const modell of [undefined, '', 'Abo', 'abo ']) {
|
||||||
|
const a = einschaltenAttrappe()
|
||||||
|
const wurzel = await oeffne(fx, { 'GET /api/funktionen': liste(modell), [EINSTELLUNGEN]: { body: daten({ bestellung: BESTELLUNG }) } }, { einschalten: a.fn })
|
||||||
|
assert.equal(a.aufrufe.length, 0, JSON.stringify(modell))
|
||||||
|
assert.equal(fx.text(fx.finde(wurzel, '[data-aktion="bestellen"]')), 'Zahlungspflichtig bestellen · 149 €')
|
||||||
|
assert.equal(fx.text(fx.finde(wurzel, '.tm-fb-bestellen h3')), 'Prüfen und bestellen')
|
||||||
|
assert.match(fx.text(fx.finde(wurzel, '.tm-schritt[aria-current="step"]')), /Prüfen und bestellen/)
|
||||||
|
}
|
||||||
|
}))
|
||||||
Reference in new issue
Block a user