/* 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. */ 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) }