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 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
@@ -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)
|
||||||
|
}
|
||||||
|
}))
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user