Wartungs-Abo v2: Rechtsrunde umgesetzt

- Bestellseite: Pflichtangaben direkt ueber dem Button, Zahlungsarten,
  Wahl des Leistungsbeginns (sofort / nach Widerrufsfrist via 15 Tage
  Stripe-Probezeit), keine Vorauswahl
- Zustimmungsnachweis: Zeitpunkt, Version, Wahl, IP, Browser in Stripe-
  Metadaten und Appwrite (neue Attribute, Setup-Skript ergaenzt)
- Checkout: Karte + SEPA (Fallback nur Karte), korrigierter Hinweistext
- Vertragsbestaetigung mit PDF der Vertragsunterlagen statt HTML
- Kuendigen/Widerrufen: Formular direkt (keine Zwischenseite), Wunsch-
  termin, Druck/PDF der Bestaetigung, Vorbefuellung bei Login
- Widerrufsbutton im Abo waehrend der Frist, 'beginnt am'-Status
- Pilotbetrieb per ABO_PILOT_EMAILS
- Vertragsbedingungen v2, Datenschutzerklaerung, Kalkulator ohne MwSt
  und mit 100/80 EUR

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-09-25 14:10:21 +00:00
1 parent c0214725c0
commit 4ab154bfa2
18 files changed
+354 -163

No files matched your search

+40 -20
View File
@@ -1,21 +1,34 @@
// Gemeinsame Logik fuer Kuendigungs- (§ 312k BGB) und Widerrufsbutton (§ 356a BGB).
// Oeffentlich, ohne Login. Zweistufig: Einstiegsbutton -> Formular -> Bestaetigungsbutton.
// Oeffentliche Erklaerungsseiten: Kuendigung (§ 312k BGB) und Widerruf (§ 356a BGB).
// Ohne Login erreichbar, Formular sofort sichtbar (keine Zwischenschritte).
// Nach dem Absenden: Bestaetigung mit Datum/Uhrzeit, speicher- und druckbar.
function initLegalForm({ endpoint, fields }) {
const intro = document.getElementById('intro')
const form = document.getElementById('decl-form')
const done = document.getElementById('done')
const errEl = document.getElementById('form-error')
const esc = (s) => String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
document.getElementById('start-btn').addEventListener('click', () => {
intro.classList.add('hidden')
form.classList.remove('hidden')
form.querySelector('input').focus()
})
// Angemeldete Kunden nicht erneut identifizieren lassen: Felder vorbefuellen
fetch('/api/auth/me', { credentials: 'same-origin' })
.then((r) => (r.ok ? r.json() : null))
.then((me) => {
const c = me?.customer
if (!c) return
if (form.elements.name && !form.elements.name.value) form.elements.name.value = c.name || ''
if (form.elements.email && !form.elements.email.value) form.elements.email.value = c.email || ''
if (form.elements.customerNumber && !form.elements.customerNumber.value) form.elements.customerNumber.value = c.code || ''
})
.catch(() => {})
const reasonWrap = document.getElementById('reason-wrap')
form.querySelectorAll('[name="kind"]').forEach((r) =>
r.addEventListener('change', () => reasonWrap?.classList.toggle('hidden', form.kind.value !== 'ausserordentlich'))
)
const dateInput = document.getElementById('effective-date')
form.querySelectorAll('[name="when"]').forEach((r) =>
r.addEventListener('change', () => {
reasonWrap?.classList.toggle('hidden', form.kind.value !== 'ausserordentlich')
const onDate = form.when.value === 'datum'
dateInput?.classList.toggle('hidden', !onDate)
if (onDate) dateInput?.focus()
})
)
@@ -23,7 +36,8 @@ function initLegalForm({ endpoint, fields }) {
ev.preventDefault()
errEl.classList.add('hidden')
const payload = {}
for (const f of fields) payload[f] = form.elements[f]?.value?.trim?.() ?? form.elements[f]?.value ?? ''
for (const f of fields) payload[f] = String(form.elements[f]?.value ?? '').trim()
if (form.when?.value === 'datum') payload.effective = dateInput?.value || ''
const btn = form.querySelector('button[type="submit"]')
btn.disabled = true
try {
@@ -34,17 +48,21 @@ function initLegalForm({ endpoint, fields }) {
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || `Fehler ${res.status}`)
const at = new Date(data.receivedAt).toLocaleString('de-DE', { dateStyle: 'full', timeStyle: 'short' })
const esc = (s) => String(s || '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
const at = new Date(data.receivedAt).toLocaleString('de-DE', { dateStyle: 'full', timeStyle: 'medium' })
document.getElementById('done-at').textContent = at
document.getElementById('done-list').innerHTML = fields
.filter((f) => payload[f])
.map((f) => `<dt>${esc(form.querySelector(`[data-label-for="${f}"]`)?.dataset.label || f)}</dt><dd>${esc(
form.elements[f]?.type === 'radio' || form.elements[f] instanceof RadioNodeList
? form.querySelector(`[name="${f}"]:checked`)?.dataset.text || payload[f]
: payload[f]
)}</dd>`)
.join('')
const rows = [['Vertrag', 'WEBklar Wartungs-Abo']]
for (const f of fields) {
if (!payload[f] || f === 'effective') continue
const label = form.querySelector(`[data-label-for="${f}"]`)?.dataset.label || f
const value = form.querySelector(`[name="${f}"]:checked`)?.dataset.text || payload[f]
rows.push([label, value])
}
if (form.when) {
rows.push(['Zeitpunkt', payload.effective
? `zum ${new Date(payload.effective + 'T12:00:00').toLocaleDateString('de-DE')} (Ende des dann laufenden Abrechnungsmonats)`
: 'zum nächstmöglichen Zeitpunkt'])
}
document.getElementById('done-list').innerHTML = rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('')
form.classList.add('hidden')
done.classList.remove('hidden')
window.scrollTo({ top: 0, behavior: 'smooth' })
@@ -54,4 +72,6 @@ function initLegalForm({ endpoint, fields }) {
btn.disabled = false
}
})
document.getElementById('print-btn')?.addEventListener('click', () => window.print())
}