Buchungskalender: Rechnungen, Einstellungen und Funktionen-Kachel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
knsoandClaude Opus 5.5 committed 2026-10-07 14:23:19 +00:00
1 parent 10fe9a2ae0
commit efeb99e214
8 files changed
+696 -11

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
/* Buchungskalender im Kundenbereich: die Anfrage der Funktion (Zustand off), Muster shop-anfrage.js.
POST /api/unterkunft/anfrage-funktion?projectId= { art, einheiten, portale, nachricht } => { anfrage } (portale: Liste aus 'booking' | 'airbnb'),
POST /api/unterkunft/anfrage-funktion/zurueckziehen?projectId=. Status im Stand (GET /status): anfrage:{ status:'offen'|'abgelehnt'|'angenommen',
at, begruendung?, anfrage:{ art, einheiten, portale, nachricht } }. Geprueft wird auf dem Server (Feldfehler am Feld). Text nur ueber textContent (h). */
import { allgemeinerFehler, datumText, feld, h, hakenFeld, request, setzeFehler, zeigeFehler } from './shop-dom.js'
import { fehlerFuerFormular } from './buchungen-fehler.js'
export const ARTEN = [['ferienwohnung', 'Ferienwohnung oder Ferienhaus'], ['monteur', 'Monteurwohnung'], ['pension', 'Pension oder Gästezimmer'], ['sonstiges', 'Sonstiges']]
export const PORTALE = [['booking', 'Booking.com'], ['airbnb', 'Airbnb']]
export const KALENDER_PREIS = '199 €'
const text = (wert) => (typeof wert === 'string' ? wert : '')
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, s.art)],
['Anzahl Einheiten', Number.isInteger(s.einheiten) ? String(s.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))))
}
/** 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 } = {}) {
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 === v.art) ? v.art : ''
const einheiten = h('input', { type: 'text', inputmode: 'numeric', maxlength: 3, autocomplete: 'off' })
einheiten.value = Number.isInteger(v.einheiten) ? String(v.einheiten) : ''
const portale = PORTALE.map(([wert]) => {
const box = h('input', { type: 'checkbox', value: wert })
box.checked = Array.isArray(v.portale) && v.portale.includes(wert)
return box
})
const nachricht = h('textarea', { rows: 3, maxlength: 1000 })
nachricht.value = text(v.nachricht)
const absenden = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'anfrage-senden' }, 'Anfrage senden')
const form = h('form', { class: 'sh-form', novalidate: true, 'aria-label': 'Buchungskalender anfragen' },
feld('art', 'Welche Art von Unterkunft vermietest du?', art),
feld('einheiten', 'Wie viele Einheiten (Wohnungen, Zimmer) vermietest du?', einheiten),
h('fieldset', { class: 'sh-gruppe' },
h('legend', null, 'Nutzt du Booking.com oder Airbnb?'),
...PORTALE.map(([wert, name], i) => hakenFeld(`portale.${wert}`, portale[i], name)),
h('p', { class: 'sh-fehler', 'data-fehler': 'portale', role: 'alert' })),
feld('nachricht', 'Noch etwas, das wir wissen sollten? (freiwillig)', nachricht),
allgemeinerFehler(),
h('div', { class: 'sh-aktionen' }, absenden))
let laeuft = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (laeuft) return
laeuft = true
absenden.disabled = true
setzeFehler(form, {})
const zahl = einheiten.value.trim()
try {
const antwort = await request(`/api/unterkunft/anfrage-funktion?projectId=${enc(projectId)}`, {
method: 'POST',
body: {
art: art.value,
einheiten: /^\d{1,3}$/.test(zahl) ? Number(zahl) : zahl,
portale: portale.filter((b) => b.checked).map((b) => b.value),
nachricht: nachricht.value.trim(),
},
})
if (typeof nachSenden === 'function') await nachSenden(antwort?.anfrage ?? null)
return
} catch (err) {
zeigeFehler(form, fehlerFuerFormular(err))
if (err?.status === 409 && typeof nachSenden === 'function') {
try { await nachSenden(null) } catch { /* der Fehler steht schon am Formular */ }
return
}
}
laeuft = false
absenden.disabled = false
})
const erklaerung = h('div', { class: 'sh-erklaerung' },
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, '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.')))
const karte = h('section', { class: 'kb-panel sh-anfrage' }, erklaerung, form)
root.replaceChildren(karte)
return karte
}
/** Karte einer Anfrage im Stand offen (mit „Anfrage zurückziehen“) oder abgelehnt (Begründung, „Neue Anfrage stellen“). */
export function anfrageKarte(ansicht, { projectId, neu, neuLaden } = {}) {
const offen = ansicht?.status === 'offen'
const meldung = h('p', { class: 'sh-fehler', role: 'alert', 'data-meldung': 'anfrage' })
const datum = datumText(ansicht?.at)
let aktion
if (offen) {
aktion = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueckziehen' }, 'Anfrage zurückziehen')
aktion.addEventListener('click', async () => {
if (aktion.disabled) return
aktion.disabled = true
meldung.textContent = ''
try {
await request(`/api/unterkunft/anfrage-funktion/zurueckziehen?projectId=${enc(projectId)}`, { method: 'POST' })
if (typeof neuLaden === 'function') await neuLaden()
return
} catch (err) {
meldung.textContent = err.message
if (err?.status === 409 && typeof neuLaden === 'function') { try { await neuLaden() } catch { /* Meldung bleibt */ } return }
}
aktion.disabled = false
})
} else {
aktion = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'neue-anfrage', onclick: () => { if (typeof neu === 'function') neu() } }, 'Neue Anfrage stellen')
}
return h('section', { class: 'kb-panel sh-info sh-anfrage-karte', 'data-anfrage': offen ? 'offen' : 'abgelehnt' },
h('span', { class: `sh-pille ${offen ? 'sh-pille-wait' : 'sh-pille-bad'}` }, offen ? 'In Prüfung' : 'Abgelehnt'),
h('h3', null, offen ? 'Deine Anfrage wird geprüft' : 'Deine Anfrage wurde leider abgelehnt'),
offen
? h('p', null, `${datum ? `Du hast deine Anfrage am ${datum} Uhr gesendet. ` : ''}Wir melden uns, sobald wir sie angesehen haben. Bis dahin kannst du sie zurückziehen und eine neue stellen.`)
: h('p', null, 'Unsere Begründung:'),
!offen ? h('blockquote', { class: 'sh-begruendung' }, text(ansicht?.begruendung) || 'Dazu liegt keine Begründung vor.') : null,
ansicht?.anfrage ? h('div', { class: 'sh-zusammenfassung' }, h('h4', null, 'Deine Angaben'), anfrageListe(ansicht.anfrage)) : null,
meldung,
h('div', { class: 'sh-aktionen' }, aktion, h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen')))
}
+182 -4
View File
@@ -1,6 +1,184 @@
/* Buchungskalender, Reiter „Einstellungen“: Platzhalter (Task 13/14 fuellt ihn). */ /* Buchungskalender, Reiter „Einstellungen“: Firmendaten, Steuernummer/USt-IdNr., Rechnungspraefix, Auftrag zur Rechnungsstellung,
import { infoKarte } from './shop-dom.js' Bankverbindung, Akzentfarbe, Stornobedingungen (Mustertext), Stripe-Konto und Online-Zahlung.
API (docs/funktionen/unterkunft-api.md, ?projectId=): GET /einstellungen => { einstellungen:{ firma, inhaber, strasse, plz, ort, email, telefon,
steuernummer, ustId, iban, bic, bank, rechnungsPraefix, akzentfarbe, onlineZahlung, rechnungAuftrag, stornobedingungen },
stripe:{ status:'keins'|'onboarding'|'aktiv', vorhandenesShopKonto:boolean }, rechnungenVorhanden:boolean };
PUT /einstellungen mit denselben Feldern (ohne Wrapper; rechnungAuftrag:true setzt rechnungAuftragAt auf dem Server);
POST /stripe/verbinden => { url } (Onboarding bei Stripe), POST /stripe/vorhandenes-konto und /stripe/aktualisieren => { stripe }.
Geprueft wird auf dem Server; Feldfehler stehen am Feld. Text nur ueber textContent (h). render(panel, ctx) wie shop-tab-*.js. */
export function render(panel) { import { abschnitt, allgemeinerFehler, feld, h, hakenFeld, setzeFehler } from './shop-dom.js'
panel.replaceChildren(infoKarte('Einstellungen', 'Dieser Bereich wird gerade gebaut.')) import { enc, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js'
export const STORNO_MUSTER = 'Stornierung bis 14 Tage vor Anreise: kostenlos. Stornierung 13 bis 3 Tage vor Anreise: 50 % des Gesamtpreises. Stornierung ab 2 Tage vor Anreise oder Nichtanreise: 90 % des Gesamtpreises. Die Stornierung bedarf der Textform (z. B. E-Mail). Maßgeblich ist der Eingang bei uns.'
export const STORNO_HINWEIS = 'Vorlage – bitte an deine Bedingungen anpassen, keine Rechtsberatung.'
export const AUFTRAG_TEXT = 'Ich beauftrage WEBklar, in meinem Namen und auf meine Rechnung Rechnungen an meine Gäste auszustellen (§ 14 Abs. 2 Satz 4 UStG).'
export const AUFTRAG_FEHLT = 'Ohne diesen Auftrag kannst du Buchungen nicht bestätigen, weil wir keine Rechnungen für dich ausstellen dürfen.'
const STRIPE_FEHLER = 'Stripe hat keine gültige Adresse geliefert. Bitte versuch es gleich noch einmal.'
const PFLICHT = { firma: 'Bitte gib den Namen deiner Firma oder Unterkunft an.', inhaber: 'Bitte gib den Namen des Inhabers an.', strasse: 'Bitte gib Straße und Hausnummer an.', plz: 'Bitte gib die Postleitzahl an.', ort: 'Bitte gib den Ort an.', email: 'Bitte gib eine E-Mail-Adresse an.' }
const textfeld = (optionen = {}) => h('input', { type: 'text', autocomplete: 'off', ...optionen })
const istHttps = (url) => { try { return new URL(url).protocol === 'https:' } catch { return false } }
export async function render(panel, ctx = {}) {
const projectId = ctx.projectId
const q = `projectId=${enc(projectId)}`
const nurLesen = ctx.nurLesen === true
const antwort = await ctx.request(`/api/unterkunft/einstellungen?${q}`)
const e = antwort?.einstellungen && typeof antwort.einstellungen === 'object' ? antwort.einstellungen : {}
const stripe = antwort?.stripe || ctx.status?.stripe || { status: 'keins' }
const rechnungenVorhanden = (antwort?.rechnungenVorhanden ?? ctx.status?.rechnungenVorhanden) === true
const melde = (text, art) => { if (typeof ctx.zeigeMeldung === 'function') ctx.zeigeMeldung(text, art) }
const neu = () => render(panel, ctx).catch((err) => panel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err.message)))
/* ----------------------------------------------------------------- Felder --- */
const firma = textfeld({ maxlength: 120, autocomplete: 'organization' }); firma.value = zeichen(e.firma)
const inhaber = textfeld({ maxlength: 120 }); inhaber.value = zeichen(e.inhaber)
const strasse = textfeld({ maxlength: 120, autocomplete: 'street-address' }); strasse.value = zeichen(e.strasse)
const plz = textfeld({ maxlength: 10, inputmode: 'numeric', autocomplete: 'postal-code' }); plz.value = zeichen(e.plz)
const ort = textfeld({ maxlength: 80, autocomplete: 'address-level2' }); ort.value = zeichen(e.ort)
const email = h('input', { type: 'email', maxlength: 254, autocomplete: 'email' }); email.value = zeichen(e.email)
const telefon = h('input', { type: 'tel', maxlength: 40, autocomplete: 'tel' }); telefon.value = zeichen(e.telefon)
const steuernummer = textfeld({ maxlength: 30 }); steuernummer.value = zeichen(e.steuernummer)
const ustId = textfeld({ maxlength: 20, placeholder: 'DE123456789' }); ustId.value = zeichen(e.ustId)
const praefix = textfeld({ maxlength: 8, placeholder: 'RE' }); praefix.value = zeichen(e.rechnungsPraefix) || 'RE'
if (rechnungenVorhanden) { praefix.disabled = true; praefix.setAttribute('aria-disabled', 'true') }
const auftrag = h('input', { type: 'checkbox' }); auftrag.checked = e.rechnungAuftrag === true
const iban = textfeld({ maxlength: 40 }); iban.value = zeichen(e.iban)
const bic = textfeld({ maxlength: 11 }); bic.value = zeichen(e.bic)
const bank = textfeld({ maxlength: 80 }); bank.value = zeichen(e.bank)
const akzent = h('input', { type: 'color' }); akzent.value = /^#[0-9a-fA-F]{6}$/.test(zeichen(e.akzentfarbe)) ? e.akzentfarbe : '#111111'
const storno = h('textarea', { rows: 6, maxlength: 5000 }); storno.value = zeichen(e.stornobedingungen)
const online = h('input', { type: 'checkbox' }); online.checked = e.onlineZahlung === true && stripe.status === 'aktiv'
const auftragHinweis = h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-hinweis': 'auftrag' }, AUFTRAG_FEHLT)
const zeigeAuftrag = () => { auftragHinweis.hidden = auftrag.checked }
auftrag.addEventListener('change', zeigeAuftrag)
zeigeAuftrag()
const musterKnopf = h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': 'storno-muster',
onclick: () => { storno.value = STORNO_MUSTER },
}, 'Mustertext übernehmen')
/* ------------------------------------------------------------------ Stripe --- */
let beschaeftigt = false
async function stripeAktion(pfad, { weiter = false } = {}) {
if (beschaeftigt || nurLesen) return
beschaeftigt = true
for (const k of stripeKarte.querySelectorAll('button')) k.setAttribute('disabled', '')
try {
const a = await ctx.request(`/api/unterkunft/stripe/${pfad}?${q}`, { method: 'POST' })
if (weiter) {
if (!istHttps(a?.url)) throw new Error(STRIPE_FEHLER)
melde('Du wirst zu Stripe weitergeleitet …', 'ok')
window.location.href = a.url
return
}
if (typeof ctx.neuLaden === 'function') { try { await ctx.neuLaden() } catch { /* der Stand erscheint beim naechsten Laden */ } }
await neu()
} catch (err) {
melde(err?.message || 'Das hat nicht geklappt. Bitte versuch es noch einmal.', 'fehler')
for (const k of stripeKarte.querySelectorAll('button')) k.removeAttribute('disabled')
} finally {
beschaeftigt = false
}
}
const knopf = (text, aktion, pfad, optionen, art = 'kb-btn-outline') => h('button', { type: 'button', class: art, 'data-aktion': aktion, onclick: () => stripeAktion(pfad, optionen) }, text)
const zustandText = { keins: 'Noch nicht verbunden', onboarding: 'Angaben bei Stripe noch offen', aktiv: 'Verbunden und aktiv' }
const status = ['keins', 'onboarding', 'aktiv'].includes(stripe.status) ? stripe.status : 'keins'
const stripeKnoepfe = nurLesen ? [] : status === 'keins'
? [knopf('Stripe-Konto verbinden', 'stripe-verbinden', 'verbinden', { weiter: true }, 'kb-btn-primary'),
stripe.vorhandenesShopKonto === true ? knopf('Vorhandenes Webshop-Konto verwenden', 'stripe-vorhanden', 'vorhandenes-konto') : null]
: status === 'onboarding'
? [knopf('Angaben bei Stripe ergänzen', 'stripe-verbinden', 'verbinden', { weiter: true }, 'kb-btn-primary'), knopf('Status aktualisieren', 'stripe-aktualisieren', 'aktualisieren')]
: [knopf('Status aktualisieren', 'stripe-aktualisieren', 'aktualisieren')]
const stripeKarte = abschnitt('Stripe-Konto für Online-Zahlung', 'Gäste können die Buchung online bezahlen. Das Geld geht direkt auf dein Stripe-Konto.',
h('p', { 'data-stripe-status': status, class: status === 'aktiv' ? 'sh-ok' : 'sh-offen' }, zustandText[status]),
h('div', { class: 'sh-aktionen' }, ...stripeKnoepfe))
/* ----------------------------------------------------------------- Formular --- */
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'einstellungen-speichern' }, 'Einstellungen speichern')
const form = h('form', { class: 'sh-form sh-einstellungen', novalidate: true, 'aria-label': 'Einstellungen des Buchungskalenders' },
abschnitt('Deine Firmendaten', 'Diese Angaben stehen auf der Buchungsseite und auf den Rechnungen an deine Gäste. Pflichtfelder sind mit * markiert.',
feld('firma', 'Firma oder Name der Unterkunft *', firma),
feld('inhaber', 'Inhaber *', inhaber),
feld('strasse', 'Straße und Hausnummer *', strasse),
feld('plz', 'Postleitzahl *', plz),
feld('ort', 'Ort *', ort),
feld('email', 'E-Mail-Adresse *', email, 'An diese Adresse gehen neue Anfragen.'),
feld('telefon', 'Telefon (freiwillig)', telefon)),
abschnitt('Steuer', 'Eine der beiden Angaben ist Pflicht (§ 14 Abs. 4 UStG).',
feld('steuernummer', 'Steuernummer', steuernummer),
feld('ustId', 'USt-IdNr.', ustId, 'Zum Beispiel DE123456789.')),
abschnitt('Rechnungen', null,
feld('rechnungsPraefix', 'Rechnungspräfix', praefix, rechnungenVorhanden
? 'Der Präfix ist gesperrt, weil es schon Rechnungen gibt. Die Nummern bleiben so lückenlos.'
: '2 bis 8 Zeichen (A–Z, 0–9). Die Nummern lauten dann zum Beispiel RE-2026-0001. Du kannst den Präfix nur vor der ersten Rechnung ändern.'),
hakenFeld('rechnungAuftrag', auftrag, AUFTRAG_TEXT),
auftragHinweis),
abschnitt('Bankverbindung (freiwillig)', 'Erscheint als Hinweis, wenn Gäste vor Ort oder per Überweisung zahlen.',
feld('iban', 'IBAN', iban),
feld('bic', 'BIC', bic),
feld('bank', 'Name der Bank', bank)),
abschnitt('Darstellung', null, feld('akzentfarbe', 'Akzentfarbe', akzent, 'Diese Farbe bekommen Knöpfe auf deiner Buchungsseite.')),
abschnitt('Stornobedingungen', 'Sie stehen auf der Buchungsseite und gelten für neue Freigaben.',
feld('stornobedingungen', 'Stornobedingungen', storno, STORNO_HINWEIS),
h('div', { class: 'sh-aktionen' }, musterKnopf)),
status === 'aktiv'
? abschnitt('Online-Zahlung', null, hakenFeld('onlineZahlung', online, 'Online-Zahlung anbieten', 'Gäste können bei der Buchung per Stripe bezahlen.'))
: null,
allgemeinerFehler(),
nurLesen ? null : h('div', { class: 'sh-aktionen' }, speichern))
/* --------------------------------------------------------------- Speichern --- */
function sammle() {
const fehler = {}
const felder = { firma, inhaber, strasse, plz, ort, email }
for (const [name, el] of Object.entries(felder)) if (!el.value.trim()) fehler[name] = PFLICHT[name]
if (!steuernummer.value.trim() && !ustId.value.trim()) {
fehler.steuernummer = 'Bitte gib Steuernummer oder USt-IdNr. an.'
fehler.ustId = 'Bitte gib Steuernummer oder USt-IdNr. an.'
}
const p = praefix.value.trim().toUpperCase()
if (!rechnungenVorhanden && !/^[A-Z0-9]{2,8}$/.test(p)) fehler.rechnungsPraefix = 'Der Präfix braucht 2 bis 8 Zeichen aus A–Z und 0–9.'
if (Object.keys(fehler).length) return { fehler }
return {
einstellungen: {
firma: firma.value.trim(), inhaber: inhaber.value.trim(), strasse: strasse.value.trim(), plz: plz.value.trim(), ort: ort.value.trim(),
email: email.value.trim(), telefon: telefon.value.trim(), steuernummer: steuernummer.value.trim(), ustId: ustId.value.trim(),
iban: iban.value.trim(), bic: bic.value.trim(), bank: bank.value.trim(),
rechnungsPraefix: rechnungenVorhanden ? zeichen(e.rechnungsPraefix) || 'RE' : p,
rechnungAuftrag: auftrag.checked, akzentfarbe: akzent.value, stornobedingungen: storno.value.trim(),
onlineZahlung: status === 'aktiv' && online.checked,
},
}
}
let laeuft = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (nurLesen || laeuft) return
laeuft = true
speichern.disabled = true
setzeFehler(form, {})
try {
const eingabe = sammle()
if (eingabe.fehler) {
setzeFehler(form, eingabe.fehler)
form.querySelector('[aria-invalid]')?.focus()
return
}
await ctx.request(`/api/unterkunft/einstellungen?${q}`, { method: 'PUT', body: eingabe.einstellungen })
melde('Die Einstellungen sind gespeichert.', 'ok')
if (typeof ctx.neuLaden === 'function') { try { await ctx.neuLaden() } catch { /* gespeichert ist gespeichert */ } }
} catch (err) {
zeigeApiFehler(form, err)
} finally {
laeuft = false
speichern.disabled = false
}
})
if (nurLesen) for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true
panel.replaceChildren(stripeKarte, form)
} }
+31 -4
View File
@@ -1,6 +1,33 @@
/* Buchungskalender, Reiter „Rechnungen“: Platzhalter (Task 13/14 fuellt ihn). */ /* Buchungskalender, Reiter „Rechnungen“: Liste der Rechnungen und Storno-Rechnungen mit PDF-Link.
import { infoKarte } from './shop-dom.js' API: GET /api/unterkunft/rechnungen?projectId= => { rechnungen:[{ id, nummer, datum, art:'rechnung'|'storno', gastName, bruttoCent, anfrageNummer? }] };
PDF: GET /api/unterkunft/rechnungen/<id>.pdf?projectId= (Einzel-PDF, kein ZIP). Fremdtext nur ueber textContent. render(panel, ctx). */
export function render(panel) { import { abschnitt, datumText, euroText, h, infoKarte } from './shop-dom.js'
panel.replaceChildren(infoKarte('Rechnungen', 'Dieser Bereich wird gerade gebaut.')) import { enc, zeichen } from './buchungen-uk-hilfe.js'
const ART = { rechnung: 'Rechnung', storno: 'Storno-Rechnung' }
const betragText = (cent) => {
if (!Number.isInteger(cent)) return ''
return cent < 0 ? `−${euroText(-cent)}` : euroText(cent)
}
export async function render(panel, ctx = {}) {
const q = `projectId=${enc(ctx.projectId)}`
const antwort = await ctx.request(`/api/unterkunft/rechnungen?${q}`)
const liste = Array.isArray(antwort?.rechnungen) ? antwort.rechnungen : []
if (!liste.length) {
panel.replaceChildren(infoKarte('Rechnungen', 'Noch keine Rechnungen. Sobald ein Gast bucht, erscheint hier die Rechnung.'))
return
}
const zeilen = liste.map((r) => h('li', { class: 'bk-rechnung', 'data-rechnung': zeichen(r.id), 'data-art': zeichen(r.art) },
h('span', { class: 'bk-nummer' }, zeichen(r.nummer)),
h('span', { class: 'bk-zeitraum' }, datumText(zeichen(r.datum), { zeit: false })),
h('span', { class: 'bk-gast' }, zeichen(r.gastName)),
h('span', { class: `sh-pille ${r.art === 'storno' ? 'sh-pille-bad' : 'sh-pille-ok'}` }, ART[r.art] || 'Rechnung'),
h('span', { class: 'bk-betrag' }, betragText(r.bruttoCent)),
h('a', {
class: 'kb-btn-outline', 'data-aktion': 'pdf', href: `/api/unterkunft/rechnungen/${enc(zeichen(r.id))}.pdf?${q}`, target: '_blank', rel: 'noopener',
'aria-label': `PDF zu ${zeichen(r.nummer)} herunterladen`,
}, 'PDF')))
panel.replaceChildren(abschnitt('Rechnungen', 'Rechnungen und Storno-Rechnungen deiner Gäste, ausgestellt in deinem Namen.', h('ul', { class: 'bk-liste' }, zeilen)))
} }
+8
View File
@@ -55,3 +55,11 @@
.bk-vorschau { font-weight: 700; } .bk-vorschau { font-weight: 700; }
.bk-import { display: flex; align-items: center; gap: 4px 12px; flex-wrap: wrap; } .bk-import { display: flex; align-items: center; gap: 4px 12px; flex-wrap: wrap; }
.bk-anleitung details { margin: 8px 0; } .bk-anleitung details { margin: 8px 0; }
/* Rechnungen */
.bk-rechnung {
display: grid; grid-template-columns: 110px 100px 1fr auto auto auto; gap: 8px 14px; align-items: center; padding: 12px 16px;
border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.03);
}
.bk-rechnung .sh-pille { margin: 0; }
@media (max-width: 720px) { .bk-rechnung { grid-template-columns: 1fr auto; } }
+29 -2
View File
@@ -5,6 +5,7 @@
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 { anfrageKarte, renderAnfrageFormular } from './buchungen-anfrage.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. */
export const REITER = [ export const REITER = [
@@ -24,11 +25,14 @@ export function vergissAuswahl() {
} }
let S = null // aktuelle Sitzung; eine alte Antwort darf eine neuere nicht ueberschreiben let S = null // aktuelle Sitzung; eine alte Antwort darf eine neuere nicht ueberschreiben
const textOr = (x) => (typeof x === 'string' && x.trim() ? x : '')
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 } = {}) {
if (!root) return Promise.resolve() if (!root) return Promise.resolve()
return starte(root, projectId) 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)
} }
async function starte(root, projectId) { async function starte(root, projectId) {
@@ -109,10 +113,33 @@ function zeichneAnsicht(s, { status, aktuell }) {
s.status = status s.status = status
const zustand = status?.state || zustandVon(s.projekt) const zustand = status?.state || zustandVon(s.projekt)
if (zustand === 'setup' || zustand === 'active' || zustand === 'disabled') zeigeReiter(s, { status, aktuell, zustand }) if (zustand === 'setup' || zustand === 'active' || zustand === 'disabled') zeigeReiter(s, { status, aktuell, zustand })
else if (zustand === 'off') s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Du hast den Buchungskalender noch nicht angefragt. Die Anfrage kommt in Kürze hier.')) // Task 14: Anfrage-/Bestellkarte else if (zustand === 'off') zeigeAnfrage(s, { status, aktuell })
else s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Der Stand deines Buchungskalenders ist gerade nicht lesbar. Bitte lade die Seite neu.')) else s.buehne.replaceChildren(infoKarte('Buchungskalender', 'Der Stand deines Buchungskalenders ist gerade nicht lesbar. Bitte lade die Seite neu.'))
} }
/* Zustand off: Anfrageformular, Karte „wird geprueft“ / „abgelehnt“ oder (angenommen) der Hinweis auf die Bestellung. */
function zeigeAnfrage(s, { status, aktuell }) {
const projectId = s.projekt.projectId
const ansicht = status?.anfrage
const neuLaden = () => zeigeProjekt(s)
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 }))
} else if (ansicht?.status === 'abgelehnt' && !s.neueAnfrage) {
s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neu: () => { s.neueAnfrage = true; zeigeAnfrage(s, { status, aktuell }) } }))
} else {
const wurzel = h('div', { class: 'sh-wurzel' })
s.buehne.replaceChildren(wurzel)
renderAnfrageFormular(wurzel, {
projectId,
vorgabe: ansicht?.anfrage || null,
preis: textOr(s.projekt?.features?.unterkunft?.priceLabel) || '199 €',
nachSenden: async () => { s.neueAnfrage = false; if (aktuell()) await neuLaden() },
})
}
}
function zeigeReiter(s, { status, aktuell, zustand }) { function zeigeReiter(s, { status, aktuell, zustand }) {
const neuLaden = async () => { const neuLaden = async () => {
const neu = await ladeStatus(s.projekt.projectId) const neu = await ladeStatus(s.projekt.projectId)
+52 -1
View File
@@ -260,6 +260,51 @@ function shopTile(projects, features) {
</article>` </article>`
} }
/* Buchungskalender (Schluessel unterkunft): Kachel nur, wenn der Server die Funktion freigibt (unterkunft.verfuegbar der Antwort, Schalter
UNTERKUNFT_ENABLED); sonst gar keine Kachel. Die Ansicht #buchungen (buchungen.js) zeigt Anfrage und Stand; die Kachel kennt nur den Zustand
des Funktionsdokuments (projects[].features.unterkunft: off | ordered | setup | active | disabled), Preis aus features.unterkunft.priceLabel,
Rueckfall 199 €. */
const UNTERKUNFT_PREIS = '199 €'
const UNTERKUNFT_TEXT = 'Belegungskalender auf deiner Website, Anfragen freigeben, Rechnung und Online-Zahlung'
const unterkunftOf = (project) => project.features?.unterkunft || { state: 'off' }
function pickUnterkunftProject(projects) {
return projects.find((p) => unterkunftOf(p).state === 'active')
|| projects.find((p) => isSetup(unterkunftOf(p).state))
|| projects.find((p) => unterkunftOf(p).state === 'disabled')
|| projects[0]
}
function unterkunftTile(projects, features) {
const preis = textOr(features?.unterkunft?.priceLabel, UNTERKUNFT_PREIS)
const active = projects.filter((p) => unterkunftOf(p).state === 'active').length
const target = pickUnterkunftProject(projects)
const state = target ? unterkunftOf(target).state : 'off'
const knopf = (text) => `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="buchungen" data-project="${esc(target.projectId)}">${text}</button>`
const ZUSTAENDE = {
off: [['Aus', 'off'], 'Buchungskalender anfragen'],
ordered: [['Zahlung offen', 'setup'], 'Zahlung offen'],
setup: [['In Einrichtung', 'setup'], 'In Einrichtung'],
active: [['Aktiv', 'on'], 'Buchungskalender öffnen'],
disabled: [['Abgeschaltet', 'off'], 'Buchungskalender ansehen'],
}
const [pill, knopfText] = ZUSTAENDE[state] || ZUSTAENDE.off
const action = target ? knopf(knopfText) : ''
let extra = ''
if (!target) extra = '<p class="fn-count">Sobald deine Website angelegt ist, kannst du den Buchungskalender anfragen.</p>'
else if (state === 'disabled') extra = '<p>Der Buchungskalender ist für diese Website abgeschaltet. Deine Buchungen kannst du noch ansehen.</p>'
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
return `
<article class="fn-tile" data-fn-tile="unterkunft">
<div class="fn-tile-head">${svg('calendar')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
<h3>Buchungskalender</h3>
<p>${UNTERKUNFT_TEXT}. ${esc(preis)} einmalig.</p>
${extra}
${count}
${action}
</article>`
}
/* Oeffnungszeiten live: Kachel nur, wenn der Schalter es erlaubt (zeiten.verfuegbar); sonst gibt es sie gar nicht (auch kein "Kommt bald"). /* Oeffnungszeiten live: Kachel nur, wenn der Schalter es erlaubt (zeiten.verfuegbar); sonst gibt es sie gar nicht (auch kein "Kommt bald").
Wie bei den anderen Funktionen steht sie inline, die Ansicht #zeiten (zeiten.js) laedt app.js. Alles, was sie zeigt, kommt mit der Liste Wie bei den anderen Funktionen steht sie inline, die Ansicht #zeiten (zeiten.js) laedt app.js. Alles, was sie zeigt, kommt mit der Liste
(GET /api/funktionen): zeiten (Angebot) mit inklusive (true: im laufenden, aktiven Wartungs-Abo enthalten, einschaltbar | false: sonst, dann (GET /api/funktionen): zeiten (Angebot) mit inklusive (true: im laufenden, aktiven Wartungs-Abo enthalten, einschaltbar | false: sonst, dann
@@ -386,12 +431,13 @@ const soonTile = (icon, title, text) => `
export async function initFunktionen(root) { export async function initFunktionen(root) {
if (!root) return if (!root) return
const { projects = [], termine, features, shop, zeiten } = await request('/api/funktionen') const { projects = [], termine, features, shop, zeiten, unterkunft } = await request('/api/funktionen')
root.innerHTML = ` root.innerHTML = `
<div class="fn-tiles"> <div class="fn-tiles">
${newsTile(projects, newsOffer(features).price)} ${newsTile(projects, newsOffer(features).price)}
${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)} ${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
${shop?.verfuegbar ? shopTile(projects, features) : ''} ${shop?.verfuegbar ? shopTile(projects, features) : ''}
${unterkunft?.verfuegbar === true ? unterkunftTile(projects, features) : ''}
${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''} ${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''}
${analyseTile(projects)} ${analyseTile(projects)}
</div>` </div>`
@@ -407,6 +453,11 @@ export async function initFunktionen(root) {
const body = document.getElementById('webshop-body') const body = document.getElementById('webshop-body')
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
}) })
// Die Buchungskalender-Ansicht liest das gewaehlte Projekt aus ihrem Behaelter (buchungen.js, initBuchungen)
root.querySelector('[data-fn-tile="unterkunft"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => {
const body = document.getElementById('buchungen-body')
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
})
// Die Zeiten-Ansicht liest Projekt und Weg (Behalten) aus ihrem Behaelter (zeiten.js, initZeiten); der Klick steigt danach zum Navigationshandler auf // Die Zeiten-Ansicht liest Projekt und Weg (Behalten) aus ihrem Behaelter (zeiten.js, initZeiten); der Klick steigt danach zum Navigationshandler auf
root.querySelector('[data-fn-tile="zeiten"]')?.addEventListener('click', (ev) => { root.querySelector('[data-fn-tile="zeiten"]')?.addEventListener('click', (ev) => {
const knopf = ev.target?.closest?.('button[data-view="zeiten"]') const knopf = ev.target?.closest?.('button[data-view="zeiten"]')
+241
View File
@@ -467,3 +467,244 @@ test('Kalender: vergangene Tage lassen sich nicht sperren', mit(ukServer(), asyn
assert.match(fx.text(fx.finde(panel, '[data-hinweis]')), /Vergangene Tage/) assert.match(fx.text(fx.finde(panel, '[data-hinweis]')), /Vergangene Tage/)
assert.equal(fx.finde(panel, '[data-aktion="sperren-senden"]'), null) assert.equal(fx.finde(panel, '[data-aktion="sperren-senden"]'), null)
})) }))
/* ===================================================== Task 14: Rechnungen, Einstellungen, Anfrage ===================================================== */
const { render: renderRechnungen } = await import('../public/buchungen-tab-rechnungen.js')
const { render: renderEinstellungen, STORNO_MUSTER, STORNO_HINWEIS, AUFTRAG_TEXT } = await import('../public/buchungen-tab-einstellungen.js')
const { renderAnfrageFormular, anfrageKarte } = await import('../public/buchungen-anfrage.js')
const { initBuchungen, vergissAuswahl } = await import('../public/buchungen.js')
const zeigeMit = async (fx, modul, ctxExtra = {}) => {
const ctx = { ...baueCtx(), ...ctxExtra }
const panel = h('div')
fx.document.body.append(panel)
await modul(panel, ctx)
await fx.ruhe()
return { panel, ctx }
}
const ES = { firma: 'Haus am See', inhaber: 'Mimi M.', strasse: 'Seeweg 1', plz: '78532', ort: 'Tuttlingen', email: 'mimi@example.com', telefon: '', steuernummer: '12/345/67890', ustId: '', iban: '', bic: '', bank: '', rechnungsPraefix: 'RE', akzentfarbe: '#336699', onlineZahlung: false, rechnungAuftrag: true, stornobedingungen: '' }
const esServer = (o = {}, antwort = {}) => server({ 'GET /api/unterkunft/einstellungen': () => ({ body: { einstellungen: ES, stripe: { status: 'keins', vorhandenesShopKonto: false }, rechnungenVorhanden: false, ...antwort } }), 'PUT /api/unterkunft/einstellungen': () => ({ body: {} }), ...o })
test('Rechnungen: Liste mit PDF-Link, Art und Betrag; Fremdtext nur als Text', mit(server({
'GET /api/unterkunft/rechnungen': () => ({ body: { rechnungen: [
{ id: 'r1', nummer: 'RE-2026-0001', datum: '2026-10-07', art: 'rechnung', gastName: 'Anna <b>M</b>', bruttoCent: 40650 },
{ id: 'r2', nummer: 'RE-2026-0002', datum: '2026-10-08', art: 'storno', gastName: 'Anna', bruttoCent: -40650 },
] } }),
}), async (fx) => {
const { panel } = await zeigeMit(fx, renderRechnungen)
assert.equal(fx.fetchLog[0].url, '/api/unterkunft/rechnungen?projectId=p1')
const z = fx.alle(panel, '[data-rechnung]')
assert.equal(z.length, 2)
assert.match(fx.text(z[0]), /RE-2026-0001/)
assert.match(fx.text(z[0]), /07\.10\.2026/)
assert.match(fx.text(z[0]), /Anna <b>M<\/b>/)
assert.match(fx.text(z[0]), /406,50\s€/)
assert.match(fx.text(z[1]), /Storno-Rechnung/)
assert.match(fx.text(z[1]), /−406,50\s€/)
assert.equal(fx.finde(z[0], '[data-aktion="pdf"]').getAttribute('href'), '/api/unterkunft/rechnungen/r1.pdf?projectId=p1')
assert.equal(fx.finde(z[0], 'b'), null)
}))
test('Rechnungen: leer → Hinweis', mit(server({ 'GET /api/unterkunft/rechnungen': () => ({ body: { rechnungen: [] } }) }), async (fx) => {
const { panel } = await zeigeMit(fx, renderRechnungen)
assert.match(fx.text(panel), /Noch keine Rechnungen/)
assert.equal(fx.alle(panel, '[data-rechnung]').length, 0)
}))
test('Einstellungen: Pflichtfelder markiert, leeres Formular sendet nicht und zeigt Feldfehler', mit(esServer({}, { einstellungen: { ...ES, firma: '', steuernummer: '', ustId: '' } }), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
for (const n of ['firma', 'inhaber', 'strasse', 'plz', 'ort', 'email']) assert.match(fx.text(fx.finde(panel, `[data-feld="${n}"]`).parentNode.querySelector('label')), /\*/, n)
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal(posts(fx).length, 0)
assert.match(fx.text(fx.finde(panel, '[data-fehler="firma"]')), /Firma/)
assert.match(fx.text(fx.finde(panel, '[data-fehler="steuernummer"]')), /Steuernummer oder USt-IdNr/)
assert.match(fx.text(fx.finde(panel, '[data-fehler="ustId"]')), /Steuernummer oder USt-IdNr/)
}))
test('Einstellungen: speichern sendet die Felder ohne Wrapper; Serverfehler am Feld', mit(esServer({ 'PUT /api/unterkunft/einstellungen': () => ({ status: 400, body: { fehler: 'ungueltig', felder: { iban: 'Bitte gib eine gültige IBAN an.' } } }) }), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
await fx.setze(fx.finde(panel, '[data-feld="iban"]'), 'xx')
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
const put = posts(fx)[0]
assert.equal(put.url, '/api/unterkunft/einstellungen?projectId=p1')
assert.equal(put.body.firma, 'Haus am See')
assert.equal(put.body.rechnungsPraefix, 'RE')
assert.equal(put.body.rechnungAuftrag, true)
assert.equal(put.body.iban, 'xx')
assert.equal(put.body.onlineZahlung, false)
assert.match(fx.text(fx.finde(panel, '[data-fehler="iban"]')), /gültige IBAN/)
}))
test('Einstellungen: Präfix prüfen; gesperrt mit Hinweis, sobald Rechnungen existieren', async () => {
await mit(esServer(), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
await fx.setze(fx.finde(panel, '[data-feld="rechnungsPraefix"]'), 'a')
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal(posts(fx).length, 0)
assert.match(fx.text(fx.finde(panel, '[data-fehler="rechnungsPraefix"]')), /2 bis 8/)
await fx.setze(fx.finde(panel, '[data-feld="rechnungsPraefix"]'), 'ab1')
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].body.rechnungsPraefix, 'AB1')
})()
await mit(esServer({}, { rechnungenVorhanden: true }), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
const p = fx.finde(panel, '[data-feld="rechnungsPraefix"]')
assert.ok(p.disabled)
assert.match(fx.text(p.parentNode), /gesperrt/)
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].body.rechnungsPraefix, 'RE')
})()
})
test('Einstellungen: Mustertext übernehmen füllt die Stornobedingungen, Hinweis „keine Rechtsberatung“', mit(esServer(), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
const t = fx.finde(panel, '[data-feld="stornobedingungen"]')
assert.equal(t.value, '')
assert.match(fx.text(t.parentNode), new RegExp(STORNO_HINWEIS.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
await fx.klick(fx.finde(panel, '[data-aktion="storno-muster"]'))
assert.equal(t.value, STORNO_MUSTER)
assert.match(STORNO_MUSTER, /^Stornierung bis 14 Tage vor Anreise: kostenlos\./)
assert.match(STORNO_MUSTER, /Maßgeblich ist der Eingang bei uns\.$/)
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].body.stornobedingungen, STORNO_MUSTER)
}))
test('Einstellungen: Auftrag zur Rechnungsstellung – ohne Haken Hinweis, mit Haken weg und rechnungAuftrag im PUT', mit(esServer({}, { einstellungen: { ...ES, rechnungAuftrag: false } }), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
const box = fx.finde(panel, '[data-feld="rechnungAuftrag"]')
assert.match(fx.text(box.parentNode), new RegExp(AUFTRAG_TEXT.slice(0, 40)))
const hinweis = fx.finde(panel, '[data-hinweis="auftrag"]')
assert.equal(hinweis.hidden, false)
assert.match(fx.text(hinweis), /nicht bestätigen/)
await fx.klick(box)
assert.equal(hinweis.hidden, true)
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].body.rechnungAuftrag, true)
}))
test('Einstellungen: Stripe nicht verbunden – Verbinden leitet nur auf https; „Vorhandenes Webshop-Konto“ nur wenn gemeldet', async () => {
await mit(esServer({ 'POST /api/unterkunft/stripe/verbinden': () => ({ body: { url: 'https://connect.stripe.com/x' } }) }), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
assert.equal(fx.finde(panel, '[data-stripe-status]').getAttribute('data-stripe-status'), 'keins')
assert.equal(fx.finde(panel, '[data-aktion="stripe-vorhanden"]'), null)
assert.equal(fx.finde(panel, '[data-feld="onlineZahlung"]'), null)
await fx.klick(fx.finde(panel, '[data-aktion="stripe-verbinden"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].url, '/api/unterkunft/stripe/verbinden?projectId=p1')
assert.equal(fx.window.location.href, 'https://connect.stripe.com/x')
})()
await mit(esServer({ 'POST /api/unterkunft/stripe/verbinden': () => ({ body: { url: 'javascript:alert(1)' } }) }), async (fx) => {
const { panel, ctx } = await zeigeMit(fx, renderEinstellungen)
await fx.klick(fx.finde(panel, '[data-aktion="stripe-verbinden"]'))
await fx.ruhe()
assert.notEqual(fx.window.location.href, 'javascript:alert(1)')
assert.equal(ctx.meldungen.at(-1).art, 'fehler')
})()
await mit(esServer({ 'POST /api/unterkunft/stripe/vorhandenes-konto': () => ({ body: { stripe: { status: 'aktiv' } } }) }, { stripe: { status: 'keins', vorhandenesShopKonto: true } }), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
await fx.klick(fx.finde(panel, '[data-aktion="stripe-vorhanden"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].url, '/api/unterkunft/stripe/vorhandenes-konto?projectId=p1')
assert.ok(fx.fetchLog.filter((a) => a.method === 'GET' && a.url.startsWith('/api/unterkunft/einstellungen')).length >= 2) // neu geladen
})()
})
test('Einstellungen: Stripe Onboarding offen und aktiv; Schalter Online-Zahlung nur bei aktiv', async () => {
await mit(esServer({ 'POST /api/unterkunft/stripe/aktualisieren': () => ({ body: { stripe: { status: 'aktiv' } } }) }, { stripe: { status: 'onboarding', vorhandenesShopKonto: true } }), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
assert.equal(fx.finde(panel, '[data-stripe-status]').getAttribute('data-stripe-status'), 'onboarding')
assert.match(fx.text(panel), /Angaben bei Stripe/)
assert.ok(fx.finde(panel, '[data-aktion="stripe-aktualisieren"]'))
assert.equal(fx.finde(panel, '[data-aktion="stripe-vorhanden"]'), null)
assert.equal(fx.finde(panel, '[data-feld="onlineZahlung"]'), null)
})()
await mit(esServer({}, { einstellungen: { ...ES, onlineZahlung: true }, stripe: { status: 'aktiv', vorhandenesShopKonto: false } }), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen)
assert.equal(fx.finde(panel, '[data-stripe-status]').getAttribute('data-stripe-status'), 'aktiv')
const schalter = fx.finde(panel, '[data-feld="onlineZahlung"]')
assert.ok(schalter.checked)
await fx.klick(schalter)
await fx.klick(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].body.onlineZahlung, false)
})()
})
test('Einstellungen: nur lesen sperrt Formular und Stripe-Knöpfe', mit(esServer(), async (fx) => {
const { panel } = await zeigeMit(fx, renderEinstellungen, { nurLesen: true })
assert.equal(fx.finde(panel, '[data-aktion="stripe-verbinden"]'), null)
assert.ok(fx.finde(panel, '[data-feld="firma"]').disabled)
assert.equal(fx.finde(panel, '[data-aktion="einstellungen-speichern"]'), null)
}))
test('Anfrageformular sendet { art, einheiten, portale, nachricht }; Feldfehler am Feld', async () => {
let antwort = { body: { anfrage: { status: 'offen' } } }
await mit((a) => (a.method === 'POST' ? antwort : { body: {} }), async (fx) => {
const wurzel = h('div')
fx.document.body.append(wurzel)
let danach = 0
renderAnfrageFormular(wurzel, { projectId: 'p1', nachSenden: async () => { danach += 1 } })
antwort = { status: 400, body: { fehler: 'ungueltig', felder: { art: 'Bitte wähle die Art deiner Unterkunft.' } } }
await fx.klick(fx.finde(wurzel, '[data-aktion="anfrage-senden"]'))
await fx.ruhe()
assert.match(fx.text(fx.finde(wurzel, '[data-fehler="art"]')), /Art deiner Unterkunft/)
assert.equal(danach, 0)
antwort = { body: { anfrage: { status: 'offen' } } }
await fx.setze(fx.finde(wurzel, '[data-feld="art"]'), 'ferienwohnung')
await fx.setze(fx.finde(wurzel, '[data-feld="einheiten"]'), '3')
await fx.klick(fx.finde(wurzel, '[data-feld="portale.airbnb"]'))
await fx.setze(fx.finde(wurzel, '[data-feld="nachricht"]'), ' Hallo ')
await fx.klick(fx.finde(wurzel, '[data-aktion="anfrage-senden"]'))
await fx.ruhe()
const p = posts(fx).at(-1)
assert.equal(p.url, '/api/unterkunft/anfrage-funktion?projectId=p1')
assert.deepEqual(p.body, { art: 'ferienwohnung', einheiten: 3, portale: ['airbnb'], nachricht: 'Hallo' })
assert.equal(danach, 1)
assert.match(fx.text(wurzel), /199 €/)
})()
})
test('Anfragekarte: offen mit Zurückziehen, abgelehnt mit Begründung als Text', async () => {
await mit(server(), async (fx) => {
let neuGeladen = 0
const k = anfrageKarte({ status: 'offen', at: '2026-10-07T10:00:00Z', anfrage: { art: 'monteur', einheiten: 2, portale: ['booking'], nachricht: 'x' } }, { projectId: 'p1', neuLaden: async () => { neuGeladen += 1 } })
fx.document.body.append(k)
assert.match(fx.text(k), /In Prüfung/)
assert.match(fx.text(k), /Monteurwohnung/)
assert.match(fx.text(k), /Booking\.com/)
await fx.klick(fx.finde(k, '[data-aktion="zurueckziehen"]'))
await fx.ruhe()
assert.equal(posts(fx)[0].url, '/api/unterkunft/anfrage-funktion/zurueckziehen?projectId=p1')
assert.equal(neuGeladen, 1)
const b = anfrageKarte({ status: 'abgelehnt', begruendung: 'Passt <i>nicht</i>' }, { projectId: 'p1', neu: () => {} })
assert.match(fx.text(b), /Passt <i>nicht<\/i>/)
assert.equal(fx.finde(b, 'i'), null)
assert.ok(fx.finde(b, '[data-aktion="neue-anfrage"]'))
})()
})
test('Ansicht #buchungen: Zustand off zeigt das Anfrageformular, Projekt vom Wunsch-Attribut', async () => {
vergissAuswahl()
await mit((a) => {
if (a.url === '/api/funktionen') return { body: { unterkunft: { verfuegbar: true }, projects: [{ projectId: 'p1', projectName: 'A', features: { unterkunft: { state: 'off' } } }, { projectId: 'p2', projectName: 'B', features: { unterkunft: { state: 'off' } } }] } }
if (a.url.startsWith('/api/unterkunft/status')) return { body: { state: 'off' } }
return { body: {} }
}, async (fx) => {
const root = h('div')
root.dataset.wunschProjekt = 'p2'
fx.document.body.append(root)
await initBuchungen(root)
await fx.ruhe()
assert.equal(fx.fetchLog[1].url, '/api/unterkunft/status?projectId=p2')
assert.ok(fx.finde(root, '[data-aktion="anfrage-senden"]'))
assert.match(fx.text(root), /Buchungskalender anfragen/)
})()
vergissAuswahl()
})
+17
View File
@@ -564,3 +564,20 @@ test('Webshop-Kachel: Projekt-ID maskiert, Klick legt das Wunschprojekt in #webs
} }
assert.equal(body.dataset.wunschProjekt, 'p7') assert.equal(body.dataset.wunschProjekt, 'p7')
}) })
// ---- Buchungskalender-Kachel: nur bei unterkunft.verfuegbar, Preis 199 €, Zustände wie die Webshop-Kachel
test('Buchungskalender-Kachel: nur bei unterkunft.verfuegbar; Text, Preis und Zustände', async () => {
const mitUk = (state) => project('a', undefined) && { ...project('a'), features: { news, unterkunft: { state } } }
const ohne = await renderMit([mitUk('off')], {})
assert.doesNotMatch(ohne, /data-fn-tile="unterkunft"/)
const zu = await renderMit([mitUk('off')], { unterkunft: { verfuegbar: false } })
assert.doesNotMatch(zu, /data-fn-tile="unterkunft"/)
const off = tile(await renderMit([mitUk('off')], { unterkunft: { verfuegbar: true } }), 'unterkunft')
assert.match(off, /<h3>Buchungskalender<\/h3>/)
assert.match(off, /Belegungskalender auf deiner Website, Anfragen freigeben, Rechnung und Online-Zahlung/)
assert.match(off, /199 €/)
assert.match(off, /Buchungskalender anfragen/)
assert.match(off, /data-view="buchungen" data-project="a"/)
const faelle = { active: /Aktiv[\s\S]*Buchungskalender öffnen/, setup: /In Einrichtung/, ordered: /Zahlung offen/, disabled: /Abgeschaltet[\s\S]*Buchungskalender ansehen/ }
for (const [state, re] of Object.entries(faelle)) assert.match(tile(await renderMit([mitUk(state)], { unterkunft: { verfuegbar: true } }), 'unterkunft'), re, state)
})