/* Buchungskalender, Reiter „Einstellungen“: Firmendaten, Steuernummer/USt-IdNr., Rechnungspraefix, Auftrag zur Rechnungsstellung, Bankverbindung, Akzentfarbe, 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 }, stripe:{ status:'keins'|'onboarding'|'aktiv', vorhandenesShopKonto:boolean }, rechnungenVorhanden:boolean }; PUT /einstellungen mit denselben Feldern (ohne Wrapper; rechnungAuftrag:true setzt rechnungAuftragAt, false widerruft); 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, frage, h, hakenFeld, setzeFehler } from './shop-dom.js' import { enc, zeichen, zeigeApiFehler } from './buchungen-uk-hilfe.js' 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_WIDERRUF = 'Ohne diese Zustimmung können keine Buchungen bestätigt werden. Zustimmung wirklich zurückziehen?' 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: 34 }); 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 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 } // Widerruf einer gespeicherten Zustimmung nur nach Rueckfrage in der Seite const widerrufPlatz = h('div', { 'data-widerruf-platz': '' }) auftrag.addEventListener('change', () => { if (!auftrag.checked && e.rechnungAuftrag === true && !widerrufPlatz.firstChild) { auftrag.checked = true const zu = () => { widerrufPlatz.replaceChildren(); auftrag.focus() } const box = frage('Zustimmung zurückziehen', h('p', null, AUFTRAG_WIDERRUF), h('div', { class: 'sh-aktionen' }, h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'widerruf-ja', onclick: () => { auftrag.checked = false; zeigeAuftrag(); zu() } }, 'Ja, zurückziehen'), h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'widerruf-nein', onclick: zu }, 'Abbrechen'))) widerrufPlatz.replaceChildren(box) box.focus() } zeigeAuftrag() }) zeigeAuftrag() /* ------------------------------------------------------------------ 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, widerrufPlatz), 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.')), 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, 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) }