diff --git a/public/buchungen-anfrage.js b/public/buchungen-anfrage.js new file mode 100644 index 0000000..29f604e --- /dev/null +++ b/public/buchungen-anfrage.js @@ -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'))) +} diff --git a/public/buchungen-tab-einstellungen.js b/public/buchungen-tab-einstellungen.js index ef2a77e..4e2d5b9 100644 --- a/public/buchungen-tab-einstellungen.js +++ b/public/buchungen-tab-einstellungen.js @@ -1,6 +1,184 @@ -/* Buchungskalender, Reiter „Einstellungen“: Platzhalter (Task 13/14 fuellt ihn). */ -import { infoKarte } from './shop-dom.js' +/* Buchungskalender, Reiter „Einstellungen“: Firmendaten, Steuernummer/USt-IdNr., Rechnungspraefix, Auftrag zur Rechnungsstellung, + 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) { - panel.replaceChildren(infoKarte('Einstellungen', 'Dieser Bereich wird gerade gebaut.')) +import { abschnitt, allgemeinerFehler, feld, h, hakenFeld, setzeFehler } from './shop-dom.js' +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) } diff --git a/public/buchungen-tab-rechnungen.js b/public/buchungen-tab-rechnungen.js index 1969d58..ccdca39 100644 --- a/public/buchungen-tab-rechnungen.js +++ b/public/buchungen-tab-rechnungen.js @@ -1,6 +1,33 @@ -/* Buchungskalender, Reiter „Rechnungen“: Platzhalter (Task 13/14 fuellt ihn). */ -import { infoKarte } from './shop-dom.js' +/* Buchungskalender, Reiter „Rechnungen“: Liste der Rechnungen und Storno-Rechnungen mit PDF-Link. + API: GET /api/unterkunft/rechnungen?projectId= => { rechnungen:[{ id, nummer, datum, art:'rechnung'|'storno', gastName, bruttoCent, anfrageNummer? }] }; + PDF: GET /api/unterkunft/rechnungen/.pdf?projectId= (Einzel-PDF, kein ZIP). Fremdtext nur ueber textContent. render(panel, ctx). */ -export function render(panel) { - panel.replaceChildren(infoKarte('Rechnungen', 'Dieser Bereich wird gerade gebaut.')) +import { abschnitt, datumText, euroText, h, infoKarte } from './shop-dom.js' +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))) } diff --git a/public/buchungen.css b/public/buchungen.css index 6789bec..a366100 100644 --- a/public/buchungen.css +++ b/public/buchungen.css @@ -55,3 +55,11 @@ .bk-vorschau { font-weight: 700; } .bk-import { display: flex; align-items: center; gap: 4px 12px; flex-wrap: wrap; } .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; } } diff --git a/public/buchungen.js b/public/buchungen.js index ce75ecb..03fe037 100644 --- a/public/buchungen.js +++ b/public/buchungen.js @@ -5,6 +5,7 @@ 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 { anfrageKarte, renderAnfrageFormular } from './buchungen-anfrage.js' /** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */ export const REITER = [ @@ -24,11 +25,14 @@ export function vergissAuswahl() { } 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' export function initBuchungen(root, { projectId } = {}) { 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) { @@ -109,10 +113,33 @@ function zeichneAnsicht(s, { status, aktuell }) { s.status = status const zustand = status?.state || zustandVon(s.projekt) 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.')) } +/* 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 }) { const neuLaden = async () => { const neu = await ladeStatus(s.projekt.projectId) diff --git a/public/funktionen.js b/public/funktionen.js index d1cf11f..6b8efd6 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -260,6 +260,51 @@ function shopTile(projects, features) { ` } +/* 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) => `` + 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 = '

Sobald deine Website angelegt ist, kannst du den Buchungskalender anfragen.

' + else if (state === 'disabled') extra = '

Der Buchungskalender ist für diese Website abgeschaltet. Deine Buchungen kannst du noch ansehen.

' + const count = projects.length > 1 && active ? `

aktiv für ${active} von ${projects.length} Websites

` : '' + return ` +
+
${svg('calendar')}${pill[0]}
+

Buchungskalender

+

${UNTERKUNFT_TEXT}. ${esc(preis)} einmalig.

+ ${extra} + ${count} + ${action} +
` +} + /* 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 (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) { 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 = `
${newsTile(projects, newsOffer(features).price)} ${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)} ${shop?.verfuegbar ? shopTile(projects, features) : ''} + ${unterkunft?.verfuegbar === true ? unterkunftTile(projects, features) : ''} ${zeiten?.verfuegbar === true ? zeitenTile(projects, zeiten) : ''} ${analyseTile(projects)}
` @@ -407,6 +453,11 @@ export async function initFunktionen(root) { const body = document.getElementById('webshop-body') 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 root.querySelector('[data-fn-tile="zeiten"]')?.addEventListener('click', (ev) => { const knopf = ev.target?.closest?.('button[data-view="zeiten"]') diff --git a/test/buchungenUi.test.js b/test/buchungenUi.test.js index 596c91c..3506b91 100644 --- a/test/buchungenUi.test.js +++ b/test/buchungenUi.test.js @@ -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.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 M', 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 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 nicht' }, { projectId: 'p1', neu: () => {} }) + assert.match(fx.text(b), /Passt 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() +}) diff --git a/test/funktionenTiles.test.js b/test/funktionenTiles.test.js index 66136da..d678363 100644 --- a/test/funktionenTiles.test.js +++ b/test/funktionenTiles.test.js @@ -564,3 +564,20 @@ test('Webshop-Kachel: Projekt-ID maskiert, Klick legt das Wunschprojekt in #webs } 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, /

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) +})