Buchungskalender: im Abo-Modell Anfrage ohne Preis und Einschalten nach der Zusage

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 18:46:42 +00:00
1 parent 28da4180bd
commit 5f3d8f3e96
4 files changed
+122 -23

No files matched your search

+20 -14
View File
@@ -16,21 +16,27 @@ const artWert = (a) => (a === 'monteur' ? 'monteurzimmer' : a)
const labelVon = (liste, key) => liste.find(([k]) => k === key)?.[1] || '' const labelVon = (liste, key) => liste.find(([k]) => k === key)?.[1] || ''
const enc = encodeURIComponent const enc = encodeURIComponent
/** Angaben der Anfrage als Definitionsliste (nur Text). */ /** Angaben der Anfrage als Zeilen [{ name, wert }] (Definitionsliste und Gruppe der Einschalt-Seite); ohne Objekt leer. */
export function anfrageListe(a) { export function anfrageZeilen(a) {
const s = a && typeof a === 'object' ? a : {} if (a === null || typeof a !== 'object') return []
const portale = (Array.isArray(s.portale) ? s.portale : []).map((p) => labelVon(PORTALE, p)).filter(Boolean).join(', ') const portale = (Array.isArray(a.portale) ? a.portale : []).map((p) => labelVon(PORTALE, p)).filter(Boolean).join(', ')
const zeilen = [ return [
['Art der Unterkunft', labelVon(ARTEN, artWert(s.art))], ['Art der Unterkunft', labelVon(ARTEN, artWert(a.art))],
['Anzahl Einheiten', Number.isInteger(s.einheiten) ? String(s.einheiten) : ''], ['Anzahl Einheiten', Number.isInteger(a.einheiten) ? String(a.einheiten) : ''],
['Portale', portale || 'keine'], ['Portale', portale || 'keine'],
['Nachricht', text(s.nachricht)], ['Nachricht', text(a.nachricht)],
].filter(([, wert]) => wert !== '') ].filter(([, wert]) => wert !== '').map(([name, wert]) => ({ name, wert }))
return h('dl', { class: 'sh-fakten' }, zeilen.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). */ /** Angaben der Anfrage als Definitionsliste (nur Text). */
export function renderAnfrageFormular(root, { projectId, vorgabe = null, preis = KALENDER_PREIS, nachSenden } = {}) { 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 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))]) 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) : '' 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('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('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('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, '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, '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) const karte = h('section', { class: 'kb-panel sh-anfrage' }, erklaerung, form)
root.replaceChildren(karte) root.replaceChildren(karte)
return karte return karte
+21 -8
View File
@@ -5,8 +5,8 @@
Helfer: shop-dom.js (h, request, Karten), Styles: buchungen.css (bk-) und shop.css (sh-). Fremdtext nur ueber textContent. */ 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 { fehlerKarte, h, infoKarte, request } from './shop-dom.js'
import { statusKarte } from './funktion-bestellen.js' import { renderEinschaltSeite, statusKarte } from './funktion-bestellen.js'
import { anfrageKarte, renderAnfrageFormular } from './buchungen-anfrage.js' import { anfrageKarte, anfrageZeilen, renderAnfrageFormular } from './buchungen-anfrage.js'
import { meldeReiter, reiterEintraege } from './fn-reiter.js' import { meldeReiter, reiterEintraege } from './fn-reiter.js'
/** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */ /** 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 keineReiter = (s) => { if (istAbo(s)) meldeReiter(s.root, { eintraege: [] }) }
const zustandVon = (projekt) => projekt?.features?.unterkunft?.state || 'off' const zustandVon = (projekt) => projekt?.features?.unterkunft?.state || 'off'
export function initBuchungen(root, { projectId } = {}) { export function initBuchungen(root, { projectId, einschalten } = {}) {
if (!root) return Promise.resolve() if (!root) return Promise.resolve()
const wunsch = projectId || root.dataset?.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab const wunsch = projectId || root.dataset?.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab
if (root.dataset) delete root.dataset.wunschProjekt 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 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 S = s
let funktionen let funktionen
try { try {
funktionen = await request('/api/funktionen') funktionen = await request('/api/funktionen')
} catch (err) { } 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 return
} }
if (S !== s) return if (S !== s) return
@@ -136,7 +136,19 @@ function zeigeAnfrage(s, { status, aktuell }) {
const ansicht = status?.anfrage const ansicht = status?.anfrage
// nach einem 409 bleibt die Meldung des Servers sichtbar, waehrend die Ansicht neu laedt // 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') } 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 })) 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') { } else if (ansicht?.status === 'offen') {
s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neuLaden })) s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neuLaden }))
@@ -149,6 +161,7 @@ function zeigeAnfrage(s, { status, aktuell }) {
projectId, projectId,
vorgabe: ansicht?.anfrage || null, vorgabe: ansicht?.anfrage || null,
preis: textOr(s.projekt?.features?.unterkunft?.priceLabel) || '199 €', preis: textOr(s.projekt?.features?.unterkunft?.priceLabel) || '199 €',
modell: s.modell,
nachSenden: async (_a, o) => { s.neueAnfrage = false; if (aktuell()) await neuLaden(o) }, nachSenden: async (_a, o) => { s.neueAnfrage = false; if (aktuell()) await neuLaden(o) },
}) })
} }
@@ -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)
}
}))
+1 -1
View File
@@ -127,7 +127,7 @@ test('Portal: app.js und dashboard.html kennen den Reiter buchungen', () => {
const html = lies('public/dashboard.html') const html = lies('public/dashboard.html')
assert.match(html, /<section class="kb-view" id="view-buchungen">[\s\S]*?<div id="buchungen-body">[\s\S]*?<\/section>/) assert.match(html, /<section class="kb-view" id="view-buchungen">[\s\S]*?<div id="buchungen-body">[\s\S]*?<\/section>/)
assert.equal([...html.matchAll(/id="buchungen-body"/g)].length, 1) 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', () => { test('Server: index.js hängt Mounts und Jobs ein, die Router-Dateien existieren', () => {