190 lines
13 KiB
JavaScript
190 lines
13 KiB
JavaScript
/* 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)
|
||
}
|