/* =====================================================================
DIE SPENDENKARTE (28.09.2026)
Was im Bild erscheint, wenn jemand etwas gegeben hat: Betrag, Name,
Gruß — und ein Bild dazu.
---------------------------------------------------------------------
WARUM DAS EIN EIGENES STÜCK IST UND NICHT TEIL DER REACTION
Dieselbe Karte läuft an zwei Orten: in der Reaction-Seite, wo alle
zusehen, und später als eigene Seite für OBS und TikTok Studio.
Zweimal gebaut hieße: Bei der nächsten Änderung sieht sie an einem
der beiden Orte anders aus — und man merkt es erst im Livestream.
Deshalb hängt hier nichts an der Reaction. Wer die Karte zeigen
will, gibt ihr einen Kasten und eine Karte, sonst nichts.
---------------------------------------------------------------------
EINE NACH DER ANDEREN
Zwei Spenden gleichzeitig sind keine Seltenheit — wenn es im Live
gut läuft, kommen drei in zehn Sekunden. Übereinander gelegt wäre
keine mehr lesbar, und ausgerechnet die größte ginge unter. Also
eine Schlange: Was ankommt, wartet, bis die vorige durch ist.
DIE SCHLANGE HAT EINE GRENZE. Bei zwanzig wartenden Karten stünde
die Anzeige zwei Minuten hinter dem Geschehen; wer dann noch
spendet, sieht seine Karte, wenn die Sendung vorbei ist. Ab dann
werden die Wartezeiten gekürzt statt die Karten weggeworfen — jede
kommt dran, nur schneller.
===================================================================== */
(() => {
'use strict';
const el = (art, klasse, text) => {
const n = document.createElement(art);
if (klasse) n.className = klasse;
if (text !== undefined) n.textContent = text;
return n;
};
/* Die mitgelieferten Vorlagen. Gezeichnet und nicht als Bilddatei:
Sie müssen in jeder Farbe funktionieren (die Stufe bringt ihre
eigene mit), und eine Zeichnung nimmt jede an. */
const VORLAGEN = {
herz: '',
welle: ''
+ '',
krone: '',
stern: '',
pfote: ''
+ '',
};
function zeichenBauen(name, farbe) {
const inhalt = VORLAGEN[name] || VORLAGEN.herz;
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 64 64');
svg.setAttribute('aria-hidden', 'true');
svg.setAttribute('fill', farbe);
svg.setAttribute('stroke', farbe);
svg.innerHTML = inhalt;
return svg;
}
/** Betrag aus Cent in einen lesbaren Text.
*
* GERECHNET WIRD IN CENT, FORMATIERT ERST HIER. Kommazahlen sind
* bei Geld die Quelle stiller Fehler; der einzige Ort, an dem ein
* Komma auftauchen darf, ist der Bildschirm. */
function alsGeld(cent, waehrung) {
try {
return new Intl.NumberFormat('de-DE', {
style: 'currency', currency: waehrung || 'EUR',
minimumFractionDigits: cent % 100 === 0 ? 0 : 2,
}).format(cent / 100);
} catch {
return (cent / 100).toFixed(2).replace('.', ',') + ' ' + (waehrung || 'EUR');
}
}
const schlange = [];
let laeuft = false;
function naechste(kasten) {
if (laeuft || !schlange.length) return;
laeuft = true;
const karte = schlange.shift();
/* BEI VOLLER SCHLANGE WIRD GEKUERZT, NICHT WEGGEWORFEN. Wer
gespendet hat, soll seine Karte sehen -- notfalls kuerzer. */
const eile = Math.min(1, 4 / Math.max(1, schlange.length + 1));
const dauer = Math.max(2200, Math.round((karte.stufe?.dauer_ms || 7000) * eile));
const tafel = bauen(karte);
kasten.append(tafel);
/* Ein Bild in derselben Umdrehung anzuhaengen und gleich zu
bewegen, ueberspringt der Browser -- er fasst beides zusammen.
Ein Bildaufbau dazwischen, dann laeuft es. */
requestAnimationFrame(() => requestAnimationFrame(() => {
tafel.dataset.da = 'ja';
}));
setTimeout(() => {
tafel.dataset.da = 'nein';
setTimeout(() => {
tafel.remove();
laeuft = false;
naechste(kasten);
}, 600);
}, dauer);
}
function bauen(karte) {
const farbe = karte.stufe?.farbe || '#8ec9ff';
const tafel = el('div', 'spendenkarte');
tafel.style.setProperty('--sp-farbe', farbe);
tafel.dataset.stufe = karte.stufe?.name || '';
tafel.setAttribute('role', 'status');
const bild = el('div', 'spendenkarte__bild');
if (karte.stufe?.bild) {
/* Ein eigenes Bild schlaegt die Vorlage -- und wenn es nicht
laedt, tritt die Vorlage wieder ein. Ein leerer Kasten waere
das Schlimmste von beidem. */
const b = document.createElement('img');
b.src = karte.stufe.bild;
b.alt = '';
b.addEventListener('error', () => {
b.remove();
bild.append(zeichenBauen(karte.stufe?.vorlage, farbe));
});
bild.append(b);
} else {
bild.append(zeichenBauen(karte.stufe?.vorlage, farbe));
}
tafel.append(bild);
const text = el('div', 'spendenkarte__text');
text.append(el('span', 'spendenkarte__wer', karte.name || 'Jemand'));
text.append(el('strong', 'spendenkarte__betrag',
alsGeld(karte.betrag_cent, karte.waehrung)));
if (karte.stufe?.name) {
text.append(el('span', 'spendenkarte__stufe', karte.stufe.name));
}
if (karte.gruss) text.append(el('p', 'spendenkarte__gruss', karte.gruss));
tafel.append(text);
return tafel;
}
/** Eine Karte zeigen. Kasten und Karte, sonst nichts. */
function zeigen(kasten, karte) {
if (!kasten || !karte) return;
/* DIESELBE KARTE NICHT ZWEIMAL. Der Ereignisstrom wird bei einer
Stoerung wiederholt; ohne diese Sperre liefe eine Spende
doppelt, und zwar ausgerechnet dann, wenn das Netz hakt. */
if (schlange.some((k) => k.id && k.id === karte.id)) return;
schlange.push(karte);
/* HOECHSTENS ZWANZIG WARTEN. Darueber ist die Anzeige nicht mehr
Begleitung, sondern Rueckstand. */
while (schlange.length > 20) schlange.shift();
naechste(kasten);
}
window.Spendenkarte = { zeigen, alsGeld, VORLAGEN: Object.keys(VORLAGEN) };
})();