From 5f3d8f3e960c43bf2f63f351e99a69ac10bd5c02 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 18:46:42 +0000 Subject: [PATCH] Buchungskalender: im Abo-Modell Anfrage ohne Preis und Einschalten nach der Zusage Co-Authored-By: Claude Sonnet 5.5 --- public/buchungen-anfrage.js | 34 +++++---- public/buchungen.js | 29 +++++--- test/kundenansichtNeuModulBuchungen.test.js | 80 +++++++++++++++++++++ test/unterkunftWiring.test.js | 2 +- 4 files changed, 122 insertions(+), 23 deletions(-) create mode 100644 test/kundenansichtNeuModulBuchungen.test.js diff --git a/public/buchungen-anfrage.js b/public/buchungen-anfrage.js index 797ee1a..7ed7cb8 100644 --- a/public/buchungen-anfrage.js +++ b/public/buchungen-anfrage.js @@ -16,21 +16,27 @@ const artWert = (a) => (a === 'monteur' ? 'monteurzimmer' : a) const labelVon = (liste, key) => liste.find(([k]) => k === key)?.[1] || '' const enc = encodeURIComponent -/** Angaben der Anfrage als Definitionsliste (nur Text). */ -export function anfrageListe(a) { - const s = a && typeof a === 'object' ? a : {} - const portale = (Array.isArray(s.portale) ? s.portale : []).map((p) => labelVon(PORTALE, p)).filter(Boolean).join(', ') - const zeilen = [ - ['Art der Unterkunft', labelVon(ARTEN, artWert(s.art))], - ['Anzahl Einheiten', Number.isInteger(s.einheiten) ? String(s.einheiten) : ''], +/** Angaben der Anfrage als Zeilen [{ name, wert }] (Definitionsliste und Gruppe der Einschalt-Seite); ohne Objekt leer. */ +export function anfrageZeilen(a) { + if (a === null || typeof a !== 'object') return [] + const portale = (Array.isArray(a.portale) ? a.portale : []).map((p) => labelVon(PORTALE, p)).filter(Boolean).join(', ') + return [ + ['Art der Unterkunft', labelVon(ARTEN, artWert(a.art))], + ['Anzahl Einheiten', Number.isInteger(a.einheiten) ? String(a.einheiten) : ''], ['Portale', portale || 'keine'], - ['Nachricht', text(s.nachricht)], - ].filter(([, wert]) => wert !== '') - return h('dl', { class: 'sh-fakten' }, zeilen.map(([name, wert]) => h('div', null, h('dt', null, name), h('dd', null, wert)))) + ['Nachricht', text(a.nachricht)], + ].filter(([, wert]) => wert !== '').map(([name, wert]) => ({ name, wert })) } -/** Formular „Buchungskalender anfragen“ in root; nachSenden(anfrage) wird nach dem Speichern aufgerufen (die Ansicht zeichnet sich neu). */ -export function renderAnfrageFormular(root, { projectId, vorgabe = null, preis = KALENDER_PREIS, nachSenden } = {}) { +/** Angaben der Anfrage als Definitionsliste (nur Text). */ +export const anfrageListe = (a) => h('dl', { class: 'sh-fakten' }, anfrageZeilen(a).map(({ name, wert }) => h('div', null, h('dt', null, name), h('dd', null, wert)))) + +/** + * Formular „Buchungskalender anfragen“ in root; nachSenden(anfrage) wird nach dem Speichern aufgerufen (die Ansicht zeichnet sich neu). + * modell === 'abo': die Stichpunkte nennen „Im Wartungs-Abo enthalten.“ statt des Preises und „einschalten“ statt „bestellen“. + */ +export function renderAnfrageFormular(root, { projectId, vorgabe = null, preis = KALENDER_PREIS, nachSenden, modell } = {}) { + const abo = modell === 'abo' const v = vorgabe && typeof vorgabe === 'object' ? vorgabe : {} const art = h('select', null, [h('option', { value: '' }, 'Bitte wählen …'), ...ARTEN.map(([w, n]) => h('option', { value: w }, n))]) art.value = ARTEN.some(([k]) => k === artWert(v.art)) ? artWert(v.art) : '' @@ -91,10 +97,10 @@ export function renderAnfrageFormular(root, { projectId, vorgabe = null, preis = h('h3', null, 'Buchungskalender anfragen'), h('p', null, 'Mit dem Buchungskalender zeigst du auf deiner Website, wann deine Unterkunft frei ist. Gäste fragen direkt an, du gibst frei, und die Rechnung entsteht automatisch.'), h('ul', null, - h('li', null, `${preis} einmalig für den Einbau in deine Website.`), + h('li', null, abo ? 'Im Wartungs-Abo enthalten.' : `${preis} einmalig für den Einbau in deine Website.`), h('li', null, 'Belegungskalender mit Abgleich zu Booking.com und Airbnb (über iCal).'), h('li', null, 'Rechnung in deinem Namen und optional Online-Zahlung auf dein eigenes Stripe-Konto.'), - h('li', null, 'Wir sehen uns deine Anfrage kurz an. Erst nach unserer Zusage kannst du bestellen.'))) + h('li', null, abo ? 'Wir sehen uns deine Anfrage kurz an. Erst nach unserer Zusage kannst du einschalten.' : 'Wir sehen uns deine Anfrage kurz an. Erst nach unserer Zusage kannst du bestellen.'))) const karte = h('section', { class: 'kb-panel sh-anfrage' }, erklaerung, form) root.replaceChildren(karte) return karte diff --git a/public/buchungen.js b/public/buchungen.js index e673654..7cb9bf3 100644 --- a/public/buchungen.js +++ b/public/buchungen.js @@ -5,8 +5,8 @@ Helfer: shop-dom.js (h, request, Karten), Styles: buchungen.css (bk-) und shop.css (sh-). Fremdtext nur ueber textContent. */ import { fehlerKarte, h, infoKarte, request } from './shop-dom.js' -import { statusKarte } from './funktion-bestellen.js' -import { anfrageKarte, renderAnfrageFormular } from './buchungen-anfrage.js' +import { renderEinschaltSeite, statusKarte } from './funktion-bestellen.js' +import { anfrageKarte, anfrageZeilen, renderAnfrageFormular } from './buchungen-anfrage.js' import { meldeReiter, reiterEintraege } from './fn-reiter.js' /** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */ @@ -34,22 +34,22 @@ const istAbo = (s) => s.modell === 'abo' const keineReiter = (s) => { if (istAbo(s)) meldeReiter(s.root, { eintraege: [] }) } const zustandVon = (projekt) => projekt?.features?.unterkunft?.state || 'off' -export function initBuchungen(root, { projectId } = {}) { +export function initBuchungen(root, { projectId, einschalten } = {}) { if (!root) return Promise.resolve() const wunsch = projectId || root.dataset?.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab if (root.dataset) delete root.dataset.wunschProjekt - return starte(root, wunsch) + return starte(root, wunsch, einschalten) } -async function starte(root, projectId) { +async function starte(root, projectId, einschalten) { if (projectId) auswahl.projectId = projectId - const s = { root, projects: [], modell: undefined, projekt: null, status: null, lauf: 0, buehne: null, meldungEl: null } + const s = { root, projects: [], modell: undefined, projekt: null, status: null, lauf: 0, buehne: null, meldungEl: null, einschalten } S = s let funktionen try { funktionen = await request('/api/funktionen') } catch (err) { - if (S === s) root.replaceChildren(fehlerKarte(err.message, () => starte(root, auswahl.projectId))) + if (S === s) root.replaceChildren(fehlerKarte(err.message, () => starte(root, auswahl.projectId, einschalten))) return } if (S !== s) return @@ -136,7 +136,19 @@ function zeigeAnfrage(s, { status, aktuell }) { const ansicht = status?.anfrage // nach einem 409 bleibt die Meldung des Servers sichtbar, waehrend die Ansicht neu laedt const neuLaden = async (o) => { await zeigeProjekt(s); if (o?.meldung && S === s) zeigeMeldung(s, o.meldung, 'fehler') } - if (ansicht?.status === 'angenommen') { + if (ansicht?.status === 'angenommen' && istAbo(s)) { + // Abo-Modell: die Zusage fuehrt zum Einschalten (kein Preis, keine Haken, keine Bestellung) + const wurzel = h('div', { class: 'sh-wurzel' }) + s.buehne.replaceChildren(wurzel) + renderEinschaltSeite(wurzel, { + ctx: { projectId, aktuell }, + daten: { offen: [], kundenansicht: false }, + key: 'unterkunft', + zusammenfassung: () => (ansicht.anfrage ? [{ schritt: 'anfrage', titel: 'Deine Anfrage', zeilen: anfrageZeilen(ansicht.anfrage).map(({ name, wert }) => `${name}: ${wert}`) }] : []), + einschalten: s.einschalten, + nachEinschalten: () => zeigeProjekt(s), + }) + } else if (ansicht?.status === 'angenommen') { s.buehne.replaceChildren(infoKarte('Deine Anfrage wurde angenommen', 'Wir melden uns bei dir mit den nächsten Schritten zur Bestellung.', { chat: true })) } else if (ansicht?.status === 'offen') { s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neuLaden })) @@ -149,6 +161,7 @@ function zeigeAnfrage(s, { status, aktuell }) { projectId, vorgabe: ansicht?.anfrage || null, preis: textOr(s.projekt?.features?.unterkunft?.priceLabel) || '199 €', + modell: s.modell, nachSenden: async (_a, o) => { s.neueAnfrage = false; if (aktuell()) await neuLaden(o) }, }) } diff --git a/test/kundenansichtNeuModulBuchungen.test.js b/test/kundenansichtNeuModulBuchungen.test.js new file mode 100644 index 0000000..2d7d4f7 --- /dev/null +++ b/test/kundenansichtNeuModulBuchungen.test.js @@ -0,0 +1,80 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { h } from '../public/shop-dom.js' +import { initBuchungen, vergissAuswahl } from '../public/buchungen.js' +import { anfrageZeilen } from '../public/buchungen-anfrage.js' +import { KEIN_PREIS, einschaltenAttrappe, mitDom, routen } from './support/modulAbo.js' + +// Buchungskalender im Abo-Modell (P4 Task 6): Anfrage ohne Preis (S1), nach der Zusage der Ablauf des Einschaltens statt des Hinweises +// „Wir melden uns bei dir“. Ohne modell === 'abo' bleibt alles wie heute. + +const ANFRAGE = { art: 'ferienwohnung', einheiten: 2, portale: ['booking'], nachricht: '' } +const ZUGESAGT = { status: 'angenommen', anfrage: ANFRAGE, at: '2026-10-05T10:30:00.000Z' } +async function oeffne(fx, { kopf = {}, anfrage = null, einschalten } = {}) { + vergissAuswahl() + routen(fx, { + 'GET /api/funktionen': { body: { projects: [{ projectId: 'p1', projectName: 'Haus am See', features: { unterkunft: { state: 'off' } } }], unterkunft: { verfuegbar: true }, ...kopf } }, + 'GET /api/unterkunft/status?projectId=p1': { body: { state: 'off', anfrage } }, + 'POST /api/unterkunft/anfrage-funktion?projectId=p1': { status: 201, body: { anfrage: {} } }, + }) + const root = h('div') + document.body.append(root) + await initBuchungen(root, { projectId: 'p1', einschalten }) + await fx.ruhe() + return root +} + +test('anfrageZeilen: Zeilen der Liste als { name, wert }, ohne Objekt leer', () => { + assert.deepEqual(anfrageZeilen(ANFRAGE), [ + { name: 'Art der Unterkunft', wert: 'Ferienwohnung oder Ferienhaus' }, + { name: 'Anzahl Einheiten', wert: '2' }, + { name: 'Portale', wert: 'Booking.com' }, + ]) + assert.deepEqual(anfrageZeilen({ art: 'monteur', einheiten: 1, portale: [], nachricht: 'Hallo' }).map((z) => z.wert), ['Monteurwohnung', '1', 'keine', 'Hallo']) + assert.deepEqual(anfrageZeilen(null), []) + assert.deepEqual(anfrageZeilen('x'), []) +}) + +test('Abo: Anfrageformular nennt S1, kein Preis, sendet den Körper unverändert', mitDom(async (fx) => { + const root = await oeffne(fx, { kopf: { modell: 'abo' } }) + const text = fx.text(fx.finde(root, '.sh-erklaerung')) + assert.match(text, /Im Wartungs-Abo enthalten\./) + assert.match(text, /Erst nach unserer Zusage kannst du einschalten\./) + assert.doesNotMatch(text, KEIN_PREIS) + assert.doesNotMatch(text, /bestellen/) + await fx.setze(fx.finde(root, '[data-feld="art"]'), 'ferienwohnung') + await fx.setze(fx.finde(root, '[data-feld="einheiten"]'), '2') + await fx.klick(fx.finde(root, '[data-feld="portale.booking"]')) + await fx.klick(fx.finde(root, '[data-aktion="anfrage-senden"]')) + assert.deepEqual(fx.fetchLog.find((x) => x.method === 'POST').body, ANFRAGE) +})) + +test('Abo, angenommen: Gruppe B1 und Ablauf key unterkunft; kein Haken, kein Wort „Rechnung“; onFertig lädt neu', mitDom(async (fx) => { + const a = einschaltenAttrappe() + const root = await oeffne(fx, { kopf: { modell: 'abo' }, anfrage: ZUGESAGT, einschalten: a.fn }) + assert.equal(a.aufrufe[0].key, 'unterkunft') + assert.equal(a.aufrufe[0].projectId, 'p1') + assert.equal(fx.text(fx.finde(root, '.tm-bestellen-gruppe h4')), 'Deine Anfrage') + assert.match(fx.text(root), /Art der Unterkunft: Ferienwohnung oder Ferienhaus/) + assert.equal(fx.alle(root, 'input[type="checkbox"]').length, 0) + assert.doesNotMatch(fx.text(root), /Rechnung/) + assert.doesNotMatch(fx.text(root), KEIN_PREIS) + a.fertig(0) + await fx.ruhe() + assert.equal(fx.fetchLog.filter((x) => x.url === '/api/unterkunft/status?projectId=p1').length, 2) +})) + +test('Abo, angenommen, ohne Funktion einschalten: Fehlerkarte E4', mitDom(async (fx) => { + const root = await oeffne(fx, { kopf: { modell: 'abo' }, anfrage: ZUGESAGT }) + assert.match(fx.text(root), /Das Einschalten ist gerade nicht verfügbar\./) +})) + +test('Ohne modell (fehlt, „Abo“): Formular mit „199 € einmalig“, angenommen zeigt „Wir melden uns bei dir“, der Ablauf wird nie gerufen', mitDom(async (fx) => { + for (const modell of [undefined, 'Abo']) { + const a = einschaltenAttrappe() + const kopf = modell ? { modell } : {} + assert.match(fx.text(await oeffne(fx, { kopf, einschalten: a.fn })), /199 € einmalig für den Einbau in deine Website\./) + assert.match(fx.text(await oeffne(fx, { kopf, anfrage: ZUGESAGT, einschalten: a.fn })), /Wir melden uns bei dir mit den nächsten Schritten zur Bestellung\./) + assert.equal(a.aufrufe.length, 0) + } +})) diff --git a/test/unterkunftWiring.test.js b/test/unterkunftWiring.test.js index 70ec4ec..52fc84b 100644 --- a/test/unterkunftWiring.test.js +++ b/test/unterkunftWiring.test.js @@ -127,7 +127,7 @@ test('Portal: app.js und dashboard.html kennen den Reiter buchungen', () => { const html = lies('public/dashboard.html') assert.match(html, /
[\s\S]*?
[\s\S]*?<\/section>/) assert.equal([...html.matchAll(/id="buchungen-body"/g)].length, 1) - assert.match(lies('public/buchungen.js'), /export function initBuchungen\(root, \{ projectId \} = \{\}\)/) + assert.match(lies('public/buchungen.js'), /export function initBuchungen\(root, \{ projectId, einschalten \} = \{\}\)/) }) test('Server: index.js hängt Mounts und Jobs ein, die Router-Dateien existieren', () => {