Kundenansicht neu P2: Reiter Abo und Abo-Auswahl

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-16 17:02:10 +00:00
1 parent cf391eb117
commit 989e7be640
2 files changed
+228

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
/* 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, safeUrl } 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.'
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, 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 = safeUrl(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
}
})
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 = safeUrl(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 = []
if (sub?.active && sub.paymentProblem) teile.push(h('p', { class: `${K.meldung} kn-meldung-warn` }, TEXT_ZAHLUNG))
if (sub?.active && sub.serviceStartsAt) teile.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.`))
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 */ }
}