Files
Webklar-Kundenbereich/public/buchungen-tab-einstellungen.js
T

185 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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)
}