/* ==========================================================================
UI: router, motion primitives and the shared building blocks.
========================================================================== */
/* Hooks come from core.js as globals, see the note there. */
/* --- routing (History API, no dependency) -------------------------------- */
const RouterCtx = createContext({ path: "/", go: () => {} });
function useRouter() { return useContext(RouterCtx); }
function routePath(path) {
const clean = BB.pathWithoutLocale(path);
return clean.path + clean.suffix;
}
function RouterProvider({ children }) {
const [path, setPath] = useState(() => routePath(window.location.pathname + window.location.search));
useEffect(() => {
const onPop = () => setPath(routePath(window.location.pathname + window.location.search));
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []);
useEffect(() => {
if (BB.analytics) BB.analytics.page(window.location.pathname + window.location.search);
}, [path]);
const go = useCallback((to, opts) => {
const target = BB.localizePath(to);
const current = window.location.pathname + window.location.search;
if (target === current) return;
const historyMethod = opts && opts.replace ? "replaceState" : "pushState";
window.history[historyMethod]({}, "", target);
setPath(routePath(target));
if (!(opts && opts.keepScroll)) window.scrollTo({ top: 0, behavior: "instant" });
}, []);
return {children};
}
/** Anchor that keeps the SPA in one page but stays a real link for the browser. */
function Link({ to, children, className, ...rest }) {
const { go } = useRouter();
const target = BB.localizePath(to);
return (
{
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
e.preventDefault();
go(to);
}}
{...rest}
>
{children}
);
}
/* --- session ------------------------------------------------------------- */
const AuthCtx = createContext({ user: null, setUser: () => {}, ready: false });
function useAuth() { return useContext(AuthCtx); }
/* --- Sprache ------------------------------------------------------------- */
const LangCtx = createContext({ lang: "de", locale: "de-de", setLocale: () => {} });
function useLang() { return useContext(LangCtx); }
/* --- toasts -------------------------------------------------------------- */
const ToastCtx = createContext(() => {});
function useToast() { return useContext(ToastCtx); }
function ToastHost({ children }) {
const [toast, setToast] = useState(null);
const timer = useRef(null);
const notify = useCallback((message, tone) => {
clearTimeout(timer.current);
setToast({ message, tone });
timer.current = setTimeout(() => setToast(null), 3200);
}, []);
useEffect(() => () => clearTimeout(timer.current), []);
const urgent = toast && (toast.tone === "bad" || toast.tone === "error");
return (
{children}
{toast ? toast.message : ""}
);
}
/* --- Zugangskarten ------------------------------------------------------ */
/** Das zentrale Angebot wird auf Startseite, Auswertung und Molekülkarte
identisch gezeigt. Preise und Leistungsumfang bleiben dadurch an einer
Stelle gepflegt. Solange der Zahlungsanbieter noch nicht angebunden ist,
erklärt der Button den aktuellen Stand per Toast. */
function OfferCards() {
const toast = useToast();
const { user } = useAuth();
const { go } = useRouter();
const [zahlung, setZahlung] = useState(null);
const [busy, setBusy] = useState("");
// Solange die Bezahlung nicht eingerichtet ist, bleibt alles wie vorher.
// Der Aufruf ist oeffentlich und darf scheitern, ohne dass die Karten fehlen.
useEffect(() => {
let alive = true;
BB.get("/zahlung/angebot")
.then((d) => { if (alive) setZahlung(d); })
.catch(() => { if (alive) setZahlung(null); });
return () => { alive = false; };
}, []);
const bald = () => toast(
BB.t("Bezahlung wird gerade eingerichtet. Bis dahin schaltet die Redaktion Guthaben von Hand frei."),
"info");
const vomServer = (id) => (zahlung?.angebote || []).find((a) => a.id === id) || null;
async function kaufen(id) {
const eintrag = vomServer(id);
if (!zahlung?.aktiv || !eintrag?.kaufbar) return bald();
// Ein Kauf braucht ein Konto: das Guthaben haengt daran, und ohne Konto
// gaebe es nach der Zahlung nichts, worauf es gutgeschrieben wird.
if (!user) {
toast(BB.t("Für einen Kauf brauchst du ein Konto."), "info");
go("/registrieren?next=/auswertung");
return;
}
setBusy(id);
try {
const { url } = await BB.post("/zahlung/kaufen", { angebot: id });
// Der Stand vor dem Kauf, damit die Rueckkehrseite erkennen kann, ob die
// Gutschrift schon da ist. Er kommt aus dem Speicher und nicht aus dem
// React-Zustand: nach der Weiterleitung zu Stripe wird die Seite neu
// geladen, und beim ersten Rendern ist `/me` noch unterwegs.
try {
sessionStorage.setItem("bb-kauf", JSON.stringify(
{ plan: user.plan, tokens: user.tokens, angebot: id }));
} catch (e) { /* Privatmodus: dann wartet die Seite einfach pauschal */ }
window.location.href = url;
} catch (err) {
toast(err.message, "bad");
setBusy("");
}
}
const angebote = [
{
id: "tokens",
label: "Analyse-Token",
title: "10 Analyse-Tokens",
price: "4,90 €",
description: "Ein Paket für einzelne Auswertungen, genau dann, wenn du sie brauchst.",
benefits: ["10 Auswertungen enthalten", "Kein Abo", "Token laufen nicht ab"],
action: "Token kaufen",
premium: false,
},
{
id: "premium",
label: "Premium",
title: "Premium-Mitgliedschaft",
price: "14,90 € / Monat",
description: "Unbegrenzte Analysen und Zugang zur Datenbank und Molekülkarte.",
benefits: ["Unbegrenzte Analysen", "Zugang zur Datenbank", "Zugang zur Molekülkarte", "Komplette Analyse-Historie"],
action: "Premium werden",
premium: true,
},
];
return (
{/* Der teuerste denkbare Irrtum ist, eine Testzahlung fuer echt zu
halten oder umgekehrt. Deshalb steht der Modus da, wo gekauft wird,
und nicht nur in der Verwaltung. */}
{zahlung?.modus === "test" && (
{BB.t("Testbetrieb: Es wird kein echtes Geld abgebucht. Testkarte 4242 4242 4242 4242, beliebiges künftiges Datum, beliebige Prüfziffer.")}
)}
{angebote.map((angebot) => {
const daten = vomServer(angebot.id);
// Der Preis kommt aus Stripe, damit angezeigter und abgebuchter
// Betrag nicht auseinandergehen. Der gepflegte Text traegt so lange,
// bis das eingerichtet ist.
const preis = daten?.preis || BB.t(angebot.price);
return (
);
}
/* --- Rückkehr aus dem Checkout ------------------------------------------ */
/** Die Seite, auf der Stripe nach der Zahlung landet.
Sie schaltet nichts frei und fragt Stripe auch nichts. Freigeschaltet wird
ausschliesslich im Webhook: diese Adresse steht im Browser und liesse sich
sonst von jedem aufrufen, der sie einmal gesehen hat. Hier wird nur so
lange auf das eigene Konto geschaut, bis die Gutschrift angekommen ist.
Der Webhook braucht dafuer in aller Regel ein bis zwei Sekunden. Bleibt er
aus, sagt die Seite das, statt still zu warten: eine Zahlung, die
durchging, und ein Konto, an dem nichts passiert, ist der Moment, in dem
jemand schreiben muss. */
function ZahlungDanke() {
const { user, setUser } = useAuth();
const [wartet, setWartet] = useState(true);
const [angekommen, setAngekommen] = useState(false);
useEffect(() => {
let alive = true;
let versuche = 0;
let vorher = null;
try { vorher = JSON.parse(sessionStorage.getItem("bb-kauf") || "null"); }
catch (e) { vorher = null; }
const schauen = async () => {
versuche += 1;
try {
const frisch = await BB.get("/me");
if (!alive) return;
setUser(frisch);
// Ohne hinterlegten Vorher-Stand (anderes Geraet, geleerter Speicher)
// bleibt nur die schwaechere Frage: steht ueberhaupt etwas da?
const da = vorher
? frisch.plan !== vorher.plan || frisch.tokens !== vorher.tokens
: frisch.plan === "premium" || frisch.tokens > 0;
if (da) {
try { sessionStorage.removeItem("bb-kauf"); } catch (e) {}
setAngekommen(true);
setWartet(false);
return;
}
} catch (e) { /* weiter versuchen */ }
if (!alive) return;
if (versuche >= 10) { setWartet(false); return; }
setTimeout(schauen, 1500);
};
const timer = setTimeout(schauen, 800);
return () => { alive = false; clearTimeout(timer); };
}, []);
const premium = angekommen && user && user.plan === "premium";
return (
{BB.t("Danke.")}
{wartet ? (
{BB.t("Die Zahlung wird bestätigt. Das dauert einen Moment.")}
) : (
{premium
? BB.t("Deine Mitgliedschaft ist aktiv.")
: angekommen && user
? BB.t("Dein Guthaben steht bereit: {n} Token.").replace("{n}", user.tokens)
: BB.t("Die Bestätigung ist noch nicht angekommen. Das kann ein paar Minuten dauern. Bleibt es dabei, schreib uns kurz, die Zahlung ist erfasst.")}
)}
{BB.t("Zur Auswertung")}
{BB.t("Einstellungen")}
);
}
/* --- motion -------------------------------------------------------------- */
/** Reveals children once they scroll into view. Cheap: CSS does the work. */
function Reveal({ children, delay = 0, as: Tag = "div", className = "", ...rest }) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (BB.reducedMotion()) { el.classList.add("in"); return; }
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
el.style.transitionDelay = delay + "ms";
el.classList.add("in");
io.disconnect();
}
},
{ threshold: 0.12, rootMargin: "0px 0px -8% 0px" }
);
io.observe(el);
return () => io.disconnect();
}, [delay]);
return {children};
}
/** Counts up to `value` when scrolled into view. */
function Counter({ value, duration = 1400 }) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (BB.reducedMotion() || typeof anime === "undefined") {
el.textContent = BB.num(value);
return;
}
const io = new IntersectionObserver(([entry]) => {
if (!entry.isIntersecting) return;
io.disconnect();
const obj = { n: 0 };
anime({
targets: obj,
n: value,
round: 1,
duration,
easing: "easeOutExpo",
update: () => { el.textContent = BB.num(obj.n); },
});
}, { threshold: 0.4 });
io.observe(el);
return () => io.disconnect();
}, [value, duration]);
return 0;
}
/** Splits a headline into words and staggers them in on mount. */
function SplitHeadline({ children, className = "" }) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const words = el.querySelectorAll(".hero-word");
if (BB.reducedMotion() || typeof anime === "undefined" || !words.length) return;
anime.set(words, { opacity: 0, translateY: "0.5em" });
anime({
targets: words,
opacity: [0, 1],
translateY: ["0.5em", 0],
duration: 900,
delay: anime.stagger(55, { start: 100 }),
easing: "easeOutExpo",
});
}, []);
return
{children}
;
}
/** Wraps plain text in per-word spans so SplitHeadline can animate it. */
function words(text) {
return text.split(" ").map((w, i) => (
{w}{" "}
));
}
/* --- primitives ---------------------------------------------------------- */
function Avatar({ user, size = "" }) {
if (!user) return
);
}
function TagList({ tags, active = [], onToggle, max }) {
if (!tags || !tags.length) return null;
const list = max ? tags.slice(0, max) : tags;
return (
{list.map((t) => {
const tag = typeof t === "string" ? t : t.tag;
const on = active.includes(tag);
// Gefiltert und verlinkt wird immer mit dem deutschen Tag, angezeigt mit
// seiner Übersetzung. Fachbegriffe wie „19-nor“ stehen nicht in
// UI_STRINGS und bleiben dadurch von selbst unverändert.
return onToggle ? (
) : (
{BB.t(tag)}
);
})}
);
}
/* --- cards --------------------------------------------------------------- */
/** Read/unread marker. Only meaningful for a logged-in reader. */
function ReadMark({ read, label = true }) {
const { user } = useAuth();
if (!user) return null;
return (
{read ? "✓" : ""}
{label && (read ? BB.t("gelesen") : BB.t("neu"))}
);
}
function ReadTime({ minutes }) {
if (!minutes) return null;
return {minutes} Min.;
}
/* Der Bearbeitungsstand hat zwei Leser mit verschiedenen Fragen. Die Redaktion
muss wissen, ob ein Artikel noch ein Entwurf ist oder schon geschrieben und
nur noch ungeprüft: daran hängt, was als Nächstes zu tun ist. Für alle
anderen ist beides dasselbe, nämlich noch nicht lesbar, und die interne
Unterscheidung liest sich von außen wie ein Blick in eine unfertige
Werkstatt. Die beiden Zustände bleiben in der Datenbank unberührt. */
function DraftMark({ post }) {
const { user } = useAuth();
const offen = post.status === "draft" || post.needsReview;
if (!offen) return null;
if (!BB.isEditor(user)) {
return TBD;
}
return (
{post.status === "draft" ? BB.t("Entwurf") : BB.t("Ungeprüft")}
);
}
function PostCard({ post, showCover = true }) {
const href = BB.postHref(post);
const cover = showCover && post.cover;
return (
{cover && (
{/* Angepinnt heisst: die Redaktion haelt das oben fest. Ohne Marke
sieht der Faden aus wie einer, der zufaellig zuerst steht. */}
{post.pinned && 📌}
{post.earlyUntil && {BB.t("Frühzugang")}}
{post.title}
);
}
/** Redaktionsmarke. Der Server liefert das Feld nur an Admins und Mods. */
function ReviewDot({ post }) {
const r = BB.REVIEWS[post.review];
if (!r) return null;
return (
{r.mark}
);
}
function PostRow({ post }) {
return (
{post.title}
{post.summary &&
{post.summary}
}
);
}
/** X von Y gelesen, als Balken. */
function ReadProgress({ read, total, label }) {
const { user } = useAuth();
label = label || BB.t("Fortschritt");
if (!user || !total) return null;
const ratio = Math.min(1, read / total);
return (
,
document.body,
)}
);
}
/** Suchfeld mit Vorschlagsliste statt eines langen Dropdowns.
Ein Auswahlfeld mit hundert Einträgen ist keine Auswahl, sondern eine
Suchaufgabe ohne Suchfeld. Diese Komponente tippt sich wie eine Suche und
verhält sich wie ein Formularfeld: Pfeiltasten wandern, Enter übernimmt,
Escape schließt, ein Klick daneben auch.
`optionen` ist eine Liste aus { id, label, gruppe, zusatz, aliase }. `gruppe`
sortiert die Treffer in Überschriften, `zusatz` steht rechts als leiser
Hinweis. `aliase` sind weitere Namen: wer "Levothyroxin" tippt, findet T4,
und der getroffene Name steht dann rechts daneben. */
function SearchSelect({ optionen, value, onChange, placeholder, gruppenLabel, disabled,
remoteSearch, remoteLabel, remoteLoading, grenze = 40 }) {
const [offen, setOffen] = useState(false);
const [suche, setSuche] = useState("");
const [aktiv, setAktiv] = useState(0);
const box = useRef(null);
const feld = useRef(null);
const gewaehlt = optionen.find((o) => o.id === value) || null;
useEffect(() => {
if (!offen) return;
const weg = (e) => { if (box.current && !box.current.contains(e.target)) setOffen(false); };
document.addEventListener("pointerdown", weg);
return () => document.removeEventListener("pointerdown", weg);
}, [offen]);
/* Ohne Suchbegriff die ersten Einträge, sonst gefiltert. Treffer am Wortanfang
stehen oben: wer "hae" tippt, meint Hähnchen und nicht Sojahackfleisch.
**Danach wird gebündelt.** Der Kopf unten entsteht, sobald `o.gruppe`
wechselt — bei einer nach Label sortierten Liste heißt das, dass jede
Gruppe so oft dasteht, wie sie unterbrochen wird. Im Wirkstoffkatalog
standen "Anabole Steroide" und "Peptide" dadurch abwechselnd ein Dutzend
Mal. Sortiert wird deshalb erst nach Gruppe, dann innerhalb der Gruppe.
Bei aktiver Suche richtet sich die Reihenfolge der Gruppen nach ihrem
besten Treffer, sonst alphabetisch. Wer "test" tippt, soll Testosteron
nicht unter drei aufgeklappten Kategorien suchen müssen. */
const [treffer, aliasTreffer] = useMemo(() => {
const q = BB.foldCase(suche.trim());
const rang = (name) => {
const l = BB.foldCase(name);
if (l === q) return 0;
if (l.startsWith(q)) return 1;
if (l.split(/[\s\-(]+/).some((w) => w.startsWith(q))) return 2;
return l.includes(q) ? 3 : 9;
};
// Ein Treffer über einen Alias steht knapp hinter einem gleich guten über
// das Label: wer "T4" tippt, meint zuerst den Eintrag, der so heißt.
const ueberAlias = new Map();
const punkte = (o) => {
if (!q) return 0;
const eigen = rang(o.label);
let bester = eigen;
(o.aliase || []).forEach((alias) => {
const p = rang(alias) + 0.5;
if (p < bester) { bester = p; ueberAlias.set(o.id, alias); }
});
if (bester === eigen) ueberAlias.delete(o.id);
return bester;
};
const bewertet = optionen.map((o) => [punkte(o), o]).filter(([p]) => p < 9);
const gruppen = new Map();
bewertet.forEach(([p, o]) => {
const key = o.gruppe || "";
if (!gruppen.has(key)) gruppen.set(key, { key, bester: p, eintraege: [] });
const g = gruppen.get(key);
g.bester = Math.min(g.bester, p);
g.eintraege.push([p, o]);
});
const sortierte = [...gruppen.values()].sort((a, b) => (
q ? a.bester - b.bester || a.key.localeCompare(b.key, "de")
: a.key.localeCompare(b.key, "de")
));
const flach = [];
sortierte.forEach((g) => {
g.eintraege
.sort((a, b) => a[0] - b[0] || a[1].label.localeCompare(b[1].label, "de"))
.forEach(([, o]) => flach.push(o));
});
// Ohne Limit bei gruppierten Listen: ein Katalog, dessen letzte Kategorien
// hinter einem Schnitt bei 40 liegen, ist für die Auswahl unerreichbar.
return [grenze > 0 ? flach.slice(0, grenze) : flach, ueberAlias];
}, [optionen, suche, grenze]);
useEffect(() => { setAktiv(0); }, [suche]);
function waehle(o) {
onChange(o.id, o);
setSuche("");
setOffen(false);
}
function taste(e) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
if (!offen) { setOffen(true); return; }
setAktiv((i) => {
const n = treffer.length || 1;
return ((e.key === "ArrowDown" ? i + 1 : i - 1) + n) % n;
});
} else if (e.key === "Enter") {
if (offen && treffer[aktiv]) { e.preventDefault(); waehle(treffer[aktiv]); }
} else if (e.key === "Escape") {
setOffen(false);
}
}
let letzteGruppe = null;
return (
}
{treffer.map((o, i) => {
const kopf = o.gruppe && o.gruppe !== letzteGruppe ? o.gruppe : null;
letzteGruppe = o.gruppe;
return (
{/* Die Gruppenueberschrift kommt je nach Aufrufer aus KLASSEN,
GRUPPEN oder dem Analytkatalog des Servers. Alle drei
liefern deutsche Labels, keines davon lief bisher durch
BB.t(). Ein Rohschluessel ohne Zuordnung bleibt stehen. */}
{kopf &&