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 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
+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. */
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) },
})
}
@@ -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')
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.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', () => {