diff --git a/public/admin.html b/public/admin.html index a5ec056..4099f7e 100644 --- a/public/admin.html +++ b/public/admin.html @@ -151,6 +151,7 @@ + @@ -231,6 +232,31 @@

Übernahmegutschrift: Der Kunde hat Domain, Hosting oder E-Mail beim bisherigen Anbieter schon bezahlt. Nach der Prüfung wird der doppelt bezahlte Anteil als Guthaben in Stripe gebucht und mit den nächsten Abo-Rechnungen verrechnet.

+ + +
+
+
+ + +
+ +
+ + +
+ + + + + + + + +
AngelegtKundeAngebotGültig bisStatus
Lade…
+
+

Ein Angebot legt sofort einen einmal einlösbaren Coupon in Stripe an. Ohne laufendes Abo wird er beim Abschluss angewendet (und schaltet die Bestellung für diesen Kunden frei), bei laufendem Abo gilt er ab der nächsten Abbuchung. Nach den Angebotsmonaten gilt automatisch wieder der reguläre Preis.

+
diff --git a/public/admin.js b/public/admin.js index dfa3386..6052b0f 100644 --- a/public/admin.js +++ b/public/admin.js @@ -243,6 +243,7 @@ async function loadCustomers(search = '') { + `).join('') @@ -255,6 +256,8 @@ async function loadCustomers(search = '') { btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.activity, 'activity'))) tbody.querySelectorAll('[data-invoices]').forEach((btn) => btn.addEventListener('click', () => toggleCustomerDetail(btn.dataset.invoices, 'invoices'))) + tbody.querySelectorAll('[data-offer-for]').forEach((btn) => + btn.addEventListener('click', () => startOfferFor(btn.dataset.offerFor))) } catch (err) { tbody.innerHTML = `${escapeHtml(err.message)}` } @@ -772,6 +775,236 @@ async function applyCredit(id) { } } +/* ------------------------------------------------------- Abo-Angebote --- */ +const OFFER_STATUS = { + offen: ['pill-warn', 'offen'], + aktiv: ['pill-ok', 'aktiv'], + abgelaufen: ['pill-muted', 'abgelaufen'], + zurueckgezogen: ['pill-muted', 'zurückgezogen'], +} + +const offerState = { filter: 'open', customers: null, labelToId: new Map(), context: null } + +function offerStatusPill(o) { + const [cls, label] = OFFER_STATUS[o.status] || ['pill-muted', o.status] + const sub = o.status === 'aktiv' && o.appliedAt ? `seit ${fmtDate(o.appliedAt)}` : o.status === 'offen' ? 'wartet auf Abschluss' : '' + return `${label}${sub ? `
${sub}` : ''}` +} + +// Vorschau im Formular; verbindlich ist der Text, den der Server beim Anlegen erzeugt +function offerPreviewText(price, months, regular, running) { + const eur = (cents) => euroFromCents(cents) + const lead = months === 1 ? (running ? 'Nächster Monat' : 'Erster Monat') : `${running ? 'Nächste' : 'Erste'} ${months} Monate` + const amount = price === 0 ? 'kostenlos' : `${months === 1 ? '' : 'je '}${eur(price)} statt ${eur(regular)}` + return `${lead} ${amount}, danach ${eur(regular)} pro Monat` +} + +function showOfferMessage(text) { + const el = document.getElementById('offer-message') + el.textContent = text + el.classList.toggle('hidden', !text) +} + +async function loadOffers() { + const tbody = document.getElementById('offer-rows') + try { + const { offers } = await api('/api/portal-admin/offers') + const shown = offerState.filter === 'open' ? offers.filter((o) => ['offen', 'aktiv'].includes(o.status)) : offers + if (!shown.length) { + tbody.innerHTML = `${offerState.filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}` + return + } + tbody.innerHTML = shown.map((o) => ` + + ${fmtDateTime(o.createdAt)}
${escapeHtml(o.ref)}${o.createdBy ? ` · ${escapeHtml(o.createdBy.replace(/\s*<.*>$/, ''))}` : ''} + ${escapeHtml(o.customerName)}${o.customerCode ? `
Kd.-Nr. ${escapeHtml(o.customerCode)}` : ''} + ${escapeHtml(o.text)}
Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''} + ${o.internalNote ? `
intern: ${escapeHtml(o.internalNote)}` : ''} + ${o.scope === 'laufend' ? 'laufendes Abo' : fmtDate(o.validUntil)} + ${offerStatusPill(o)} +
Stripe: ${o.stripeCouponUrl ? `Coupon` : '–'}${o.stripeSubscriptionUrl ? ` · Abo` : ''} + ${['offen', 'abgelaufen'].includes(o.status) ? `
` : ''} + `).join('') + tbody.querySelectorAll('[data-withdraw-offer]').forEach((btn) => + btn.addEventListener('click', () => withdrawOfferAction(btn.dataset.withdrawOffer, btn))) + } catch (err) { + tbody.innerHTML = `${escapeHtml(err.message)}` + } +} + +async function withdrawOfferAction(id, btn) { + if (!window.confirm('Angebot zurückziehen? Der Coupon wird in Stripe gelöscht, der Kunde sieht das Angebot nicht mehr.')) return + btn.disabled = true + try { + await api(`/api/portal-admin/offers/${encodeURIComponent(id)}/withdraw`, { method: 'POST' }) + showOfferMessage('Angebot zurückgezogen, Coupon in Stripe gelöscht.') + loadOffers() + } catch (err) { + btn.disabled = false + alert('Zurückziehen fehlgeschlagen: ' + err.message) + } +} + +const offerCustomerLabel = (c) => + [c.name || '(ohne Name)', c.email, c.code ? `Kd.-Nr. ${c.code}` : ''].filter(Boolean).join(' · ') + +async function ensureOfferCustomers() { + if (offerState.customers) return offerState.customers + const { customers } = await api('/api/portal-admin/customers') + offerState.customers = customers + offerState.labelToId = new Map(customers.map((c) => [offerCustomerLabel(c), c.id])) + return customers +} + +function offerFormValues(form) { + return { + customerId: offerState.labelToId.get(form.querySelector('#offer-customer').value.trim()) || '', + monthlyPriceCents: Math.round(Number(form.querySelector('#offer-price').value) * 100), + months: Number(form.querySelector('#offer-months').value), + validUntil: form.querySelector('#offer-valid').value, + note: form.querySelector('#offer-note').value.trim(), + internalNote: form.querySelector('#offer-internal').value.trim(), + notifyCustomer: form.querySelector('#offer-notify').checked, + } +} + +function renderOfferPreview(form) { + const box = form.querySelector('#offer-preview') + const save = form.querySelector('#offer-save') + const ctx = offerState.context + const v = offerFormValues(form) + save.disabled = true + if (!v.customerId || !ctx || ctx.customer.id !== v.customerId) { + box.textContent = v.customerId ? 'Lade Kunde…' : 'Bitte einen Kunden aus der Liste wählen.' + return + } + const regular = ctx.regularPriceCents + const running = Boolean(ctx.subscription?.active) + const lines = [] + let ok = true + if (ctx.openOffer) { + lines.push(`Es gibt schon ein offenes Angebot (${ctx.openOffer.ref}: ${ctx.openOffer.text}) – bitte zuerst zurückziehen.`) + ok = false + } + if (!Number.isInteger(v.monthlyPriceCents) || v.monthlyPriceCents < 0 || v.monthlyPriceCents >= regular) { + lines.push(`Angebotspreis bitte zwischen 0,00 € und ${euroFromCents(regular - 1)} angeben.`) + ok = false + } + if (!v.validUntil && !running) { + lines.push('Bitte „Gültig bis“ angeben.') + ok = false + } + if (ok) { + lines.push(`${offerPreviewText(v.monthlyPriceCents, v.months, regular, running)} · Ersparnis ${euroFromCents((regular - v.monthlyPriceCents) * v.months)}`) + } + lines.push(running + ? `Laufendes Abo: gilt ab der nächsten Abbuchung${ctx.subscription.currentPeriodEnd ? ` (${fmtDate(ctx.subscription.currentPeriodEnd)})` : ''} und wird sofort in Stripe angewendet.` + : 'Noch kein laufendes Abo: Der Coupon wird angelegt und beim Abschluss im Kundenportal angewendet.') + if (!running && !ctx.customer.portalAccessEnabled) lines.push('Achtung: Kunde hat keinen Portalzugang – er kann erst bestellen, wenn der Zugang freigeschaltet ist.') + if (ctx.customer.testAccount) lines.push('Testkonto: Im Checkout bleibt der 0,50-€-Testrabatt, das Angebot wird danach zusätzlich angehängt.') + box.innerHTML = lines.map((l) => `
${escapeHtml(l)}
`).join('') + save.disabled = !ok + save.textContent = running ? 'Angebot anlegen und aufs laufende Abo anwenden' : 'Angebot anlegen' +} + +async function loadOfferContext(form) { + const v = offerFormValues(form) + offerState.context = null + renderOfferPreview(form) + if (!v.customerId) return + try { + offerState.context = await api(`/api/portal-admin/offers/context/${encodeURIComponent(v.customerId)}`) + if (!form.querySelector('#offer-valid').value) form.querySelector('#offer-valid').value = offerState.context.defaultValidUntil + } catch (err) { + form.querySelector('#offer-preview').textContent = err.message + return + } + renderOfferPreview(form) +} + +async function saveOffer(form) { + const errEl = form.querySelector('#offer-error') + errEl.classList.add('hidden') + const v = offerFormValues(form) + const ctx = offerState.context + const text = offerPreviewText(v.monthlyPriceCents, v.months, ctx.regularPriceCents, Boolean(ctx.subscription?.active)) + if (!window.confirm(`Angebot für ${ctx.customer.name || 'den Kunden'} anlegen?\n\n${text}\n\nDer Coupon wird sofort in Stripe angelegt${ctx.subscription?.active ? ' und auf das laufende Abo angewendet' : ''}.`)) return + const save = form.querySelector('#offer-save') + save.disabled = true + try { + const result = await api('/api/portal-admin/offers', { method: 'POST', body: JSON.stringify(v) }) + const o = result.offer + form.classList.add('hidden') + showOfferMessage([ + `Angebot ${o.ref} für ${o.customerName} angelegt: ${o.text}.`, + `Stripe-Coupon ${o.stripeCouponId} ist eingerichtet${o.status === 'aktiv' ? ' und am laufenden Abo aktiv' : ''}.`, + v.notifyCustomer ? (result.mailSent ? 'Die E-Mail an den Kunden ist raus.' : `E-Mail an den Kunden fehlgeschlagen (${result.mailError}).`) : '', + ].filter(Boolean).join(' ')) + loadOffers() + } catch (err) { + errEl.textContent = err.message + errEl.classList.remove('hidden') + renderOfferPreview(form) + } +} + +async function openOfferForm(customerId = '') { + const form = document.getElementById('offer-form') + showOfferMessage('') + form.classList.remove('hidden') + form.innerHTML = '

Lade Kunden…

' + let customers + try { + customers = await ensureOfferCustomers() + } catch (err) { + form.innerHTML = `

