/* ============================================================ PlayBull Integration β€” verbindet Trilogy Hub mit dem PlayBull-Backend (echtes Login, Live-Trading, Casino). Additiv: laeuft NACH app.js, ohne dessen Logik zu entfernen. ============================================================ */ (() => { "use strict"; const $ = (s, c = document) => c.querySelector(s); const $$ = (s, c = document) => Array.from(c.querySelectorAll(s)); const PB = { user: null, assets: new Map(), portfolio: null, socket: null, chart: null, parisLive: false, casinoLoaded: false, }; window.PlayBull = PB; /* -------------------- API -------------------- */ async function api(path, body, method = "POST") { const opts = { method, headers: { "Content-Type": "application/json" }, credentials: "same-origin" }; if (body && method !== "GET") opts.body = JSON.stringify(body); const res = await fetch(path, method === "GET" ? { credentials: "same-origin" } : opts); const data = await res.json().catch(() => ({ ok: false, error: "Netzwerkfehler" })); return data; } const get = (p) => api(p, null, "GET"); /* -------------------- Format -------------------- */ const eur = (n, d = 2) => "€" + (Number(n) || 0).toLocaleString("de-DE", { minimumFractionDigits: d, maximumFractionDigits: d }); const px = (n) => (Number(n) < 1 ? (Number(n)).toLocaleString("de-DE", { maximumFractionDigits: 4 }) : eur(n)); const signed = (n) => (n >= 0 ? "+" : "-") + "€" + Math.abs(Number(n) || 0).toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); /* -------------------- Toast -------------------- */ function toast(msg, type = "") { let wrap = $("#pbToasts"); if (!wrap) { wrap = document.createElement("div"); wrap.id = "pbToasts"; wrap.style.cssText = "position:fixed;top:80px;left:0;right:0;z-index:200;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none"; document.body.appendChild(wrap); } const el = document.createElement("div"); const border = type === "win" ? "var(--success)" : type === "lose" ? "var(--error)" : "var(--border)"; el.style.cssText = `pointer-events:auto;background:var(--surface);border:1px solid ${border};color:var(--text);padding:11px 18px;border-radius:12px;font-weight:600;font-size:.9rem;box-shadow:0 10px 30px rgba(0,0,0,.45);max-width:90%`; el.innerHTML = msg; wrap.appendChild(el); setTimeout(() => { el.style.transition = "opacity .3s"; el.style.opacity = "0"; }, 2600); setTimeout(() => el.remove(), 3000); } /* -------------------- Modal -------------------- */ function openModal(html, maxw = "440px") { closeModal(); const back = document.createElement("div"); back.className = "modal-backdrop"; back.id = "pbModal"; back.innerHTML = `
${html}
`; document.body.appendChild(back); back.addEventListener("click", (e) => { if (e.target === back) closeModal(); }); if (window.lucide) window.lucide.createIcons(); return back; } function closeModal() { const m = $("#pbModal"); if (m) m.remove(); } PB.closeModal = closeModal; /* -------------------- Auth -------------------- */ function openAuthModal(mode = "login") { const isLogin = mode === "login"; openModal(`

${isLogin ? "Login" : "Registrieren"} πŸ‚

${isLogin ? "Noch kein Account?" : "Schon registriert?"}

`); $("#pbModal [data-close]").onclick = closeModal; $("#pbAuthSwitch").onclick = () => openAuthModal(isLogin ? "register" : "login"); const submit = async () => { const username = $("#pbUser").value.trim(); const password = $("#pbPass").value; const r = await api(isLogin ? "/api/login" : "/api/register", { username, password }); if (!r.ok) { $("#pbAuthErr").textContent = r.error || "Fehler"; return; } PB.user = r.user; closeModal(); toast(`Willkommen, ${r.user.username}! πŸŽ‰`, "win"); onAuthChange(); }; $("#pbAuthSubmit").onclick = submit; $("#pbPass").addEventListener("keydown", (e) => { if (e.key === "Enter") submit(); }); setTimeout(() => $("#pbUser")?.focus(), 50); } PB.openAuthModal = openAuthModal; async function logout() { await api("/api/logout", {}); PB.user = null; PB.parisLive = false; onAuthChange(); toast("Ausgeloggt"); } function onAuthChange() { // Members-only Navigation ein/ausblenden $$("[data-members]").forEach((el) => { el.hidden = !PB.user; }); // aktuelle Seite aktualisieren const route = (location.hash || "#home").replace("#", ""); if (route === "paris") setupParis(); if (route === "casino") setupCasino(); // Falls eingeloggt: Portfolio laden if (PB.user) loadPortfolio(); } /* -------------------- Portfolio / Markt -------------------- */ async function loadMarket() { const r = await get("/api/market"); if (r.ok) r.assets.forEach((a) => PB.assets.set(a.symbol, a)); } async function loadPortfolio() { if (!PB.user) return; const r = await get("/api/portfolio"); if (r.ok) { PB.portfolio = r.portfolio; if (PB.user) PB.user.balance = r.portfolio.balance; } } /* ==================== PARIS β€” LIVE TRADING ==================== */ function setupParis() { // Demo-Handler von app.js abklemmen: Login-Form + Logout klonen const form = $("#loginForm"); if (form && !form.dataset.pbWired) { const clone = form.cloneNode(true); form.replaceWith(clone); clone.dataset.pbWired = "1"; clone.addEventListener("submit", async (e) => { e.preventDefault(); const u = clone.querySelector("#username").value.trim(); const pw = clone.querySelector("#password").value; const err = clone.querySelector("#loginError"); // 1) Login versuchen, 2) sonst automatisch registrieren let r = await api("/api/login", { username: u, password: pw }); if (!r.ok) { const reg = await api("/api/register", { username: u, password: pw }); if (reg.ok) r = reg; } if (!r.ok) { err.textContent = r.error || "Login fehlgeschlagen"; return; } err.textContent = ""; PB.user = r.user; onAuthChange(); revealDashboard(); }); // Hinweis-Text anpassen const hint = clone.querySelector("p.text-faint"); if (hint) hint.innerHTML = 'Dein PlayBull-Konto β€” neu? Einfach Name + Passwort eingeben, der Account wird automatisch erstellt.'; } const logoutBtn = $("#logoutBtn"); if (logoutBtn && !logoutBtn.dataset.pbWired) { const lc = logoutBtn.cloneNode(true); logoutBtn.replaceWith(lc); lc.dataset.pbWired = "1"; lc.addEventListener("click", () => { logout(); $("#parisDashboard").classList.add("hidden"); $("#parisLogin").classList.remove("hidden"); }); } if (PB.user) revealDashboard(); } async function revealDashboard() { if (!PB.user) return; $("#parisLogin")?.classList.add("hidden"); $("#parisDashboard")?.classList.remove("hidden"); await loadPortfolio(); injectDashboardExtras(); renderMarketTable(); renderTradeLog(); renderHoldings(); updateKpis(); requestAnimationFrame(() => { try { buildAllocationChart(); } catch {} }); PB.parisLive = true; if (window.lucide) window.lucide.createIcons(); } // Logged-in-as Anzeige + Casino-CTA in die Navbar setzen function injectDashboardExtras() { const chip = $$("#parisDashboard .chip").find((c) => /Logged in as|Eingeloggt/.test(c.textContent)); if (chip) chip.textContent = `Eingeloggt: ${PB.user.username}`; const bar = $("#parisDashboard .wrap.flex"); if (bar && !$("#pbCasinoCta")) { const a = document.createElement("button"); a.id = "pbCasinoCta"; a.className = "btn btn-ghost !min-h-0 !py-2.5"; a.innerHTML = 'Casino & Games '; a.onclick = () => { location.hash = "#casino"; }; const logout = $("#parisDashboard #logoutBtn"); logout?.parentElement?.insertBefore(a, logout); } } function updateKpis() { const p = PB.portfolio; if (!p) return; const pnl = (p.holdings || []).reduce((s, h) => s + h.pnl, 0); // Reihenfolge im KPI-Grid: Portfolio Value, Today's P&L, Open Positions, Buying Power const grid = $("#parisDashboard .grid.grid-cols-2"); if (grid) { const vals = $$("[data-counter]", grid); if (vals[0]) vals[0].textContent = eur(p.netWorth); if (vals[1]) { vals[1].textContent = signed(pnl); vals[1].style.color = pnl >= 0 ? "var(--success)" : "var(--error)"; } if (vals[2]) vals[2].textContent = String((p.holdings || []).length); if (vals[3]) vals[3].textContent = eur(p.balance); // statische "(+0.66%)" Klammer hinter P&L entfernen, falls vorhanden const pnlExtra = vals[1]?.parentElement?.querySelector("span.text-sm"); if (pnlExtra) pnlExtra.textContent = ""; } // Portfolio-Wert oben in der Navbar const navPv = $("#parisDashboard .text-right [data-counter]"); if (navPv) navPv.textContent = eur(p.netWorth); } function renderMarketTable() { const table = $("#watchlistTable"); if (!table) return; const assets = [...PB.assets.values()]; const posBySym = {}; (PB.portfolio?.holdings || []).forEach((h) => (posBySym[h.symbol] = h)); table.innerHTML = ` Asset Kurs 24h Position Aktionen ${assets.map((a) => { const pos = posBySym[a.symbol]; const cls = a.changePct >= 0 ? "text-success" : "text-error"; return ` ${a.emoji || ""} ${a.symbol}
${a.name}
${px(a.price)} ${a.changePct >= 0 ? "+" : ""}${(a.changePct).toFixed(2)}% ${pos ? pos.quantity + (pos.side === "short" ? " (S)" : "") : "β€”"} `; }).join("")} `; $$("#watchlistTable tr[data-sym]").forEach((tr) => { const sym = tr.dataset.sym; tr.querySelector("[data-buy]").onclick = () => openTradeModal(sym, "buy"); tr.querySelector("[data-sell]").onclick = () => openTradeModal(sym, "sell"); tr.querySelector("[data-pump]").onclick = () => manipulate(sym, "pump"); tr.querySelector("[data-dump]").onclick = () => manipulate(sym, "dump"); }); } async function manipulate(sym, action) { const r = await api("/api/manipulate", { symbol: sym, action, value: action === "pump" || action === "dump" ? 3 : 0 }); if (!r.ok) return toast(r.error, "lose"); toast(`${sym}: ${action === "pump" ? "πŸš€ gepumpt" : "πŸ“‰ gedumpt"} (fΓΌr alle)`); } function openTradeModal(sym, side) { const a = PB.assets.get(sym); openModal(`

${a.emoji || ""} ${sym} ${a.name}

Kurs ${px(a.price)}
${[0.1, 1, 5, 10].map((v) => ``).join("")}

Guthaben: ${eur(PB.user.balance)}

`); let mode = side; const qty = $("#tmQty"); const refresh = () => { const cur = PB.assets.get(sym); $("#tmPx").textContent = px(cur.price); const cost = (parseFloat(qty.value) || 0) * cur.price; $("#tmEst").textContent = `β‰ˆ ${eur(cost)}`; $("#tmGo").textContent = mode === "buy" ? "Jetzt kaufen" : "Jetzt verkaufen"; $("#tmGo").className = "btn w-full " + (mode === "buy" ? "btn-primary" : "btn-ghost"); }; $("#pbModal [data-close]").onclick = closeModal; $$("#pbModal [data-mode]").forEach((b) => b.onclick = () => { mode = b.dataset.mode; $$("#pbModal [data-mode]").forEach((x) => x.className = "btn flex-1 " + (x.dataset.mode === mode ? "btn-primary" : "btn-ghost")); refresh(); }); $$("#pbModal [data-amt]").forEach((c) => c.onclick = () => { qty.value = c.dataset.amt; refresh(); }); $("#pbModal [data-max]").onclick = () => { qty.value = (PB.user.balance / PB.assets.get(sym).price).toFixed(4); refresh(); }; qty.addEventListener("input", refresh); $("#tmGo").onclick = async () => { const r = await api("/api/trade", { symbol: sym, side: mode, qty: parseFloat(qty.value) }); if (!r.ok) return toast(r.error, "lose"); const tradedQty = parseFloat(qty.value); PB.user.balance = r.balance; closeModal(); toast(`${mode === "buy" ? "Gekauft" : "Verkauft"}: ${tradedQty} ${sym} @ ${px(r.price)}`, "win"); await loadPortfolio(); updateKpis(); renderMarketTable(); renderTradeLog(); buildAllocationChart(); }; refresh(); } async function renderTradeLog() { const log = $("#tradeLog"); if (!log) return; const r = await get("/api/trades"); const trades = r.ok ? r.trades : []; log.innerHTML = trades.length ? trades.slice(0, 12).map((t) => { const isBuy = t.side === "buy"; const d = new Date(t.ts).toLocaleString("de-DE", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }); return `
${isBuy ? "BUY" : "SELL"} ${t.qty} ${t.symbol} @ ${px(t.price)} ${d}
`; }).join("") : '
Noch keine Trades.
'; } function renderHoldings() { updateKpis(); } const chartColors = ["#c9a84c", "#2f9e8f", "#5b7aa6", "#9a958a", "#7d8a4f", "#b8763a", "#6a8caf", "#a85f7a"]; function buildAllocationChart() { const ctx = $("#allocationChart"); if (!ctx || typeof Chart === "undefined") return; const holds = (PB.portfolio?.holdings || []).filter((h) => Math.abs(h.value) > 0.01); const data = holds.map((h) => Math.abs(h.value)); const labels = holds.map((h) => h.symbol); if (PB.chart) PB.chart.destroy(); if (!data.length) { const c2 = ctx.getContext("2d"); c2.clearRect(0, 0, ctx.width, ctx.height); $("#chartLegend").innerHTML = '

Noch keine Positionen.

'; return; } PB.chart = new Chart(ctx, { type: "doughnut", data: { labels, datasets: [{ data, backgroundColor: labels.map((_, i) => chartColors[i % chartColors.length]), borderColor: "transparent", borderWidth: 2, hoverOffset: 8 }] }, options: { cutout: "62%", responsive: true, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (c) => ` ${c.label}: ${eur(c.parsed)}` } } } }, }); const total = data.reduce((a, b) => a + b, 0); $("#chartLegend").innerHTML = labels.map((l, i) => `
${l} ${((data[i] / total) * 100).toFixed(1)}%
`).join(""); } /* ==================== CASINO (eingebettete Arcade) ==================== */ function setupCasino() { const gate = $("#casinoGate"), wrap = $("#casinoFrameWrap"); if (!gate || !wrap) return; if (PB.user) { gate.classList.add("hidden"); wrap.classList.remove("hidden"); const frame = $("#casinoFrame"); if (frame) { const url = "/play.html?embed=1"; if (!frame.src.includes("embed=1")) frame.src = url; PB.casinoLoaded = true; } } else { gate.classList.remove("hidden"); wrap.classList.add("hidden"); } const btn = $("#casinoLoginBtn"); if (btn && !btn.dataset.pbWired) { btn.dataset.pbWired = "1"; btn.onclick = () => openAuthModal(); } if (window.lucide) window.lucide.createIcons(); } /* -------------------- Live (Socket.IO) -------------------- */ function connectSocket() { if (typeof io === "undefined") return; const socket = io(); PB.socket = socket; socket.on("hello", ({ assets }) => { assets && assets.forEach((a) => PB.assets.set(a.symbol, a)); }); socket.on("prices", ({ data }) => { data.forEach((d) => { const a = PB.assets.get(d.s); if (!a) return; const prev = a.price; a.price = d.p; a.change = d.c; a.changePct = d.cp; a.halted = !!d.h; if ((location.hash || "#home").replace("#", "") === "paris" && PB.parisLive) { const row = $(`#watchlistTable tr[data-sym="${d.s}"]`); if (row) { const pxc = row.querySelector("[data-px]"), chc = row.querySelector("[data-chg]"); if (pxc) { pxc.textContent = px(d.p); pxc.classList.remove("flash-up", "flash-down"); void pxc.offsetWidth; pxc.classList.add(d.p >= prev ? "flash-up" : "flash-down"); } if (chc) { chc.textContent = (d.cp >= 0 ? "+" : "") + d.cp.toFixed(2) + "%"; chc.className = "py-3 px-3 text-right " + (d.cp >= 0 ? "text-success" : "text-error"); } } } }); // Portfolio-Wert live nachrechnen if (PB.portfolio && PB.parisLive) recomputeLive(); }); socket.on("balance_changed", ({ userId, balance }) => { if (PB.user && userId === PB.user.id) { PB.user.balance = balance; if (PB.portfolio) PB.portfolio.balance = balance; updateKpis(); } }); } function recomputeLive() { const p = PB.portfolio; if (!p) return; let hv = 0; (p.holdings || []).forEach((h) => { const a = PB.assets.get(h.symbol); if (a) { h.price = a.price; h.value = h.quantity * a.price; h.pnl = h.value - h.quantity * h.avgPrice; } hv += h.value; }); p.holdingsValue = hv; p.netWorth = p.balance + hv; updateKpis(); if (PB.chart) { PB.chart.data.datasets[0].data = (p.holdings || []).filter(h => Math.abs(h.value) > 0.01).map(h => Math.abs(h.value)); PB.chart.update("none"); } } /* -------------------- Route Hooks -------------------- */ function onRoute() { const route = (location.hash || "#home").replace("#", ""); if (route === "paris") setupParis(); if (route === "casino") setupCasino(); } PB.onRoute = onRoute; /* -------------------- Init -------------------- */ async function init() { await loadMarket(); const me = await get("/api/me"); if (me.ok && me.user) { PB.user = me.user; await loadPortfolio(); } $$("[data-members]").forEach((el) => { el.hidden = !PB.user; }); connectSocket(); window.addEventListener("hashchange", onRoute); // Erststart-Route behandeln (z.B. direkt auf #paris) onRoute(); } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); else init(); })();