/* ========================================================================== 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 (
{BB.t(angebot.label)}

{BB.t(angebot.title)}

{preis}{daten?.intervall === "month" ? BB.t(" / Monat") : ""}

{BB.t(angebot.description)}

    {angebot.benefits.map((benefit) => (
  • {BB.t(benefit)}
  • ))}
); })}
); } /* --- 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
?
; const isSystem = user.role === "system"; if (user.avatar) { return (
{ e.target.style.display = "none"; }} />
); } return (
{isSystem ? "BB" : BB.initials(user.displayName || user.username)}
); } function KindBadge({ kind }) { const k = BB.KINDS[kind]; if (!k) return null; return {BB.t(k.label)}; } function Byline({ author, at, extra }) { if (!author) return null; return (
{author.displayName} {at && <>·{BB.when(at)}} {extra && <>·{extra}}
); } function Empty({ mark = "∅", title, children, action }) { return (
{mark}

{title}

{children &&

{children}

} {action}
); } function Spinner({ label = null }) { const text = label == null ? BB.t("Lädt …") : label; return (
◆

{text}

); } function SkeletonCards({ n = 6 }) { return (
{Array.from({ length: n }).map((_, i) => (
))}
); } function Field({ label, hint, error, children }) { return (
{label && } {children} {hint && !error &&
{hint}
} {error &&
{error}
}
); } /** Bild hochladen oder optional eine Adresse einfügen. Der Server prüft das Format und übernimmt die passende Kompression. */ function ImageUpload({ value, onChange, label, hint, compact = false, allowUrl = true, variant = "image" }) { const [busy, setBusy] = useState(false); const [over, setOver] = useState(false); const [error, setError] = useState(""); const [showUrl, setShowUrl] = useState(false); const input = useRef(null); async function send(file) { if (!file) return; setBusy(true); setError(""); const fd = new FormData(); fd.append("file", file); fd.append("variant", variant); try { // bewusst nicht über BB.api: FormData braucht den vom Browser gesetzten // Content-Type samt boundary const res = await fetch("/api/uploads", { method: "POST", body: fd, credentials: "same-origin" }); const text = await res.text(); const data = text ? JSON.parse(text) : {}; if (!res.ok) throw new Error(data.detail || "Der Upload hat nicht geklappt."); onChange(data.url); } catch (e) { setError(e.message); } finally { setBusy(false); } } function drop(e) { e.preventDefault(); setOver(false); send(e.dataTransfer.files && e.dataTransfer.files[0]); } return (
{label && } {value ? (
{ e.target.style.opacity = 0.2; }} />
) : (
{ e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)} onDrop={drop} onClick={() => input.current && input.current.click()} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") input.current.click(); }} > {busy ? "Lädt hoch …" : "Bild hierher ziehen oder klicken"} {BB.t("PNG, JPG oder WEBP, maximal 8 MB")}
)} { send(e.target.files[0]); e.target.value = ""; }} /> {allowUrl && (!showUrl ? ( ) : ( onChange(e.target.value)} placeholder="https://…" autoFocus /> ))} {hint && !error &&
{hint}
} {error &&
⚠ {error}
}
); } function Segmented({ value, options, onChange }) { return (
{options.map((o) => ( ))}
); } 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 ( {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 && (
{ e.target.parentNode.style.display = "none"; }} />
)}

{/* 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}

{post.summary &&

{post.summary}

}
{post.author && <>·{post.author.displayName}} · {BB.when(post.createdAt)} {post.votes > 0 && !BB.isWiki(post) && <>·▲ {post.votes}} {post.comments > 0 && <>·{post.comments} ✎}
); } /** 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 (
{label} {BB.num(read)} {BB.t("von")} {BB.num(total)} {BB.t("gelesen")}
); } /* --- data viz ------------------------------------------------------------ */ function MetricBars({ schema, values }) { if (!schema || !values) return null; const rows = schema.filter((m) => values[m.key] !== undefined && values[m.key] !== null); if (!rows.length) return null; return (
{rows.map((m) => { const v = values[m.key]; const max = m.max || 10; const ratio = Math.min(1, v / max); const tone = m.inverse ? (ratio >= 0.7 ? "hot" : ratio >= 0.4 ? "warn" : "") : ""; return (
{BB.t(m.label)} {m.unit ? `${v} ${m.unit}` : `${v}/${max}`}
); })}
); } /** 2D-Strukturformel. Bilder stammen aus PubChem und liegen lokal unter static/molecules, deshalb funktioniert die Anzeige auch ohne Internet. */ function Molecule({ slug, cid, title, note }) { const [broken, setBroken] = useState(false); const [zoom, setZoom] = useState(false); // Escape schliesst die Grossansicht, solange sie offen ist useEffect(() => { if (!zoom) return; const onKey = (e) => e.key === "Escape" && setZoom(false); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [zoom]); if (!slug || broken) { return note ?

Keine Strukturformel: {note}.

: null; } const src = `/static/molecules/${slug}.png`; const alt = `${BB.t("Strukturformel von")} ${title || slug}`; return (
{cid && (
PubChem CID {cid}
)} {zoom && ReactDOM.createPortal( // ueber einen Portal ans body, damit die Grossansicht nicht in der // scrollbaren Seitenleiste haengen bleibt
setZoom(false)} role="dialog" aria-modal="true" aria-label={alt}>
{alt}
{title || slug} {BB.t("Klick oder Escape schließt")}
, 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 (
{ setSuche(e.target.value); setOffen(true); }} onFocus={() => { setSuche(""); setOffen(true); }} onKeyDown={taste} role="combobox" aria-expanded={offen} aria-autocomplete="list" /> {gewaehlt && !offen && ( )} {offen && (
{treffer.length === 0 &&
{BB.t("Nichts gefunden")}
} {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 &&
{BB.t((gruppenLabel || {})[kopf] || kopf)}
}
); })} {remoteSearch && suche.trim().length >= 3 && ( )}
)}
); } Object.assign(window, { RouterProvider, useRouter, Link, AuthCtx, useAuth, LangCtx, useLang, ToastHost, useToast, OfferCards, ZahlungDanke, Reveal, Counter, SplitHeadline, words, Avatar, KindBadge, Byline, Empty, Spinner, SkeletonCards, Field, Segmented, TagList, PostCard, PostRow, DraftMark, MetricBars, Molecule, ReadMark, ReadTime, ReadProgress, ReviewDot, ImageUpload, SearchSelect, });