Files
Webklar-Kundenbereich/public/neu/vertraege-abo.js
T
ClaudeandClaude Sonnet 5.5 140275022d Kundenansicht neu E-Fix M6: Zahlungsknöpfe sind nach der Rückkehr von Stripe wieder bedienbar
Nach "Zahlungspflichtig bestellen" setzt die Seite den Knopf auf "Weiter zur Zahlung…" (gesperrt) und leitet zu Stripe. Bricht der Kunde per
Zurück-Wischgeste ab, stellt Safari die Seite aus dem Bfcache her: der Knopf blieb gesperrt, ein neuer Versuch ging nur nach Neuladen. Nur
ansicht-wechsel.js hatte einen pageshow-Handler. Neuer Helfer beiRueckkehr(el, fn) in ansicht-hilfen.js (pageshow mit persisted, entfernt sich,
sobald das Element nicht mehr im Dokument steht); genutzt von der Bestellseite und von "Zahlungsart aktualisieren" (Verträge).
Speicher und Gutschrift leiten nicht zu Stripe, dort ist nichts zu tun.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 21:45:15 +00:00

182 lines
10 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.
/* Kundenansicht neu (P2): Reiter Abo in Verträge. Mit laufendem Abo der Vertrag samt Einstiegen (Wechsel, Gutschrift, Widerruf),
ohne Abo die Auswahl der beiden Abos. Texte der Vertragsseite stammen aus app.js, in der Du-Form. */
import { api } from './api.js'
import { h, leeren } from './dom.js'
import { K, zuAnsicht, fmtEuro, fmtDatum, fmtTagMonat, sichereAdresse, beiRueckkehr } from './ansicht-hilfen.js'
const laeufe = new WeakMap() // Reiter-Inhalt → Nummer des jüngsten Aufrufs
const TEXT_UST = 'Gemäß § 19 UStG ohne Umsatzsteuer.'
const TEXT_ZAHLUNG = 'Die letzte Abbuchung ist fehlgeschlagen. Bitte aktualisiere deine Zahlungsart, damit deine Website online bleibt.'
const TEXT_ANSICHTSMODUS = 'Im Ansichtsmodus nicht möglich.'
const TEXT_NICHT_VERFUEGBAR = 'Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat.'
/** Die zwei Warnungen über dem Vertrag (auch im Stundenkonto): fehlgeschlagene Abbuchung, späterer Beginn nach der Widerrufsfrist. */
export function aboWarnungen(sub) {
const aus = []
if (sub?.active && sub.paymentProblem) aus.push(h('p', { class: `${K.meldung} kn-meldung-warn` }, TEXT_ZAHLUNG))
if (sub?.active && sub.serviceStartsAt) aus.push(h('p', { class: K.meldung }, `Wie gewählt beginnen wir nach Ablauf der Widerrufsfrist am ${fmtDatum(sub.serviceStartsAt)}. Dann wird auch zum ersten Mal abgebucht.`))
return aus
}
const gekuendigt = (sub) => Boolean(sub?.cancelAtPeriodEnd || sub?.cancelAt)
const produktName = (daten, key) => {
const p = (daten?.products || []).find((x) => x.key === key)
return (p?.name || (key === 'hosting' ? 'Hosting-Abo' : 'Wartungs-Abo')).replace(/^WEBklar\s+/, '')
}
const produkt = (daten, key) => (daten?.products || []).find((x) => x.key === key) || null
/** 'ohne' | 'beendet' | 'laeuft' | 'gekuendigt'. */
export function aboZustand(daten) {
const sub = daten?.subscription
if (!sub) return 'ohne'
if (!sub.active) return 'beendet'
return gekuendigt(sub) ? 'gekuendigt' : 'laeuft'
}
/** Zeile zur Abrechnung (app.js aboDateLine). */
export function aboZeile(sub) {
if (gekuendigt(sub)) return `Leistungen bis ${fmtDatum(sub.cancelAt || sub.currentPeriodEnd)}`
if (sub.serviceStartsAt) return `Beginn und erste Abbuchung am ${fmtDatum(sub.serviceStartsAt)}`
return `nächste Abbuchung ${fmtDatum(sub.currentPeriodEnd)}`
}
/** Laufzeit-Hinweis (app.js aboTermText, § 8 der Bedingungen). */
export function laufzeitText(sub) {
if (sub.cancelAtPeriodEnd) return `Gekündigt zum ${fmtDatum(sub.cancelAt || sub.currentPeriodEnd)}. Bis dahin bleiben alle Leistungen verfügbar.`
if (sub.minTermEnd && new Date(sub.minTermEnd) > new Date()) return `Mindestlaufzeit bis ${fmtDatum(sub.minTermEnd)}. Kündigung frühestens zu diesem Termin, mit einem Monat Frist.`
return 'Monatlich kündbar zum Ende des Abrechnungsmonats.'
}
const leistungen = (features) => h('ul', { class: 'kn-leistungen' }, (features || []).map(([titel, detail]) => h('li', null, titel, detail ? h('small', null, detail) : null)))
const fakt = (titel, wert) => h('div', null, h('dt', null, titel), h('dd', null, wert))
// "Zahlungsart aktualisieren": Stripe-Kundenportal; im Ansichtsmodus gesperrt
function zahlungsKnopf(viewAs) {
const fehler = h('p', { class: K.fehler, role: 'alert', hidden: true })
const knopf = h('button', { type: 'button', class: K.primaer, disabled: viewAs }, 'Zahlungsart aktualisieren')
knopf.addEventListener('click', async () => {
if (viewAs) return
knopf.disabled = true
fehler.hidden = true
try {
const { url } = await api('/api/abo/portal', { method: 'POST', body: {} })
const ziel = sichereAdresse(url)
if (!ziel) throw new Error('Die Zahlungsseite konnte nicht geöffnet werden. Bitte versuche es später noch einmal.')
window.location.href = ziel
} catch (f) {
fehler.textContent = f.message
fehler.hidden = false
knopf.disabled = false
}
})
beiRueckkehr(knopf, () => { knopf.disabled = viewAs }) // zurück von Stripe (Bfcache): wieder bedienbar (E-Fix M6)
return [knopf, viewAs ? h('span', { class: K.meldung }, TEXT_ANSICHTSMODUS) : null, fehler] // ohne Umhüllung: der Knopf ist das Element mit diesem Text
}
function wechselKarte(daten, sub) {
if (sub.productKey !== 'hosting') return null
const wartung = produkt(daten, 'wartung')
if (sub.switchPending) {
const url = sichereAdresse(sub.switchPending.paymentUrl)
return h('section', { class: 'kn-sektion kn-wechsel' },
h('h3', null, 'Dein Wechsel wartet auf die Zahlung'),
h('p', { class: 'kn-hinweis' }, `Dein Wechsel zum Wartungs-Abo ist erst abgeschlossen, wenn die Zahlung bestätigt ist.${url ? '' : ' Schreib uns gern im Chat, wenn du Hilfe brauchst.'}`),
url ? h('a', { class: K.primaer, href: url }, 'Jetzt bezahlen') : null)
}
if (gekuendigt(sub)) {
return h('section', { class: 'kn-sektion kn-wechsel' }, h('h3', null, 'Wartungs-Abo'),
h('p', { class: 'kn-hinweis' }, 'Dein Abo ist gekündigt. Bitte schreib uns im Chat, wenn du zum Wartungs-Abo wechseln möchtest.'))
}
if (!sub.canSwitch || !wartung) return null
return h('section', { class: 'kn-sektion kn-wechsel' },
h('h3', null, 'Mehr als Hosting? Das Wartungs-Abo'),
h('p', { class: 'kn-preis' }, h('strong', null, fmtEuro(wartung.amountCents / 100)), ' pro Monat'),
h('p', { class: 'kn-hinweis' }, `${TEXT_UST} Zusätzlich zum Hosting: Pflege deiner Website, beliebig viele Postfächer und 1 Stunde Arbeitszeit pro Monat.`),
h('a', { class: K.primaer, ...zuAnsicht('wechsel') }, 'Zum Wartungs-Abo wechseln'))
}
function vertragsBlock(daten, sub, viewAs) {
const zustand = aboZustand(daten)
const pille = zustand === 'gekuendigt' ? K.pille.grau : sub.paymentProblem ? K.pille.orange : K.pille.gruen
const pilleText = zustand === 'gekuendigt' ? 'gekündigt' : sub.paymentProblem ? 'Zahlung offen' : 'aktiv'
const mail = daten.mailStorage || {}
const einheiten = Number(sub.storage?.units || 0)
const gb = (Number(mail.includedGb) || 0) + einheiten * (Number(mail.gbPerUnit) || 0)
const katalog = produkt(daten, sub.productKey)
return h('section', { class: 'kn-sektion kn-vertrag', id: 'kn-vertrag' },
h('div', { class: 'kn-vertrag-kopf' }, h('h2', { class: K.abschnitt }, 'Mein Vertrag'), h('span', { class: pille }, pilleText)),
h('h3', null, produktName(daten, sub.productKey)),
h('p', { class: 'kn-preis' }, h('strong', null, fmtEuro(sub.priceAmount / 100)), ' pro Monat'),
h('p', { class: 'kn-hinweis' }, TEXT_UST),
h('dl', { class: 'kn-fakten' },
fakt(gekuendigt(sub) ? 'Leistungen' : 'Abrechnung', aboZeile(sub)),
fakt('Laufzeit', laufzeitText(sub)),
sub.offerText ? fakt('Angebot', sub.offerText) : null,
fakt('E-Mail-Speicher', `${gb} GB${einheiten ? ` (${einheiten} Zusatzpaket${einheiten === 1 ? '' : 'e'})` : ''}`)),
leistungen(katalog?.features),
h('p', { class: 'kn-links' },
h('a', { href: sub.termsUrl || '/abo-bedingungen.html', target: '_blank', rel: 'noopener noreferrer' }, 'Vertragsbedingungen'), ' · ',
h('a', { href: sub.termsPdfUrl || '/WEBklar-Abo-Vertragsunterlagen.pdf', target: '_blank', rel: 'noopener noreferrer' }, 'als PDF')),
sub.withdrawalOpen ? h('p', { class: 'kn-widerruf' }, h('a', { href: '/widerrufen.html' }, `Vertrag widerrufen · bis ${fmtTagMonat(sub.withdrawalUntil)}`)) : null,
sub.paymentProblem ? zahlungsKnopf(viewAs) : null)
}
function gutschriftLink() {
const badge = h('span', { class: 'kn-badge', 'data-badge': true, hidden: true })
const link = h('a', { class: `${K.knopf} kn-gutschrift-link`, ...zuAnsicht('takeover') }, 'Übernahmegutschrift beantragen')
return { link, setzeZahl(n) { if (n > 0) { badge.textContent = String(n); badge.hidden = false; link.append(' ', badge) } } }
}
function auswahlKarte(daten, key) {
const p = produkt(daten, key)
if (!p) return null
const angebot = daten.offers?.[key]
const link = daten.available
? h('a', { class: K.primaer, ...zuAnsicht(`bestellen-${key}`) }, 'Abo abschließen')
: h('a', { class: `${K.primaer} kn-gesperrt`, 'aria-disabled': 'true', role: 'link' }, 'Abo abschließen')
return h('article', { class: 'kn-abo-karte', 'data-produkt': key },
h('h3', null, produktName(daten, key)),
angebot ? h('p', { class: 'kn-angebot' }, h('span', { class: K.pille.violett }, 'Dein persönliches Angebot'), h('span', { class: 'kn-angebot-text' }, `${angebot.text}${angebot.validUntil ? ` · gültig bis ${fmtDatum(angebot.validUntil)}` : ''}`)) : null,
h('p', { class: 'kn-preis' }, h('strong', null, fmtEuro(p.amountCents / 100)), ' pro Monat'),
h('p', { class: 'kn-hinweis' }, `${TEXT_UST} ${p.minTermMonths} Monate Mindestlaufzeit, danach monatlich kündbar.`),
leistungen(p.features),
link)
}
function auswahl(daten, beendet) {
return h('div', { class: 'kn-abo-auswahl' },
beendet ? h('p', { class: K.meldung }, 'Dein bisheriges Abo ist beendet. Du kannst jederzeit ein neues abschließen.') : null,
daten.available ? null : h('p', { class: K.meldung }, TEXT_NICHT_VERFUEGBAR),
h('div', { class: 'kn-abo-karten' }, auswahlKarte(daten, 'hosting'), auswahlKarte(daten, 'wartung')),
h('p', { class: 'kn-hinweis' }, 'Alle Preise gemäß § 19 UStG ohne Umsatzsteuer. Es gelten die ', h('a', { href: '/abo-bedingungen.html', target: '_blank', rel: 'noopener noreferrer' }, 'Vertragsbedingungen'), '.'))
}
/** Reiter-Inhalt Abo; daten ist die Antwort von GET /api/abo. */
export async function zeigeAbo(box, { daten, me } = {}) {
const nr = (laeufe.get(box) ?? 0) + 1
laeufe.set(box, nr)
const aktuell = () => laeufe.get(box) === nr
const viewAs = me?.viewAs === true
const sub = daten?.subscription ?? null
const zustand = aboZustand(daten)
const gutschrift = sub ? gutschriftLink() : null
const teile = aboWarnungen(sub)
if (sub?.active) {
teile.push(vertragsBlock(daten, sub, viewAs), wechselKarte(daten, sub))
} else {
teile.push(auswahl(daten ?? {}, zustand === 'beendet'))
}
if (gutschrift) teile.push(h('p', { class: 'kn-gutschrift' }, gutschrift.link))
leeren(box).append(...teile.filter(Boolean))
// Zahl der offenen Rückfragen zur Übernahmegutschrift: ein Ausfall lässt nur die Zahl weg
if (!gutschrift) return
try {
const antwort = await api('/api/abo/takeover-credits')
if (!aktuell()) return
gutschrift.setzeZahl((Array.isArray(antwort?.requests) ? antwort.requests : []).filter((r) => r?.status === 'rueckfrage').length)
} catch { /* ohne Zahl bleibt der Einstieg stehen */ }
}