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:
1 parent
28da4180bd
commit
5f3d8f3e96
4 files changed
+122
-23
No files matched your search
+20
-14
@@ -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
@@ -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)
|
||||
}
|
||||
}))
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user