/* Kundenansicht neu (P7): Übernahmegutschrift beantragen (#gutschrift) im schlanken Muster der Spec 5.6, Fassung 6. Antrag stellen, Stand sehen, Rückfragen beantworten. Über dem Knopf steht ein Satz mit den Links zu den Bedingungen und zum Datenschutz (der Klick auf den Knopf ist die Zustimmung, kein Häkchen). Die Bedingungen sind eine eigene Ansicht (#gutschrift?bedingungen=1) mit unverändertem Rechtstext (Block TAKEOVER_CONDITIONS, Hash-Pin in legalTexts.test.js, conditionsVersion 2026-09-30). Kundentext (Rückfrage, Entscheidung, Anbieter, Domain) kommt nur als Text in die Seite. Der Titel der Bedingungen kommt von der Hülle (meldeTitel); keine eigene h1. Die Sie-Form steht nur noch im Rechtstext TAKEOVER_CONDITIONS (hash-gepinnt), sonst gilt „du“. */ import { api } from './api.js' import { h, leeren } from './dom.js' import { K, ersetze, zuAnsicht, ladeAbo, fmtDatum, fmtEuro, meldeTitel, meldeZaehlerNeu, ABO_NICHT_GELADEN } from './ansicht-hilfen.js' import { baueLinkzeile, hatWortlaut } from './bestell-bausteine.js' const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs const TEXT_ANSICHTSMODUS = 'Im Ansichtsmodus kann kein Antrag gestellt werden.' const TEXT_NICHT_VERFUEGBAR = 'Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat.' const TITEL_BEDINGUNGEN = 'Bedingungen der Übernahmegutschrift' const GRENZEN_VORGABE = { maxFiles: 3, maxFileBytes: 8388608, maxTotalBytes: 10485760 } const TAKEOVER_STATUS = { offen: ['In Prüfung', 'orange'], rueckfrage: ['Rückfrage', 'violett'], genehmigt: ['Genehmigt', 'gruen'], abgelehnt: ['Nicht genehmigt', 'grau'], } // Bei Textänderungen config.takeover.conditionsVersion auf dem Server hochziehen const TAKEOVER_CONDITIONS = [ 'Die Übernahmegutschrift gibt es für Leistungen, die Ihr Abo ersetzt (Domain, Hosting bzw. Betrieb der Website, E-Mail-Postfächer) und die Sie bei Ihrem bisherigen Anbieter bereits im Voraus bezahlt haben.', 'Wir schreiben den Anteil der Rechnung gut, der auf die Zeit ab Leistungsbeginn Ihres Abos bis zum Ende des bezahlten Zeitraums entfällt, tagesgenau berechnet.', 'Nachweis ist die Rechnung des bisherigen Anbieters, ausgestellt auf Sie, mit Leistung, Zeitraum und Betrag. Wir können weitere Nachweise anfordern, etwa die Kündigungsbestätigung. Erstattet Ihr bisheriger Anbieter Beträge für diesen Zeitraum, teilen Sie uns das bitte mit.', 'Die Gutschrift wird mit Ihren nächsten Monatsgebühren verrechnet, auch nach einem Wechsel in ein anderes Abo. Sie wird nicht ausgezahlt und ist nicht übertragbar; endet Ihr Abo ohne einen solchen Wechsel, entfällt ein noch nicht verrechneter Rest.', 'Die Übernahmegutschrift ist eine freiwillige Leistung von WEBklar. Über Ihren Antrag entscheiden wir nach Prüfung und antworten Ihnen per E-Mail.', ] const TAKEOVER_MONTHS = [[1, '1 Monat'], [3, '3 Monate'], [6, '6 Monate'], [12, '12 Monate (Jahresrechnung)'], [24, '24 Monate'], [36, '36 Monate']] /** Datei als Base64 lesen (ohne den data:-Anfang). Tests ersetzen IO.lies, im Test gibt es keinen FileReader. */ function leseAlsBase64(datei) { return new Promise((resolve, reject) => { const leser = new FileReader() leser.onload = () => resolve(String(leser.result).split(',')[1] || '') leser.onerror = () => reject(new Error(`„${datei.name}“ konnte nicht gelesen werden.`)) leser.readAsDataURL(datei) }) } export const IO = { lies: leseAlsBase64 } /** Dateien prüfen und lesen: → [{ name, data }]; wirft die Texte der Prüfungen aus app.js. */ export async function sammleDateien(dateien, grenzen, { erforderlich }, lies = IO.lies) { const files = [...(dateien || [])] const mb = (bytes) => Math.round(bytes / 1024 / 1024) if (erforderlich && !files.length) throw new Error('Bitte lade die Rechnung deines bisherigen Anbieters hoch.') if (files.length > grenzen.maxFiles) throw new Error(`Bitte höchstens ${grenzen.maxFiles} Dateien auf einmal hochladen.`) const zuGross = files.find((f) => f.size > grenzen.maxFileBytes) if (zuGross) throw new Error(`„${zuGross.name}“ ist größer als ${mb(grenzen.maxFileBytes)} MB.`) if (files.reduce((s, f) => s + f.size, 0) > grenzen.maxTotalBytes) throw new Error(`Die Dateien sind zusammen größer als ${mb(grenzen.maxTotalBytes)} MB.`) return Promise.all(files.map(async (f) => ({ name: f.name, data: await lies(f) }))) } const feld = (label, steuerung, klein) => h('div', { class: 'kn-tk-feld' }, h('label', { for: steuerung.getAttribute('id') }, label, klein ? [' ', h('small', null, klein)] : null), steuerung) const leistung = (wert, text) => h('label', null, h('input', { type: 'checkbox', name: 'services', value: wert }), ` ${text}`) function antragsFormular(grenzen, email, T) { const maxMb = Math.round(grenzen.maxTotalBytes / 1024 / 1024) return h('form', { id: 'tk-form', class: 'kn-tk-form kn-bestellen', novalidate: true, hidden: true }, h('fieldset', { class: 'kn-tk-feld' }, h('legend', null, 'Welche Leistung hast du bisher woanders bezahlt?'), h('div', { class: 'kn-tk-leistungen' }, leistung('domain', 'Domain'), leistung('hosting', 'Hosting / Website-Betrieb'), leistung('email', 'E-Mail-Postfächer'), leistung('sonstiges', 'Sonstiges'))), h('div', { class: 'kn-tk-zeile' }, feld('Bisheriger Anbieter', h('input', { id: 'tk-provider', class: K.input, maxlength: 200, placeholder: 'z. B. IONOS, STRATO' })), feld('Domain', h('input', { id: 'tk-domain', class: K.input, maxlength: 255, placeholder: 'meine-firma.de', autocapitalize: 'off', spellcheck: 'false' }), '(falls vorhanden)')), h('div', { class: 'kn-tk-zeile' }, feld('Rechnungsbetrag', h('input', { id: 'tk-amount', class: K.input, type: 'number', min: '0.01', step: '0.01', inputmode: 'decimal', placeholder: '119,88' }), '(brutto, in €)'), feld('Die Rechnung gilt für', h('select', { id: 'tk-months', class: K.select }, h('option', { value: '' }, 'bitte wählen'), TAKEOVER_MONTHS.map(([v, l]) => h('option', { value: String(v) }, l)))), feld('Bezahlt bis', h('input', { id: 'tk-paid-until', class: K.input, type: 'date' }), '(letzter Tag)')), feld('Grund / Nachricht an uns', h('textarea', { id: 'tk-message', class: K.textarea, maxlength: 4000, placeholder: 'z. B.: Domain und Postfach liegen noch bis Ende März bei IONOS, der Vertrag dort ist gekündigt.' })), feld('Rechnungsnachweis', h('input', { id: 'tk-files', type: 'file', accept: 'application/pdf,image/jpeg,image/png', multiple: true }), `(PDF, JPG oder PNG · bis zu ${grenzen.maxFiles} Dateien · zusammen max. ${maxMb} MB)`), h('p', { class: 'kn-hinweis' }, 'Tipp: Lade auch die Kündigungsbestätigung deines bisherigen Anbieters hoch – daraus sehen wir, wann der Vertrag dort endet.'), feld('E-Mail-Adresse für unsere Antwort', h('input', { id: 'tk-email', class: K.input, type: 'email', maxlength: 255, autocomplete: 'email', value: email })), baueLinkzeile(T.gutschrift.satz, { id: 'tk-satz', neuerTab: true }), h('p', { class: K.fehler, id: 'tk-error', hidden: true }), h('div', { class: 'kn-aktionen' }, h('button', { type: 'submit', class: K.primaer, id: 'tk-submit' }, T.gutschrift.knopf || 'Antrag absenden'), h('button', { type: 'button', class: K.knopf, id: 'tk-cancel' }, 'Abbrechen'))) } function antrag(r, viewAs) { const fakten = [ `eingereicht am ${fmtDatum(r.createdAt)}`, `bisher bei ${r.provider}${r.domain ? ` (${r.domain})` : ''}`, `${fmtEuro(r.invoiceAmountCents / 100)} für ${r.billingMonths === 1 ? '1 Monat' : `${r.billingMonths} Monate`}, bezahlt bis ${fmtDatum(r.paidUntil)}`, ] const [pilleText, ton] = TAKEOVER_STATUS[r.status] || [r.status, 'grau'] let koerper if (r.status === 'genehmigt') { koerper = [h('p', null, 'Gutschrift: ', h('strong', null, fmtEuro(r.amountCents / 100)), ' – wird mit deinen nächsten Monatsgebühren verrechnet.'), r.decisionNote ? h('p', { class: 'kn-hinweis kn-umbruch' }, r.decisionNote) : null] } else if (r.status === 'abgelehnt') { koerper = [h('p', { class: 'kn-hinweis kn-umbruch' }, r.decisionNote)] } else if (r.status === 'rueckfrage') { koerper = [ h('div', { class: 'kn-tk-frage' }, h('strong', null, 'Unsere Rückfrage:'), h('br'), r.question), h('form', { class: 'kn-tk-antwort', 'data-reply-for': r.id, novalidate: true }, h('textarea', { name: 'message', class: K.textarea, maxlength: 4000, placeholder: 'Deine Antwort…' }), h('input', { type: 'file', name: 'files', accept: 'application/pdf,image/jpeg,image/png', multiple: true, 'aria-label': 'Weitere Nachweise' }), h('p', { class: K.fehler, hidden: true }), h('div', { class: 'kn-aktionen' }, h('button', { type: 'submit', class: K.primaer, disabled: viewAs }, 'Antwort senden')))] } else { koerper = [h('p', { class: 'kn-hinweis' }, 'Wir prüfen deine Angaben und antworten dir per E-Mail.')] } return h('div', { class: 'kn-tk-antrag' }, h('div', { class: 'kn-tk-kopf' }, h('strong', null, `Antrag ${r.ref} · ${r.services}`), h('span', { class: K.pille[ton] }, pilleText)), h('div', { class: 'kn-hinweis' }, fakten.join(' · ')), ...koerper) } /** Seite Übernahmegutschrift. Lädt Abo und Anträge selbst; nach Absenden oder Antworten lädt sie neu und meldet den Zähler. */ export async function zeigeGutschrift(wurzel, { route, me, nav } = {}) { const viewAs = me?.viewAs === true const email = me?.customer?.email || '' const nr = (laeufe.get(wurzel) ?? 0) + 1 laeufe.set(wurzel, nr) const aktuell = () => laeufe.get(wurzel) === nr // Die Bedingungen sind eine eigene Ansicht (#gutschrift?bedingungen=1): fünf Sätze, kein Abruf, kein Formular if (new URLSearchParams(String(route?.query ?? '')).get('bedingungen') === '1') { meldeTitel(wurzel, TITEL_BEDINGUNGEN) leeren(wurzel).append(h('div', { class: 'kn-panel kn-gutschrift-bedingungen', 'data-karte': 'gutschrift-bedingungen' }, h('ol', { class: 'kn-tk-bedingungen' }, TAKEOVER_CONDITIONS.map((c) => h('li', null, c))), h('p', { class: 'kn-links' }, h('a', { ...zuAnsicht('gutschrift'), href: '#gutschrift' }, 'Zurück zum Antrag')))) return } leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …')) await lade('') async function lade(notice, { zaehler = false } = {}) { const abo = await ladeAbo() if (!aktuell()) return if (!abo) { leeren(wurzel).append(h('p', { class: K.fehler }, ABO_NICHT_GELADEN)); return } const aktiv = Boolean(abo.subscription?.active) let daten = { requests: [], limits: null } if (abo.subscription) { try { daten = await api('/api/abo/takeover-credits') } catch (f) { if (!aktuell()) return leeren(wurzel).append(h('div', { 'data-karte': 'gutschrift' }, h('div', { class: 'kn-panel kn-leer' }, `Übernahmegutschrift konnte nicht geladen werden: ${f.message}`))) return } if (!aktuell()) return } if (zaehler) meldeZaehlerNeu('vertraege') const anfragen = Array.isArray(daten.requests) ? daten.requests : [] const grenzen = daten.limits || GRENZEN_VORGABE if (!aktiv && !anfragen.length) { leeren(wurzel).append(h('div', { 'data-karte': 'gutschrift' }, h('div', { class: 'kn-panel' }, h('p', { class: 'kn-hinweis' }, 'Die Übernahmegutschrift kannst du beantragen, sobald dein Abo läuft. ', h('a', zuAnsicht('vertraege'), 'Zum Abo'))))) return } // Ohne Wortlaut vom Server (der Satz über dem Knopf) gibt es kein Formular und keinen Knopf const wortlaut = hatWortlaut(abo, 'gutschrift') const formular = aktiv && wortlaut ? antragsFormular(grenzen, email, abo.bestelltexte) : null const start = aktiv && wortlaut ? h('button', { type: 'button', class: K.knopf, id: 'tk-start', disabled: viewAs }, 'Gutschrift beantragen') : null ersetze(wurzel, h('div', { 'data-karte': 'gutschrift' }, h('div', { class: 'kn-panel' }, h('p', { class: 'kn-hinweis kn-tk-einleitung' }, 'Hast du Domain, Hosting oder E-Mail bei deinem bisherigen Anbieter schon im Voraus bezahlt? Den Anteil, den du ab Beginn deines Abos doppelt zahlst, schreiben wir dir gut und verrechnen ihn mit deinen nächsten Monatsgebühren. Schick uns dazu einfach die Rechnung.'), notice ? h('div', { class: 'kn-meldung kn-meldung-ok', role: 'status' }, notice) : null, aktiv && wortlaut ? h('div', { class: 'kn-aktionen' }, start, viewAs ? h('span', { class: K.meldung }, TEXT_ANSICHTSMODUS) : null) : null, aktiv && !wortlaut ? h('p', { class: 'kn-hinweis' }, TEXT_NICHT_VERFUEGBAR) : null, formular, anfragen.length ? h('div', { class: 'kn-tk-liste' }, anfragen.map((r) => antrag(r, viewAs))) : null))) const neuLaden = (text) => lade(text, { zaehler: true }) start?.addEventListener('click', () => { formular.hidden = false start.hidden = true formular.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' }) }) formular?.querySelector('#tk-cancel')?.addEventListener('click', () => { formular.hidden = true; if (start) start.hidden = false }) formular?.addEventListener('submit', (ev) => { ev.preventDefault(); sendeAntrag(formular, grenzen, neuLaden, abo.termsVersion) }) wurzel.querySelectorAll('form[data-reply-for]').forEach((f) => f.addEventListener('submit', (ev) => { ev.preventDefault(); sendeAntwort(f, grenzen, neuLaden, viewAs) })) } } function zeigeFehler(el, text) { if (!el) return el.textContent = text el.hidden = false } async function sendeAntrag(form, grenzen, neuLaden, termsVersion) { const fehler = form.querySelector('#tk-error') const knopf = form.querySelector('#tk-submit') fehler.hidden = true const wert = (id) => form.querySelector(id).value.trim() const payload = { services: [...form.querySelectorAll('input[name="services"]:checked')].map((i) => i.value), provider: wert('#tk-provider'), domain: wert('#tk-domain'), invoiceAmount: wert('#tk-amount'), billingMonths: Number(wert('#tk-months')), paidUntil: wert('#tk-paid-until'), message: wert('#tk-message'), email: wert('#tk-email'), // Der Klick auf den Knopf ist die Zustimmung zu Satz und Bedingungen; die Kundenart gibt es hier nicht, der Server kennt sie consent: true, termsVersion, } try { if (!payload.services.length) throw new Error('Bitte wähle, welche Leistung du bisher woanders bezahlt hast.') if (!payload.provider) throw new Error('Bitte nenne deinen bisherigen Anbieter.') if (!payload.invoiceAmount || !payload.billingMonths || !payload.paidUntil) throw new Error('Bitte gib Rechnungsbetrag, Zeitraum und „Bezahlt bis“ an.') knopf.disabled = true knopf.textContent = 'Wird gesendet…' payload.files = await sammleDateien(form.querySelector('#tk-files').files, grenzen, { erforderlich: true }) await api('/api/abo/takeover-credits', { method: 'POST', body: payload }) await neuLaden(`Vielen Dank! Dein Antrag ist eingegangen. Die Eingangsbestätigung haben wir an ${payload.email} geschickt; unsere Antwort kommt ebenfalls per E-Mail.`) } catch (f) { zeigeFehler(fehler, f.message) knopf.disabled = false knopf.textContent = 'Antrag absenden' } } async function sendeAntwort(form, grenzen, neuLaden, viewAs) { if (viewAs) return const fehler = form.querySelector('p') const knopf = form.querySelector('button[type="submit"]') fehler.hidden = true knopf.disabled = true try { const message = form.querySelector('textarea').value.trim() const files = await sammleDateien(form.querySelector('input[type="file"]').files, grenzen, { erforderlich: false }) if (!message && !files.length) throw new Error('Bitte schreib eine Antwort oder lade einen Nachweis hoch.') await api(`/api/abo/takeover-credits/${encodeURIComponent(form.getAttribute('data-reply-for'))}/reply`, { method: 'POST', body: { message, files } }) await neuLaden('Danke für deine Antwort! Wir prüfen deinen Antrag weiter und melden uns per E-Mail.') } catch (f) { zeigeFehler(fehler, f.message) knopf.disabled = false } }