176 lines
10 KiB
JavaScript
176 lines
10 KiB
JavaScript
/* Kundenansicht neu (P7): Bestellseite je Abo (#bestellen-hosting, #bestellen-wartung) im schlanken Muster der Spec 5.6, Fassung 6.
|
||
Reihenfolge ohne Klappe und ohne Scrollbereich: Block „Was du bestellst“, eine Linkzeile, bei Privatkunden ein Häkchen, der Knopf
|
||
„Zahlungspflichtig bestellen“ (§ 312j BGB: Kerninfo und Knopf auf demselben Bildschirm), darunter die Kundenart-Zeile.
|
||
Der Block ist knapp gehalten (Leistung als eine Zeile mit Link auf die Leistungsbeschreibung), damit er mit dem Knopf in den nutzbaren Bereich
|
||
zwischen Kopf und Leiste passt (375 x 667); die ausführlichen Sätze stehen unter dem Knopf (baueEinzelheiten), ohne Klappe.
|
||
Die Sätze (Linkzeile, Häkchen, Kundenart-Zeile) kommen vom Server (GET /api/abo → bestelltexte). Der Server entscheidet die Kundenart selbst:
|
||
der Client sendet nie kundenart oder startChoice, bei Privatkunden nur earlyStart (das Häkchen) und den gezeigten Leistungsbeginn.
|
||
Der Titel kommt von der Hülle (meldeTitel); die Seite hat keine eigene h1. */
|
||
|
||
import { api } from './api.js'
|
||
import { h, leeren } from './dom.js'
|
||
import { K, ladeAbo, sichereAdresse, meldeTitel, beiRueckkehr, fmtEuro, fmtDatum, ABO_NICHT_GELADEN } from './ansicht-hilfen.js'
|
||
import { baueBlock, baueEinzelheiten, baueKundenartZeile, baueLinkzeile, hatWortlaut, kundenartVon, leistungLink } from './bestell-bausteine.js'
|
||
|
||
const laeufe = new WeakMap() // Wurzel → Nummer des jüngsten Aufrufs
|
||
const TEXT_SPERRE = 'Im Ansichtsmodus nicht möglich.'
|
||
const TEXT_NICHT_VERFUEGBAR = 'Die Online-Bestellung wird gerade eingerichtet – schreib uns gern im Chat.'
|
||
const TEXT_ZAHLUNGSSEITE = 'Die Zahlungsseite konnte nicht geöffnet werden. Bitte versuche es später noch einmal.'
|
||
const KNOPF = 'Zahlungspflichtig bestellen'
|
||
const UST = 'gemäß § 19 UStG ohne Umsatzsteuer'
|
||
|
||
/** 'hosting' | 'wartung' aus der Route, sonst aus dem Hash (#bestellen-hosting), sonst ''. */
|
||
export function produktAusRoute(route, hash = (typeof window !== 'undefined' ? window.location?.hash : '') || '') {
|
||
if (route?.produkt === 'hosting' || route?.produkt === 'wartung') return route.produkt
|
||
const treffer = /bestellen-(hosting|wartung)/.exec(String(hash || ''))
|
||
return treffer ? treffer[1] : ''
|
||
}
|
||
|
||
const produktVon = (daten, key) => (daten?.products || []).find((p) => p.key === key) || null
|
||
const labelVon = (daten, key) => (produktVon(daten, key)?.name || (key === 'hosting' ? 'Hosting-Abo' : 'Wartungs-Abo')).replace(/^WEBklar\s+/, '')
|
||
|
||
/** Preiszeile und Betrag der ersten Abbuchung aus Katalog und Angebot (während des Rabatts der Angebotspreis, sonst der Katalogpreis). */
|
||
function preise(product, offer) {
|
||
const offerMonthly = Number(offer?.monthlyPriceCents)
|
||
const offerRegular = Number(offer?.regularPriceCents) || product.amountCents
|
||
const offerMonths = Number(offer?.months)
|
||
const mitZahlen = Boolean(offer) && Number.isFinite(offerMonthly) && offer.monthlyPriceCents != null && offerMonths > 0
|
||
const preis = fmtEuro(product.amountCents / 100)
|
||
const preisZeile = !offer
|
||
? `${preis} pro Monat (Gesamtpreis, ${UST})`
|
||
: mitZahlen
|
||
? `${fmtEuro(offerMonthly / 100)} pro Monat für die ersten ${offerMonths} Monate, danach ${fmtEuro(offerRegular / 100)} pro Monat (individuelles Angebot: ${offer.text}; Gesamtpreis, ${UST})`
|
||
: `${preis} pro Monat (individuelles Angebot: ${offer.text}; Gesamtpreis, ${UST})`
|
||
return { preisZeile, erste: fmtEuro((mitZahlen ? offerMonthly : product.amountCents) / 100) }
|
||
}
|
||
|
||
/** Texte der Zeilen „Fällig“ und „Beginn“ je nach Lage: sofort (Betrieb), sofort auf Wunsch (Privat mit Häkchen), nach der Frist (Privat ohne). */
|
||
function faelligBeginn({ lage, erste, frist }) {
|
||
if (lage === 'frist') {
|
||
return {
|
||
faellig: `erst am ${frist || 'Ende der Widerrufsfrist'} (${erste})`,
|
||
beginn: `nach Ablauf der 14-tägigen Widerrufsfrist${frist ? ` (am ${frist})` : ''}`,
|
||
}
|
||
}
|
||
return {
|
||
faellig: `heute ${erste}`,
|
||
beginn: lage === 'wunsch' ? 'sofort, auf deinen ausdrücklichen Wunsch' : 'sofort',
|
||
}
|
||
}
|
||
|
||
function angebotAbschnitt(offer) {
|
||
if (!offer) return null
|
||
return h('section', { class: 'kn-angebot-kasten', id: 'abo-angebot' },
|
||
h('h2', null, 'Dein persönliches Angebot'),
|
||
h('p', null, `${offer.text}. Du sparst ${fmtEuro(offer.savingsCents / 100)}.`),
|
||
offer.note ? h('p', { class: 'kn-hinweis' }, offer.note) : null,
|
||
h('p', { class: 'kn-hinweis' }, `Gültig bis ${fmtDatum(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt`))
|
||
}
|
||
|
||
function leistungenAbschnitt(product) {
|
||
return h('section', { class: 'kn-panel', id: 'abo-leistungen' },
|
||
h('ul', { class: 'kn-leistungen' }, (product.features || []).map(([titel, detail]) => h('li', null, titel, h('small', null, detail)))),
|
||
h('p', { class: 'kn-hinweis' }, 'Schon bei einem anderen Anbieter für Domain, Hosting oder E-Mail bezahlt? Nach dem Abschluss kannst du eine Übernahmegutschrift beantragen – wir rechnen die doppelt bezahlte Zeit anteilig an.'))
|
||
}
|
||
|
||
function bestellung(daten, productKey, { viewAs, kundenart }) {
|
||
const product = produktVon(daten, productKey)
|
||
const T = daten.bestelltexte
|
||
const offer = daten.offers?.[productKey] || null
|
||
const privat = kundenart !== 'unternehmen'
|
||
const frist = daten.lateStartAt ? fmtDatum(daten.lateStartAt) : ''
|
||
const { preisZeile, erste } = preise(product, offer)
|
||
// Betrieb: sofort, ohne Auswahl. Privat und leer: ohne Häkchen nach der Frist, mit Häkchen sofort.
|
||
const start = faelligBeginn({ lage: privat ? 'frist' : 'sofort', erste, frist })
|
||
|
||
const block = baueBlock([
|
||
{ label: 'Leistung', text: labelVon(daten, productKey), link: { text: 'Leistungen ansehen', href: leistungLink(T.abo.linkzeile, productKey) } },
|
||
{ label: 'Preis', text: preisZeile },
|
||
{ id: 'abo-due-summary', label: 'Fällig', text: start.faellig },
|
||
{ id: 'abo-start-summary', label: 'Beginn', text: start.beginn },
|
||
{ label: 'Laufzeit', text: `Mindestlaufzeit ${product.minTermMonths} Monate, kündbar bis 1 Monat vor Ende; danach monatlich` },
|
||
{ label: 'Zahlung', text: 'Karte oder SEPA über Stripe' },
|
||
], { id: 'abo-block' })
|
||
// Die ausführlichen Sätze stehen unter dem Knopf
|
||
const einzelheiten = baueEinzelheiten([
|
||
{ label: 'Laufzeit', text: `Mindestlaufzeit ${product.minTermMonths} Monate ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach jederzeit zum Ende des Abrechnungsmonats kündbar` },
|
||
{ label: 'Zahlung', text: 'Kreditkarte oder SEPA-Lastschrift über Stripe; die Zahlungsart hinterlegst du im nächsten Schritt, bis zum Abschluss dort kannst du abbrechen' },
|
||
{ label: 'Kosten', text: 'keine weiteren Kosten' },
|
||
], { id: 'abo-einzelheiten' })
|
||
|
||
const haken = privat ? h('input', { type: 'checkbox', id: 'abo-beginn', disabled: viewAs }) : null
|
||
const fehler = h('p', { class: K.fehler, id: 'abo-error', role: 'alert', hidden: true })
|
||
const kaufen = h('button', { type: 'button', class: K.primaer, id: 'abo-buy', disabled: viewAs }, T.knopf || KNOPF)
|
||
const zeigeFehler = (text) => { fehler.textContent = text; fehler.hidden = false }
|
||
|
||
const abschnitt = h('section', { class: 'kn-panel kn-bestellen', id: 'abo-order' },
|
||
h('h2', null, 'Was du bestellst'),
|
||
daten.testAccount ? h('div', { class: 'kn-meldung kn-meldung-warn' }, `Testkonto: Bei Stripe wird ein Test-Rabatt verrechnet, abgebucht werden nur 0,50 € pro Monat.${offer ? ' Das Angebot wird nach dem Abschluss zusätzlich ans Abo gehängt.' : ''}`) : null,
|
||
block,
|
||
baueLinkzeile(T.abo.linkzeile, { id: 'abo-linkzeile' }),
|
||
haken ? h('label', { class: K.check }, haken, h('span', { id: 'abo-beginn-text' }, T.abo.haken)) : null,
|
||
fehler,
|
||
h('div', { class: 'kn-aktionen' }, kaufen, viewAs ? h('span', { class: K.meldung }, TEXT_SPERRE) : null),
|
||
baueKundenartZeile(kundenart, T.kundenart, { id: 'abo-kundenart' }),
|
||
einzelheiten)
|
||
|
||
// Zurück von der Zahlungsseite (Bfcache): der Knopf ist wieder bedienbar (E-Fix M6, wie ansicht-wechsel.js)
|
||
beiRueckkehr(abschnitt, () => { kaufen.disabled = viewAs; kaufen.textContent = T.knopf || KNOPF })
|
||
|
||
// Das Häkchen schaltet die Zeilen „Fällig“ und „Beginn“ um
|
||
haken?.addEventListener('change', () => {
|
||
const neu = faelligBeginn({ lage: haken.checked ? 'wunsch' : 'frist', erste, frist })
|
||
abschnitt.querySelector('#abo-due-summary').textContent = neu.faellig
|
||
abschnitt.querySelector('#abo-start-summary').textContent = neu.beginn
|
||
})
|
||
|
||
kaufen.addEventListener('click', async () => {
|
||
if (viewAs) return
|
||
fehler.hidden = true
|
||
kaufen.disabled = true
|
||
kaufen.textContent = 'Weiter zur Zahlung…'
|
||
try {
|
||
// Der Klick auf den Knopf ist die Zustimmung (acceptTerms). Nie kundenart oder startChoice: den Beginn bestimmt der Server aus der Kundenart.
|
||
const { url } = await api('/api/abo/checkout', {
|
||
method: 'POST',
|
||
body: {
|
||
productKey,
|
||
acceptTerms: true,
|
||
termsVersion: daten.termsVersion,
|
||
offerId: offer?.id || '',
|
||
...(privat ? { earlyStart: haken.checked, lateStartAt: daten.lateStartAt || '' } : {}),
|
||
},
|
||
})
|
||
const ziel = sichereAdresse(url)
|
||
if (!ziel) throw new Error(TEXT_ZAHLUNGSSEITE)
|
||
window.location.href = ziel
|
||
} catch (f) {
|
||
zeigeFehler(f.message)
|
||
kaufen.disabled = false
|
||
kaufen.textContent = T.knopf || KNOPF
|
||
}
|
||
})
|
||
|
||
return [angebotAbschnitt(offer), leistungenAbschnitt(product), abschnitt]
|
||
}
|
||
|
||
/** Bestellseite. Das Produkt kommt aus route.produkt, hilfsweise aus dem Hash; daten (GET /api/abo) ist optional. */
|
||
export async function zeigeBestellen(wurzel, { route, me, nav, daten } = {}) {
|
||
const nr = (laeufe.get(wurzel) ?? 0) + 1
|
||
laeufe.set(wurzel, nr)
|
||
const aktuell = () => laeufe.get(wurzel) === nr
|
||
leeren(wurzel).append(h('p', { class: K.leer }, 'Wird geladen …'))
|
||
const stand = daten ?? await ladeAbo()
|
||
if (!aktuell()) return
|
||
if (!stand) { leeren(wurzel).append(h('p', { class: K.fehler }, ABO_NICHT_GELADEN)); return }
|
||
const key = produktAusRoute(route)
|
||
const product = key ? produktVon(stand, key) : null
|
||
if (key) meldeTitel(wurzel, labelVon(stand, key))
|
||
// Ohne Wortlaut vom Server gibt es keinen Bestellknopf
|
||
if (!product || !stand.available || !hatWortlaut(stand)) {
|
||
leeren(wurzel).append(h('div', { class: 'kn-panel' }, h('p', { class: 'kn-hinweis' }, TEXT_NICHT_VERFUEGBAR)))
|
||
return
|
||
}
|
||
leeren(wurzel).append(...bestellung(stand, key, { viewAs: me?.viewAs === true, kundenart: kundenartVon(me, stand) }).filter(Boolean))
|
||
}
|