${escapeHtml(err.message)}

` + return + } + const preselected = customers.find((c) => c.id === customerId) + form.innerHTML = ` +

Neues Angebot

+
+
+ + ${customers.map((c) => ``).join('')}
+
+
+
+
+
+
+
+
+
+
+
+
+
Bitte einen Kunden aus der Liste wählen.
+
+ + +
+ ` + + form.querySelector('#offer-customer').addEventListener('change', () => loadOfferContext(form)) + form.querySelector('#offer-customer').addEventListener('input', () => { + if (offerState.labelToId.has(form.querySelector('#offer-customer').value.trim())) loadOfferContext(form) + }) + form.querySelectorAll('#offer-price, #offer-months, #offer-valid').forEach((el) => el.addEventListener('input', () => renderOfferPreview(form))) + form.querySelector('#offer-save').addEventListener('click', () => saveOffer(form)) + form.querySelector('#offer-cancel').addEventListener('click', () => form.classList.add('hidden')) + if (preselected) loadOfferContext(form) + form.scrollIntoView({ behavior: 'smooth', block: 'start' }) +} + +/** Aus der Kundenliste: Tab wechseln und Formular mit dem Kunden oeffnen. */ +function startOfferFor(customerId) { + document.querySelector('.admin-tab[data-tab="offers"]')?.click() + openOfferForm(customerId) +} + /* ----------------------------------------------------------------- Init --- */ async function initAdminPage() { document.getElementById('logout-btn')?.addEventListener('click', async () => { @@ -800,6 +1033,7 @@ async function initAdminPage() { let chatListLoaded = false let creditsLoaded = false + let offersLoaded = false document.querySelectorAll('.admin-tab').forEach((tab) => { tab.addEventListener('click', () => { document.querySelectorAll('.admin-tab').forEach((t) => t.classList.remove('active')) @@ -814,9 +1048,22 @@ async function initAdminPage() { creditsLoaded = true loadCredits() } + if (tab.dataset.tab === 'offers' && !offersLoaded) { + offersLoaded = true + loadOffers() + } }) }) + document.querySelectorAll('[data-offer-filter]').forEach((btn) => { + btn.addEventListener('click', () => { + document.querySelectorAll('[data-offer-filter]').forEach((b) => b.classList.toggle('active', b === btn)) + offerState.filter = btn.dataset.offerFilter + loadOffers() + }) + }) + document.getElementById('offer-new')?.addEventListener('click', () => openOfferForm()) + document.querySelectorAll('[data-credit-filter]').forEach((btn) => { btn.addEventListener('click', () => { document.querySelectorAll('[data-credit-filter]').forEach((b) => b.classList.toggle('active', b === btn)) diff --git a/public/app.js b/public/app.js index 56200ff..8382334 100644 --- a/public/app.js +++ b/public/app.js @@ -613,14 +613,28 @@ function lateStartDate() { function renderAboOffer(card, data, { ended } = {}) { const available = data.available + // Individuelles Angebot (vom WEBklar-Team angelegt), z. B. erste 2 Monate je 25 € + const offer = data.offer + const offerPrice = offer + ? (offer.monthlyPriceCents % 100 === 0 ? `${offer.monthlyPriceCents / 100} €` : fmtEuro(offer.monthlyPriceCents / 100)) + : '' card.innerHTML = `

${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}

Wartungs-Abo

-
50 €pro Monat · 12 Monate Mindestlaufzeit
+ ${offer + ? `
${offerPrice}${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50 € · 12 Monate Mindestlaufzeit
` + : '
50 €pro Monat · 12 Monate Mindestlaufzeit
'}
+ ${offer ? ` +
+ Ihr persönliches Angebot +

${escapeHtml(offer.text)}. Sie sparen ${fmtEuro(offer.savingsCents / 100)}.

+ ${offer.note ? `

${escapeHtml(offer.note)}

` : ''} +

Gültig bis ${fmtDateShort(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt

+
` : ''} ${aboFeaturesHtml()} ${available ? '

Schon bei einem anderen Anbieter für Domain, Hosting oder E-Mail bezahlt? Nach dem Abschluss können Sie eine Übernahmegutschrift beantragen – wir rechnen die doppelt bezahlte Zeit anteilig an.

' : ''}
@@ -631,7 +645,7 @@ function renderAboOffer(card, data, { ended } = {}) {