Abo: individuelle Angebote (z. B. erste 2 Monate je 25 EUR) mit Stripe-Coupon
- Admin-Tab "Abo-Angebote" und Button "Angebot" in der Kundenliste: Preis, Monate, Gueltigkeit, Hinweis fuer den Kunden, interne Notiz, Vorschau vor dem Anlegen - Je Angebot sofort ein Stripe-Coupon (amount_off, repeating ueber die Angebotsmonate, max. 1 Einloesung); danach automatisch wieder Normalpreis - Kunde ohne Abo: Angebot im Portal, Angebotspreis in der Bestelluebersicht, Coupon im Checkout; Angebot schaltet die Bestellung auch ausserhalb der Pilotliste frei; Bestellung prueft, dass das angezeigte Angebot noch gilt - Laufendes Abo: Coupon sofort am Abo fuer die naechsten Abbuchungen - Testkonten: Testrabatt im Checkout, Angebot per Webhook zusaetzlich am Abo - Angebot in Abo-Metadaten, Vertragsbestaetigung, Admin-Mail und Abo-Karte - Zurueckziehen loescht den Coupon; abgelaufene Angebote nicht mehr bestellbar - Stundenkontingent nach invoice.subtotal: auch voll rabattierte oder per Guthaben bezahlte Monate bringen die Stunde, die Probezeit nicht - setup-abo-collections.mjs: aboOffers, subscriptions.offerId/offerText, Attribut-Abfrage mit Limit (sonst Haenger ab 26 Attributen) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
48b13f4488
commit
bb2f46e191
11 files changed
+856
-30
No files matched your search
@@ -151,6 +151,7 @@
|
||||
<button class="admin-tab" data-tab="customers">Kunden</button>
|
||||
<button class="admin-tab" data-tab="chat">Chat <span id="admin-chat-badge" class="badge-pill hidden"></span></button>
|
||||
<button class="admin-tab" data-tab="credits">Gutschriften <span id="admin-credit-badge" class="badge-pill hidden"></span></button>
|
||||
<button class="admin-tab" data-tab="offers">Abo-Angebote</button>
|
||||
</div>
|
||||
|
||||
<!-- Übersicht -->
|
||||
@@ -231,6 +232,31 @@
|
||||
<div id="credit-detail" class="portal-card credit-detail hidden"></div>
|
||||
<p class="admin-note">Ü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.</p>
|
||||
</section>
|
||||
|
||||
<!-- Abo-Angebote -->
|
||||
<section class="admin-section" id="tab-offers">
|
||||
<div class="flex-between" style="margin-bottom:12px">
|
||||
<div class="credit-filter" style="margin:0">
|
||||
<button type="button" class="active" data-offer-filter="open">Offen & aktiv</button>
|
||||
<button type="button" data-offer-filter="all">Alle</button>
|
||||
</div>
|
||||
<button type="button" class="mini-btn primary" id="offer-new">Neues Angebot</button>
|
||||
</div>
|
||||
<div id="offer-message" class="calc-result hidden"></div>
|
||||
<div id="offer-form" class="portal-card hidden" style="margin-bottom:16px"></div>
|
||||
<div class="admin-table-wrap no-scroll">
|
||||
<table class="admin-table">
|
||||
<colgroup>
|
||||
<col style="width:15%"><col style="width:19%"><col style="width:33%"><col style="width:15%"><col style="width:18%">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th>Angelegt</th><th>Kunde</th><th>Angebot</th><th>Gültig bis</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody id="offer-rows"><tr><td colspan="5" class="muted">Lade…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="admin-note">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.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/admin.js"></script>
|
||||
|
||||
+247
@@ -243,6 +243,7 @@ async function loadCustomers(search = '') {
|
||||
<button class="mini-btn${isLead(c) ? '' : ' primary'}" data-view-as="${escapeHtml(c.id)}">Als Kunde ansehen</button>
|
||||
<button class="mini-btn" data-activity="${escapeHtml(c.id)}">Arbeiten</button>
|
||||
<button class="mini-btn" data-invoices="${escapeHtml(c.id)}"${c.invoiceNinjaLinked ? '' : ' title="Nicht mit InvoiceNinja verknüpft"'}>Rechnungen</button>
|
||||
<button class="mini-btn" data-offer-for="${escapeHtml(c.id)}" title="Individuelles Abo-Angebot anlegen">Angebot</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`).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 = `<tr><td colspan="7" class="error">${escapeHtml(err.message)}</td></tr>`
|
||||
}
|
||||
@@ -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 `<span class="pill ${cls}">${label}</span>${sub ? `<br><span class="muted">${sub}</span>` : ''}`
|
||||
}
|
||||
|
||||
// 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 = `<tr><td colspan="5" class="muted">${offerState.filter === 'open' ? 'Keine offenen oder aktiven Angebote.' : 'Noch keine Angebote.'}</td></tr>`
|
||||
return
|
||||
}
|
||||
tbody.innerHTML = shown.map((o) => `
|
||||
<tr>
|
||||
<td>${fmtDateTime(o.createdAt)}<br><span class="muted">${escapeHtml(o.ref)}${o.createdBy ? ` · ${escapeHtml(o.createdBy.replace(/\s*<.*>$/, ''))}` : ''}</span></td>
|
||||
<td><strong>${escapeHtml(o.customerName)}</strong>${o.customerCode ? `<br><span class="muted">Kd.-Nr. ${escapeHtml(o.customerCode)}</span>` : ''}</td>
|
||||
<td>${escapeHtml(o.text)}<br><span class="muted">Ersparnis ${euroFromCents(o.savingsCents)}${o.note ? ` · „${escapeHtml(o.note)}“` : ''}</span>
|
||||
${o.internalNote ? `<br><span class="muted"><em>intern: ${escapeHtml(o.internalNote)}</em></span>` : ''}</td>
|
||||
<td style="white-space:nowrap;overflow-wrap:normal">${o.scope === 'laufend' ? '<span class="muted">laufendes Abo</span>' : fmtDate(o.validUntil)}</td>
|
||||
<td>${offerStatusPill(o)}
|
||||
<br><span class="muted">Stripe:</span> ${o.stripeCouponUrl ? `<a class="plain" href="${escapeHtml(o.stripeCouponUrl)}" target="_blank" rel="noreferrer">Coupon</a>` : '–'}${o.stripeSubscriptionUrl ? ` · <a class="plain" href="${escapeHtml(o.stripeSubscriptionUrl)}" target="_blank" rel="noreferrer">Abo</a>` : ''}
|
||||
${['offen', 'abgelaufen'].includes(o.status) ? `<div style="margin-top:6px"><button class="mini-btn danger" data-withdraw-offer="${escapeHtml(o.id)}">Zurückziehen</button></div>` : ''}</td>
|
||||
</tr>`).join('')
|
||||
tbody.querySelectorAll('[data-withdraw-offer]').forEach((btn) =>
|
||||
btn.addEventListener('click', () => withdrawOfferAction(btn.dataset.withdrawOffer, btn)))
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="error">${escapeHtml(err.message)}</td></tr>`
|
||||
}
|
||||
}
|
||||
|
||||
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) => `<div>${escapeHtml(l)}</div>`).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 = '<p class="muted">Lade Kunden…</p>'
|
||||
let customers
|
||||
try {
|
||||
customers = await ensureOfferCustomers()
|
||||
} catch (err) {
|
||||
form.innerHTML = `<p class="error">${escapeHtml(err.message)}</p>`
|
||||
return
|
||||
}
|
||||
const preselected = customers.find((c) => c.id === customerId)
|
||||
form.innerHTML = `
|
||||
<h3 style="margin-top:0">Neues Angebot</h3>
|
||||
<div class="calc-grid">
|
||||
<div style="grid-column:1/-1"><label for="offer-customer">Kunde</label>
|
||||
<input class="credit-input" id="offer-customer" list="offer-customer-list" autocomplete="off"
|
||||
placeholder="Name, E-Mail oder Kundennummer tippen …" value="${preselected ? escapeHtml(offerCustomerLabel(preselected)) : ''}">
|
||||
<datalist id="offer-customer-list">${customers.map((c) => `<option value="${escapeHtml(offerCustomerLabel(c))}"></option>`).join('')}</datalist></div>
|
||||
<div><label for="offer-price">Preis pro Monat im Angebot (€)</label>
|
||||
<input class="credit-input" id="offer-price" type="number" min="0" step="0.01" value="25.00"></div>
|
||||
<div><label for="offer-months">für</label>
|
||||
<select class="credit-input" id="offer-months">${Array.from({ length: 12 }, (_, i) => i + 1)
|
||||
.map((m) => `<option value="${m}"${m === 2 ? ' selected' : ''}>${m} Monat${m === 1 ? '' : 'e'}</option>`).join('')}</select></div>
|
||||
<div><label for="offer-valid">Angebot gültig bis</label>
|
||||
<input class="credit-input" id="offer-valid" type="date"></div>
|
||||
</div>
|
||||
<div class="credit-field"><label for="offer-note">Hinweis für den Kunden (optional, z. B. „Übernahme Ihres IONOS-Hostings“)</label>
|
||||
<input class="credit-input" id="offer-note" maxlength="500"></div>
|
||||
<div class="credit-field"><label for="offer-internal">Interne Notiz (optional, sieht nur das Team)</label>
|
||||
<input class="credit-input" id="offer-internal" maxlength="2000"></div>
|
||||
<div class="manual-checks" style="margin-top:8px"><label><input type="checkbox" id="offer-notify" checked> Kunden per E-Mail über das Angebot informieren</label></div>
|
||||
<div class="calc-result" id="offer-preview">Bitte einen Kunden aus der Liste wählen.</div>
|
||||
<div class="credit-actions">
|
||||
<button class="mini-btn primary" id="offer-save" disabled>Angebot anlegen</button>
|
||||
<button class="mini-btn" id="offer-cancel">Abbrechen</button>
|
||||
</div>
|
||||
<p class="error hidden" id="offer-error"></p>`
|
||||
|
||||
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))
|
||||
|
||||
+21
-4
@@ -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 = `
|
||||
<div class="abo-head">
|
||||
<div>
|
||||
<p class="abo-kicker">${ended ? 'Ihr Abo ist beendet' : 'Rundum versorgt'}</p>
|
||||
<h2 class="abo-title">Wartungs-Abo</h2>
|
||||
</div>
|
||||
<div class="abo-price"><strong>50 €</strong><span>pro Monat · 12 Monate Mindestlaufzeit</span></div>
|
||||
${offer
|
||||
? `<div class="abo-price"><strong>${offerPrice}</strong><span>${offer.months === 1 ? 'im ersten Monat' : `pro Monat in den ersten ${offer.months} Monaten`}, danach 50 € · 12 Monate Mindestlaufzeit</span></div>`
|
||||
: '<div class="abo-price"><strong>50 €</strong><span>pro Monat · 12 Monate Mindestlaufzeit</span></div>'}
|
||||
</div>
|
||||
${offer ? `
|
||||
<div class="abo-offer-box">
|
||||
<strong>Ihr persönliches Angebot</strong>
|
||||
<p>${escapeHtml(offer.text)}. Sie sparen ${fmtEuro(offer.savingsCents / 100)}.</p>
|
||||
${offer.note ? `<p class="abo-note">${escapeHtml(offer.note)}</p>` : ''}
|
||||
<p class="abo-note">Gültig bis ${fmtDateShort(offer.validUntil)} · wird beim Abschluss automatisch berücksichtigt</p>
|
||||
</div>` : ''}
|
||||
${aboFeaturesHtml()}
|
||||
${available ? '<p class="abo-note" style="margin:-6px 0 16px">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.</p>' : ''}
|
||||
<div class="abo-actions">
|
||||
@@ -631,7 +645,7 @@ function renderAboOffer(card, data, { ended } = {}) {
|
||||
</div>
|
||||
<div class="abo-order hidden" id="abo-order">
|
||||
<h3>Ihre Bestellung</h3>
|
||||
${data.testAccount ? '<div class="abo-alert">Testkonto: Bei Stripe wird ein Test-Rabatt verrechnet, abgebucht werden nur 0,50 € pro Monat.</div>' : ''}
|
||||
${data.testAccount ? `<div class="abo-alert">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.' : ''}</div>` : ''}
|
||||
<label class="abo-check"><input type="checkbox" id="abo-terms">
|
||||
<span>Ich habe die <a href="/abo-bedingungen.html" target="_blank" rel="noopener">Vertragsbedingungen mit Leistungsbeschreibung und Auftragsverarbeitungsvertrag</a> gelesen und stimme ihnen zu. Die <a href="/abo-bedingungen.html#widerruf" target="_blank" rel="noopener">Widerrufsbelehrung</a> habe ich zur Kenntnis genommen.</span></label>
|
||||
|
||||
@@ -647,7 +661,9 @@ function renderAboOffer(card, data, { ended } = {}) {
|
||||
<div class="abo-summary-box">
|
||||
<table class="abo-summary">
|
||||
<tr><td>Leistung</td><td>WEBklar Wartungs-Abo: Betrieb & Pflege Ihrer Website bzw. Web-Anwendung, 1 Domain, 1 E-Mail-Postfach (5 GB), 1 Stunde Arbeitszeit je bezahltem Monat (12 Monate nutzbar)</td></tr>
|
||||
<tr><td>Preis</td><td><strong>50,00 € pro Monat</strong> (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten</td></tr>
|
||||
<tr><td>Preis</td><td>${offer
|
||||
? `<strong>${escapeHtml(offer.text)}</strong> (individuelles Angebot; Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten`
|
||||
: '<strong>50,00 € pro Monat</strong> (Gesamtpreis, gemäß § 19 UStG ohne Umsatzsteuer), keine weiteren Kosten'}</td></tr>
|
||||
<tr><td>Abrechnung</td><td>monatlich im Voraus, erstmals zum Leistungsbeginn</td></tr>
|
||||
<tr><td>Laufzeit</td><td><strong>Mindestlaufzeit 12 Monate</strong> ab Leistungsbeginn, kündbar zum Ende der Mindestlaufzeit bis einen Monat vorher; danach unbefristet und jederzeit zum Ende des Abrechnungsmonats kündbar</td></tr>
|
||||
<tr><td>Zahlung</td><td>Kreditkarte oder SEPA-Lastschrift über Stripe</td></tr>
|
||||
@@ -689,7 +705,7 @@ function renderAboOffer(card, data, { ended } = {}) {
|
||||
try {
|
||||
const { url } = await api('/api/abo/checkout', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ acceptTerms, startChoice, termsVersion: data.termsVersion }),
|
||||
body: JSON.stringify({ acceptTerms, startChoice, termsVersion: data.termsVersion, offerId: offer?.id || '' }),
|
||||
})
|
||||
window.location.href = url
|
||||
} catch (err) {
|
||||
@@ -739,6 +755,7 @@ function renderAboActive(card, data) {
|
||||
</div>
|
||||
<p class="abo-note">Jeden bezahlten Monat kommt 1 Stunde dazu. Angerechnet wird in 15-Minuten-Schritten, die ältesten Stunden zuerst.
|
||||
Aufträge schicken Sie uns einfach über den Chat.${sub.minTermEnd && !sub.cancelAtPeriodEnd ? ` Mindestlaufzeit bis ${fmtDateShort(sub.minTermEnd)}, danach monatlich kündbar.` : ''}</p>
|
||||
${sub.offerText ? `<p class="abo-note">Ihr Angebot: ${escapeHtml(sub.offerText)}.</p>` : ''}
|
||||
<div class="abo-actions" style="margin-top:14px">
|
||||
<button type="button" class="portal-btn-outline" id="abo-manage">${sub.paymentProblem ? 'Zahlungsart aktualisieren' : 'Abo verwalten · Rechnungen'}</button>
|
||||
${sub.withdrawalOpen ? `<a class="abo-withdraw" href="/widerrufen.html">Vertrag widerrufen</a><span class="abo-note">möglich bis ${fmtDateShort(sub.withdrawalUntil)}</span>` : ''}
|
||||
|
||||
@@ -157,6 +157,9 @@
|
||||
.abo-summary td:last-child { text-align: left; }
|
||||
.abo-withdraw { color: #fff; font-weight: 700; padding: 9px 16px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.6); text-decoration: none; }
|
||||
.abo-withdraw:hover { background: #fff; color: #1c1c1c; }
|
||||
/* Individuelles Angebot */
|
||||
.abo-offer-box { margin: 16px 0 4px; padding: 14px 16px; border-radius: 14px; background: rgba(74,222,128,0.10); border: 1px solid rgba(74,222,128,0.45); }
|
||||
.abo-offer-box p { margin: 4px 0 0; }
|
||||
|
||||
/* Übernahmegutschrift */
|
||||
.pill-amber { background: rgba(251,191,36,0.15); border-color: rgba(251,191,36,0.45); }
|
||||
|
||||
Reference in new issue
Block a user