1<!DOCTYPE html> 2<html lang="en"> 3<head> 4<!-- Google tag (gtag.js) -->
vendor: 64 bytes, lines 4-5
4 5<script async src="https://www.googletagmanager.com/gtag/js?id=
5G-ZTZYGKSF6B
vendor: 12 bytes, line 5
5"></script>
6<script> 7
vendor: 133 bytes, lines 7-10
7window.dataLayer = window.dataLayer || []; 8 function gtag(){dataLayer.push(arguments);} 9 gtag('js', new Date()); 10 gtag('config', '
10G-ZTZYGKSF6B
vendor: 6 bytes, lines 10-11
10'); 11
11gtag('config', 'G-L4P5VTL06P'); 12</script>
12 13<meta charset="UTF-8"> 14<meta name="viewport" content="width=device-width, initial-scale=1.0"> 15<title>Factoz Command Center</title> 16<meta name="description" content="Factoz Command Center â finance content automation platform for creators and trading desks. Research signals, AI captions, TradingView charts and scheduled social posting."> 17<meta name="robots" content="index, follow"> 18<link rel="canonical" href="https://factozcentral.com"> 19<meta property="og:type" content="website"> 20<meta property="og:url" content="https://factozcentral.com"> 21<meta property="og:title" content="Factoz Command Center â Finance Content Automation"> 22<meta property="og:description" content="Research signals, AI captions, charts and scheduled posting â all in one platform for finance creators and trading desks."> 23<meta property="og:site_name" content="Factoz"> 24<meta name="twitter:card" content="summary_large_image"> 25<meta name="twitter:title" content="Factoz Command Center â Finance Content Automation"> 26<meta name="twitter:description" content="Research signals, AI captions, charts and scheduled posting â all in one platform.">
27<script type="application/ld+json"> 28{ 29 "@context": "https://schema.org", 30 "@type": "WebApplication", 31 "name": "Factoz Command Center", 32 "url": "https://factozcentral.com", 33 "description": "Finance content automation platform for creators and trading desks.", 34 "applicationCategory": "FinanceApplication", 35 "operatingSystem": "Web", 36 "offers": { 37 "@type": "Offer", 38 "price": "25", 39 "priceCurrency": "USD" 40 } 41} 42</script>
42 43<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> 44<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet"> 45<style> 46:root{--bg:#0b132b;--bg2:#0f1a36;--bg3:#131f40;--bg4:#1a274d;--border:#202e56;--teal:#E2C275;--green:#00e676;--red:#ff4444;--yellow:#ffd600;--muted:#3d4f63;--text:#c5d0dc;--text2:#7a8fa3;--mono:'Space Mono',monospace;--sans:'Syne',sans-serif} 47*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%;overflow:hidden} 48body{background:var(--bg);color:var(--text);font-family:var(--sans);display:flex} 49#sidebar{width:210px;min-width:210px;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;overflow-y:auto} 50.logo{padding:22px 18px 18px;border-bottom:1px solid var(--border)} 51.logo-title{font-family:var(--mono);font-size:.9rem;font-weight:700;color:var(--teal);letter-spacing:4px} 52.logo-sub{font-size:.58rem;color:var(--muted);letter-spacing:3px;margin-top:3px;font-family:var(--mono)} 53nav{padding:12px 0;flex:1} 54.nav-section{font-size:.62rem;font-weight:700;letter-spacing:1.5px;color:var(--text2);padding:12px 16px 4px;font-family:var(--mono);cursor:pointer;user-select:none;display:flex;justify-content:space-between;align-items:center} 55.nav-section::after{content:'â¾';font-size:.6rem;opacity:.5;letter-spacing:0} 56.nav-section.collapsed::after{content:'â¸'} 57.nav-section-items.collapsed{display:none} 58.nav-section-noc::after{display:none} 59.nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;cursor:pointer;font-size:.74rem;color:var(--text2);transition:all .15s;border-left:2px solid transparent;font-weight:500;letter-spacing:.2px} 60.nav-item:hover{background:var(--bg3);color:var(--text)} 61.nav-item.active{background:rgba(226,194,117,.07);color:var(--teal);border-left-color:var(--teal)} 62.nav-item.admin-item{color:#ffd600} 63.nav-item.admin-item:hover{background:rgba(255,214,0,.05)} 64.nav-item.admin-item.active{background:rgba(255,214,0,.07);color:var(--yellow);border-left-color:var(--yellow)} 65.nav-item svg{width:13px;height:13px;stroke-width:1.75;flex-shrink:0;opacity:.6;transition:opacity .15s} 66.nav-item:hover svg,.nav-item.active svg{opacity:1} 67.nav-item.admin-item svg{opacity:.8} 68.nav-icon{display:flex;align-items:center;flex-shrink:0} 69.sidebar-footer{padding:14px 18px;border-top:1px solid var(--border);font-size:.65rem;color:var(--muted);font-family:var(--mono)} 70#clock{color:var(--text2);margin-bottom:4px} 71#user-tag{color:var(--teal);font-size:.62rem;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} 72#tier-tag{font-size:.58rem;margin-bottom:8px;font-family:var(--mono)} 73#xp-widget{display:none;margin-bottom:10px;padding:8px 10px;background:var(--bg3);border:1px solid var(--border);border-radius:8px} 74#xp-widget .xp-row{display:flex;justify-content:space-between;align-items:center} 75#xp-progress-track{height:5px;background:var(--bg2);border-radius:3px;overflow:hidden;margin:5px 0 4px} 76#xp-progress-bar{height:100%;background:var(--teal);width:0%;transition:width .3s ease} 77.logout-btn{cursor:pointer;color:var(--muted);font-size:.62rem;transition:color .15s} 78.logout-btn:hover{color:var(--red)} 79#main{flex:1;overflow-y:auto;padding:26px 30px;background:var(--bg)} 80.page{display:none}.page.active{display:block;animation:fadeIn .2s ease} 81@keyframes fadeIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}} 82/* ââ Overview Bento Layout ââ */ 83.ov-stats{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px;margin-bottom:14px} 84.ov-stat{background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:16px 18px;border-left:3px solid var(--teal)} 85.ov-stat .ov-stat-v{font-size:1.8rem;font-weight:700;color:#e2eaf2;font-family:var(--mono);line-height:1} 86.ov-stat .ov-stat-l{font-size:.58rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-top:6px} 87.ov-stat .ov-stat-s{font-size:.68rem;color:var(--text2);margin-top:2px} 88.ad-slot{line-height:0;}.ad-slot a{display:block;}.ad-slot img{width:100%;border-radius:8px;display:block;}
89.ov-bento{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-top:0} 90.ov-bento-wide{grid-column:1/-1} 91.ov-card{background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:14px 16px} 92.ov-card-hdr{display:flex;align-items:center;gap:4px;margin-bottom:10px} 93.ov-card-hdr>span{font-size:.58rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;font-weight:700} 94.ov-card-hdr>a{margin-left:auto;font-size:.58rem;color:var(--teal);font-family:var(--mono);cursor:pointer;letter-spacing:1px;text-decoration:none} 95.ov-card-hdr>button{background:none;border:none;color:var(--text2);font-size:.8rem;cursor:pointer;padding:0 2px;line-height:1} 96.ov-card-hdr>button:first-of-type{margin-left:auto} 97@keyframes skelPulse{0%,100%{opacity:.3}50%{opacity:.55}} 98.ov-skel{height:11px;background:var(--bg3);border-radius:4px;animation:skelPulse 1.5s ease infinite;margin-bottom:7px} 99.ov-skel-sm{height:8px;width:55%} 100.page-header{margin-bottom:24px} 101.page-title{font-size:1.2rem;font-weight:800;color:#e2eaf2;display:flex;align-items:center;gap:12px;letter-spacing:-.3px} 102.page-title svg{width:22px;height:22px;stroke-width:1.75;flex-shrink:0;opacity:.85} 103.page-title lord-icon{flex-shrink:0} 104.pm-tab{color:var(--text2)!important;border-color:transparent!important;background:transparent!important} 105.pm-tab-active{color:var(--teal)!important;border-color:var(--teal)!important;background:rgba(0,200,83,.07)!important} 106.step-badge{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;background:rgba(226,194,117,.1);border:1px solid rgba(226,194,117,.3);border-radius:50%;font-family:var(--mono);font-size:.75rem;color:var(--teal);flex-shrink:0} 107.page-sub{font-size:.78rem;color:var(--text2);margin-top:5px;margin-left:40px;font-weight:400} 108.g2{display:grid;grid-template-columns:1fr 1fr;gap:18px} 109.scanner-grid{display:grid;grid-template-columns:440px 1fr;gap:18px} 110.g3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px} 111.g4{display:grid;grid-template-columns:repeat(4,1fr);gap:12px} 112.card{background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:16px 18px;margin-bottom:14px} 113.card h4{color:var(--teal);font-size:.72rem;margin-bottom:6px;font-family:var(--mono);letter-spacing:1px} 114.card p{color:var(--text2);font-size:.78rem;line-height:1.55} 115.stat-card{background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:18px} 116.stat-card .lbl{font-size:.6rem;color:var(--text2);font-family:var(--mono);letter-spacing:2px;margin-bottom:8px} 117.stat-card .val{font-size:1.7rem;font-weight:700;color:#e2eaf2;font-family:var(--mono)} 118.stat-card .sub{font-size:.7rem;color:var(--muted);margin-top:3px} 119.btn{display:inline-flex;align-items:center;gap:7px;padding:8px 16px;border-radius:7px;font-size:.78rem;font-weight:700;cursor:pointer;border:none;transition:all .15s;font-family:var(--sans);letter-spacing:.3px} 120.btn-primary{background:rgba(226,194,117,.12);color:var(--teal);border:1px solid rgba(226,194,117,.35)} 121.btn-primary:hover{background:rgba(226,194,117,.22);border-color:var(--teal)} 122.btn-green{background:rgba(0,230,118,.1);color:var(--green);border:1px solid rgba(0,230,118,.3)} 123.btn-green:hover{background:rgba(0,230,118,.18)} 124.btn-yellow{background:rgba(255,214,0,.1);color:var(--yellow);border:1px solid rgba(255,214,0,.3)} 125.btn-yellow:hover{background:rgba(255,214,0,.18)} 126.btn-red{background:rgba(255,68,68,.1);color:var(--red);border:1px solid rgba(255,68,68,.3)} 127.btn-red:hover{background:rgba(255,68,68,.18)} 128.btn-ghost{background:transparent;color:var(--text2);border:1px solid var(--border)} 129.btn-ghost:hover{background:var(--bg3);color:var(--text)} 130.btn:disabled{opacity:.35;cursor:not-allowed} 131.btn-full{width:100%;justify-content:center} 132.form-group{margin-bottom:12px} 133.form-label{display:block;font-size:.62rem;color:var(--text2);margin-bottom:5px;font-family:var(--mono);letter-spacing:1px} 134input,textarea,select{width:100%;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:8px 11px;color:var(--text);font-size:.82rem;font-family:var(--sans);outline:none;transition:border-color .15s} 135input:focus,textarea:focus,select:focus{border-color:var(--teal)} 136textarea{resize:vertical;min-height:80px;line-height:1.55} 137select option{background:var(--bg2)} 138input[type=file]{padding:6px;font-size:.75rem;color:var(--text2)} 139input[type=color]{width:36px;height:34px;padding:2px;border-radius:6px;cursor:pointer} 140input[type=checkbox]{width:auto} 141.badge{display:inline-flex;align-items:center;gap:4px;padding:2px 9px;border-radius:20px;font-size:.68rem;font-weight:700;font-family:var(--mono)} 142.badge-green{background:rgba(0,230,118,.1);color:var(--green)} 143.badge-teal{background:rgba(226,194,117,.1);color:var(--teal)} 144.badge-yellow{background:rgba(255,214,0,.1);color:var(--yellow)} 145.badge-red{background:rgba(255,68,68,.1);color:var(--red)} 146.badge-muted{background:var(--bg3);color:var(--text2)} 147.terminal{background:#020509;border:1px solid var(--border);border-radius:8px;padding:12px 14px;font-family:var(--mono);font-size:.73rem;color:var(--green);max-height:260px;overflow-y:auto;white-space:pre-wrap;line-height:1.65;margin-top:10px} 148hr{border:none;border-top:1px solid var(--border);margin:18px 0} 149.spin{display:inline-block;width:13px;height:13px;border:2px solid rgba(226,194,117,.2);border-top-color:var(--teal);border-radius:50%;animation:spin .7s linear infinite} 150@keyframes spin{to{transform:rotate(360deg)}} 151.pm-scan-badges{display:flex;flex-wrap:wrap;gap:6px} 152.pm-badge{padding:4px 10px;border-radius:20px;font-size:.68rem;font-weight:700;font-family:var(--mono);cursor:pointer;transition:opacity .15s} 153.pm-badge:hover{opacity:.8} 154.pm-badge.bullish{background:rgba(0,230,118,.1);color:var(--green);border:1px solid rgba(0,230,118,.4)} 155.pm-badge.bearish{background:rgba(255,68,68,.1);color:var(--red);border:1px solid rgba(255,68,68,.4)} 156.pm-badge.neutral{background:var(--bg3);color:var(--muted);border:1px solid var(--border);cursor:default} 157@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}} 158.wiz-bar{display:flex;align-items:center;gap:0;margin-bottom:14px} 159.wiz-step{display:flex;flex-direction:column;align-items:center;gap:3px;min-width:60px} 160.wiz-num{width:26px;height:26px;border-radius:50%;background:var(--bg3);border:2px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:.72rem;font-weight:700;color:var(--muted);font-family:var(--mono)} 161.wiz-label{font-size:.62rem;color:var(--muted);font-family:var(--mono);white-space:nowrap} 162.wiz-line{flex:1;height:2px;background:var(--border);min-width:20px} 163.wiz-step.active .wiz-num{background:var(--teal);border-color:var(--teal);color:#000} 164.wiz-step.active .wiz-label{color:var(--teal);font-weight:700} 165.wiz-step.done .wiz-num{background:var(--green);border-color:var(--green);color:#000} 166.wiz-step.done .wiz-label{color:var(--green)} 167#toast{position:fixed;bottom:22px;right:22px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:11px 16px;font-size:.78rem;color:var(--text);transform:translateY(70px);opacity:0;transition:all .25s;z-index:999;max-width:280px;font-family:var(--mono)} 168#toast.show{transform:translateY(0);opacity:1} 169#toast.ok{border-color:var(--green);color:var(--green)} 170#toast.err{border-color:var(--red);color:var(--red)} 171#auth{position:fixed;inset:0;z-index:100;background:var(--bg);display:flex;align-items:center;justify-content:center} 172.auth-box{background:var(--bg2);border:1px solid var(--border);border-top:2px solid var(--teal);border-radius:14px;padding:38px;width:380px} 173.auth-logo{font-family:var(--mono);font-size:1.55rem;color:var(--teal);letter-spacing:6px;text-align:center;margin-bottom:6px;font-weight:700} 174.auth-sub{text-align:center;font-size:.6rem;color:var(--muted);margin-bottom:8px;letter-spacing:2px;font-family:var(--mono)} 175.auth-tagline{text-align:center;font-size:.75rem;color:var(--text2);margin-bottom:20px;line-height:1.4} 176.auth-pills{display:flex;gap:6px;justify-content:center;flex-wrap:wrap;margin-bottom:18px} 177.auth-pill{background:var(--bg3);color:var(--teal);border:1px solid var(--border);border-radius:20px;padding:4px 10px;font-size:.6rem;letter-spacing:1px;font-family:var(--mono)} 178.auth-tabs{display:flex;gap:3px;margin-bottom:20px;background:var(--bg3);padding:3px;border-radius:8px} 179.auth-tab{flex:1;text-align:center;padding:7px;border-radius:6px;cursor:pointer;font-size:.78rem;color:var(--text2);font-weight:700;transition:all .15s} 180.auth-tab.active{background:var(--bg2);color:var(--teal)} 181.lang-toggle{display:flex;gap:3px;background:var(--bg3);padding:3px;border-radius:8px;border:1px solid var(--border)} 182.lang-toggle-btn{flex:1;text-align:center;padding:4px 8px;border-radius:6px;cursor:pointer;font-size:.62rem;letter-spacing:.05em;font-family:var(--mono);color:var(--text2);font-weight:700;background:transparent;border:none;transition:all .15s} 183.lang-toggle-btn.active{background:var(--bg2);color:var(--teal)} 184.lang-toggle-auth{margin:0 auto 14px;max-width:120px} 185.lang-toggle-sidebar{margin-bottom:8px} 186#quiz-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:210;display:none;align-items:center;justify-content:center} 187#quiz-modal-overlay.show{display:flex} 188.quiz-modal{width:600px} 189.quiz-topic-badge{display:inline-block;background:var(--bg3);color:var(--teal);border:1px solid var(--border);border-radius:20px;padding:3px 11px;font-size:.62rem;letter-spacing:1px;font-family:var(--mono);text-transform:uppercase;margin-bottom:14px} 190#quiz-lesson-content{font-size:.85rem;line-height:1.65;color:var(--text2);margin-bottom:16px} 191.quiz-unlock-note{display:none;background:var(--bg3);border:1px solid var(--border);border-left:2px solid var(--teal);border-radius:6px;padding:9px 12px;font-size:.72rem;color:var(--text2);margin-bottom:20px} 192.quiz-question{margin-bottom:22px} 193.quiz-question-text{font-size:.82rem;font-weight:700;margin-bottom:10px;line-height:1.4} 194.quiz-choices{display:flex;flex-direction:column;gap:7px} 195.quiz-choice{display:flex;align-items:center;gap:10px;background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:9px 12px;font-size:.78rem;cursor:pointer;transition:all .15s} 196.quiz-choice:hover{border-color:var(--teal)} 197.quiz-choice.selected{border-color:var(--teal);background:rgba(45,212,191,.08);color:var(--teal)} 198.quiz-choice-letter{font-family:var(--mono);font-weight:700;font-size:.68rem;color:var(--muted);width:16px;flex-shrink:0} 199.quiz-choice.selected .quiz-choice-letter{color:var(--teal)} 200.quiz-score{text-align:center;font-size:1.6rem;font-weight:800;margin-bottom:6px} 201.quiz-stats-row{display:flex;justify-content:center;gap:28px;margin-bottom:16px} 202.quiz-stats-row>div{text-align:center} 203.quiz-stat-num{display:block;font-size:1.05rem;font-weight:800;font-family:var(--mono)} 204.quiz-stat-label{display:block;font-size:.6rem;color:var(--muted);letter-spacing:.5px;text-transform:uppercase;margin-top:2px} 205.quiz-levelup{text-align:center;background:rgba(45,212,191,.1);border:1px solid var(--teal);border-radius:8px;padding:10px;font-size:.85rem;font-weight:700;color:var(--teal);margin-bottom:18px} 206.quiz-review{display:flex;flex-direction:column;gap:12px;margin-bottom:18px} 207.quiz-review-item{border-radius:8px;padding:10px 12px;border:1px solid var(--border);background:var(--bg3)} 208.quiz-review-item.correct{border-left:3px solid var(--green)} 209.quiz-review-item.incorrect{border-left:3px solid var(--red)} 210.quiz-review-q{font-size:.76rem;font-weight:700;margin-bottom:5px} 211.quiz-review-answer{font-size:.75rem;color:var(--text2);margin-bottom:4px} 212.quiz-review-explanation{font-size:.7rem;color:var(--muted);line-height:1.4} 213#auth-err{color:var(--red);font-size:.72rem;margin-top:8px;min-height:16px;font-family:var(--mono)} 214.chart-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:12px} 215.chart-thumb{background:var(--bg3);border:1px solid var(--border);border-radius:6px;overflow:hidden;position:relative;cursor:pointer;transition:border-color .15s} 216.chart-thumb:hover{border-color:var(--teal)} 217.chart-thumb img{width:100%;display:block} 218.chart-thumb .clabel{font-size:.62rem;color:var(--muted);padding:3px 6px;text-align:center;font-family:var(--mono)} 219.sig-bull{position:absolute;top:4px;left:4px;background:rgba(0,230,118,.85);color:#000;font-size:.58rem;font-weight:700;padding:2px 5px;border-radius:4px;font-family:var(--mono)} 220.sig-bear{position:absolute;top:4px;left:4px;background:rgba(255,68,68,.85);color:#fff;font-size:.58rem;font-weight:700;padding:2px 5px;border-radius:4px;font-family:var(--mono)} 221/* Guide accordion */ 222.guide-section{border:1px solid var(--border);border-radius:8px;margin-bottom:10px;overflow:hidden} 223.guide-header{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;cursor:pointer;background:var(--bg2);font-size:.82rem;font-weight:600;color:var(--text);user-select:none} 224.guide-header:hover{background:var(--bg3)} 225.guide-arrow{font-size:.7rem;color:var(--muted);transition:transform .2s} 226.guide-arrow.open{transform:rotate(90deg)} 227.guide-body{padding:16px;background:var(--bg);font-size:.78rem;color:var(--text2);line-height:1.7} 228.guide-steps{padding-left:20px;margin:0} 229.guide-steps li{margin-bottom:8px} 230.guide-note{background:#1e2a1e;border:1px solid #2a4a2a;border-radius:6px;padding:8px 12px;font-size:.75rem;color:#8bc88b} 231/* AI Chat */ 232.chat-chip{background:var(--bg2);border:1px solid var(--border);color:var(--text2);border-radius:20px;padding:3px 10px;font-size:.72rem;cursor:pointer;transition:border-color .15s,color .15s} 233.chat-chip:hover{border-color:var(--teal);color:var(--text)} 234.chat-msg{padding:8px 12px;border-radius:8px;font-size:.78rem;line-height:1.55;max-width:88%;word-break:break-word} 235.chat-msg.user{background:#1e3a5f;align-self:flex-end;color:#d1d4dc} 236.chat-msg.ai{background:var(--bg2);border:1px solid var(--border);align-self:flex-start;color:var(--text)} 237.chat-msg.err{background:#3a1a1a;border:1px solid #a33;align-self:flex-start;color:#f88} 238.template-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:16px} 239.tmpl-card{background:var(--bg3);border:2px solid var(--border);border-radius:10px;padding:16px;cursor:pointer;transition:all .15s;text-align:center} 240.tmpl-card:hover{border-color:var(--text2)} 241.tmpl-card.selected{border-color:var(--teal);background:rgba(226,194,117,.05)} 242.tmpl-card .tmpl-icon{font-size:1.8rem;margin-bottom:8px} 243.tmpl-card .tmpl-name{font-size:.8rem;font-weight:700;color:#e2eaf2;margin-bottom:4px} 244.tmpl-card .tmpl-desc{font-size:.68rem;color:var(--text2);line-height:1.4} 245.tmpl-thumb-overlay{position:absolute;inset:0;border-radius:inherit;background:linear-gradient(to top,rgba(0,0,0,.8) 0%,transparent 55%);display:flex;flex-direction:column;justify-content:flex-end;padding:8px} 246.tmpl-thumb-overlay .tmpl-name{font-size:.8rem;font-weight:700;color:#e2eaf2;margin:0} 247.tmpl-thumb-overlay .tmpl-desc{font-size:.62rem;color:rgba(255,255,255,.7);margin:0} 248.carousel-preview{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:14px} 249.slide-thumb{background:var(--bg3);border:1px solid var(--border);border-radius:6px;overflow:hidden;aspect-ratio:1} 250.slide-thumb img{width:100%;height:100%;object-fit:cover} 251.swatch{width:28px;height:28px;border-radius:50%;cursor:pointer;border:2px solid transparent;transition:border-color .15s} 252.swatch.selected{border-color:var(--accent)} 253.file-row{display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--bg3);border:1px solid var(--border);border-radius:7px;margin-bottom:6px;transition:border-color .15s} 254.file-row:hover{border-color:var(--text2)} 255.file-row .fname{flex:1;font-size:.78rem;color:var(--text);font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} 256.file-row .fsize{font-size:.65rem;color:var(--muted);white-space:nowrap} 257.hist-row{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;margin-bottom:6px} 258.hist-row .hplatform{font-size:.78rem;font-weight:700;color:var(--text);min-width:90px} 259.hist-row .hcaption{flex:1;font-size:.72rem;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} 260.hist-row .htime{font-size:.65rem;color:var(--muted);font-family:var(--mono);white-space:nowrap} 261.caption-box{background:var(--bg3);border:1px solid var(--border);border-radius:8px;paddi
261ng:14px;font-size:.78rem;line-height:1.75;white-space:pre-wrap;color:var(--text);min-height:100px;font-family:var(--mono)} 262.color-row{display:flex;align-items:center;gap:8px} 263.color-row input[type=text]{flex:1} 264/* ADMIN SPECIFIC */ 265.client-card{background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:16px 18px;margin-bottom:10px;transition:border-color .15s} 266.client-card:hover{border-color:var(--text2)} 267.client-card-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px} 268.client-name{font-size:.88rem;font-weight:700;color:#e2eaf2} 269.client-meta{font-size:.68rem;color:var(--text2);font-family:var(--mono)} 270.client-stats{display:flex;gap:12px;margin-top:8px} 271.client-stat{font-size:.68rem;color:var(--muted);font-family:var(--mono)} 272.client-stat span{color:var(--text2)} 273/* MODAL */ 274#modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:200;display:none;align-items:center;justify-content:center} 275#modal-overlay.show{display:flex} 276.modal{background:var(--bg2);border:1px solid var(--border);border-radius:14px;padding:28px;width:560px;max-height:85vh;overflow-y:auto} 277.modal-title{font-size:1rem;font-weight:800;color:#e2eaf2;margin-bottom:20px;display:flex;align-items:center;justify-content:space-between} 278.modal-close{cursor:pointer;color:var(--muted);font-size:1.1rem;transition:color .15s} 279.modal-close:hover{color:var(--red)} 280.modal-tabs{display:flex;gap:3px;margin-bottom:18px;background:var(--bg3);padding:3px;border-radius:8px} 281.modal-tab{flex:1;text-align:center;padding:7px;border-radius:6px;cursor:pointer;font-size:.75rem;color:var(--text2);font-weight:700;transition:all .15s} 282.modal-tab.active{background:var(--bg2);color:var(--teal)} 283/* API KEY DISPLAY */ 284.api-key-box{background:var(--bg3);border:1px solid var(--green);border-radius:8px;padding:14px;font-family:var(--mono);font-size:.75rem;color:var(--green);word-break:break-all;margin:10px 0} 285::-webkit-scrollbar{width:4px} 286::-webkit-scrollbar-track{background:transparent} 287::-webkit-scrollbar-thumb{background:var(--border);border-radius:4px} 288/* NEWS FEED */ 289.nav-badge{margin-left:auto;background:var(--red);color:#fff;border-radius:10px;font-size:.52rem;padding:1px 6px;font-family:var(--mono);font-weight:700;line-height:1.6} 290.nav-item-locked{opacity:.45} 291.nav-item-locked:hover{opacity:.7} 292.nav-lock{margin-left:auto;font-size:.68rem;opacity:.8;flex-shrink:0} 293@keyframes navShine{0%{background-position:-150% 0}100%{background-position:250% 0}} 294.nav-item-shine{background-image:linear-gradient(110deg,transparent 35%,rgba(45,212,191,.4) 50%,transparent 65%);background-size:250% 100%;animation:navShine 1.4s ease-in-out 2} 295@keyframes badgePulse{0%,100%{transform:scale(1);box-shadow:0 0 0 0 rgba(99,179,237,.7)}50%{transform:scale(1.15);box-shadow:0 0 0 6px rgba(99,179,237,0)}} 296.badge-pulse{animation:badgePulse 1.4s ease-in-out infinite} 297.badge-purple{background:rgba(180,120,255,.12);color:#c084fc} 298.badge-orange{background:rgba(255,160,60,.12);color:#fb923c} 299.news-filter-btn.active-filter{background:rgba(226,194,117,.12);color:var(--teal);border-color:rgba(226,194,117,.35)} 300.news-card{background:var(--bg2);border:1px solid var(--border);border-radius:10px;overflow:hidden;margin-bottom:14px;display:flex;gap:0;transition:border-color .15s} 301.news-card:hover{border-color:var(--muted)} 302.tv-chart-expanded{max-height:600px!important;cursor:zoom-out!important;object-fit:contain!important} 303.cluster-section{margin-bottom:24px} 304.cluster-header{display:flex;align-items:center;gap:10px;padding:8px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;cursor:pointer;user-select:none;margin-bottom:6px;transition:background .15s} 305.cluster-header:hover{background:var(--bg3)} 306.cluster-arrow{font-size:.7rem;color:var(--muted);transition:transform .2s;margin-left:auto} 307.cluster-arrow.open{transform:rotate(90deg)} 308.cluster-body{display:none} 309.cluster-body.open{display:block;padding-left:14px} 310.cluster-row{display:flex;align-items:center;gap:10px;padding:9px 12px;border:1px solid var(--border);border-radius:7px;margin-bottom:8px;
310cursor:pointer;background:var(--bg2);transition:background .15s} 311.cluster-row:hover{background:var(--bg3)} 312.cluster-row-title{flex:1;font-size:.87rem;font-weight:700;color:var(--teal);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} 313.cluster-row-date{font-size:.65rem;color:#8b9cb8;white-space:nowrap} 314.cluster-detail{display:none;margin-bottom:8px} 315.cluster-detail.open{display:block} 316.news-thumb{width:150px;min-width:150px;height:auto;max-height:220px;object-fit:contain;background:var(--bg3);display:block} 317.news-body{padding:14px 16px;flex:1;min-width:0} 318.news-title{font-weight:700;color:#e2eaf2;font-size:.88rem;margin-bottom:6px;line-height:1.35} 319.news-date{font-size:.6rem;color:#8b9cb8;font-family:var(--mono);margin-bottom:8px} 320.news-sum{font-size:.75rem;color:var(--text2);line-height:1.55;margin-bottom:10px} 321.news-caption{font-size:.72rem;color:var(--muted);font-family:var(--mono);background:var(--bg3);border:1px solid var(--border);border-radius:5px;padding:8px;margin-bottom:10px;max-height:56px;overflow:hidden;line-height:1.5} 322.news-actions{display:flex;gap:8px;flex-wrap:wrap} 323/* ADMIN NEWS LIST */ 324.news-admin-row{display:flex;align-items:center;gap:12px;padding:9px 12px;background:var(--bg3);border-radius:7px;margin-bottom:6px;font-size:.75rem} 325.news-admin-title{flex:1;color:#e2eaf2;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} 326.news-admin-date{color:var(--muted);font-family:var(--mono);font-size:.62rem;white-space:nowrap} 327 328/* ââ MOBILE RESPONSIVE âââââââââââââââââââââââââââââââââââââââââââââââââââ */ 329#mob-bar{display:none} 330@media(max-width:768px){ 331 html,body{overflow:auto} 332 /* Mobile top bar */ 333 #mob-bar{display:flex;align-items:center;justify-content:space-between; 334 position:sticky;top:0;z-index:200;background:var(--bg2); 335 border-bottom:1px solid var(--border);padding:10px 16px;width:100%} 336 #mob-bar .mob-logo{font-family:var(--mono);font-size:.8rem;font-weight:700; 337 color:var(--teal);letter-spacing:3px} 338 #mob-hamburger{background:none;border:1px solid var(--border);border-radius:6px; 339 color:var(--text);font-size:1.1rem;padding:4px 9px;cursor:pointer;line-height:1} 340 /* Body becomes column on mobile */ 341 body{flex-direction:column} 342 /* Sidebar becomes overlay */ 343 #sidebar{position:fixed;top:0;left:0;bottom:0;z-index:300;width:220px; 344 transform:translateX(-100%);transition:transform .25s ease;box-shadow:4px 0 24px rgba(0,0,0,.5)} 345 #sidebar.mob-open{transform:translateX(0)} 346 /* Sidebar overlay backdrop */ 347 #mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:299} 348 #mob-backdrop.visible{display:block} 349 /* Main fills full width */ 350 #main{padding:16px 14px;width:100%;overflow-y:auto} 351 /* Collapse multi-col grids */ 352 .g2,.g3,.g4,.scanner-grid,.ov-bento,.ov-stats{grid-template-columns:1fr} 353 .ov-bento-wide{grid-column:1} 354 .chart-grid{grid-template-columns:1fr 1fr} 355 .template-grid{grid-template-columns:1fr 1fr} 356 .carousel-preview{grid-template-columns:1fr} 357 /* Auth box full width */ 358 #auth .auth-box{width:92vw;padding:32px 22px} 359 .auth-logo{font-size:1.35rem} 360 /* Modals full width */ 361 .modal{width:94vw;padding:20px 16px} 362 /* Tables scroll horizontally */ 363 .tbl-wrap,table{display:block;overflow-x:auto;-webkit-overflow-scrolling:touch} 364 /* Reduce page title size */ 365 .page-title{font-size:1rem} 366 /* Two-col carousel build layout stacks */ 367 .carousel-build-layout{grid-template-columns:1fr!important} 368 /* News cards stack vertically â 150px fixed thumb overflows on 375px */ 369 .news-card{flex-direction:column} 370 .news-thumb{width:100%;min-width:0;max-height:160px;object-fit:cover} 371 /* TradingView charts â 700px height is excessive on phone */ 372 #heatmap-pane-stock{height:400px!important} 373 #tv-chart-wrap{height:380px!important} 374 /* Design Lab gallery modal â 40px auto margin clips on narrow screens */ 375 #dl-gallery-modal > div{margin:10px!important;padding:16px!important} 376 /* Macro panes â cap height so one pane doesn't dominate the whole screen */ 377 .macro-pane{max-height:480px;overflow-y:auto} 378 /* Buttons â minimum 38px tap target */ 379 .btn{min-height:38px} 380} 381@media(max-width:480px){ 382 .g2{grid-template-columns:1fr} 383 .chart-grid{grid-template-columns:1fr} 384 .template-grid{grid-template-columns:1fr} 385 #main{padding:12px 10px} 386 /* Form groups â allow narrower min-width on small phones so they wrap properly */ 387 .form-group[style*="min-width"]{min-width:140px!important} 388} 389/* ââ Macro Dashboard ââ */ 390.macro-tabs{display:flex;gap:6px;margin-bottom:18px;flex-wrap:wrap} 391.macro-tab{background:var(--bg2);border:1px solid var(--border);color:var(--text2);font-family:var(--mono);font-size:.72rem;padding:5px 14px;border-radius:6px;cursor:pointer;letter-spacing:.5px} 392.macro-tab.active{background:var(--teal);border-color:var(--teal);color:#000;font-weight:700} 393.macro-pane{display:none} 394.macro-pane.active{display:block} 395.macro-card{background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:14px 16px} 396.macro-card .mc-label{font-size:.6rem;color:var(--text2);font-family:var(--mono);letter-spacing:2px;margin-bottom:6px} 397.macro-card .mc-val{font-size:1.4rem;font-weight:700;font-family:var(--mono);color:#e2eaf2} 398.macro-card .mc-sub{font-size:.68rem;color:var(--muted);margin-top:3px} 399.mc-badge{display:inline-block;border-radius:4px;font-size:.6rem;font-family:var(--mono);font-weight:700;letter-spacing:.5px;padding:2px 8px;margin-top:6px} 400.mc-badge.ok{background:rgba(0,180,100,.15);color:#00b464;border:1px solid #00b464} 401.mc-badge.neutral{background:rgba(150,150,150,.1);color:var(--text2);border:1px solid var(--border)} 402.mc-badge.alert{background:rgba(220,50,50,.15);color:#e05050;border:1px solid #e05050} 403.macro-scorecard-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:12px} 404#mc-tooltip,#hm-tooltip{position:fixed;z-index:9999;background:#1C2128;border:1px solid #30363D;border-radius:6px;padding:10px 14px;font-size:.72rem;color:#E8F0F7;pointer-events:none;min-width:175px;box-shadow:0 4px 16px rgba(0,0,0,.5)} 405#hm-tooltip .hmt-sector{font-size:.55rem;letter-spacing:2px;color:#8B949E;margin-bottom:7px;font-family:var(--mono)} 406#hm-tooltip .hmt-label{font-size:.58rem;color:#8B949E;margin:6px 0 3px;font-family:var(--mono);letter-spacing:1px} 407#hm-tooltip .hmt-row{display:flex;justify-content:space-between;gap:14px;padding:2px 0} 408#hm-tooltip .hmt-ticker{font-weight:600} 409#hm-tooltip .hmt-up{color:#4caf82} 410#hm-tooltip .hmt-dn{color:#ef5350} 411#mc-tooltip .mct-title{font-size:.55rem;letter-spacing:2px;color:#8B949E;margin-bottom:7px;font-family:var(--mono)} 412#mc-tooltip .mct-row{display:flex;justify-content:space-between;gap:16px;padding:3px 0;border-bottom:1px solid #21262D} 413#mc-tooltip .mct-row:last-child{border-bottom:none} 414#mc-tooltip .mct-date{color:#8B949E} 415#mc-tooltip .mct-val{font-weight:600;color:#E8F0F7}
416@media(max-width:900px){.macro-scorecard-grid{grid-template-columns:repeat(2,1fr)}} 417@media(max-width:480px){.macro-scorecard-grid{grid-template-columns:1fr}} 418</style>
419<script src="https://cdn.jsdelivr.net/npm/[email protected]/i18next.min.js"></script>
vendor: 1 bytes, line 419
419
420<script src="https://unpkg.com/feather-icons/dist/feather.min.js"></script>
vendor: 1 bytes, line 420
420
421<script src="https://cdn.lordicon.com/lordicon.js"></script>
vendor: 1 bytes, line 421
421
422<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"></script>
vendor: 1 bytes, line 422
422
423<script src="https://unpkg.com/xlsx/dist/xlsx.full.min.js"></script>
423 424</head> 425<body> 426 427<!-- MOBILE TOP BAR --> 428<div id="mob-bar"> 429 <span class="mob-logo">FACTOZ</span> 430 <button id="mob-hamburger" onclick="toggleSidebar()">â°</button> 431</div> 432<div id="mob-backdrop" onclick="toggleSidebar()"></div> 433 434<!-- AUTH --> 435<div id="auth"> 436 <div class="auth-box"> 437 <div class="lang-toggle lang-toggle-auth"> 438 <button type="button" class="lang-toggle-btn" data-lang="es" onclick="setLocale('es')">ES</button> 439 <button type="button" class="lang-toggle-btn" data-lang="en" onclick="setLocale('en')">EN</button> 440 </div> 441 <div class="auth-logo">FACTOZ</div> 442 <div class="auth-sub" data-i18n="auth.sub">CENTRO DE COMANDO</div> 443 <div class="auth-tagline" data-i18n="auth.tagline">Aprende, avanza y supérate con Factoz hasta convertirte en experto</div> 444 <div class="auth-pills"> 445 <span class="auth-pill" data-i18n="auth.pill_scanner">â¡ Scanner</span> 446 <span class="auth-pill" data-i18n="auth.pill_learn">ð Aprende gratis</span> 447 <span class="auth-pill" data-i18n="auth.pill_levelup">â Sube de nivel</span> 448 </div> 449 <a href="https://chromewebstore.google.com/detail/factoz-intelligence-widget/jbjfefeinkalicgplonblljjpmfmcbpc" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:6px;margin:10px auto 4px;text-decoration:none;background:#1a2a1a;border:1px solid #4caf50;border-radius:20px;padding:5px 14px;font-size:.7rem;color:#4caf50;font-weight:600;letter-spacing:.02em"> 450 <svg width="13" height="13" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="12" r="10" stroke="#4caf50" stroke-width="2"/><circle cx="12" cy="12" r="4" fill="#4caf50"/><path d="M12 8h8.5M5.5 18.5l4-7M5.5 5.5l4 7" stroke="#4caf50" stroke-width="2"/></svg> 451 <span data-i18n="auth.install_extension">Instalar gratis en Chrome</span> 452 </a> 453 <div class="auth-tabs"> 454 <div class="auth-tab active" onclick="authTab('quick')" data-tab="quick" data-i18n="auth.tab_quick">Gratis</div> 455 <div class="auth-tab" onclick="authTab('login')" data-tab="login" data-i18n="auth.tab_login_short">Entrar</div> 456 <div class="auth-tab" onclick="authTab('register')" data-tab="register" data-i18n="auth.tab_register_short">Registro</div> 457 </div> 458 <div id="quick-form"> 459 <p style="text-align:center;font-size:.72rem;color:var(--text2);margin-bottom:16px" data-i18n="auth.quick_intro">Entra gratis y empieza a aprender â sin contraseña, sube de nivel a tu ritmo.</p> 460 <div class="form-group"><label class="form-label" data-i18n="auth.label_email">CORREO</label><input type="email" id="q-email" placeholder="[email protected]" data-i18n-placeholder="auth.placeholder_email" onkeydown="if(event.key==='Enter')doQuickEntry()"></div> 461 <button class="btn btn-primary btn-full" onclick="doQuickEntry()" style="font-size:1rem;padding:13px;letter-spacing:1px" data-i18n="auth.btn_quick_entry">Empezar gratis â</button> 462 <p style="text-align:center;margin-top:12px;font-size:.68rem;color:var(--muted)" data-i18n="auth.quick_no_password">Sin contraseña. Podrás agregar una después si quieres.</p> 463 </div> 464 <div id="login-form" style="display:none"> 465 <div class="form-group"><label class="form-label" data-i18n="auth.label_email">CORREO</label><input type="email" id="l-email" placeholder="[email protected]" data-i18n-placeholder="auth.placeholder_email"></div> 466 <div class="form-group"><label class="form-label" data-i18n="auth.label_password">CONTRASEÃA</label><input type="password" id="l-pass" placeholder="â¢â¢â¢â¢â¢â¢â¢â¢" onkeydown="if(event.key==='Enter')doLogin()"></div> 467 <button class="btn btn-primary btn-full" onclick="doLogin()" style="font-size:1rem;padding:13px;letter-spacing:1px" data-i18n="auth.btn_signin">Iniciar sesión â</button> 468 <p style="text-align:center;margin-top:12px;font-size:.72rem"><a href="/reset-password" style="color:var(--teal);text-decoration:none" data-i18n="auth.forgot_password">¿Olvidaste tu contraseña?</a></p> 469 <p style="text-align:center;margin-top:8px;font-size:.72rem;color:var(--text2)"><span data-i18n="auth.no_account">¿No tienes cuenta?</span> <a onclick="authTab('register')" style="color:var(--teal);text-decoration:none;cursor:pointer" data-i18n="auth.register_free">RegÃstrate gratis â</a></p> 470 </div> 471 <div id="register-form" style="display:none"> 472 <div class="form-group"><label class="form-label" data-i18n="auth.label_name">NOMBRE</label><input type="text" id="r-name" placeholder="Tu nombre" data-i18n-placeholder="auth.placeholder_name"></div> 473 <div class="form-group"><label class="form-label" data-i18n="auth.label_email">CORREO</label><input type="email" id="r-email" placeholder="[email protected]" data-i18n-placeholder="auth.placeholder_email"></div> 474 <div class="form-group"><label class="form-label" data-i18n="auth.label_password">CONTRASEÃA</label><input type="password" id="r-pass" placeholder="â¢â¢â¢â¢â¢â¢â¢â¢"></div> 475 <button class="btn btn-primary btn-full" onclick="doRegister()" data-i18n="auth.btn_create_account">Crear cuenta â</button> 476 </div> 477 <div id="auth-err"></div> 478 <p style="text-align:center;margin-top:18px;font-size:.7rem;color:var(--muted)">
479 <span data-i18n="auth.not_user_yet">¿Aún no eres usuario?</span> <a href="https://factozcentral.com/home" style="color:var(--teal);text-decoration:none" data-i18n="auth.learn_more">Conoce más en factozcentral.com â</a> 480 </p> 481 </div> 482</div> 483 484<!-- DAILY QUIZ MODAL --> 485<div id="quiz-modal-overlay"> 486 <div class="modal quiz-modal"> 487 <div class="modal-title"> 488 <span id="quiz-modal-title">Daily Quiz</span> 489 <span class="modal-close" onclick="closeQuizModal()">â</span> 490 </div> 491 <div id="quiz-lesson-view"> 492 <div class="quiz-topic-badge" id="quiz-topic-badge"></div> 493 <div id="quiz-lesson-content"></div> 494 <div class="quiz-unlock-note" id="quiz-unlocks-hint"></div> 495 <button class="btn btn-primary btn-full" onclick="_quizStartQuestions()" data-i18n="quiz.start_btn">Empezar quiz â</button> 496 </div> 497 <div id="quiz-questions-view" style="display:none"> 498 <div id="quiz-questions-list"></div> 499 <button class="btn btn-primary btn-full" onclick="submitQuizAnswers()" data-i18n="quiz.submit_btn">Enviar respuestas</button> 500 </div> 501 <div id="quiz-results-view" style="display:none"> 502 <div id="quiz-results-content"></div> 503 <button class="btn btn-primary btn-full" onclick="closeQuizModal()" data-i18n="quiz.done_btn">Listo</button> 504 </div> 505 </div> 506</div> 507 508<!-- CLIENT MODAL --> 509<div id="modal-overlay"> 510 <div class="modal"> 511 <div class="modal-title"> 512 <span id="modal-title-text">Enterprise Client</span> 513 <span class="modal-close" onclick="closeModal()">â</span> 514 </div> 515 <div class="modal-tabs"> 516 <div class="modal-tab active" onclick="modalTab('info')">Info</div> 517 <div class="modal-tab" onclick="modalTab('keys')">API Keys</div> 518 <div class="modal-tab" onclick="modalTab('config')">Config</div> 519 </div> 520 <!-- INFO TAB --> 521 <div id="modal-info"> 522 <div class="form-group"><label class="form-label">CLIENT NAME</label><input type="text" id="m-name" placeholder="Acme Trading Desk"></div> 523 <div class="form-group"><label class="form-label">CONTACT EMAIL</label><input type="email" id="m-email" placeholder="[email protected]"></div> 524 <div class="form-group"><label class="form-label">TIER</label> 525 <select id="m-tier"> 526 <option value="enterprise_api">Enterprise API</option> 527 </select> 528 </div> 529 <div class="g2"> 530 <div class="form-group"><label class="form-label">MONTHLY SCAN LIMIT</label><input type="number" id="m-scan-limit" value="500"></div> 531 <div class="form-group"><label class="form-label">MONTHLY POST LIMIT</label><input type="number" id="m-post-limit" value="200"></div> 532 </div> 533 </div> 534 <!-- KEYS TAB --> 535 <div id="modal-keys" style="display:none"> 536 <div class="form-group"><label class="form-label">CHART-IMG API KEY</label><input type="password" id="m-chartimg" placeholder="6skJ8Lky..."></div> 537 <hr> 538 <div class="form-group"><label class="form-label">X Consumer Key</label><input type="password" id="m-x-api"></div> 539 <div class="form-group"><label class="form-label">X Consumer Secret</label><input type="password" id="m-x-secret"></div> 540 <div class="form-group"><label class="form-label">X Access Token</label><input type="password" id="m-x-token"></div> 541 <div class="form-group"><label class="form-label">X Access Token Secret</label><input type="password" id="m-x-tsecret"></div> 542 <hr> 543 <div class="form-group"><label class="form-label">INSTAGRAM WEBHOOK (Make.com)</label><input type="text" id="m-make-ig" placeholder="https://hook.us2.make.com/..."></div> 544 <div class="form-group"><label class="form-label">TIKTOK WEBHOOK</label><input type="text" id="m-make-tt" placeholder="https://hook.us2.make.com/..."></div> 545 </div> 546 <!-- CONFIG TAB --> 547 <div id="modal-config" style="display:none"> 548 <div class="form-group"><label class="form-label">WATCHLIST (comma separated)</label><textarea id="m-watchlist" rows="3" placeholder="AAPL, NVDA, TSLA, MSFT, BTC-USD"></textarea></div> 549 <div class="form-group"><label class="form-label">CAROUSEL TEMPLATE</label> 550 <select id="m-template"> 551 <option value="factoz">Factoz (Default)</option> 552 <option value="terminal">Terminal</option> 553 <option value="editorial">Editorial</option> 554 </select> 555 </div> 556 <div class="form-group"><label class="form-label">ALLOWED PLATFORMS</label> 557 <div style="display:flex;gap:16px;margin-top:4px"> 558 <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:.78rem;font-weight:600"><input type="checkbox" id="m-plat-x" checked style="width:auto"> ð¦ X</label> 559 <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:.78rem;font-weight:600"><input type="checkbox" id="m-plat-ig" checked style="width:auto"> ð¸ Instagram</label> 560 <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:.78rem;font-weight:600"><input type="checkbox" id="m-plat-tt" style="width:auto"> ðµ TikTok</label> 561 </div> 562 </div> 563 <div class="form-group"><label class="form-label">CUSTOM HASHTAGS</label><input type="text" id="m-hashtags" placeholder="#Brand #Signals #Trading"></div> 564 <div class="form-group"><label class="form-label">CONTENT TONE</label> 565 <select id="m-tone"> 566 <option value="professional">Professional</option> 567 <option value="aggressive">Aggressive / FOMO</option> 568 <option value="educational">Educational</option> 569 </select> 570 </div> 571 <label style="display:flex;align-items:center;gap:8px;
571cursor:pointer;font-size:.78rem;font-weight:600;margin-bottom:12px"> 572 <input type="checkbox" id="m-autopost" style="width:auto"> Auto-post daily (Tier 1) 573 </label> 574 </div> 575 <hr> 576 <div style="display:flex;gap:10px;justify-content:flex-end"> 577 <button class="btn btn-ghost" onclick="closeModal()">Cancel</button> 578 <button class="btn btn-yellow" id="modal-save-btn" onclick="saveClient()">ð¾ Save Client</button> 579 </div> 580 <!-- API KEY RESULT --> 581 <div id="modal-key-result" style="display:none"> 582 <div class="card" style="border-color:var(--green);margin-top:14px"> 583 <h4>â ï¸ SAVE THIS API KEY â SHOWN ONCE</h4> 584 <div class="api-key-box" id="new-api-key"></div> 585 <button class="btn btn-ghost" onclick="copyKey()" style="margin-top:8px;font-size:.72rem">ð Copy Key</button> 586 </div> 587 </div> 588 </div> 589</div> 590 591<!-- OUTREACH COMPOSE MODAL --> 592<div id="oc-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:500;align-items:center;justify-content:center"> 593 <div style="background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:24px;max-width:560px;width:95%;position:relative;max-height:90vh;overflow-y:auto"> 594 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px"> 595 <div id="oc-modal-title" style="font-family:var(--mono);font-weight:700;font-size:.82rem;letter-spacing:1px"></div> 596 <span onclick="closeOutreachModal()" style="cursor:pointer;color:var(--muted);font-size:1.1rem">â</span> 597 </div> 598 <div class="form-group" style="margin-bottom:8px"> 599 <label class="form-label">TEMPLATE VARIANT</label> 600 <select id="oc-modal-variant"></select> 601 </div> 602 <div id="oc-modal-subj-row" class="form-group" style="margin-bottom:8px"> 603 <label class="form-label">SUBJECT</label> 604 <input type="text" id="oc-modal-subj" style="width:100%"> 605 </div> 606 <div class="form-group" style="margin-bottom:12px"> 607 <label class="form-label">MESSAGE <span style="color:var(--muted);font-weight:400">(edit before sending â fill in {{specific_post}})</span></label> 608 <textarea id="oc-modal-body" rows="12" style="width:100%;background:var(--bg3);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:10px;font-size:.78rem;line-height:1.6;resize:vertical;font-family:inherit"></textarea> 609 </div> 610 <div id="oc-modal-affiliate-row" style="display:none;margin-bottom:10px;padding:8px 10px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;font-size:.73rem"> 611 <span style="color:var(--muted)">Affiliate link:</span> 612 <a id="oc-modal-affiliate-link" href="#" target="_blank" style="color:var(--teal);word-break:break-all;margin-left:6px"></a> 613 <button onclick="navigator.clipboard.writeText($('oc-modal-affiliate-link').href)" class="btn btn-ghost" style="font-size:.65rem;padding:1px 7px;margin-left:8px">Copy</button> 614 </div> 615 <div style="display:flex;gap:10px;align-items:center"> 616 <button id="oc-modal-send" class="btn btn-primary" style="font-size:.78rem">Send Email</button> 617 <button id="oc-modal-copy" class="btn btn-yellow" style="font-size:.78rem">ð Copy to Clipboard</button> 618 <button class="btn btn-ghost" style="font-size:.78rem" onclick="closeOutreachModal()">Cancel</button> 619 </div> 620 <div id="oc-modal-result" style="margin-top:8px;font-size:.73rem"></div> 621 </div> 622</div> 623 624<!-- TEMPLATE SLOT PAYMENT MODAL --> 625<div id="slot-pay-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:1000;align-items:center;justify-content:center"> 626 <div style="background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:28px 24px;max-width:420px;width:90%;position:relative"> 627 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px"> 628 <div style="font-family:var(--mono);font-weight:700;font-size:.85rem;letter-spacing:1px">BUY TEMPLATE SLOT</div> 629 <span onclick="closeSlotModal()" style="cursor:pointer;color:var(--muted);font-size:1.1rem">â</span> 630 </div> 631 <div id="slot-pay-step-initiate"> 632 <div style="font-size:.75rem;color:var(--muted);margin-bottom:14px">You are purchasing access to a private Templated.io render slot. Payment is processed via NOWPayments (crypto).</div> 633 <div style="margin-bottom:14px"> 634 <div style="font-size:.65rem;color:var(--muted);letter-spacing:1px;margin-bottom:4px">SLOT</div> 635 <div id="slot-pay-label" style="font-size:.85rem;font-weight:700"></div> 636 </div> 637 <div style="margin-bottom:18px"> 638 <div style="font-size:.65rem;color:var(--muted);letter-spacing:1px;margin-bottom:4px">PRICE</div> 639 <div id="slot-pay-price" style="font-size:1.1rem;font-weight:700;color:var(--green)"></div> 640 </div> 641 <div style="margin-bottom:18px"> 642 <div style="font-size:.65rem;color:var(--muted);letter-spacing:1px;margin-bottom:6px">PAY WITH</div> 643 <div style="display:flex;gap:8px">
644 <button id="slot-curr-usdt" onclick="selectSlotCurrency('USDT')" class="btn btn-ghost" style="flex:1;font-size:.72rem">USDT (TRC-20)</button> 645 <button id="slot-curr-usdc" onclick="selectSlotCurrency('USDC')" class="btn btn-ghost" style="flex:1;font-size:.72rem">USDC (ERC-20)</button> 646 </div> 647 </div> 648 <button class="btn btn-primary" onclick="createSlotPayment()" id="slot-pay-btn" style="width:100%">Create Payment Invoice</button> 649 <div id="slot-pay-err" style="color:var(--red);font-size:.72rem;margin-top:8px;display:none"></div> 650 </div> 651 <div id="slot-pay-step-address" style="display:none"> 652 <div style="font-size:.72rem;color:var(--muted);margin-bottom:14px">Send the exact amount below to the wallet address. Payment is confirmed on-chain automatically.</div> 653 <div style="margin-bottom:12px"> 654 <div style="font-size:.65rem;color:var(--muted);letter-spacing:1px;margin-bottom:4px">SEND EXACTLY</div> 655 <div id="slot-pay-amount" style="font-family:var(--mono);font-size:1rem;font-weight:700;color:var(--green)"></div> 656 </div> 657 <div style="margin-bottom:16px"> 658 <div style="font-size:.65rem;color:var(--muted);letter-spacing:1px;margin-bottom:4px">TO ADDRESS</div> 659 <div style="display:flex;align-items:center;gap:8px"> 660 <div id="slot-pay-addr" style="font-family:var(--mono);font-size:.7rem;word-break:break-all;flex:1;background:var(--bg3);padding:8px 10px;border-radius:5px;border:1px solid var(--border)"></div> 661 <button class="btn btn-ghost" onclick="copySlotAddress()" style="font-size:.65rem;padding:6px 9px;white-space:nowrap">Copy</button> 662 </div> 663 </div> 664 <div style="font-size:.65rem;color:var(--muted);margin-bottom:14px">After sending, click below to check confirmation status.</div> 665 <button class="btn btn-primary" onclick="checkSlotPayment()" id="slot-check-btn" style="width:100%">Check Payment Status</button> 666 <div id="slot-check-result" style="font-size:.72rem;margin-top:8px"></div> 667 </div> 668 <div id="slot-pay-step-done" style="display:none;text-align:center;padding:10px 0"> 669 <div style="font-size:2rem;margin-bottom:10px">â </div> 670 <div style="font-weight:700;margin-bottom:6px">Template Slot Activated!</div> 671 <div style="font-size:.75rem;color:var(--muted);margin-bottom:18px">Your custom render slot is now available in the template grid.</div> 672 <button class="btn btn-primary" onclick="closeSlotModal()" style="width:100%">Done</button> 673 </div> 674 </div> 675</div> 676 677<!-- SIDEBAR --> 678<div id="sidebar"> 679 <div class="logo"> 680 <div class="logo-title">FACTO<span style="display:inline-block;transform:scaleX(-1)">Z</span></div> 681 <div class="logo-sub">COMMAND CENTER</div> 682 </div> 683 <nav id="nav-menu"></nav> 684 <div class="sidebar-footer"> 685 <div id="clock"></div> 686 <div id="user-tag"></div> 687 <div id="tier-tag"></div> 688 <div id="xp-widget"> 689 <div class="xp-row"> 690 <span id="xp-level-label" style="color:var(--teal);font-weight:700;text-transform:uppercase;letter-spacing:.5px;font-size:.6rem"></span> 691 <span id="xp-quiz-status" style="color:var(--muted);font-size:.6rem"></span> 692 </div> 693 <div id="xp-progress-track"><div id="xp-progress-bar"></div></div> 694 <div class="xp-row"> 695 <span id="xp-total-label" style="color:var(--text2);font-size:.58rem"></span> 696 <span id="xp-streak-label" style="color:var(--muted);font-size:.58rem"></span> 697 </div> 698 </div> 699 <div id="manage-sub-btn" onclick="manageSubscription()" style="display:none;font-size:.58rem;font-family:var(--mono);color:var(--muted);cursor:pointer;margin-bottom:4px;letter-spacing:.05em" onmouseover="this.style.color='var(--teal)'" onmouseout="this.style.color='var(--muted)'" data-i18n="sidebar.manage_subscription">â gestionar suscripción</div> 700 <div id="sidebar-platforms" style="display:flex;flex-wrap:wrap;gap:5px;margin-bottom:8px"></div> 701 <div class="lang-toggle lang-toggle-sidebar"> 702 <button type="button" class="lang-toggle-btn" data-lang="es" onclick="set
702Locale('es')">ES</button> 703 <button type="button" class="lang-toggle-btn" data-lang="en" onclick="setLocale('en')">EN</button> 704 </div> 705 <div class="logout-btn" onclick="logout()" data-i18n="sidebar.logout">â cerrar sesión</div> 706 </div> 707</div> 708 709<!-- MAIN --> 710<div id="main"> 711 712<!-- TRIAL BANNER --> 713<div id="trial-banner" style="display:none;align-items:center;justify-content:center;gap:8px;background:rgba(0,200,83,.1);border-bottom:1px solid rgba(0,200,83,.25);color:var(--green);font-size:.75rem;font-family:var(--mono);padding:7px 16px;text-align:center"></div> 714 715<!-- FREE / LEVELING NUDGE BANNER --> 716<div id="free-upgrade-bar" style="display:none;align-items:center;justify-content:center;gap:8px;background:rgba(226,194,117,.08);border-bottom:1px solid rgba(226,194,117,.2);color:var(--text2);font-size:.75rem;font-family:var(--mono);padding:7px 16px;text-align:center"> 717 <span data-i18n="banner.level_up">ð Sube de nivel aprendiendo con nuestras lecciones y quizzes diarios.</span> <a href="#" onclick="openQuizModal();return false;" style="color:var(--teal,#00c8a0);text-decoration:underline;font-weight:700" data-i18n="banner.level_up_cta">Hacer el quiz de hoy â</a> 718</div> 719 720<!-- TRIAL EXPIRED OVERLAY --> 721<div id="trial-expired-overlay" style="display:none;position:fixed;inset:0;background:#0d0d0d;z-index:9999;align-items:center;justify-content:center;flex-direction:column;text-align:center;padding:32px;overflow-y:auto"> 722 <div style="font-family:monospace;font-size:1.1rem;color:#00e5b4;letter-spacing:2px;margin-bottom:20px">FACTOZ</div> 723 <h2 style="font-size:1.4rem;font-weight:700;margin-bottom:8px;color:#e2eaf2">Subscribe to access Pro</h2> 724 <p style="color:#8b949e;max-width:480px;line-height:1.7;margin-bottom:24px;font-size:.9rem"> 725 Get full access to scanner, AI briefs, options flow, content tools, macro data, and more â $49/mo. 726 </p> 727 728 <!-- Plan comparison --> 729 <div style="display:flex;gap:12px;flex-wrap:wrap;justify-content:center;margin-bottom:20px;max-width:780px;width:100%"> 730 731 <!-- Creator --> 732 <div style="background:#111;border:1px solid #1a2a1a;border-radius:12px;padding:20px 22px;min-width:220px;flex:1;text-align:left"> 733 <div style="font-family:monospace;font-size:.58rem;letter-spacing:2px;color:#00e5b4;margin-bottom:6px">CREATOR</div> 734 <div style="font-size:1.2rem;font-weight:800;color:#e2eaf2;margin-bottom:4px">$25<span style="font-size:.75rem;font-weight:400;color:#555">/mo</span></div> 735 <ul style="list-style:none;padding:0;margin:12px 0 16px;font-size:.72rem;color:#8b949e;line-height:2"> 736 <li>â Scanner & signals</li> 737 <li>â News feed</li> 738 <li>â AI chat (5/day)</li> 739 <li>â Analyst targets</li> 740 </ul> 741 <div id="paypal-plans-creator" class="paypal-hosted-btn-container" style="margin-bottom:6px"></div> 742 <button onclick="cryptoCheckout('creator')" style="display:block;width:100%;background:transparent;border:1px solid #00e5b4;color:#00e5b4;font-weight:700;padding:9px;border-radius:8px;cursor:pointer;font-family:monospace;font-size:.75rem;text-align:center">Pay with Crypto</button> 743 </div> 744 745 <!-- Pro --> 746 <div style="background:#111;border:1px solid #2a2a4a;border-radius:12px;padding:20px 22px;min-width:220px;flex:1;text-align:left;position:relative"> 747 <div style="position:absolute;top:-10px;left:50%;transform:translateX(-50%);background:#6366f1;color:#fff;font-size:.58rem;font-weight:700;letter-spacing:1.5px;padding:3px 10px;border-radius:20px;font-family:monospace">POPULAR</div> 748 <div style="font-family:monospace;font-size:.58rem;letter-spacing:2px;color:#818cf8;margin-bottom:6px">PRO</div> 749 <div style="font-size:1.2rem;font-weight:800;color:#e2eaf2;margin-bottom:4px">$49<span style="font-size:.75rem;font-weight:400;color:#555">/mo</span></div> 750 <ul style="list-style:none;padding:0;margin:12px 0 16px;font-size:.72rem;color:#8b949e;line-height:2"> 751 <li>â Everything in Creator</li> 752 <li>â Macro data & heatmaps</li> 753 <li>â Portfolio risk / VAR</li> 754 <li>â Polymarket insights</li> 755 <li>â Pine Script library</li> 756 <li style="color:#e2c275;font-weight:600">⦠Factoz Intelligence Widget</li> 757 <li style="color:#a0aab8;font-size:.67rem;padding-left:12px">AI ticker analysis in your browser</li> 758 </ul> 759 <div id="paypal-plans-pro" class="paypal-hosted-btn-container" style="margin-bottom:6px"></div> 760 <button onclick="cryptoCheckout('pro')" style="display:block;width:100%;background:transparent;border:1px solid #6366f1;color:#a5b4fc;font-weight:700;padding:9px;border-radius:8px;
760cursor:pointer;font-family:monospace;font-size:.75rem;text-align:center">Pay with Crypto</button> 761 </div> 762 763 <!-- Stellar --> 764 <div class="stellar-paddle-btn" style="display:none;background:#111;border:1px solid #3a3010;border-radius:12px;padding:20px 22px;min-width:220px;flex:1;text-align:left"> 765 <div style="font-family:monospace;font-size:.58rem;letter-spacing:2px;color:#e2c275;margin-bottom:6px">STELLAR</div> 766 <div style="font-size:1.2rem;font-weight:800;color:#e2eaf2;margin-bottom:4px">$99<span style="font-size:.75rem;font-weight:400;color:#555">/mo</span></div> 767 <ul style="list-style:none;padding:0;margin:12px 0 16px;font-size:.72rem;color:#8b949e;line-height:2"> 768 <li>â Everything in Pro</li> 769 <li>â Full financial statements</li> 770 <li>â Priority support</li> 771 <li style="color:#e2c275;font-weight:600">⦠Factoz Intelligence Widget</li> 772 <li style="color:#a0aab8;font-size:.67rem;padding-left:12px">AI ticker analysis in your browser</li> 773 </ul> 774 <button onclick="paddleCheckout('stellar')" style="display:block;width:100%;background:#e2c275;color:#000;font-weight:700;padding:10px;border-radius:8px;border:none;cursor:pointer;font-family:monospace;font-size:.8rem;text-align:center">Subscribe</button> 775 </div> 776 777 </div> 778 779 <!-- Extension callout --> 780 <div style="max-width:540px;background:rgba(226,194,117,.06);border:1px solid rgba(226,194,117,.2);border-radius:10px;padding:14px 18px;margin-bottom:20px;text-align:left;display:flex;gap:14px;align-items:flex-start"> 781 <div style="font-size:1.4rem;flex-shrink:0">ð§©</div> 782 <div> 783 <div style="font-size:.78rem;font-weight:700;color:#e2c275;margin-bottom:4px">Factoz Intelligence Widget â Chrome Extension</div> 784 <div style="font-size:.72rem;color:#8b949e;line-height:1.6">Type any stock ticker and get a live AI brief â real-time news, earnings beat/miss, analyst consensus, risks & catalysts â powered by Perplexity Sonar. Available on <strong style="color:#818cf8">Pro</strong> and <strong style="color:#e2c275">Stellar</strong> plans.</div> 785 </div> 786 </div> 787 788 <div style="margin-bottom:16px"> 789 <button onclick="continueForFree()" style="background:transparent;border:1px solid #444;color:#8b949e;font-family:monospace;font-size:.75rem;padding:9px 22px;border-radius:8px;cursor:pointer">Continue with Free plan â</button> 790 <div style="font-size:.65rem;color:#555;margin-top:6px">Scanner, News Feed & Charts â always free</div> 791 </div> 792 793 <p style="color:#6e7681;font-size:.75rem"> 794 Already subscribed? <a href="#" onclick="localStorage.removeItem('fz_token');location.reload()" style="color:#00e5b4">Log in again</a> 795 </p> 796</div> 797 798<!-- OVERVIEW --> 799<div class="page active" id="page-overview"> 800 <div class="page-header"> 801 <div class="page-title"><lord-icon src="https://cdn.lordicon.com/wyupmbaf.json" trigger="hover" style="width:28px;height:28px"></lord-icon>Factoz Command Center</div> 802 <div class="page-sub" style="margin-left:0">Global market intelligence â autonomous signals, daily briefs, macro research.</div> 803 </div> 804 805 806 <!-- Economic Countdown Banner --> 807 <div id="eco-countdown-banner" style="display:none;align-items:center;gap:10px;background:rgba(255,193,7,.08);border:1px solid rgba(255,193,7,.3);border-radius:6px;padding:9px 14px;margin-bottom:10px;font-size:.78rem;color:#ffc107;font-family:var(--mono)"></div> 808 809 <!-- Extension Install Banner (Pro / Stellar / Admin only) --> 810 <div id="ext-install-banner" style="display:none;align-items:center;gap:12px;background:rgba(226,194,117,.07);border:1px solid rgba(226,194,117,.25);border-radius:8px;padding:11px 16px;margin-bottom:10px"> 811 <span style="font-size:1.2rem;flex-shrink:0">ð§©</span> 812 <div style="flex:1;min-width:0"> 813 <div style="font-size:.76rem;font-weight:700;color:#e2c275;margin-bottom:2px">Factoz Intelligence Widget â Chrome Extension</div> 814 <div style="font-size:.68rem;color:#7a8fa3;line-height:1.5">Get live AI ticker analysis as you browse Yahoo Finance, TradingView, Finviz and more. Type any ticker in the extension and Sonar researches it instantly.</div> 815 </div> 816 <a id="ext-install-btn" href="https://chromewebstore.google.com/detail/factoz-intelligence-widget/jbjfefeinkalicgplonblljjpmfmcbpc" target="_blank" style="flex-shrink:0;background:#e2c275;color:#0b132b;font-size:.72rem;font-weight:700;padding:7px 14px;border-radius:6px;text-decoration:none;white-space:nowrap">
816Install Extension</a> 817 <button onclick="dismissExtBanner()" style="background:none;border:none;cursor:pointer;color:#3d4f63;font-size:16px;padding:2px 4px;flex-shrink:0" title="Dismiss">â</button> 818 </div> 819 820 <!-- Stat Cards --> 821 <div class="ov-stats"> 822 <div class="ov-stat"> 823 <div class="ov-stat-v" id="ov-signals">â</div> 824 <div class="ov-stat-l">SIGNALS TODAY</div> 825 <div class="ov-stat-s">last scanner run</div> 826 </div> 827 <div class="ov-stat" style="border-left-color:var(--yellow)"> 828 <div class="ov-stat-v" id="ov-tier" style="font-size:1rem;padding-top:4px">â</div> 829 <div class="ov-stat-l" id="ov-email">â</div> 830 <div class="ov-stat-s">account</div> 831 </div> 832 </div> 833 834 <!-- Quick Actions --> 835 <div id="ov-quick-actions" style="display:flex;gap:10px;margin-bottom:14px;flex-wrap:wrap"></div> 836 837 <!-- Ad slot: overview-top --> 838 <div id="ad-overview-top" class="ad-slot" style="margin-bottom:14px"></div> 839 840 <!-- Bento Grid --> 841 <div class="ov-bento"> 842 843 <!-- Pre-Market Scans â full width --> 844 <div class="ov-card ov-bento-wide"> 845 <div class="ov-card-hdr"> 846 <span>TODAY'S PRE-MARKET SCANS</span> 847 <div style="display:flex;align-items:center;gap:10px"> 848 <a id="pm-collage-dl" href="#" download="premarket_collage.png" style="display:none;align-items:center;gap:4px;font-size:0.7rem;color:var(--green);text-decoration:none;padding:2px 8px;border:1px solid var(--green);border-radius:4px">⬠Collage</a> 849 <a onclick="go('scanner')" style="font-size:.58rem;color:var(--teal);font-family:var(--mono);cursor:pointer;letter-spacing:1px;text-decoration:none">SCANNER â</a> 850 </div> 851 </div> 852 <div id="premarket-scans-panel"> 853 <div class="ov-skel" style="width:55%;margin-bottom:10px"></div> 854 <div style="display:flex;gap:7px"><div class="ov-skel" style="width:68px;height:26px;border-radius:20px;margin-bottom:0"></div><div class="ov-skel" style="width:68px;height:26px;border-radius:20px;margin-bottom:0"></div><div class="ov-skel" style="width:68px;height:26px;border-radius:20px;margin-bottom:0"></div><div class="ov-skel" style="width:68px;height:26px;border-radius:20px;margin-bottom:0"></div></div> 855 </div> 856 </div> 857 858 <!-- Top Movers --> 859 <div class="ov-card"> 860 <div class="ov-card-hdr"> 861 <span>TOP MOVERS</span> 862 <button onclick="loadOverviewMovers()" title="Refresh">â»</button> 863 <a onclick="go('movers')" style="margin-left:8px">VIEW ALL â</a> 864 </div> 865 <div id="ov-movers-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:8px"> 866 <div><div class="ov-skel ov-skel-sm"></div><div class="ov-skel"></div><div class="ov-skel"></div><div class="ov-skel"></div></div> 867 <div><div class="ov-skel ov-skel-sm"></div><div class="ov-skel"></div><div class="ov-skel"></div><div class="ov-skel"></div></div> 868 </div> 869 </div> 870 871 <!-- Latest Brief --> 872 <div class="ov-card" style="cursor:pointer" onclick="go('news')"> 873 <div class="ov-card-hdr"> 874 <span>LATEST BRIEF</span> 875 <a onclick="go('news')" style="font-size:.58rem;color:var(--teal);font-family:var(--mono);cursor:pointer;letter-spacing:1px;text-decoration:none">READ â</a> 876 </div> 877 <div id="ov-latest-brief"> 878 <div class="ov-skel"></div><div class="ov-skel"></div><div class="ov-skel"></div> 879 </div> 880 </div> 881 882 <!-- Latest News --> 883 <div class="ov-card"> 884 <div class="ov-card-hdr" style="flex-direction:column;align-items:stretch;gap:6px"> 885 <span>LATEST NEWS</span> 886 <input id="ov-news-search" type="text" placeholder="Filter by keywordâ¦" oninput="filterOvNews(this.value)" style="width:100%;background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:5px 10px;color:var(--text);font-size:.72rem;outline:none;box-sizing:border-box"> 887 </div> 888 <div id="ov-news-preview" style="max-height:280px;overflow-y:auto"> 889 <div class="ov-skel"></div><div class="ov-skel ov-skel-sm" style="margin-bottom:12px"></div> 890 <div class="ov-skel"></div><div class="ov-skel ov-skel-sm"></div> 891 </div> 892 </div> 893 894 <!-- DJT / Trump Posts (only for opted-in users) --> 895 <div class="ov-card" id="ov-djt-card" style="display:none"> 896 <div class="ov-card-hdr"><span>ðºð¸ TRUMP POSTS</span></div> 897 <div id="ov-djt-preview" style="max-height:220px;overflow-y:auto;display:flex;flex-direction:column;gap:4px"></div> 898 </div> 899 900 <!-- Insider Flow --> 901 <div class="ov-card"> 902 <div class="ov-card-hdr">
903 <span>INSIDER FLOW</span> 904 <button onclick="loadInsiderFlow()" title="Refresh">â»</button> 905 </div> 906 <div id="ov-insider-panel"> 907 <div class="ov-skel"></div><div class="ov-skel"></div><div class="ov-skel"></div><div class="ov-skel"></div><div class="ov-skel"></div> 908 </div> 909 </div> 910 911 <!-- Options Flow --> 912 <div class="ov-card"> 913 <div class="ov-card-hdr"> 914 <span>â¡ OPTIONS FLOW</span> 915 <div style="display:flex;gap:6px;align-items:center"> 916 <span id="ov-options-badge" style="display:none;font-size:.6rem;background:rgba(46,204,142,.15);color:#2ecc8e;border:1px solid rgba(46,204,142,.3);border-radius:4px;padding:1px 7px;font-family:var(--mono)"></span> 917 <button onclick="loadOptionsFlow()" title="Refresh">â»</button> 918 </div> 919 </div> 920 <div id="ov-options-panel"> 921 <div class="ov-skel"></div><div class="ov-skel"></div><div class="ov-skel"></div><div class="ov-skel"></div> 922 </div> 923 </div> 924 925 <!-- EPS Surprises --> 926 <div class="ov-card"> 927 <div class="ov-card-hdr"> 928 <span>EPS SURPRISES</span> 929 <a onclick="filterNews('earnings');go('news')" style="font-size:.58rem;color:var(--teal);font-family:var(--mono);cursor:pointer;letter-spacing:1px;text-decoration:none">VIEW ALL â</a> 930 </div> 931 <div id="ov-eps-panel"> 932 <div class="ov-skel"></div><div class="ov-skel"></div><div class="ov-skel"></div> 933 </div> 934 </div> 935 936 <!-- Sector Performance --> 937 <div class="ov-card" style="padding:0;overflow:hidden"> 938 <div class="ov-card-hdr" style="padding:10px 12px 0"><span>SECTOR PERFORMANCE</span></div> 939 <div class="tradingview-widget-container"> 940 <div class="tradingview-widget-container__widget"></div> 941
941<script type="text/javascript" src="https://s3.tradingview.com/external-embedding/embed-widget-market-overview.js" async>{"colorTheme":"dark","dateRange":"1D","showChart":false,"locale":"en","width":"100%","height":230,"isTransparent":true,"showSymbolLogo":false,"showFloatingTooltip":false,"tabs":[{"title":"Sectors","symbols":[{"s":"AMEX:XLK","d":"Technology"},{"s":"AMEX:XLF","d":"Financials"},{"s":"AMEX:XLE","d":"Energy"},{"s":"AMEX:XLV","d":"Healthcare"},{"s":"AMEX:XLI","d":"Industrials"},{"s":"AMEX:XLY","d":"Cons. Disc."},{"s":"AMEX:XLP","d":"Cons. Staples"},{"s":"AMEX:XLU","d":"Utilities"},{"s":"AMEX:XLRE","d":"Real Estate"},{"s":"AMEX:XLB","d":"Materials"},{"s":"AMEX:XLC","d":"Comm. Svcs"}]}]}</script>
941 942 </div> 943 </div> 944 945 <!-- Analyst Ratings --> 946 <div class="ov-card"> 947 <div class="ov-card-hdr"> 948 <span>ANALYST RATINGS</span> 949 <button onclick="loadAnalystRatings()" title="Refresh">â»</button> 950 <a onclick="filterNews('company_analysis');go('news')" style="font-size:.58rem;color:var(--teal);font-family:var(--mono);cursor:pointer;letter-spacing:1px;text-decoration:none">VIEW ALL â</a> 951 </div> 952 <div id="ov-analyst-panel"> 953 <div class="ov-skel"></div><div class="ov-skel"></div><div class="ov-skel"></div><div class="ov-skel"></div> 954 </div> 955 </div> 956 957 </div><!-- /ov-bento --> 958</div> 959 960<!-- SCANNER --> 961<div class="page" id="page-scanner"> 962 <div class="page-header"> 963 <div class="page-title"><span class="step-badge">1</span>Scanner</div> 964 <div class="page-sub">MA Crossover · RSI Extremes · S/R Breakout · Vol Surge · 52-Wk · Gap & Hold</div> 965 </div> 966 <div class="macro-tabs" style="margin-bottom:14px"> 967 <button class="macro-tab active" onclick="scannerTab('scanner')">â¶ Scanner</button> 968 <button class="macro-tab" onclick="scannerTab('screener')">ð Screener</button> 969 </div> 970 <div id="scanner-pane-scanner" class="macro-pane active"> 971 <div class="scanner-grid"> 972 <div> 973 <div class="form-group"> 974 <label class="form-label">SCAN TYPE</label> 975 <select id="scan-type-select" style="width:100%;background:var(--bg3);color:var(--text1);border:1px solid var(--border);border-radius:5px;padding:7px 10px;font-size:.8rem"> 976 <option value="ma_crossover">MA Crossover â EMA9 / SMA50 / SMA200</option> 977 <option value="rsi">RSI Extremes â Overbought (>70) / Oversold (<30)</option> 978 <option value="sr_breakout">S/R Breakout â 20-day High/Low Break</option> 979 <option value="volume_surge" class="pro-scan-opt" disabled>ð Vol Surge â Volume ⥠1.5à 3-month avg [PRO]</option> 980 <option value="breakout_52wk" class="pro-scan-opt" disabled>ð 52-Wk Breakout â Within 5% of 52-week high/low [PRO]</option> 981 <option value="gap_and_hold" class="pro-scan-opt" disabled>â¡ Gap & Hold â Gapped ⥠3% and held direction [PRO]</option> 982 </select> 983 </div> 984 <div class="form-group"> 985 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px"> 986 <label class="form-label" style="margin-bottom:0">TICKER LIST (comma separated)</label> 987 <label style="font-size:.68rem;color:var(--teal);cursor:pointer;display:flex;align-items:center;gap:4px"> 988 ð Import .xlsx / .xls / .csv 989 <input type="file" accept=".xlsx,.xls,.csv" style="display:none" onchange="loadTickersFromFile(this)"> 990 </label> 991 </div> 992 <textarea id="scan-tickers" rows="4" placeholder="AAPL, NVDA, TSLA, MSFT, AMZN, META, GOOGL, AMD, NFLX, COIN"></textarea> 993 </div> 994 <button class="btn btn-primary" onclick="runScan()" id="scan-btn">â¶ Run Scanner</button> 995 <div style="display:flex;gap:6px;margin-top:7px"> 996 <button class="btn btn-ghost" onclick="saveScannerList()" style="font-size:.68rem;padding:3px 10px">ð¾ Save list</button> 997 <button class="btn btn-ghost" onclick="loadScannerList()" style="font-size:.68rem;padding:3px 10px" id="load-list-btn">â¡ Load saved</button> 998 </div> 999 <div id="scan-status" style="margin-top:10px"></div> 1000 <hr> 1001 <div class="card"><h4>RESULTS</h4><div id="scan-results"><p>Run a scan to see signals.</p></div></div> 1002 </div> 1003 <div> 1004 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px"> 1005 <span class="badge badge-muted" id="chart-count">0 charts</span> 1006 </div> 1007 <div class="chart-grid" id="chart-grid"></div> 1008 </div> 1009 </div> 1010 </div><!-- /scanner-pane-scanner --> 1011 1012 <div id="scanner-pane-screener" class="macro-pane"> 1013 <div style="text-align:right;margin-bottom:6px"><a href="https://in.tradingview.com/?aff_id=164648" target="_blank" rel="noopener" style="display:inline-block;background:#1a2340;border:1px solid #E2C275;border-radius:20px;padding:4px 12px;font-size:.68rem;color:#E2C275;text-decoration:none;letter-spacing:.3px">ð Get <strong>$15 USD off</strong> TradingView â Open here â</a></div> 1014 <div class="tradingview-widget-container" style="background:#0b132b;border:1px solid #202e56;border-radius:10px;overflow:hidden"> 1015 <div class="tradingview-widget-container__widget"></div> 1016 <div class="tradingview-widget-copyright" style="font-size:.5rem;color:var(--muted);opacity:.35;text-align:right;padding:1px 4px"><a href="https://www.tradingview.com/screener/" rel="noopener nofollow" target="_blank" style="color:var(--muted);text-decoration:none">Stock Screener by TradingView</a></div> 1017
1017<script type="text/javascript" src="https://s3.tradingview.com/external-embedding/embed-widget-screener.js" async> 1018 {"market":"america","showToolbar":true,"defaultColumn":"overview","defaultScreen":"most_capitalized","isTransparent":true,"locale":"en","colorTheme":"dark","width":"100%","height":700,"backgroundColor":"#0b132b"} 1019 </script>
1019 1020 </div> 1021 </div><!-- /scanner-pane-screener --> 1022 1023 <!-- SCAN HISTORY â full width --> 1024 <div class="card" style="margin-top:18px"> 1025 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px"> 1026 <h4 style="margin:0">SCAN HISTORY</h4> 1027 </div> 1028 <div id="scan-history-list"><p style="font-size:.75rem;color:var(--text2)">No saved scans yet.</p></div> 1029 </div> 1030 1031 <!-- MY CHARTS â full width --> 1032 <div class="card" style="margin-top:12px"> 1033 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px"> 1034 <h4 style="margin:0">MY CHARTS (7 days)</h4> 1035 </div> 1036 <div id="my-charts-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px"> 1037 <p style="font-size:.75rem;color:var(--text2);grid-column:1/-1">Run a scan to save charts here.</p> 1038 </div> 1039 </div> 1040 1041 <!-- CHART BUILDER --> 1042 <div class="card" id="chart-builder-card" style="margin-top:18px"> 1043 <h4 style="margin:0 0 12px">ð Custom Chart Builder</h4> 1044 <div class="g2" style="gap:10px"> 1045 <div> 1046 <div class="form-group" style="margin-bottom:8px"> 1047 <label class="form-label">TICKER</label> 1048 <input id="cb-ticker" type="text" placeholder="AAPL" style="text-transform:uppercase"> 1049 </div> 1050 <div style="display:flex;gap:8px;margin-bottom:8px"> 1051 <div class="form-group" style="flex:1;margin-bottom:0"> 1052 <label class="form-label">INTERVAL</label> 1053 <select id="cb-interval" style="width:100%;background:var(--bg3);color:var(--text1);border:1px solid var(--border);border-radius:5px;padding:7px 10px;font-size:.8rem"> 1054 <option value="1D">1 Day</option> 1055 <option value="4h">4 Hour</option> 1056 <option value="1h">1 Hour</option> 1057 <option value="15m">15 Min</option> 1058 <option value="1W">1 Week</option> 1059 </select> 1060 </div> 1061 <div class="form-group" style="flex:1;margin-bottom:0"> 1062 <label class="form-label">RANGE</label> 1063 <select id="cb-range" style="width:100%;background:var(--bg3);color:var(--text1);border:1px solid var(--border);border-radius:5px;padding:7px 10px;font-size:.8rem"> 1064 <option value="3M">3 Months</option> 1065 <option value="1M">1 Month</option> 1066 <option value="6M">6 Months</option> 1067 <option value="1Y">1 Year</option> 1068 </select> 1069 </div> 1070 </div> 1071 <button class="btn btn-primary" onclick="buildCustomChart()" id="cb-btn" style="width:100%">ð Generate Chart</button> 1072 <div id="cb-status" style="margin-top:8px;font-size:.75rem"></div> 1073 </div> 1074 <div> 1075 <label class="form-label">INDICATORS</label> 1076 <div id="cb-indicators" style="display:flex;flex-direction:column;gap:6px;max-height:260px;overflow-y:auto;padding-right:4px"> 1077 <p style="font-size:.75rem;color:var(--muted)">Loading indicatorsâ¦</p> 1078 </div> 1079 </div> 1080 </div> 1081 <div id="cb-chart-output" style="margin-top:14px;text-align:center"></div> 1082 </div> 1083 1084 1085 <!-- AI FINANCIAL ADVISOR CHAT --> 1086 <div class="card" id="scanner-chat" style="margin-top:18px"> 1087 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px"> 1088 <h4 style="margin:0">ð¬ AI Market Analyst</h4> 1089 <button class="btn btn-ghost" onclick="clearChat()" style="font-size:.72rem;padding:3px 8px">â Clear</button> 1090 </div> 1091 <!-- Quick-prompt chips --> 1092 <div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:10px"> 1093 <button class="chat-chip" onclick="chipPrompt('Why is [ticker] moving today?')">ð° Why moving?</button> 1094 <button class="chat-chip" onclick="chipPrompt('Summarize recent earnings for [ticker]')">ð Earnings</button> 1095 <button class="chat-chip" onclick="chipPrompt('What do analysts say about [ticker]?')">ð¦ Analyst view</button> 1096 <button class="chat-chip" onclick="chipPrompt('Key risks for [ticker] right now?')">â ï¸ Risks</button> 1097 </div> 1098 <!-- Message log --> 1099 <div id="chat-log" style="max-height:320px;overflow-y:auto;display:flex;flex-direction:column;gap:8px;margin-bottom:10px"></div> 1100 <!-- Input row --> 1101 <div style="display:flex;gap:8px"> 1102 <input id="chat-input" type="text" placeholder="Ask about any stock or the scan resultsâ¦" 1103 style="flex:1" onkeydown="if(event.key==='Enter')sendChat()"> 1104 <button class="btn btn-primary" onclick="sendChat()" id="chat-btn" style="white-space:nowrap">Send âµ</button> 1105 </div> 1106 <div id="chat-status" style="font-size:.72rem;color:var(--muted);margin-top:5px;min-height:1em"></div> 1107 </div> 1108</div> 1109 1110<!-- ANALYST TARGETS --> 1111<div class="page" id="page-analyst"> 1112 <div class="page-header"> 1113 <div class="page-title"><lord-icon src="https://cdn.lordicon.com/xvmmqwjv.json" trigger="in" delay="1500" stroke="bold" state="in-reveal" style="width:28px;height:28px"></lord-icon>Analyst Price Targets</div> 1114 <div class="page-sub">Wall Street consensus price targets â powered by Yahoo Finance</div> 1115 </div> 1116 <div style="margin-bottom:20px"> 1117 <div style="display:flex;gap:8px;max-width:420px"> 1118 <div style="position:relative;flex:1"> 1119 <input id="analyst-ticker-input" class="input" placeholder="e.g. AAPL" 1120 style="text-transform:uppercase;width:100%;box-sizing:border-box" 1121 autocomplete="off"
1122 oninput="analystAutoComplete(this.value)" 1123 onkeydown="analystInputKey(event)"> 1124 <div id="analyst-suggestions" 1125 style="display:none;position:absolute;top:100%;left:0;right:0; 1126 background:#161b22;border:1px solid #30363d;border-top:none; 1127 border-radius:0 0 6px 6px;z-index:100;max-height:220px;overflow-y:auto"></div> 1128 </div> 1129 <button class="btn" onclick="getAnalystTarget()">Get Targets</button> 1130 </div> 1131 </div> 1132 <div id="analyst-result"></div> 1133</div> 1134 1135<!-- CAROUSEL --> 1136<div class="page" id="page-carousel"> 1137 <div class="page-header"> 1138 <div class="page-title"><span class="step-badge">2</span>Carousel Builder</div> 1139 <div class="page-sub">4-slide branded carousels â person cover, chart, data overlays</div> 1140 </div> 1141 <div class="wiz-bar"> 1142 <div class="wiz-step active" id="rstep-1"><span class="wiz-num">1</span><span class="wiz-label">Charts</span></div> 1143 <div class="wiz-line"></div> 1144 <div class="wiz-step" id="rstep-2"><span class="wiz-num">2</span><span class="wiz-label">Configure</span></div> 1145 <div class="wiz-line"></div> 1146 <div class="wiz-step" id="rstep-3"><span class="wiz-num">3</span><span class="wiz-label">Build</span></div> 1147 <div class="wiz-line"></div> 1148 <div class="wiz-step" id="rstep-4"><span class="wiz-num">4</span><span class="wiz-label">Done</span></div> 1149 </div> 1150 <div id="carousel-hint" style="font-size:.74rem;color:var(--muted);margin-bottom:14px;font-family:var(--mono)">Upload charts or send from Scanner to begin.</div> 1151 1152 <!-- Mode tabs --> 1153 <div style="display:flex;gap:0;margin-bottom:18px;border:1px solid var(--border);border-radius:8px;overflow:hidden;width:fit-content"> 1154 <button id="tab-pil" onclick="switchCarouselTab('pil')" style="display:none;padding:8px 20px;font-size:.75rem;font-weight:700;font-family:var(--mono);cursor:pointer;background:rgba(226,194,117,.12);color:var(--teal);border:none;border-right:1px solid var(--border)">â¡ Quick Build</button> 1155 <button id="tab-premium" onclick="switchCarouselTab('premium')" style="padding:8px 20px;font-size:.75rem;font-weight:700;font-family:var(--mono);cursor:pointer;background:rgba(226,194,117,.12);color:var(--teal);border:none">⨠Premium Designs</button> 1156 </div> 1157 1158 <!-- ââ QUICK BUILD (PIL) â hidden ââ --> 1159 <div id="carousel-pil-panel" style="display:none"> 1160 <div class="g2"> 1161 <div> 1162 <div class="template-grid"> 1163 <div class="tmpl-card selected" data-tmpl="factoz" onclick="selectTemplate('factoz',this)"><div class="tmpl-icon">ð</div><div class="tmpl-name">Factoz</div><div class="tmpl-desc">Dark moody, bold text</div></div> 1164 <div class="tmpl-card" data-tmpl="terminal" onclick="selectTemplate('terminal',this)"><div class="tmpl-icon">ð»</div><div class="tmpl-name">Terminal</div><div class="tmpl-desc">Bloomberg feel</div></div> 1165 <div class="tmpl-card" data-tmpl="editorial" onclick="selectTemplate('editorial',this)"><div class="tmpl-icon">ð°</div><div class="tmpl-name">Editorial</div><div class="tmpl-desc">Magazine style</div></div> 1166 </div> 1167 <div style="font-size:.65rem;color:var(--muted);margin-bottom:10px">Your brand kit colors & logo are applied to the output.</div> 1168 <div class="form-group"><label class="form-label">UPLOAD IMAGES</label><input type="file" id="carousel-files" accept="image/*" multiple><div id="carousel-files-info" style="font-size:.7rem;color:var(--text2);margin-top:4px"></div></div> 1169 <div class="form-group"> 1170 <label class="form-label">SLIDE LABELS <span style="opacity:.45;font-weight:400;font-size:.65rem">(optional)</span></label> 1171 <input type="text" id="slide-text-1" placeholder="Slide 1 text" style="width:100%;margin-bottom:6px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:7px 10px;color:var(--text);font-size:.78rem"> 1172 <input type="text" id="slide-text-2" placeholder="Slide 2 text" style="width:100%;margin-bottom:6px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:7px 10px;color:var(--text);font-size:.78rem"> 1173 <input type="text" id="slide-text-3" placeholder="Slide 3 text" style="width:100%;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:7px 10px;color:var(--text);font-size:.78rem"> 1174 </div> 1175 <div class="form-group"> 1176 <label class="form-label">OVERLAY FONT</label> 1177 <select id="overlay-font" style="width:100%;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:7px 10px;color:var(--text);font-size:.78rem"> 1178 <option value="anton">Anton (Impact Style)</option> 1179 <option value="bebas">Bebas Neue</option> 1180 <option value="oswald">Oswald Bold</option> 1181 <option value="playfair">Playfair Display</option> 1182 <option value="montserrat">Montserrat Bold</option> 1183 </select> 1184 </div> 1185 <div class="form-group"> 1186 <label class="form-label">FONT SIZE <span id="font-size-val" style="color:var(--accent);font-weight:700">40</span></label> 1187 <input type="range" id="font-size" min="20" max="80" value="40" step="2" 1188 style="width:100%;accent-color:var(--accent)"
1189 oninput="document.getElementById('font-size-val').textContent=this.value"> 1190 </div> 1191 <div class="form-group"> 1192 <label class="form-label">TEXT COLOR</label> 1193 <div id="color-swatches" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:4px"> 1194 <div class="swatch selected" data-color="#FFFFFF" style="background:#fff" title="White"></div> 1195 <div class="swatch" data-color="#000000" style="background:#000" title="Black"></div> 1196 <div class="swatch" data-color="#E2C275" style="background:#E2C275" title="Gold"></div> 1197 <div class="swatch" data-color="#FFD700" style="background:#FFD700" title="Yellow"></div> 1198 <div class="swatch" data-color="#FF4444" style="background:#FF4444" title="Red"></div> 1199 <div class="swatch" data-color="#00E676" style="background:#00E676" title="Green"></div> 1200 </div> 1201 <input type="hidden" id="text-color" value="#FFFFFF"> 1202 </div> 1203 <div style="display:flex;gap:10px"> 1204 <button class="btn btn-primary" onclick="buildCarousel()" id="carousel-btn">â¡ Build</button> 1205 </div> 1206 </div> 1207 <div> 1208 <div class="carousel-preview" id="carousel-preview"> 1209 <div style="background:var(--bg3);border:1px dashed var(--border);border-radius:6px;aspect-ratio:1;display:flex;align-items:center;justify-content:center;font-size:.65rem;color:var(--muted)">Slide 1</div> 1210 <div style="background:var(--bg3);border:1px dashed var(--border);border-radius:6px;aspect-ratio:1;display:flex;align-items:center;justify-content:center;font-size:.65rem;color:var(--muted)">Slide 2</div> 1211 <div style="background:var(--bg3);border:1px dashed var(--border);border-radius:6px;aspect-ratio:1;display:flex;align-items:center;justify-content:center;font-size:.65rem;color:var(--muted)">Slide 3</div> 1212 </div> 1213 <hr> 1214 <div class="card"><h4>CAPTION</h4><textarea id="carousel-caption-preview" rows="5" placeholder="Write your caption here..." style="width:100%;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:.72rem;font-family:var(--mono);color:var(--text);resize:vertical"></textarea></div> 1215 <div id="carousel-actions" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px"></div> 1216 </div> 1217 </div> 1218 </div> 1219 1220 <!-- ââ TEMPLATED DESIGNER ââ --> 1221 <div id="carousel-premium-panel"> 1222 <!-- Status bar --> 1223 <div id="premium-status-bar" style="background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px 18px;margin-bottom:18px"> 1224 <div id="premium-status-text" style="font-size:.78rem;color:var(--muted)">Loading...</div> 1225 </div> 1226 1227 <!-- Build form --> 1228 <div id="premium-build-form" style="display:none"> 1229 <div class="g2"> 1230 <div> 1231 <!-- Dynamic template grid --> 1232 <div style="display:flex;justify-content:flex-end;margin-bottom:6px"> 1233 <button onclick="reloadTemplates()" title="Reload template list" style="font-family:var(--mono);font-size:.6rem;padding:3px 9px;background:transparent;border:1px solid var(--border);border-radius:4px;color:var(--muted);cursor:pointer">â» Reload</button> 1234 </div> 1235 <div id="premium-card-grid" class="template-grid" style="grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:10px;margin-bottom:14px"> 1236 <!-- Cards injected by renderTemplateGrid() --> 1237 </div> 1238 <div id="premium-logo-row" style="display:none;align-items:center;gap:8px;margin-bottom:12px;font-size:.72rem;color:var(--muted)"> 1239 <img id="premium-logo-preview" src="" style="width:28px;height:28px;object-fit:contain;border-radius:4px;border:1px solid var(--border)"> 1240 <span>Logo from Brand Kit will be injected into <code style="font-size:.65rem">logo-1</code> on every slide</span> 1241 </div> 1242 <div style="font-size:.72rem;color:var(--muted);margin-bottom:10px">Upload your pre-formatted data PNGs â they will be centered on the finance background template. Upload 1 to 3 slides.</div> 1243 <div class="form-group"> 1244 <label class="form-label">SLIDE 1 CONTENT</label> 1245 <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap"> 1246 <label class="btn btn-ghost" style="font-size:.7rem;padding:4px 10px;cursor:pointer;margin:0">ð Upload<input type="file" id="premium-slide1" accept="image/*" style="display:none" onchange="clearSlotSelection(1)"></label> 1247 <button class="btn btn-ghost" onclick="openGalleryPicker(1)" style="font-size:.7rem;padding:4px 10px">ð Pick from Gallery</button>
1248 <span id="slide-sel-1" style="font-size:.65rem;color:var(--teal);display:none">â Gallery image selected</span> 1249 </div> 1250 <div id="slide-preview-1" style="display:none;margin-top:6px"><img style="height:60px;border-radius:6px;border:1px solid var(--border)"></div> 1251 <input type="text" id="premium-caption-1" placeholder="Slide 1 â text-1â¦" style="margin-top:6px"> 1252 </div> 1253 <div class="form-group"> 1254 <label class="form-label">SLIDE 2 CONTENT</label> 1255 <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap"> 1256 <label class="btn btn-ghost" style="font-size:.7rem;padding:4px 10px;cursor:pointer;margin:0">ð Upload<input type="file" id="premium-slide2" accept="image/*" style="display:none" onchange="clearSlotSelection(2)"></label> 1257 <button class="btn btn-ghost" onclick="openGalleryPicker(2)" style="font-size:.7rem;padding:4px 10px">ð Pick from Gallery</button> 1258 <span id="slide-sel-2" style="font-size:.65rem;color:var(--teal);display:none">â Gallery image selected</span> 1259 </div> 1260 <div id="slide-preview-2" style="display:none;margin-top:6px"><img style="height:60px;border-radius:6px;border:1px solid var(--border)"></div> 1261 <input type="text" id="premium-caption-2" placeholder="Slide 2 â text-2â¦" style="margin-top:6px"> 1262 </div> 1263 <div class="form-group"> 1264 <label class="form-label">SLIDE 3 CONTENT</label> 1265 <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap"> 1266 <label class="btn btn-ghost" style="font-size:.7rem;padding:4px 10px;cursor:pointer;margin:0">ð Upload<input type="file" id="premium-slide3" accept="image/*" style="display:none" onchange="clearSlotSelection(3)"></label> 1267 <button class="btn btn-ghost" onclick="openGalleryPicker(3)" style="font-size:.7rem;padding:4px 10px">ð Pick from Gallery</button> 1268 <span id="slide-sel-3" style="font-size:.65rem;color:var(--teal);display:none">â Gallery image selected</span> 1269 </div> 1270 <div id="slide-preview-3" style="display:none;margin-top:6px"><img style="height:60px;border-radius:6px;border:1px solid var(--border)"></div> 1271 <input type="text" id="premium-caption-3" placeholder="Slide 3 â text-3â¦" style="margin-top:6px"> 1272 </div> 1273 <button class="btn btn-primary" onclick="buildPremiumCarousel()" id="premium-build-btn">⨠Generate Carousel</button> 1274 <div id="premium-build-result" style="margin-top:10px"></div> 1275 </div> 1276 <div> 1277 <div id="premium-preview-grid" class="carousel-preview"> 1278 <div style="background:var(--bg3);border:1px dashed var(--border);border-radius:6px;aspect-ratio:1;display:flex;align-items:center;justify-content:center;font-size:.65rem;color:var(--muted)">Slide 1</div> 1279 <div style="background:var(--bg3);border:1px dashed var(--border);border-radius:6px;aspect-ratio:1;display:flex;align-items:center;justify-content:center;font-size:.65rem;color:var(--muted)">Slide 2</div> 1280 <div style="background:var(--bg3);border:1px dashed var(--border);border-radius:6px;aspect-ratio:1;display:flex;align-items:center;justify-content:center;font-size:.65rem;color:var(--muted)">Slide 3</div> 1281 </div> 1282 </div> 1283 </div> 1284 </div> 1285 1286 <!-- Factoz AI Image Generator --> 1287 <div class="card" id="ai-chart-card" style="margin-top:18px"> 1288 <h4 style="margin:0 0 4px">⦠Factoz AI Image Generator</h4> 1289 <p style="font-size:.72rem;color:var(--muted);margin:0 0 14px">Pick your market view â AI generates themed finance art for your slides.</p> 1290 <div style="display:flex;gap:10px;flex-wrap:wrap"> 1291 <button onclick="runAiChart('bull')" id="ai-chart-bull-btn" 1292 style="flex:1;padding:10px;border-radius:8px;cursor:pointer;background:rgba(0,200,83,.12); 1293 color:#00c853;border:1px solid rgba(0,200,83,.3);font-weight:600;font-size:.85rem"> 1294 ð Bull Case 1295 </button> 1296 <button onclick="runAiChart('bear')" id="ai-chart-bear-btn" 1297 style="flex:1;padding:10px;border-radius:8px;
1297cursor:pointer;background:rgba(242,54,69,.12); 1298 color:#f23645;border:1px solid rgba(242,54,69,.3);font-weight:600;font-size:.85rem"> 1299 ð» Bear Case 1300 </button> 1301 <button onclick="runAiChart('neutral')" id="ai-chart-neutral-btn" 1302 style="flex:1;padding:10px;border-radius:8px;cursor:pointer;background:rgba(79,195,247,.10); 1303 color:#4fc3f7;border:1px solid rgba(79,195,247,.3);font-weight:600;font-size:.85rem"> 1304 â¡ Neutral 1305 </button> 1306 </div> 1307 <div id="ai-chart-status" style="margin-top:10px;font-size:.75rem;color:var(--muted)"></div> 1308 <div id="ai-chart-result" style="margin-top:14px;display:none"> 1309 <img id="ai-chart-img" src="" style="width:100%;max-width:400px;border-radius:10px;display:block"> 1310 <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px"> 1311 <button onclick="downloadAiChartImage()" style="font-size:.72rem" class="btn btn-ghost">⬠Download</button> 1312 <button onclick="useAiImageForSlot(1)" style="font-size:.72rem" class="btn btn-ghost">â Use for Slide 1</button> 1313 <button onclick="useAiImageForSlot(2)" style="font-size:.72rem" class="btn btn-ghost">â Use for Slide 2</button> 1314 <button onclick="useAiImageForSlot(3)" style="font-size:.72rem" class="btn btn-ghost">â Use for Slide 3</button> 1315 </div> 1316 </div> 1317 </div> 1318 1319 <!-- Gallery Picker Modal --> 1320 <div id="gallery-picker-modal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.7);align-items:center;justify-content:center"> 1321 <div style="background:var(--bg2);border:1px solid var(--border);border-radius:12px;width:min(700px,95vw);max-height:80vh;display:flex;flex-direction:column;overflow:hidden"> 1322 <div style="display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-bottom:1px solid var(--border)"> 1323 <span style="font-weight:700;font-size:.9rem">Pick Image for Slide <span id="gp-slot-label"></span></span> 1324 <button onclick="closeGalleryPicker()" style="background:none;border:none;color:var(--text);font-size:1.2rem;cursor:pointer">â</button> 1325 </div> 1326 <div id="gp-grid" style="padding:16px;display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:10px;overflow-y:auto"></div> 1327 </div> 1328 </div> 1329 1330 <!-- Recent Outputs (72h) --> 1331 <div class="card" style="margin-top:18px"> 1332 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px"> 1333 <h4 style="margin:0">ð Recent Outputs <span style="font-size:.7rem;color:var(--muted);font-weight:400">(last 3 days)</span></h4> 1334 </div> 1335 <div id="recent-outputs-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px"> 1336 <div style="font-size:.72rem;color:var(--muted)">Loading...</div> 1337 </div> 1338 </div> 1339 1340 <!-- Setup guide --> 1341 <div id="premium-setup-guide" style="display:none"> 1342 <div class="card"> 1343 <h4>CAROUSEL ENGINE SETUP GUIDE</h4> 1344 <ol style="padding-left:20px;font-size:.78rem;color:var(--text2);line-height:2.2"> 1345 <li>Create account at the carousel engine â copy your API key from the dashboard</li> 1346 <li>Set Railway env var: <code style="color:var(--yellow)">TEMPLATED_API_KEY</code> = your API key</li> 1347 <li>Create a carousel template with <strong>4 pages</strong> â use a plain finance-themed background image on each page</li> 1348 <li>On each page add a centered image layer and name it exactly: <code style="color:var(--yellow)">content_image_1</code> (page 1), <code style="color:var(--yellow)">content_image_2</code> (page 2), <code style="color:var(--yellow)">content_image_3</code> (page 3), <code style="color:var(--yellow)">content_image_4</code> (page 4)</li> 1349 <li>Add a footer text layer named <code style="color:var(--yellow)">brand_name</code> on <strong>all 4 pages</strong></li> 1350 <li>Add a logo layer named <code style="color:var(--yellow)">logo-1</code> (populated from your Brand Kit logo)</li> 1351 <li>
1351Set Railway env var: <code style="color:var(--yellow)">TEMPLATED_TEMPLATE_FACTOZ</code> = your template ID</li> 1352 </ol> 1353 <div style="font-size:.7rem;color:var(--muted);margin-top:8px">Use <code>GET /api/templated/layers/{template_id}</code> in DevTools to verify layer names match.</div> 1354 </div> 1355 </div> 1356 </div> 1357</div> 1358 1359<!-- COLLAGE --> 1360<div class="page" id="page-collage"> 1361 <div class="page-header"> 1362 <div class="page-title"><span class="step-badge">3</span>Collage Builder</div> 1363 <div class="page-sub">Branded grid collages from chart images</div> 1364 </div> 1365 <div class="wiz-bar"> 1366 <div class="wiz-step active" id="cstep-1"><span class="wiz-num">1</span><span class="wiz-label">Charts</span></div> 1367 <div class="wiz-line"></div> 1368 <div class="wiz-step" id="cstep-2"><span class="wiz-num">2</span><span class="wiz-label">Configure</span></div> 1369 <div class="wiz-line"></div> 1370 <div class="wiz-step" id="cstep-3"><span class="wiz-num">3</span><span class="wiz-label">Generate</span></div> 1371 <div class="wiz-line"></div> 1372 <div class="wiz-step" id="cstep-4"><span class="wiz-num">4</span><span class="wiz-label">Done</span></div> 1373 </div> 1374 <div id="collage-hint" style="font-size:.74rem;color:var(--muted);margin-bottom:14px;font-family:var(--mono)">Upload charts or send from Scanner to begin.</div> 1375 <div class="g2"> 1376 <div> 1377 <div class="form-group"><label class="form-label">UPLOAD CHARTS (or uses scan results)</label><input type="file" id="collage-files" accept="image/*" multiple><div id="collage-files-info" style="font-size:.7rem;color:var(--text2);margin-top:4px"></div></div> 1378 <div class="form-group"><label class="form-label">ORIENTATION</label> 1379 <select id="collage-orientation"><option value="square">Square</option><option value="landscape">Landscape</option><option value="portrait">Portrait</option></select> 1380 </div> 1381 <div class="form-group"><label class="form-label">EXPORT TARGET</label> 1382 <select id="collage-export"> 1383 <option value="instagram_square">Instagram Square (1080Ã1080)</option> 1384 <option value="instagram_portrait">Instagram Portrait (1080Ã1350)</option> 1385 <option value="tiktok">TikTok (1080Ã1920)</option> 1386 <option value="twitter">Twitter / X (1600Ã900)</option> 1387 </select> 1388 </div> 1389 <button class="btn btn-primary" onclick="buildCollage()" id="collage-btn">ð§© Generate Collage</button> 1390 </div> 1391 <div> 1392 <div id="collage-preview"><div style="background:var(--bg3);border:1px dashed var(--border);border-radius:8px;padding:40px;color:var(--muted);font-size:.78rem;text-align:center">No collage yet</div></div> 1393 <div id="collage-actions" style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap"></div> 1394 </div> 1395 </div> 1396 <!-- ââ My Collages Library ââââââââââââââââââââ --> 1397 <div class="card" style="margin-top:12px"> 1398 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px"> 1399 <h4 style="margin:0">MY COLLAGES (72h)</h4> 1400 <button class="btn btn-ghost" style="font-size:.7rem;padding:3px 8px" onclick="loadMyCollages()">↻ Refresh</button> 1401 </div> 1402 <div id="my-collages-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:8px"> 1403 <p style="font-size:.75rem;color:var(--text2);grid-column:1/-1">Generate a collage to save it here.</p> 1404 </div> 1405 </div> 1406</div> 1407 1408<!-- COMMENTS --> 1409 1410<!-- POST --> 1411<div class="page" id="page-post"> 1412 <div class="page-header"> 1413 <div class="page-title"><span class="step-badge">4</span>Post</div> 1414 <div class="page-sub">Compose and post to X, Instagram, or both</div> 1415 </div> 1416 <div class="g2"> 1417 <div> 1418 <div class="form-group"><label class="form-label">IMAGE</label><input type="file" id="post-file" accept="image/*" onchange="previewPost()"></div> 1419 <div id="post-img-preview" style="margin-bottom:12px"></div> 1420 <div class="form-group"><label class="form-label">CAPTION</label><textarea id="post-caption" rows="6" oninput="syncCaption()" placeholder="Your caption here..."></textarea></div> 1421 <div id="char-count" style="text-align:right;font-size:.65rem;color:var(--muted);margin-top:-8px;margin-bottom:10px">0 / 280</div> 1422 <div style="display:flex;gap:16px;margin-bottom:12px"> 1423 <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:.78rem;font-weight:600"><input type="checkbox" id="post-x" checked style="width:auto"> ð¦ X</label> 1424 <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:.78rem;font-weight:600"><input type="checkbox" id="post-ig" style="width:auto"> ð¸ Instagram</label> 1425 </div> 1426 <button class="btn btn-green btn-full" onclick="doPost()" id="post-btn">ð¤ Post Now</button> 1427 <div id="post-result" style="margin-top:10px"></div> 1428 </div> 1429 <div> 1430 <div class="card"><h4>LIVE PREVIEW</h4></div> 1431 <div class="caption-box" id="caption-preview">Start typing...</div> 1432 <hr> 1433 <div class="card"><h4>QUICK CAPTION</h4></div> 1434 <div class="form-group" style="margin-top:10px"><input type="text" id="quick-tickers" placeholder="AAPL, NVDA, TSLA"></div> 1435 <button class="btn btn-ghost btn-full" onclick="quickCaption()">⨠Generate Caption</button> 1436 </div> 1437 </div> 1438</div> 1439 1440<!-- CALENDAR --> 1441<div class="page" id="page-calendar"> 1442 <div class="page-header"> 1443 <div class="page-title"><span class="step-badge">5</span>Calendar</div> 1444 <div class="page-sub">Schedule and manage your content pipeline</div> 1445 </div> 1446 <div id="calendar-embed" style="width:100%;height:calc(100vh - 140px)"></div> 1447</div> 1448 1449<!-- LOCAL FILES --> 1450<div class="page" id="page-files"> 1451 <div class="page-header"> 1452 <div class="page-title"><i data-feather="upload"></i>Upload Images</div> 1453 <div class="page-sub">Upload PNG / JPG images directly to your Gallery</div> 1454 </div> 1455 <div class="card" style="max-width:520px"> 1456 <h4>SELECT IMAGES</h4> 1457 <p style="color:var(--muted);font-size:.82rem;margin-bottom:14px">Choose one or more PNG/JPG files. They will be saved to your Gallery immediately.</p> 1458 <label class="btn btn-primary" style="cursor:pointer"> 1459 ð Choose Files 1460 <input type="file" accept="image/png,image/jpeg" multiple style="display:none" 1461 onchange="uploadLocalPageFiles(this)"> 1462 </label> 1463 <div id="files-upload-status" style="margin-top:12px;font-size:.82rem;color:var(--muted)"></div> 1464 <div id="files-go-gallery" style="display:none;margin-top:14px"> 1465 <button class="btn btn-ghost" onclick="showPage('gallery')">â View in Gallery</button> 1466 </div> 1467 </div> 1468</div> 1469 1470<!-- BRAND KIT --> 1471<div class="page" id="page-brand"> 1472 <div class="page-header"> 1473 <div class="page-title"><i data-feather="sliders"></i>Brand Kit</div> 1474 <div class="page-sub">Visual identity â colors, logo, and defaults applied automatically to all carousels</div> 1475 </div> 1476 <div class="g2" style="gap:28px;align-items:start"> 1477 1478 <!-- LEFT: Settings form --> 1479 <div> 1480 <!-- ââ BRAND IDENTITY ââ --> 1481 <div style="font-size:.58rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:12px">BRAND IDENTITY</div> 1482 <div class="form-group"><label class="form-label">BRAND NAME</label><input type="text" id="b-name" placeholder="FACTOZ" oninput="updateBrandPreview()"></div> 1483 <div class="form-group"><label class="form-label">TAGLINE</label><input type="text" id="b-tagline" placeholder="DEFI YIELD INTEL" oninput="updateBrandPreview()"></div> 1484 1485 <!-- Logo drop-zone --> 1486 <div style="font-size:.58rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin:18px 0 10px">LOGO</div> 1487 <div id="b-logo-zone" onclick="document.getElementById('b-logo').click()" 1488 style="border:2px dashed var(--border);border-radius:10px;padding:18px 16px;text-align:center;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:8px;transition:border-color .2s" 1489 onmouseenter="this.style.borderColor='var(--teal)'" onmouseleave="this.style.borderColor='var(--border)'"> 1490 <img id="b-logo-preview" src="" style="display:none;max-height:58px;border-radius:6px;margin-bottom:4px"> 1491 <div id="b-logo-hint" style="font-size:.72rem;color:var(--muted)">
1491Click to upload PNG (transparency recommended)</div> 1492 <input type="file" id="b-logo" accept="image/*" style="display:none" onchange="uploadLogo()"> 1493 </div> 1494 1495 <hr style="margin:22px 0"> 1496 1497 <!-- ââ COLOR PALETTE ââ --> 1498 <div style="font-size:.58rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:12px">COLOR PALETTE</div> 1499 1500 <div class="form-group"> 1501 <label class="form-label">BACKGROUND</label> 1502 <div style="display:flex;align-items:center;gap:10px"> 1503 <label style="width:34px;height:34px;border-radius:50%;border:2px solid var(--border);overflow:hidden;cursor:pointer;flex-shrink:0;display:block"> 1504 <input type="color" id="b-bg-pick" value="#0D1117" style="width:200%;height:200%;margin:-50%;border:none;padding:0;cursor:pointer" oninput="$('b-bg').value=this.value;updateBrandPreview()"> 1505 </label> 1506 <input type="text" id="b-bg" value="#0D1117" style="flex:1;font-family:var(--mono)" maxlength="7" oninput="if(/^#[0-9a-fA-F]{6}$/.test(this.value))$('b-bg-pick').value=this.value;updateBrandPreview()"> 1507 </div> 1508 </div> 1509 1510 <div class="form-group"> 1511 <label class="form-label">ACCENT / BORDER</label> 1512 <div style="display:flex;align-items:center;gap:10px"> 1513 <label style="width:34px;height:34px;border-radius:50%;border:2px solid var(--border);overflow:hidden;cursor:pointer;flex-shrink:0;display:block"> 1514 <input type="color" id="b-acc-pick" value="#00BCD4" style="width:200%;height:200%;margin:-50%;border:none;padding:0;cursor:pointer" oninput="$('b-acc').value=this.value;updateBrandPreview()"> 1515 </label> 1516 <input type="text" id="b-acc" value="#00BCD4" style="flex:1;font-family:var(--mono)" maxlength="7" oninput="if(/^#[0-9a-fA-F]{6}$/.test(this.value))$('b-acc-pick').value=this.value;updateBrandPreview()"> 1517 </div> 1518 </div> 1519 1520 <div class="form-group"> 1521 <label class="form-label">TEXT COLOR</label> 1522 <div style="display:flex;align-items:center;gap:10px"> 1523 <label style="width:34px;height:34px;border-radius:50%;border:2px solid var(--border);overflow:hidden;cursor:pointer;flex-shrink:0;display:block"> 1524 <input type="color" id="b-txt-pick" value="#E8F0F7" style="width:200%;height:200%;margin:-50%;border:none;padding:0;cursor:pointer" oninput="$('b-txt').value=this.value;updateBrandPreview()"> 1525 </label> 1526 <input type="text" id="b-txt" value="#E8F0F7" style="flex:1;font-family:var(--mono)" maxlength="7" oninput="if(/^#[0-9a-fA-F]{6}$/.test(this.value))$('b-txt-pick').value=this.value;updateBrandPreview()"> 1527 </div> 1528 </div> 1529 1530 <hr style="margin:22px 0"> 1531 1532 <!-- ââ CONTENT DEFAULTS ââ --> 1533 <div style="font-size:.58rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:12px">CONTENT DEFAULTS</div> 1534 1535 <div class="form-group"> 1536 <label class="form-label">DEFAULT CAROUSEL TEMPLATE</label> 1537 <select id="b-template"> 1538 <option value="factoz">ð Factoz â Dark moody</option> 1539 <option value="terminal">ð» Terminal â Bloomberg</option> 1540 <option value="editorial">ð° Editorial â Magazine</option> 1541 </select> 1542 </div> 1543 1544 <div class="form-group"><label class="form-label">DEFAULT HASHTAGS</label> 1545 <input type="text" id="b-hashtags" placeholder="#Factoz #TechnicalAnalysis #Stocks"> 1546 </div> 1547 1548 <div class="form-group"><label class="form-label">WATERMARK TEXT</label> 1549 <input type="text" id="b-wm" placeholder="FACTOZ" oninput="updateBrandPreview()"> 1550 </div> 1551 1552 <div class="form-group"> 1553 <label class="form-label">WATERMARK OPACITY â <span id="b-wm-op-label" style="color:var(--teal)">11%</span></label> 1554 <input type="range" id="b-wm-op" min="0" max="255" value="28" style="width:100%;accent-color:var(--teal)" 1555 oninput="$('b-wm-op-label').textContent=Math.round(this.value/2.55)+'%'"> 1556 </div> 1557 1558 <div style="display:flex;gap:22px;margin-bottom:18px"> 1559 <label style="display:flex;align-items:center;gap:7px;cursor:pointer;font-size:.78rem;font-weight:600;color:var(--text2)"> 1560 <input type="checkbox" id="b-show-wm" style="width:auto;accent-color:var(--teal)"> Show watermark 1561 </label> 1562 <label style="display:flex;align-items:center;gap:7px;cursor:pointer;font-size:.78rem;font-weight:600;color:var(--text2)"> 1563 <input type="checkbox" id="b-show-tag" checked style="width:auto;accent-color:var(--teal)"> Corner tag 1564 </label> 1565 </div> 1566 1567 <div class="form-group"><label class="form-label">DEFAULT EXPORT FORMAT</label> 1568 <select id="b-export"> 1569 <option value="instagram_square">Instagram Square</option> 1570 <option value="instagram_portrait">Instagram Portrait</option> 1571 <option value="tiktok">TikTok</option> 1572 <option value="twitter">Twitter / X</option> 1573 </select> 1574 </div> 1575 1576 <button class="btn btn-primary btn-full" onclick="saveBrand()" id="brand-save-btn">ð¾ Save Brand Kit</button> 1577 <div id="brand-save-result" style="margin-top:8px"></div> 1578 </div> 1579 1580 <!-- RIGHT: Live preview --> 1581 <div style="position:sticky;top:20px"> 1582 <div style="font-size:.58rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:12px">LIVE PREVIEW</div> 1583 1584 <!-- Mock slide card --> 1585 <div id="brand-preview-card" style="width:100%;max-width:300px;aspect-ratio:1;border-radius:14px;padding:24px 20px;display:flex;flex-direction:column;justify-content:space-between;background:#0D1117;border:2px solid #00BCD4;position:relative;overflow:hidden;transition:background .25s,border-color .25s"> 1586 <div id="bp-accent-top" style="position:absolute;top:0;left:0;right:0;height:3px;background:#00BCD4;transition:background .25s"></div> 1587 <!-- Header --> 1588 <div style="display:flex;justify-content:space-between;align-items:flex-start"> 1589 <div> 1590 <div id="bp-name" style="font-size:1rem;font-weight:800;letter-spacing:2px;color:#E8F0F7;font-family:var(--mono);transition:color .25s">FACTOZ</div> 1591 <div id="bp-tagline" style="font-size:.55rem;color:#00BCD4;margin-top:3px;font-family:var(--mono);letter-spacing:1px;transition:color .25s">MARKET INTELLIGENCE</div> 1592 </div> 1593 <img id="bp-logo" src="" style="display:none;height:32px;border-radius:4px"> 1594 </div> 1595 <!-- Center mock signal --> 1596 <div style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:14px 0"> 1597 <div id="bp-signal" style="font-size:1.5rem;font-weight:900;letter-spacing:1px;color:#E8F0F7;font-family:var(--mono);transition:color .25s">NVDA â²</div> 1598 <div id="bp-sub" style="font-size:.58rem;color:#00BCD4;font-family:var(--mono);transition:color .25s">
1598BULLISH · EMA9 CROSS</div> 1599 <!-- Accent divider --> 1600 <div id="bp-divider" style="width:60%;height:1px;background:#00BCD4;opacity:.5;margin-top:4px;transition:background .25s"></div> 1601 </div> 1602 <!-- Footer --> 1603 <div style="display:flex;justify-content:space-between;align-items:center"> 1604 <div id="bp-wm" style="font-size:.5rem;color:rgba(232,240,247,.25);font-family:var(--mono);letter-spacing:3px;transition:color .25s">FACTOZ</div> 1605 <div id="bp-dot" style="width:7px;height:7px;border-radius:50%;background:#00BCD4;transition:background .25s"></div> 1606 </div> 1607 <!-- Corner accent triangle --> 1608 <div id="bp-corner" style="position:absolute;bottom:0;right:0;width:36px;height:36px;background:linear-gradient(135deg,transparent 50%,#00BCD4 50%);opacity:.35;transition:background .25s"></div> 1609 </div> 1610 1611 <div style="margin-top:10px;font-size:.65rem;color:var(--muted);text-align:center">Updates live as you edit â</div> 1612 1613 <!-- Palette chips --> 1614 <div style="margin-top:16px"> 1615 <div style="font-size:.58rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:8px">CURRENT PALETTE</div> 1616 <div style="display:flex;gap:8px;align-items:center"> 1617 <div id="chip-bg" title="Background" style="width:32px;height:32px;border-radius:6px;border:1px solid var(--border);background:#0D1117;transition:background .25s"></div> 1618 <div id="chip-acc" title="Accent" style="width:32px;height:32px;border-radius:6px;border:1px solid var(--border);background:#00BCD4;transition:background .25s"></div> 1619 <div id="chip-txt" title="Text" style="width:32px;height:32px;border-radius:6px;border:1px solid var(--border);background:#E8F0F7;transition:background .25s"></div> 1620 <div style="font-size:.62rem;color:var(--muted);margin-left:4px">BG · Accent · Text</div> 1621 </div> 1622 </div> 1623 </div> 1624 1625 </div> 1626</div> 1627 1628<!-- API KEYS --> 1629<div class="page" id="page-settings"> 1630 <div class="page-header"> 1631 <div class="page-title"><i data-feather="settings"></i>Settings</div> 1632 <div class="page-sub">Your keys are stored securely per account</div> 1633 </div> 1634 1635 <!-- ââ Feed Subscriptions ââââââââââââââââââââââââââââââââââââââââââ --> 1636 <div class="card" style="margin-bottom:18px"> 1637 <h4>FEED SUBSCRIPTIONS</h4> 1638 <div style="display:flex;align-items:center;justify-content:space-between;padding:8px 0"> 1639 <div> 1640 <div style="font-size:.8rem;font-weight:600">ðºð¸ DJT Posts</div> 1641 <div style="font-size:.7rem;color:var(--muted)">Show Trump's posts from X and Truth Social in your news feed</div> 1642 </div> 1643 <button id="djt-toggle-btn" class="btn btn-ghost" onclick="toggleDjtSubscribe()" style="min-width:64px">â¦</button> 1644 </div> 1645 </div> 1646 1647 <div class="g2"> 1648 <!-- Admin-only keys (Chart provider + Cloudinary) â hidden for non-admin --> 1649 <div id="admin-keys-section"> 1650 </div> 1651 <!-- User-managed keys (X/Twitter + Integrations) â visible to all --> 1652 <div> 1653 <div class="card"><h4>X / TWITTER</h4></div> 1654 <div class="form-group"><label class="form-label">Consumer Key <span style="font-weight:400;font-size:.8em;opacity:.65">(API Key)</span></label><input type="password" id="k-x-api"></div> 1655 <div class="form-group"><label class="form-label">Consumer Secret <span style="font-weight:400;font-size:.8em;opacity:.65">(API Secret)</span></label><input type="password" id="k-x-secret"></div> 1656 <div class="form-group"><label class="form-label">Access Token</label><input type="password" id="k-x-token"></div> 1657 <div class="form-group"><label class="form-label">Access Token Secret</label><input type="password" id="k-x-tsecret"></div> 1658 </div> 1659 </div> 1660 <button class="btn btn-primary" onclick="saveKeys()">ð¾ Save API Keys</button> 1661 <div id="keys-result" style="margin-top:10px"></div> 1662</div> 1663 1664<!-- HISTORY --> 1665<div class="page" id="page-history"> 1666 <div class="page-header"> 1667 <div class="page-title"><i data-feather="clock"></i>History</div> 1668 <div class="page-sub">Posting log across all platforms</div> 1669 </div> 1670 <div class="g3" style="margin-bottom:18px"> 1671 <div class="stat-card"><div class="lbl">X POSTS</div><div class="val" id="h-x">â</div><div class="sub">successful</div></div> 1672 <div class="stat-card"><div class="lbl">INSTAGRAM</div><div class="val" id="h-ig">â</div><div class="sub">successful</div></div> 1673 <div class="stat-card"><div class="lbl">TOTAL</div><div class="val" id="h-tot">â</div><div class="sub">all platforms</div></div> 1674 </div> 1675 <div style="display:flex;gap:7px;margin-bottom:14px"> 1676 <button class="btn btn-primary" onclick="loadHistory('all')">All</button> 1677 <button class="btn btn-ghost" onclick="loadHistory('x')">X</button> 1678 <button class="btn btn-ghost" onclick="loadHistory('instagram')">Instagram</button> 1679 </div> 1680 <div id="history-list"></div> 1681</div> 1682 1683<!-- GUIDE --> 1684<div class="page" id="page-guide"> 1685 <div class="page-header"> 1686 <div class="page-title"><lord-icon src="https://cdn.lordicon.com/shcfcebj.json" trigger="hover" style="width:28px;height:28px"></lord-icon>What is Factoz?</div> 1687 <div class="page-sub">Global market intelligence â autonomous, institutional-grade, delivered daily</div> 1688 </div> 1689 1690 <!-- Hero statement --> 1691 <div class="card" style="margin-bottom:18px;border-color:rgba(0,200,183,.3);background:linear-gradient(135deg,var(--bg2),var(--bg3))"> 1692 <div style="display:flex;align-items:flex-start;gap:20px"> 1693 <div style="flex:1"> 1694 <div style="font-family:var(--mono);font-size:.6rem;color:var(--teal);letter-spacing:3px;margin-bottom:10px">PLATFORM OVERVIEW</div> 1695 <div style="font-size:1.05rem;font-weight:700;color:var(--text);line-height:1.5;margin-bottom:10px">Factoz is a global market intelligence and research platform.</div> 1696 <div style="font-size:.8rem;color:var(--text2);line-height:1.75"> 1697 Every day, Factoz autonomously scans <strong style="color:var(--text)">5,900+ tickers</strong>, monitors global macro data, tracks options flow, and generates an institutional-grade intelligence brief â all without any user input. Social posting is available as a secondary capability for users who want to distribute their insights. 1698 </div> 1699 </div> 1700 </div> 1701 </div> 1702 1703 <!-- Core capabilities grid --> 1704 <div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);letter-spacing:3px;margin-bottom:10px">CORE CAPABILITIES</div> 1705 <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:18px"> 1706 1707 <div class="card" style="border-color:rgba(0,200,183,.2);cursor:pointer" onclick="go('news')"> 1708 <div style="font-size:1.3rem;margin-bottom:8px">ð</div> 1709 <div style="font-weight:700;font-size:.82rem;margin-bottom:5px;color:var(--text)">Daily Intelligence Brief</div> 1710 <div style="font-size:.72rem;color:var(--text2);line-height:1.6">AI-written pre-market brief covering US, Europe, LATAM and Asia â real futures prices, risk alerts, top stories, editorial view. Auto-sent every weekday 6:30am EST.</div> 1711 <div style="margin-top:8px;font-size:.65rem;color:var(--teal);font-family:var(--mono)">NEWS FEED â DAILY BRIEFS</div> 1712 </div> 1713 1714 <div class="card" style="border-color:rgba(0,200,183,.2);cursor:pointer" onclick="go('scanner')"> 1715 <div style="font-size:1.3rem;margin-bottom:8px">â¡</div> 1716 <div style="font-weight:700;font-size:.82rem;margin-bottom:5px;color:var(--text)">Autonomous Market Scanner</div> 1717 <div style="font-size:.72rem;color:var(--text2);line-height:1.6">
1717Scans 5,900+ tickers daily â volume surges, 52-week breakouts, gap & hold, RSI extremes, support/resistance. Runs automatically. No user input required.</div> 1718 <div style="margin-top:8px;font-size:.65rem;color:var(--teal);font-family:var(--mono)">SIGNALS â SCANNER</div> 1719 </div> 1720 1721 <div class="card" style="border-color:rgba(0,200,183,.2);cursor:pointer" onclick="go('news')"> 1722 <div style="font-size:1.3rem;margin-bottom:8px">â¡</div> 1723 <div style="font-weight:700;font-size:.82rem;margin-bottom:5px;color:var(--text)">Options Flow Monitor</div> 1724 <div style="font-size:.72rem;color:var(--text2);line-height:1.6">Detects unusual options activity 4à per day across the full ticker universe â flags contracts with vol/OI >3Ã, premium >$50K, short-dated directional bets.</div> 1725 <div style="margin-top:8px;font-size:.65rem;color:var(--teal);font-family:var(--mono)">OVERVIEW â OPTIONS FLOW</div> 1726 </div> 1727 1728 <div class="card" style="border-color:rgba(0,200,183,.2);cursor:pointer" onclick="go('news')"> 1729 <div style="font-size:1.3rem;margin-bottom:8px">ð</div> 1730 <div style="font-weight:700;font-size:.82rem;margin-bottom:5px;color:var(--text)">Global News Intelligence</div> 1731 <div style="font-size:.72rem;color:var(--text2);line-height:1.6">Real-time market news, earnings, economic calendar, insider transactions, analyst ratings, macro data â all curated and filterable in one feed.</div> 1732 <div style="margin-top:8px;font-size:.65rem;color:var(--teal);font-family:var(--mono)">MARKETS â NEWS FEED</div> 1733 </div> 1734 1735 <div class="card" style="border-color:rgba(0,200,183,.2);cursor:pointer" onclick="go('macro')"> 1736 <div style="font-size:1.3rem;margin-bottom:8px">ð</div> 1737 <div style="font-weight:700;font-size:.82rem;margin-bottom:5px;color:var(--text)">FRED Macro Dashboard</div> 1738 <div style="font-size:.72rem;color:var(--text2);line-height:1.6">Live FRED economic indicators â CPI, GDP, unemployment, yield curve â with AI-generated macro regime analysis and custom alert triggers.</div> 1739 <div style="margin-top:8px;font-size:.65rem;color:var(--teal);font-family:var(--mono)">SIGNALS â FRED / MACRO</div> 1740 </div> 1741 1742 <div class="card" style="border-color:rgba(0,200,183,.2);cursor:pointer" onclick="go('financials')"> 1743 <div style="font-size:1.3rem;margin-bottom:8px">ð°</div> 1744 <div style="font-weight:700;font-size:.82rem;margin-bottom:5px;color:var(--text)">Financial Statement Generator</div> 1745 <div style="font-size:.72rem;color:var(--text2);line-height:1.6">Any public company, any period â P&L, balance sheet, cash flow generated in seconds. Export-ready for client presentations or research.</div> 1746 <div style="margin-top:8px;font-size:.65rem;color:var(--teal);font-family:var(--mono)">ANALYSIS â FINANCIALS</div> 1747 </div> 1748 1749 </div> 1750 1751 <!-- Weekly digest --> 1752 <div class="card" style="margin-bottom:18px;border-color:rgba(46,204,142,.2)"> 1753 <div style="display:flex;align-items:center;gap:12px;margin-bottom:10px"> 1754 <div style="font-size:1.2rem">ð</div> 1755 <div> 1756 <div style="font-weight:700;font-size:.85rem;color:var(--text)">Weekly Signal Performance Digest</div> 1757 <div style="font-size:.7rem;color:var(--muted);font-family:var(--mono)">EVERY MONDAY 7:00AM EST</div> 1758 </div> 1759 </div> 1760 <div style="font-size:.78rem;color:var(--text2);line-height:1.7">Every Monday, Factoz sends a performance report showing how every autonomous signal performed during the week â entry price, current price, % change, and win rate. It also includes the week's unusual options flow hits. Sent automatically to all subscribers.</div> 1761 </div> 1762 1763 <!-- Secondary: content & posting --> 1764 <div style="font-family:var(--mono);font-size:.6rem;color:var(--muted);letter-spacing:3px;margin-bottom:10px">ADDITIONAL CAPABILITIES</div> 1765 <div id="guide-sections"> 1766 1767 <div class="guide-section"> 1768 <div class="guide-header" onclick="toggleGuide('g-ai')"> 1769 <span>ð¬ AI Market Analyst â Ticker Deep Dives</span>
1770 <span class="guide-arrow" id="g-ai-arrow">â¶</span> 1771 </div> 1772 <div class="guide-body" id="g-ai" style="display:none"> 1773 <p style="font-size:.8rem;color:var(--text2);margin-bottom:10px">Ask any finance question â the AI analyst uses live scanner data as context for its answers.</p> 1774 <ul class="guide-steps" style="list-style:none;padding-left:0"> 1775 <li>ð <strong>Run a scan first</strong> â the AI automatically sees your tickers and signals</li> 1776 <li>âï¸ Ask specific questions: "Why is NVDA up today?", "Summarize AAPL earnings risk", "What does this breakout signal mean?"</li> 1777 <li>â¡ Use the <strong>quick-prompt chips</strong> for the most common question types</li> 1778 </ul> 1779 </div> 1780 </div> 1781 1782 </div> 1783</div> 1784 1785<!-- NEWS FEED --> 1786<div class="page" id="page-news"> 1787 <div class="page-header"> 1788 <div class="page-title"><lord-icon src="https://cdn.lordicon.com/yquwpfkc.json" trigger="hover" stroke="bold" style="width:28px;height:28px"></lord-icon>News Feed</div> 1789 <div class="page-sub">Admin-curated content â use directly in your Factoz workflow</div> 1790 </div> 1791 <div style="display:flex;gap:7px;margin-bottom:16px;flex-wrap:wrap"> 1792 <button class="btn btn-ghost news-filter-btn active-filter" data-type="all" onclick="filterNews('all')">All</button> 1793 <button class="btn btn-ghost news-filter-btn" data-type="chart" onclick="filterNews('chart')">ð Charts</button> 1794 <button class="btn btn-ghost news-filter-btn" data-type="news" onclick="filterNews('news')">ð° News</button> 1795 <button class="btn btn-ghost news-filter-btn" data-type="economic" onclick="filterNews('economic')">ð Economic</button> 1796 <button class="btn btn-ghost news-filter-btn" data-type="earnings" onclick="filterNews('earnings')">ð° Earnings</button> 1797 <button class="btn btn-ghost news-filter-btn" data-type="company_analysis" onclick="filterNews('company_analysis')">ð¢ Analysis</button> 1798 <button class="btn btn-ghost news-filter-btn" data-type="brief" onclick="filterNews('brief')">ð Daily Briefs</button> 1799 <button class="btn btn-ghost news-filter-btn" data-type="macro" onclick="filterNews('macro')">ð Macro</button> 1800 <button class="btn btn-ghost news-filter-btn" data-type="tradingview" onclick="filterNews('tradingview')">ð¡ Ideas</button> 1801 <button class="btn btn-ghost news-filter-btn" data-type="insider" onclick="filterNews('insider')">ð Insider</button> 1802 <button class="btn btn-ghost news-filter-btn" id="djt-filter-btn" data-type="djt" onclick="filterNews('djt')" style="display:none">ðºð¸ DJT Tweets</button> 1803 <button class="btn btn-ghost" style="margin-left:auto;font-size:.7rem;padding:5px 10px" onclick="loadNews()">â» Refresh</button> 1804 <button class="btn btn-ghost" style="font-size:.65rem;padding:4px 8px" title="Collapse all" onclick="collapseAllClusters()">â</button> 1805 </div> 1806 <!-- Ad slot: news-sidebar --> 1807 <div id="ad-news-sidebar" class="ad-slot" style="margin-bottom:14px"></div> 1808 1809 <div id="eco-calendar" style="display:none;margin-bottom:20px"> 1810 <div class="tradingview-widget-container" style="border-radius:10px;overflow:hidden"> 1811 <div class="tradingview-widget-container__widget"></div> 1812
1812<script type="text/javascript" 1813 src="https://s3.tradingview.com/external-embedding/embed-widget-events.js" async> 1814 { 1815 "colorTheme": "dark", 1816 "isTransparent": true, 1817 "width": "100%", 1818 "height": "600", 1819 "locale": "en", 1820 "importanceFilter": "0,1", 1821 "countryFilter": "us" 1822 } 1823 </script>
1823 1824 </div> 1825 </div> 1826 <div style="margin-bottom:12px"> 1827 <input id="news-search" type="text" placeholder="ð Search ticker or keyword across all categoriesâ¦" oninput="searchNews(this.value)" style="width:100%;background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:9px 14px;color:var(--text);font-size:.8rem;outline:none;box-sizing:border-box"> 1828 </div> 1829 <div id="news-list"><div class="card"><p>Loading news...</p></div></div> 1830</div> 1831 1832<!-- FX / CRYPTO --> 1833<div class="page" id="page-forex"> 1834 <div class="page-header"> 1835 <div class="page-title"><lord-icon src="https://cdn.lordicon.com/tyntlpjn.json" trigger="hover" stroke="bold" style="width:28px;height:28px"></lord-icon>FX / Crypto</div> 1836 <div class="page-sub">Live currency rates & top cryptocurrencies â auto-updating</div> 1837 </div> 1838 <div style="margin-bottom:8px;font-size:.7rem;font-weight:700;color:var(--muted);letter-spacing:2px">FOREX CROSS RATES</div> 1839 <div style="border-radius:10px;overflow:hidden;border:1px solid var(--border);height:500px;margin-bottom:20px"> 1840 <iframe src="https://www.widgets.investing.com/live-currency-cross-rates?theme=darkTheme&roundedCorners=true" 1841 width="100%" height="100%" frameborder="0" allowtransparency="true" marginwidth="0" marginheight="0" 1842 style="display:block"></iframe> 1843 </div> 1844 <div style="margin-bottom:8px;font-size:.7rem;font-weight:700;color:var(--muted);letter-spacing:2px">TOP CRYPTOCURRENCIES</div> 1845 <div style="border-radius:10px;overflow:hidden;border:1px solid var(--border);height:500px;margin-bottom:8px"> 1846 <iframe src="https://www.widgets.investing.com/top-cryptocurrencies?theme=darkTheme&roundedCorners=true" 1847 width="100%" height="100%" frameborder="0" allowtransparency="true" marginwidth="0" marginheight="0" 1848 style="display:block"></iframe> 1849 </div> 1850 <div style="font-size:.65rem;color:var(--muted);text-align:right"> 1851 Powered by <a href="https://www.investing.com/" target="_blank" rel="nofollow" style="color:var(--teal)">Investing.com</a> 1852 </div> 1853</div> 1854<!-- ADMIN PANEL --> 1855<!-- MOVERS --> 1856<div class="page" id="page-movers"> 1857 <div class="page-header"> 1858 <div class="page-title">
1858<lord-icon src="https://cdn.lordicon.com/mwhabkof.json" trigger="hover" style="width:28px;height:28px"></lord-icon>Pre-Market Top Movers</div> 1859 <div class="page-sub" style="margin-left:0">Top 10 gainers & losers before the opening bell â updated daily at 8am EST</div> 1860 </div> 1861 <div style="display:flex;align-items:center;gap:10px;margin-bottom:16px"> 1862 <div id="movers-meta" style="font-size:.72rem;color:var(--text2);font-family:var(--mono)"></div> 1863 </div> 1864 <div id="movers-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:16px"> 1865 <p style="font-size:.8rem;color:var(--text2);grid-column:1/-1">Loading...</p> 1866 </div> 1867</div> 1868 1869<!-- WATCHLIST --> 1870<div class="page" id="page-watchlist"> 1871 <div class="page-header"> 1872 <div class="page-title"><i data-feather="eye"></i><span data-i18n="watchlist.title">Watchlist</span></div> 1873 <div class="page-sub" data-i18n="watchlist.sub">Tickers que quieres seguir de cerca â sin comprar todavÃa</div> 1874 </div> 1875 <div style="display:flex;gap:8px;margin-bottom:18px;max-width:360px"> 1876 <input type="text" id="wl-ticker-input" placeholder="AAPL" style="flex:1;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:9px 12px;color:var(--text);font-size:.8rem;text-transform:uppercase" onkeydown="if(event.key==='Enter')addToWatchlist()"> 1877 <button class="btn btn-primary" onclick="addToWatchlist()" data-i18n="watchlist.add_btn">+ Agregar</button> 1878 </div> 1879 <div id="watchlist-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:10px"> 1880 <p style="font-size:.8rem;color:var(--text2);grid-column:1/-1" data-i18n="watchlist.loading">Cargando...</p> 1881 </div> 1882</div> 1883 1884<!-- FINANCIAL STATEMENTS --> 1885<div class="page" id="page-financials"> 1886 <div class="page-header"> 1887 <div class="page-title"><i data-feather="file-text"></i>Financial Statements</div> 1888 <div class="page-sub">GAAP statements â public companies via Sonar AI, or upload your own Excel/CSV</div> 1889 </div> 1890 1891 <!-- Mode toggle --> 1892 <div style="display:flex;gap:8px;margin-bottom:18px"> 1893 <button id="fin-mode-public" class="btn active-filter" onclick="setFinMode('public')">ð¢ Public Company</button> 1894 <button id="fin-mode-private" class="btn btn-ghost" onclick="setFinMode('private')">ð Private (Upload)</button> 1895 <button id="fin-mode-raw" class="btn btn-ghost" onclick="setFinMode('raw')">ð Raw Input</button> 1896 </div> 1897 1898 <!-- Public mode inputs --> 1899 <div id="fin-public-form" class="card" style="margin-bottom:14px"> 1900 <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end"> 1901 <div class="form-group" style="flex:1;min-width:180px;margin-bottom:0"> 1902 <label class="form-label">COMPANY / TICKER</label> 1903 <input id="fin-company" type="text" placeholder="Apple Inc or AAPL"> 1904 </div> 1905 <div class="form-group" style="margin-bottom:0"> 1906 <label class="form-label">PERIOD</label> 1907 <select id="fin-period"> 1908 <option value="annual">Annual</option> 1909 <option value="quarterly">Quarterly</option> 1910 <option value="ttm">Trailing 12 Months</option> 1911 </select> 1912 </div> 1913 <button class="btn btn-primary" onclick="generateFinancials()">Generate Statements</button> 1914 <button class="btn btn-ghost" onclick="clearFinForm('public')" style="font-size:.75rem">â Clear</button> 1915 </div> 1916 </div> 1917 1918 <!-- Private mode inputs --> 1919 <div id="fin-private-form" class="card" style="margin-bottom:14px;display:none"> 1920 <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end"> 1921 <div class="form-group" style="flex:1;min-width:180px;margin-bottom:0"> 1922 <label class="form-label">COMPANY NAME</label> 1923 <input id="fin-private-company" type="text" placeholder="My Company LLC"> 1924 </div> 1925 <div class="form-group" style="margin-bottom:0"> 1926 <label class="form-label">PERIOD</label> 1927 <select id="fin-private-period"> 1928 <option value="annual">Annual</option> 1929 <option value="quarterly">Quarterly</option> 1930 </select> 1931 </div> 1932 <label class="btn btn-ghost" style="cursor:pointer;margin-bottom:0" title="Photo of handwritten statements â Claude reads it â pre-filled Excel"> 1933 ð· Scan Photo 1934 <input type="file" id="fin-scan-file" accept="image/*" style="display:none" onchange="scanFinancialPhoto(this)"> 1935 </label>
1936 <span id="fin-scan-status" style="font-size:.72rem;color:var(--muted);align-self:center"></span> 1937 <a class="btn btn-ghost" href="/api/financials/template" download style="margin-bottom:0">⬠Template</a> 1938 <label class="btn btn-ghost" style="cursor:pointer;margin-bottom:0"> 1939 ð Upload Excel/CSV 1940 <input type="file" id="fin-file" accept=".xlsx,.xls,.csv" style="display:none" onchange="$('fin-file-name').textContent=this.files[0]?.name||''"> 1941 </label> 1942 <span id="fin-file-name" style="font-size:.72rem;color:var(--text2);align-self:center"></span> 1943 <button class="btn btn-primary" onclick="generateFinancialsPrivate()">Generate Statements</button> 1944 <button class="btn btn-ghost" onclick="clearFinForm('private')" style="font-size:.75rem">â Clear</button> 1945 </div> 1946 </div> 1947 1948 <!-- Raw input form --> 1949 <div id="fin-raw-form" class="card" style="margin-bottom:14px;display:none"> 1950 <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:10px"> 1951 <div class="form-group" style="flex:1;min-width:180px;margin-bottom:0"> 1952 <label class="form-label">COMPANY NAME</label> 1953 <input id="fin-raw-company" type="text" placeholder="My Company LLC"> 1954 </div> 1955 <div class="form-group" style="margin-bottom:0"> 1956 <label class="form-label">PERIOD</label> 1957 <select id="fin-raw-period"> 1958 <option value="annual">Annual</option> 1959 <option value="quarterly">Quarterly</option> 1960 <option value="ttm">Trailing 12 Months</option> 1961 </select> 1962 </div> 1963 <button class="btn btn-primary" onclick="generateFinancialsRaw()">Generate Statements</button> 1964 <button class="btn btn-ghost" onclick="clearFinForm('raw')" style="font-size:.75rem">â Clear</button> 1965 </div> 1966 <div class="form-group" style="margin-bottom:0"> 1967 <label class="form-label">PASTE RAW FINANCIAL DATA</label> 1968 <textarea id="fin-raw-text" rows="10" placeholder="Paste anything â account balances, journal entries, gains and expenses, ledger export... Sonar maps it to GAAP categories. Example: Sales Revenue: 1,200,000 Cost of goods sold: 700,000 Office rent: 50,000 Salaries: 300,000 Cash in bank: 180,000 Accounts receivable: 90,000 Long-term bank loan: 400,000" style="width:100%;resize:vertical;background:var(--input);border:1px solid var(--border);border-radius:6px;padding:10px;color:var(--text);font-size:.8rem;font-family:var(--mono)"></textarea> 1969 </div> 1970 </div> 1971 1972 <div id="fin-status" style="font-size:.78rem;color:var(--muted);margin-bottom:10px"></div> 1973 1974 <!-- Results â 4 tabs --> 1975 <div id="fin-results" style="display:none"> 1976 <div style="display:flex;gap:6px;margin-bottom:12px;flex-wrap:wrap"> 1977 <button class="btn fin-tab active-filter" data-tab="balance" onclick="showFinTab('balance')">Balance Sheet</button> 1978 <button class="btn btn-ghost fin-tab" data-tab="income" onclick="showFinTab('income')">Income Statement</button> 1979 <button class="btn btn-ghost fin-tab" data-tab="cashflow" onclick="showFinTab('cashflow')">Cash Flow</button> 1980 <button class="btn btn-ghost fin-tab" data-tab="equity" onclick="showFinTab('equity')">Shareholders' Equity</button> 1981 <button class="btn btn-ghost fin-tab" data-tab="summary" onclick="showFinTab('summary')">Summary</button> 1982 </div> 1983 <div id="fin-tab-content" style="background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px;overflow-x:auto"></div> 1984 <div style="display:flex;gap:8px;margin-top:10px"> 1985 <button class="btn btn-ghost" onclick="downloadFinTab()" style="font-size:.75rem">⬠Download CSV</button> 1986 <button class="btn btn-ghost" onclick="newFinStatement()" style="font-size:.75rem">+ New Statement</button> 1987 </div> 1988 </div> 1989 1990 <!-- History log --> 1991 <div id="fin-history-wrap" style="margin-top:28px;display:none"> 1992 <div style="font-size:.65rem;color:var(--muted);letter-spacing:1px;margin-bottom:10px">PREVIOUSLY GENERATED</div> 1993 <div id="fin-history-list"></div> 1994 <button id="fin-history-more" class="btn btn-ghost" onclick="loadFinHistory(true)" style="display:none;margin-top:8px;font-size:.75rem">Load more</button> 1995 </div> 1996 1997 <!-- A/B Comparison --> 1998 <div id="fin-compare-wrap" style="display:none;margin-top:20px;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:16px"> 1999 <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap">
2000 <span style="font-size:.65rem;color:var(--muted);letter-spacing:1px">COMPARISON</span> 2001 <span id="fin-compare-label" style="font-size:.78rem;font-weight:700;color:var(--text);font-family:var(--mono);flex:1"></span> 2002 <button class="btn btn-ghost" style="font-size:.65rem;padding:3px 8px" onclick="downloadFinCompareCSV()">⬠CSV</button> 2003 <button class="btn btn-ghost" style="font-size:.65rem;padding:3px 8px" onclick="clearFinCompare()">â Clear</button> 2004 </div> 2005 <div style="display:flex;gap:6px;margin-bottom:12px;flex-wrap:wrap"> 2006 <button id="fcmp-tab-balance" class="btn active-filter" style="font-size:.7rem;padding:4px 10px" onclick="showFinCompareTab('balance')">Balance Sheet</button> 2007 <button id="fcmp-tab-income" class="btn btn-ghost" style="font-size:.7rem;padding:4px 10px" onclick="showFinCompareTab('income')">Income Stmt</button> 2008 <button id="fcmp-tab-cashflow" class="btn btn-ghost" style="font-size:.7rem;padding:4px 10px" onclick="showFinCompareTab('cashflow')">Cash Flow</button> 2009 <button id="fcmp-tab-equity" class="btn btn-ghost" style="font-size:.7rem;padding:4px 10px" onclick="showFinCompareTab('equity')">Equity</button> 2010 </div> 2011 <div id="fin-compare-table"></div> 2012 </div> 2013</div> 2014 2015<!-- SECTOR HEATMAP --> 2016<div class="page" id="page-heatmap"> 2017 <div class="page-header"> 2018 <div class="page-title"><i data-feather="grid"></i>Heatmaps</div> 2019 <div class="page-sub">Stocks · Crypto â TradingView</div> 2020 </div> 2021 <div class="macro-tabs" style="margin-bottom:14px"> 2022 <button class="macro-tab active" onclick="heatmapTab('stock')">ð Stocks</button> 2023 <button class="macro-tab" onclick="heatmapTab('crypto')">ðª Crypto</button> 2024 </div> 2025 2026 <div id="heatmap-pane-stock" class="macro-pane active" style="height:700px"> 2027 <div style="text-align:right;margin-bottom:6px"><a href="https://in.tradingview.com/?aff_id=164648" target="_blank" rel="noopener" style="display:inline-block;background:#1a2340;border:1px solid #E2C275;border-radius:20px;padding:4px 12px;font-size:.68rem;color:#E2C275;text-decoration:none;letter-spacing:.3px">ð Get <strong>$15 USD off</strong> TradingView â Open here â</a></div> 2028 <div class="tradingview-widget-container" style="height:calc(100% - 32px);background:#0b132b;border:1px solid #202e56;border-radius:10px;overflow:hidden"> 2029 <div class="tradingview-widget-container__widget" style="height:calc(100% - 18px)"></div> 2030 <div class="tradingview-widget-copyright" style="font-size:.6rem;color:var(--muted);opacity:.5"><a href="https://www.tradingview.com/" rel="noopener nofollow" target="_blank" style="color:var(--muted);text-decoration:none">Stock Heatmap by TradingView</a></div> 2031
2031<script type="text/javascript" src="https://s3.tradingview.com/external-embedding/embed-widget-stock-heatmap.js" async> 2032 {"dataSource":"SPX500","blockSize":"volume","blockColor":"change","grouping":"sector","locale":"en","symbolUrl":"","colorTheme":"dark","exchanges":[],"hasTopBar":false,"isDataSetEnabled":false,"isZoomEnabled":true,"hasSymbolTooltip":true,"isMonoSize":false,"width":"100%","height":"100%","backgroundColor":"#0b132b"} 2033 </script>
2033 2034 </div> 2035 </div> 2036 2037 <div id="heatmap-pane-crypto" class="macro-pane" style="height:700px"> 2038 <div style="text-align:right;margin-bottom:6px"><a href="https://in.tradingview.com/?aff_id=164648" target="_blank" rel="noopener" style="display:inline-block;background:#1a2340;border:1px solid #E2C275;border-radius:20px;padding:4px 12px;font-size:.68rem;color:#E2C275;text-decoration:none;letter-spacing:.3px">ð Get <strong>$15 USD off</strong> TradingView â Open here â</a></div> 2039 <div class="tradingview-widget-container" style="height:calc(100% - 32px);background:#0b132b;border:1px solid #202e56;border-radius:10px;overflow:hidden"> 2040 <div class="tradingview-widget-container__widget" style="height:calc(100% - 18px)"></div> 2041 <div class="tradingview-widget-copyright" style="font-size:.6rem;color:var(--muted);opacity:.5"><a href="https://www.tradingview.com/" rel="noopener nofollow" target="_blank" style="color:var(--muted);text-decoration:none">Crypto Heatmap by TradingView</a></div> 2042
2042<script type="text/javascript" src="https://s3.tradingview.com/external-embedding/embed-widget-crypto-coins-heatmap.js" async> 2043 {"dataSource":"Crypto","blockSize":"24h_vol_cmc","blockColor":"24h_close_change|5","locale":"en","symbolUrl":"","colorTheme":"dark","hasTopBar":true,"isDataSetEnabled":true,"isZoomEnabled":true,"hasSymbolTooltip":true,"isMonoSize":false,"width":"100%","height":"100%","backgroundColor":"#0b132b"} 2044 </script>
2044 2045 </div> 2046 </div> 2047 2048 <!-- Legacy heatmap (kept, not rendered) 2049 <div class="card"> 2050 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px"> 2051 <span id="heatmap-as-of" style="font-size:.65rem;color:var(--muted);font-family:var(--mono)"></span> 2052 <button class="btn btn-ghost" onclick="loadHeatmap()" style="font-size:.72rem">â» Refresh</button> 2053 </div> 2054 <div id="heatmap-status" style="font-size:.75rem;color:var(--muted);margin-bottom:10px"></div> 2055 <div id="heatmap-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px"></div> 2056 </div> 2057 --> 2058 <div id="hm-tooltip" style="display:none"></div> 2059</div> 2060 2061<!-- TRADING PERFORMANCE (MyFXBook) --> 2062<div class="page" id="page-trading"> 2063 <div class="page-header"> 2064 <div class="page-title"><i data-feather="activity"></i> Trading</div> 2065 <div class="page-sub">Live MT4/MT5 performance via MyFXBook â generate & share performance cards</div> 2066 </div> 2067 2068 <!-- Connect section (shown when not connected) --> 2069 <div id="mfx-connect-section" style="display:none;max-width:460px;margin:0 auto;padding:32px 0"> 2070 <div style="background:var(--bg2);border:1px solid var(--border);border-radius:14px;padding:28px 28px 24px"> 2071 <div style="font-size:.7rem;letter-spacing:2px;color:var(--muted);font-family:var(--mono);margin-bottom:12px">CONNECT MYFXBOOK</div> 2072 <p style="font-size:.85rem;color:var(--text2);margin-bottom:20px;line-height:1.6">Enter your MyFXBook credentials to pull live performance data from your linked MT4/MT5 accounts. Your password is never stored â only an access session token is kept.</p> 2073 <div style="display:flex;flex-direction:column;gap:12px"> 2074 <input id="mfx-email" type="email" placeholder="MyFXBook email" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:10px 14px;font-size:.85rem;width:100%;box-sizing:border-box"> 2075 <input id="mfx-password" type="password" placeholder="MyFXBook password" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:10px 14px;font-size:.85rem;width:100%;box-sizing:border-box"> 2076 <button class="btn btn-primary" style="width:100%;justify-content:center" onclick="mfxConnect()">Connect MyFXBook â</button> 2077 </div> 2078 <p style="font-size:.7rem;color:var(--muted);margin-top:14px;text-align:center">Don't have an account? <a href="https://www.myfxbook.com" target="_blank" style="color:var(--teal)">Sign up free at myfxbook.com</a></p> 2079 </div> 2080 </div> 2081 2082 <!-- Connected section --> 2083 <div id="mfx-connected-section" style="display:none"> 2084 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;flex-wrap:wrap;gap:10px"> 2085 <div id="mfx-connected-info" style="font-size:.8rem;color:var(--muted);font-family:var(--mono)"></div> 2086 <div style="display:flex;gap:8px"> 2087 <button class="btn btn-ghost" style="font-size:.75rem" onclick="mfxLoadAccounts()">â» Refresh</button> 2088 <button class="btn btn-ghost" style="font-size:.75rem;color:var(--muted)" onclick="mfxDisconnect()">Disconnect</button> 2089 </div> 2090 </div> 2091 <div id="mfx-accounts-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px"></div> 2092 </div> 2093 2094 <!-- Loading / error state --> 2095 <div id="mfx-status" style="display:none;text-align:center;padding:40px;color:var(--muted);font-size:.85rem"></div> 2096 2097 <!-- Performance card canvas (hidden, used for export) --> 2098 <canvas id="mfx-card-canvas" style="display:none"></canvas> 2099</div> 2100 2101<!-- PORTFOLIO RISK / VaR --> 2102<div class="page" id="page-var"> 2103 <div class="page-header"> 2104 <div class="page-title"><lord-icon src="https://cdn.lordicon.com/knitbwfa.json" trigger="hover" stroke="bold" state="hover-look-around" style="width:28px;height:28px"></lord-icon>Portfolio Risk</div> 2105 <div class="page-sub">Value at Risk · CVaR · Monte Carlo · Max Drawdown · Efficient Frontier</div> 2106 </div> 2107 2108 <!-- Input card --> 2109 <div class="card" style="margin-bottom:16px"> 2110 <div style="display:flex;gap:24px;align-items:flex-start"> 2111 <!-- Left: ticker inputs + controls --> 2112 <div style="flex:0 0 auto"> 2113 <div style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:14px">PORTFOLIO</div> 2114 <div id="var-ticker-rows" style="display:flex;flex-direction:column;gap:10px;margin-bottom:12px"></div> 2115 <button class="btn btn-ghost" style="font-size:.75rem" onclick="addVarTicker()">+ Add Ticker</button> 2116 <div style="display:flex;flex-wrap:wrap;gap:12px;margin-top:18px;align-items:flex-end"> 2117 <div style="display:flex;flex-direction:column;gap:4px"> 2118 <label style="font-size:.72rem;color:var(--muted)">Portfolio Value (USD)</label> 2119 <input id="var-portfolio-value" type="number" value="10000" min="1" class="input" style="width:160px"> 2120 </div> 2121 <div style="display:flex;flex-direction:column;gap:4px"> 2122 <label style="font-size:.72rem;color:var(--muted)">Confidence Level</label> 2123 <select id="var-confidence" class="input" style="width:120px"> 2124 <option value="0.99">99%</option> 2125 <option value="0.95">95%</option> 2126 <option value="0.90">90%</option> 2127 </select> 2128 </div> 2129 <button id="var-run-btn" class="btn btn-primary" onclick="runVarAnalysis()">Run Analysis</button> 2130 <button id="var-opt-btn" class="btn btn-ghost" onclick="runPortfolioOptimize()" style="border-color:var(--teal);color:var(--teal)">â¡ Optimize</button> 2131 </div> 2132 <div id="var-error" style="display:none;margin-top:10px;font-size:.78rem;color:var(--red)"></div> 2133 </div> 2134 <!-- Right: latest prices widget --> 2135 <div style="flex:1;min-width:180px;border-left:1px solid var(--border);padding-left:20px"> 2136 <div style="font-size:.65rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:10px;display:flex;justify-content:space-between;align-items:center"> 2137 LATEST PRICES 2138 <button class="btn btn-ghost" style="font-size:.65rem;padding:2px 8px" onclick="fetchVarPrices()">â»</button> 2139 </div> 2140 <div id="var-price-rows"><div style="font-size:.75rem;color:var(--muted)">â</div></div> 2141 </div> 2142 </div> 2143 </div> 2144 2145 <div id="var-loading" style="display:none"> 2146 <div style="text-align:center;padding:52px 20px;border:1px solid var(--border);border-radius:12px;background:var(--bg2);margin-bottom:16px"> 2147 <div style="display:flex;justify-content:center;margin-bottom:18px"> 2148 <div style="width:40px;height:40px;border:3px solid rgba(0,200,83,.15);border-top-color:var(--teal);border-radius:50%;animation:spin .8s linear infinite"></div> 2149 </div> 2150 <div style="font-family:var(--mono);font-size:.68rem;letter-spacing:3px;color:var(--teal);margin-bottom:8px">RUNNING MONTE CARLO</div> 2151 <div style="font-size:.8rem;color:var(--text2)">Simulating 5,000 portfolio pathsâ¦</div> 2152 <div style="font-size:.72rem;color:var(--muted);margin-top:6px">This takes 5â15 seconds</div> 2153 </div> 2154 </div> 2155 2156 <!-- Results --> 2157 <div id="var-results" style="display:none"> 2158 <!-- Metrics grid --> 2159 <div style="background:rgba(0,200,83,0.05);border:1px solid rgba(0,200,83,0.15);border-radius:8px;padding:10px 14px;margin-bottom:16px;font-size:.78rem;color:var(--text2);line-height:1.6"> 2160 <strong style="color:var(--green)">VaR</strong> = the most you could lose in one day with 99% confidence. 2161 <strong style="color:#ff6b6b">CVaR</strong> = average loss on the worst days beyond VaR. 2162 <strong style="color:var(--yellow)">Max Drawdown</strong> = biggest peak-to-trough drop over 1 year. 2163 </div> 2164 <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;margin-bottom:20px" id="var-metrics-grid"></div> 2165 2166 <!-- Charts --> 2167 <div class="card" style="margin-bottom:16px"> 2168 <div style="margin-bottom:12px"> 2169 <div style="font-size:.65rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:5px">MONTE CARLO SIMULATION PATHS</div> 2170 </div> 2171 <div id="var-chart-paths" style="width:100%;height:280px"></div> 2172 </div> 2173 <div class="card"> 2174 <div style="margin-bottom:12px"> 2175 <div style="font-size:.65rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:5px">RETURN DISTRIBUTION</div> 2176 </div> 2177 <div id="var-chart-dist" style="width:100%;height:240px"></div> 2178 </div> 2179 </div> 2180 2181 <!-- Optimization Results --> 2182 <div id="var-optimize-results" style="display:none;margin-top:20px"> 2183 <div style="font-size:.65rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:14px">PORTFOLIO OPTIMIZATION â EFFICIENT FRONTIER</div> 2184 <!-- 2 optimal portfolio cards --> 2185 <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px" id="var-opt-cards"></div> 2186 <!-- Efficient frontier chart --> 2187 <div class="card" style="margin-bottom:16px"> 2188 <div style="font-size:.65rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:10px">EFFICIENT FRONTIER</div> 2189 <div id="var-chart-frontier" style="width:100%;height:300px"></div> 2190 </div> 2191 <!-- Weights comparison table --> 2192 <div class="card"> 2193 <div style="font-size:.65rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:12px">WEIGHT COMPARISON</div> 2194 <div id="var-opt-table"></div> 2195 </div> 2196 </div> 2197 <div id="var-opt-loading" style="display:none"> 2198 <div style="text-align:center;padding:52px 20px;border:1px solid var(--border);border-radius:12px;background:var(--bg2);margin-top:20px"> 2199 <div style="display:flex;justify-content:center;margin-bottom:18px"> 2200 <div style="width:40px;height:40px;border:3px solid rgba(226,194,117,.15);border-top-color:#e2c275;border-radius:50%;animation:spin .8s linear infinite"></div> 2201 </div> 2202 <div style="font-family:var(--mono);font-size:.68rem;letter-spacing:3px;color:#e2c275;margin-bottom:8px">OPTIMIZING PORTFOLIO</div> 2203 <div style="font-size:.8rem;color:var(--text2)">Computing efficient frontierâ¦</div> 2204 <div style="font-size:.72rem;color:var(--muted);margin-top:6px">Calculating Sharpe ratios & min-volatility weights</div> 2205 </div> 2206 </div> 2207 <div id="var-opt-error" style="display:none;margin-top:10px;font-size:.78rem;color:var(--red)"></div> 2208</div> 2209 2210<!-- OPTIONS FLOW --> 2211<div class="page" id="page-options"> 2212 <div class="page-header"> 2213 <div class="page-title"><i data-feather="zap" style="width:20px;height:20px;margin-right:8px;vertical-align:middle"></i>Options Flow</div> 2214 <div class="page-sub" style="margin-left:0">Unusual options activity â high vol/OI contracts flagged across 11,000+ t
2214ickers, scanned 4à daily MonâFri.</div> 2215 </div> 2216 2217 <!-- Filter bar --> 2218 <div style="display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap"> 2219 <div style="display:flex;gap:0;border:1px solid var(--border);border-radius:6px;overflow:hidden;font-family:var(--mono);font-size:.68rem"> 2220 <button id="opt-f-all" onclick="optSetType('')" style="padding:5px 12px;background:var(--teal);color:#000;border:none;cursor:pointer;font-family:inherit;font-weight:700">ALL</button> 2221 <button id="opt-f-call" onclick="optSetType('call')" style="padding:5px 12px;background:transparent;color:var(--text2);border:none;cursor:pointer;font-family:inherit">CALLS</button> 2222 <button id="opt-f-put" onclick="optSetType('put')" style="padding:5px 12px;background:transparent;color:var(--text2);border:none;cursor:pointer;font-family:inherit">PUTS</button> 2223 </div> 2224 <input id="opt-f-ticker" placeholder="Tickerâ¦" maxlength="8" 2225 style="width:90px;padding:5px 10px;background:var(--bg2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.78rem;font-family:var(--mono);text-transform:uppercase" 2226 oninput="this.value=this.value.toUpperCase()" onkeydown="if(event.key==='Enter')loadOptionsPage()"> 2227 <select id="opt-f-premium" onchange="loadOptionsPage()" 2228 style="padding:5px 10px;background:var(--bg2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.78rem;cursor:pointer"> 2229 <option value="0">Any premium</option> 2230 <option value="100000">$100K+</option> 2231 <option value="500000">$500K+</option> 2232 <option value="1000000">$1M+</option> 2233 <option value="5000000">$5M+</option> 2234 </select> 2235 <select id="opt-f-hours" onchange="loadOptionsPage()" 2236 style="padding:5px 10px;background:var(--bg2);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:.78rem;cursor:pointer"> 2237 <option value="24">Last 24h</option> 2238 <option value="48">Last 48h</option> 2239 <option value="168">Last 7 days</option> 2240 </select> 2241 <button onclick="loadOptionsPage()" style="padding:5px 12px;background:transparent;border:1px solid var(--border);border-radius:6px;color:var(--text2);cursor:pointer;font-size:.72rem">â» Refresh</button> 2242 <div id="opt-page-count" style="font-size:.7rem;color:var(--muted);font-family:var(--mono);margin-left:4px"></div> 2243 </div> 2244 2245 <!-- Sentiment summary bar --> 2246 <div id="opt-sentiment-bar" style="display:none;margin-bottom:14px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:10px 16px;display:flex;gap:24px;flex-wrap:wrap"> 2247 <div> 2248 <div style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:1px;margin-bottom:2px">CALL PREMIUM</div> 2249 <div id="opt-call-total" style="font-size:.95rem;font-weight:700;color:var(--green);font-family:var(--mono)">â</div> 2250 </div> 2251 <div> 2252 <div style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:1px;margin-bottom:2px">PUT PREMIUM</div> 2253 <div id="opt-put-total" style="font-size:.95rem;font-weight:700;color:var(--red);font-family:var(--mono)">â</div> 2254 </div> 2255 <div> 2256 <div style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:1px;margin-bottom:2px">SENTIMENT</div> 2257 <div id="opt-sentiment-label" style="font-size:.95rem;font-weight:700;font-family:var(--mono)">â</div> 2258 </div> 2259 </div> 2260 2261 <div id="opt-page-panel" style="overflow-x:auto"> 2262 <div class="ov-skel"></div><div class="ov-skel"></div><div class="ov-skel"></div><div class="ov-skel"></div><div class="ov-skel"></div> 2263 </div> 2264</div> 2265 2266<!-- POLYMARKET --> 2267<div class="page" id="page-polymarket"> 2268 <div class="page-header"> 2269 <div class="page-title"><i data-feather="target"></i>Polymarket</div> 2270 <div class="page-sub">Prediction markets â open interest, live volume, order book alerts</div> 2271 </div> 2272 2273 <!-- status bar --> 2274 <div style="display:flex;gap:12px;align-items:center;margin-bottom:16px;font-size:.75rem;color:var(--text2);font-family:var(--mono)"> 2275 <span id="pm-monitor-dot" style="color:#888">â checkingâ¦</span> 2276 <span id="pm-alerts-today-label"></span> 2277 <span style="flex:1"></span> 2278 <button class="btn btn-ghost" style="font-size:.7rem;padding:3px 10px" onclick="pmRefreshAll()">↻ Refresh</button> 2279 </div> 2280
2281 <!-- live alert toast strip --> 2282 <div id="pm-live-strip" style="display:none;padding:8px 14px;background:rgba(0,200,83,.07);border:1px solid rgba(0,200,83,.25);border-radius:8px;margin-bottom:14px;font-size:.77rem;color:#00c853;font-family:var(--mono)"> 2283 <span id="pm-live-text"></span> 2284 </div> 2285 2286 <!-- tabs --> 2287 <div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:18px"> 2288 <button id="pm-tab-markets" class="btn btn-ghost pm-tab pm-tab-active" style="border-radius:6px 6px 0 0;border-bottom:none;font-size:.74rem" onclick="pmTab('markets')">Markets</button> 2289 <button id="pm-tab-volume" class="btn btn-ghost pm-tab" style="border-radius:6px 6px 0 0;border-bottom:none;font-size:.74rem" onclick="pmTab('volume')">Volume Tracker</button> 2290 <button id="pm-tab-book" class="btn btn-ghost pm-tab" style="border-radius:6px 6px 0 0;border-bottom:none;font-size:.74rem" onclick="pmTab('book')">Order Book</button> 2291 <button id="pm-tab-alerts" class="btn btn-ghost pm-tab" style="border-radius:6px 6px 0 0;border-bottom:none;font-size:.74rem" onclick="pmTab('alerts')">My Alerts</button> 2292 </div> 2293 2294 <!-- tab: Markets --> 2295 <div id="pm-panel-markets"> 2296 <p style="font-size:.73rem;color:var(--text2);margin-bottom:12px">Top 50 active markets by 24h volume. Click <b>Watch</b> to track a market and receive focused alerts.</p> 2297 <div id="pm-markets-list"><p style="font-size:.8rem;color:var(--text2)">Loadingâ¦</p></div> 2298 </div> 2299 2300 <!-- tab: Volume Tracker --> 2301 <div id="pm-panel-volume" style="display:none"> 2302 <p style="font-size:.73rem;color:var(--text2);margin-bottom:12px">Live 24h volume â top 10 markets. Auto-refreshes every 60 s.</p> 2303 <canvas id="pm-vol-chart" height="260" style="max-width:100%"></canvas> 2304 <div id="pm-vol-empty" style="display:none;font-size:.8rem;color:var(--text2)">No market data yet.</div> 2305 </div> 2306 2307 <!-- tab: Order Book --> 2308 <div id="pm-panel-book" style="display:none"> 2309 <div style="display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap"> 2310 <select id="pm-book-select" style="flex:1;
2310min-width:200px;font-size:.77rem;background:var(--bg2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 10px" onchange="loadPmBook()"> 2311 <option value="">â select a market â</option> 2312 </select> 2313 <button class="btn btn-ghost" style="font-size:.72rem;padding:4px 12px" onclick="loadPmBook()">↻ Fetch</button> 2314 </div> 2315 <div id="pm-book-result"></div> 2316 </div> 2317 2318 <!-- tab: Alerts --> 2319 <div id="pm-panel-alerts" style="display:none"> 2320 <div style="display:flex;gap:10px;align-items:center;margin-bottom:14px"> 2321 <div style="font-size:.73rem;color:var(--text2);flex:1">Recent global alerts fired by the monitor. SSE stream is active while this page is open.</div> 2322 <button class="btn btn-ghost" style="font-size:.72rem;padding:4px 12px" onclick="loadPmAlerts()">↻ Refresh</button> 2323 </div> 2324 <div id="pm-alerts-list"><p style="font-size:.8rem;color:var(--text2)">Loadingâ¦</p></div> 2325 2326 <div style="margin-top:20px;border-top:1px solid var(--border);padding-top:14px"> 2327 <div style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:10px">WATCHED MARKETS</div> 2328 <div id="pm-watches-list"><p style="font-size:.8rem;color:var(--text2)">Loadingâ¦</p></div> 2329 </div> 2330 </div> 2331</div> 2332 2333<!-- TRENDS --> 2334<div class="page" id="page-trends"> 2335 <div class="page-header"> 2336 <div class="page-title"><lord-icon src="https://cdn.lordicon.com/iuvnsegf.json" trigger="hover" stroke="bold" state="hover-rotation" style="width:28px;height:28px"></lord-icon>Google Trends</div> 2337 <div class="page-sub" style="margin-left:0">Top trending searches â updated every 30 minutes</div> 2338 </div> 2339 <div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;flex-wrap:wrap"> 2340 <select id="trends-geo" onchange="_trendsLoaded=false;loadTrends()" style="font-family:var(--mono);font-size:.78rem;background:var(--bg2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 10px"> 2341 <option value="US">ðºð¸ United States</option> 2342 <option value="GB">ð¬ð§ United Kingdom</option> 2343 <option value="CA">ð¨ð¦ Canada</option> 2344 <option value="AU">ð¦ðº Australia</option> 2345 <option value="IN">ð®ð³ India</option> 2346 </select> 2347 <div id="trends-meta" style="font-size:.72rem;color:var(--text2);font-family:var(--mono)"></div> 2348 <button class="btn btn-ghost" style="font-size:.7rem;padding:3px 10px;margin-left:auto" onclick="loadTrends(true)">↻ Refresh</button> 2349 </div> 2350 <div id="trends-list"><p style="font-size:.8rem;color:var(--text2)">Loading...</p></div> 2351 <div style="margin-top:20px;font-size:.68rem;color:var(--muted);font-family:var(--mono)"> 2352 Powered by <a href="https://trends.google.com" target="_blank" rel="noopener" style="color:var(--teal)">Google Trends</a> 2353 </div> 2354</div> 2355 2356<!-- MACRO DASHBOARD --> 2357<div class="page" id="page-macro"> 2358 <div class="page-header"> 2359 <div class="page-title"><lord-icon src="https://cdn.lordicon.com/tyntlpjn.json" trigger="hover" stroke="bold" style="width:28px;height:28px"></lord-icon>Macro Dashboard</div> 2360 <div class="page-sub">FRED Economic Indicators â real-time macro regime analysis</div> 2361 </div> 2362 <div class="macro-tabs"> 2363 <button class="macro-tab active" onclick="macroTab('scorecard')">Scorecard</button> 2364 <button class="macro-tab" onclick="macroTab('charts')">Charts</button> 2365 <button class="macro-tab" onclick="macroTab('compare')">Compare</button> 2366 <button class="macro-tab" onclick="macroTab('aibrief')">AI Brief</button> 2367 <button class="macro-tab" onclick="macroTab('releases')">Calendar</button> 2368 <button class="macro-tab" onclick="macroTab('alerts')">Alerts</button> 2369 </div> 2370 2371 <!-- Scorecard --> 2372 <div id="macro-pane-scorecard" class="macro-pane active"> 2373 <div style="display:flex;align-items:center;gap:10px;margin-bottom:14px"> 2374 <span style="font-size:.65rem;color:var(--muted);font-family:var(--mono)">10 KEY INDICATORS · FRED DATA · 15 MIN CACHE</span> 2375 <button class="btn btn-ghost" style="font-size:.7rem;padding:3px 10px;margin-left:auto" onclick="loadMacroScorecard(true)">↻ Refresh</button> 2376 </div> 2377 <div class="macro-scorecard-grid" id="macro-scorecard-grid"> 2378 <p style="font-size:.8rem;color:var(--text2);grid-column:1/-1">Loading scorecard...</p> 2379 </div> 2380 </div> 2381 <div id="mc-tooltip" style="display:none"></div> 2382 2383 <!-- Charts --> 2384 <div id="macro-pane-charts" class="macro-pane"> 2385 <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:14px"> 2386 <div class="form-group" style="flex:1;min-width:180px"> 2387 <label class="form-label">SERIES</label> 2388 <select id="macro-chart-series" style="width:100%;font-family:var(--mono);font-size:.78rem;background:var(--bg2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 10px"></select> 2389 </div> 2390 <div class="form-group"> 2391 <label class="form-label">TRANSFORM</label> 2392 <select id="macro-chart-transform" style="font-family:var(--mono);font-size:.78rem;background:var(--bg2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 10px"> 2393 <option value="level">Level</option> 2394 <option value="yoy">YoY %</option> 2395 <option value="mom">MoM %</option> 2396 </select> 2397 </div> 2398 <div class="form-group"> 2399 <label class="form-label">PERIODS</label> 2400 <select id="macro-chart-limit" style="font-family:var(--mono);font-size:.78rem;background:var(--bg2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 10px"> 2401 <option value="60">5 Years</option> 2402 <option value="120" selected>10 Years</option> 2403 <option value="300">25 Years</option> 2404 <option value="500">All</option> 2405 </select> 2406 </div> 2407 <div class="form-group" style="display:flex;align-items:center;gap:6px;padding-bottom:2px"> 2408 <input type="checkbox" id="macro-chart-recession" checked style="accent-color:var(--teal)"> 2409 <label style="font-size:.72rem;color:var(--text2);cursor:pointer" for="macro-chart-recession">Recession shading</label> 2410 </div> 2411 <button class="btn btn-primary" style="padding:5px 16px;font-size:.78rem" onclick="loadMacroChart()">Draw</button> 2412 </div> 2413 <div id="macro-chart-wrap" style="background:var(--bg2);border:1px solid var(--border);border-radius:10px;min-height:200px;display:flex;align-items:center;justify-content:center;overflow:hidden"> 2414 <p style="font-size:.8rem;color:var(--text2)">Select a series and click Draw</p> 2415 </div> 2416 <div style="margin-top:10px"> 2417 <button id="macro-chart-export" class="btn btn-ghost" style="font-size:.72rem;padding:4px 12px;display:none" onclick="downloadMacroChart()">⇓ Download Chart PNG</button> 2418 </div> 2419 <!-- Raw observations table --> 2420 <div id="macro-chart-table-wrap" style="display:none;margin-top:16px"> 2421 <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
2422 <span style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px" id="macro-table-meta"></span> 2423 <button class="btn btn-ghost" style="font-size:.7rem;padding:3px 10px;margin-left:auto" onclick="downloadFredCSV()">↓ Download CSV</button> 2424 </div> 2425 <div style="max-height:280px;overflow-y:auto;border:1px solid var(--border);border-radius:8px"> 2426 <table style="width:100%;border-collapse:collapse;font-family:var(--mono);font-size:.75rem"> 2427 <thead> 2428 <tr style="background:var(--bg2);position:sticky;top:0"> 2429 <th style="text-align:left;padding:7px 14px;color:var(--text2);font-weight:600;border-bottom:1px solid var(--border)">DATE</th> 2430 <th style="text-align:right;padding:7px 14px;color:var(--text2);font-weight:600;border-bottom:1px solid var(--border)" id="macro-table-unit-hdr">VALUE</th> 2431 </tr> 2432 </thead> 2433 <tbody id="macro-chart-table-body"></tbody> 2434 </table> 2435 </div> 2436 </div> 2437 </div> 2438 2439 <!-- Compare --> 2440 <div id="macro-pane-compare" class="macro-pane"> 2441 <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:14px"> 2442 <div class="form-group" style="flex:1;min-width:150px"> 2443 <label class="form-label">SERIES 1</label> 2444 <select id="macro-cmp-s1" style="width:100%;font-family:var(--mono);font-size:.78rem;background:var(--bg2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 10px"></select> 2445 </div> 2446 <div class="form-group" style="flex:1;min-width:150px"> 2447 <label class="form-label">SERIES 2</label> 2448 <select id="macro-cmp-s2" style="width:100%;font-family:var(--mono);font-size:.78rem;background:var(--bg2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 10px"><option value="">â optional â</option></select> 2449 </div> 2450 <div class="form-group" style="flex:1;min-width:150px"> 2451 <label class="form-label">SERIES 3</label> 2452 <select id="macro-cmp-s3" style="width:100%;font-family:var(--mono);font-size:.78rem;background:var(--bg2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 10px"><option value="">â optional â</option></select> 2453 </div> 2454 <div class="form-group"> 2455 <label class="form-label">TRANSFORM</label> 2456 <select id="macro-cmp-transform" style="font-family:var(--mono);font-size:.78rem;background:var(--bg2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 10px"> 2457 <option value="level">Level</option> 2458 <option value="yoy">YoY %</option> 2459 <option value="mom">MoM %</option> 2460 </select> 2461 </div> 2462 <button class="btn btn-primary" style="padding:5px 16px;font-size:.78rem" onclick="loadMacroCompare()">Compare</button> 2463 </div> 2464 <div id="macro-compare-wrap" style="background:var(--bg2);border:1px solid var(--border);border-radius:10px;min-height:200px;display:flex;align-items:center;justify-content:center;overflow:hidden"> 2465 <p style="font-size:.8rem;color:var(--text2)">Select series and click Compare</p> 2466 </div> 2467 <div style="margin-top:10px"> 2468 <button id="macro-compare-dl" class="btn btn-ghost" style="font-size:.72rem;padding:4px 12px;display:none" onclick="downloadMacroCompare()">⇓ Download Compare PNG</button> 2469 </div> 2470 </div> 2471 2472 <!-- AI Brief --> 2473 <div id="macro-pane-aibrief" class="macro-pane"> 2474 <div style="margin-bottom:14px"> 2475 <button class="btn btn-primary" onclick="loadMacroAIBrief()" id="macro-brief-btn">Generate Macro Brief</button> 2476 <span style="font-size:.7rem;color:var(--muted);font-family:var(--mono);margin-left:12px">Uses Claude Haiku · ~$0.001</span> 2477 </div> 2478 <div id="macro-brief-out" style="display:none;background:var(--bg2);border:1px solid var(--teal);border-radius:10px;padding:20px 22px;font-size:.9rem;line-height:1.7;color:var(--text)"></div> 2479 <div id="macro-brief-data" style="display:none;margin-top:14px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:14px 16px;font-size:.72rem;color:var(--text2);font-family:var(--mono);line-height:1.8"></div> 2480 </div> 2481 2482 <!-- Release Calendar --> 2483 <div id="macro-pane-releases" class="macro-pane"> 2484 <div style="display:flex;align-items:center;gap:10px;margin-bottom:14px">
2485 <span style="font-size:.65rem;color:var(--muted);font-family:var(--mono)">UPCOMING FRED DATA RELEASES · NEXT 30 DAYS</span> 2486 <button class="btn btn-ghost" style="font-size:.7rem;padding:3px 10px;margin-left:auto" onclick="loadMacroReleases()">↻ Refresh</button> 2487 </div> 2488 <div id="macro-releases-list"><p style="font-size:.8rem;color:var(--text2)">Loading...</p></div> 2489 </div> 2490 2491 <!-- Alerts --> 2492 <div id="macro-pane-alerts" class="macro-pane"> 2493 <div style="background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:16px 18px;margin-bottom:18px"> 2494 <div style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:12px">NEW THRESHOLD ALERT</div> 2495 <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end"> 2496 <div class="form-group" style="flex:1;min-width:160px"> 2497 <label class="form-label">SERIES</label> 2498 <select id="fred-alert-series" style="width:100%;font-family:var(--mono);font-size:.78rem;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 10px"></select> 2499 </div> 2500 <div class="form-group"> 2501 <label class="form-label">TRANSFORM</label> 2502 <select id="fred-alert-transform" style="font-family:var(--mono);font-size:.78rem;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 10px"> 2503 <option value="level">Level</option> 2504 <option value="yoy">YoY %</option> 2505 <option value="mom">MoM %</option> 2506 </select> 2507 </div> 2508 <div class="form-group"> 2509 <label class="form-label">CONDITION</label> 2510 <select id="fred-alert-condition" style="font-family:var(--mono);font-size:.78rem;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 10px"> 2511 <option value="gt">Above â²</option> 2512 <option value="lt">Below â¼</option> 2513 </select> 2514 </div> 2515 <div class="form-group"> 2516 <label class="form-label">THRESHOLD</label> 2517 <input type="number" id="fred-alert-threshold" step="0.01" placeholder="e.g. 3.5" style="width:90px;font-family:var(--mono);font-size:.78rem;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 10px"> 2518 </div> 2519 <button class="btn btn-primary" style="padding:5px 16px;font-size:.78rem" onclick="createFredAlert()">+ Save Alert</button> 2520 </div> 2521 </div> 2522 <div id="fred-alerts-triggered" style="display:none;background:rgba(220,50,50,.08);border:1px solid #e05050;border-radius:8px;padding:12px 16px;margin-bottom:14px;font-size:.8rem;color:#e05050;font-family:var(--mono)"></div> 2523 <div style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:10px">ACTIVE ALERTS</div> 2524 <div id="fred-alerts-list"><p style="font-size:.8rem;color:var(--text2)">Loading...</p></div> 2525 </div> 2526</div> 2527 2528<div class="page" id="page-admin"> 2529 <div class="page-header"> 2530 <div class="page-title"><lord-icon src="https://cdn.lordicon.com/wyupmbaf.json" trigger="hover" style="width:28px;height:28px"></lord-icon>Admin Panel</div> 2531 <div class="page-sub">Manage enterprise clients, API keys, usage tracking</div> 2532 </div> 2533 <!-- Platform stats --> 2534 <div class="g4" style="margin-bottom:20px"> 2535 <div class="stat-card"><div class="lbl">TOTAL CLIENTS</div><div class="val" id="adm-total">â</div></div> 2536 <div class="stat-card"><div class="lbl">ACTIVE</div><div class="val" id="adm-active">â</div></div> 2537 <div class="stat-card"><div class="lbl">SCANS THIS MONTH</div><div class="val" id="adm-scans">â</div></div> 2538 <div class="stat-card"><div class="lbl">POSTS THIS MONTH</div><div class="val" id="adm-posts">â</div></div> 2539 </div> 2540 2541 <!-- Credits & Usage --> 2542 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px"> 2543 <div style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px">CREDITS & USAGE â <span id="credits-month" style="color:var(--yellow)">...</span></div> 2544 <button class="btn btn-ghost" style="font-size:.7rem;padding:4px 10px" onclick="loadAdminCredits()">â» Refresh</button> 2545 </div> 2546 <div class="g3" style="margin-bottom:20px" id="credits-cards"> 2547 <!-- Injected by loadAdminCredits() --> 2548 <div class="stat-card"><div class="lbl">ð¤ CLAUDE CALLS</div><div class="val" id="crd-claude-calls">â</div></div> 2549 <div class="stat-card"><div class="lbl">ð¤ EST. COST (USD)</div><div class="val" id="crd-claude-cost">â</div></div> 2550 <div class="stat-card"><div class="lbl">ð¨ CAROUSEL RENDERS</div><div class="val" id="crd-tmpl">â</div></div> 2551 <div class="stat-card"><div class="lbl">âï¸ CLD STORAGE</div><div class="val" id="crd-cld-storage">â</div></div> 2552 <div class="stat-card"><div class="lbl">âï¸ CLD RESOURCES</div><div class="val" id="crd-cld-res">â</div></div> 2553 <div class="stat-card"><div class="lbl">ð§© SONAR CALLS</div><div class="val" id="crd-sonar-calls">â</div></div> 2554 <div class="stat-card"><div class="lbl">ð§© SONAR COST</div><div class="val" id="crd-sonar-cost">â</div></div> 2555 <div class="stat-card"><div class="lbl">ð¼ DALL-E IMAGES</div><div class="val" id="crd-openai-calls">â</div></div> 2556 <div class="stat-card"><div class="lbl">ð¼ DALL-E COST</div><div class="val" id="crd-openai-cost">â</div></div> 2557 </div> 2558 <div class="card" style="margin-bottom:20px;
2558display:none" id="credits-detail"> 2559 <div style="display:flex;gap:24px;flex-wrap:wrap"> 2560 <div> 2561 <div style="font-size:.6rem;color:var(--muted);letter-spacing:1px;margin-bottom:6px">ð¤ CLAUDE AI</div> 2562 <div id="crd-claude-detail" style="font-size:.75rem;color:var(--text2);line-height:1.9"></div> 2563 </div> 2564 <div> 2565 <div style="font-size:.6rem;color:var(--muted);letter-spacing:1px;margin-bottom:6px">ð POSTS BY CLIENT</div> 2566 <div id="crd-x-detail" style="font-size:.75rem;color:var(--text2);line-height:1.9"></div> 2567 </div> 2568 <div> 2569 <div style="font-size:.6rem;color:var(--muted);letter-spacing:1px;margin-bottom:6px">ð§© SONAR (EXTENSION)</div> 2570 <div id="crd-sonar-detail" style="font-size:.75rem;color:var(--text2);line-height:1.9"></div> 2571 <!-- Sonar weekly limit editor --> 2572 <div style="margin-top:12px;padding:10px;background:rgba(226,194,117,.05);border:1px solid rgba(226,194,117,.15);border-radius:7px"> 2573 <div style="font-size:.6rem;color:var(--yellow);letter-spacing:1px;margin-bottom:8px;font-weight:700">WEEKLY CALL LIMITS</div> 2574 <div style="display:grid;grid-template-columns:auto 1fr;align-items:center;gap:5px 10px;font-size:.72rem;color:var(--text2)"> 2575 <label>Beginner</label> 2576 <input id="sonar-limit-beginner" type="number" min="1" style="background:#131f40;border:1px solid #202e56;border-radius:4px;padding:3px 7px;color:#e0e8f0;font-size:.72rem;width:70px" /> 2577 <label>Intermediate</label> 2578 <input id="sonar-limit-intermediate" type="number" min="1" style="background:#131f40;border:1px solid #202e56;border-radius:4px;padding:3px 7px;color:#e0e8f0;font-size:.72rem;width:70px" /> 2579 <label>Advanced</label> 2580 <input id="sonar-limit-advanced" type="number" min="1" style="background:#131f40;border:1px solid #202e56;border-radius:4px;padding:3px 7px;color:#e0e8f0;font-size:.72rem;width:70px" /> 2581 <label>Expert</label> 2582 <input id="sonar-limit-expert" type="number" min="1" style="background:#131f40;border:1px solid #202e56;border-radius:4px;padding:3px 7px;color:#e0e8f0;font-size:.72rem;width:70px" /> 2583 </div> 2584 <div style="display:flex;align-items:center;gap:8px;margin-top:8px"> 2585 <button onclick="saveSonarLimits()" class="btn btn-yellow" style="font-size:.65rem;padding:4px 12px">Save</button> 2586 <span id="sonar-limits-msg" style="font-size:.65rem;color:var(--muted)"></span> 2587 </div> 2588 </div> 2589 </div> 2590 <div> 2591 <div style="font-size:.6rem;color:var(--muted);letter-spacing:1px;margin-bottom:6px">ð¼ OPENAI / DALL-E</div> 2592 <div id="crd-openai-detail" style="font-size:.75rem;color:var(--text2);line-height:1.9"></div> 2593 </div> 2594 </div> 2595 </div> 2596 <hr> 2597 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;cursor:pointer" onclick="toggleAdminSection('as-clients')"> 2598 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">ENTERPRISE CLIENTS <span id="as-clients-arrow" style="margin-left:4px">â¾</span></div> 2599 <button class="btn btn-yellow" onclick="event.stopPropagation();openNewClient()">+ New Client</button> 2600 </div> 2601 <div id="as-clients"> 2602 <div id="client-list"></div> 2603 </div> 2604 <hr> 2605 <!-- Dashboard Users --> 2606 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;cursor:pointer" onclick="toggleAdminSection('as-users')"> 2607 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">DASHBOARD USERS <span id="as-users-arrow" style="margin-left:4px">â¾</span></div> 2608 <button class="btn btn-primary" style="font-size:.7rem;padding:5px 12px" onclick="event.stopPropagation();openNewUser()">+ New User</button> 2609 </div> 2610 <div id="as-users"> 2611 <div id="user-list" style="margin-bottom:16px"></div> 2612 <!-- Create/Edit User modal-style inline form --> 2613 <div id="user-form-panel" style="display:none;background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:18px;margin-bottom:16px"> 2614 <h4 id="user-form-title" style="margin-bottom:14px">New Dashboard User</h4> 2615 <div class="g2"> 2616 <div class="form-group"><label class="form-label">EMAIL</label><input type="email" id="uf-email" placeholder="[email protected]"></div> 2617 <div class="form-group"><label class="form-label">NAME (optional)</label><input type="text" id="uf-name" placeholder="Display name"></div> 2618 <div class="form-group"><label class="form-label">TIER</label> 2619 <select id="uf-tier"> 2620 <option value="creator">
2620creator</option> 2621 <option value="pro" selected>pro</option> 2622 <option value="stellar">stellar</option> 2623 <option value="enterprise_api">enterprise_api</option> 2624 <option value="admin">admin</option> 2625 <option value="super_admin">super_admin</option> 2626 </select> 2627 </div> 2628 <div class="form-group"><label class="form-label">PASSWORD</label><input type="password" id="uf-password" placeholder="Set login password"></div> 2629 </div> 2630 <div style="display:flex;gap:10px;margin-top:10px"> 2631 <button class="btn btn-primary" onclick="saveUser()" id="uf-save-btn">ð¾ Create User</button> 2632 <button class="btn btn-ghost" onclick="$('user-form-panel').style.display='none'">Cancel</button> 2633 </div> 2634 <div id="uf-result" style="margin-top:10px"></div> 2635 </div> 2636 </div><!-- /as-users --> 2637 <hr> 2638 <!-- Activity feed --> 2639 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;cursor:pointer" onclick="toggleAdminSection('as-activity')"> 2640 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">RECENT ACTIVITY <span id="as-activity-arrow" style="margin-left:4px">â¸</span></div> 2641 <button class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px" onclick="event.stopPropagation();loadAdminActivity()">â» Refresh</button> 2642 </div> 2643 <div id="as-activity" style="display:none"> 2644 <div id="admin-activity-list"></div> 2645 </div> 2646 <hr> 2647 <!-- News management --> 2648 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;cursor:pointer" onclick="toggleAdminSection('as-news')"> 2649 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">NEWS FEED â PUSH HISTORY <span id="as-news-arrow" style="margin-left:4px">â¾</span></div> 2650 <div style="display:flex;gap:8px"> 2651 <button class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px" onclick="event.stopPropagation();loadAdminNews()">â» Refresh</button> 2652 <button class="btn btn-primary" style="font-size:.7rem;padding:5px 12px" onclick="event.stopPropagation();adminRunJob('fetch_insider',this,'Insider transactions fetched')">â¡ Fetch Insider Transactions</button> 2653 </div> 2654 </div> 2655 <div id="as-news"> 2656 <!-- AI Analysis Generator --> 2657 <div class="card" style="margin-bottom:14px;border:1px solid rgba(99,102,241,.35)"> 2658 <h4>ð¤ AI ANALYSIS GENERATOR</h4> 2659 <p style="margin-bottom:12px;font-size:.73rem;color:var(--muted)">Describe the analysis you want. Claude Sonnet will draft a structured report. Review and edit before pushing to clients.</p> 2660 <div class="form-group" style="margin-top:10px"><label class="form-label">TYPE</label><select id="ai-content-type"><option value="company_analysis">ð¢ Company Analysis</option><option value="macro">ð Macro</option><option value="earnings">ð° Earnings</option><option value="news">ð° News</option></select></div> 2661 <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px"> 2662 <div class="form-group"><label class="form-label">COMPANY / TICKER</label><input type="text" id="ai-meta-company" placeholder="e.g. NVDA / Nvidia" style="width:100%"></div> 2663 <div class="form-group"><label class="form-label">KEY DATA POINTS</label><input type="text" id="ai-meta-data" placeholder="e.g. Q4 EPS $0.89, revenue +18%" style="width:100%"></div> 2664 </div> 2665 <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px"> 2666 <div class="form-group"><label class="form-label">TONE</label><select id="ai-meta-tone" style="width:100%"><option value="">Neutral</option><option value="bullish">Bullish</option><option value="bearish">Bearish</option><option value="cautious">Cautious</option></select></div> 2667 <div class="form-group"><label class="form-label">LANGUAGE</label><select id="ai-meta-lang" style="width:100%"><option value="en">English</option><option value="es">Spanish</option></select></div> 2668 </div> 2669 <button class="btn btn-ghost" onclick="adminBuildPrompt()" style="font-size:.72rem;margin-bottom:10px">â¡ Build Prompt from Fields</button> 2670 <div class="form-group"><label class="form-label">YOUR REQUEST</label><textarea id="ai-prompt" rows="3" placeholder="e.g. Write a company deep-dive on NVDA covering valuation, AI strategy and risks e.g. Macro analysis of US CPI for Q1 2026 and Fed implications"></textarea></div> 2671 <button class="btn btn-primary" onclick="adminGenerateAnalysis()" id="ai-gen-btn">ð¤ Generate Analysis</button> 2672 <div id="ai-gen-result" style="margin-top:8px"></div> 2673 <!-- Preview section â hidden until generation completes --> 2674 <div id="ai-preview" style="display:none;margin-top:18px;border-top:1px solid rgba(255,255,255,.08);padding-top:14px"> 2675 <div style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:10px">PREVIEW â EDIT BEFORE PUSHING</div> 2676 <div class="form-group"><label class="form-label">TITLE</label><input type="text" id="ai-title" style="width:100%"></div> 2677 <div class="form-group"><label class="form-label">SUMMARY</label><textarea id="ai-summary" rows="10" style="width:100%;font-size:.78rem;line-height:1.55"></textarea></div> 2678 <button class="btn btn-green" onclick="adminPushAnalysis()">ð° Push to Feed</button> 2679 <div id="ai-push-result" style="margin-top:8px"></div> 2680 </div> 2681 </div> 2682 </div><!-- /as-news --> 2683 2684 <!-- Daily Briefs --> 2685 <hr> 2686 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:18px;
2686cursor:pointer" onclick="toggleAdminSection('as-briefs')"> 2687 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">DAILY INTELLIGENCE BRIEFS <span id="as-briefs-arrow" style="margin-left:4px">â¾</span></div> 2688 <div style="display:flex;gap:8px"> 2689 <button class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px" onclick="event.stopPropagation();loadAdminBriefs()">â» Refresh</button> 2690 <button class="btn btn-primary" style="font-size:.7rem;padding:5px 12px" onclick="event.stopPropagation();adminGenerateBrief()">â¡ Generate Today's Brief</button> 2691 </div> 2692 </div> 2693 <div id="as-briefs"> 2694 <div class="card" style="margin-bottom:14px;border:1px solid rgba(46,204,142,.25)"> 2695 <p style="font-size:.73rem;color:var(--muted);margin-bottom:12px">Claude generates the full HTML brief using live market context (news, movers, earnings, scanner signals) from your DB. Runs automatically MonâFri at 6:30am EST. Click Generate to trigger it manually now.</p> 2696 <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px"> 2697 <label style="font-size:.78rem;display:flex;align-items:center;gap:7px;cursor:pointer"><input type="checkbox" id="brief-auto-enabled" onchange="saveDailyBriefConfig()"> Automatic daily generation enabled</label> 2698 <span id="brief-auto-result" style="font-size:.7rem;color:var(--muted)"></span> 2699 </div> 2700 <p style="font-size:.68rem;color:var(--muted);margin:-6px 0 12px">Turning this off stops the 6:30am scheduled Claude API call. "Generate Today's Brief" above still works manually regardless.</p> 2701 <div style="display:flex;gap:8px;margin-bottom:12px"> 2702 <button id="btn-preview-daily" class="btn btn-ghost" style="font-size:.7rem;padding:5px 12px" onclick="previewEmailInTab('daily')">ð Preview Daily</button> 2703 <button id="btn-preview-weekly" class="btn btn-ghost" style="font-size:.7rem;padding:5px 12px" onclick="previewEmailInTab('weekly')">ð Preview Weekly</button> 2704 <button id="btn-preview-monthly" class="btn btn-ghost" style="font-size:.7rem;padding:5px 12px" onclick="previewEmailInTab('monthly')">ð Preview Monthly</button> 2705 </div> 2706 <div id="brief-gen-result" style="margin-bottom:10px"></div> 2707 <div id="brief-list" style="margin-top:8px"></div> 2708 </div> 2709 <!-- Brief preview modal --> 2710 <div id="brief-preview-wrap" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:9999;overflow:auto;padding:24px"> 2711 <div style="max-width:740px;margin:0 auto;position:relative"> 2712 <button onclick="document.getElementById('brief-preview-wrap').style.display='none'" style="position:sticky;top:0;float:right;background:#e05252;color:#fff;border:none;border-radius:6px;padding:6px 14px;cursor:pointer;font-size:.8rem;z-index:10;margin-bottom:8px">â Close</button> 2713 <div id="brief-preview-content"></div> 2714 </div> 2715 </div> 2716 </div><!-- /as-briefs --> 2717 2718 <!-- ââ NEWSROOM (world-sourced content, review queue) ââ --> 2719 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:22px;cursor:pointer" onclick="toggleAdminSection('as-newsroom')"> 2720 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">NEWSROOM <span id="as-newsroom-arrow" style="margin-left:4px">â¸</span></div> 2721 <button class="btn btn-ghost" style="font-size:.7rem;padding:4px 10px" onclick="event.stopPropagation();loadNewsroom()">â» Refresh</button> 2722 </div> 2723 <div id="as-newsroom" style="display:none"> 2724 <div class="card" style="margin-bottom:14px;border:1px solid rgba(0,188,212,.25)"> 2725 <p style="font-size:.73rem;color:var(--muted);margin-bottom:12px"> 2726 Story ideas sourced from live web research plus Factoz's own data, drafted per format. 2727 <strong>Nothing publishes without your approval.</strong> Spanish goes to Instagram/TikTok, English to X. 2728 </p> 2729 <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px"> 2730 <!-- "Find Stories" read as "find historias" next to a beat button 2731 literally called Historias, while it actually runs all three 2732 beats at five times the Sonar cost. The labels now say which 2733 beat and what it spends. --> 2734 <button class="btn btn-ghost" style="font-size:.72rem;padding:5px 11px" onclick="newsroomDiscover()" title="Busca en los tres pilares. 5 llamadas a Sonar.">ð Buscar en todo (5)</button> 2735 <button class="btn btn-ghost" style="font-size:.72rem;padding:5px 11px" onclick="nrMoneyFlow()" title="Sankey de a dónde va el dinero de una empresa. No usa Sonar ni Claude â no cuesta créditos.">ð° Cómo gana dinero</button> 2736 <button class="btn btn-ghost" style="font-size:.72rem;padding:5px 11px" onclick="nrGrowth()" title="Cuánto valdrÃan hoy 1.000 dólares invert
2736idos hace N años. Reel. No cuesta créditos.">ð ¿Cuánto tendrÃas hoy?</button> 2737 <button class="btn btn-ghost" style="font-size:.72rem;padding:5px 11px" onclick="nrLesson()" title="Lección de price action con un caso real, gráfica animada y voz. No cuesta créditos.">ð Lección de trading</button> 2738 <button class="btn btn-ghost" style="font-size:.72rem;padding:5px 11px" onclick="newsroomDiscover('markets')" title="Solo mercados y smart money. 3 llamadas a Sonar.">Markets (3)</button> 2739 <button class="btn btn-ghost" style="font-size:.72rem;padding:5px 11px" onclick="newsroomDiscover('macro')" title="Solo macro y economia. 1 llamada a Sonar.">Macro (1)</button> 2740 <button class="btn btn-ghost" style="font-size:.72rem;padding:5px 11px" onclick="newsroomDiscover('stories')" title="Solo historias de empresas. 1 llamada a Sonar.">Historias (1)</button> 2741 <button class="btn btn-ghost" style="font-size:.72rem;padding:5px 11px" onclick="nrToggleAll()">ð Ver todos los drafts</button> 2742 </div> 2743 <div id="nr-status" style="font-size:.72rem;color:var(--muted);margin-bottom:10px"></div> 2744 2745 <div style="font-size:.72rem;color:var(--muted);font-family:var(--mono);letter-spacing:1px;margin:16px 0 8px">IDEA QUEUE</div> 2746 <div id="nr-ideas"></div> 2747 <div id="nr-ideas-empty" style="display:none;padding:18px 0;text-align:center;font-size:.75rem;color:var(--muted)">No pending ideas. Hit âFind Storiesâ.</div> 2748 2749 <div style="font-size:.72rem;color:var(--muted);font-family:var(--mono);letter-spacing:1px;margin:22px 0 8px">DRAFTS AWAITING REVIEW</div> 2750 <div id="nr-drafts"></div> 2751 <div id="nr-drafts-empty" style="display:none;padding:18px 0;text-align:center;font-size:.75rem;color:var(--muted)">Sin drafts en la cola de trabajo. Usa "Ver todos los drafts" para incluir publicados y rechazados.</div> 2752 </div> 2753 </div><!-- /as-newsroom --> 2754 2755 <!-- ââ SOCIAL CONTENT PILLARS (Buffer) ââ --> 2756 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:22px;cursor:pointer" onclick="toggleAdminSection('as-social-pillars')"> 2757 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">SOCIAL CONTENT PILLARS <span id="as-social-pillars-arrow" style="margin-left:4px">â¸</span></div> 2758 <button class="btn btn-ghost" style="font-size:.7rem;padding:4px 10px" onclick="event.stopPropagation();loadSocialPillars()">â» Refresh</button> 2759 </div> 2760 <div id="as-social-pillars" style="display:none"> 2761 <div class="card" style="margin-bottom:14px;border:1px solid rgba(0,188,212,.25)"> 2762 <p style="font-size:.73rem;color:var(--muted);margin-bottom:12px">5 automated brand-content pillars post straight into Buffer's queue (X, TikTok, Instagram) â no review step. Toggle any pillar off independently if it misbehaves; changes take effect on the pillar's next scheduled run.</p> 2763 <div style="display:grid;grid-template-columns:1fr auto;gap:8px 10px;align-items:center;margin-bottom:14px"> 2764 <label style="font-size:.78rem;display:flex;align-items:center;gap:7px;cursor:pointer"><input type="checkbox" id="sp-tradingview"> TradingView Ideas</label> 2765 <button class="btn btn-ghost" style="font-size:.65rem;padding:3px 8px" onclick="triggerSocialPillar('tradingview')">Test Now</button> 2766 <label style="font-size:.78rem;display:flex;align-items:center;gap:7px;cursor:pointer"><input type="checkbox" id="sp-eps"> EPS Beats/Misses (9:30pm ET)</label> 2767 <button class="btn btn-ghost" style="font-size:.65rem;padding:3px 8px" onclick="triggerSocialPillar('eps')">Test Now</button> 2768 <label style="font-size:.78rem;display:flex;align-items:center;gap:7px;cursor:pointer"><input type="checkbox" id="sp-movers"> Top Movers (8:15am ET)</label> 2769 <button class="btn btn-ghost" style="font-size:.65rem;padding:3px 8px" onclick="triggerSocialPillar('movers')">Test Now</button> 2770 <label style="font-size:.78rem;display:flex;align-items:center;gap:7px;cursor:pointer"><input type="checkbox" id="sp-options"> Unusual Options (4pm ET)</label> 2771 <button class="btn btn-ghost" style="font-size:.65rem;padding:3px 8px" onclick="triggerSocialPillar('options')">Test Now</button> 2772 <label style="font-size:.78rem;display:flex;align-items:center;gap:7px;cursor:pointer"><input type="checkbox" id="sp-headline"> Top Headline (11am ET)</label> 2773 <button class="btn btn-ghost" style="font-size:.65rem;padding:3px 8px" onclick="triggerSocialPillar('headline')">Test Now</button> 2774 </div> 2775 <div id="sp-trigger-result" style="font-size:.72rem;color:var(--muted);margin-bottom:6px"></div> 2776 <div style="display:flex;align-items:center;gap:10px;margin-bottom:14px"> 2777 <button class="btn btn-primary" onclick="saveSocialPillarConfig()">Save Config</button> 2778 <div id="sp-config-result" style="font-size:.73rem"></div> 2779 </div> 2780 <div style="font-size:.68rem;color:var(--muted);letter-spacing:1.5px;margin-bottom:6px">RECENT POSTS</div> 2781 <div style="overflow-x:auto"> 2782 <table style="width:100%;border-collapse:collapse;font-size:.72rem" id="sp-posts-tbl"> 2783 <thead> 2784 <tr style="border-bottom:1px solid var(--border)"> 2785 <th style="text-align:left;padding:5px 8px;color:var(--muted);font-weight:600">PILLAR</th> 2786 <th style="text-align:left;padding:5px 8px;color:var(--muted);font-weight:600">TEXT</th> 2787 <th style="text-align:left;padding:5px 8px;color:var(--muted);font-weight:600">CHANNEL</th> 2788 <th style="text-align:left;padding:5px 8px;color:var(--muted);font-weight:600">POSTED</th> 2789 </tr> 2790 </thead> 2791 <tbody id="sp-posts-body"></tbody> 2792 </table> 2793 <div id="sp-posts-empty" style="display:none;padding:24px 0;text-align:center;font-size:.75rem;color:var(--muted)">No posts yet.</div> 2794 </div> 2795 </div> 2796 </div><!-- /as-social-pillars --> 2797 2798 <!-- Templated.io Template Manager --> 2799 <div style="display:none"> 2800 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:18px;
2800cursor:pointer" onclick="toggleAdminSection('as-templated')"> 2801 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">TEMPLATED.IO â TEMPLATE MANAGER <span id="as-templated-arrow" style="margin-left:4px">â¸</span></div> 2802 <button class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px" onclick="event.stopPropagation();loadTemplatedConfigs()">â» Refresh</button> 2803 </div> 2804 <div id="as-templated" style="display:none"> 2805 <div class="card" style="margin-bottom:14px;border:1px solid rgba(99,102,241,.35)"> 2806 <h4>ADD NEW TEMPLATE</h4> 2807 <p style="font-size:.73rem;color:var(--muted);margin-bottom:12px">Add a Templated.io template without touching Railway env vars. No redeploy needed.</p> 2808 <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px"> 2809 <div class="form-group"><label class="form-label">INTERNAL KEY (unique)</label><input type="text" id="tc-key" placeholder="dark_theme, premium_4â¦"></div> 2810 <div class="form-group"><label class="form-label">DISPLAY LABEL</label><input type="text" id="tc-label" placeholder="Dark Theme"></div> 2811 </div> 2812 <div class="form-group"><label class="form-label">TEMPLATED.IO TEMPLATE UUID</label><input type="text" id="tc-tid" placeholder="a44da04c-91c2-4c61-b556-65e8811aceb8" style="font-family:var(--mono);font-size:.78rem"></div> 2813 <div style="display:flex;align-items:center;gap:16px;margin-bottom:12px;flex-wrap:wrap"> 2814 <label style="font-size:.78rem;display:flex;align-items:center;gap:6px;cursor:pointer"> 2815 <input type="checkbox" id="tc-free" checked> Free template 2816 </label> 2817 <label style="font-size:.78rem;display:flex;align-items:center;gap:6px;cursor:pointer"> 2818 <input type="checkbox" id="tc-user-slot"> User-purchasable slot 2819 </label> 2820 <div class="form-group" style="margin:0;flex:1"><label class="form-label">PRICE (USD, if paid)</label><input type="number" id="tc-price" placeholder="9.99" step="0.01" min="0"></div> 2821 </div> 2822 <div style="display:flex;gap:8px"> 2823 <button class="btn btn-ghost" onclick="verifyTemplatedConfig()" id="tc-verify-btn">ð Verify Template</button> 2824 <button class="btn btn-primary" onclick="saveTemplatedConfig()" id="tc-save-btn">+ Save Template</button> 2825 </div> 2826 <div id="tc-verify-result" style="margin-top:8px;font-size:.73rem"></div> 2827 </div> 2828 <div id="tc-list" style="margin-bottom:14px"></div> 2829 </div><!-- /as-templated --> 2830 </div><!-- hidden wrapper --> 2831 2832 <!-- Polymarket Monitor Config --> 2833 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:18px;cursor:pointer" onclick="toggleAdminSection('as-polymarket')"> 2834 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">POLYMARKET MONITOR â CONFIG <span id="as-polymarket-arrow" style="margin-left:4px">â¸</span></div> 2835 <button class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px" onclick="event.stopPropagation();loadPolymarketConfig()">↻ Refresh</button> 2836 </div> 2837 <div id="as-polymarket" style="display:none"> 2838 <div class="card" style="margin-bottom:14px;border:1px solid rgba(0,200,83,.2)"> 2839 <h4>MONITOR THRESHOLDS â v2</h4> 2840 <p style="font-size:.73rem;color:var(--muted);margin-bottom:12px">Controls the background monitor. Changes take effect within 10 min.</p> 2841 <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px"> 2842 <div class="form-group"><label class="form-label">OI CHANGE THRESHOLD (%)</label><input type="number" id="pm-oi-chg" step="1" placeholder="10"></div> 2843 <div class="form-group"><label class="form-label">VOLUME SURGE MULTIPLIER (Ã)</label><input type="number" id="pm-vol-mult" step="0.1" placeholder="1.5"></div> 2844 <div class="form-group"><label class="form-label">BOOK IMBALANCE RATIO</label><input type="number" id="pm-book-imb" step="0.5" placeholder="3.0"></div> 2845 <div class="form-group"><label class="form-label">MAX SPREAD (% of mid)</label><input type="number" id="pm-spread-pct" step="0.5" placeholder="5.0"></div> 2846 <div class="form-group"><label class="form-label">MIN 24H VOLUME ($)</label><input type="number" id="pm-min-vol24" placeholder="10000"></div> 2847 <div class="form-group"><label class="form-label">MAX MARKETS TRACKED</label><input type="number" id="pm-max-markets" placeholder="50"></div> 2848 <div class="form-group"><label class="form-label">ALERT COOLDOWN (sec)</label><input type="number" id="pm-cooldown" placeholder="3600"></div> 2849 <div class="form-group"><label class="form-label">POLLING INTERVAL (sec)</label><input type="number" id="pm-poll-interval" placeholder="60"></div> 2850 </div> 2851 <div class="form-group"><label class="form-label">INCLUDE KEYWORDS (comma-separated, blank = all)</label><input type="text" id="pm-include-kw" placeholder="trump, fed, bitcoin"></div> 2852 <div class="form-group"><label class="form-label">EXCLUDE KEYWORDS (comma-separated)</label><input type="text" id="pm-exclude-kw" placeholder="nfl, nba, oscars"></div> 2853 <div style="margin-bottom:14px"> 2854 <label style="font-size:.78rem;display:flex;align-items:center;gap:7px;cursor:pointer"><input type="checkbox" id="pm-enabled"> Monitor Enabled</label> 2855 </div> 2856 <div style="display:flex;align-items:center;gap:10px"> 2857 <button class="btn btn-primary" onclick="savePolymarketConfig()">Save Config</button> 2858 <div id="pm-config-result" style="font-size:.73rem"></div> 2859 </div> 2860 <div style="margin-top:16px;border-top:1px solid var(--border);padding-top:12px"> 2861 <div style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:8px">
2861RECENT ALERTS (ADMIN VIEW)</div> 2862 <div style="display:flex;gap:8px;margin-bottom:8px"> 2863 <button class="btn btn-ghost" style="font-size:.7rem;padding:4px 10px" onclick="loadAdminPolymarketAlerts()">Load Alerts</button> 2864 <button class="btn btn-ghost" style="font-size:.7rem;padding:4px 10px;color:var(--red);border-color:rgba(255,68,68,.3)" onclick="clearPolymarketAlerts()">Clear History</button> 2865 </div> 2866 <div id="pm-admin-alerts-list" style="max-height:260px;overflow-y:auto"></div> 2867 </div> 2868 </div> 2869 </div><!-- /as-polymarket --> 2870 2871 <!-- DJT Signal Tools --> 2872 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:18px;cursor:pointer" onclick="toggleAdminSection('as-djt')"> 2873 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">ðºð¸ DJT SIGNAL TOOLS <span id="as-djt-arrow" style="margin-left:4px">â¸</span></div> 2874 </div> 2875 <div id="as-djt" style="display:none"> 2876 <div class="card" style="margin-bottom:14px;border:1px solid rgba(255,68,68,.2)"> 2877 <h4>TRUTH SOCIAL MONITOR â @realDonaldTrump</h4> 2878 <p style="font-size:.73rem;color:var(--muted);margin-bottom:12px">Manually trigger a fetch from Truth Social. After clicking, check Railway logs for the HTTP status â look for <code>fetch_djt_tweets: N new post(s) saved</code> or a warning with the HTTP code.</p> 2879 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"> 2880 <button class="btn btn-primary" onclick="adminDjtRefresh(this)">â¡ Trigger Fetch Now</button> 2881 <div id="djt-refresh-result" style="font-size:.73rem;color:var(--muted)"></div> 2882 </div> 2883 </div> 2884 </div><!-- /as-djt --> 2885 2886 <!-- Tax Sessions (CPA) --> 2887 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:18px;cursor:pointer" onclick="toggleAdminSection('as-tax')"> 2888 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">PANAMA TAX FORM â CPA REVIEW <span id="as-tax-arrow" style="margin-left:4px">â¾</span></div> 2889 <button class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px" onclick="event.stopPropagation();loadTaxSessions()">↻ Refresh</button> 2890 </div> 2891 <div id="as-tax"> 2892 <div class="card" style="margin-bottom:10px;border:1px solid rgba(226,194,117,.2)"> 2893 <h4>CPA EMAIL DESTINATIONS</h4> 2894 <p style="font-size:.73rem;color:var(--muted);margin-bottom:10px">When a user categorizes their Panama tax form, the result is emailed to these addresses for CPA review.</p> 2895 <div class="form-group" style="margin-bottom:8px"> 2896 <label class="form-label">EMAIL(S) â comma-separated</label> 2897 <input type="text" id="tax-cpa-emails" placeholder="[email protected],[email protected]"> 2898 </div> 2899 <div class="form-group" style="margin-bottom:8px"> 2900 <label class="form-label">YAPPY QR URL â image URL shown to users for payment</label> 2901 <input type="text" id="tax-yappy-qr-url" placeholder="https://... (leave blank to hide)"> 2902 </div> 2903 <div style="display:flex;align-items:center;gap:10px"> 2904 <button class="btn btn-primary" onclick="saveTaxConfig()">Save</button> 2905 <span id="tax-config-result" style="font-size:.73rem"></span> 2906 </div> 2907 </div> 2908 <div class="card" style="margin-bottom:14px;padding:0;overflow:hidden"> 2909 <div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between"> 2910 <h4 style="margin:0">TAX SESSIONS</h4> 2911 <span id="tax-paid-badge" style="font-size:.65rem;color:var(--muted);font-family:var(--mono)"></span> 2912 </div> 2913 <div id="tax-sessions-list" style="max-height:400px;overflow-y:auto"> 2914 <div style="padding:20px;text-align:center;color:var(--muted);font-size:.8rem">Click Refresh to load sessions</div> 2915 </div> 2916 </div> 2917 </div><!-- /as-tax --> 2918 2919 <!-- Tax Session Detail Modal --> 2920 <div id="tax-modal-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:9999;overflow-y:auto;padding:20px" onclick="if(event.target===this)closeTaxModal()"> 2921 <div style="background:var(--surface);border:1px solid var(--border);border-radius:10px;max-width:760px;margin:auto;padding:24px;position:relative"> 2922 <button onclick="closeTaxModal()" style="position:absolute;top:12px;right:16px;background:none;border:none;color:var(--muted);font-size:1.2rem;cursor:pointer">â</button> 2923 <div id="tax-modal-body"></div> 2924 </div> 2925 </div> 2926 2927 <!-- Manual push form --> 2928 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:18px;
2928cursor:pointer" onclick="toggleAdminSection('as-news-push')"> 2929 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">NEWS â PUSH & HISTORY <span id="as-news-push-arrow" style="margin-left:4px">â¸</span></div> 2930 </div> 2931 <div id="as-news-push" style="display:none"> 2932 <div class="card" style="margin-bottom:14px"> 2933 <h4>PUSH NEWS MANUALLY</h4> 2934 <div class="form-group" style="margin-top:10px"><label class="form-label">TYPE</label><select id="an-content-type"><option value="news">ð° News</option><option value="economic">ð Economic</option><option value="earnings">ð° Earnings</option><option value="company_analysis">ð¢ Company Analysis</option><option value="macro">ð Macro</option></select></div> 2935 <div class="form-group"><label class="form-label">HEADLINE</label><input type="text" id="an-title" placeholder="Breaking: ..."></div> 2936 <div class="form-group"><label class="form-label">SUMMARY</label><textarea id="an-summary" rows="2" placeholder="Article summary..."></textarea></div> 2937 <div class="form-group"><label class="form-label">IMAGE URL (Cloudinary â permanent)</label><input type="text" id="an-img" placeholder="https://res.cloudinary.com/..."></div> 2938 <div class="form-group"><label class="form-label">AI CAPTION</label><textarea id="an-caption" rows="3" placeholder="Ready-to-post caption..."></textarea></div> 2939 <div class="form-group"><label class="form-label">SOURCE URL</label><input type="text" id="an-source" placeholder="https://..."></div> 2940 <button class="btn btn-primary" onclick="adminPushNews()">ð° Push to Clients</button> 2941 <div id="an-result" style="margin-top:8px"></div> 2942 </div> 2943 <!-- Manual screenshot upload card --> 2944 <div class="card" style="margin-bottom:14px"> 2945 <h4>PUSH SCREENSHOT / PNG</h4> 2946 <p style="margin-bottom:10px;font-size:.73rem;color:var(--muted)">Upload a screenshot directly â no Cloudinary URL needed. Image is uploaded automatically.</p> 2947 <div class="form-group" style="margin-top:10px"><label class="form-label">TYPE</label><select id="anu-content-type"><option value="chart">ð Chart</option><option value="news">ð° News</option><option value="economic">ð Economic</option><option value="earnings">ð° Earnings</option><option value="company_analysis">ð¢ Company Analysis</option><option value="macro">ð Macro</option></select></div> 2948 <div class="form-group"><label class="form-label">HEADLINE</label><input type="text" id="anu-title" placeholder="NVDA breakout above EMA9"></div> 2949 <div class="form-group"><label class="form-label">SCREENSHOT (PNG / JPG)</label><input type="file" id="anu-file" accept="image/*" style="color:var(--text);font-size:.78rem"></div> 2950 <div class="form-group"><label class="form-label">CAPTION (optional)</label><textarea id="anu-caption" rows="2" placeholder="Ready-to-post caption..."></textarea></div> 2951 <button class="btn btn-primary" onclick="adminUploadNews()" id="anu-btn">ð¤ Upload & Push</button> 2952 <div id="anu-result" style="margin-top:8px"></div> 2953 </div> 2954 <!-- TradingView Ideas fetch --> 2955 <div class="card" style="margin-bottom:14px"> 2956 <h4>TRADINGVIEW IDEAS</h4> 2957 <p style="margin-bottom:12px;font-size:.73rem;color:var(--muted)">Manually pull the latest ideas from TradingView (username: <strong>Factoz</strong>) into the News Feed.</p> 2958 <button class="btn btn-primary" id="tv-fetch-btn" onclick="adminFetchTVIdeas()">ð¡ Fetch Latest Ideas</button> 2959 <div id="tv-fetch-result" style="margin-top:8px;font-size:.75rem"></div> 2960 </div> 2961 <!-- Rich text / HTML content push --> 2962 <div class="card" style="margin-bottom:14px"> 2963 <h4>PUSH RICH TEXT / HTML CONTENT <span id="nl-sub-count" style="font-size:.7rem;color:var(--text2);font-weight:400"></span> <button onclick="toggleSubList()" id="nl-view-btn" class="btn btn-ghost" style="font-size:.65rem;padding:2px 8px;margin-left:6px">View List</button></h4> 2964 <div id="nl-sub-list" style="display:none;margin-bottom:12px"></div> 2965 <p style="margin-bottom:10px;font-size:.73rem;color:var(--muted)">Push an analysis or macro post with HTML formatting â no image needed. HTML in the body renders directly in client dashboards.</p> 2966 <div class="form-group"><label class="form-label">TYPE</label> 2967 <select id="art-content-type"> 2968 <option value="macro">ð Macro</option> 2969 <option value="company_analysis">ð¢ Company Analysis</option> 2970 <option value="news">ð° News</option> 2971 <option value="economic">ð Economic</option> 2972 </select> 2973 </div> 2974 <div class="form-group"><label class="form-label">HEADLINE</label><input type="text" id="art-title" placeholder="Q1 2026 Earnings Preview: NVDA"></div> 2975 <div class="form-group"> 2976 <label class="form-label">BODY (HTML supported â <b>, <br>, <ul>, <table> etc.)</label> 2977 <textarea id="art-body" rows="8" placeholder="<b>Revenue</b>: $39.3B (+78% YoY)<br><b>
2977Key takeaway</b>: Data center growth continues..."></textarea> 2978 </div> 2979 <div class="form-group"><label class="form-label">SOURCE URL (optional)</label><input type="text" id="art-source" placeholder="https://..."></div> 2980 <div style="display:flex;align-items:center;gap:10px;margin-bottom:10px"> 2981 <input type="checkbox" id="art-newsletter" style="width:16px;height:16px;cursor:pointer;accent-color:var(--teal)"> 2982 <label for="art-newsletter" style="font-size:.78rem;color:var(--text2);cursor:pointer">ð§ Also send as newsletter to email subscribers</label> 2983 </div> 2984 <div style="display:flex;align-items:center;gap:10px;margin-bottom:14px"> 2985 <input type="checkbox" id="art-publish-blog" style="width:16px;height:16px;cursor:pointer;accent-color:#E2C275"> 2986 <label for="art-publish-blog" style="font-size:.78rem;color:var(--text2);cursor:pointer">ð Also publish to <strong style="color:#E2C275">factozcentral.com/blog</strong></label> 2987 </div> 2988 <button class="btn btn-primary" onclick="adminPushRichText()" id="art-btn">ð Push to Clients</button> 2989 <div id="art-result" style="margin-top:8px"></div> 2990 </div> 2991 2992 <div id="admin-news-list"></div> 2993 </div><!-- /as-news-push --> 2994 <hr> 2995 <!-- Newsletter Subscribers --> 2996 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:18px;cursor:pointer" onclick="toggleAdminSection('as-subscribers')"> 2997 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">NEWSLETTER SUBSCRIBERS <span id="as-subscribers-badge" style="color:var(--teal);margin-left:6px"></span><span id="as-subscribers-arrow" style="margin-left:4px">â¾</span></div> 2998 <button class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px" onclick="event.stopPropagation();loadSubscriberList()">â» Refresh</button> 2999 </div> 3000 <div id="as-subscribers"> 3001 <div class="card" style="margin-bottom:14px;border:1px solid rgba(0,200,183,.25)"> 3002 <p style="font-size:.73rem;color:var(--muted);margin-bottom:12px">Everyone on this list receives the daily brief and weekly signal digest automatically â no account needed.</p> 3003 3004 <!-- Signup link to paste into IG/TikTok DM replies. Lives here rather 3005 than in the Newsroom because this is the list it feeds. --> 3006 <div style="background:rgba(0,188,212,.07);border:1px solid rgba(0,188,212,.28);border-radius:8px;padding:11px 13px;margin-bottom:14px"> 3007 <div style="font-size:.68rem;color:var(--muted);font-family:var(--mono);letter-spacing:1px;margin-bottom:7px">LINK DE REGISTRO â para responder DMs</div> 3008 <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"> 3009 <code id="sub-signup-url" style="flex:1;min-width:220px;font-size:.78rem;color:var(--text);background:rgba(0,0,0,.25);padding:8px 10px;border-radius:6px;word-break:break-all"></code> 3010 <button class="btn btn-ghost" style="font-size:.7rem;padding:6px 11px" onclick="copySignupUrl(this)">ð Copiar</button> 3011 <a id="sub-signup-open" href="#" target="_blank" rel="noopener" class="btn btn-ghost" style="font-size:.7rem;padding:6px 11px;text-decoration:none">â Abrir</a> 3012 </div> 3013 <div style="font-size:.68rem;color:var(--muted);margin-top:7px">Quien se registre aquà entra a esta lista automáticamente y recibe el correo de confirmación.</div> 3014 </div> 3015 3016 <!-- Single add --> 3017 <div style="display:flex;gap:8px;margin-bottom:10px;align-items:flex-end"> 3018 <div class="form-group" style="flex:1;margin:0"> 3019 <label class="form-label">ADD EMAIL</label> 3020 <input type="email" id="sub-email-input" placeholder="[email protected]" style="width:100%"> 3021 </div> 3022 <button class="btn btn-primary" style="font-size:.75rem;padding:7px 16px;flex-shrink:0" onclick="addSubscriber()">+ Add</button> 3023 </div> 3024 3025 <!-- Bulk paste --> 3026 <div style="margin-bottom:10px"> 3027 <label class="form-label">BULK ADD (one email per line or comma-separated)</label> 3028 <textarea id="sub-bulk-input" rows="4" placeholder="[email protected] [email protected] [email protected]" style="width:100%;font-size:.75rem;line-height:1.6"></textarea> 3029 <button class="btn btn-ghost" style="font-size:.72rem;margin-top:6px" onclick="addSubscribersBulk()">⬠Import All</button> 3030 </div> 3031 <div id="sub-add-result" style="font-size:.75rem;margin-bottom:8px"></div> 3032 3033 <!-- Subscriber list --> 3034 <div id="sub-list" style="margin-top:10px;max-height:300px;overflow-y:auto"></div> 3035 </div> 3036 </div> 3037 3038 <hr> 3039 <!-- Outreach Tracker --> 3040 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:18px;
3040cursor:pointer" onclick="toggleAdminSection('as-outreach')"> 3041 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">OUTREACH TRACKER <span id="as-outreach-arrow" style="margin-left:4px">â¸</span></div> 3042 <button class="btn btn-yellow" style="font-size:.7rem;padding:5px 10px" onclick="event.stopPropagation();openOutreachForm()">+ Add Prospect</button> 3043 </div> 3044 <div id="as-outreach" style="display:none"> 3045 <!-- Add/Edit Form --> 3046 <div id="outreach-form-panel" style="display:none;background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:18px;margin-bottom:14px"> 3047 <h4 style="margin-bottom:14px;font-size:.82rem">New Prospect</h4> 3048 <div class="g2"> 3049 <div class="form-group"> 3050 <label class="form-label">NAME *</label> 3051 <input type="text" id="oc-name" placeholder="Jane Trader"> 3052 </div> 3053 <div class="form-group"> 3054 <label class="form-label">TOPIC (what they cover)</label> 3055 <input type="text" id="oc-topic" placeholder="crypto, small caps, macro"> 3056 </div> 3057 <div class="form-group"> 3058 <label class="form-label">EMAIL</label> 3059 <input type="email" id="oc-email" placeholder="[email protected]"> 3060 </div> 3061 <div class="form-group"> 3062 <label class="form-label">PRIMARY PLATFORM</label> 3063 <select id="oc-platform"> 3064 <option value="x">X / Twitter</option> 3065 <option value="youtube">YouTube</option> 3066 <option value="newsletter">Newsletter</option> 3067 <option value="other">Other</option> 3068 </select> 3069 </div> 3070 <div class="form-group"> 3071 <label class="form-label">SEGMENT</label> 3072 <select id="oc-segment"> 3073 <option value="creator">Finance Creator</option> 3074 <option value="advisor">RIA / Advisor</option> 3075 <option value="crypto">Crypto Influencer</option> 3076 <option value="other">Other</option> 3077 </select> 3078 </div> 3079 <div class="form-group"> 3080 <label class="form-label">X HANDLE</label> 3081 <input type="text" id="oc-handle-x" placeholder="@janetrader"> 3082 </div> 3083 <div class="form-group"> 3084 <label class="form-label">INSTAGRAM HANDLE</label> 3085 <input type="text" id="oc-handle-ig" placeholder="@janetrader"> 3086 </div> 3087 <div class="form-group"> 3088 <label class="form-label">TELEGRAM USERNAME</label> 3089 <input type="text" id="oc-handle-tg" placeholder="@janetrader"> 3090 </div> 3091 <div class="form-group"> 3092 <label class="form-label">LINKEDIN</label> 3093 <input type="text" id="oc-handle-li" placeholder="linkedin.com/in/jane"> 3094 </div> 3095 <div class="form-group"> 3096 <label class="form-label">TIKTOK HANDLE</label> 3097 <input type="text" id="oc-handle-tt" placeholder="@janetrader"> 3098 </div> 3099 </div> 3100 <div class="form-group"> 3101 <label class="form-label">PRIMARY HANDLE (if only one)</label> 3102 <input type="text" id="oc-handle" placeholder="@janetrader â fill if only one handle"> 3103 </div> 3104 <div class="form-group"> 3105 <label class="form-label">NOTES</label> 3106 <textarea id="oc-notes" rows="2" style="width:100%;background:var(--bg3);border:1px solid var(--border);border-radius:6px;color:var(--text);padding:8px;font-size:.78rem;resize:vertical" placeholder="Context, intro angle, mutual connections..."></textarea> 3107 </div> 3108 <div class="form-group"> 3109 <label class="form-label">PADDLE AFFILIATE LINK</label> 3110 <input type="url" id="oc-affiliate" placeholder="https://buy.paddle.com/...?affiliate=xxx" style="width:100%"> 3111 </div> 3112 <div style="display:flex;gap:10px;margin-top:10px"> 3113 <button class="btn btn-primary" onclick="saveOutreachContact()" id="oc-save-btn">ð¾ Save Prospect</button> 3114 <button class="btn btn-ghost" onclick="$('outreach-form-panel').style.display='none'">Cancel</button> 3115 </div> 3116 <div id="oc-result" style="margin-top:8px;font-size:.75rem"></div> 3117 </div> 3118 <!-- Contact List --> 3119 <div id="outreach-contact-list"><p style="font-size:.73rem;color:var(--muted)">Loading...</p></div> 3120 </div> 3121 <!-- Person Library â hidden --> 3122 <div style="display:none"> 3123 <hr> 3124 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;cursor:pointer" onclick="toggleAdminSection('as-carousel')"> 3125 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">CAROUSEL â PERSON LIBRARY <span id="as-carousel-arrow" style="margin-left:4px">
3125â¸</span></div> 3126 <button class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px" onclick="event.stopPropagation();loadPersons()">â» Refresh</button> 3127 </div> 3128 <div id="as-carousel" style="display:none"> 3129 <div class="card" style="margin-bottom:14px"> 3130 <h4>UPLOAD PERSON IMAGE</h4> 3131 <p style="margin-bottom:12px;font-size:.73rem">Upload cropped headshots of famous people (CEOs, politicians, etc). Clients will pick from this list when building carousels. Claude vision will read the image to generate captions.</p> 3132 <div class="form-group" style="margin-top:10px"> 3133 <label class="form-label">DISPLAY NAME (e.g. "Elon Musk")</label> 3134 <input type="text" id="person-name" placeholder="Elon Musk"> 3135 </div> 3136 <div class="form-group"> 3137 <label class="form-label">PHOTO (JPG/PNG â cropped headshot, min 500Ã500)</label> 3138 <input type="file" id="person-file" accept=".jpg,.jpeg,.png,.webp" style="color:var(--text);font-size:.78rem"> 3139 </div> 3140 <button class="btn btn-primary" onclick="uploadPerson()">ð¤ Upload Photo</button> 3141 <div id="person-upload-result" style="margin-top:8px"></div> 3142 </div> 3143 <!-- Person grid --> 3144 <div id="person-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;margin-bottom:20px"></div> 3145 </div><!-- /as-carousel --> 3146 </div><!-- hidden wrapper --> 3147 3148 <hr> 3149 <!-- Ad Manager --> 3150 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:18px;cursor:pointer" onclick="toggleAdminSection('as-ads')"> 3151 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">AD MANAGER <span id="as-ads-arrow" style="margin-left:4px">â¸</span></div> 3152 <button class="btn btn-yellow" style="font-size:.7rem;padding:5px 10px" onclick="event.stopPropagation();document.getElementById('ad-upload-panel').style.display='block'">+ New Ad</button> 3153 </div> 3154 <div id="as-ads" style="display:none"> 3155 <!-- Upload form --> 3156 <div id="ad-upload-panel" style="display:none;background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:18px;margin-bottom:14px"> 3157 <h4 style="margin-bottom:14px;font-size:.82rem">New Ad Slot</h4> 3158 <div class="g2"> 3159 <div class="form-group"> 3160 <label class="form-label">SLOT NAME</label> 3161 <select id="ad-slot-name" style="width:100%;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:7px 10px;color:var(--text);font-size:.78rem"> 3162 <option value="overview-top">overview-top â Above bento grid</option> 3163 <option value="news-sidebar">news-sidebar â Below news tabs</option> 3164 <option value="scanner-bottom">scanner-bottom â Below scanner</option> 3165 <option value="carousel-side">carousel-side â Carousel sidebar</option> 3166 </select> 3167 </div> 3168 <div class="form-group"> 3169 <label class="form-label">INTERNAL LABEL (optional)</label> 3170 <input type="text" id="ad-label" placeholder="Acme Corp Q1 2026"> 3171 </div> 3172 </div> 3173 <div class="form-group"> 3174 <label class="form-label">CLICK URL (optional)</label> 3175 <input type="text" id="ad-link-url" placeholder="https://example.com"> 3176 </div> 3177 <div class="form-group"> 3178 <label class="form-label">IMAGE (GIF or PNG â recommended 728Ã90 banner or 300Ã250 square)</label> 3179 <input type="file" id="ad-file" accept=".gif,.png,.jpg,.jpeg,.webp" style="color:var(--text);font-size:.78rem"> 3180 </div> 3181 <div style="display:flex;gap:8px"> 3182 <button class="btn btn-primary" onclick="uploadAdSlot()" id="ad-upload-btn">ð¤ Upload Ad</button> 3183 <button class="btn btn-ghost" onclick="document.getElementById('ad-upload-panel').style.display='none'">Cancel</button> 3184 </div> 3185 <div id="ad-upload-result" style="margin-top:8px;font-size:.75rem"></div> 3186 </div> 3187 <!-- Ad list --> 3188 <div id="ad-slot-list"></div> 3189 </div> 3190 3191 <!-- ââ OPTIONS FLOW PERFORMANCE ââ --> 3192 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:22px;
3192cursor:pointer" onclick="toggleAdminSection('as-opts-perf')"> 3193 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">OPTIONS FLOW PERFORMANCE <span id="as-opts-perf-arrow" style="margin-left:4px">â¸</span></div> 3194 <button class="btn btn-ghost" style="font-size:.7rem;padding:4px 10px" onclick="event.stopPropagation();loadOptionsPerformance()">â» Refresh</button> 3195 </div> 3196 <div id="as-opts-perf" style="display:none"> 3197 <!-- Summary bar --> 3198 <div id="opts-perf-summary" style="display:flex;gap:20px;flex-wrap:wrap;margin-bottom:16px;padding:14px 16px;background:var(--bg3);border-radius:8px;border:1px solid var(--border)"> 3199 <div style="text-align:center"> 3200 <div style="font-size:1.4rem;font-weight:700;color:var(--green)" id="opts-perf-winrate">â</div> 3201 <div style="font-size:.62rem;color:var(--muted);letter-spacing:1px;margin-top:2px">OVERALL WIN RATE</div> 3202 </div> 3203 <div style="text-align:center"> 3204 <div style="font-size:1.4rem;font-weight:700;color:var(--text)" id="opts-perf-total">â</div> 3205 <div style="font-size:.62rem;color:var(--muted);letter-spacing:1px;margin-top:2px">ARCHIVED</div> 3206 </div> 3207 <div style="text-align:center"> 3208 <div style="font-size:1.4rem;font-weight:700;color:var(--text)" id="opts-perf-resolved">â</div> 3209 <div style="font-size:.62rem;color:var(--muted);letter-spacing:1px;margin-top:2px">RESOLVED</div> 3210 </div> 3211 <div id="opts-perf-tickers" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center"></div> 3212 </div> 3213 <!-- Week range filter --> 3214 <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap"> 3215 <label style="font-size:.7rem;color:var(--muted)">WEEKS BACK</label> 3216 <select id="opts-perf-weeks" onchange="loadOptionsPerformance()" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);font-size:.75rem;padding:4px 8px;border-radius:5px"> 3217 <option value="4">4 weeks</option> 3218 <option value="12" selected>12 weeks</option> 3219 <option value="26">26 weeks</option> 3220 <option value="52">52 weeks</option> 3221 </select> 3222 <input type="text" id="opts-perf-ticker" placeholder="Filter ticker (e.g. MSFT)" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);font-size:.75rem;padding:4px 10px;border-radius:5px;width:160px" onkeydown="if(event.key==='Enter')loadOptionsPerformance()"> 3223 </div> 3224 <!-- Weekly table --> 3225 <div style="font-size:.68rem;color:var(--muted);letter-spacing:1.5px;margin-bottom:6px">WEEKLY BREAKDOWN</div> 3226 <div style="overflow-x:auto;margin-bottom:18px"> 3227 <table style="width:100%;border-collapse:collapse;font-size:.76rem" id="opts-perf-weekly-tbl"> 3228 <thead> 3229 <tr style="border-bottom:1px solid var(--border)"> 3230 <th style="text-align:left;padding:6px 8px;color:var(--muted);font-weight:600">WEEK</th> 3231 <th style="text-align:right;padding:6px 8px;color:var(--muted);font-weight:600">TOTAL</th> 3232 <th style="text-align:right;padding:6px 8px;color:var(--muted);font-weight:600">RESOLVED</th> 3233 <th style="text-align:right;padding:6px 8px;color:var(--muted);font-weight:600">WIN</th> 3234 <th style="text-align:right;padding:6px 8px;color:var(--muted);font-weight:600">WIN RATE</th> 3235 <th style="text-align:right;padding:6px 8px;color:var(--muted);font-weight:600">CALL ITM</th> 3236 <th style="text-align:right;padding:6px 8px;color:var(--muted);font-weight:600">PUT OTM</th> 3237 </tr> 3238 </thead> 3239 <tbody id="opts-perf-weekly-body"></tbody> 3240 </table> 3241 </div> 3242 <!-- Contract list --> 3243 <div style="font-size:.68rem;color:var(--muted);letter-spacing:1.5px;margin-bottom:6px">ALL ARCHIVED CONTRACTS</div> 3244 <div style="overflow-x:auto"> 3245 <table style="width:100%;border-collapse:collapse;font-size:.72rem" id="opts-perf-contracts-tbl"> 3246 <thead> 3247 <tr style="border-bottom:1px solid var(--border)"> 3248 <th style="text-align:left;padding:5px 8px;color:var(--muted);font-weight:600">WEEK</th> 3249 <th style="text-align:left;padding:5px 8px;color:var(--muted);font-weight:600">TICKER</th> 3250 <th style="text-align:left;padding:5px 8px;color:var(--muted);font-weight:600">TYPE</th> 3251 <th style="text-align:right;padding:5px 8px;color:var(--muted);font-weight:600">STRIKE</th> 3252 <th style="text-align:left;padding:5px 8px;color:var(--muted);font-weight:600">EXPIRY</th> 3253 <th style="text-align:right;padding:5px 8px;color:var(--muted);font-weight:600">PREMIUM</th> 3254 <th style="text-align:right;padding:5px 8px;color:var(--muted);font-weight:600">VOL/OI</th> 3255 <th style="text-align:left;padding:5px 8px;color:var(--muted);font-weight:600">RESULT</th> 3256 <th style="text-align:right;padding:5px 8px;color:var(--muted);font-weight:600">P&L %</th> 3257 <th style="text-align:left;padding:5px 8px;color:var(--muted);font-weight:600">FLAGGED</th> 3258 </tr> 3259 </thead> 3260 <tbody id="opts-perf-contracts-body"></tbody> 3261 </table> 3262 <div id="opts-perf-empty" style="display:none;padding:24px 0;text-align:center;font-size:.75rem;color:var(--muted)">No archived contracts yet. Data appears here after contracts expire + newsletter is sent (30-day retention before archival).</div> 3263 </div> 3264 </div> 3265 3266 <!-- ââ EQUITY SCANNER PERFORMANCE ââ --> 3267 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;margin-top:22px;
3267cursor:pointer" onclick="toggleAdminSection('as-scan-perf')"> 3268 <div style="font-size:.8rem;color:var(--text);font-family:var(--mono);letter-spacing:2px">EQUITY SCANNER PERFORMANCE <span id="as-scan-perf-arrow" style="margin-left:4px">â¸</span></div> 3269 <button class="btn btn-ghost" style="font-size:.7rem;padding:4px 10px" onclick="event.stopPropagation();loadScannerPerformance()">â» Refresh</button> 3270 </div> 3271 <div id="as-scan-perf" style="display:none"> 3272 <!-- Summary bar --> 3273 <div id="scan-perf-summary" style="display:flex;gap:20px;flex-wrap:wrap;margin-bottom:16px;padding:14px 16px;background:var(--bg3);border-radius:8px;border:1px solid var(--border)"> 3274 <div style="text-align:center"> 3275 <div style="font-size:1.4rem;font-weight:700;color:var(--green)" id="scan-perf-winrate">â</div> 3276 <div style="font-size:.62rem;color:var(--muted);letter-spacing:1px;margin-top:2px">30D WIN RATE</div> 3277 </div> 3278 <div style="text-align:center"> 3279 <div style="font-size:1.4rem;font-weight:700;color:var(--text)" id="scan-perf-total">â</div> 3280 <div style="font-size:.62rem;color:var(--muted);letter-spacing:1px;margin-top:2px">TOTAL PICKS</div> 3281 </div> 3282 <div style="text-align:center"> 3283 <div style="font-size:1.4rem;font-weight:700;color:var(--text)" id="scan-perf-resolved">â</div> 3284 <div style="font-size:.62rem;color:var(--muted);letter-spacing:1px;margin-top:2px">RESOLVED (30D)</div> 3285 </div> 3286 </div> 3287 <!-- Filters --> 3288 <div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap"> 3289 <label style="font-size:.7rem;color:var(--muted)">DAYS BACK</label> 3290 <select id="scan-perf-days" onchange="loadScannerPerformance()" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);font-size:.75rem;padding:4px 8px;border-radius:5px"> 3291 <option value="30">30 days</option> 3292 <option value="90">90 days</option> 3293 <option value="180" selected>180 days</option> 3294 <option value="365">365 days</option> 3295 </select> 3296 <label style="font-size:.7rem;color:var(--muted)">STRATEGY</label> 3297 <select id="scan-perf-strategy" onchange="loadScannerPerformance()" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);font-size:.75rem;padding:4px 8px;border-radius:5px"> 3298 <option value="">All strategies</option> 3299 <option value="rsi_extremes">RSI Extremes (Pre-Market)</option> 3300 <option value="premarket_movers">Pre-Market Movers (MA Crossover, retired 2026-08-31)</option> 3301 <option value="breakout_52wk">52-Week Breakout</option> 3302 <option value="gap_and_hold">Gap & Hold</option> 3303 </select> 3304 <input type="text" id="scan-perf-ticker" placeholder="Filter ticker (e.g. AAPL)" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);font-size:.75rem;padding:4px 10px;border-radius:5px;width:160px" onkeydown="if(event.key==='Enter')loadScannerPerformance()"> 3305 </div> 3306 <!-- Horizon breakdown table --> 3307 <div style="font-size:.68rem;color:var(--muted);letter-spacing:1.5px;margin-bottom:6px">BY STRATEGY & HORIZON</div> 3308 <div style="overflow-x:auto;margin-bottom:18px"> 3309 <table style="width:100%;border-collapse:collapse;font-size:.76rem" id="scan-perf-horizon-tbl"> 3310 <thead> 3311 <tr style="border-bottom:1px solid var(--border)"> 3312 <th style="text-align:left;padding:6px 8px;color:var(--muted);font-weight:600">STRATEGY</th> 3313 <th style="text-align:left;padding:6px 8px;color:var(--muted);font-weight:600">HORIZON</th> 3314 <th style="text-align:right;padding:6px 8px;color:var(--muted);font-weight:600">RESOLVED</th> 3315 <th style="text-align:right;padding:6px 8px;color:var(--muted);font-weight:600">PENDING</th> 3316 <th style="text-align:right;padding:6px 8px;color:var(--muted);font-weight:600">WIN RATE</th> 3317 <th style="text-align:right;padding:6px 8px;color:var(--muted);font-weight:600">95% CI</th> 3318 <th style="text-align:right;padding:6px 8px;color:var(--muted);font-weight:600">MEDIAN VS SPY</th> 3319 </tr> 3320 </thead> 3321 <tbody id="scan-perf-horizon-body"></tbody> 3322 </table> 3323 </div> 3324 <!-- Recent picks --> 3325 <div style="font-size:.68rem;color:var(--muted);letter-spacing:1.5px;margin-bottom:6px">RECENT PICKS</div> 3326 <div style="overflow-x:auto"> 3327 <table style="width:100%;border-collapse:collapse;font-size:.72rem" id="scan-perf-picks-tbl"> 3328 <thead> 3329 <tr style="border-bottom:1px solid var(--border)"> 3330 <th style="text-align:left;padding:5px 8px;color:var(--muted);font-weight:600">TICKER</th> 3331 <th style="text-align:left;padding:5px 8px;color:var(--muted);font-weight:600">STRATEGY</th> 3332 <th style="text-align:left;padding:5px 8px;color:var(--muted);font-weight:600">DIRECTION</th> 3333 <th style="text-align:left;padding:5px 8px;color:var(--muted);font-weight:600">CALLED</th> 3334 <th style="text-align:right;padding:5px 8px;color:var(--muted);font-weight:600">ENTRY</th> 3335 <th style="text-align:right;padding:5px 8px;color:var(--muted);font-weight:600">30D VS SPY</th> 3336 </tr> 3337 </thead> 3338 <tbody id="scan-perf-picks-body"></tbody> 3339 </table> 3340 <div id="scan-perf-empty" style="display:none;padding:24px 0;text-align:center;font-size:.75rem;color:var(--muted)">No scan picks yet in this window.</div> 3341 </div> 3342 </div> 3343</div> 3344 3345<!-- ââ DAILY BRIEF MODAL ââ --> 3346<div id="brief-modal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.85);backdrop-filter:blur(4px);align-items:flex-start;justify-content:center;padding:20px;overflow-y:auto" onclick="if(event.target===this)closeBriefModal()">
3347 <div style="background:#fff;border-radius:10px;width:100%;max-width:720px;margin:0 auto;overflow:hidden;position:relative"> 3348 <div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:#0b132b;border-bottom:1px solid #202e56"> 3349 <div id="brief-modal-title" style="font-size:.8rem;font-weight:700;color:#c5d0dc;font-family:monospace;letter-spacing:1px"></div> 3350 <button onclick="closeBriefModal()" style="background:none;border:none;color:#7a8fa3;font-size:1.2rem;cursor:pointer;padding:2px 6px">â</button> 3351 </div> 3352 <iframe id="brief-modal-frame" style="width:100%;min-height:80vh;border:none;display:block"></iframe> 3353 </div> 3354</div> 3355 3356<!-- ââ DESIGN STUDIO ââ --> 3357<div class="page" id="page-studio"> 3358 <div class="page-header"> 3359 <div class="page-title"><lord-icon src="https://cdn.lordicon.com/cbtlerlm.json" trigger="hover" stroke="bold" style="width:28px;height:28px"></lord-icon>Design Studio</div> 3360 <div class="page-sub">Create branded carousels & posts â powered by ContentDrips</div> 3361 </div> 3362 3363 <div style="margin-bottom:20px"> 3364 <button class="btn btn-primary" onclick="openDesignStudio()" id="studio-open-btn">âï¸ Open Design Editor</button> 3365 <span style="font-size:.72rem;color:var(--muted);margin-left:12px">Design a carousel or post â your exports are saved automatically</span> 3366 </div> 3367 3368 <div id="studio-saving" style="display:none;font-size:.75rem;color:var(--yellow);margin-bottom:12px">Saving your design...</div> 3369 3370 <div style="font-size:.7rem;font-weight:700;color:var(--muted);letter-spacing:2px;margin-bottom:10px">MY DESIGNS</div> 3371 <div id="studio-gallery" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px"></div> 3372 <div id="studio-empty" style="display:none;font-size:.75rem;color:var(--muted);padding:20px 0">No designs yet. Click "Open Design Editor" to get started.</div> 3373</div> 3374 3375 3376<!-- ââ DESIGN LAB ââ --> 3377<div class="page" id="page-designlab"> 3378 <div class="page-header"> 3379 <div class="page-title">ð¨ Design Lab</div> 3380 <div class="page-sub">Create branded social content â backgrounds, fonts, photos & your gallery</div> 3381 </div> 3382
3383 <!-- ââ Background Gallery ââ --> 3384 <div style="margin-bottom:16px"> 3385 <div style="font-size:.65rem;font-weight:700;color:var(--muted);letter-spacing:1.5px;margin-bottom:8px">BACKGROUND</div> 3386 <div style="display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:10px"> 3387 <button id="dl-tab-gradients" onclick="dlSetBgTab('gradients')" style="background:none;border:none;border-bottom:2px solid var(--teal);padding:6px 16px;font-size:.75rem;font-weight:700;color:var(--teal);cursor:pointer">Gradients</button> 3388 <button id="dl-tab-photos" onclick="dlSetBgTab('photos')" style="background:none;border:none;border-bottom:2px solid transparent;padding:6px 16px;font-size:.75rem;font-weight:700;color:var(--muted);cursor:pointer">Photos</button> 3389 <button id="dl-tab-colors" onclick="dlSetBgTab('colors')" style="background:none;border:none;border-bottom:2px solid transparent;padding:6px 16px;font-size:.75rem;font-weight:700;color:var(--muted);cursor:pointer">Color</button> 3390 <button id="dl-tab-mybgs" onclick="dlSetBgTab('mybgs')" style="background:none;border:none;border-bottom:2px solid transparent;padding:6px 16px;font-size:.75rem;font-weight:700;color:var(--muted);cursor:pointer">My BGs</button> 3391 </div> 3392 <div id="dl-bg-gradients" style="display:flex;flex-wrap:wrap;gap:8px"></div> 3393 <div id="dl-bg-photos" style="display:none"> 3394 <div id="dl-photo-cats" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px"></div> 3395 <div id="dl-photo-grid" style="display:flex;flex-wrap:wrap;gap:8px;max-height:280px;overflow-y:auto;padding-bottom:4px"></div> 3396 </div> 3397 <div id="dl-bg-colors" style="display:none;align-items:center;gap:10px;flex-wrap:wrap"> 3398 <div style="display:flex;flex-wrap:wrap;gap:6px" id="dl-color-swatches"></div> 3399 <div style="display:flex;align-items:center;gap:6px"> 3400 <label style="font-size:.65rem;color:var(--muted)">Custom:</label> 3401 <input type="color" id="dl-bg-color" value="#0b132b" title="Background color" onchange="dlSetBg(this.value)" style="width:36px;height:32px;border:none;border-radius:4px;cursor:pointer;padding:2px"> 3402 </div> 3403 </div> 3404 <div id="dl-bg-mybgs" style="display:none;flex-direction:column;gap:8px"> 3405 <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"> 3406 <input type="file" id="dl-mybg-upload" accept="image/*" style="display:none" onchange="dlUploadMyBg(this)"> 3407 <button class="btn btn-ghost" style="font-size:.72rem" onclick="document.getElementById('dl-mybg-upload').click()">ï¼ Upload Background</button> 3408 <span style="font-size:.65rem;color:var(--muted)">Saved to your Gallery â click any to set as background</span> 3409 </div> 3410 <div id="dl-mybg-grid" style="display:flex;flex-wrap:wrap;gap:8px;max-height:250px;overflow-y:auto"></div> 3411 </div> 3412 </div> 3413 3414 <!-- ââ Toolbar ââ --> 3415 <div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px;align-items:center"> 3416 <button class="btn btn-ghost" style="font-size:.78rem" onclick="dlAddText()">ï¼ Text</button> 3417 <button class="btn btn-ghost" style="font-size:.78rem" onclick="dlAddRect()">â Rect</button> 3418 <button class="btn btn-ghost" style="font-size:.78rem" onclick="dlAddCircle()">â Circle</button> 3419 <select id="dl-font-select" onchange="dlLoadFont(this.value)" title="Font" style="background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--fg);font-size:.75rem;padding:5px 8px;cursor:pointer;max-width:150px"></select> 3420 <input type="number" id="dl-font-size" value="72" min="12" max="300" title="Font size" style="width:60px;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--fg);font-size:.75rem;padding:5px 6px;text-align:center"> 3421 <label style="font-size:.65rem;color:var(--muted)">Fill</label> 3422 <input type="color" id="dl-fill-color" value="#e2c275" title="Object fill color" style="width:32px;height:28px;border:none;border-radius:4px;cursor:pointer;padding:2px"> 3423 <input type="file" id="dl-img-upload" accept="image/*" onchange="dlAddImageFile(this)" style="display:none"> 3424 <button class="btn btn-ghost" style="font-size:.78rem" onclick="document.getElementById('dl-img-upload').click()">ð Local</button> 3425 <button class="btn btn-ghost" style="font-size:.78rem" onclick="dlOpenGalleryPicker()">ð¼ Gallery</button> 3426 <button class="btn btn-ghost" style="font-size:.78rem" title="Send selected object behind others" onclick="dlSendToBack()">⬠Back</button> 3427 <button class="btn btn-ghost" style="font-size:.78rem" title="Bring selected object to front" onclick="dlBringToFront()">⬠Front</button> 3428 <button class="btn btn-ghost" style="font-size:.78rem;color:var(--red)" onclick="dlDeleteSelected()">ð Delete</button> 3429 <button id="dl-draw-btn" class="btn btn-ghost" style="font-size:.78rem" onclick="dlToggleDraw()" title="Draw mode â use finger or stylus">âï¸ Draw</button>
3430 <input id="dl-brush-size" type="range" min="1" max="20" value="3" style="display:none;width:70px;accent-color:var(--teal);cursor:pointer" oninput="dlUpdateBrush()" title="Brush size"> 3431 <select id="dl-brush-type" style="display:none;font-size:.72rem;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 6px" onchange="dlUpdateBrush()"> 3432 <option value="pencil">Pencil</option> 3433 <option value="marker">Marker</option> 3434 <option value="spray">Spray</option> 3435 </select> 3436 <button class="btn btn-primary" style="font-size:.78rem" onclick="dlExportPng()">â Export PNG</button> 3437 </div> 3438 3439 <!-- ââ Canvas size presets ââ --> 3440 <div style="display:flex;gap:6px;align-items:center;margin-bottom:14px;font-size:.72rem;color:var(--muted);flex-wrap:wrap"> 3441 <span>Canvas:</span> 3442 <button class="btn btn-ghost" style="font-size:.7rem;padding:4px 10px" onclick="dlResize(1080,1080)">1:1 Square</button> 3443 <button class="btn btn-ghost" style="font-size:.7rem;padding:4px 10px" onclick="dlResize(1080,1920)">9:16 Story</button> 3444 <button class="btn btn-ghost" style="font-size:.7rem;padding:4px 10px" onclick="dlResize(1200,628)">16:9 Banner</button> 3445 </div> 3446 3447 <!-- ââ Canvas ââ --> 3448 <div style="border:1px solid var(--border);border-radius:10px;overflow:hidden;display:inline-block;max-width:100%"> 3449 <canvas id="dl-canvas"></canvas> 3450 </div> 3451 3452 <!-- ââ Gallery Picker Modal ââ --> 3453 <div id="dl-gallery-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:9000;overflow-y:auto"> 3454 <div style="background:var(--card);border:1px solid var(--border);border-radius:12px;max-width:680px;margin:40px auto;padding:20px"> 3455 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px"> 3456 <div style="font-size:.85rem;font-weight:700;color:var(--fg)">Pick from Gallery</div> 3457 <button onclick="document.getElementById('dl-gallery-modal').style.display='none'" style="background:none;border:none;cursor:pointer;font-size:1.2rem;color:var(--muted)">â</button> 3458 </div> 3459 <div id="dl-gallery-picker-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px"></div> 3460 <div id="dl-gallery-picker-more" style="text-align:center;margin-top:12px;display:none"> 3461 <button class="btn btn-ghost" style="font-size:.75rem" onclick="dlLoadGalleryPickerMore()">Load more</button> 3462 </div> 3463 </div> 3464 </div> 3465 3466 <!-- ââ Article Reader Modal ââ --> 3467 <div id="article-reader-modal" style="display:none;position:fixed;inset:0;z-index:1200;background:rgba(0,0,0,.75);overflow-y:auto;padding:20px 16px" onclick="if(event.target===this)closeArticleModal()"> 3468 <div style="max-width:820px;margin:0 auto;background:var(--bg2);border:1px solid var(--border);border-radius:12px;overflow:hidden"> 3469 <div id="article-reader-header" style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border);gap:12px"> 3470 <div id="article-reader-meta" style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"></div> 3471 <button onclick="closeArticleModal()" style="background:none;border:none;color:var(--muted);font-size:1.3rem;cursor:pointer;line-height:1;padding:2px 6px">â</button> 3472 </div> 3473 <div id="article-reader-title" style="padding:16px 20px 0;font-weight:700;font-size:1rem;color:var(--text);line-height:1.4"></div> 3474 <div id="article-reader-body" style="padding:16px 20px 20px;overflow-x:auto"></div> 3475 <div id="article-reader-actions" style="display:flex;gap:8px;flex-wrap:wrap;padding:14px 20px;border-top:1px solid var(--border)"></div> 3476 </div> 3477 </div> 3478</div> 3479 3480<!-- ââ FACTOZ GRAPHS ââ --> 3481<div class="page" id="page-graphs"> 3482 <div class="page-header"> 3483 <div class="page-title"><lord-icon src="https://cdn.lordicon.com/pgirtdfe.json" trigger="hover" stroke="bold" state="hover-partial-roll" style="width:28px;height:28px"></lord-icon>Factoz Graphs</div> 3484 <div class="page-sub">Interactive charts â preview, download, or save to your Factoz gallery</div> 3485 </div> 3486 <div id="graphs-filters" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px"></div> 3487 <div id="graphs-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:14px"></div> 3488</div> 3489 3490<!-- ââ LIVE CHARTS ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ --> 3491<div class="page" id="page-charts">
3492 <div class="page-header"> 3493 <div class="page-title"><lord-icon src="https://cdn.lordicon.com/mwhabkof.json" trigger="hover" style="width:28px;height:28px"></lord-icon>Live Charts</div> 3494 <div class="page-sub">Interactive advanced charts â powered by TradingView</div> 3495 </div> 3496 3497 <!-- TV Advanced Chart --> 3498 <div id="tv-chart-wrap" style="width:100%;height:700px;margin-bottom:32px;background:#0b132b;border:1px solid #202e56;border-radius:10px;overflow:hidden"></div> 3499 3500 <!-- Pine Script Library --> 3501 <div style="border-top:1px solid #21262d;padding-top:28px;margin-top:8px"> 3502 <div style="margin-bottom:20px"> 3503 <div style="font-size:.6rem;font-family:var(--mono);letter-spacing:3px;color:var(--text2);margin-bottom:6px">PINE SCRIPT LIBRARY</div> 3504 <div style="font-size:1.1rem;font-weight:700;color:var(--text)">16 curated indicators â ready to paste into TradingView</div> 3505 </div> 3506 <div id="pine-library-wrap"></div> 3507 </div> 3508 3509</div> 3510 3511<!-- ââ GALLERY ââ --> 3512<div class="page" id="page-gallery"> 3513 <div class="page-header"> 3514 <div class="page-title"><lord-icon src="https://cdn.lordicon.com/fcwmlcjj.json" trigger="hover" style="width:28px;height:28px"></lord-icon>Gallery</div> 3515 <div class="page-sub">All your saved images â carousels, collages, AI-generated, uploads</div> 3516 </div> 3517 <div style="display:flex;align-items:center;gap:10px;margin-bottom:16px;flex-wrap:wrap"> 3518 <label style="cursor:pointer" class="btn btn-ghost"> 3519 ð¤ Upload Image 3520 <input type="file" accept="image/*" multiple style="display:none" onchange="uploadGalleryFiles(this)"> 3521 </label> 3522 <span id="gallery-upload-status" style="font-size:.7rem;color:var(--muted)"></span> 3523 </div> 3524 <div style="display:flex;gap:7px;margin-bottom:16px;flex-wrap:wrap"> 3525 <button class="btn gallery-filter active-filter" data-type="" onclick="loadGallery('')">All</button> 3526 <button class="btn btn-ghost gallery-filter" data-type="carousel_pil" onclick="loadGallery('carousel_pil')">ð Carousels</button> 3527 <button class="btn btn-ghost gallery-filter" data-type="collage" onclick="loadGallery('collage')">ð§© Collages</button> 3528 <button class="btn btn-ghost gallery-filter" data-type="ai_image" onclick="loadGallery('ai_image')">ð¤ AI Images</button> 3529 <button class="btn btn-ghost gallery-filter" data-type="design_studio" onclick="loadGallery('design_studio')">âï¸ Studio</button> 3530 <button class="btn btn-ghost gallery-filter" data-type="upload" onclick="loadGallery('upload')">ð¤ Uploads</button> 3531 </div> 3532 <div id="gallery-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px"></div> 3533 <div id="gallery-load-more" style="text-align:center;margin-top:16px;display:none"> 3534 <button class="btn btn-ghost" onclick="loadGalleryMore()">Load more</button> 3535 </div> 3536</div> 3537 3538</div><!-- /main --> 3539 3540<!-- Gallery lightbox modal --> 3541<div id="gallery-lightbox" onclick="if(event.target===this)closeGalleryLightbox()" 3542 style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.88);z-index:1100; 3543 align-items:center;justify-content:center;padding:16px"> 3544 <div style="position:relative;display:flex;flex-direction:column;align-items:center;max-width:900px;width:100%;max-height:96vh"> 3545 <!-- top bar --> 3546 <div style="display:flex;justify-content:space-between;align-items:center;width:100%;margin-bottom:10px"> 3547 <div style="display:flex;gap:8px;align-items:center"> 3548 <span id="lb-type-label" style="font-size:.68rem;color:rgba(255,255,255,.5);font-family:var(--mono);letter-spacing:1px"></span> 3549 <span id="lb-slide-counter" style="font-size:.68rem;color:rgba(255,255,255,.4);font-family:var(--mono)"></span> 3550 </div> 3551 <div style="display:flex;gap:8px"> 3552 <button onclick="downloadGalleryImage()" style="background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.2);color:#fff;border-radius:6px;padding:5px 14px;cursor:pointer;font-size:.75rem">⬠Download</button> 3553 <button onclick="closeGalleryLightbox()" style="background:none;border:none;color:rgba(255,255,255,.6);cursor:pointer;font-size:1.4rem;line-height:1;padding:0 4px">â</button> 3554 </div> 3555 </div> 3556 <!-- image --> 3557 <img id="lb-img" src="" style="max-width:100%;max-height:78vh;object-fit:contain;border-radius:8px;display:block">
3558 <!-- slide nav (multi-slide only) --> 3559 <div id="lb-nav" style="display:none;align-items:center;gap:16px;margin-top:12px"> 3560 <button onclick="lbNav(-1)" style="background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.2);color:#fff;border-radius:6px;padding:6px 16px;cursor:pointer;font-size:.9rem">â¹ Prev</button> 3561 <div id="lb-dots" style="display:flex;gap:6px"></div> 3562 <button onclick="lbNav(1)" style="background:rgba(255,255,255,.1);border:1px solid rgba(255,255,255,.2);color:#fff;border-radius:6px;padding:6px 16px;cursor:pointer;font-size:.9rem">Next âº</button> 3563 </div> 3564 </div> 3565</div> 3566 3567<!-- Earnings detail modal --> 3568<div id="earnings-detail-modal" onclick="if(event.target===this)this.style.display='none'" 3569 style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:1000; 3570 align-items:center;justify-content:center;padding:20px"> 3571 <div style="background:var(--bg2);border-radius:12px;width:100%;max-width:520px;max-height:88vh; 3572 display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--border)"> 3573 <div style="display:flex;justify-content:space-between;align-items:center; 3574 padding:14px 18px;border-bottom:1px solid var(--border);flex-shrink:0"> 3575 <div id="earnings-detail-title" style="font-weight:700;font-size:1.1rem"></div> 3576 <button onclick="$('earnings-detail-modal').style.display='none'" 3577 style="background:none;border:none;cursor:pointer;font-size:1.2rem;color:var(--muted)">â</button> 3578 </div> 3579 <div id="earnings-detail-body" style="padding:16px 18px;overflow-y:auto;flex:1"></div> 3580 </div> 3581</div> 3582 3583<!-- Graph preview modal --> 3584<div id="graph-modal-overlay" onclick="closeGraphModal(event)" 3585 style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:1000; 3586 align-items:center;justify-content:center;padding:20px"> 3587 <div style="background:var(--card);border-radius:12px;width:100%;max-width:900px; 3588 height:90vh;overflow:hidden;display:flex;flex-direction:column"> 3589 <div style="display:flex;justify-content:space-between;align-items:center; 3590 padding:14px 18px;border-bottom:1px solid var(--border)"> 3591 <div id="graph-modal-title" style="font-weight:700;font-size:.9rem"></div> 3592 <div style="display:flex;gap:8px;align-items:center"> 3593 <button id="graph-dl-btn" onclick="downloadGraphPNG()" class="btn btn-ghost" 3594 style="font-size:.72rem">â Download PNG</button> 3595 <button onclick="closeGraphModal(null)" 3596 style="background:none;border:none;cursor:pointer;font-size:1.2rem;color:var(--muted)">â</button> 3597 </div> 3598 </div> 3599 <iframe id="graph-modal-iframe" src="" loading="lazy" 3600 style="width:100%;border:none;flex:1;min-height:0"></iframe> 3601 <div style="padding:6px 18px;font-size:.65rem;color:var(--muted);border-top:1px solid var(--border)"> 3602 Source: <a id="graph-modal-source" href="#" target="_blank" style="color:var(--accent)">Our World in Data</a> 3603 · Data is interactive â explore countries, timeframes, and chart types above 3604 </div> 3605 </div> 3606</div> 3607 3608<div id="toast"></div> 3609 3610
3611<script> 3612const API=''; 3613let TOKEN=localStorage.getItem('fz_token')||''; 3614let USER={}; 3615 3616// ââ I18N âââââââââââââââââââââââââââââââ 3617// Default locale is Spanish app-wide. Priority: logged-in user's saved 3618// locale (synced in onLogin) > localStorage (anonymous/pre-login) > 'es'. 3619// Never derived from navigator.language. 3620let LOCALE=localStorage.getItem('fz_locale')||'es'; 3621const _I18N_RESOURCES={}; 3622async function _loadLocaleFile(lng){ 3623 if(_I18N_RESOURCES[lng])return _I18N_RESOURCES[lng]; 3624 try{ 3625 const r=await fetch('/i18n/'+lng+'.json'); 3626 const j=await r.json(); 3627 _I18N_RESOURCES[lng]=j; 3628 return j; 3629 }catch(e){return {};} 3630} 3631async function initI18n(){ 3632 const [es,en]=await Promise.all([_loadLocaleFile('es'),_loadLocaleFile('en')]); 3633 await i18next.init({ 3634 lng:LOCALE, 3635 fallbackLng:'es', 3636 resources:{es:{translation:es},en:{translation:en}}, 3637 }); 3638 document.documentElement.lang=LOCALE; 3639 applyStaticTranslations(); 3640 _updateLangToggle(); 3641} 3642function t(key){return (window.i18next&&i18next.isInitialized)?i18next.t(key):key;} 3643function applyStaticTranslations(){ 3644 document.querySelectorAll('[data-i18n]').forEach(el=>{el.textContent=t(el.getAttribute('data-i18n'));}); 3645 document.querySelectorAll('[data-i18n-placeholder]').forEach(el=>{el.setAttribute('placeholder',t(el.getAttribute('data-i18n-placeholder')));}); 3646 if(typeof buildNav==='function'&&USER&&USER.tier)buildNav(USER.tier); 3647} 3648async function setLocale(lng){ 3649 if(lng!=='es'&&lng!=='en')return; 3650 LOCALE=lng; 3651 localStorage.setItem('fz_locale',lng); 3652 await i18next.changeLanguage(lng); 3653 document.documentElement.lang=lng; 3654 applyStaticTranslations(); 3655 _updateLangToggle(); 3656 if(TOKEN){ 3657 try{await api('PUT','/api/auth/locale',{locale:lng});}catch(e){} 3658 } 3659} 3660function _syncLocaleFromUser(userLocale){ 3661 if(!userLocale||userLocale===LOCALE){_updateLangToggle();return;} 3662 LOCALE=userLocale; 3663 localStorage.setItem('fz_locale',userLocale); 3664 i18next.changeLanguage(userLocale).then(()=>{ 3665 document.documentElement.lang=userLocale; 3666 applyStaticTranslations(); 3667 _updateLangToggle(); 3668 }); 3669} 3670function _updateLangToggle(){ 3671 document.querySelectorAll('.lang-toggle-btn').forEach(b=>{ 3672 b.classList.toggle('active',b.getAttribute('data-lang')===LOCALE); 3673 }); 3674} 3675const _I18N_READY=initI18n(); 3676 3677// ââ XP / LEVEL WIDGET ââââââââââââââââââââââââââââ 3678function _updateXpWidget(d){ 3679 const widget=$('xp-widget'); 3680 if(!widget)return; 3681 if(USER.tier==='admin'||USER.tier==='super_admin'){widget.style.display='none';return;} 3682 widget.style.display='block'; 3683 $('xp-level-label').textContent=t('level.'+d.level)||d.level; 3684 $('xp-quiz-status').textContent=t('xp.quiz_today').replace('{count}',d.due?'0':'1'); 3685 const pct=d.xp_needed?Math.min(100,Math.round(d.xp_total/d.xp_needed*100)):100; 3686 $('xp-progress-bar').style.width=pct+'%'; 3687 $('xp-total-label').textContent=d.next_level?`${d.xp_total} / ${d.xp_needed} XP`:`${d.xp_total} XP · ${t('xp.max')}`; 3688 $('xp-streak-label').textContent=d.streak_days>0?`ð¥ ${d.streak_days}`:''; 3689} 3690 3691// ââ DAILY QUIZ âââââââââââââââââââââââââââââââââ 3692let _quizCurrent=null; // {quiz_id, title, level, lesson_content, unlocks_hint, questions:[...]} 3693let _quizAnswers={}; // question_id -> 'a'|'b'|'c'|'d' 3694 3695function _quizEsc(s){ 3696 const d=document.createElement('div'); 3697 d.textContent=s==null?'':String(s); 3698 return d.innerHTML; 3699} 3700 3701async function checkQuizDue(){ 3702 if(!TOKEN)return; 3703 if(USER.tier==='admin'||USER.tier==='super_admin')return; // never nag admin accounts with the leveling popup 3704 try{ 3705 const r=await api('GET','/api/quizzes/due-today'); 3706 if(!r.ok){console.warn('checkQuizDue: due-today returned',r.status);return;} 3707 const d=await r.json(); 3708 _updateXpWidget(d); 3709 if(d.due)setTimeout(openQuizModal,1200); 3710 }catch(e){console.error('checkQuizDue failed:',e);} 3711} 3712 3713async function openQuizModal(){ 3714 try{ 3715 const r=await api('GET','/api/quizzes/next'); 3716 if(!r.ok){console.warn('openQuizModal: /next returned',r.status,'- no quiz seeded for this level/locale?');return;} 3717 const d=await r.json(); 3718 _quizCurrent=d; 3719 _quizAnswers={}; 3720 $('quiz-modal-title').textContent=d.title||''; 3721 $('quiz-topic-badge').textContent=t('level.'+d.level)||d.level; 3722 $('quiz-lesson-content').innerHTML=_quizEsc(d.lesson_content||'').replace(/\n/g,'<br>'); 3723 const hintEl=$('quiz-unlocks-hint'); 3724 if(d.unlocks_hint){ 3725 hintEl.innerHTML=`ð ${t('quiz.unlocks_prefix')} <b>${_quizEsc(d.unlocks_hint)}</b>`; 3726 hintEl.style.display='block'; 3727 }else hintEl.style.display='none'; 3728 $('quiz-lesson-view').style.display='block'; 3729 $('quiz-questions-view').style.display='none'; 3730 $('quiz-results-view').style.display='none'; 3731 $('quiz-modal-overlay').classList.add('show'); 3732 }catch(e){console.error('openQuizModal failed:',e);} 3733} 3734 3735function closeQuizModal(){ 3736 $('quiz-modal-overlay').classList.remove('show'); 3737} 3738 3739function _quizStartQuestions(){ 3740 $('quiz-lesson-view').style.display='none'; 3741 $('quiz-questions-view').style.display='block'; 3742 const list=$('quiz-questions-list'); 3743 list.innerHTML=_quizCurrent.questions.map((q,i)=>` 3744 <div class="quiz-question"> 3745 <div class="quiz-question-text">${i+1}. ${_quizEsc(q.question_text)}</div> 3746 <div class="quiz-choices"> 3747 ${['a','b','c','d'].map(letter=>` 3748 <div class="quiz-choice" data-qid="${q.id}" data-letter="${letter}" onclick="_quizSelectChoice('${q.id}','${letter}')">
3749 <span class="quiz-choice-letter">${letter.toUpperCase()}</span><span>${_quizEsc(q.choices[letter])}</span> 3750 </div> 3751 `).join('')} 3752 </div> 3753 </div> 3754 `).join(''); 3755} 3756 3757function _quizSelectChoice(qid,letter){ 3758 _quizAnswers[qid]=letter; 3759 document.querySelectorAll(`.quiz-choice[data-qid="${qid}"]`).forEach(el=>{ 3760 el.classList.toggle('selected',el.getAttribute('data-letter')===letter); 3761 }); 3762} 3763 3764async function submitQuizAnswers(){ 3765 if(!_quizCurrent)return; 3766 const total=_quizCurrent.questions.length; 3767 if(Object.keys(_quizAnswers).length<total){ 3768 toast(t('quiz.answer_all'),'err'); 3769 return; 3770 } 3771 try{ 3772 const r=await api('POST',`/api/quizzes/${_quizCurrent.quiz_id}/submit`,{answers:_quizAnswers}); 3773 const d=await r.json(); 3774 if(!r.ok){ 3775 toast(d.detail||t('quiz.submit_error'),'err'); 3776 return; 3777 } 3778 _renderQuizResults(d); 3779 }catch(e){ 3780 toast(t('quiz.submit_error'),'err'); 3781 } 3782} 3783 3784function _renderQuizResults(d){ 3785 $('quiz-questions-view').style.display='none'; 3786 $('quiz-results-view').style.display='block'; 3787 const qMap={}; 3788 _quizCurrent.questions.forEach(q=>{qMap[q.id]=q;}); 3789 3790 let html=`<div class="quiz-score">â ${d.stars_earned} / ${d.total_questions}</div>`; 3791 html+=`<div class="quiz-stats-row"> 3792 <div><span class="quiz-stat-num">${d.xp_total}</span><span class="quiz-stat-label">${t('quiz.xp_total')}</span></div> 3793 <div><span class="quiz-stat-num">ð¥ ${d.streak_days}</span><span class="quiz-stat-label">${t('quiz.streak_days')}</span></div> 3794 </div>`; 3795 if(d.leveled_up){ 3796 html+=`<div class="quiz-levelup">ð ${t('quiz.leveled_up_to')} <b>${t('level.'+d.level)}</b>!</div>`; 3797 } 3798 html+=`<div class="quiz-review">`+d.graded.map((g,i)=>{ 3799 const q=qMap[g.question_id]||{question_text:'',choices:{}}; 3800 return `<div class="quiz-review-item ${g.correct?'correct':'incorrect'}"> 3801 <div class="quiz-review-q">${i+1}. ${_quizEsc(q.question_text)}</div> 3802 <div class="quiz-review-answer">${g.correct?'â ':'â'} ${_quizEsc((q.choices||{})[g.correct_choice]||'')}</div> 3803 ${g.explanation?`<div class="quiz-review-explanation">${_quizEsc(g.explanation)}</div>`:''} 3804 </div>`; 3805 }).join('')+`</div>`; 3806 3807 $('quiz-results-content').innerHTML=html; 3808 _updateXpWidget({...d,due:false}); 3809 if(d.leveled_up){ 3810 toast(t('quiz.leveled_up_toast'),'ok'); 3811 USER.level=d.level; 3812 _justUnlockedRank=LEVEL_RANK[d.level]; 3813 buildNav(USER.tier); 3814 setTimeout(()=>{_justUnlockedRank=null;buildNav(USER.tier);},5000); 3815 } 3816} 3817 3818// ââ WATCHLIST ââââââââââââââââââââââââââââââââââ 3819async function loadWatchlist(){ 3820 const grid=$('watchlist-grid'); 3821 if(!grid)return; 3822 try{ 3823 const res=await api('GET','/api/watchlist'); 3824 if(!res.ok){grid.innerHTML='';return;} 3825 const items=await res.json(); 3826 if(!items.length){ 3827 grid.innerHTML=`<p style="font-size:.8rem;color:var(--text2);grid-column:1/-1">${t('watchlist.empty')}</p>`; 3828 return; 3829 } 3830 grid.innerHTML=items.map(i=>` 3831 <div style="background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:12px 14px;display:flex;align-items:center;justify-content:space-between"> 3832 <div> 3833 <div style="font-weight:700;font-size:.85rem">${_quizEsc(i.ticker)}</div> 3834 <div style="font-size:.6rem;color:var(--muted);font-family:var(--mono)">${new Date(i.added_at).toLocaleDateString()}</div> 3835 </div> 3836 <button class="btn btn-ghost" style="font-size:.65rem;padding:3px 8px;color:var(--red)" onclick="removeFromWatchlist('${i.ticker}')">â</button> 3837 </div>`).join(''); 3838 }catch(e){grid.innerHTML='';} 3839} 3840async function addToWatchlist(){ 3841 const input=$('wl-ticker-input'); 3842 const ticker=(input.value||'').trim().toUpperCase(); 3843 if(!ticker)return; 3844 try{ 3845 const res=await api('POST','/api/watchlist',{ticker}); 3846 const d=await res.json(); 3847 if(res.ok){input.value='';toast(t('watchlist.added'),'ok');loadWatchlist();} 3848 else{toast(d.detail||t('watchlist.add_error'),'err');} 3849 }catch(e){toast(t('watchlist.add_error'),'err');} 3850} 3851async function removeFromWatchlist(ticker){ 3852 try{ 3853 const res=await api('DELETE',`/api/watchlist/${ticker}`); 3854 if(res.ok){toast(t('watchlist.removed'),'ok');loadWatchlist();} 3855 }catch(e){} 3856} 3857 3858let lastScanCharts=[]; 3859let lastCollageBlob=null; 3860let _modalChart=null;
3861let _selectedChartIdxs=new Set(); 3862let lastCarouselSlides=[]; 3863let selectedTemplate='factoz'; 3864let selectedFiles=new Set(); 3865let editingClientId=null; 3866 3867const $=id=>document.getElementById(id); 3868function hdr(extra={}){return{'Authorization':`Bearer ${TOKEN}`,...extra}} 3869async function api(method,path,body=null,isForm=false){ 3870 const opts={method,headers:hdr(isForm?{}:{'Content-Type':'application/json'})}; 3871 if(body)opts.body=isForm?body:JSON.stringify(body); 3872 return fetch(API+path,opts); 3873} 3874// ââ LIGHTBOX ââââââââââââââââââââââââââââââââââ 3875let _lbUrls=[],_lbIdx=0; 3876function openLightbox(urls,idx){_lbUrls=urls;_lbIdx=idx||0;$('lightbox').style.display='flex';_lbRender();} 3877function _lbRender(){const u=_lbUrls[_lbIdx];$('lb-img').src=u;$('lb-dl').href=u;$('lb-caption').textContent=_lbUrls.length>1?`${_lbIdx+1} / ${_lbUrls.length}`:'';} 3878function lbPrev(){_lbIdx=(_lbIdx-1+_lbUrls.length)%_lbUrls.length;_lbRender();} 3879function lbNext(){_lbIdx=(_lbIdx+1)%_lbUrls.length;_lbRender();} 3880function closeLightbox(){$('lightbox').style.display='none';const tvEl=$('lb-tv-link');if(tvEl)tvEl.style.display='none';} 3881document.addEventListener('keydown',e=>{if($('lightbox').style.display==='none')return;if(e.key==='Escape')closeLightbox();if(e.key==='ArrowLeft')lbPrev();if(e.key==='ArrowRight')lbNext();}); 3882 3883function toast(msg,type=''){ 3884 const el=$('toast');el.textContent=msg; 3885 el.className='show '+(type==='ok'?'ok':type==='err'?'err':''); 3886 setTimeout(()=>el.className='',3200); 3887} 3888function b64toBlob(b64,mime='image/png'){ 3889 const b=atob(b64),ab=new ArrayBuffer(b.length),ia=new Uint8Array(ab); 3890 for(let i=0;i<b.length;i++)ia[i]=b.charCodeAt(i); 3891 return new Blob([ab],{type:mime}); 3892} 3893function updateClock(){ 3894 const n=new Date(); 3895 $('clock').textContent=n.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})+' '+n.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit'}); 3896} 3897setInterval(updateClock,1000);updateClock(); 3898 3899// ââ TIER-BASED NAVIGATION ââââââââââââââââââââ 3900let NAV_MODE=localStorage.getItem('nav_mode')||'simple'; 3901 3902function toggleNavMode(){ 3903 NAV_MODE=NAV_MODE==='simple'?'power':'simple'; 3904 localStorage.setItem('nav_mode',NAV_MODE); 3905 if(USER&&USER.tier)buildNav(USER.tier); 3906 _updateNavModeBtn(); 3907} 3908 3909function _updateNavModeBtn(){ 3910 const btn=document.getElementById('nav-mode-btn'); 3911 if(!btn)return; 3912 btn.textContent=NAV_MODE==='simple'?'â simple mode':'â power mode'; 3913 btn.title=NAV_MODE==='simple'?'Switch to power mode (show all features)':'Switch to simple mode (focus view)'; 3914} 3915 3916// ââ LEVEL-GATED NAV âââââââââââââââââââââââââââââ 3917// Frontend mirror of core/levels.py::effective_level_rank - only routes whose 3918// BACKEND has actually been migrated to require_level() should use this for 3919// nav lock/unlock (currently: Polymarket, FRED/Macro, Trading). Everything 3920// else stays tier-gated until its own backend migrates, or the nav would 3921// show a green light on something the API still 403s. 3922const LEVELS=['beginner','intermediate','advanced','expert']; 3923const LEVEL_RANK={beginner:0,intermediate:1,advanced:2,expert:3}; 3924const TIER_TO_LEVEL={free:'beginner',creator:'intermediate',pro:'advanced',stellar:'expert',enterprise_api:'expert',admin:'expert',super_admin:'expert'}; 3925function _effectiveLevelRank(user){ 3926 const earned=LEVEL_RANK[user.level]??0; 3927 const floor=LEVEL_RANK[TIER_TO_LEVEL[user.tier]]??0; 3928 const bought=user.skip_line_purchased?LEVEL_RANK.expert:0; 3929 return Math.max(earned,floor,bought); 3930} 3931let _justUnlockedRank=null; // set briefly right after a level-up, drives the nav shine animation 3932function _lockedNavClick(){ 3933 toast(t('nav.locked_hint'),''); 3934} 3935function _levelNavItem(pageId,icon,label,minRank,unlockedNow,extraHtml,onclickPrefix){ 3936 const locked=!unlockedNow; 3937 const shine=unlockedNow && _justUnlockedRank===minRank; 3938 const cls='nav-item'+(locked?' nav-item-locked':'')+(shine?' nav-item-shine':''); 3939 const click=locked?'_lockedNavClick()':`${onclickPrefix||''}go('${pageId}')`; 3940 const lock=locked?'<span class="nav-lock">ð</span>':''; 3941 return `<div class="${cls}" onclick="${click}
3941"><span class="nav-icon"><i data-feather="${icon}"></i></span>${label}${locked?'':(extraHtml||'')}${lock}</div>`; 3942} 3943 3944function buildNav(tier){ 3945 const isAdmin = tier==='admin' || tier==='super_admin'; 3946 const hasStellar = tier==='stellar' || isAdmin; 3947 const hasPro = tier==='pro' || tier==='stellar' || tier==='enterprise_api' || isAdmin; 3948 const hasCreator = tier==='creator' || hasPro; 3949 const hasFree = tier==='free'; 3950 const canPost = tier==='pro' || tier==='stellar' || tier==='enterprise_api' || isAdmin; 3951 const isPower = NAV_MODE==='power'; 3952 const advUnlocked = _effectiveLevelRank(USER) >= LEVEL_RANK.advanced; 3953 const intUnlocked = _effectiveLevelRank(USER) >= LEVEL_RANK.intermediate; 3954 // Financials keeps "pro" tier grandfathered server-side (it had this before 3955 // Expert-level gating existed) - the nav must mirror that or pro-tier users 3956 // would see it locked despite the API letting them through. See 3957 // routes/financials.py::_require_financials_access. 3958 const expUnlocked = _effectiveLevelRank(USER) >= LEVEL_RANK.expert || tier==='pro'; 3959 const nav=$('nav-menu'); 3960 let html=''; 3961 3962 // Top items 3963 html+=`<div class="nav-item" onclick="go('guide')"><span class="nav-icon"><i data-feather="book-open"></i></span>${t('nav.getting_started')}</div> 3964 <div class="nav-item active" onclick="go('overview')"><span class="nav-icon"><i data-feather="home"></i></span>${t('nav.overview')}</div>`; 3965 3966 // SIGNALS â core alert engine 3967 html+=`<div class="nav-section" data-section="signals" onclick="toggleNav('signals')">${t('nav.section_signals')}</div> 3968 <div class="nav-section-items" id="ns-signals"> 3969 <div class="nav-item" onclick="go('scanner')"><span class="nav-icon"><i data-feather="bar-chart-2"></i></span>${t('nav.scanner')}<span id="scanner-badge" class="nav-badge badge-pulse" style="display:none">!</span></div>`; 3970 html+=_levelNavItem('options','zap',t('nav.options_flow'),LEVEL_RANK.intermediate,intUnlocked); 3971 html+=_levelNavItem('polymarket','target',t('nav.polymarket'),LEVEL_RANK.advanced,advUnlocked); 3972 html+=_levelNavItem('macro','globe',t('nav.macro'),LEVEL_RANK.advanced,advUnlocked); 3973 html+=`</div>`; 3974 3975 // MARKETS â market data 3976 html+=`<div class="nav-section" data-section="markets" onclick="toggleNav('markets')">${t('nav.section_markets')}</div> 3977 <div class="nav-section-items" id="ns-markets"> 3978 <div class="nav-item" onclick="go('watchlist')"><span class="nav-icon"><i data-feather="eye"></i></span>${t('nav.watchlist')}</div> 3979 <div class="nav-item" onclick="go('movers')"><span class="nav-icon"><i data-feather="trending-up"></i></span>${t('nav.movers')}</div>`; 3980 html+=_levelNavItem('news','rss',t('nav.news_feed'),LEVEL_RANK.intermediate,intUnlocked,'<span id="news-badge" class="nav-badge" style="display:none"></span>',"_newsFilter='all';"); 3981 html+=_levelNavItem('news','calendar',t('nav.earnings'),LEVEL_RANK.intermediate,intUnlocked,'',"filterNews('earnings');"); 3982 html+=_levelNavItem('trends','search',t('nav.trends'),LEVEL_RANK.intermediate,intUnlocked); 3983 html+=`</div>`; 3984 3985 // ANALYSIS â deeper tools 3986 html+=`<div class="nav-section" data-section="analysis" onclick="toggleNav('analysis')">${t('nav.section_analysis')}</div> 3987 <div class="nav-section-items" id="ns-analysis"> 3988 <div class="nav-item" onclick="go('charts')"><span class="nav-icon"><i data-feather="activity"></i></span>${t('nav.live_charts')}</div>`; 3989 if(hasCreator){ 3990 html+=`<div class="nav-item" onclick="go('analyst')"><span class="nav-icon"><i data-feather="crosshair"></i></span>${t('nav.analyst_targets')}</div>`; 3991 } 3992 html+=_levelNavItem('heatmap','grid',t('nav.heatmaps'),LEVEL_RANK.advanced,advUnlocked); 3993 html+=_levelNavItem('var','shield',t('nav.portfolio_risk'),LEVEL_RANK.advanced,advUnlocked); 3994 html+=_levelNavItem('trading','bar-chart',t('nav.trading'),LEVEL_RANK.advanced,advUnlocked); 3995 html+=_levelNavItem('financials','file-text',t('nav.financials'),LEVEL_RANK.expert,expUnlocked); 3996 html+=`<div class="nav-item" onclick="go('graphs')"><span class="nav-icon"><i data-feather="pie-chart"></i></span>${t('nav.graphs')}</div>`; 3997 html+=`</div>`; 3998 3999 // ACCOUNT 4000 html+=`<div class="nav-section" data-section="account" onclick="toggleNav('account')">${t('nav.section_account')}</div> 4001 <div class="nav-section-items" id="ns-account"> 4002 <div class="nav-item" onclick="go('settings')"><span class="nav-icon"><i data-feather="settings"></i></span>${t('nav.settings')}</div> 4003 </div>`; 4004 4005 // ADMIN 4006 if(isAdmin){ 4007 html+=`<div class="nav-section nav-section-noc" style="color:var(--yellow);opacity:.7;cursor:default">${t('nav.section_admin')}</div> 4008 <div class="nav-item admin-item" onclick="go('admin');markAdminNotifsRead()"><span class="nav-icon"><i data-feather="zap"></i></span>${t('nav.admin_panel')}<span id="admin-notif-badge" class="nav-badge" style="display:none"></span></div>`; 4009 } 4010 4011 nav.innerHTML=html; 4012 if(window.feather) feather.replace(); 4013 _restoreNavState(); 4014 _updateNavModeBtn(); 4015} 4016 4017// ââ COLLAPSIBLE NAV âââââââââââââââââââââââââââ 4018const _NAV_SECTIONS={ 4019 news:'markets',scanner:'signals',options:'signals',movers:'markets',watchlist:'markets',forex:'markets',trends:'markets',charts:'analysis', 4020 analyst:'analysis',polymarket:'signals',macro:'signals',heatmap:'analysis',var:'analysis',financials:'analysis',graphs:'analysis',trading:'analysis', 4021 carousel:'create',collage:'create',files:'create',studio:'create',gallery:'create',designlab:'create', 4022 post:'publish',calendar:'publish', 4023 brand:'account',settings:'account',history:'account' 4024}; 4025 4026function toggleNav(id){ 4027 const el=$('ns-'+id); 4028 const hdr=document.querySelector('[data-section="'+id+'"]'); 4029 if(!el)return; 4030 const now=el.classList.toggle('collapsed');
4031 hdr&&hdr.classList.toggle('collapsed',now); 4032 now?localStorage.setItem('nav_col_'+id,'1'):localStorage.removeItem('nav_col_'+id); 4033} 4034 4035function _restoreNavState(){ 4036 ['signals','markets','analysis','account'].forEach(s=>{ 4037 if(localStorage.getItem('nav_col_'+s))toggleNav(s); 4038 }); 4039} 4040 4041// ââ NAVIGATION ââââââââââââââââââââââââââââââââ 4042let _prevPage=null; 4043function go(id){ 4044 document.querySelectorAll('.page').forEach(p=>p.classList.remove('active')); 4045 document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active')); 4046 $('page-'+id).classList.add('active'); 4047 document.querySelectorAll('.nav-item').forEach(n=>{ 4048 if(n.getAttribute('onclick')?.includes("'"+id+"'"))n.classList.add('active'); 4049 }); 4050 // Auto-expand section if collapsed 4051 const _sec=_NAV_SECTIONS[id]; 4052 if(_sec){const _el=$('ns-'+_sec);if(_el&&_el.classList.contains('collapsed'))toggleNav(_sec);} 4053 if(id==='history')loadHistory('all'); 4054 if(id==='scanner'){loadScanHistory();loadChartIndicators();loadMyCharts();_initProScanOpts();} 4055 if(id==='collage')loadMyCollages(); 4056 if(id==='brand')loadBrand(); 4057 if(id==='settings'){loadConnectedAccounts();_initDjtToggle();} 4058 if(id==='overview'){loadOverview();loadOverviewMovers();loadOptionsFlow();} 4059 if(id==='admin'){loadAdminPanel();loadAdminUsers();loadAdminNews();loadAdminActivity();loadPersons();loadAdminCredits();loadTemplatedConfigs();loadPolymarketConfig();loadTaxSessions();loadOutreachContacts();loadAdminAds();loadAdminBriefs();loadSubscriberList();loadDailyBriefConfig();loadSocialPillars();} 4060 if(id==='options')loadOptionsPage(); 4061 if(id==='polymarket'){pmLoadStatus();loadPmMarkets();pmStartSSE();loadPmWatches();} 4062 if(_prevPage==='polymarket'&&id!=='polymarket')pmStopSSE(); 4063 if(id==='movers')loadMovers(); 4064 if(id==='watchlist')loadWatchlist(); 4065 if(id==='trends')loadTrends(); 4066 if(id==='gallery')loadGallery(''); 4067 if(id==='macro')loadMacroScorecard(); 4068 if(id==='financials'){ 4069 initFinancials(); 4070 } 4071 // loadHeatmap() removed â heatmap page now uses TradingView widgets 4072 if(id==='var')initVarPage(); 4073 if(id==='trading')mfxInit(); 4074 if(id==='calendar')renderCalendar(); 4075 4076 if(id==='designlab')initDesignLab(); 4077 if(id==='studio')loadStudioGallery(); 4078 if(id==='graphs')renderGraphsPage(); 4079 if(id==='charts') initChartsPage(); 4080 else if(_lcWs){ _lcWs.close(); _lcWs = null; } 4081 if(id==='carousel'){loadRecentOutputs();updateTemplatePreview(selectedTemplate||'factoz');reloadTemplates();} 4082 if(id==='analyst')_loadAnalystTickers(); 4083 if(id==='news'){ 4084 // mark feed as read â store timestamp so badge resets 4085 localStorage.setItem('fz_news_seen_at', new Date().toISOString()); 4086 _newsUnread=0; updateNewsBadge();
4087 if(_newsItems.length) filterNews(_newsFilter, false); 4088 loadNews(); 4089 } 4090 _prevPage=id; 4091} 4092 4093// ââ ANALYST TARGETS âââââââââââââââââââââââââââ 4094let _analystTickers=[]; 4095 4096async function _loadAnalystTickers(){ 4097 if(_analystTickers.length)return; 4098 try{ 4099 const res=await api('GET','/api/scanner/tickers'); 4100 const d=await res.json(); 4101 if(res.ok)_analystTickers=d.tickers||[]; 4102 }catch(e){} 4103} 4104 4105function analystAutoComplete(val){ 4106 const box=$('analyst-suggestions'); 4107 const q=val.trim().toUpperCase(); 4108 if(q.length<1){box.style.display='none';return;} 4109 const matches=_analystTickers.filter(t=>t.startsWith(q)).slice(0,10); 4110 if(!matches.length){box.style.display='none';return;} 4111 box.innerHTML=matches.map(t=> 4112 `<div onclick="analystPickTicker('${t}')" 4113 onmouseover="this.style.background='#1c2128'" 4114 onmouseout="this.style.background=''" 4115 style="padding:8px 12px;cursor:pointer;color:#e6edf3;font-size:.9em">${t}</div>` 4116 ).join(''); 4117 box.style.display='block'; 4118} 4119 4120function analystPickTicker(t){ 4121 $('analyst-ticker-input').value=t; 4122 $('analyst-suggestions').style.display='none'; 4123 getAnalystTarget(); 4124} 4125 4126function analystInputKey(e){ 4127 if(e.key==='Enter'){$('analyst-suggestions').style.display='none';getAnalystTarget();} 4128 if(e.key==='Escape')$('analyst-suggestions').style.display='none'; 4129} 4130 4131document.addEventListener('click',e=>{ 4132 if(!e.target.closest('#analyst-suggestions')&&e.target.id!=='analyst-ticker-input'){ 4133 const box=$('analyst-suggestions'); 4134 if(box)box.style.display='none'; 4135 } 4136}); 4137 4138async function getAnalystTarget(){ 4139 const ticker=($('analyst-ticker-input').value||'').trim().toUpperCase(); 4140 if(!ticker){toast('Enter a ticker symbol','err');return;} 4141 const res=await api('GET',`/api/scanner/analyst-target?ticker=${ticker}`); 4142 const d=await res.json(); 4143 const el=$('analyst-result'); 4144 if(!res.ok){el.innerHTML=`<p style="color:#f87171">${d.detail||'Lookup failed'}</p>`;return;} 4145 const upSign=d.upsidePercent>=0?'+':''; 4146 const upColor=d.upsidePercent>=0?'#00e5b4':'#f87171'; 4147 const recMap={strong_buy:'Strong Buy',buy:'Buy',hold:'Hold',sell:'Sell',strong_sell:'Strong Sell'}; 4148 const recColors={strong_buy:'#00e5b4',buy:'#86efac',hold:'#fbbf24',sell:'#f87171',strong_sell:'#ef4444'}; 4149 const rec=d.recommendation||''; 4150 const recLabel=recMap[rec]||rec; 4151 const recColor=recColors[rec]||'#9ca3af'; 4152 const actionColors={Upgrades:'#00e5b4',Initiates:'#86efac',Reiterates:'#86efac',Maintains:'#6e7681',Downgrades:'#f87171'}; 4153 let gradingsHtml=''; 4154 if(d.gradings&&d.gradings.length){ 4155 const rows=d.gradings.map(g=>{ 4156 const col=actionColors[g.action]||'#9ca3af'; 4157 return `<tr> 4158 <td style="padding:6px 8px;color:#e6edf3">${g.firm}</td> 4159 <td style="padding:6px 8px;color:#e6edf3">${g.grade}</td> 4160 <td style="padding:6px 8px"><span style="color:${col};font-size:.8em">${g.action}</span></td> 4161 <td style="padding:6px 8px;color:#6e7681;font-size:.8em;white-space:nowrap">${g.date}</td> 4162 </tr>`; 4163 }).join(''); 4164 gradingsHtml=` 4165 <div class="card" style="padding:0;margin-top:12px;overflow:hidden"> 4166 <button onclick="var p=this.nextElementSibling;p.style.display=p.style.display==='none'?'block':'none';this.querySelector('.arr').textContent=p.style.display==='none'?'â¶':'â¼'" 4167 style="width:100%;background:none;border:none;color:#e6edf3;padding:14px 16px;text-align:left;cursor:pointer;display:flex;justify-content:space-between;align-items:center"> 4168 <span style="font-size:.8em;text-transform:uppercase;letter-spacing:.05em;color:#6e7681">${d.gradings.length} Analyst Ratings</span> 4169 <span class="arr" style="color:#6e7681;font-size:.8em">â¶</span> 4170 </button> 4171 <div style="display:none;padding:0 16px 16px"> 4172 <table style="width:100%;border-collapse:collapse"> 4173 <thead><tr style="color:#6e7681;font-size:.72em;text-transform:uppercase"> 4174 <th style="text-align:left;padding:4px 8px;border-bottom:1px solid #30363d">Firm</th> 4175 <th style="text-align:left;padding:4px 8px;border-bottom:1px solid #30363d">Rating</th> 4176 <th style="text-align:left;padding:4px 8px;border-bottom:1px solid #30363d">Action</th> 4177 <th style="text-align:left;padding:4px 8px;border-bottom:1px solid #30363d">Date</th> 4178 </tr></thead> 4179 <tbody>${rows}</tbody> 4180 </table> 4181 </div> 4182 </div>`; 4183 } 4184 el.innerHTML=` 4185 <div class="card" style="padding:20px"> 4186 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
4187 <span style="font-size:1.3em;font-weight:700">${d.ticker}</span> 4188 <span style="background:${recColor}22;color:${recColor};padding:3px 12px;border-radius:20px;font-size:.85em;font-weight:600">${recLabel}</span> 4189 </div> 4190 <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:14px"> 4191 <div> 4192 <div style="color:#6e7681;font-size:.75em;text-transform:uppercase;letter-spacing:.05em">Current Price</div> 4193 <div style="font-size:1.3em;font-weight:600">$${d.currentPrice?.toFixed(2)??'â'}</div> 4194 </div> 4195 <div> 4196 <div style="color:#6e7681;font-size:.75em;text-transform:uppercase;letter-spacing:.05em">Consensus Target</div> 4197 <div style="font-size:1.3em;font-weight:600">$${d.targetMean?.toFixed(2)??'â'} <span style="color:${upColor};font-size:.8em">(${upSign}${d.upsidePercent}%)</span></div> 4198 </div> 4199 </div> 4200 <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;font-size:.85em;color:#9ca3af;border-top:1px solid #30363d;padding-top:12px"> 4201 <div>High Target<br><span style="color:#e6edf3;font-weight:600">$${d.targetHigh?.toFixed(2)??'â'}</span></div> 4202 <div>Low Target<br><span style="color:#e6edf3;font-weight:600">$${d.targetLow?.toFixed(2)??'â'}</span></div> 4203 <div>Analysts<br><span style="color:#e6edf3;font-weight:600">${d.numberOfAnalysts??'â'}</span></div> 4204 </div> 4205 </div> 4206 <div id="analyst-minichart-wrap" style="margin-top:12px;border-radius:8px;overflow:hidden;height:220px"></div>`+gradingsHtml+` 4207 <div id="research-financials" style="margin-top:8px"></div> 4208 <div id="research-insider-sentiment" style="margin-top:8px"></div> 4209 <div id="research-news" style="margin-top:8px"></div> 4210 <div id="research-filings" style="margin-top:8px"></div> 4211 <div id="tv-tech-analysis-wrap" style="margin-top:16px"></div> 4212 <div id="tv-financials-wrap" style="margin-top:12px"></div>`; 4213 4214 // Inject TradingView widgets 4215 _injectTVMiniChart(d.ticker); 4216 _injectTVTechWidget(d.ticker); 4217 _injectTVFinancialsWidget(d.ticker); 4218 4219 // Fire all 4 Finnhub research calls in parallel 4220 _loadResearch(ticker); 4221} 4222 4223function _injectTVMiniChart(ticker){ 4224 const wrap = $('analyst-minichart-wrap'); 4225 if(!wrap) return; 4226 wrap.innerHTML = ''; 4227 const sym = ticker.includes(':') ? ticker : 'NASDAQ:' + ticker; 4228 4229 const container = document.createElement('div'); 4230 container.className = 'tradingview-widget-container'; 4231 container.style.cssText = 'height:100%;width:100%'; 4232 4233 const widget = document.createElement('div'); 4234 widget.className = 'tradingview-widget-container__widget'; 4235 widget.style.cssText = 'height:100%;width:100%'; 4236 4237 const config = { 4238 symbol: sym, 4239 colorTheme: 'dark', 4240 isTransparent: false, 4241 width: '100%', 4242 height: 220, 4243 locale: 'en', 4244 dateRange: '1M', 4245 trendLineColor: 'rgba(0, 229, 180, 1)', 4246 underLineColor: 'rgba(0, 229, 180, 0.15)', 4247 underLineBottomColor: 'rgba(0, 229, 180, 0)', 4248 isUnderLine: true, 4249 autosize: true, 4250 }; 4251 4252 const script = document.createElement('script'); 4253 script.type = 'text/javascript'; 4254 script.src = 'https://s3.tradingview.com/external-embedding/embed-widget-mini-symbol-overview.js'; 4255 script.async = true; 4256 script.textContent = JSON.stringify(config); 4257 4258 container.appendChild(widget); 4259 container.appendChild(script); 4260 wrap.appendChild(container); 4261} 4262 4263function _injectTVTechWidget(ticker){ 4264 const wrap = $('tv-tech-analysis-wrap'); 4265 if(!wrap) return; 4266 wrap.innerHTML = ''; 4267 const sym = ticker.includes(':') ? ticker : 'NASDAQ:' + ticker; 4268 4269 const container = document.createElement('div'); 4270 container.className = 'tradingview-widget-container'; 4271 4272 const widget = document.createElement('div'); 4273 widget.className = 'tradingview-widget-container__widget'; 4274 4275 const copy = document.createElement('div'); 4276 copy.className = 'tradingview-widget-copyright'; 4277 copy.style.cssText = 'font-size:.5rem;color:var(--muted);opacity:.35;text-align:right;padding:1px 4px'; 4278 copy.innerHTML = `<a href="https://www.tradingview.com/" rel="noopener nofollow" target="_blank" style="color:var(--muted);text-decoration:none">Technical Analysis by TradingView</a>`; 4279 4280 const config = { 4281 colorTheme: 'dark', 4282 displayMode: 'multiple', 4283 isTransparent: true, 4284 locale: 'en', 4285 interval: '4h', 4286 disableInterval: false, 4287 width: '100%', 4288 height: 450, 4289 symbol: sym, 4290 showIntervalTabs: true, 4291 }; 4292 4293 const script = document.createElement('script'); 4294 script.type = 'text/javascript'; 4295 script.src = 'https://s3.tradingview.com/external-embedding/embed-widget-technical-analysis.js'; 4296 script.async = true; 4297 script.textContent = JSON.stringify(config); 4298 4299 container.appendChild(widget); 4300 container.appendChild(copy); 4301 container.appendChild(script); 4302 wrap.appendChild(container); 4303} 4304 4305function _injectTVFinancialsWidget(ticker){ 4306 const wrap = $('tv-financials-wrap'); 4307 if(!wrap) return; 4308 wrap.innerHTML = ''; 4309 const sym = ticker.includes(':') ? ticker : 'NASDAQ:' + ticker; 4310 4311 const container = document.createElement('div'); 4312 container.className = 'tradingview-widget-container'; 4313 4314 const widget = document.createElement('div'); 4315 widget.className = 'tradingview-widget-container__widget'; 4316 4317 const copy = document.createElement('div'); 4318 copy.style.cssText = 'font-size:.5rem;color:var(--muted);opacity:.35;text-align:right;padding:1px 4px'; 4319 copy.innerHTML = `<a href="https://www.tradingview.com/" rel="noopener nofollow" target="_blank" style="color:var(--muted);text-decoration:none">Fundamentals by TradingView</a>`; 4320 4321 const config = { 4322 symbol: sym, 4323 colorTheme: 'dark', 4324 displayMode: 'adaptive', 4325 isTransparent: true, 4326 locale: 'en', 4327 width: '100%', 4328 height: 550, 4329 }; 4330 4331 const script = document.createElement('script'); 4332 script.type = 'text/javascript'; 4333 script.src = 'https://s3.tradingview.com/external-embedding/embed-widget-financials.js'; 4334 script.async = true; 4335 script.textContent = JSON.stringify(config); 4336 4337 container.appendChild(widget); 4338 container.appendChild(copy); 4339 container.appendChild(script); 4340 wrap.appendChild(container); 4341} 4342 4343function _collapsibleCard(label, innerHtml){ 4344 return `<div class="card" style="padding:0;overflow:hidden"> 4345 <button onclick="var p=this.nextElementSibling;p.style.display=p.style.display==='none'?'block':'none';this.querySelector('.arr').textContent=p.style.display==='none'?'â¶':'â¼'" 4346 style="width:100%;background:none;border:none;color:#e6edf3;padding:14px 16px;text-align:left;cursor:pointer;display:flex;justify-content:space-between;align-items:center">
4347 <span style="font-size:.8em;text-transform:uppercase;letter-spacing:.05em;color:#6e7681">${label}</span> 4348 <span class="arr" style="color:#6e7681;font-size:.8em">â¶</span> 4349 </button> 4350 <div style="display:none;padding:0 16px 16px">${innerHtml}</div> 4351 </div>`; 4352} 4353 4354async function _loadResearch(ticker){ 4355 const [finRes, insRes, newsRes, filRes] = await Promise.allSettled([ 4356 api('GET', `/api/research/financials?ticker=${ticker}`), 4357 api('GET', `/api/research/insider-sentiment?ticker=${ticker}`), 4358 api('GET', `/api/research/news?ticker=${ticker}`), 4359 api('GET', `/api/research/filings?ticker=${ticker}`), 4360 ]); 4361 4362 // ââ Financials ââ 4363 const finEl=$('research-financials'); 4364 if(finEl&&finRes.status==='fulfilled'&&finRes.value.ok){ 4365 const fd=await finRes.value.json(); 4366 const m=fd.metric||{}; 4367 const fmt=(v,pre='',suf='')=>v!=null?`${pre}${typeof v==='number'?v.toFixed(2):v}${suf}`:'â'; 4368 const rows=[ 4369 ['P/E TTM', fmt(m.peTTM)], 4370 ['EPS TTM', fmt(m.epsTTM,'$')], 4371 ['Revenue TTM', m.revenueTTM!=null?`$${(m.revenueTTM/1e9).toFixed(2)}B`:'â'], 4372 ['Gross Margin', fmt(m.grossMarginTTM,'',' %')], 4373 ['52w High', fmt(m['52WeekHigh'],'$')], 4374 ['52w Low', fmt(m['52WeekLow'],'$')], 4375 ['Beta', fmt(m.beta)], 4376 ]; 4377 const grid=rows.map(([k,v])=>`<div style="display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid #30363d22;font-size:.85em"> 4378 <span style="color:#6e7681">${k}</span><span style="color:#e6edf3;font-weight:600">${v}</span></div>`).join(''); 4379 finEl.innerHTML=_collapsibleCard('Basic Financials',grid); 4380 } 4381 4382 // ââ Insider Sentiment ââ 4383 const insEl=$('research-insider-sentiment'); 4384 if(insEl&&insRes.status==='fulfilled'&&insRes.value.ok){ 4385 const id=await insRes.value.json(); 4386 const months=(id.data||[]).slice(-12).reverse(); // most recent first 4387 let inner; 4388 if(!months.length){ 4389 inner=`<p style="color:#6e7681;font-size:.85em;margin:0">No insider sentiment data available.</p>`; 4390 }else{ 4391 const rows=months.map(m=>{ 4392 const mspr=m.mspr??0; 4393 const col=mspr>0?'#00e5b4':mspr<0?'#f87171':'#6e7681'; 4394 const sign=mspr>0?'+':''; 4395 const chg=m.change??0; 4396 const chgSign=chg>0?'+':''; 4397 return `<tr> 4398 <td style="padding:6px 8px;color:#6e7681;font-size:.78em;white-space:nowrap">${m.year}-${String(m.month).padStart(2,'0')}</td> 4399 <td style="padding:6px 8px;font-size:.82em;font-weight:600;color:${col}">${sign}${mspr.toFixed(2)}</td> 4400 <td style="padding:6px 8px;color:#9ca3af;font-size:.78em">${chgSign}${chg.toLocaleString()}</td> 4401 </tr>`; 4402 }).join(''); 4403 inner=`<p style="color:#6e7681;font-size:.75em;margin:0 0 10px">MSPR = Monthly Share Purchase Ratio. Positive = net insider buying.</p> 4404 <table style="width:100%;border-collapse:collapse"> 4405 <thead><tr style="color:#6e7681;font-size:.72em;text-transform:uppercase"> 4406 <th style="text-align:left;padding:4px 8px;border-bottom:1px solid #30363d">Month</th> 4407 <th style="text-align:left;padding:4px 8px;border-bottom:1px solid #30363d">MSPR</th> 4408 <th style="text-align:left;padding:4px 8px;border-bottom:1px solid #30363d">Net Shares</th> 4409 </tr></thead> 4410 <tbody>${rows}</tbody> 4411 </table>`; 4412 } 4413 insEl.innerHTML=_collapsibleCard(`Insider Sentiment (${months.length}mo)`,inner); 4414 } 4415 4416 // ââ Company News ââ 4417 const newsEl=$('research-news'); 4418 if(newsEl&&newsRes.status==='fulfilled'&&newsRes.value.ok){ 4419 const nd=await newsRes.value.json(); 4420 const articles=nd.news||[]; 4421 let inner; 4422 if(!articles.length){ 4423 inner=`<p style="color:#6e7681;font-size:.85em;margin:0">No news published today.</p>`; 4424 }else{ 4425 inner=articles.map(a=>`<div style="padding:8px 0;border-bottom:1px solid #30363d22"> 4426 <a href="${a.url}" target="_blank" rel="noopener" 4427 style="color:#e6edf3;font-size:.85em;text-decoration:none;line-height:1.4">${a.headline}</a> 4428 <div style="color:#6e7681;font-size:.75em;margin-top:3px">${a.source||''}</div> 4429 </div>`).join(''); 4430 } 4431 newsEl.innerHTML=_collapsibleCard(`Company News â Today (${articles.length})`,inner); 4432 } 4433 4434 // ââ SEC Filings ââ 4435 const filEl=$('research-filings'); 4436 if(filEl&&filRes.status==='fulfilled'&&filRes.value.ok){ 4437 const fdata=await filRes.value.json(); 4438 const filings=fdata.filings||[]; 4439 let inner; 4440 if(!filings.length){ 4441 inner=`<p style="color:#6e7681;font-size:.85em;margin:0">No recent filings found.</p>`; 4442 }else{ 4443 const rows=filings.map(f=>`<tr> 4444 <td style="padding:6px 8px;color:#e6edf3;font-size:.8em;white-space:nowrap">${f.form||f.type||'â'}</td> 4445 <td style="padding:6px 8px;color:#6e7681;font-size:.78em;white-space:nowrap">${(f.filedDate||f.date||'').slice(0,10)}</td> 4446 <td style="padding:6px 8px;color:#9ca3af;font-size:.78em">${f.description||''}</td> 4447 <td style="padding:6px 8px;font-size:.78em">${f.filingUrl||f.reportUrl?`<a href="${f.filingUrl||f.reportUrl}" target="_blank" rel="noopener" style="color:#58a6ff">View</a>`:'â'}</td> 4448 </tr>`).join(''); 4449 inner=`<table style="width:100%;border-collapse:collapse"> 4450 <thead><tr style="color:#6e7681;font-size:.72em;text-transform:uppercase"> 4451 <th style="text-align:left;padding:4px 8px;border-bottom:1px solid #30363d">Type</th> 4452 <th style="text-align:left;padding:4px 8px;border-bottom:1px solid #30363d">Filed</th> 4453 <th style="text-align:left;padding:4px 8px;border-bottom:1px solid #30363d">Description</th> 4454 <th style="text-align:left;padding:4px 8px;border-bottom:1px solid #30363d">Link</th> 4455 </tr></thead> 4456 <tbody>${rows}</tbody> 4457 </table>`; 4458 } 4459 filEl.innerHTML=_collapsibleCard(`SEC Filings (${filings.length})`,inner); 4460 } 4461} 4462 4463// ââ AUTH ââââââââââââââââââââââââââââââââââââââ 4464function authTab(tab){ 4465 document.querySelectorAll('.auth-tab').forEach(t=>t.classList.toggle('active',t.getAttribute('data-tab')===tab)); 4466 $('quick-form').style.display=tab==='quick'?'':'none'; 4467 $('login-form').style.display=tab==='login'?'':'none'; 4468 $('register-form').style.display=tab==='register'?'':'none'; 4469 $('auth-err').textContent='';$('auth-err').style.color=''; 4470} 4471async function doQuickEntry(){ 4472 const email=$('q-email').value; 4473 if(!email){$('auth-err').textContent=t('auth.quick_email_required');return;} 4474 try{ 4475 const res=await fetch(API+'/api/auth/quick-entry',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,locale:LOCALE})}); 4476 const d=await res.json(); 4477 if(res.ok){await _completeLogin(d.token,d);} 4478 else if(res.status===409){ 4479 $('auth-err').textContent=t('auth.quick_account_exists'); 4480 authTab('login'); 4481 $('l-email').value=email; 4482 }else{ 4483 $('auth-err').textContent=d.detail||t('auth.connection_error'); 4484 } 4485 }catch(e){$('auth-err').textContent=t('auth.connection_error');} 4486} 4487async function doLogin(){ 4488 const email=$('l-email').value,password=$('l-pass').value; 4489 try{ 4490 const res=await fetch(API+'/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,password})}); 4491 const d=await res.json(); 4492 if(res.ok){await _completeLogin(d.token,d);} 4493 else if(res.status===403){ 4494 // Trial expired / pending payment â show PayPal checkout 4495 $('auth-err').innerHTML=` 4496 <div style="margin-top:12px;padding:16px;background:#0d1117;border:1px solid #222;border-radius:10px;text-align:center"> 4497 <div style="color:#8b949e;font-size:.72rem;margin-bottom:4px">Your trial has ended â subscribe to continue</div> 4498 <div style="color:#555;font-size:.62rem;margin-bottom:14px">Pro â $49/mo · PayPal, Visa, Mastercard</div> 4499 <div id="paypal-login-pro" class="paypal-hosted-btn-container"></div> 4500 <div style="color:#555;font-size:.62rem;margin-top:10px">Secure checkout via PayPal</div> 4501 </div>`; 4502 setTimeout(_initPayPalButton, 100); 4503 } 4504 else{ 4505 // Make email addresses in error messages clickable (e.g. paywall contact) 4506 const msg=d.detail||'Login failed'; 4507 $('auth-err').innerHTML=msg.replace(/([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})/g, 4508 '<a href="mailto:$1" style="color:var(--teal)">$1</a>'); 4509 } 4510 }catch(e){$('auth-err').textContent='Connection error';} 4511} 4512async function doRegister(){ 4513 const name=$('r-name').value,email=$('r-email').value,password=$('r-pass').value; 4514 try{ 4515 const res=await fetch(API+'/api/auth/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name,email,password,locale:LOCALE})}); 4516 const d=await res.json(); 4517 if(res.ok){ 4518 $('auth-err').style.color='var(--green)'; 4519 $('auth-err').textContent=t('auth.register_success'); 4520 }else $('auth-err').textContent=d.detail||t('auth.register_failed'); 4521 }catch(e){$('auth-err').textContent=t('auth.connection_error');} 4522} 4523async function _completeLogin(token,fallback){ 4524 TOKEN=token; 4525 localStorage.setItem('fz_token',TOKEN); 4526 try{ 4527 const meRes=await api('GET','/api/auth/me'); 4528 if(meRes.ok){onLogin(await meRes.json());return;} 4529 }catch(e){} 4530 onLogin(fallback); // /me failed - degrade to the login response shape rather than blocking entry 4531} 4532function onLogin(d){ 4533 USER=d; 4534 _syncLocaleFromUser(d.locale); 4535 $('auth').style.display='none'; 4536 $('user-tag').textContent=d.email||''; 4537 const tierLabels={'free':'Free','creator':'Creator','pro':'Pro','stellar':'Stellar','enterprise_api':'API Client','admin':'Admin','super_admin':'Super Admin'}; 4538 const tierLabel=(tierLabels[d.tier]||d.tier||'Creator').toUpperCase(); 4539 $('tier-tag').textContent=tierLabel; 4540 const _isAdmin=d.tier==='admin'||d.tier==='super_admin'; 4541 $('tier-tag').style.color=_isAdmin?'var(--yellow)':d.tier==='pro'||d.tier==='stellar'||d.tier==='enterprise_api'?'var(--green)':'var(--teal)'; 4542 $('ov-tier').textContent=tierLabel; 4543 // Hide admin-only settings fields for non-admin users 4544 if(!_isAdmin){ 4545 const adminSec=document.getElementById('admin-keys-section'); 4546 if(adminSec)adminSec.style.display='none'; 4547 } 4548 $('ov-email').textContent=d.email||''; 4549 initExtBanner(); 4550 // Show "Manage Subscription" only for paid users with a Paddle subscription 4551 // PayPal / manually-activated users see a contact link instead 4552 const subBtn=$('manage-sub-btn'); 4553 if(subBtn){ 4554 if(d.is_paid && (d.tier==='creator'||d.tier==='pro'||d.tier==='stellar')){ 4555 if(d.paddle_customer_id){ 4556 subBtn.style.display='block'; 4557 subBtn.textContent='â manage subscription'; 4558 subBtn.onclick=manageSubscription; 4559 } else { 4560 subBtn.style.display='block'; 4561 subBtn.textContent='â contact support'; 4562 subBtn.onclick=()=>window.open('mailto:[email protected]?subject=Subscription%20Management','_blank'); 4563 } 4564 } else { 4565 subBtn.style.display='none'; 4566 } 4567 } 4568 // Trial countdown banner 4569 if(d.trial_ends_at){ 4570 const trialEnd=new Date(d.trial_ends_at); 4571 const daysLeft=Math.ceil((trialEnd-Date.now())/(1000*60*60*24)); 4572 if(daysLeft>0){ 4573 const banner=document.getElementById('trial-banner'); 4574 if(banner){ 4575 banner.innerHTML=`Trial expires in <b>${daysLeft} day${daysLeft===1?'':'s'}</b> â <a href="https://factozcentral.com" target="_blank" style="color:inherit;font-weight:700">Upgrade to keep access</a>`; 4576 banner.style.display='flex'; 4577 } 4578 } 4579 } 4580 // Free tier upgrade nudge bar
4581 const freeBar=document.getElementById('free-upgrade-bar'); 4582 if(freeBar) freeBar.style.display=d.tier==='free'?'flex':'none'; 4583 buildNav(d.tier||'creator'); 4584 try{_updateScannerBadge();}catch(e){} 4585 loadAds(); 4586 go('overview'); 4587 loadOverviewMovers(); 4588 setTimeout(loadOverview, 3000); 4589 setTimeout(loadOverview, 8000); 4590 // start polling unread badge every 5 min 4591 pollNewsUnread(); 4592 setInterval(pollNewsUnread, 5*60*1000); 4593 pollAdminNotifications(); 4594 setInterval(pollAdminNotifications, 60*1000); 4595 checkQuizDue(); 4596} 4597function logout(){TOKEN='';localStorage.removeItem('fz_token');USER={};$('auth').style.display='flex';} 4598function startCreatorTrial(){ 4599 // No free trial â redirect to plans page 4600 showPage('page-plans'); 4601} 4602async function continueForFree(){ 4603 if(!TOKEN){return;} 4604 try{ 4605 const r=await fetch('/api/auth/downgrade-to-free',{method:'POST',headers:{'Authorization':'Bearer '+TOKEN}}); 4606 if(r.ok){toast('Welcome to the Free plan','ok');location.reload();} 4607 else{const d=await r.json();toast(d.detail||'Error','err');} 4608 }catch(e){toast('Connection error','err');} 4609} 4610function showTrialExpiredOverlay(){ 4611 const overlay=$('trial-expired-overlay'); 4612 if(overlay)overlay.style.display='flex'; 4613 $('auth').style.display='none'; 4614 $('main').style.display='none'; 4615} 4616 4617async function tryAutoLogin(){ 4618 if(!TOKEN)return; 4619 try{ 4620 const res=await api('GET','/api/auth/me'); 4621 if(res.ok){const d=await res.json();onLogin(d);} 4622 else if(res.status===403){ 4623 const d=await res.json(); 4624 if(d.detail==='trial_expired'){showTrialExpiredOverlay();} 4625 else{TOKEN='';localStorage.removeItem('fz_token');} 4626 } 4627 else{TOKEN='';localStorage.removeItem('fz_token');} 4628 }catch(e){} 4629} 4630_I18N_READY.then(tryAutoLogin); 4631 4632// ââ EMAIL VERIFICATION REDIRECT HANDLING ââââââ 4633(function(){ 4634 const vp=new URLSearchParams(location.search).get('verified'); 4635 if(!vp)return; 4636 history.replaceState(null,'',location.pathname); // clean URL 4637 setTimeout(()=>{ 4638 if(vp==='1') toast('Email verified! You can now log in once your account is activated.','ok'); 4639 else if(vp==='error') toast('Invalid verification link.','err'); 4640 else if(vp==='expired')toast('Verification link expired. Please register again.','err'); 4641 },400); 4642})(); 4643 4644// ââ ECONOMIC COUNTDOWN BANNER âââââââââââââââââ 4645const _HIGH_IMPACT_RELEASES=[ 4646 {kw:"Employment Situation", time:"8:30 AM EST"}, 4647 {kw:"Consumer Price", time:"8:30 AM EST"}, 4648 {kw:"Producer Price", time:"8:30 AM EST"}, 4649 {kw:"Gross Domestic Product", time:"8:30 AM EST"}, 4650 {kw:"Retail Sales", time:"8:30 AM EST"}, 4651 {kw:"Personal Income", time:"8:30 AM EST"}, 4652 {kw:"Initial Claims", time:"8:30 AM EST"}, 4653 {kw:"Housing Starts", time:"8:30 AM EST"}, 4654 {kw:"Industrial Production", time:"10:15 AM EST"}, 4655]; 4656async function loadEcoCountdown(){ 4657 const banner=$('eco-countdown-banner'); 4658 if(!banner)return; 4659 try{ 4660 const res=await api('GET','/api/fred/releases?days=7'); 4661 if(!res.ok)return; 4662 const d=await res.json(); 4663 const today=new Date().toISOString().slice(0,10); 4664 const scored=[]; 4665 for(const r of (d.releases||[])){ 4666 if(r.date!==today)continue; 4667 const idx=_HIGH_IMPACT_RELEASES.findIndex(k=>r.release_name.includes(k.kw)); 4668 if(idx>=0)scored.push({ev:r,pri:idx,time:_HIGH_IMPACT_RELEASES[idx].time}); 4669 } 4670 if(!scored.length)return; 4671 scored.sort((a,b)=>a.pri-b.pri); 4672 const{ev,time}=scored[0]; 4673 // Hide banner if the event release time has already passed (ET) 4674 const _parseET=t=>{const m=t.match(/(\d+):(\d+)\s*(AM|PM)/i);if(!m)return null;let h=parseInt(m[1]),min=parseInt(m[2]);if(m[3].toUpperCase()==='PM'&&h!==12)h+=12;if(m[3].toUpperCase()==='AM'&&h===12)h=0;return{h,min};}; 4675 const et=_parseET(time); 4676 if(et){const nowStr=new Date().toLocaleString('en-US',{timeZone:'America/New_York',hour:'2-digit',minute:'2-digit',hour12:false});const[nowH,nowM]=nowStr.split(':').map(Number);if(nowH>et.h||(nowH===et.h&&nowM>=et.min))return;} 4677 banner.innerHTML=` 4678 <span style="font-size:.95rem">ð¨</span>
4679 <span><b>${ev.release_name}</b> releasing today 4680 <span style="opacity:.7;font-size:.7rem"> · est. ${time}</span> 4681 </span> 4682 <button onclick="filterNews('economic');go('news')" 4683 style="margin-left:auto;background:transparent;border:1px solid rgba(255,193,7,.4);color:#ffc107;border-radius:4px;padding:2px 9px;cursor:pointer;font-size:.7rem"> 4684 View Calendar 4685 </button> 4686 <button onclick="this.closest('#eco-countdown-banner').style.display='none'" 4687 style="background:transparent;border:none;color:inherit;cursor:pointer;opacity:.5;font-size:.85rem">â</button>`; 4688 banner.style.display='flex'; 4689 }catch(e){} 4690} 4691// ââ OVERVIEW ââââââââââââââââââââââââââââââââââ 4692function initExtBanner(){ 4693 const proTiers=new Set(['creator','pro','stellar','enterprise_api','admin','super_admin']); 4694 if(!proTiers.has(USER.tier))return; 4695 if(localStorage.getItem('fz_ext_dismissed'))return; 4696 const b=document.getElementById('ext-install-banner'); 4697 if(b)b.style.display='flex'; 4698} 4699function dismissExtBanner(){ 4700 localStorage.setItem('fz_ext_dismissed','1'); 4701 const b=document.getElementById('ext-install-banner'); 4702 if(b)b.style.display='none'; 4703} 4704 4705function _lockedWidgetHtml(){ 4706 return `<div style="font-size:.72rem;color:var(--muted)">${t('overview.locked_widget')}</div>`; 4707} 4708function loadOverview(){ 4709 loadEcoCountdown(); 4710 4711 // Quick Actions â synchronous, USER is set before loadOverview is called 4712 const isCreatorTier=USER.tier==='creator'||USER.tier==='pro'||USER.tier==='stellar'||USER.tier==='admin'||USER.tier==='super_admin'; 4713 if($('ov-quick-actions')){ 4714 $('ov-quick-actions').innerHTML=isCreatorTier 4715 ?`<button class="btn btn-primary" style="flex:1;justify-content:center;padding:12px 8px" onclick="go('scanner')">â¶ Run Scanner</button> 4716 <button class="btn btn-primary" style="flex:1;justify-content:center;padding:12px 8px" onclick="go('scanner');setTimeout(()=>scannerTab('screener'),100)">ð Run Screener</button> 4717 <button class="btn btn-primary" style="flex:1;justify-content:center;padding:12px 8px" onclick="filterNews('brief');go('news')">ð Daily Brief</button>` 4718 :`<button class="btn btn-primary" style="flex:1;justify-content:center;padding:12px 8px" onclick="go('news')">ð° News Feed</button> 4719 <button class="btn btn-primary" style="flex:1;justify-content:center;padding:12px 8px" onclick="filterNews('brief');go('news')">ð Daily Brief</button>`; 4720 } 4721 4722 // All API sections fire in parallel â each renders independently as data arrives 4723 (async()=>{try{const res=await api('GET','/api/scanner/status');if(res.ok){const d=await res.json();$('ov-signals').textContent=(d.results||[]).filter(r=>r.direction).length;}}catch(e){}})(); 4724 4725 // Latest brief widget 4726 (async()=>{try{ 4727 const el=$('ov-latest-brief'); 4728 if(!el)return; 4729 const r=await api('GET','/api/briefs/today'); 4730 if(r.ok){ 4731 const d=await r.json(); 4732 const date=d.edition_date||''; 4733 const snippet=(d.html_content||'').replace(/<[^>]+>/g,' ').replace(/\s+/g,' ').trim().slice(0,180); 4734 el.innerHTML=` 4735 <div style="font-size:.65rem;color:var(--muted);font-family:var(--mono);margin-bottom:4px">${date} · PRE-MARKET</div> 4736 <div style="font-size:.75rem;color:var(--text2);line-height:1.6">${snippet}â¦</div>`; 4737 } else { 4738 el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">No brief today yet.</div>'; 4739 } 4740 }catch(e){const el=$('ov-latest-brief');if(el)el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">â</div>';}})(); 4741 4742 (async()=>{try{ 4743 const ro=await api('GET','/api/outputs'); 4744 if(ro.ok){ 4745 const rod=await ro.json(); 4746 const outputs=(rod.outputs||[]).slice(0,4); 4747 const el=$('ov-outputs'); 4748 if(!el)return; 4749 if(!outputs.length){el.innerHTML='<div style="font-size:.72rem;color:var(--muted);grid-column:1/-1">No outputs yet.</div>';return;} 4750 const typeLabel={'carousel_pil':'Carousel','carousel_templated':'Templated','collage':'Collage'}; 4751 const typeColor={'carousel_pil':'var(--teal)','carousel_templated':'var(--yellow)','collage':'var(--green)'}; 4752 el.innerHTML=outputs.map(o=>{ 4753 const slides=o.slides||[]; 4754 const thumb=o.thumbnail||slides[0]?.url||''; 4755 const label=typeLabel[o.type]||o.type; 4756 const color=typeColor[o.type]||'var(--muted)'; 4757 const urlsJson=JSON.stringify(slides.map(s=>s.url)); 4758 const thumbClick=thumb?`onclick="openLightbox(${urlsJson},0)" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:4px;background:var(--bg2);cursor:pointer"`: 4759 `style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:4px;background:var(--bg2)"`; 4760 return `<div style="background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:6px"> 4761 <div style="position:relative"><img src="${thumb}" ${thumbClick} onerror="this.style.display='none'">
4762 <span style="position:absolute;top:4px;left:4px;background:${color};color:#000;font-size:.55rem;font-weight:700;padding:2px 5px;border-radius:3px;font-family:var(--mono)">${label}</span> 4763 </div></div>`; 4764 }).join(''); 4765 } 4766 }catch(e){}})(); 4767 4768 (async()=>{try{ 4769 const nr=await api('GET','/api/news'); 4770 if(nr.ok){ 4771 const nd=await nr.json(); 4772 if(!_newsItems.length)_newsItems=nd.items||[]; 4773 _ovNewsItems=(nd.items||[]).filter(n=>n.content_type==='news'); 4774 _renderOvNews(_ovNewsItems.slice(0,5)); 4775 // DJT card â only if user opted in 4776 if(USER&&USER.djt_alerts){ 4777 const djtItems=(nd.items||[]).filter(n=>n.content_type==='djt').slice(0,5); 4778 const djtCard=$('ov-djt-card'),djtPrev=$('ov-djt-preview'); 4779 if(djtCard&&djtPrev&&djtItems.length){ 4780 djtCard.style.display=''; 4781 djtPrev.innerHTML=djtItems.map(n=>`<a href="${n.source_url||'#'}" target="_blank" style="text-decoration:none;color:inherit"><div style="font-size:.68rem;line-height:1.4;padding:6px 0;border-bottom:1px solid var(--border)"><span style="color:var(--muted);font-family:var(--mono);font-size:.58rem">${new Date(n.created_at).toLocaleString(undefined,{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}</span><br>${(n.title||'').slice(0,200)}${(n.title||'').length>200?'â¦':''}</div></a>`).join(''); 4782 } 4783 } 4784 }else if(nr.status===403){ 4785 _renderOvNews([]); 4786 const el=$('ov-news-preview'); 4787 if(el)el.innerHTML=_lockedWidgetHtml(); 4788 } 4789 }catch(e){}})(); 4790 4791 4792 (async()=>{try{ 4793 const [ms,cf,fg]=await Promise.all([ 4794 api('GET','/api/news/market-status'), 4795 api('GET','/api/news/crypto-feed'), 4796 api('GET','/api/news/crypto-feargreed'), 4797 ]); 4798 const strip=$('ov-market-strip'); 4799 if(strip){ 4800 let html=''; 4801 if(ms.ok){const md=await ms.json();const excs=md.exchanges||[];if(excs.length){html=excs.map((e,i)=>`${i>0?'<span style="color:var(--border);padding:0 2px">|</span>':''}<span style="display:inline-flex;align-items:center;gap:3px">${e.flag}<span style="color:var(--text2)">${e.label}</span><span style="color:${e.isOpen?'var(--green)':'var(--muted)'}">${e.isOpen?'â':'â'}</span></span>`).join('');}} 4802 if(cf.ok){const cd=await cf.json();const assets=cd.assets||[];const SHOW=['BTC','ETH','SOL','BNB','XRP'];const coins=SHOW.map(s=>assets.find(a=>a.symbol===s)).filter(Boolean);if(coins.length){html+='<span style="color:var(--border);padding:0 6px">·</span>';html+=coins.map(c=>{const p=Number(c.priceUsd);const pct=Number(c.changePercent24Hr);const pStr=p>=1000?'$'+p.toLocaleString('en-US',{maximumFractionDigits:0}):p>=1?'$'+p.toFixed(2):'$'+p.toFixed(4);const arrow=pct>0?'â²':pct<0?'â¼':'';const col=pct>0?'var(--green)':pct<0?'var(--red)':'var(--muted)';return `<span style="display:inline-flex;align-items:center;gap:3px"><span style="color:var(--text2);font-weight:700">${c.symbol}</span><span style="color:var(--text1)">${pStr}</span><span style="color:${col}">${arrow}${Math.abs(pct).toFixed(1)}%</span></span>`;}).join('<span style="color:var(--border);padding:0 3px"> </span>');}} 4803 if(fg.ok){const fd=await fg.json();const val=fd.value!=null?Math.round(fd.value):null;if(val!=null){const emoji=val<=25?'ð±':val<=45?'ð¨':val<=55?'ð':val<=75?'ð':'ð¤';const lbl=fd.classification||'';html+='<span style="color:var(--border);padding:0 6px">|</span>';html+=`<span style="display:inline-flex;align-items:center;gap:3px"><span style="color:var(--muted)">F&G</span><span style="color:var(--text1);font-weight:700">${val}</span><span>${emoji}</span><span style="color:var(--text2)">${lbl}</span></span>`;}} 4804 strip.innerHTML=html||'<span style="color:var(--muted)">Market status unavailable</span>'; 4805 } 4806 }catch(e){}})(); 4807 4808 (async()=>{try{ 4809 const er=await api('GET','/api/news/earnings-recent'); 4810 const epsel=$('ov-eps-panel'); 4811 if(er.ok&&epsel){ 4812 const ed=await er.json(); 4813 const eps=(ed.entries||[]).slice(0,6); 4814 if(!eps.length){epsel.innerHTML='<div style="font-size:.72rem;color:var(--muted)">No recent earnings reports.</div>';} 4815 else{epsel.innerHTML=eps.map(e=>{ 4816 const sp=e.surprise_pct; 4817 const beat=sp!=null&&sp>0,miss=sp!=null&&sp<0; 4818 const badge=beat?`<span style="background:rgba(0,200,83,.15);color:var(--green);border-radius:3px;padding:1px 5px;font-size:.6rem">â² BEAT</span>` 4819 :miss?`<span style="background:rgba(255,68,68,.15);color:var(--red);border-radius:3px;padding:1px 5px;font-size:.6rem">â¼ MISS</span>` 4820 :`<span style="color:var(--muted);font-size:.6rem">IN-LINE</span>`;
4821 const spTxt=sp!=null?`${sp>0?'+':''}${sp.toFixed(1)}%`:'â'; 4822 const fmtRev=v=>v!=null?(Math.abs(v)>=1e9?`$${(v/1e9).toFixed(2)}B`:`$${(v/1e6).toFixed(0)}M`):null; 4823 const revAct=fmtRev(e.rev_actual),revEst=fmtRev(e.rev_estimate); 4824 const _safeE=encodeURIComponent(JSON.stringify(e)); 4825 return `<div onclick="openEarningsDetail(JSON.parse(decodeURIComponent('${_safeE}')))" style="cursor:pointer;display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;margin-bottom:5px;transition:border-color .15s" onmouseover="this.style.borderColor='var(--teal)'" onmouseout="this.style.borderColor='var(--border)'"> 4826 <div style="font-size:.75rem;font-weight:700;color:var(--text);font-family:var(--mono);min-width:44px">${e.ticker}</div> 4827 ${badge} 4828 <div style="margin-left:auto;text-align:right"> 4829 <div style="font-size:.72rem;color:${beat?'var(--green)':miss?'var(--red)':'var(--muted)'};font-weight:600">${spTxt}</div> 4830 <div style="font-size:.62rem;color:var(--text2)">EPS ${e.eps_actual!=null?e.eps_actual.toFixed(2):'â'} vs ${e.eps_estimate!=null?e.eps_estimate.toFixed(2):'â'} est</div> 4831 ${revAct?`<div style="font-size:.62rem;color:var(--text2)">Rev ${revAct}${revEst?' vs '+revEst+' est':''}</div>`:''} 4832 </div> 4833 </div>`; 4834 }).join('');} 4835 } 4836 }catch(e){}})(); 4837 4838 loadInsiderFlow(); 4839 loadAnalystRatings(); 4840 loadPremarketScans(); 4841} 4842 4843// ââ PRE-MARKET SCANS PANEL âââââââââââââââââââ 4844async function loadPremarketScans(){ 4845 const el=$('premarket-scans-panel'); 4846 if(!el)return; 4847 try{ 4848 const res=await api('GET','/api/scanner/premarket-today'); 4849 if(!res.ok){el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">Pre-market scan unavailable.</div>';return;} 4850 const d=await res.json(); 4851 renderPremarketScans(el,d.results||[],d.collage_urls||[]); 4852 }catch(e){ 4853 el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">Pre-market scan unavailable.</div>'; 4854 } 4855} 4856let _pmData=[]; 4857function renderPremarketScans(el,results,collageUrls){ 4858 if(!results.length){ 4859 el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">No pre-market scan yet. Check back after 8 AM EST.</div>'; 4860 return; 4861 } 4862 _pmData=results; 4863 const _collageDl=$('pm-collage-dl'); 4864 if(_collageDl){const _isAdm=USER&&(USER.tier==='admin'||USER.tier==='super_admin');if(_isAdm&&collageUrls&&collageUrls.length){_collageDl.href=collageUrls[0];_collageDl.style.display='inline-flex';}else{_collageDl.style.display='none';}} 4865 const signals=results.filter(r=>r.direction==='BULLISH'||r.direction==='BEARISH'); 4866 const neutral=results.filter(r=>!r.direction||r.direction==='NONE'||r.direction===''); 4867 const sigCount=signals.length; 4868 4869 let html=`<div style="font-size:.62rem;color:var(--muted);margin-bottom:10px;font-family:var(--mono)">${results.length} tickers · ${sigCount} signal${sigCount!==1?'s':''}</div>`; 4870 4871 if(signals.length){ 4872 html+=`<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px">`; 4873 for(let i=0;i<signals.length;i++){ 4874 const r=signals[i]; 4875 const pmIdx=results.indexOf(r); 4876 const isBull=r.direction==='BULLISH'; 4877 const color=isBull?'var(--green)':'var(--red)'; 4878 const icon=isBull?'ð':'ð'; 4879 html+=`<div onclick="openPmChart(${pmIdx})" style="cursor:pointer;background:var(--bg3); 4880 border:1px solid ${isBull?'rgba(34,197,94,.25)':'rgba(239,68,68,.25)'};border-radius:8px; 4881 padding:8px 14px;display:flex;flex-direction:column;gap:3px;transition:background .15s" 4882 onmouseover="this.style.background='var(--bg2)'" onmouseout="this.style.background='var(--bg3)'"> 4883 <div style="font-size:.8rem;font-weight:700;color:${color}">${icon} ${r.ticker}</div> 4884 <div style="font-size:.58rem;color:var(--muted);letter-spacing:.4px">${r.direction} · view chart â</div> 4885 </div>`; 4886 } 4887 html+=`</div>`; 4888 } 4889 4890 if(neutral.length){ 4891 html+=`<div style="display:flex;gap:5px;flex-wrap:wrap">`; 4892 for(const r of neutral){ 4893 html+=`<span class="pm-badge neutral">${r.ticker}</span>`;
4894 } 4895 html+=`</div>`; 4896 } 4897 4898 el.innerHTML=html; 4899} 4900function openPmChart(idx){ 4901 try{ 4902 const r=_pmData[idx]; 4903 if(!r)return; 4904 $('pm-chart-caption').textContent=r.ticker+' \u00b7 '+r.direction; 4905 const container=$('pm-chart-container'); 4906 container.innerHTML=''; 4907 const w=document.createElement('tv-mini-chart'); 4908 w.setAttribute('symbol','NASDAQ:'+r.ticker); 4909 w.style.cssText='width:100%;height:220px'; 4910 container.appendChild(w); 4911 $('pm-chart-modal').style.display='flex'; 4912 document.body.style.overflow='hidden'; 4913 }catch(e){} 4914} 4915function closePmChart(){ 4916 $('pm-chart-modal').style.display='none'; 4917 document.body.style.overflow=''; 4918} 4919 4920function openTickerModal(ticker){ 4921 try{ 4922 const overlay=document.createElement('div'); 4923 overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:9999;display:flex;align-items:center;justify-content:center'; 4924 overlay.onclick=e=>{if(e.target===overlay)overlay.remove();}; 4925 const box=document.createElement('div'); 4926 box.style.cssText='background:var(--bg2);border-radius:10px;overflow:hidden;width:min(680px,95vw);height:420px;position:relative;border:1px solid var(--border)'; 4927 const close=document.createElement('button'); 4928 close.textContent='â'; 4929 close.style.cssText='position:absolute;top:8px;right:10px;background:transparent;border:none;color:var(--text2);font-size:.9rem;cursor:pointer;z-index:1'; 4930 close.onclick=()=>overlay.remove(); 4931 const script=document.createElement('script'); 4932 script.src='https://s3.tradingview.com/external-embedding/embed-widget-mini-symbol-overview.js'; 4933 script.async=true; 4934 script.textContent=JSON.stringify({ 4935 symbol:ticker,colorTheme:'dark',isTransparent:true, 4936 width:'100%',height:'100%',locale:'en', 4937 dateRange:'5D',trendLineColor:'rgba(0,230,118,1)',underLineColor:'rgba(0,230,118,0.1)' 4938 }); 4939 const tvWrap=document.createElement('div'); 4940 tvWrap.className='tradingview-widget-container'; 4941 tvWrap.style.cssText='width:100%;height:100%'; 4942 const inner=document.createElement('div'); 4943 inner.className='tradingview-widget-container__widget'; 4944 inner.style.cssText='width:100%;height:100%'; 4945 tvWrap.appendChild(inner); 4946 tvWrap.appendChild(script); 4947 box.appendChild(close); 4948 box.appendChild(tvWrap); 4949 overlay.appendChild(box); 4950 document.body.appendChild(overlay); 4951 }catch(e){} 4952} 4953 4954// ââ SCANNER âââââââââââââââââââââââââââââââââââ 4955let _scanAnimTimer=null; 4956function _startScanAnim(tickers){ 4957 const phases=['Fetching price data','Calculating MAs','Detecting signals','Generating chart']; 4958 let i=0; 4959 _scanAnimTimer=setInterval(()=>{ 4960 const ticker=tickers[i%tickers.length]; 4961 const phase=phases[Math.floor(i/tickers.length)%phases.length]; 4962 $('scan-status').innerHTML=`<span class="badge badge-teal" style="animation:pulse 1.1s ease infinite"><span class="spin" style="margin-right:5px"></span>${ticker} â ${phase}â¦</span>`; 4963 i++; 4964 },900); 4965} 4966function _stopScanAnim(){clearInterval(_scanAnimTimer);_scanAnimTimer=null;} 4967function loadTickersFromFile(input){ 4968 const file=input.files[0]; 4969 if(!file)return; 4970 const reader=new FileReader(); 4971 reader.onload=e=>{ 4972 try{ 4973 const wb=XLSX.read(e.target.result,{type:'array'}); 4974 const ws=wb.Sheets[wb.SheetNames[0]]; 4975 const rows=XLSX.utils.sheet_to_json(ws,{header:1,defval:''}); 4976 const tickers=rows 4977 .map(r=>String(r[0]||'').trim().toUpperCase()) 4978 .filter(t=>t&&/^[A-Z]{1,5}$/.test(t)); 4979 if(!tickers.length){toast('No valid tickers found in column A','err');return;} 4980 $('scan-tickers').value=tickers.join(', '); 4981 toast(`${tickers.length} ticker(s) loaded`,'ok'); 4982 }catch(err){toast('Could not read file','err');} 4983 input.value=''; 4984 }; 4985 reader.readAsArrayBuffer(file); 4986} 4987 4988function saveScannerList(){ 4989 const val=$('scan-tickers').value.trim(); 4990 if(!val){toast('No tickers to save','err');return;} 4991 localStorage.setItem('fz_default_tickers',val); 4992 toast('Ticker list saved'); 4993} 4994function loadScannerList(){ 4995 const saved=localStorage.getItem('fz_default_tickers'); 4996 if(!saved){toast('No saved list found','err');return;} 4997 $('scan-tickers').value=saved; 4998 toast('List loaded'); 4999} 5000 5001function _updateScannerBadge(){ 5002 const badge=$('scanner-badge'); 5003 if(!badge)return; 5004 try{ 5005 const now=new Date(); 5006 const day=now.toLocaleDateString('en-US',{timeZone:'America/Ne
5006w_York',weekday:'short'}); 5007 const h=parseInt(now.toLocaleString('en-US',{timeZone:'America/New_York',hour:'numeric',hour12:false})); 5008 const isWeekday=!['Sat','Sun'].includes(day); 5009 const isMarketHours=h>=8&&h<17; 5010 badge.style.display=(isWeekday&&isMarketHours&&lastScanCharts.length===0)?'':'none'; 5011 }catch(e){badge.style.display='none';} 5012} 5013 5014function _initProScanOpts(){ 5015 const isPro=USER&&new Set(['pro','stellar','enterprise_api','admin','super_admin']).has(USER.tier); 5016 document.querySelectorAll('.pro-scan-opt').forEach(opt=>{opt.disabled=!isPro;}); 5017 // Auto-fill saved ticker list if textarea is empty 5018 const saved=localStorage.getItem('fz_default_tickers'); 5019 if(saved&&!$('scan-tickers').value.trim())$('scan-tickers').value=saved; 5020} 5021 5022async function runScan(){ 5023 const tickers=$('scan-tickers').value.split(',').map(t=>t.trim().toUpperCase()).filter(Boolean); 5024 if(!tickers.length){toast('Enter tickers','err');return;} 5025 const scanType=$('scan-type-select').value; 5026 const btn=$('scan-btn');btn.innerHTML='<span class="spin"></span> Scanning...';btn.disabled=true; 5027 $('chart-grid').innerHTML=''; 5028 _startScanAnim(tickers); 5029 try{ 5030 const res=await api('POST','/api/scanner/run',{tickers,scan_type:scanType}); 5031 const d=await res.json(); 5032 if(!res.ok){$('scan-status').innerHTML=`<span class="badge badge-red">${d.detail}</span>`;return;} 5033 const signals=(d.results||[]).filter(r=>r.direction&&r.chart_b64); 5034 lastScanCharts=signals; 5035 _updateScannerBadge(); 5036 $('scan-status').innerHTML=`<span class="badge badge-green">â ${d.signals_found} signal(s)</span> <span class="badge badge-muted">${d.total_scanned} scanned</span>`; 5037 $('chart-count').textContent=`${signals.length} chart(s)`; 5038 $('scan-results').innerHTML=(d.results||[]).map(r=>{ 5039 let extra=''; 5040 if(r.rsi!=null)extra+=`<span style="color:var(--text2);font-size:.7rem">RSI ${r.rsi}</span>`; 5041 if(r.level!=null)extra+=`<span style="color:var(--text2);font-size:.7rem">Lvl $${r.level}</span>`; 5042 if(r.anchor)extra+=`<span style="color:var(--muted);font-size:.65rem">${r.anchor}</span>`; 5043 if(r.vol_ratio!=null)extra+=`<span style="color:var(--text2);font-size:.7rem">Vol Ã${r.vol_ratio}</span><span style="color:var(--muted);font-size:.65rem">${r.change_pct>0?'+':''}${r.change_pct}%</span>`; 5044 if(r.pct_from_extreme!=null)extra+=`<span style="color:var(--text2);font-size:.7rem">${r.pct_from_extreme}% from extreme</span>`; 5045 if(r.gap_pct!=null)extra+=`<span style="color:var(--text2);font-size:.7rem">Gap ${r.gap_pct>0?'+':''}${r.gap_pct}%</span>`; 5046 return `<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:6px 8px;background:var(--bg3);border-radius:5px;margin-bottom:5px;font-size:.75rem"> 5047 <span style="font-family:var(--mono);font-weight:700;color:#e2eaf2;min-width:50px">${r.ticker}</span> 5048 ${r.direction?`<span class="badge ${r.direction==='BULLISH'||r.direction==='OVERSOLD'?'badge-green':'badge-red'}">${r.direction}</span>`:'<span class="badge badge-muted">â</span>'} 5049 ${r.price?`<span style="color:var(--text2)">$${parseFloat(r.price).toFixed(2)}</span>`:''} 5050 ${extra} 5051 ${r.error?`<span style="color:var(--red);font-size:.65rem">${r.error}</span>`:''} 5052 </div>`;}).join(''); 5053 $('chart-grid').innerHTML=signals.map((c,i)=>` 5054 <div class="chart-thumb" id="cthumb-${i}" style="position:relative"> 5055 <img src="data:image/png;base64,${c.chart_b64}" alt="${c.ticker}" 5056 onclick="showChartModal(${i})" style="cursor:pointer" title="Click to enlarge"> 5057 <div class="${c.direction==='BULLISH'||c.direction==='OVERSOLD'?'sig-bull':'sig-bear'}">${c.ticker}</div> 5058 <div class="clabel">${c.direction||''}</div> 5059 </div>`).join(''); 5060 toast(`${d.signals_found} signal(s) found`,d.signals_found>0?'ok':''); 5061 loadScanHistory(); 5062 // Reload My Charts after a short delay (Cloudinary uploads are async) 5063 setTimeout(loadMyCharts, 4000); 5064 }catch(e){$('scan-status').innerHTML='<span class="badge badge-red">Connection error</span>';} 5065 finally{_stopScanAnim();btn.innerHTML='â¶ Run Scanner';btn.disabled=false;} 5066} 5067 5068// ââ CHART BUILDER ââââââââââââââââââââââââââââââ 5069async function loadChartIndicators(){ 5070 try{ 5071 const res=await api('GET','/api/scanner/indicators'); 5072 if(!res.ok)return; 5073 const d=await res.json(); 5074 const indicators=d.indicators||[]; 5075 $('cb-indicators').innerHTML=indicators.map(ind=>{ 5076 const params=ind.params.map(p=>` 5077 <label style="font-size:.7rem;color:var(--muted);margin-left:8px">${p.label}:</label> 5078 <input type="number" id="cb-p-${ind.id}-${p.key}" value="${p.default}" 5079 style="width:56px;padding:2px 5px;font-size:.75rem;background:var(--bg2); 5080 color:var(--text1);border:1px solid var(--border);border-radius:4px;margin-left:2px">`).join(''); 5081 return `<div style="display:flex;align-items:center;flex-wrap:wrap;gap:4px;padding:5px 8px;background:var(--bg3);border-radius:5px"> 5082 <input type="checkbox" id="cb-chk-${ind.id}" value="${ind.id}" style="margin-right:4px"> 5083 <label for="cb-chk-${ind.id}" data-api-name="${ind.api_name}" style="font-size:.8rem;font-weight:600;color:#e2eaf2;cursor:pointer;flex:1">${ind.label}</label> 5084 ${params} 5085 </div>`; 5086 }).join(''); 5087 }catch(e){$('cb-indicators').innerHTML='<p style="font-size:.75rem;color:var(--red)">Failed to load indicators</p>';} 5088} 5089 5090async function buildCustomChart(){ 5091 const ticker=($('cb-ticker').value||'').trim().toUpperCase(); 5092 if(!ticker){toast('Enter a ticker','err');return;} 5093 const interval=$('cb-interval').value; 5094 const range=$('cb-range').value; 5095 5096 // Build studies array from checked indicators 5097 const studies=[]; 5098 const indEls=document.querySelectorAll('#cb-indicators input[type=checkbox]:checked');
5099 indEls.forEach(chk=>{ 5100 const id=chk.value; 5101 const container=chk.closest('div'); 5102 const paramInputs=container.querySelectorAll(`input[id^="cb-p-${id}-"]`); 5103 const inputMap={}; 5104 paramInputs.forEach(inp=>{ 5105 const key=inp.id.replace(`cb-p-${id}-`,''); 5106 inputMap[key]=parseFloat(inp.value)||0; 5107 }); 5108 // Look up api_name from rendered data attr or use checkbox label text 5109 const label=container.querySelector('label[for]'); 5110 const apiName=label?label.dataset.apiName||label.textContent.trim():''; 5111 studies.push({name:apiName,input:inputMap}); 5112 }); 5113 5114 const btn=$('cb-btn');btn.innerHTML='<span class="spin"></span> Generating...';btn.disabled=true; 5115 $('cb-status').innerHTML=''; 5116 $('cb-chart-output').innerHTML=''; 5117 try{ 5118 const res=await api('POST','/api/scanner/chart-builder',{ticker,interval,range,studies}); 5119 if(!res.ok){const e=await res.json();$('cb-status').innerHTML=`<span style="color:var(--red)">${e.detail||'Error'}</span>`;return;} 5120 const blob=await res.blob(); 5121 const url=URL.createObjectURL(blob); 5122 $('cb-chart-output').innerHTML=`<img src="${url}" alt="${ticker}" style="max-width:100%;border-radius:6px">`; 5123 }catch(e){$('cb-status').innerHTML=`<span style="color:var(--red)">Connection error</span>`;} 5124 finally{btn.innerHTML='ð Generate Chart';btn.disabled=false;} 5125} 5126 5127// ââ SCAN HISTORY âââââââââââââââââââââââââââââââ 5128let _scanHistoryOpen={}; 5129 5130// ââ MY CHARTS LIBRARY ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 5131async function loadMyCharts(){ 5132 const grid=$('my-charts-grid'); 5133 if(!grid)return; 5134 try{ 5135 const res=await api('GET','/api/charts'); 5136 if(!res.ok){grid.innerHTML='<p style="font-size:.75rem;color:var(--text2);grid-column:1/-1">Failed to load charts.</p>';return;} 5137 const data=await res.json(); 5138 const charts=data.charts||[]; 5139 if(!charts.length){grid.innerHTML='<p style="font-size:.75rem;color:var(--text2);grid-column:1/-1">No saved charts yet. Run a scan to populate your library.</p>';return;} 5140 grid.innerHTML=charts.map(c=>{ 5141 const ago=_relTime(c.created_at); 5142 const dirColor=c.direction==='BULLISH'?'#4caf50':c.direction==='BEARISH'?'#f44336':'#aaa'; 5143 return `<div style="border:1px solid var(--border);border-radius:6px;overflow:hidden;background:var(--bg3)"> 5144 <img src="${c.chart_url}" alt="${c.ticker}" style="width:100%;height:90px;object-fit:cover;display:block;cursor:pointer" onclick="openImgPreview('${c.chart_url}')"> 5145 <div style="padding:5px 7px"> 5146 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px"> 5147 <span style="font-size:.72rem;font-weight:700;font-family:var(--mono)">${c.ticker}</span> 5148 <span style="font-size:.62rem;color:${dirColor};font-weight:600">${c.direction||'â'}</span> 5149 </div> 5150 <div style="font-size:.6rem;color:var(--text2);margin-bottom:5px">${ago}</div> 5151 <div style="display:flex;gap:4px"> 5152 <button class="btn btn-ghost" style="font-size:.6rem;padding:2px 6px;color:var(--red)" onclick="deleteLibChart('${c.id}',this)">â</button> 5153 </div> 5154 </div> 5155 </div>`; 5156 }).join(''); 5157 }catch(e){grid.innerHTML='<p style="font-size:.75rem;color:var(--text2);grid-column:1/-1">Error loading charts.</p>';} 5158} 5159 5160function _relTime(iso){ 5161 const d=new Date(iso);const now=new Date(); 5162 const diff=Math.round((now-d)/1000); 5163 if(diff<60)return'just now'; 5164 if(diff<3600)return`${Math.round(diff/60)}m ago`; 5165 if(diff<86400)return`${Math.round(diff/3600)}h ago`; 5166 return`${Math.round(diff/86400)}d ago`; 5167} 5168 5169// Fetch chart from URL and add it to the carousel file inputs 5170async function addLibChartToCarousel(url, ticker){ 5171 try{ 5172 const resp=await fetch(url); 5173 const blob=await resp.blob(); 5174 const file=new File([blob],`${ticker}.png`,{type:'image/png'}); 5175 go('carousel'); 5176 // Small delay to let carousel tab render 5177 setTimeout(()=>{ 5178 const inp=$('carousel-files'); 5179 if(inp){ 5180 const dt=new DataTransfer();
5181 if(inp.files){Array.from(inp.files).forEach(f=>dt.items.add(f));} 5182 dt.items.add(file);inp.files=dt.files; 5183 inp.dispatchEvent(new Event('change')); 5184 } 5185 toast(`${ticker} added to Carousel â click Build`,'ok'); 5186 },300); 5187 }catch(e){toast('Failed to load chart','err');} 5188} 5189 5190// Fetch chart from URL and add it to the collage file input 5191async function addLibChartToCollage(url, ticker){ 5192 try{ 5193 const resp=await fetch(url); 5194 const blob=await resp.blob(); 5195 const file=new File([blob],`${ticker}.png`,{type:'image/png'}); 5196 go('collage'); 5197 setTimeout(()=>{ 5198 const inp=$('collage-files'); 5199 if(inp){ 5200 const dt=new DataTransfer(); 5201 // preserve existing files 5202 if(inp.files){Array.from(inp.files).forEach(f=>dt.items.add(f));} 5203 dt.items.add(file); 5204 inp.files=dt.files; 5205 inp.dispatchEvent(new Event('change')); 5206 } 5207 toast(`${ticker} added to Collage`,'ok'); 5208 },300); 5209 }catch(e){toast('Failed to load chart','err');} 5210} 5211 5212async function deleteLibChart(id, btn){ 5213 btn.disabled=true; 5214 try{ 5215 const res=await api('DELETE',`/api/charts/${id}`); 5216 if(res.ok){btn.closest('div[style*="border-radius"]').remove();toast('Chart removed','ok');} 5217 else{btn.disabled=false;toast('Delete failed','err');} 5218 }catch(e){btn.disabled=false;toast('Error','err');} 5219} 5220 5221// ââ MY COLLAGES LIBRARY âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 5222async function loadMyCollages(){ 5223 const grid=$('my-collages-grid'); 5224 if(!grid)return; 5225 try{ 5226 const res=await api('GET','/api/collage/saved'); 5227 if(!res.ok){grid.innerHTML='<p style="font-size:.75rem;color:var(--text2);grid-column:1/-1">Failed to load collages.</p>';return;} 5228 const data=await res.json(); 5229 const collages=data.collages||[]; 5230 if(!collages.length){grid.innerHTML='<p style="font-size:.75rem;color:var(--text2);grid-column:1/-1">Generate a collage to save it here.</p>';return;} 5231 grid.innerHTML=collages.map(c=>{ 5232 const ago=_relTime(c.created_at); 5233 return `<div style="border:1px solid var(--border);border-radius:6px;overflow:hidden;background:var(--bg3)"> 5234 <img src="${c.thumbnail}" alt="collage" style="width:100%;height:90px;object-fit:cover;display:block;cursor:pointer" onclick="openLightbox(['${c.thumbnail}'],0)"> 5235 <div style="padding:5px 7px"> 5236 <div style="font-size:.6rem;color:var(--text2);margin-bottom:5px">${ago}</div> 5237 <div style="display:flex;gap:4px;flex-wrap:wrap"> 5238 <button class="btn btn-ghost" style="font-size:.6rem;padding:2px 5px;flex:1" onclick="useLibCollage('${c.thumbnail}')">ð¤ Use</button> 5239 <a href="${c.thumbnail}" download="collage.png" class="btn btn-ghost" style="font-size:.6rem;padding:2px 5px">â¬</a> 5240 <button class="btn btn-ghost" style="font-size:.6rem;padding:2px 5px;color:var(--red)" onclick="deleteLibCollage('${c.id}',this)">â</button> 5241 </div> 5242 </div> 5243 </div>`; 5244 }).join(''); 5245 }catch(e){grid.innerHTML='<p style="font-size:.75rem;color:var(--text2);grid-column:1/-1">Error loading collages.</p>';} 5246} 5247 5248async function useLibCollage(url){ 5249 try{ 5250 const res=await fetch(url); 5251 lastCollageBlob=await res.blob(); 5252 const objUrl=URL.createObjectURL(lastCollageBlob); 5253 $('collage-preview').innerHTML=`<img src="${objUrl}" style="width:100%;border-radius:8px;border:1px solid var(--border)">`; 5254 $('collage-actions').innerHTML=`<a href="${objUrl}" download="factoz_collage.png" class="btn btn-green">⬠Download</a><button class="btn btn-primary" onclick="sendCollageToPost()">ð¤ Send to Post â</button>`; 5255 setCollageStep(4); 5256 $('collage-actions').scrollIntoView({behavior:'smooth'}); 5257 toast('Collage loaded â click Send to Post to share','ok'); 5258 }catch(e){toast('Could not load collage','err');} 5259} 5260 5261async function deleteLibCollage(id, btn){ 5262 btn.disabled=true; 5263 try{ 5264 const res=await api('DELETE',`/api/collage/saved/${id}`); 5265 if(res.ok){btn.closest('div[style*="border-radius"]').remove();} 5266 else{btn.disabled=false;toast('Delete failed','err');} 5267 }catch(e){btn.disabled=false;toast('Error','err');} 5268} 5269 5270async function loadScanHistory(){ 5271 try{ 5272 const res=await api('GET','/api/scanner/history'); 5273 if(!res.ok)return; 5274 const runs=await res.json(); 5275 const el=$('scan-history-list'); 5276 if(!runs.length){el.innerHTML='<p style="font-size:.75rem;color:var(--text2)">No saved scans yet.</p>';return;} 5277 el.innerHTML=runs.map(r=>` 5278 <div id="shr-${r.id}" style="border:1px solid var(--border);border-radius:6px;margin-bottom:6px;overflow:hidden"> 5279 <div style="display:flex;align-items:center;justify-content:space-between;padding:7px 10px;background:var(--bg3);cursor:pointer" onclick="toggleScanRun('${r.id}')">
5280 <span style="font-size:.72rem;font-family:var(--mono);color:#e2eaf2">${r.name}</span> 5281 <div style="display:flex;align-items:center;gap:6px"> 5282 <span class="badge badge-muted" style="font-size:.62rem">${r.ticker_count} tickers</span> 5283 <span class="badge ${r.signal_count>0?'badge-green':'badge-muted'}" style="font-size:.62rem">${r.signal_count} signal${r.signal_count!==1?'s':''}</span> 5284 <button class="btn btn-ghost" style="font-size:.62rem;padding:2px 6px;color:var(--red)" onclick="event.stopPropagation();deleteScanRun('${r.id}')">✕</button> 5285 </div> 5286 </div> 5287 <div id="shd-${r.id}" style="display:none;padding:8px 10px"></div> 5288 </div>`).join(''); 5289 }catch(e){} 5290} 5291 5292async function toggleScanRun(id){ 5293 const detail=$('shd-'+id); 5294 if(!detail)return; 5295 const open=detail.style.display!=='none'; 5296 detail.style.display=open?'none':'block'; 5297 if(!open&&!_scanHistoryOpen[id]){ 5298 _scanHistoryOpen[id]=true; 5299 detail.innerHTML='<span style="font-size:.72rem;color:var(--text2)">Loading...</span>'; 5300 try{ 5301 const res=await api('GET',`/api/scanner/history/${id}`); 5302 if(!res.ok){detail.innerHTML='<span style="color:var(--red);font-size:.7rem">Failed to load</span>';return;} 5303 const d=await res.json(); 5304 detail.innerHTML=_renderHistoryResults(d.results,id); 5305 }catch(e){detail.innerHTML='<span style="color:var(--red);font-size:.7rem">Error loading scan</span>';} 5306 } 5307} 5308 5309function _renderHistoryResults(results,scanId){ 5310 const rows=results.map(r=>` 5311 <div id="shr-row-${scanId}-${r.ticker}" style="display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.72rem"> 5312 <span style="font-family:var(--mono);font-weight:700;width:54px">${r.ticker}</span> 5313 ${r.direction?`<span class="badge ${r.direction==='BULLISH'?'badge-green':'badge-red'}" style="font-size:.6rem">${r.direction==='BULLISH'?'â²':'â¼'} ${r.direction}</span>`:'<span class="badge badge-muted" style="font-size:.6rem">â</span>'} 5314 ${r.anchor?`<span style="color:var(--text2);font-size:.65rem">${r.anchor}</span>`:''} 5315 ${r.price?`<span style="color:#e2eaf2">$${parseFloat(r.price).toFixed(2)}</span>`:''} 5316 ${r.error?`<span style="color:var(--red);font-size:.65rem">${r.error}</span>`:''} 5317 <span id="shr-cmp-${scanId}-${r.ticker}"></span> 5318 </div>`).join(''); 5319 return `${rows} 5320 <button class="btn btn-primary" style="margin-top:8px;font-size:.72rem;padding:5px 12px" onclick="comparePrices('${scanId}',this)">Compare Today's Prices</button> 5321 <div id="shc-${scanId}" style="margin-top:8px"></div>`; 5322} 5323 5324async function deleteScanRun(id){ 5325 if(!confirm('Delete this scan?'))return; 5326 const res=await api('DELETE',`/api/scanner/history/${id}`); 5327 if(res.ok){const el=$('shr-'+id);if(el)el.remove();toast('Scan deleted','ok');} 5328 else toast('Delete failed','err'); 5329} 5330 5331async function comparePrices(scanId,btn){ 5332 btn.disabled=true;btn.textContent='Fetching prices...'; 5333 const ctr=$('shc-'+scanId); 5334 ctr.innerHTML='<span style="font-size:.7rem;color:var(--text2)">Comparing prices vs original scan...</span>'; 5335 document.querySelectorAll(`[id^="shr-cmp-${scanId}-"]`).forEach(s=>s.innerHTML=''); 5336 try{ 5337 const res=await api('POST',`/api/scanner/history/${scanId}/compare`); 5338 if(!res.ok){const e=await res.json();ctr.innerHTML=`<span style="color:var(--red);font-size:.7rem">${e.detail||'Error'}</span>`;return;} 5339 const d=await res.json(); 5340 ctr.innerHTML=''; 5341 (d.comparison||[]).forEach(r=>{ 5342 if(r.error){ 5343 const s=document.getElementById(`shr-cmp-${scanId}-${r.ticker}`); 5344 if(s)s.innerHTML=`<span style="color:var(--red);font-size:.65rem;margin-left:4px">${r.error}</span>`; 5345 return; 5346 } 5347 const up=r.delta>0,dn=r.delta<0; 5348 const dColor=up?'#26a69a':dn?'#ef5350':'var(--text2)'; 5349 const arrow=up?'â':dn?'â':'â'; 5350 const deltaStr=r.delta!=null?`<span style="color:${dColor};font-weight:700;margin-left:4px">${arrow} ${r.delta>0?'+':''}$${r.delta.toFixed(2)} (${r.delta_pct>0?'+':''}${r.delta_pct.toFixed(2)}%)</span>`:''; 5351 const newSig=r.new_direction?`<span class="badge ${r.new_direction==='BULLISH'?'badge-green':'badge-red'}" style="font-size:.6rem;margin-left:4px">NEW ${r.new_direction}</span>`:''; 5352 const curr=`<span style="color:var(--text2)">â $${parseFloat(r.curr_price).toFixed(2)}</span>`;
5353 const cmpSpan=document.getElementById(`shr-cmp-${scanId}-${r.ticker}`); 5354 if(cmpSpan)cmpSpan.innerHTML=` ${curr}${deltaStr}${newSig}`; 5355 if(r.chart_b64){ 5356 ctr.innerHTML+=`<div class="chart-thumb" style="margin-top:8px"> 5357 <img src="data:image/png;base64,${r.chart_b64}" alt="${r.ticker}" style="width:100%;border-radius:4px"> 5358 <div class="${r.new_direction==='BULLISH'?'sig-bull':'sig-bear'}">${r.ticker}</div> 5359 </div>`; 5360 } 5361 }); 5362 toast('Prices updated','ok'); 5363 }catch(e){ctr.innerHTML='<span style="color:var(--red);font-size:.7rem">Connection error</span>';} 5364 finally{btn.disabled=false;btn.textContent='Compare Today\'s Prices';} 5365} 5366 5367// ââ CAROUSEL ââââââââââââââââââââââââââââââââââ 5368let selectedPremiumTemplate='factoz'; 5369 5370async function loadRecentOutputs(){ 5371 const el=$('recent-outputs-grid'); 5372 if(!el)return; 5373 if(!el.querySelector('img')) el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">Loadingâ¦</div>'; 5374 try{ 5375 const res=await api('GET','/api/outputs'); 5376 const d=await res.json(); 5377 const outputs=d.outputs||[]; 5378 if(!outputs.length){ 5379 el.innerHTML='<div style="font-size:.72rem;color:var(--muted);grid-column:1/-1">No outputs yet â generate a carousel or collage to see it here.</div>'; 5380 return; 5381 } 5382 const typeLabel={'carousel_pil':'Carousel','carousel_templated':'Templated','collage':'Collage'}; 5383 const typeColor={'carousel_pil':'var(--teal)','carousel_templated':'var(--yellow)','collage':'var(--green)'}; 5384 el.innerHTML=outputs.map(o=>{ 5385 const slides=o.slides||[]; 5386 const thumb=o.thumbnail||slides[0]?.url||''; 5387 const label=typeLabel[o.type]||o.type; 5388 const color=typeColor[o.type]||'var(--muted)'; 5389 const ts=new Date(o.created_at).toLocaleString(undefined,{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}); 5390 const allUrls=slides.map(s=>s.url); 5391 const dlBtns=allUrls.map((u,i)=>`<button onclick="downloadCarouselOutput('${u}','${label.toLowerCase()}_slide${i+1}.png')" style="flex:1;font-size:.55rem;padding:2px 4px;background:var(--bg2);border:1px solid var(--border);border-radius:3px;color:var(--muted);cursor:pointer">â Download${allUrls.length>1?' '+(i+1):''}</button>`).join(''); 5392 return `<div style="background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:6px;display:flex;flex-direction:column;gap:4px"> 5393 <div style="position:relative"> 5394 <img src="${thumb}" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:4px;background:var(--bg2)" onerror="this.style.display='none'"> 5395 <span style="position:absolute;top:4px;left:4px;background:${color};color:#000;font-size:.55rem;font-weight:700;padding:2px 5px;border-radius:3px;font-family:var(--mono)">${label}</span> 5396 </div> 5397 <div style="font-size:.6rem;color:var(--muted);font-family:var(--mono)">${ts}</div> 5398 <div style="display:flex;gap:3px">${dlBtns}</div> 5399 </div>`; 5400 }).join(''); 5401 }catch(e){ 5402 if(el)el.innerHTML='<div style="font-size:.72rem;color:var(--muted);grid-column:1/-1">Could not load recent outputs.</div>'; 5403 } 5404} 5405 5406async function downloadCarouselOutput(url,filename){ 5407 try{ 5408 const resp=await fetch(url); 5409 const blob=await resp.blob(); 5410 const a=document.createElement('a'); 5411 a.href=URL.createObjectURL(blob); 5412 a.download=filename||'carousel.png'; 5413 a.click(); 5414 setTimeout(()=>URL.revokeObjectURL(a.href),5000); 5415 }catch(e){window.open(url,'_blank');} 5416} 5417 5418async function loadCarouselPersons(){ 5419 try{ 5420 const res=await api('GET','/api/carousel/persons'); 5421 if(!res.ok)return; 5422 const d=await res.json(); 5423 const opts='<option value="">â No person (plain background) â</option>'+ 5424 (d.persons||[]).map(p=>`<option value="${p.id}">${p.name}</option>`).join(''); 5425 const sel=$('carousel-person'); 5426 if(sel)sel.innerHTML=opts; 5427 }catch(e){} 5428} 5429 5430function switchCarouselTab(tab){ 5431 const isPil=tab==='pil'; 5432 $('carousel-pil-panel').style.display=isPil?'':'none'; 5433 $('carousel-premium-panel').style.display=isPil?'none':''; 5434 $('tab-pil').style.background=isPil?'rgba(226,194,117,.12)':'transparent'; 5435 $('tab-pil').style.color=isPil?'var(--teal)':'var(--muted)'; 5436 $('tab-premium').style.background=isPil?'transparent':'rgba(226,194,117,.12)'; 5437 $('tab-premium').style.color=isPil?'var(--muted)':'var(--teal)'; 5438 if(!isPil)checkPremiumStatus(); 5439} 5440 5441const TMPL_PREVIEWS={ 5442 factoz:{bg:'#0b132b',accent:'#E2C275',text:'#FFFFFF',font:'var(--mono)',label:'Factoz â Dark & Bold',desc:'Deep navy canvas with gold accents and grid overlay. Bold white headlines.'}, 5443 terminal:{bg:'#000000',accent:'#00FF41',text:'#00FF41',font:'var(--mono)',label:'Terminal â Bloomberg Feel',desc:'Pure black background, neon green monospace text. Trading terminal aesthetic.'}, 5444 editorial:{bg:'#F5F0E8',accent:'#1A1A2E',text:'#1A1A2E',font:'Georgia,serif',label:'Editorial â Magazine Style',desc:'Clean cream background, dark serif headlines. Premium print magazine layout.'}, 5445}; 5446function updateTemplatePreview(name){ 5447 const p=TMPL_PREVIEWS[name]; 5448 if(!p)return; 5449 const preview=$('carousel-preview'); 5450 if(!preview)return; 5451 if(preview.querySelector('.slide-thumb img'))return; 5452 preview.innerHTML=`<div style="grid-column:1/-1;border-radius:10px;overflow:hidden;border:2px solid ${p.accent};background:${p.bg};padding:26px 20px;font-family:${p.font};display:flex;flex-direction:column;gap:10px"> 5453 <div style="font-size:.6rem;letter-spacing:.14em;color:${p.accent};font-weight:700;text-transform:uppercase">${p.label}</div> 5454 <div style="width:100%;height:2px;background:${p.accent};border-radius:2px;opacity:.6"></div> 5455 <div style="font-size:1.1rem;font-weight:700;color:${p.text};line-height:1.3">MARKET SIGNAL<br>REPORT</div> 5456 <div style="font-size:.63rem;color:${p.text};opacity:.55;margin-top:2px">${p.desc}</div> 5457 <div style="display:flex;gap:6px;margin-top:8px"> 5458 <div style="background:${p.accent};opacity:.2;border-radius:4px;
5458height:28px;flex:1"></div> 5459 <div style="background:${p.accent};opacity:.14;border-radius:4px;height:28px;flex:2"></div> 5460 <div style="background:${p.accent};opacity:.09;border-radius:4px;height:28px;flex:1"></div> 5461 </div> 5462 <div style="font-size:.53rem;color:${p.accent};opacity:.75;text-align:right;margin-top:6px;letter-spacing:.07em">Brand kit applied â</div> 5463 </div>`; 5464} 5465function resetCarouselPreview(){ 5466 const preview=$('carousel-preview'); 5467 if(!preview)return; 5468 preview.innerHTML=` 5469 <div style="background:var(--bg3);border:1px dashed var(--border);border-radius:6px;aspect-ratio:1;display:flex;align-items:center;justify-content:center;font-size:.65rem;color:var(--muted)">Slide 1</div> 5470 <div style="background:var(--bg3);border:1px dashed var(--border);border-radius:6px;aspect-ratio:1;display:flex;align-items:center;justify-content:center;font-size:.65rem;color:var(--muted)">Slide 2</div> 5471 <div style="background:var(--bg3);border:1px dashed var(--border);border-radius:6px;aspect-ratio:1;display:flex;align-items:center;justify-content:center;font-size:.65rem;color:var(--muted)">Slide 3</div>`; 5472} 5473function selectTemplate(name,el){ 5474 selectedTemplate=name; 5475 document.querySelectorAll('#carousel-pil-panel .tmpl-card').forEach(c=>c.classList.remove('selected')); 5476 el.classList.add('selected'); 5477 updateTemplatePreview(name); 5478} 5479 5480function selectPremiumTemplate(name,el){ 5481 selectedPremiumTemplate=name; 5482 document.querySelectorAll('#premium-card-grid .tmpl-card').forEach(c=>c.classList.remove('selected')); 5483 if(el)el.classList.add('selected'); 5484} 5485 5486// ââ TEMPLATE CARD GRID ââââââââââââââââââââââââ 5487let _allTemplatesMeta=[]; 5488 5489async function loadTemplatesMeta(){ 5490 try{ 5491 const res=await api('GET','/api/templated/templates'); 5492 if(res.ok){const d=await res.json();_allTemplatesMeta=d.templates||[];} 5493 }catch(_){ 5494 _allTemplatesMeta=[ 5495 {id:'factoz', label:'Factoz', free:true, configured:true}, 5496 {id:'terminal', label:'Terminal', free:true, configured:true}, 5497 {id:'editorial', label:'Editorial', free:true, configured:true}, 5498 {id:'template_4', label:'Template 4', free:true, configured:false}, 5499 {id:'template_5', label:'Template 5', free:true, configured:false}, 5500 {id:'template_6', label:'Template 6', free:true, configured:false}, 5501 {id:'template_7', label:'Template 7', free:true, configured:false}, 5502 {id:'template_8', label:'Template 8', free:true, configured:false}, 5503 {id:'template_9', label:'Template 9', free:true, configured:false}, 5504 {id:'template_10',label:'Template 10',free:true, configured:false}, 5505 ]; 5506 } 5507} 5508 5509async function reloadTemplates(){ 5510 if(_allTemplatesMeta.length) renderTemplateGrid(); 5511 await loadTemplatesMeta(); 5512 renderTemplateGrid(); 5513} 5514 5515function renderTemplateGrid(){ 5516 const grid=$('premium-card-grid'); 5517 if(!grid)return; 5518 const isAdmin=USER.tier==='admin'||USER.tier==='super_admin'; 5519 5520 grid.innerHTML=_allTemplatesMeta.map(t=>{ 5521 // User-purchasable slot 5522 if(t.is_user_slot){ 5523 if(t.is_mine){ 5524 // User owns this slot â render as a selectable card 5525 const isSelected=t.id===selectedPremiumTemplate; 5526 return `<div class="tmpl-card${isSelected?' selected':''}" onclick="selectPremiumTemplate('${t.id}',this)" style="position:relative;cursor:pointer"> 5527 <div style="position:absolute;top:4px;right:4px;font-size:.5rem;background:var(--green);color:#000;padding:1px 5px;border-radius:3px;font-weight:700">MINE</div> 5528 <div class="tmpl-icon">ð</div> 5529 <div class="tmpl-name">${t.label}</div> 5530 <div class="tmpl-desc" style="font-size:.55rem;color:var(--green)">Unlocked</div> 5531 </div>`; 5532 } 5533 if(t.is_taken){ 5534 // Taken by another user â dimmed 5535 return `<div class="tmpl-card" style="opacity:.3;cursor:default;pointer-events:none"> 5536 <div class="tmpl-icon" style="font-size:1.1rem">ð</div> 5537 <div class="tmpl-name">${t.label}</div> 5538 <div class="tmpl-desc" style="font-size:.55rem;color:var(--muted)">Taken</div> 5539 </div>`; 5540 } 5541 // Available for purchase 5542 const price=t.price?`$${t.price} USDT`:'â'; 5543 return `<div class="tmpl-card" onclick="openSlotModal('${t.id}','${t.label}',${t.price||0})" style="cursor:pointer;border:1px dashed var(--border)"> 5544 <div class="tmpl-icon" style="font-size:1.1rem">ð³</div> 5545 <div class="tmpl-name">${t.label}</div> 5546 <div class="tmpl-desc" style="font-size:.55rem;color:var(--yellow)">${price}</div> 5547 </div>`; 5548 } 5549 5550 // Unconfigured admin slot â show dimmed placeholder 5551 if(t.configured===false){ 5552 return `<div class="tmpl-card" style="opacity:.3;cursor:default;pointer-events:none"> 5553 <div class="tmpl-icon" style="font-size:1.1rem">ï¼</div> 5554 <div class="tmpl-name">${t.label}</div> 5555 <div class="tmpl-desc" style="font-size:.55rem;color:var(--muted)">Not set</div> 5556 </div>`; 5557 } 5558 const isSelected=t.id===selectedPremiumTemplate; 5559 const thumb=t.thumbnail_url; 5560 const adminBtn=isAdmin 5561 ?`<button onclick="previewTemplate('${t.id}',event)" title="Regenerate preview" style="position:absolute;top:4px;right:4px;font-size:.55rem;padding:2px 5px;background:rgba(0,0,0,.65);border:1px solid #555;border-radius:4px;cursor:pointer;color:#fff;z-index:2">ð</button>` 5562 :''; 5563 5564 if(thumb){ 5565 return `<div class="tmpl-card${isSelected?' selected':''}" onclick="selectPremiumTemplate('${t.id}',this)" 5566 style="position:relative;cursor:pointer;background-image:url(${thumb});background-size:cover;background-position:center;padding:0;min-height:110px"> 5567 ${adminBtn} 5568 <div class="tmpl-thumb-overlay"> 5569 <div class="tmpl-name">${t.label}</div> 5570 <div class="tmpl-desc">Free</div> 5571 </div> 5572 </div>`; 5573 } 5574 return `<div class="tmpl-card${isSelected?' selected':''}" onclick="selectPremiumTemplate('${t.id}',this)" style="position:relative;cursor:pointer"> 5575 ${adminBtn} 5576 <div class="tmpl-icon">ð¨</div> 5577 <div class="tmpl-name">${t.label}</div> 5578 <div class="tmpl-desc" style="font-size:.58rem;color:var(--muted)">Free</div> 5579 </div>`; 5580 }).join(''); 5581} 5582 5583// âââ Template Slot Purchase âââââââââââââââââââââââââââââââââââââââââââ 5584let _slotPayKey='', _slotPayCurrency='USDT', _slotPaymentId='', _slotPayAddr=''; 5585 5586function openSlotModal(key,label,price){ 5587 _slotPayKey=key; _slotPayCurrency='USDT'; _slotPaymentId=''; _slotPayAddr=''; 5588 $('slot-pay-label').textContent=label; 5589 $('slot-pay-price').textContent=price?`$${price} USDT`:'â'; 5590 $('slot-pay-step-initiate').style.display=''; 5591 $('slot-pay-step-address').style.display='none'; 5592 $('slot-pay-step-done').style.display='none'; 5593 $('slot-pay-err').style.display='none';
5594 selectSlotCurrency('USDT'); 5595 const ov=$('slot-pay-overlay'); 5596 ov.style.display='flex'; 5597} 5598 5599function closeSlotModal(){ 5600 $('slot-pay-overlay').style.display='none'; 5601 if(_slotPaymentId) reloadTemplates(); // refresh grid if payment was initiated 5602} 5603 5604function selectSlotCurrency(c){ 5605 _slotPayCurrency=c; 5606 ['USDT','USDC'].forEach(x=>{ 5607 const btn=$(`slot-curr-${x.toLowerCase()}`); 5608 if(btn) btn.style.background = x===c?'var(--yellow)':''; 5609 if(btn) btn.style.color = x===c?'#000':''; 5610 }); 5611} 5612 5613async function createSlotPayment(){ 5614 const btn=$('slot-pay-btn'); 5615 btn.disabled=true; btn.textContent='Creatingâ¦'; 5616 $('slot-pay-err').style.display='none'; 5617 try{ 5618 const r=await api('POST','/api/payments/create',{template_id:_slotPayKey,currency:_slotPayCurrency}); 5619 const d=await r.json(); 5620 if(!r.ok){ throw new Error(d.detail||'Payment creation failed'); } 5621 if(d.already_unlocked){ 5622 toast('You already own this template slot','ok'); 5623 closeSlotModal(); return; 5624 } 5625 _slotPaymentId=d.payment_id; 5626 _slotPayAddr=d.pay_address; 5627 $('slot-pay-amount').textContent=`${d.pay_amount} ${d.pay_currency.toUpperCase()}`; 5628 $('slot-pay-addr').textContent=d.pay_address; 5629 $('slot-pay-step-initiate').style.display='none'; 5630 $('slot-pay-step-address').style.display=''; 5631 }catch(e){ 5632 const el=$('slot-pay-err'); 5633 el.textContent=e.message; el.style.display=''; 5634 }finally{ 5635 btn.disabled=false; btn.textContent='Create Payment Invoice'; 5636 } 5637} 5638 5639function copySlotAddress(){ 5640 navigator.clipboard.writeText(_slotPayAddr).then(()=>toast('Address copied','ok')); 5641} 5642 5643async function checkSlotPayment(){ 5644 const btn=$('slot-check-btn'); 5645 const res=$('slot-check-result'); 5646 btn.disabled=true; btn.textContent='Checkingâ¦'; 5647 res.textContent=''; 5648 try{ 5649 const r=await api('GET','/api/payments/unlocked'); 5650 const d=await r.json(); 5651 if((d.unlocked||[]).includes(_slotPayKey)){ 5652 $('slot-pay-step-address').style.display='none'; 5653 $('slot-pay-step-done').style.display=''; 5654 }else{ 5655 res.style.color='var(--muted)'; 5656 res.textContent='Payment not yet confirmed. Please wait a few minutes and try again.'; 5657 } 5658 }catch(e){ 5659 res.style.color='var(--red)'; res.textContent='Error checking status.'; 5660 }finally{ 5661 btn.disabled=false; btn.textContent='Check Payment Status'; 5662 } 5663} 5664// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 5665 5666async function previewTemplate(id,e){ 5667 e.stopPropagation(); 5668 const btn=e.currentTarget; 5669 const orig=btn.textContent; 5670 btn.textContent='...'; 5671 btn.disabled=true; 5672 try{ 5673 const r=await fetch(`/api/admin/templates/${id}/preview`,{ 5674 method:'POST', 5675 headers:{Authorization:`Bearer ${TOKEN}`} 5676 }); 5677 const d=await r.json(); 5678 if(r.ok&&d.thumbnail_url){ 5679 // Update cached meta with new thumbnail URL 5680 const meta=_allTemplatesMeta.find(t=>t.id===id); 5681 if(meta)meta.thumbnail_url=d.thumbnail_url+'?v='+Date.now(); 5682 renderTemplateGrid(); 5683 toast(`Preview updated for ${id}`,'ok'); 5684 }else{ 5685 toast(d.detail||'Preview failed','err'); 5686 btn.textContent=orig; 5687 btn.disabled=false; 5688 } 5689 }catch(err){ 5690 toast('Preview request failed','err'); 5691 btn.textContent=orig; 5692 btn.disabled=false; 5693 } 5694} 5695 5696 5697function setCarouselStep(n){ 5698 for(let i=1;i<=4;i++){const el=document.getElementById(`rstep-${i}`);if(!el)continue;el.className='wiz-step'+(i<n?' done':i===n?' active':'');} 5699 const hints={1:'Upload images or send from Scanner to begin.',2:'Images loaded â pick template & person, then build.',3:'Building carouselâ¦',4:'Carousel ready â download slides or send to Post â'}; 5700 const h=$('carousel-hint');if(h)h.textContent=hints[n]||''; 5701 if(n===1)resetCarouselPreview(); 5702} 5703document.addEventListener('DOMContentLoaded',()=>{ 5704 // Swatch color picker 5705 document.querySelectorAll('.swatch').forEach(s=>{ 5706 s.addEventListener('click',()=>{ 5707 document.querySelectorAll('.swatch').forEach(x=>x.classList.remove('selected')); 5708 s.classList.add('selected'); 5709 const tc=document.getElementById('text-color');if(tc)tc.value=s.dataset.color; 5710 }); 5711 }); 5712 const cf=document.getElementById('carousel-files'); 5713 if(cf)cf.addEventListener('change',()=>{ 5714 const info=$('carousel-files-info'); 5715 if(cf.files.length>0){ 5716 setCarouselStep(2); 5717 if(info)info.textContent=`${cf.files.length} image${cf.files.length>1?'s':''} ready: `+Array.from(cf.files).map(f=>f.name).join(', '); 5718 }else if(info)info.textContent=''; 5719 }); 5720}); 5721async function buildCarousel(){ 5722 const btn=$('carousel-btn'); 5723 btn.innerHTML='<span class="spin"></span> Building...';btn.disabled=true; 5724 const fd=new FormData(); 5725 const files=$('carousel-files').files; 5726 if(files.length>
57260){Array.from(files).forEach(f=>fd.append('images',f));} 5727 else if(lastScanCharts.length>0){ 5728 lastScanCharts.slice(0,3).forEach(c=>{fd.append('images',b64toBlob(c.chart_b64),`${c.ticker}.png`);}); 5729 }else{toast('Run a scan or upload images first','err');btn.innerHTML='â¡ Build';btn.disabled=false;return;} 5730 fd.append('template',selectedTemplate); 5731 const t1=document.getElementById('slide-text-1')?.value||''; 5732 const t2=document.getElementById('slide-text-2')?.value||''; 5733 const t3=document.getElementById('slide-text-3')?.value||''; 5734 if(t1)fd.append('slide_1_text',t1); 5735 if(t2)fd.append('slide_2_text',t2); 5736 if(t3)fd.append('slide_3_text',t3); 5737 fd.append('overlay_font',document.getElementById('overlay-font')?.value||'anton'); 5738 fd.append('text_color',document.getElementById('text-color')?.value||'#FFFFFF'); 5739 fd.append('font_size',document.getElementById('font-size')?.value||'40'); 5740 setCarouselStep(3); 5741 try{ 5742 const res=await fetch(API+'/api/carousel/build',{method:'POST',headers:{Authorization:`Bearer ${TOKEN}`},body:fd}); 5743 if(!res.ok){const e=await res.json();toast('Error: '+(e.detail||'Failed'),'err');
5743setCarouselStep(2);return;} 5744 const d=await res.json(); 5745 lastCarouselSlides=d.slides_b64||[]; 5746 const _slideUrls=lastCarouselSlides.map(b64=>URL.createObjectURL(b64toBlob(b64))); 5747 const _urlsJson=JSON.stringify(_slideUrls); 5748 $('carousel-preview').innerHTML=lastCarouselSlides.map((b64,i)=>` 5749 <div><div class="slide-thumb" onclick="openLightbox(${_urlsJson},${i})" style="cursor:pointer"><img src="data:image/png;base64,${b64}"></div> 5750 <div style="font-size:.62rem;color:var(--muted);text-align:center;margin-top:4px;font-family:var(--mono)">Slide ${i+1}</div></div>`).join(''); 5751 $('carousel-caption-preview').value=''; 5752 const dlLinks=lastCarouselSlides.map((b64,i)=>{ 5753 const url=URL.createObjectURL(b64toBlob(b64)); 5754 return `<a href="${url}" download="slide_${i+1}.png" class="btn btn-ghost" style="font-size:.72rem">⬠Slide ${i+1}</a>`; 5755 }).join(''); 5756 $('carousel-actions').innerHTML=dlLinks 5757 +`<button class="btn btn-primary" onclick="sendCarouselToPost()">ð¤ Use in Post â</button>` 5758 +`<button class="btn btn-ghost" onclick="resetCarouselBuild()" style="margin-left:auto">⺠New Build</button>`; 5759 setCarouselStep(4); 5760 toast('Carousel built!','ok'); 5761 }catch(e){toast('Connection error','err');setCarouselStep(2);} 5762 finally{btn.innerHTML='â¡ Build';btn.disabled=false;} 5763} 5764function resetCarouselBuild(){ 5765 lastCarouselSlides=[]; 5766 const cf=$('carousel-files'); 5767 if(cf){cf.value='';const info=$('carousel-files-info');if(info)info.textContent='';} 5768 $('carousel-actions').innerHTML=''; 5769 $('carousel-caption-preview').value=''; 5770 ['slide-text-1','slide-text-2','slide-text-3'].forEach(id=>{const el=document.getElementById(id);if(el)el.value='';}); 5771 const of=document.getElementById('overlay-font');if(of)of.value='impact'; 5772 document.querySelectorAll('.swatch').forEach(s=>s.classList.toggle('selected',s.dataset.color==='#FFFFFF')); 5773 const tc=document.getElementById('text-color');if(tc)tc.value='#FFFFFF'; 5774 const fs=document.getElementById('font-size');if(fs){fs.value=40;const fv=document.getElementById('font-size-val');if(fv)fv.textContent='40';} 5775 setCarouselStep(1); 5776} 5777function sendCarouselToPost(){ 5778 if(!lastCarouselSlides.length)return; 5779 const file=new File([b64toBlob(lastCarouselSlides[0])],'carousel_slide1.png',{type:'image/png'}); 5780 const dt=new DataTransfer();dt.items.add(file); 5781 $('post-file').files=dt.files;previewPost(); 5782 const cap=$('carousel-caption-preview').value.trim(); 5783 if(cap){$('post-caption').value=cap;syncCaption();} 5784 go('post');toast('Sent to Post','ok'); 5785} 5786async function uploadAndGetURLs(){ 5787 if(!lastCarouselSlides.length){toast('Build carousel first','err');return;} 5788 const btn=$('carousel-upload-btn');btn.innerHTML='<span class="spin"></span>';btn.disabled=true; 5789 const fd=new FormData(); 5790 lastCarouselSlides.forEach((b64,i)=>fd.append('images',b64toBlob(b64),`slide_${i+1}.png`)); 5791 fd.append('template',selectedTemplate);fd.append('upload_slides','true'); 5792 try{ 5793 const res=await fetch(API+'/api/carousel/build',{method:'POST',headers:{Authorization:`Bearer ${TOKEN}`},body:fd}); 5794 if(res.ok){const d=await res.json();toast(`${d.slide_count} slides uploaded`,'ok');} 5795 }catch(e){toast('Upload failed','err');} 5796 finally{btn.innerHTML='âï¸ Upload';btn.disabled=false;} 5797} 5798 5799// ââ TEMPLATED.IO INTEGRATION âââââââââââââââââââââââââ 5800async function checkPremiumStatus(){ 5801 try{ 5802 const res=await api('GET','/api/templated/status'); 5803 if(!res.ok)return; 5804 const d=await res.json(); 5805 const txt=$('premium-status-text'); 5806 const buildForm=$('premium-build-form'); 5807 const guide=$('premium-setup-guide'); 5808 5809 if(!d.configured){ 5810 if(USER.tier==='admin'||USER.tier==='super_admin'){ 5811 txt.innerHTML='<span style="color:var(--red)">â Carousel engine not configured</span> â set required env vars in Railway'; 5812 guide.style.display='block'; 5813 } else { 5814 txt.innerHTML='<span style="color:var(--yellow)">Carousel generation unavailable</span>'; 5815 } 5816 buildForm.style.display='none'; 5817 return; 5818 } 5819 5820 const tr=d.templates_ready||{}; 5821 const anyReady=Object.values(tr).some(v=>v); 5822 txt.innerHTML='<span style="color:var(--green)">â Carousel engine ready</span>'; 5823 5824 if(!anyReady){ 5825 if(USER.tier==='admin')guide.style.display='block'; 5826 buildForm.style.display='none'; 5827 txt.innerHTML+=' â <span style="color:var(--yellow)">no templates configured yet</span>'; 5828 return; 5829 } 5830 guide.style.display='none';buildForm.style.display='block'; 5831 5832 // Load template metadata, then render card grid 5833 await loadTemplatesMeta(); 5834 renderTemplateGrid(); 5835
5836 // Pre-fill captions from brand kit + show logo indicator 5837 try{ 5838 const br=await api('GET','/api/brand'); 5839 if(br.ok){ 5840 const bd=await br.json(); 5841 if(bd.name){[1,2,3].forEach(i=>{const el=$(`premium-caption-${i}`);if(el&&!el.value)el.value=bd.name;});} 5842 if(bd.logo_url){ 5843 const row=$('premium-logo-row'),img=$('premium-logo-preview'); 5844 if(row&&img){img.src=bd.logo_url;row.style.display='flex';} 5845 } 5846 } 5847 }catch(_){} 5848 5849 }catch(e){console.error('Carousel status check failed',e);} 5850} 5851 5852async function buildPremiumCarousel(){ 5853 const template=selectedPremiumTemplate; 5854 const btn=$('premium-build-btn'); 5855 const resultEl=$('premium-build-result'); 5856 5857 async function fileToB64(file){ 5858 if(!file)return null; 5859 return new Promise(res=>{ 5860 const r=new FileReader();r.onload=e=>res(e.target.result.split(',')[1]);r.readAsDataURL(file); 5861 }); 5862 } 5863 5864 async function urlToB64(url){ 5865 const resp=await fetch(url); 5866 const blob=await resp.blob(); 5867 return new Promise(res=>{ 5868 const r=new FileReader();r.onload=e=>res(e.target.result.split(',')[1]);r.readAsDataURL(blob); 5869 }); 5870 } 5871 5872 // Collect slide images â gallery URL picks are fetched and converted to b64 client-side 5873 const slideB64s=[]; 5874 for(let i=1;i<=3;i++){ 5875 const sel=_slideSelections[i]; 5876 if(sel&&sel.type==='url'){ 5877 const b64=await urlToB64(sel.value); 5878 if(b64) slideB64s.push(b64); 5879 else break; 5880 } else { 5881 const f=$(`premium-slide${i}`)?.files[0]; 5882 const b64=await fileToB64(f); 5883 if(b64) slideB64s.push(b64); 5884 else break; 5885 } 5886 } 5887 5888 const hasAny=slideB64s.length>0; 5889 if(!hasAny){ 5890 toast('Upload or pick at least one slide image','err');return; 5891 } 5892 5893 btn.innerHTML='<span class="spin"></span> Rendering...';btn.disabled=true; 5894 resultEl.innerHTML=`<div style="font-size:.72rem;color:var(--muted)">Generating carousel... ~5â10 seconds</div>`; 5895 5896 try{ 5897 const captions=[1,2,3].map(i=>($(`premium-caption-${i}`)?.value||'').trim()); 5898 const res=await api('POST','/api/templated/render',{ 5899 template, 5900 slides_b64: slideB64s, 5901 captions, 5902 }); 5903 const d=await res.json(); 5904 if(!res.ok){resultEl.innerHTML=`<span style="color:var(--red)">${d.detail||'Render failed'}</span>`;return;} 5905 5906 const slides=d.slides||[]; 5907 const _allSlideUrls=slides.map(s=>s.cloudinary_url||s.render_url); 5908 const _allSlideUrlsJson=JSON.stringify(_allSlideUrls); 5909 $('premium-preview-grid').innerHTML=slides.map((s,i)=>` 5910 <div> 5911 <div class="slide-thumb" style="cursor:pointer" onclick="openLightbox(${_allSlideUrlsJson},${i})"><img src="${s.cloudinary_url||s.render_url}" onerror="this.src='${s.render_url}'"></div> 5912 </div> 5913 `).join(''); 5914 5915 resultEl.innerHTML=`<span style="color:var(--green)">â ${slides.length} slides rendered!</span>`; 5916 toast('Carousel ready!','ok'); 5917 }catch(e){resultEl.innerHTML=`<span style="color:var(--red)">Connection error</span>`;} 5918 finally{btn.innerHTML='⨠Generate Carousel';btn.disabled=false;} 5919} 5920 5921// ââ COLLAGE âââââââââââââââââââââââââââââââââââ 5922function setCollageStep(n){ 5923 for(let i=1;i<=4;i++){const el=document.getElementById(`cstep-${i}`);if(!el)continue;el.className='wiz-step'+(i<n?' done':i===n?' active':'');} 5924 const hints={1:'Upload charts or send from Scanner to begin.',2:'Charts loaded â pick orientation & export target, then generate.',3:'Generating collageâ¦',4:'Collage ready â download or send to Post â'}; 5925 const h=$('collage-hint');if(h)h.textContent=hints[n]||''; 5926} 5927document.addEventListener('DOMContentLoaded',()=>{ 5928 const cf=document.getElementById('collage-files'); 5929 if(cf)cf.addEventListener('change',()=>{ 5930 const info=$('collage-files-info'); 5931 if(cf.files.length>0){ 5932 setCollageStep(2); 5933 if(info)info.textContent=`${cf.files.length} image${cf.files.length>1?'s':''} ready: `+Array.from(cf.files).map(f=>f.name).join(', '); 5934 }else if(info)info.textContent=''; 5935 }); 5936}); 5937async function buildCollage(){ 5938 const orientation=$('collage-orientation').value,exportTarget=$('collage-export').value; 5939 const btn=$('collage-btn');btn.innerHTML='<span class="spin"></span> Generating...';btn.disabled=true; 5940 const fd=new FormData(); 5941 const files=$('collage-files').files; 5942 if(files.length>
59420)Array.from(files).forEach(f=>fd.append('images',f)); 5943 else if(lastScanCharts.length>0)lastScanCharts.slice(0,10).forEach(c=>fd.append('images',b64toBlob(c.chart_b64),`${c.ticker}.png`)); 5944 else{toast('Upload images or run a scan first','err');btn.innerHTML='ð§© Generate Collage';btn.disabled=false;return;} 5945 fd.append('orientation',orientation);fd.append('export_target',exportTarget); 5946 setCollageStep(3); 5947 try{ 5948 const res=await fetch(API+'/api/collage/generate',{method:'POST',headers:{Authorization:`Bearer ${TOKEN}`},body:fd}); 5949 if(!res.ok){const e=await res.json();toast('Error: '+(e.detail||'Failed'),'err');setCollageStep(2);return;} 5950 const blob=await res.blob();lastCollageBlob=blob; 5951 const url=URL.createObjectURL(blob); 5952 $('collage-preview').innerHTML=`<img src="${url}" style="width:100%;border-radius:8px;border:1px solid var(--border)">`; 5953 $('collage-actions').innerHTML=`<a href="${url}" download="factoz_collage.png" class="btn btn-green">⬠Download</a><button class="btn btn-primary" onclick="sendCollageToPost()">ð¤ Send to Post â</button>`; 5954 setCollageStep(4); 5955 toast('Collage generated!','ok'); 5956 setTimeout(loadMyCollages, 4000); 5957 }catch(e){toast('Connection error','err');setCollageStep(2);} 5958 finally{btn.innerHTML='ð§© Generate Collage';btn.disabled=false;} 5959} 5960function sendCollageToPost(){ 5961 if(!lastCollageBlob)return; 5962 const file=new File([lastCollageBlob],'collage.png',{type:'image/png'}); 5963 const dt=new DataTransfer();dt.items.add(file); 5964 $('post-file').files=dt.files;previewPost();go('post');toast('Sent to Post','ok'); 5965} 5966 5967// ââ POST ââââââââââââââââââââââââââââââââââââââ 5968function previewPost(){const file=$('post-file').files[0];if(!file)return;$('post-img-preview').innerHTML=`<img src="${URL.createObjectURL(file)}" style="width:100%;border-radius:8px;border:1px solid var(--border);max-height:180px;object-fit:contain">`;} 5969function syncCaption(){const v=$('post-caption').value;$('caption-preview').textContent=v||'Start typing...';const el=$('char-count');el.textContent=`${v.length} / 280`;el.style.color=v.length>260?'var(--red)':'var(--muted)';} 5970async function quickCaption(){ 5971 const fileInput=$('post-file'); 5972 if(!fileInput?.files?.[0]){toast('Upload an image first to generate a caption','err');return;} 5973 const tickers=($('quick-tickers').value||'').trim(); 5974 const btn=document.querySelector('[onclick="quickCaption()"]'); 5975 const origLabel=btn.innerHTML; 5976 btn.disabled=true;btn.innerHTML='<span class="spin"></span> Analyzing...'; 5977 try{ 5978 const fd=new FormData(); 5979 fd.append('image',fileInput.files[0]); 5980 if(tickers)fd.append('tickers',tickers); 5981 fd.append('tone','professional'); 5982 const res=await fetch('/api/post/caption',{method:'POST',headers:hdr(),body:fd}); 5983 const d=await res.json(); 5984 if(res.status===429){ 5985 const msg=d.detail?.message||'Daily AI limit reached (5/day on Creator plan). Upgrade to Pro for unlimited.'; 5986 toast(msg,'err'); 5987 return; 5988 } 5989 if(!res.ok){toast((typeof d.detail==='string'?d.detail:d.detail?.message)||'Caption failed','err');return;} 5990 $('post-caption').value=d.caption||''; 5991 syncCaption(); 5992 const rem=d.remaining; 5993 toast(rem!=null?`Caption generated! (${rem} left this week)`:'Caption generated!','ok'); 5994 }catch(e){toast('Error generating caption','err');} 5995 finally{btn.disabled=false;btn.innerHTML=origLabel;} 5996} 5997async function doPost(){ 5998 const file=$('post-file').files[0],caption=$('post-caption').value; 5999 const postX=$('post-x').checked,postIg=$('post-ig').checked; 6000 if(!file){toast('Upload an image first','err');return;} 6001 if(!caption){toast('Write a caption first','err');return;} 6002 if(!postX&&!postIg){toast('Select at least one platform','err');return;} 6003 const btn=$('post-btn');btn.innerHTML='<span class="spin"></span> Posting...';btn.disabled=true; 6004 const platforms=[postX&&'x',postIg&&'instagram'].filter(Boolean).join(','); 6005 const fd=new FormData();fd.append('image',file);fd.append('caption',caption);fd.append('platforms',platforms); 6006 try{ 6007 const res=await fetch(API+'/api/post/all',{method:'POST',headers:{Authorization:`Bearer ${TOKEN}`},body:fd}); 6008 const d=await res.json(); 6009 if(!res.ok){$('post-result').innerHTML=`<div class="card"><p style="color:var(--red)">${d.detail}</p></div>`;return;} 6010 $('post-result').innerHTML=(d.results||[]).map(r=>` 6011 <div class="card" style="padding:10px 14px;margin-bottom:8px"> 6012 <div style="display:flex;align-items:center;justify-content:space-between">
6013 <span style="font-weight:700">${r.platform}</span> 6014 <span class="badge ${r.success?'badge-green':'badge-red'}">${r.success?'â Posted':'â Failed'}</span> 6015 </div> 6016 ${r.url?`<a href="${r.url}" target="_blank" style="color:var(--teal);font-size:.72rem">${r.url}</a>`:''} 6017 ${r.error?`<div style="font-size:.72rem;color:var(--red);margin-top:4px">${r.error}</div>`:''} 6018 </div>`).join(''); 6019 toast((d.results||[]).every(r=>r.success)?'Posted!':'Some posts failed',(d.results||[]).every(r=>r.success)?'ok':'err'); 6020 }catch(e){toast('Connection error','err');} 6021 finally{btn.innerHTML='ð¤ Post Now';btn.disabled=false;} 6022} 6023 6024// ââ EARNINGS WEEKLY CALENDAR ââââââââââââââââââ 6025async function loadEarningsCalendar(){ 6026 const el=$('news-list'); 6027 el.innerHTML='<div class="card"><p>Loading earnings calendarâ¦</p></div>'; 6028 try{ 6029 const res=await api('GET','/api/news/earnings-week'); 6030 const d=await res.json(); 6031 if(!res.ok){el.innerHTML=`<div class="card"><p style="color:var(--red)">${d.detail||'Failed'}</p></div>`;return;} 6032 renderEarningsCalendar(d); 6033 }catch(e){el.innerHTML='<div class="card"><p style="color:var(--red)">Connection error</p></div>';} 6034} 6035 6036function renderEarningsCalendar(d){ 6037 const el=$('news-list'); 6038 const today=d.today; 6039 const dayMap={}; 6040 const monday=new Date(d.week_start+'T00:00:00'); 6041 const days=[]; 6042 for(let i=0;i<5;i++){ 6043 const dt=new Date(monday); 6044 dt.setDate(monday.getDate()+i); 6045 const iso=dt.toISOString().split('T')[0]; 6046 const label=dt.toLocaleDateString('en-US',{weekday:'short',month:'short',day:'numeric'}); 6047 days.push({iso,label}); 6048 dayMap[iso]={bmo:[],amc:[],dmh:[]}; 6049 } 6050 for(const e of d.entries){ 6051 const bucket=dayMap[e.date]; 6052 if(!bucket)continue; 6053 const h=e.hour||'dmh'; 6054 (bucket[h]||bucket.dmh).push(e); 6055 } 6056 let html=`<div style="overflow-x:auto"><div style="display:grid;grid-template-columns:repeat(5,minmax(180px,1fr));gap:10px;min-width:900px">`; 6057 for(const day of days){ 6058 const isToday=day.iso===today; 6059 const bucket=dayMap[day.iso]; 6060 const totalCount=bucket.bmo.length+bucket.amc.length+bucket.dmh.length; 6061 const summaryStyle=`padding:9px 12px;background:var(--bg3);font-size:.88rem;font-weight:700;color:${isToday?'var(--teal)':'var(--text)'};cursor:pointer;list-style:none;display:flex;justify-content:space-between;align-items:center`; 6062 html+=`<details open style="background:var(--bg2);border-radius:10px;overflow:hidden;border:1px solid ${isToday?'var(--teal)':'var(--border)'};min-width:0"> 6063 <summary style="${summaryStyle}"> 6064 <span>${day.label}${isToday?' <span style="font-size:.6rem;opacity:.7">TODAY</span>':''}</span> 6065 <span style="font-size:.6rem;opacity:.45;font-weight:400">${totalCount||'â'}</span> 6066 </summary>`; 6067 if(bucket.bmo.length){ 6068 html+=`<details open style="border-top:1px solid var(--border)"> 6069 <summary style="padding:6px 10px;font-size:.68rem;color:var(--text2);cursor:pointer;list-style:none;display:flex;justify-content:space-between;align-items:center"> 6070 <span>âï¸ BEFORE OPEN</span><span style="opacity:.5">${bucket.bmo.length}</span> 6071 </summary> 6072 <div style="padding:4px 8px 8px;display:flex;flex-direction:column;gap:3px">${bucket.bmo.map(earningsPill).join('')}</div> 6073 </details>`; 6074 } 6075 if(bucket.amc.length){ 6076 html+=`<details open style="border-top:1px solid var(--border)"> 6077 <summary style="padding:6px 10px;font-size:.68rem;color:var(--text2);cursor:pointer;list-style:none;display:flex;justify-content:space-between;align-items:center"> 6078 <span>ð AFTER CLOSE</span><span style="opacity:.5">${bucket.amc.length}</span> 6079 </summary> 6080 <div style="padding:4px 8px 8px;display:flex;flex-direction:column;gap:3px">${bucket.amc.map(earningsPill).join('')}</div> 6081 </details>`; 6082 } 6083 if(!bucket.bmo.length&&!bucket.amc.length){ 6084 if(bucket.dmh.length){ 6085 html+=`<details open style="border-top:1px solid var(--border)"> 6086 <summary style="padding:6px 10px;font-size:.68rem;color:var(--text2);cursor:pointer;list-style:none;display:flex;justify-content:space-between;align-items:center">
6087 <span>ð DURING MARKET</span><span style="opacity:.5">${bucket.dmh.length}</span> 6088 </summary> 6089 <div style="padding:4px 8px 8px;display:flex;flex-direction:column;gap:3px">${bucket.dmh.map(earningsPill).join('')}</div> 6090 </details>`; 6091 }else{ 6092 html+=`<div style="padding:12px 8px;font-size:.65rem;color:var(--muted);text-align:center">No earnings</div>`; 6093 } 6094 }else if(bucket.dmh.length){ 6095 html+=`<details open style="border-top:1px solid var(--border)"> 6096 <summary style="padding:6px 10px;font-size:.68rem;color:var(--text2);cursor:pointer;list-style:none;display:flex;justify-content:space-between;align-items:center"> 6097 <span>ð DURING</span><span style="opacity:.5">${bucket.dmh.length}</span> 6098 </summary> 6099 <div style="padding:4px 8px 8px;display:flex;flex-direction:column;gap:3px">${bucket.dmh.map(earningsPill).join('')}</div> 6100 </details>`; 6101 } 6102 html+=`</details>`; 6103 } 6104 html+=`</div></div>`; 6105 el.innerHTML=html; 6106} 6107 6108function earningsPill(e){ 6109 const hasActual=e.eps_actual!=null; 6110 const beat=hasActual&&e.surprise_pct!=null&&e.surprise_pct>=0; 6111 const sign=(e.surprise_pct||0)>0?'+':''; 6112 const color=hasActual?(beat?'var(--green)':'var(--red)'):'var(--muted)'; 6113 const badge=hasActual 6114 ?`<span style="font-size:.6rem;padding:1px 6px;border-radius:3px;background:${beat?'rgba(0,200,80,.15)':'rgba(255,60,60,.15)'};color:${beat?'var(--green)':'var(--red)'};font-weight:700">${beat?'BEAT':'MISS'}${e.surprise_pct!=null?' '+sign+e.surprise_pct.toFixed(1)+'%':''}</span>` 6115 :''; 6116 const epsStr=hasActual 6117 ?`EPS <span style="color:${color};font-weight:700">$${e.eps_actual.toFixed(2)}</span>` 6118 :(e.eps_estimate!=null?`EPS est <b>$${e.eps_estimate.toFixed(2)}</b>`:''); 6119 const fmtRev=v=>v==null?null:Math.abs(v)>=1e9?'$'+(v/1e9).toFixed(1)+'B':'$'+(v/1e6).toFixed(0)+'M'; 6120 const revVal=hasActual?fmtRev(e.rev_actual):fmtRev(e.rev_estimate); 6121 const revStr=revVal?` · Rev <span style="color:${hasActual?color:'inherit'}">${revVal}${!hasActual?' est':''}</span>`:''; 6122 const safeE=JSON.stringify(e).replace(/"/g,'"'); 6123 return `<div onclick="openEarningsDetail(JSON.parse(this.dataset.e))" data-e="${safeE}" 6124 style="padding:6px 8px;border-radius:6px;cursor:pointer;background:var(--bg3); 6125 transition:background .15s;border:1px solid var(--border)" 6126 onmouseover="this.style.borderColor='var(--teal)'" 6127 onmouseout="this.style.borderColor='var(--border)'"> 6128 <div style="display:flex;align-items:center;justify-content:space-between;gap:4px"> 6129 <span style="font-size:.78rem;font-weight:700">${e.ticker}</span>${badge} 6130 </div> 6131 ${epsStr||revStr?`<div style="font-size:.65rem;color:var(--text2);margin-top:3px">${epsStr}${revStr}</div>`:''} 6132 </div>`; 6133} 6134 6135function openEarningsDetail(e){ 6136 const hasActual=e.eps_actual!=null; 6137 const beat=hasActual&&e.surprise_pct!=null&&e.surprise_pct>=0; 6138 const sign=(e.surprise_pct||0)>0?'+':''; 6139 const fmtRev=v=>v==null?'â':Math.abs(v)>=1e9?'$'+(v/1e9).toFixed(2)+'B':'$'+(v/1e6).toFixed(0)+'M'; 6140 const period=e.quarter?`Q${e.quarter} ${e.year||''}`:''; 6141 const hourLabel=e.hour==='bmo'?'âï¸ Before Open':e.hour==='amc'?'ð After Close':'ð During Market'; 6142 $('earnings-detail-title').textContent=e.ticker; 6143 $('earnings-detail-body').innerHTML=` 6144 <div style="display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap"> 6145 ${period?`<span style="font-size:.7rem;background:var(--surface);padding:2px 8px;border-radius:12px;border:1px solid var(--border)">${period}</span>`:''} 6146 <span style="font-size:.7rem;color:var(--muted)">${e.date} · ${hourLabel}</span> 6147 ${hasActual?`<span style="font-size:.7rem;padding:2px 8px;border-radius:12px;font-weight:700;background:${beat?'rgba(0,200,80,.15)':'rgba(255,60,60,.15)'};color:${beat?'var(--green)':'var(--red)'}">${beat?'â BEAT':'â MISS'}${e.surprise_pct!=null?' '+sign+e.surprise_pct.toFixed(2)+'%':''}</span>`:''} 6148 </div> 6149 <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px"> 6150 <div style="background:var(--bg3);border-radius:8px;padding:12px;border:1px solid var(--border)"> 6151 <div style="font-size:.65rem;color:var(--text2);margin-bottom:8px;font-weight:600">EPS</div> 6152 <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px"> 6153 <div><div style="font-size:.62rem;color:var(--text2)">ESTIMATE</div> 6154 <div style="font-size:1rem;font-weight:700">${e.eps_estimate!=null?'$'+e.eps_estimate.toFixed(2):'â'}</div></div> 6155 <div><div style="font-size:.62rem;color:var(--text2)">ACTUAL</div> 6156 <div style="font-size:1rem;font-weight:700;color:${hasActual?(beat?'var(--green)':'var(--red)'):'var(--text)'}">${hasActual?'$'+e.eps_actual.toFixed(2):'â³'}</div></div> 6157 </div> 6158 </div> 6159 <div style="background:var(--bg3);border-radius:8px;padding:12px;border:1px solid var(--border)"> 6160 <div style="font-size:.65rem;color:var(--text2);margin-bottom:8px;font-weight:600">REVENUE</div> 6161 <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px"> 6162 <div><div style="font-size:.62rem;color:var(--text2)">ESTIMATE</div> 6163 <div style="font-size:1rem;font-weight:700">${fmtRev(e.rev_estimate)}</div></div> 6164 <div><div style="font-size:.62rem;color:var(--text2)">ACTUAL</div> 6165 <div style="font-size:1rem;font-weight:700;color:${e.rev_actual!=null?(beat?'var(--green)':'var(--red)'):'var(--text)'}">${e.rev_actual!=null?fmtRev(e.rev_actual):'â³'}</div></div> 6166 </div> 6167 </div> 6168 </div> 6169 ${!hasActual?`<div style="margin-top:12px;text-align:center;font-size:.7rem;color:var(--muted)">â³ Awaiting results â actuals refresh hourly 4â9pm EST</div>`:''} 6170 <div id="eps-history-chart" style="margin-top:14px"> 6171 <div style="font-size:.6rem;color:var(--muted)">Loading EPS historyâ¦</div> 6172 </div>`; 6173 $('earnings-detail-modal').style.display='flex'; 6174 loadEpsHistory(e.ticker); 6175} 6176 6177async function loadEpsHistory(ticker){ 6178 const el=$('eps-history-chart'); 6179 if(!el)return; 6180 try{ 6181 const res=await api('GET',`/api/news/earnings-history?ticker=${encodeURIComponent(ticker)}`); 6182 const d=await res.json(); 6183 if(!res.ok||!d.quarters||!d.quarters.length){el.innerHTML='';return;} 6184 el.innerHTML=renderEpsHistoryChart(d.quarters); 6185 }catch(_){el.innerHTML='';} 6186} 6187 6188function renderEpsHistoryChart(quarters){ 6189 const qs=[...quarters].reverse(); 6190 const vals=[...qs.flatMap(q=>[q.actual,q.estimate]).filter(v=>v!=null)];
6191 if(!vals.length)return''; 6192 const minV=Math.min(...vals); 6193 const maxV=Math.max(...vals); 6194 const range=maxV-minV||1; 6195 const W=440,H=110,padL=36,padR=8,padT=10,padB=32; 6196 const colW=(W-padL-padR)/qs.length; 6197 const barW=Math.min(18,colW*0.35); 6198 const yScale=v=>padT+(H-padT-padB)*(1-(v-minV)/range); 6199 let bars='',axes='',labels=''; 6200 for(let i=0;i<=4;i++){ 6201 const v=minV+range*i/4; 6202 const y=yScale(v); 6203 axes+=`<line x1="${padL-4}" y1="${y}" x2="${W-padR}" y2="${y}" stroke="rgba(255,255,255,.06)" stroke-width="1"/>`; 6204 axes+=`<text x="${padL-6}" y="${y+4}" text-anchor="end" font-size="8" fill="rgba(255,255,255,.35)">${v<0?'-':''}$${Math.abs(v)<10?Math.abs(v).toFixed(1):Math.round(Math.abs(v))}</text>`; 6205 } 6206 if(minV<0&&maxV>0){ 6207 const yz=yScale(0); 6208 axes+=`<line x1="${padL}" y1="${yz}" x2="${W-padR}" y2="${yz}" stroke="rgba(255,255,255,.2)" stroke-width="1" stroke-dasharray="3,3"/>`; 6209 } 6210 qs.forEach((q,i)=>{ 6211 const cx=padL+i*colW+colW/2; 6212 const beat=q.actual!=null&&q.surprise_pct!=null&&q.surprise_pct>=0; 6213 const actColor=q.actual!=null?(beat?'#22c55e':'#ef4444'):'#555'; 6214 if(q.estimate!=null){ 6215 const ye=yScale(q.estimate); 6216 bars+=`<circle cx="${cx-barW*0.6}" cy="${ye}" r="${barW*0.55}" fill="#60a5fa" opacity=".75"/>`; 6217 } 6218 if(q.actual!=null){ 6219 const ya=yScale(q.actual); 6220 bars+=`<circle cx="${cx+barW*0.6}" cy="${ya}" r="${barW*0.55}" fill="${actColor}" opacity=".9"/>`; 6221 } 6222 const lbl=q.quarter&&q.year?`Q${q.quarter}'${String(q.year).slice(2)}`:(q.period||'').slice(0,7); 6223 labels+=`<text x="${cx}" y="${H-padB+14}" text-anchor="middle" font-size="8.5" fill="rgba(255,255,255,.45)">${lbl}</text>`; 6224 if(q.surprise_pct!=null){ 6225 const sc=q.surprise_pct>=0?'#22c55e':'#ef4444'; 6226 labels+=`<text x="${cx}" y="${H-padB+25}" text-anchor="middle" font-size="7.5" fill="${sc}">${q.surprise_pct>=0?'+':''}${q.surprise_pct.toFixed(1)}%</text>`; 6227 } 6228 }); 6229 return `<div style="border-top:1px solid var(--border);padding-top:12px"> 6230 <div style="font-size:.65rem;font-weight:600;color:var(--muted);margin-bottom:5px">Historical EPS Surprises</div> 6231 <div style="display:flex;gap:12px;margin-bottom:6px;font-size:.58rem;color:var(--muted)"> 6232 <span>ðµ Estimate</span><span style="color:#22c55e">ð¢ Actual (Beat)</span><span style="color:#ef4444">ð´ Actual (Miss)</span> 6233 </div> 6234 <svg width="100%" viewBox="0 0 ${W} ${H}" style="overflow:visible">${axes}${bars}${labels}</svg> 6235 </div>`; 6236} 6237// ââââââââââââââââââââââââââââââââââââââââââââââ 6238 6239// ââ CALENDAR ââââââââââââââââââââââââââââââââââ 6240function renderCalendar(){ 6241 window.location.href='/calendar.html'; 6242} 6243 6244// ââ LOCAL FILES âââââââââââââââââââââââââââââââ 6245async function uploadLocalPageFiles(input){ 6246 const status = $('files-upload-status'); 6247 const goBtn = $('files-go-gallery'); 6248 const files = [...input.files]; 6249 if(!files.length) return; 6250 status.textContent = `Uploading ${files.length} file(s)â¦`; 6251 goBtn.style.display = 'none'; 6252 let ok = 0; 6253 for(const f of files){ 6254 const fd = new FormData(); fd.append('file', f); 6255 try{ 6256 const res = await fetch('/api/gallery/upload', { 6257 method:'POST', headers:{Authorization:'Bearer '+localStorage.getItem('fz_token')}, body: fd 6258 }); 6259 if(res.ok) ok++; 6260 }catch(e){} 6261 } 6262 status.textContent = `${ok}/${files.length} uploaded successfully.`; 6263 input.value = ''; 6264 if(ok > 0) goBtn.style.display = 'block'; 6265} 6266 6267// ââ BRAND âââââââââââââââââââââââââââââââââââââ 6268// ââ BRAND KIT âââââââââââââââââââââââââââââââââ 6269function updateBrandPreview(){ 6270 const bg=$('b-bg')?.value||'#0D1117'; 6271 const acc=$('b-acc')?.value||'#00BCD4'; 6272 const txt=$('b-txt')?.value||'#E8F0F7'; 6273 const name=($('b-name')?.value||'FACTOZ').toUpperCase(); 6274 const tagline=($('b-tagline')?.value||'MARKET INTELLIGENCE').toUpperCase(); 6275 const wm=$('b-wm')?.value||'FACTOZ'; 6276 const card=$('brand-preview-card');if(!card)return; 6277 card.style.background=bg;card.style.borderColor=acc; 6278 $('bp-accent-top').style.background=acc; 6279 $('bp-name').style.color=txt;$('bp-name').textContent=name; 6280 $('bp-tagline').style.color=acc;$('bp-tagline').textContent=tagline; 6281 $('bp-signal').style.color=txt; 6282 $('bp-sub').style.color=acc; 6283 $('bp-divider').style.background=acc; 6284 $('bp-wm').textContent=wm; 6285 $('bp-dot').style.background=acc; 6286 $('bp-corner').style.backgroundImage=`linear-gradient(135deg,transparent 50%,${acc} 50%)`; 6287 $('chip-bg').style.background=bg; 6288 $('chip-acc').style.background=acc; 6289 $('chip-txt').style.background=txt; 6290} 6291 6292async function loadBrand(){ 6293 try{ 6294 const res=await api('GET','/api/brand');if(!res.ok)return;const d=await res.json(); 6295 $('b-name').value=d.name||'';$('b-tagline').value=d.tagline||''; 6296 $('b-bg').value=d.bg_color||'#0D1117';$('b-bg-pick').value=d.bg_color||'#0D1117'; 6297 $('b-acc').value=d.accent_color||'#00BCD4';$('b-acc-pick').value=d.accent_color||'#00BCD4'; 6298 $('b-txt').value=d.text_color||'#E8F0F7';
6298if($('b-txt-pick'))$('b-txt-pick').value=d.text_color||'#E8F0F7'; 6299 $('b-wm').value=d.watermark_text||''; 6300 const op=d.watermark_opacity??28; 6301 $('b-wm-op').value=op;$('b-wm-op-label').textContent=Math.round(op/2.55)+'%'; 6302 $('b-show-wm').checked=d.show_watermark!==false;$('b-show-tag').checked=d.show_corner_tag!==false; 6303 $('b-hashtags').value=d.default_hashtags||'';$('b-template').value=d.default_template||'factoz'; 6304 $('b-export').value=d.default_export_target||'instagram_square'; 6305 // Logo preview (Bug 3 fix) 6306 if(d.logo_url){ 6307 $('b-logo-preview').src=d.logo_url;$('b-logo-preview').style.display='block'; 6308 $('b-logo-hint').textContent='Click to replace logo'; 6309 $('bp-logo').src=d.logo_url;$('bp-logo').style.display='block'; 6310 } 6311 // Auto-select carousel template (Bug 1 fix) 6312 if(d.default_template){ 6313 const tmplCard=document.querySelector(`.tmpl-card[data-tmpl="${d.default_template}"]`); 6314 if(tmplCard)selectTemplate(d.default_template,tmplCard); 6315 } 6316 updateBrandPreview(); 6317 }catch(e){} 6318} 6319 6320async function saveBrand(){ 6321 const btn=$('brand-save-btn');btn.innerHTML='<span class="spin"></span> Saving...';btn.disabled=true; 6322 const body={
6323 name:$('b-name').value,tagline:$('b-tagline').value, 6324 bg_color:$('b-bg').value,accent_color:$('b-acc').value, 6325 border_color:$('b-acc').value,text_color:$('b-txt')?.value||'#E8F0F7', 6326 watermark_text:$('b-wm').value,watermark_opacity:parseInt($('b-wm-op').value), 6327 show_watermark:$('b-show-wm').checked,show_corner_tag:$('b-show-tag').checked, 6328 default_hashtags:$('b-hashtags').value,default_template:$('b-template').value, 6329 default_export_target:$('b-export').value, 6330 }; 6331 const res=await api('PUT','/api/brand',body); 6332 btn.innerHTML='ð¾ Save Brand Kit';btn.disabled=false; 6333 $('brand-save-result').innerHTML=res.ok 6334 ?'<span class="badge badge-green">â Brand kit saved â applied to next carousel</span>' 6335 :'<span class="badge badge-red">â Save failed</span>'; 6336 if(res.ok)toast('Brand kit saved!','ok');else toast('Save failed','err'); 6337} 6338 6339async function uploadLogo(){ 6340 const file=$('b-logo').files[0];if(!file)return; 6341 $('b-logo-hint').textContent='Uploading...'; 6342 const fd=new FormData();fd.append('logo',file); 6343 const res=await fetch(API+'/api/brand/logo',{method:'POST',headers:{Authorization:`Bearer ${TOKEN}`},body:fd}); 6344 if(res.ok){ 6345 const d=await res.json(); 6346 $('b-logo-preview').src=d.logo_url;$('b-logo-preview').style.display='block'; 6347 $('b-logo-hint').textContent='Click to replace logo'; 6348 $('bp-logo').src=d.logo_url;$('bp-logo').style.display='block'; 6349 toast('Logo uploaded!','ok'); 6350 }else{ 6351 $('b-logo-hint').textContent='Upload failed â try again'; 6352 toast('Upload failed','err'); 6353 } 6354} 6355 6356// ââ API KEYS ââââââââââââââââââââââââââââââââââ 6357async function saveKeys(){ 6358 const body={}; 6359 [['k-x-api','x_api_key'],['k-x-secret','x_api_secret'],['k-x-token','x_access_token'],['k-x-tsecret','x_access_token_secret']].forEach(([id,key])=>{const v=$(id);if(v&&v.value)body[key]=v.value;}); 6360 const res=await api('PUT','/api/auth/keys',body); 6361 $('keys-result').innerHTML=res.ok?'<span class="badge badge-green">â Keys saved</span>':'<span class="badge badge-red">â Save failed</span>'; 6362 if(res.ok)toast('API keys saved!','ok'); 6363} 6364 6365// ââ CONNECTED ACCOUNTS (SocialBu) âââââââââââââââââââââââââââââââââââââââââ 6366const _PROVIDERS = [ 6367 { key: 'instagram', label: 'Instagram', icon: 'ð¸' }, 6368 { key: 'reddit', label: 'Reddit', icon: 'ð¤' }, 6369 { key: 'tiktok', label: 'TikTok', icon: 'ðµ' }, 6370 { key: 'linkedin', label: 'LinkedIn', icon: 'ð¼' }, 6371]; 6372 6373let _connectedAccounts = []; 6374let _connectPollInterval = null; 6375 6376async function loadConnectedAccounts(){ 6377 const grid = $('connected-accounts-grid'); 6378 if(!grid) return; 6379 try{ 6380 const res = await api('GET','/api/socialbu/accounts'); 6381 if(!res.ok){ grid.innerHTML='<p style="font-size:.75rem;color:var(--text2)">Could not load accounts.</p>'; return; } 6382 const data = await res.json(); 6383 _connectedAccounts = data.accounts || []; 6384 const limit = data.limit ?? 1; 6385 const used = data.slots_used ?? _connectedAccounts.length; 6386 const isAdmin = USER.tier === 'admin' || USER.tier === 'super_admin'; 6387 const connMap = {}; 6388 _connectedAccounts.forEach(a => connMap[a.provider] = a); 6389 6390 // Slot counter 6391 const badge = `<div style="font-size:.72rem;color:var(--text2);margin-bottom:10px"> 6392 ${used} of ${isAdmin?'â':limit} account${limit!==1?'s':''} connected 6393 </div>`; 6394 6395 const rows = _PROVIDERS.map((p) => { 6396 const acct = connMap[p.key]; 6397 6398 if(acct){ 6399 const disconnectBtn = isAdmin 6400 ? `<button class="btn btn-ghost" style="font-size:.65rem;padding:3px 9px;color:var(--red)" 6401 onclick="disconnectSocialAccount('${acct.id}','${p.label}')">Disconnect</button>` 6402 : ''; 6403 return `<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg3)"> 6404 <div style="display:flex;align-items:center;gap:10px">
6405 <span style="font-size:1.2rem">${p.icon}</span> 6406 <div> 6407 <div style="font-size:.78rem;font-weight:600">${p.label}</div> 6408 <div style="font-size:.65rem;color:#4caf50">â ${acct.account_name||'Connected'}</div> 6409 </div> 6410 </div> 6411 ${disconnectBtn} 6412 </div>`; 6413 } 6414 6415 return `<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg3)"> 6416 <div style="display:flex;align-items:center;gap:10px"> 6417 <span style="font-size:1.2rem">${p.icon}</span> 6418 <div> 6419 <div style="font-size:.78rem;font-weight:600">${p.label}</div> 6420 <div style="font-size:.65rem;color:var(--text2)">Not connected</div> 6421 </div> 6422 </div> 6423 <button class="btn btn-primary" style="font-size:.68rem;padding:4px 12px" 6424 onclick="connectSocialAccount('${p.key}')">Connect</button> 6425 </div>`; 6426 }).join(''); 6427 6428 // X always posts via developer API key â always show the hint 6429 const xHint = `<div style="margin-top:10px;padding:10px 12px;background:var(--bg3); 6430 border-radius:6px;border:1px solid var(--border);font-size:.72rem;color:var(--text2)"> 6431 ð¦ <strong style="color:var(--text)">X (Twitter)</strong> posts use your developer API key â 6432 add it in <a href="#" onclick="go('keys')" style="color:var(--teal)">API Keys</a>. 6433 </div>`; 6434 6435 grid.innerHTML = badge + `<div style="display:flex;flex-direction:column;gap:8px">${rows}</div>` + xHint; 6436 }catch(e){ 6437 grid.innerHTML='<p style="font-size:.75rem;color:var(--text2)">Error loading accounts.</p>'; 6438 } 6439} 6440 6441async function connectSocialAccount(provider){ 6442 try{ 6443 const res = await api('POST','/api/socialbu/connect',{provider}); 6444 if(!res.ok){ const e=await res.json(); toast(e.detail||'Connection failed','err'); return; } 6445 const data = await res.json(); 6446 const popup = window.open(data.connect_url,'socialbu_connect','width=640,height=720,left=200,top=100'); 6447 toast(`Connecting ${provider}... complete the OAuth in the popup`,'ok'); 6448 // Listen for popup postMessage or poll every 3s 6449 const handler = (ev) => { if(ev.data==='socialbu_connected'){ clearInterval(_connectPollInterval); loadConnectedAccounts(); toast(`${provider} connected!`,'ok'); window.removeEventListener('message',handler); } }; 6450 window.addEventListener('message', handler); 6451 _connectPollInterval = setInterval(async()=>{ 6452 if(!popup || popup.closed){ clearInterval(_connectPollInterval); window.removeEventListener('message',handler); await loadConnectedAccounts(); } 6453 }, 3000); 6454 }catch(e){ toast('Connection error','err'); } 6455} 6456 6457async function disconnectSocialAccount(id, label){ 6458 if(!confirm(`Disconnect ${label}?`)) return; 6459 const res = await api('DELETE',`/api/socialbu/accounts/${id}`); 6460 if(res.ok){ toast(`${label} disconnected`,'ok'); loadConnectedAccounts(); } 6461 else{ toast('Disconnect failed','err'); } 6462} 6463 6464// ââ HISTORY âââââââââââââââââââââââââââââââââââ 6465async function loadHistory(platform='all'){ 6466 try{ 6467 const path=platform==='all'?'/api/post/history?limit=100':`/api/post/history?limit=100&platform=${platform}`; 6468 const res=await api('GET',path);if(!res.ok)return; 6469 const d=await res.json();const h=d.history||[]; 6470 const xOk=h.filter(p=>p.platform==='x'&&p.status==='success').length; 6471 const igOk=h.filter(p=>p.platform==='instagram'&&p.status==='success').length; 6472 $('h-x').textContent=xOk;$('h-ig').textContent=igOk;$('h-tot').textContent=xOk+igOk; 6473 if(!h.length){$('history-list').innerHTML='<div class="card"><p>No posts yet.</p></div>';return;} 6474 $('history-list').innerHTML=h.map(p=>` 6475 <div class="hist-row"> 6476 <span class="badge ${p.status==='success'?'badge-green':'badge-red'}">${p.status==='success'?'â':'â'}</span> 6477 <span class="hplatform">${p.platform==='x'?'ð¦ X':'ð¸ Instagram'}</span> 6478 <span class="hcaption">${p.caption||'â'}</span> 6479 ${p.post_url?`<a href="${p.post_url}" target="_blank" style="color:var(--teal);font-size:.68rem">View â</a>`:''}
6480 <span class="htime">${p.posted_at?new Date(p.posted_at).toLocaleDateString('en-US',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):'â'}</span> 6481 </div>`).join(''); 6482 }catch(e){} 6483} 6484 6485// ââ ADMIN PANEL âââââââââââââââââââââââââââââââ 6486async function loadAdminPanel(){ 6487 try{ 6488 const res=await api('GET','/api/admin/clients'); 6489 if(!res.ok)return; 6490 const d=await res.json(); 6491 const clients=d.clients||[]; 6492 $('adm-total').textContent=d.total||0; 6493 $('adm-active').textContent=clients.filter(c=>c.is_active).length; 6494 // Load platform usage 6495 try{ 6496 const ur=await api('GET','/api/admin/usage'); 6497 if(ur.ok){const u=await ur.json();$('adm-scans').textContent=u.enterprise_clients?.total_scans||0;$('adm-posts').textContent=u.enterprise_clients?.total_posts||0;} 6498 }catch(e){} 6499 if(!clients.length){$('client-list').innerHTML='<div class="card"><p>No enterprise clients yet. Click + New Client to create one.</p></div>';return;} 6500 $('client-list').innerHTML=clients.map(c=>` 6501 <div class="client-card"> 6502 <div class="client-card-header"> 6503 <div> 6504 <div class="client-name">${c.name}</div> 6505 <div class="client-meta">${c.email} · ${c.tier}</div> 6506 </div> 6507 <div style="display:flex;align-items:center;gap:8px"> 6508 <span class="badge ${c.is_active?'badge-green':'badge-red'}">${c.is_active?'Active':'Inactive'}</span> 6509 <button class="btn btn-ghost" style="padding:4px 10px;font-size:.7rem" onclick="openEditClient('${c.id}')">âï¸ Edit</button> 6510 <button class="btn btn-red" style="padding:4px 10px;font-size:.7rem" onclick="deactivateClient('${c.id}','${c.name}')">â</button> 6511 </div> 6512 </div> 6513 <div class="client-stats"> 6514 <div class="client-stat">Scans: <span>${c.this_month?.scans||0}</span></div> 6515 <div class="client-stat">Posts: <span>${c.this_month?.posts||0}</span></div> 6516 <div class="client-stat">Collages: <span>${c.this_month?.collages||0}</span></div> 6517 <div class="client-stat" style="margin-left:auto">Key: <span>${c.api_key_preview||'â'}</span></div> 6518 </div> 6519 <div style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap"> 6520 ${(c.config?.watchlist||[]).slice(0,8).map(t=>`<span class="badge badge-muted">${t}</span>`).join('')} 6521 ${(c.config?.watchlist||[]).length>8?`<span class="badge badge-muted">+${c.config.watchlist.length-8} more</span>`:''} 6522 </div> 6523 </div>`).join(''); 6524 }catch(e){$('client-list').innerHTML='<div class="card"><p style="color:var(--red)">Failed to load clients.</p></div>';} 6525} 6526 6527function openNewClient(){ 6528 editingClientId=null; 6529 $('modal-title-text').textContent='New Enterprise Client'; 6530 $('modal-save-btn').textContent='ð¾ Create Client'; 6531 $('modal-key-result').style.display='none'; 6532 // Reset fields 6533 ['m-name','m-email','m-chartimg','m-x-api','m-x-secret','m-x-token','m-x-tsecret','m-make-ig','m-make-tt','m-watchlist','m-hashtags'].forEach(id=>{$(id).value='';}); 6534 $('m-scan-limit').value=500;$('m-post-limit').value=200; 6535 $('m-tier').value='enterprise';$('m-template').value='factoz';$('m-tone').value='professional'; 6536 $('m-plat-x').checked=true;$('m-plat-ig').checked=true;$('m-plat-tt').checked=false; 6537 $('m-autopost').checked=false; 6538 modalTab('info'); 6539 $('modal-overlay').classList.add('show'); 6540} 6541 6542async function openEditClient(id){ 6543 editingClientId=id; 6544 $('modal-title-text').textContent='Edit Enterprise Client'; 6545 $('modal-save-btn').textContent='ð¾ Update Client'; 6546 $('modal-key-result').style.display='none'; 6547 try{ 6548 const res=await api('GET',`/api/admin/clients/${id}`); 6549 if(!res.ok)return; 6550 const d=await res.json(); 6551 $('m-name').value=d.name||'';$('m-email').value=d.email||''; 6552 $('m-tier').value=d.tier||'enterprise'; 6553 const config=d.config||{}; 6554 $('m-watchlist').value=(config.watchlist||[]).join(', '); 6555 $('m-template').value=config.carousel_template||'factoz'; 6556 $('m-tone').value=config.tone||'professional'; 6557 $('m-hashtags').value=config.custom_hashtags||''; 6558 $('m-autopost').checked=config.auto_post||false; 6559 $('m-plat-x').checked=(config.allowed_platforms||['x']).includes('x'); 6560 $('m-plat-ig').checked=(config.allowed_platforms||['instagram']).includes('instagram'); 6561 $('m-plat-tt').checked=(config.allowed_platforms||[]).includes('tiktok'); 6562 if(d.usage_history&&d.usage_history.length){ 6563 const u=d.usage_history[0]; 6564 $('m-scan-limit').value=u.limits?.scans||500; 6565 $('m-post-limit').value=u.limits?.posts||200; 6566 } 6567 }catch(e){} 6568 modalTab('info'); 6569 $('modal-overlay').classList.add('show'); 6570} 6571 6572function closeModal(){$('modal-overlay').classList.remove('show');editingClientId=null;} 6573 6574function modalTab(tab){ 6575 document.querySelectorAll('.modal-tab').forEach((t,i)=>t.classList.toggle('active',['info','keys','config'][i]===tab)); 6576 $('modal-info').style.display=tab==='info'?'':'none'; 6577 $('modal-keys').style.display=tab==='keys'?'':'none'; 6578 $('modal-config').style.display=tab==='config'?'':'none'; 6579} 6580 6581async function saveClient(){ 6582 const btn=$('modal-save-btn');btn.innerHTML='<span class="spin"></span>';btn.disabled=true; 6583 const platforms=[$('m-plat-x').checked&&'x',$('m-plat-ig').checked&&'instagram',$('m-plat-tt').checked&&'tiktok'].filter(
6583Boolean); 6584 const watchlist=$('m-watchlist').value.split(',').map(t=>t.trim().toUpperCase()).filter(Boolean); 6585 const config={watchlist,allowed_platforms:platforms,carousel_template:$('m-template').value,tone:$('m-tone').value,custom_hashtags:$('m-hashtags').value,auto_post:$('m-autopost').checked}; 6586 try{ 6587 let res,d; 6588 if(editingClientId){ 6589 // Update existing client 6590 const body={name:$('m-name').value,email:$('m-email').value,tier:$('m-tier').value,config,monthly_scan_limit:parseInt($('m-scan-limit').value),monthly_post_limit:parseInt($('m-post-limit').value)}; 6591 if($('m-chartimg').value)body.chartimg_api_key=$('m-chartimg').value; 6592 if($('m-x-api').value)body.x_api_key=$('m-x-api').value; 6593 if($('m-x-secret').value)body.x_api_secret=$('m-x-secret').value; 6594 if($('m-x-token').value)body.x_access_token=$('m-x-token').value; 6595 if($('m-x-tsecret').value)body.x_access_token_secret=$('m-x-tsecret').value; 6596 if($('m-make-ig').value)body.make_webhook_instagram=$('m-make-ig').value; 6597 if($('m-make-tt').value)body.make_webhook_tiktok=$('m-make-tt').value; 6598 res=await api('PUT',`/api/admin/clients/${editingClientId}`,body); 6599 d=await res.json(); 6600 if(res.ok){toast('Client updated!','ok');closeModal();loadAdminPanel();} 6601 else toast(d.detail||'Update failed','err'); 6602 }else{ 6603 // Create new client 6604 const body={name:$('m-name').value,email:$('m-email').value,tier:$('m-tier').value,config,monthly_scan_limit:parseInt($('m-scan-limit').value),monthly_post_limit:parseInt($('m-post-limit').value)}; 6605 res=await api('POST','/api/admin/clients',body); 6606 d=await res.json(); 6607 if(res.ok){ 6608 toast('Client created!','ok'); 6609 // Show API key 6610 $('new-api-key').textContent=d.api_key; 6611 $('modal-key-result').style.display='block'; 6612 $('modal-save-btn').style.display='none'; 6613 loadAdminPanel(); 6614 // Now save keys separately 6615 if($('m-chartimg').value||$('m-x-api').value||$('m-make-ig').value){ 6616 const keyBody={chartimg_api_key:$('m-chartimg').value,x_api_key:$('m-x-api').value,x_api_secret:$('m-x-secret').value,x_access_token:$('m-x-token').value,x_access_token_secret:$('m-x-tsecret').value,make_webhook_instagram:$('m-make-ig').value,make_webhook_tiktok:$('m-make-tt').value}; 6617 await api('PUT',`/api/admin/clients/${d.client_id}`,keyBody); 6618 } 6619 }else toast(d.detail||'Create failed','err'); 6620 } 6621 }catch(e){toast('Connection error','err');} 6622 finally{btn.innerHTML=editingClientId?'ð¾ Update Client':'ð¾ Create Client';btn.disabled=false;} 6623} 6624 6625async function deactivateClient(id,name){ 6626 if(!confirm(`Deactivate "${name}"?`))return; 6627 const res=await fetch(API+`/api/admin/clients/${id}`,{method:'DELETE',headers:hdr()}); 6628 if(res.ok){toast(`${name} deactivated`,'ok');loadAdminPanel();} 6629 else toast('Failed','err'); 6630} 6631 6632function copyKey(){ 6633 const key=$('new-api-key').textContent; 6634 navigator.clipboard.writeText(key).then(()=>toast('API key copied!','ok')); 6635} 6636 6637// ââ NEWS FEED ââââââââââââââââââââââââââââââââââ 6638let _newsUnread=0; 6639let _newsItems=[]; 6640let _newsFilter='all'; 6641let _newsLoading=false; 6642 6643const NEWS_TYPE_BADGE={chart:'badge-green',news:'badge-teal',economic:'badge-yellow',earnings:'badge-yellow',company_analysis:'badge-purple',macro:'badge-orange',tradingview:'badge-blue',djt:'badge-red',brief:'badge-teal'}; 6644const NEWS_TYPE_ICON={chart:'ð',news:'ð°',economic:'ð',earnings:'ð°',company_analysis:'ð¢',macro:'ð',tradingview:'ð¡',djt:'ðºð¸',brief:'ð'}; 6645 6646function earningsDataGrid(cap){ 6647 if(!cap) return ''; 6648 const hasActual = cap.eps_actual != null; 6649 const beat = hasActual && cap.surprise_pct != null && cap.surprise_pct >= 0; 6650 const color = hasActual ? (beat ? 'var(--green)' : 'var(--red)') : 'var(--muted)'; 6651 const sign = cap.surprise_pct > 0 ? '+' : ''; 6652 const rev = cap.rev_actual != null ? `$${(cap.rev_actual/1e9).toFixed(2)}B` 6653 : cap.rev_estimate != null ? `$${(cap.rev_estimate/1e9).toFixed(2)}B est` : 'â'; 6654 return `<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;background:var(--bg3);border-radius:7px;padding:10px;border:1px solid var(--border);margin-bottom:8px"> 6655 <div style="text-align:center"><div style="font-size:.55rem;color:var(--muted);letter-spacing:1.5px;margin-bottom:4px">EPS EST</div><div style="font-size:.88rem;font-weight:700">${cap.eps_estimate!=null?'$'+cap.eps_estimate.toFixed(2):'â'}</div></div> 6656 <div style="text-align:center"><div style="font-size:.55rem;color:var(--muted);letter-spacing:1.5px;margin-bottom:4px">EPS ACTUAL</div><div style="font-size:.88rem;font-weight:700;color:${color}">${hasActual?'$'+cap.eps_actual.toFixed(2):'â'}</div></div> 6657 <div style="text-align:center"><div style="font-size:.55rem;color:var(--muted);letter-spacing:1.5px;margin-bottom:4px">SURPRISE</div><div style="font-size:.88rem;font-weight:700;color:${color}">${cap.surprise_pct!=null?sign+cap.surprise_pct.toFixed(2)+'%':'â'}</div></div> 6658 <div style="text-align:center"><div style="font-size:.55rem;color:var(--muted);letter-spacing:1.5px;margin-bottom:4px">REVENUE</div><div style="font-size:.88rem;font-weight:700">${rev}</div></div> 6659 </div>${!hasActual?'<div style="font-size:.65rem;color:var(--muted);margin-bottom:6px">â³ Awaiting resultsâ¦</div>':''}`; 6660} 6661 6662function newsCard(n){ 6663 if(n.content_type==='djt'){
6664 const date=n.created_at?new Date(n.created_at).toLocaleString('en-US',{month:'short',day:'numeric',year:'numeric',hour:'numeric',minute:'2-digit'}):''; 6665 const avatar=n.image_url||''; 6666 return `<div class="news-card" style="align-items:flex-start;gap:12px;padding:14px"> 6667 <div style="flex-shrink:0;width:48px;height:48px;border-radius:50%;overflow:hidden;border:2px solid rgba(255,68,68,.4);background:var(--bg3);display:flex;align-items:center;justify-content:center"> 6668 ${avatar?`<img src="${avatar}" alt="DJT" style="width:100%;height:100%;object-fit:cover" onerror="this.parentElement.innerHTML='ðºð¸'">`:`<span style="font-size:1.4rem">ðºð¸</span>`} 6669 </div> 6670 <div style="flex:1;min-width:0"> 6671 <div style="display:flex;align-items:center;gap:8px;margin-bottom:5px"> 6672 <span class="badge badge-red" style="font-size:.58rem">ðºð¸ DJT</span> 6673 <span style="font-size:.62rem;color:var(--muted);font-family:var(--mono)">@realDonaldTrump</span> 6674 <span class="news-date" style="margin:0">${date}</span> 6675 </div> 6676 <div class="news-title" style="font-size:.85rem;line-height:1.5">${n.title||''}</div> 6677 <div class="news-actions" style="margin-top:8px"> 6678 ${n.source_url?`<a href="${n.source_url}" target="_blank" rel="noopener" class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px;text-decoration:none">ð View on Truth Social</a>`:''} 6679 </div> 6680 </div> 6681 </div>`; 6682 } 6683 if(n.content_type==='tradingview'){ 6684 const date=n.created_at?new Date(n.created_at).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}):''; 6685 const tvTicker=((n.title||'').match(/\$([A-Z]{1,5})/)||[])[1]; 6686 return `<div class="news-card" style="flex-direction:column;gap:0"> 6687 <div class="news-body" style="width:100%"> 6688 <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px"> 6689 <span class="badge badge-blue" style="font-size:.58rem">ð¡ TRADINGVIEW</span> 6690 ${tvTicker?`<span onclick="openTickerModal('${tvTicker}')" style="font-family:var(--mono);font-size:.65rem;font-weight:700;background:rgba(0,200,83,.12);color:var(--green);padding:2px 8px;border-radius:4px;cursor:pointer">${tvTicker} ð</span>`:''} 6691 <span class="news-date" style="margin:0">${date}</span> 6692 </div> 6693 <div class="news-title">${n.title||'Untitled'}</div> 6694 ${n.summary?`<div class="news-sum">${n.summary}</div>`:''} 6695 ${n.image_url?`<img src="${n.image_url}" alt="chart" loading="lazy" onclick="this.classList.toggle('tv-chart-expanded')" style="width:100%;border-radius:8px;margin:10px 0;cursor:zoom-in;border:1px solid var(--border);max-height:220px;object-fit:cover;transition:max-height .3s ease" onerror="this.style.display='none'">`:''} 6696 <div class="news-actions"> 6697 ${n.source_url?`<a href="https://in.tradingview.com/?aff_id=164648" target="_blank" rel="noopener" class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px;text-decoration:none">ð View on TradingView</a>`:''} 6698 </div> 6699 </div> 6700 </div>`; 6701 } 6702 if(n.content_type==='earnings'){ 6703 let cap=null;try{if(n.caption&&n.caption[0]==='{')cap=JSON.parse(n.caption);}catch(e){} 6704 return `<div class="news-card"> 6705 <div class="news-thumb" style="display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:2rem">ð°</div> 6706 <div class="news-body"> 6707 <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px"> 6708 <span class="badge badge-yellow" style="font-size:.58rem">ð° EARNINGS</span> 6709 <span class="news-date" style="margin:0">${n.created_at?new Date(n.created_at).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}):''}</span> 6710 </div> 6711 <div class="news-title">${n.title||'Untitled'}</div> 6712 ${earningsDataGrid(cap)} 6713 <div class="news-actions"> 6714 ${n.image_url?`<a href="${n.image_url}" target="_blank" rel="noopener" class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px;text-decoration:none">ð¼ Image</a>`:''} 6715 </div> 6716 </div> 6717 </div>`; 6718 } 6719 const badge=NEWS_TYPE_BADGE[n.content_type]||'badge-muted'; 6720 const icon=NEWS_TYPE_ICON[n.content_type]||'ð'; 6721 const label=(n.content_type||'news').replace('_',' ').toUpperCase(); 6722 const isAiAnalysis = n.content_type==='company_analysis' && n.image_url; 6723 const isBrief = n.content_type==='brief'; 6724 6725 // Brief cards: pill card with Read button â modal 6726 if(isBrief){ 6727 const dateStr = n.created_at ? new Date(n.created_at).toLocaleDateString('en-US',{weekday:'long',month:'long',day:'numeric',year:'numeric'}) : '';
6728 const preview = (n.summary||'').replace(/\\n/g,' ').replace(/\s+/g,' ').trim().slice(0,180); 6729 const safeId = n.id; 6730 return `<div class="news-card" style="flex-direction:column;gap:0;padding:16px 18px;cursor:default"> 6731 <div style="display:flex;align-items:center;gap:8px;margin-bottom:8px"> 6732 <span class="badge ${badge}" style="font-size:.58rem">${icon} DAILY BRIEF</span> 6733 <span class="news-date" style="margin:0">${dateStr}</span> 6734 </div> 6735 <div class="news-title" style="margin-bottom:6px">${n.title||'Untitled'}</div> 6736 ${preview?`<div style="font-size:.72rem;color:var(--text2);line-height:1.5;margin-bottom:12px">${preview}â¦</div>`:''} 6737 <button class="btn btn-ghost" style="font-size:.72rem;padding:6px 14px;align-self:flex-start" onclick="openBriefModal('${safeId}')">ð Read Full Brief</button> 6738 </div>`; 6739 } 6740 6741 return `<div class="news-card" ${isAiAnalysis?'style="flex-direction:column"':''}> 6742 ${!isAiAnalysis?(n.image_url?`<img class="news-thumb" src="${n.image_url}" alt="" loading="lazy" onerror="this.style.display='none'">`:n.summary&&n.summary.includes('<')?'':`<div class="news-thumb" style="display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:2rem">${icon}</div>`):''} 6743 <div class="news-body" style="width:100%"> 6744 <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px"> 6745 <span class="badge ${badge}" style="font-size:.58rem">${icon} ${label}</span> 6746 ${n.source?`<span style="font-size:.58rem;color:var(--text2);font-weight:600;font-family:var(--mono)">${n.source}</span>`:''} 6747 <span class="news-date" style="margin:0">${n.created_at?new Date(n.created_at).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}):''}</span> 6748 </div> 6749 ${n.summary&&n.summary.includes('<') 6750 ?`<div class="news-title" style="cursor:pointer" onclick="toggleArticle('${n.id}')">${n.title||'Untitled'} <span id="art-toggle-${n.id}" style="font-size:.62rem;color:var(--teal);font-family:var(--mono)">READ â</span></div>` 6751 :`<div class="news-title">${n.title||'Untitled'}</div>`} 6752 ${isAiAnalysis?`<img src="${n.image_url}" loading="lazy" onclick="this.classList.toggle('tv-chart-expanded')" style="width:100%;border-radius:8px;margin:10px 0;cursor:zoom-in;border:1px solid var(--border);max-height:320px;object-fit:cover;transition:max-height .3s ease" onerror="this.style.display='none'">` : ''} 6753 ${n.summary&&!n.caption?n.summary.includes('<')? 6754 `<div id="art-preview-${n.id}" style="position:relative;max-height:180px;overflow:hidden;margin-bottom:6px;border-radius:6px;cursor:pointer" onclick="toggleArticle('${n.id}')"> 6755 <div style="pointer-events:none;transform:scale(0.72);transform-origin:top left;width:139%;overflow:hidden">${n.summary}</div> 6756 <div style="position:absolute;bottom:0;left:0;right:0;height:60px;background:linear-gradient(transparent,var(--bg2))"></div> 6757 </div> 6758 <div id="art-body-${n.id}" style="display:none;margin-bottom:10px;border-radius:8px;overflow:hidden;border:1px solid var(--border)"></div>`: 6759 `<div class="news-sum">${n.summary}</div>`:''} 6760 ${n.caption?`<div id="news-caption-edit-${n.id}" contenteditable="true" style="font-size:.73rem;line-height:1.5;padding:6px 8px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;margin-bottom:8px;outline:none;color:var(--text2)">${n.caption}</div>`:''} 6761 <div class="news-actions"> 6762 ${n.caption?`<button class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px" onclick="copyNewsCaption('${n.id}')">ð Copy</button>`:''} 6763 ${n.image_url?`<a href="${n.image_url}" target="_blank" rel="noopener" class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px;text-decoration:none">ð¼ Image</a>`:''} 6764 ${n.source_url?`<a href="${n.source_url}" target="_blank" rel="noopener" class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px;text-decoration:none">ð Source</a>`:''} 6765 </div> 6766 </div> 6767 </div>`; 6768} 6769 6770const CLUSTER_ORDER=['brief','tradingview','chart','news','economic','earnings','company_analysis','macro','
6770insider','djt']; 6771const CLUSTER_LABEL={brief:'ð Daily Briefs',tradingview:'ð¡ Ideas',chart:'ð Charts',news:'ð° News',economic:'ð Economic',earnings:'ð° Earnings',company_analysis:'ð¢ Analysis',macro:'ð Macro',insider:'ð Insider',djt:'ðºð¸ DJT Tweets'}; 6772 6773function renderClusters(items){ 6774 const groups={}; 6775 for(const n of items){const t=n.content_type||'news';if(!groups[t])groups[t]=[];groups[t].push(n);} 6776 const order=[...CLUSTER_ORDER,...Object.keys(groups).filter(t=>!CLUSTER_ORDER.includes(t))]; 6777 const globalSeenRaw=localStorage.getItem('fz_news_seen_at'); 6778 return order.filter(t=>groups[t]?.length).map(t=>{ 6779 const label=CLUSTER_LABEL[t]||t.replace('_',' ').toUpperCase(); 6780 const count=groups[t].length; 6781 const seenRaw=localStorage.getItem('fz_seen_'+t)||globalSeenRaw; 6782 const seenAt=seenRaw?new Date(seenRaw):null; 6783 const newCount=seenAt?groups[t].filter(n=>n.created_at&&new Date(n.created_at)>seenAt).length:0; 6784 const newDot=newCount>0?`<span id="cnb-${t}" class="nav-badge badge-pulse" style="font-size:.55rem;padding:2px 5px;cursor:pointer" onclick="dismissClusterBadge('${t}',event)" title="Dismiss">â ${newCount}</span>`:''; 6785 const rows=groups[t].map(n=>{ 6786 const date=n.created_at?new Date(n.created_at).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}):''; 6787 const badge=NEWS_TYPE_BADGE[n.content_type]||'badge-muted'; 6788 const tvTicker=n.content_type==='tradingview'?((n.title||'').match(/\$([A-Z]{1,5})/)||[])[1]:null; 6789 const tickerBadge=tvTicker?`<span onclick="event.stopPropagation();openTickerModal('${tvTicker}')" style="font-family:var(--mono);font-size:.58rem;background:rgba(0,200,83,.12);color:var(--green);padding:1px 6px;border-radius:3px;flex-shrink:0;cursor:pointer">${tvTicker}</span>`:''; 6790 const srcLabel=n.source?`<span style="font-size:.55rem;color:var(--text2);font-family:var(--mono);flex-shrink:0">${n.source}</span>`:''; 6791 return `<div class="cluster-row" onclick="toggleClusterRow('cr-${n.id}')"> 6792 <span class="badge ${badge}" style="font-size:.55rem;white-space:nowrap">${NEWS_TYPE_ICON[n.content_type]||'ð'}</span> 6793 <span class="cluster-row-title">${n.title||'Untitled'}</span> 6794 ${tickerBadge} 6795 ${srcLabel} 6796 <span class="cluster-row-date">${date}</span> 6797 <span style="font-size:.65rem;color:var(--muted);margin-left:4px">â¸</span> 6798 </div> 6799 <div class="cluster-detail" id="cr-${n.id}">${newsCard(n)}</div>`; 6800 }).join(''); 6801 return `<div class="cluster-section"> 6802 <div class="cluster-header" onclick="toggleCluster('cb-${t}','ca-${t}')"> 6803 <span style="font-weight:700;font-size:.82rem">${label}</span> 6804 <span class="badge badge-muted" style="font-size:.6rem">${count}</span> 6805 ${newDot} 6806 <span class="cluster-arrow open" id="ca-${t}">â¶</span> 6807 </div> 6808 <div class="cluster-body open" id="cb-${t}">${rows}</div> 6809 </div>`; 6810 }).join(''); 6811} 6812 6813function dismissClusterBadge(type,event){ 6814 event.stopPropagation(); 6815 localStorage.setItem('fz_seen_'+type, new Date().toISOString()); 6816 const el=document.getElementById('cnb-'+type); 6817 if(el)el.remove(); 6818} 6819 6820function toggleCluster(bodyId,arrowId){ 6821 const b=$(bodyId),a=$(arrowId); 6822 if(!b)return; 6823 b.classList.toggle('open'); 6824 if(a)a.classList.toggle('open'); 6825} 6826function collapseAllClusters(){ 6827 document.querySelectorAll('.cluster-body.open').forEach(b=>b.classList.remove('open')); 6828 document.querySelectorAll('.cluster-arrow.open').forEach(a=>a.classList.remove('open')); 6829} 6830 6831function toggleClusterRow(detailId){ 6832 const d=$(detailId); 6833 if(!d)return; 6834 const isOpen=d.classList.contains('open'); 6835 d.classList.toggle('open'); 6836 const row=d.previousElementSibling; 6837 if(row){const arr=row.querySelector('span:last-child');if(arr)arr.textContent=isOpen?'â¸':'â¾';} 6838} 6839 6840function renderNewsItems(items,type){ 6841 const el=$('news-list'); 6842 if(!items.length){el.innerHTML=_newsLoading?'<div class="card"><p>Loading news...</p></div>':'<div class="card"><p>No items for this category yet.</p></div>';return;} 6843 if(type==='all'){el.innerHTML=renderClusters(items);return;} 6844 el.innerHTML=items.map(newsCard).join(''); 6845} 6846 6847async function loadNews(retry=true){ 6848 _initDjtToggle(); 6849 _newsLoading=true; 6850 const el=$('news-list');
6851 el.innerHTML='<div class="card"><p>Loading news...</p></div>'; 6852 try{ 6853 const [newsRes, briefsRes] = await Promise.all([ 6854 api('GET','/api/news'), 6855 api('GET','/api/news/briefs'), 6856 ]); 6857 const newsD = await newsRes.json(); 6858 const briefsD = briefsRes.ok ? await briefsRes.json() : {items:[]}; 6859 if(!newsRes.ok){ 6860 _newsLoading=false; 6861 if(retry){setTimeout(()=>loadNews(false),2500);return;} 6862 el.innerHTML=`<div class="card"><p style="color:var(--red)">${newsD.detail||'Failed to load news'}</p></div>`; 6863 return; 6864 } 6865 const briefIds = new Set((briefsD.items||[]).map(b=>b.id)); 6866 _newsItems=[ 6867 ...(briefsD.items||[]), 6868 ...(newsD.items||[]).filter(n=>!briefIds.has(n.id)), 6869 ]; 6870 _newsLoading=false; 6871 filterNews(_newsFilter, false); 6872 }catch(e){ 6873 _newsLoading=false; 6874 if(retry){setTimeout(()=>loadNews(false),2500);return;} 6875 el.innerHTML='<div class="card"><p style="color:var(--red)">Connection error.</p></div>'; 6876 } 6877} 6878 6879 6880function searchNews(q){ 6881 const el=$('news-search'); 6882 if(!q.trim()){ 6883 filterNews(_newsFilter,false); 6884 if(el)el.style.borderColor='var(--border)'; 6885 return; 6886 } 6887 const term=q.toLowerCase(); 6888 const searchable=_newsItems.filter(n=>['news','earnings','tradingview'].includes(n.content_type)); 6889 const hits=searchable.filter(n=> 6890 (n.title||'').toLowerCase().includes(term)|| 6891 (n.summary||'').toLowerCase().includes(term)|| 6892 (n.caption||'').toLowerCase().includes(term)|| 6893 (n.source||'').toLowerCase().includes(term) 6894 ); 6895 const el2=$('news-list'); 6896 if(!hits.length){el2.innerHTML=`<div class="card"><p style="color:var(--muted)">No results for "<strong>${q}</strong>"</p></div>`;return;} 6897 // deactivate tab buttons while searching 6898 document.querySelectorAll('.news-filter-btn').forEach(b=>b.classList.remove('active-filter')); 6899 el2.innerHTML=hits.map(newsCard).join(''); 6900} 6901 6902function filterNews(type,scroll=true){ 6903 _newsFilter=type; 6904 // clear search box when switching tabs 6905 const srch=$('news-search');if(srch)srch.value=''; 6906 document.querySelectorAll('.news-filter-btn').forEach(b=>{ 6907 b.classList.toggle('active-filter',b.dataset.type===type); 6908 }); 6909 const cal=$('eco-calendar'); 6910 if(cal)cal.style.display=type==='economic'?'block':'none'; 6911 if(type==='earnings'){loadEarningsCalendar();return;} 6912 const items=type==='all'?_newsItems:_newsItems.filter(n=>n.content_type===type); 6913 renderNewsItems(items,type); 6914 if(scroll)window.scrollTo({top:0,behavior:'smooth'}); 6915} 6916 6917function copyNewsCaption(id){ 6918 const item=_newsItems.find(n=>n.id===id); 6919 if(!item?.caption)return; 6920 navigator.clipboard.writeText(item.caption).then(()=>toast('Caption copied!','ok')); 6921} 6922 6923async function sendToPost(id){ 6924 const item=_newsItems.find(n=>n.id===id); 6925 if(!item)return; 6926 // Clear previous post-page state immediately before navigating 6927 $('post-caption').value=''; 6928 $('post-img-preview').innerHTML=''; 6929 $('post-file').files=new DataTransfer().files; 6930 const editEl=document.getElementById(`news-caption-edit-${id}`); 6931 const editedCaption=editEl?editEl.innerText.trim():''; 6932 const caption=editedCaption||item.caption||item.title||''; 6933 if(caption){$('post-caption').value=caption;if(typeof syncCaption==='function')syncCaption();} 6934 go('post'); 6935 if(item.image_url){ 6936 $('post-img-preview').innerHTML='<div style="font-size:.72rem;color:var(--muted);padding:8px">Loading image...</div>'; 6937 try{ 6938 const resp=await fetch(item.image_url); 6939 const blob=await resp.blob(); 6940 const file=new File([blob],'news-image.jpg',{type:blob.type||'image/jpeg'}); 6941 const dt=new DataTransfer();dt.items.add(file); 6942 $('post-file').files=dt.files;previewPost(); 6943 toast('Image + caption loaded in Post','ok'); 6944 }catch(e){ 6945 $('post-img-preview').innerHTML=''; 6946 toast('Caption loaded. Could not auto-load image â attach manually.','ok'); 6947 } 6948 }else{ 6949 toast('Caption loaded in Post tab.','ok'); 6950 } 6951} 6952 6953async function postNewsItem(id, platform){ 6954 const item=_newsItems.find(n=>n.id===id); 6955 if(!item){toast('Item not found','err');return;} 6956 const editEl=document.getElementById(`news-caption-edit-${id}`); 6957 const caption=editEl?editEl.innerText.trim():item.caption||''; 6958 if(!caption){toast('No caption to post','err');return;} 6959 const btnId=platform==='x'?`post-x-${id}`:`post-ig-${id}`; 6960 const btn=$(btnId); 6961 if(btn){btn.disabled=true;btn.innerHTML='<span class="spin"></span>';} 6962 try{ 6963 const body={caption,platforms:[platform]}; 6964 if(item.image_url)body.image_url=item.image_url; 6965 const res=await api('POST','/api/post/post',body); 6966 const d=await res.json(); 6967 if(res.ok){toast(`Posted to ${platform==='x'?'X':'Instagram'}!`,'ok');} 6968 else toast(d.detail||'Post failed','err'); 6969 }catch(e){toast('Connection error','err');} 6970 finally{if(btn){btn.disabled=false;btn.innerHTML=platform==='x'?'ð¦ Post X':'ð¸ Post IG';}} 6971} 6972 6973// ââ NEWS BADGE (unread counter) âââââââââââââââââ 6974function updateNewsBadge(){ 6975 const badge=$('news-badge'); 6976 if(!badge)return; 6977 if(_newsUnread>0){ 6978 badge.textContent=_newsUnread>9?'9+':_newsUnread; 6979 badge.style.display='inline-flex'; 6980 badge.classList.add('badge-pulse'); 6981 }else{ 6982 badge.style.display='none'; 6983 badge.classList.remove('badge-pulse'); 6984 } 6985} 6986 6987async function pollNewsUnread(){ 6988 const tier=USER?.tier||''; 6989 if(tier!=='creator'&&tier!=='pro'&&tier!=='stellar'&&tier!=='enterprise_api'&&tier!=='admin'&&tier!=='super_admin')return; 6990 try{ 6991 let seen=localStorage.getItem('fz_news_seen_at')||''; 6992 if(seen){ 6993 const sevenDaysAgo=new Date(Date.now()-7*24*60*60*1000); 6994 if(new Date(seen)<sevenDaysAgo){ 6995 seen=sevenDaysAgo.toISOString(); 6996 localStorage.setItem('fz_news_seen_at',seen); 6997 } 6998 } 6999 const url='/api/news/unread'+(seen?`?since=${encodeURIComponent(seen)}`:''); 7000 const res=await api('GET',url); 7001 if(res.ok){const d=await res.json();_newsUnread=d.count||0;updateNewsBadge();} 7002 }catch(e){} 7003} 7004 7005// ââ ADMIN TEMPLATED.IO TEMPLATE MANAGER âââââââââ 7006async function loadTemplatedConfigs(){ 7007 const el=$('tc-list'); if(!el)return; 7008 el.innerHTML='<p style="font-size:.73rem;color:var(--muted)">Loadingâ¦</p>'; 7009 try{ 7010 const r=await api('GET','/api/admin/templated'); 7011 const d=await r.json(); 7012 if(!d.configs||!d.configs.length){el.innerHTML='<p style="font-size:.73rem;color:var(--muted)">No DB-managed templates yet.</p>';return;} 7013 let h='<div style="display:flex;flex-direction:column;gap:8px">'; 7014 for(const c of d.configs){ 7015 h+=`<div class="card" style="display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 14px"> 7016 <div style="flex:1;min-width:0"> 7017 <div style="font-size:.8rem;font-weight:600">${c.label}</div> 7018 <div style="font-size:.68rem;color:var(--muted);font-family:var(--mono)">${c.key} · ${c.template_id}</div> 7019 <div style="font-size:.68rem;color:var(--muted)">${c.is_user_slot?`User Slot · $${c.price||'?'}${c.assigned_user_id?' · Assigned':'· Available'}`:c.is_free?'Free':'Paid'+(c.price?' · $'+c.price:'')}</div> 7020 </div> 7021 <button class="btn btn-ghost" style="font-size:.68rem;padding:4px 8px;color:#f87171" onclick="deleteTemplatedConfig('${c.id}','${c.label}')">Delete</button> 7022 </div>`; 7023 } 7024 h+='</div>'; 7025 el.innerHTML=h; 7026 }catch(e){el.innerHTML=`<p style="font-size:.73rem;color:#f87171">Error: ${e.message}</p>`;} 7027} 7028 7029async function verifyTemplatedConfig(){ 7030 const tid=($('tc-tid').value||'').trim(); 7031 if(!tid){toast('Enter a Template UUID first','err');return;} 7032 const btn=$('tc-verify-btn'); btn.disabled=true; btn.textContent='Verifyingâ¦'; 7033 const res=$('tc-verify-result'); 7034 try{ 7035 const r=await api('GET',`/api/admin/templated/verify?template_id=${encodeURIComponent(tid)}`); 7036 const d=await r.json(); 7037 if(r.ok){ 7038 res.innerHTML=`<span style="color:#4ade80">â Valid â ${d.layers.length} layer(s): ${d.layers.join(', ')}</span>`;
7039 } else { 7040 res.innerHTML=`<span style="color:#f87171">â ${d.detail||'Invalid template'}</span>`; 7041 } 7042 }catch(e){res.innerHTML=`<span style="color:#f87171">Error: ${e.message}</span>`;} 7043 finally{btn.disabled=false;btn.textContent='ð Verify Template';} 7044} 7045 7046async function saveTemplatedConfig(){ 7047 const key=($('tc-key').value||'').trim().replace(/\s+/g,'_').toLowerCase(); 7048 const label=($('tc-label').value||'').trim(); 7049 const tid=($('tc-tid').value||'').trim(); 7050 const isFree=$('tc-free').checked; 7051 const isUserSlot=$('tc-user-slot')&&$('tc-user-slot').checked; 7052 const price=parseFloat($('tc-price').value)||null; 7053 if(!key||!label||!tid){toast('Fill in key, label and template UUID','err');return;} 7054 const btn=$('tc-save-btn'); btn.disabled=true; btn.textContent='Savingâ¦'; 7055 try{ 7056 const r=await api('POST','/api/admin/templated',{key,label,template_id:tid,is_free:isFree,price:isFree?null:price,is_user_slot:!!isUserSlot}); 7057 const d=await r.json(); 7058 if(r.ok){ 7059 toast(`Template "${label}" saved`,'ok'); 7060 ['tc-key','tc-label','tc-tid','tc-price'].forEach(id=>{$( id).value='';}); 7061 $('tc-free').checked=true; 7062 if($('tc-user-slot'))$('tc-user-slot').checked=false; 7063 $('tc-verify-result').innerHTML=''; 7064 await loadTemplatedConfigs(); 7065 } else { 7066 toast(d.detail||'Save failed','err'); 7067 } 7068 }catch(e){toast('Error: '+e.message,'err');} 7069 finally{btn.disabled=false;btn.textContent='+ Save Template';} 7070} 7071 7072async function deleteTemplatedConfig(id,label){ 7073 if(!confirm(`Delete template "${label}"?`))return; 7074 try{ 7075 const r=await api('DELETE',`/api/admin/templated/${id}`); 7076 if(r.ok||r.status===204){toast(`Deleted "${label}"`,'ok');await loadTemplatedConfigs();} 7077 else{const d=await r.json();toast(d.detail||'Delete failed','err');} 7078 }catch(e){toast('Error: '+e.message,'err');} 7079} 7080 7081// ââ ADMIN AI ANALYSIS GENERATOR ââââââââââââââââ 7082function adminBuildPrompt(){ 7083 const company=$('ai-meta-company').value.trim(); 7084 const data=$('ai-meta-data').value.trim(); 7085 const tone=$('ai-meta-tone').value; 7086 const lang=$('ai-meta-lang').value; 7087 const ct=$('ai-content-type').value; 7088 let p=`Write a ${ct.replace(/_/g,' ')} analysis`; 7089 if(company) p+=` for ${company}`; 7090 if(data) p+=`. Key data: ${data}`; 7091 if(tone) p+=`. Tone: ${tone}`; 7092 if(lang==='es') p+=`. Write the full output in Spanish.`; 7093 $('ai-prompt').value=p.trim(); 7094} 7095 7096async function adminGenerateAnalysis(){ 7097 const prompt=$('ai-prompt').value.trim(); 7098 if(!prompt){toast('Enter a request prompt','err');return;} 7099 const btn=$('ai-gen-btn'); 7100 btn.innerHTML='<span class="spin"></span> Generatingâ¦';btn.disabled=true; 7101 $('ai-gen-result').innerHTML=''; 7102 $('ai-preview').style.display='none'; 7103 try{ 7104 const res=await api('POST','/api/admin/analysis/generate',{prompt,content_type:$('ai-content-type').value}); 7105 const d=await res.json(); 7106 if(res.ok){ 7107 $('ai-title').value=d.title||''; 7108 $('ai-summary').value=d.summary||''; 7109 $('ai-preview').style.display='block'; 7110 toast('Analysis generated â review and push','ok'); 7111 }else{ 7112 $('ai-gen-result').innerHTML=`<span style="color:var(--red);font-size:.75rem">${d.detail||'Generation failed'}</span>`; 7113 } 7114 }catch(e){toast('Connection error','err');} 7115 finally{btn.innerHTML='ð¤ Generate Analysis';btn.disabled=false;} 7116} 7117 7118async function adminPushAnalysis(){ 7119 const title=$('ai-title').value.trim(); 7120 const summary=$('ai-summary').value.trim(); 7121 const ct=$('ai-content-type').value; 7122 if(!title){toast('Title required','err');return;} 7123 try{ 7124 const res=await api('POST','/api/admin/news',{title,content_type:ct,summary:summary||null}); 7125 const d=await res.json(); 7126 if(res.ok){ 7127 toast('Analysis pushed to clients!','ok'); 7128 $('ai-prompt').value='';$('ai-title').value='';$('ai-summary').value=''; 7129 $('ai-preview').style.display='none'; 7130 $('ai-push-result').innerHTML=''; 7131 loadAdminNews(); 7132 }else{ 7133 $('ai-push-result').innerHTML=`<span style="color:var(--red);font-size:.75rem">${d.detail||'Push failed'}</span>`;
7134 } 7135 }catch(e){toast('Connection error','err');} 7136} 7137 7138// ââ ADMIN NEWS âââââââââââââââââââââââââââââââââ 7139async function adminPushNews(){ 7140 const title=$('an-title').value.trim(); 7141 if(!title){toast('Headline required','err');return;} 7142 const ct=$('an-content-type')?.value||'news'; 7143 const body={title,content_type:ct,summary:$('an-summary').value.trim()||null,image_url:$('an-img').value.trim()||null,caption:$('an-caption').value.trim()||null,source_url:$('an-source').value.trim()||null}; 7144 try{ 7145 const res=await api('POST','/api/admin/news',body); 7146 const d=await res.json(); 7147 if(res.ok){ 7148 toast('News pushed to clients!','ok'); 7149 ['an-title','an-summary','an-img','an-caption','an-source'].forEach(id=>{$(id).value='';}); 7150 loadAdminNews(); 7151 }else $('an-result').innerHTML=`<span style="color:var(--red);font-size:.75rem">${d.detail||'Failed'}</span>`; 7152 }catch(e){toast('Connection error','err');} 7153} 7154 7155async function adminPushRichText(){ 7156 const title=$('art-title').value.trim(); 7157 const body=$('art-body').value.trim(); 7158 if(!title){toast('Headline required','err');return;} 7159 if(!body){toast('Body content required','err');return;} 7160 const btn=$('art-btn'); 7161 btn.innerHTML='<span class="spin"></span> Pushing...';btn.disabled=true; 7162 try{ 7163 const publishBlog=$('art-publish-blog').checked; 7164 const d=await api('POST','/api/admin/news',{ 7165 title, 7166 content_type:$('art-content-type').value, 7167 summary:body, 7168 source_url:$('art-source').value.trim()||null, 7169 send_newsletter:$('art-newsletter').checked, 7170 publish_to_blog:publishBlog, 7171 blog_content_type:$('art-content-type').value, 7172 }); 7173 toast('Content pushed to clients!','ok'); 7174 $('art-title').value='';$('art-body').value='';$('art-source').value=''; 7175 $('art-newsletter').checked=false;$('art-publish-blog').checked=false; 7176 let resultHtml=`<span style="color:var(--green);font-size:.72rem">â Pushed â ID: ${d.id}</span>`; 7177 if(d.blog_slug) resultHtml+=` · <a href="/blog/${d.blog_slug}" target="_blank" style="color:var(--teal);font-size:.72rem">View blog post â</a>`; 7178 $('art-result').innerHTML=resultHtml; 7179 loadAdminNews(); 7180 }catch(e){ 7181 $('art-result').innerHTML=`<span style="color:var(--red);font-size:.75rem">${e.message||'Push failed'}</span>`; 7182 }finally{btn.innerHTML='ð Push to Clients';btn.disabled=false;} 7183} 7184 7185async function adminFetchTVIdeas(){ 7186 const btn=$('tv-fetch-btn'); 7187 const res=$('tv-fetch-result'); 7188 btn.disabled=true;btn.innerHTML='<span class="spin"></span> Fetching...'; 7189 res.innerHTML=''; 7190 try{ 7191 const r=await api('POST','/api/admin/run-job?job=fetch_tv_ideas'); 7192 const d=await r.json(); 7193 if(r.ok){res.innerHTML='<span style="color:var(--green)">â Done â check Ideas tab in News Feed.</span>';} 7194 else{res.innerHTML=`<span style="color:var(--red)">${d.detail||'Error'}</span>`;} 7195 }catch(e){res.innerHTML='<span style="color:var(--red)">Connection error.</span>';} 7196 finally{btn.disabled=false;btn.innerHTML='ð¡ Fetch Latest Ideas';} 7197} 7198 7199async function adminUploadNews(){ 7200 const title=$('anu-title').value.trim(); 7201 const file=$('anu-file').files[0]; 7202 if(!title){toast('Headline required','err');return;} 7203 if(!file){toast('Select an image file','err');return;} 7204 const btn=$('anu-btn'); 7205 btn.innerHTML='<span class="spin"></span> Uploading...';btn.disabled=true; 7206 try{ 7207 const fd=new FormData(); 7208 fd.append('file',file); 7209 fd.append('title',title); 7210 fd.append('content_type',$('anu-content-type').value); 7211 fd.append('caption',$('anu-caption').value.trim()); 7212 const res=await fetch(API+'/api/admin/news/upload',{method:'POST',headers:{Authorization:`Bearer ${TOKEN}`},body:fd}); 7213 const d=await res.json(); 7214 if(res.ok){ 7215 toast('Screenshot pushed to clients!','ok'); 7216 $('anu-title').value='';$('anu-caption').value='';$('anu-file').value=''; 7217 $('anu-result').innerHTML=`<span style="color:var(--green);font-size:.72rem">â Pushed â ${d.image_url?.slice(0,60)}...</span>`; 7218 loadAdminNews(); 7219 }else $('anu-result').innerHTML=`<span style="color:var(--red);font-size:.75rem">${d.detail||'Upload failed'}</span>`;
7220 }catch(e){toast('Connection error','err');} 7221 finally{btn.innerHTML='ð¤ Upload & Push';btn.disabled=false;} 7222} 7223 7224async function loadNewsletterSubCount(){ 7225 try{ 7226 const d=await api('GET','/api/admin/newsletter/stats'); 7227 const el=$('nl-sub-count'); 7228 if(el) el.textContent = (d && d.active_subscribers !== undefined) 7229 ? `· ð§ ${d.active_subscribers} subscriber${d.active_subscribers===1?'':'s'}` 7230 : `· ð§ 0 subscribers`; 7231 }catch(e){} 7232} 7233 7234let _subListOpen = false; 7235async function toggleSubList(){ 7236 const wrap = $('nl-sub-list'); 7237 const btn = $('nl-view-btn'); 7238 if(!wrap) return; 7239 if(_subListOpen){ wrap.style.display='none'; btn.textContent='View List'; _subListOpen=false; return; } 7240 wrap.style.display='block'; 7241 btn.textContent='Hide List'; 7242 _subListOpen=true; 7243 wrap.innerHTML='<p style="font-size:.75rem;color:var(--muted)">Loadingâ¦</p>'; 7244 try{ 7245 const res = await api('GET','/api/admin/newsletter/subscribers'); 7246 if(!res.ok){ wrap.innerHTML='<p style="color:var(--red);font-size:.75rem">Failed to load</p>'; return; } 7247 const d = await res.json(); 7248 if(!d.subscribers.length){ wrap.innerHTML='<p style="font-size:.75rem;color:var(--muted)">No subscribers yet.</p>'; return; } 7249 const rows = d.subscribers.map(s=>`<tr> 7250 <td style="padding:5px 10px;font-size:.74rem">${s.email}</td> 7251 <td style="padding:5px 10px;font-size:.7rem;color:var(--muted)">${s.subscribed_at}</td> 7252 <td style="padding:5px 10px;text-align:center"><span style="font-size:.65rem;padding:2px 7px;border-radius:4px;${s.is_active?'background:rgba(0,180,100,.15);color:#4caf82;border:1px solid #4caf82':'background:rgba(200,50,50,.1);color:#ef5350;border:1px solid #ef5350'}">${s.is_active?'Active':'Unsub'}</span></td> 7253 <td style="padding:5px 10px;font-size:.7rem;color:var(--muted);text-align:right">${s.charts_used} charts</td> 7254 </tr>`).join(''); 7255 wrap.innerHTML=`<div style="font-size:.65rem;color:var(--muted);margin-bottom:6px;font-family:var(--mono)">${d.active} ACTIVE · ${d.total} TOTAL</div> 7256 <div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;border:1px solid var(--border);border-radius:6px;overflow:hidden"> 7257 <thead><tr style="background:var(--bg3)"> 7258 <th style="padding:6px 10px;font-size:.6rem;color:var(--muted);text-align:left;font-family:var(--mono);letter-spacing:1px">EMAIL</th> 7259 <th style="padding:6px 10px;font-size:.6rem;color:var(--muted);text-align:left;font-family:var(--mono);letter-spacing:1px">SUBSCRIBED</th> 7260 <th style="padding:6px 10px;font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:1px">STATUS</th> 7261 <th style="padding:6px 10px;font-size:.6rem;color:var(--muted);text-align:right;font-family:var(--mono);letter-spacing:1px">USAGE</th> 7262 </tr></thead> 7263 <tbody>${rows}</tbody> 7264 </table></div>`; 7265 }catch(e){ wrap.innerHTML=`<p style="color:var(--red);font-size:.75rem">${e.message}</p>`; } 7266} 7267 7268async function pollAdminNotifications(){ 7269 const tier=USER?.tier||''; 7270 if(tier!=='admin'&&tier!=='super_admin')return; 7271 try{ 7272 const d=await api('GET','/api/admin/notifications'); 7273 const badge=$('admin-notif-badge'); 7274 if(!badge)return; 7275 if(d.count>0){ 7276 badge.textContent=d.count>9?'9+':d.count; 7277 badge.style.display='inline-flex'; 7278 badge.classList.add('badge-pulse'); 7279 }else{ 7280 badge.style.display='none'; 7281 badge.classList.remove('badge-pulse'); 7282 } 7283 }catch(e){} 7284} 7285 7286async function markAdminNotifsRead(){ 7287 const tier=USER?.tier||''; 7288 if(tier!=='admin'&&tier!=='super_admin')return; 7289 try{ 7290 await api('POST','/api/admin/notifications/read'); 7291 const badge=$('admin-notif-badge'); 7292 if(badge){badge.style.display='none';badge.classList.remove('badge-pulse');} 7293 }catch(e){} 7294} 7295 7296// ââ Outreach Tracker ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7297const OC_STATUS_BADGE={prospect:'badge-muted',contacted:'badge-yellow',replied:'badge-teal',trial:'badge-blue',converted:'badge-green',lost:'badge-red'}; 7298const OC_STATUS_LABEL={prospect:'Prospect',contacted:'Contacted',replied:'Replied',trial:'Trial',converted:'Converted',lost:'Lost'}; 7299const OC_PLATFORM_ICON={x:'ð',youtube:'â¶',newsletter:'ð§',other:'ð'}; 7300const OC_SEGMENT_LABEL={creator:'Finance Creator',advisor:'RIA / Advisor',crypto:'Crypto',other:'Other'}; 7301 7302// ââ Default templates ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7303const OC_DEFAULT_TEMPLATES={ 7304 email_creator:{ 7305 subject:'Quick tool for your {{topic}} content', 7306 body:`Hey {{name}}, 7307 7308Been following your work â {{specific_post}} was a great take. 7309 7310I built Factoz, a command center for market content creators. It scans thousands of tickers, flags signals, and generates ready-to-post carousels and charts automatically. 7311 7312I think it fits exactly what you do. Happy to give you full free access â no demo call, no pitch deck, just log in and use it. 7313 7314Interested? 7315 7316factozcentral.com` 7317 }, 7318 email_advisor:{
7319 subject:'Client-ready market visuals in seconds', 7320 body:`Hi {{name}}, 7321 7322I came across your work at {{firm}} and thought this might be relevant. 7323 7324Factoz turns market scans and economic data into polished carousels and reports â the kind of thing advisors send clients. Earnings summaries, macro dashboards, sector heatmaps. 7325 7326Would a 2-week free trial be worth trying? No setup required. 7327 7328factozcentral.com` 7329 }, 7330 email_crypto:{ 7331 subject:'Built something for your audience', 7332 body:`Hey {{name}}, 7333 7334Love how you cover {{topic}}. I built Factoz â market scanner + instant carousel generator. Your audience would eat this up. 7335 7336Free access, no strings. Want in? 7337 7338factozcentral.com` 7339 }, 7340 x_creator:`Hey {{name}} â love your {{topic}} content. Built a tool called Factoz that turns market scans into post-ready carousels instantly. Thought it fits your workflow. Free access if you want to try it â factozcentral.com`, 7341 x_crypto:`{{name}} your {{topic}} takes are ð¥. Built Factoz â market scanner + auto-carousel generator. Free for creators. Worth a look? factozcentral.com`, 7342 x_advisor:`Hi {{name}}, saw your work on {{topic}}. Built Factoz for market professionals â scans, charts, carousels, all in one. Free trial if useful. factozcentral.com`, 7343 7344 // ââ Spanish templates â Panama finance professionals ââââââââââââââââââââââ 7345 7346 es_newsletter:{ 7347 subject:'Brief diario de inteligencia de mercados â gratis para profesionales', 7348 body:`Estimado/a {{name}}, 7349 7350Mi nombre es Abraham, fundador de Factoz â una plataforma de inteligencia financiera global con cobertura de Estados Unidos, Europa, Latinoamérica y Asia. 7351 7352Cada mañana, antes de las 9:30 ET, generamos un **Brief Diario de Inteligencia de Mercados** que incluye: 7353 7354⢠Resumen de futuros y condiciones pre-mercado con precios reales 7355⢠Alertas de riesgo geopolÃtico y macro 7356⢠Principales noticias que mueven los mercados ese dÃa 7357⢠Análisis editorial con perspectiva institucional 7358⢠Señales técnicas automáticas sobre más de 5,900 acciones 7359 7360Adicionalmente, cada lunes enviamos un reporte semanal con el rendimiento de las señales generadas por el sistema. 7361 7362El servicio es gratuito para profesionales del sector financiero en Panamá. Me gustarÃa añadirle a nuestra lista de distribución. 7363 7364¿Le interesa recibirlo? 7365 7366Saludos, 7367Abraham 7368Factoz â Global Market Intelligence 7369factozcentral.com` 7370 }, 7371 7372 es_advisor:{ 7373 subject:'Herramienta de análisis de mercados para profesionales financieros', 7374 body:`Estimado/a {{name}}, 7375 7376Le escribo desde Factoz, una plataforma de inteligencia de mercados que combina análisis macroeconómico, señales técnicas automatizadas y cobertura de flujo de opciones. 7377 7378Hemos desarrollado una suite de herramientas pensada para asesores financieros, analistas y gestores de portafolio: 7379 7380â Scanner técnico sobre 5,900+ activos (RSI, ruptura de 52 semanas, volumen inusual) 7381â Brief pre-mercado generado por IA con datos reales de futuros 7382â Estados financieros de cualquier empresa pública en segundos 7383â Monitor de flujo de opciones inusual (4 veces al dÃa) 7384â Reporte semanal con rendimiento histórico de señales 7385 7386Actualmente ofrecemos acceso de prueba gratuito de 7 dÃas. No requiere tarjeta de crédito. 7387 7388¿TendrÃa 5 minutos para revisarlo? 7389 7390Saludos, 7391Abraham 7392factozcentral.com` 7393 }, 7394 7395 es_association:{ 7396 subject:'Propuesta de colaboración â Brief de mercados para sus miembros', 7397 body:`Estimado/a {{name}}, 7398 7399Le escribo en representación de Factoz, plataforma de inteligencia financiera global. 7400 7401Estamos desarrollando una alianza con asociaciones del sector financiero en Panamá para distribuir nuestro **Brief Diario de Inteligencia de Mercados** de forma gratuita a sus miembros. 7402 7403El brief cubre: 7404⢠Condiciones de futuros con precios en tiempo real 7405⢠Análisis de los principales mercados: EE.UU., Europa, LATAM y Asia 7406⢠Señales técnicas y flujo inusual de opciones 7407⢠Editorial con perspectiva institucional 7408 7409PodrÃamos co-brandear el reporte con el logo de su organización y envi
7409arlo directamente a su base de miembros sin costo alguno. 7410 7411El objetivo es generar valor para sus afiliados y posicionar a {{firm}} como referente en la distribución de inteligencia de mercados en la región. 7412 7413¿PodrÃamos agendar una llamada breve esta semana? 7414 7415Saludos cordiales, 7416Abraham 7417Factoz â Global Market Intelligence 7418factozcentral.com` 7419 }, 7420 7421 es_cold:{ 7422 subject:'Análisis de mercados automatizado â Factoz', 7423 body:`Hola {{name}}, 7424 7425Vi su perfil en el sector financiero de Panamá y pensé que esto podrÃa interesarle. 7426 7427Factoz es una plataforma que genera análisis de mercados en tiempo real â señales técnicas, flujo de opciones, datos macro y briefs pre-mercado â de forma automatizada. 7428 7429Actualmente estamos ofreciendo acceso gratuito a profesionales del sector. Sin contratos, sin tarjeta de crédito. 7430 7431Si le interesa explorar la plataforma, puede acceder en factozcentral.com 7432 7433Quedo atento. 7434Abraham` 7435 }, 7436}; 7437 7438function _ocTemplates(){ 7439 try{return JSON.parse(localStorage.getItem('fz_outreach_templates'))||{};}catch{return {};} 7440} 7441function _ocTpl(key){ 7442 const saved=_ocTemplates(); 7443 return saved[key]!==undefined?saved[key]:OC_DEFAULT_TEMPLATES[key]; 7444} 7445function _ocFill(text,c){ 7446 return (text||'') 7447 .replace(/\{\{name\}\}/g, (c.name||'').split(' ')[0]) 7448 .replace(/\{\{topic\}\}/g, c.topic||'[topic]') 7449 .replace(/\[AFFILIATE_LINK\]/g, c.affiliate_link||'[AFFILIATE_LINK]') 7450 .replace(/\{\{handle\}\}/g, c.handle_x||c.handle||'[handle]') 7451 .replace(/\{\{firm\}\}/g, '[firm]') 7452 .replace(/\{\{specific_post\}\}/g, '[specific post]'); 7453} 7454 7455// ââ Outreach modal âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7456let _ocModalContact=null; 7457 7458function openOutreachModal(c, mode){ 7459 _ocModalContact=c; 7460 const modal=$('oc-modal'); 7461 const title=$('oc-modal-title'); 7462 const ta=$('oc-modal-body'); 7463 const subjRow=$('oc-modal-subj-row'); 7464 const subj=$('oc-modal-subj'); 7465 const sendBtn=$('oc-modal-send'); 7466 const copyBtn=$('oc-modal-copy'); 7467 const sel=$('oc-modal-variant'); 7468 modal.style.display='flex'; 7469 $('oc-modal-result').innerHTML=''; 7470 const affRow=$('oc-modal-affiliate-row'); 7471 const affLink=$('oc-modal-affiliate-link'); 7472 if(c.affiliate_link){affRow.style.display='';affLink.href=c.affiliate_link;affLink.textContent=c.affiliate_link;} 7473 else{affRow.style.display='none';} 7474 7475 if(mode==='email'){ 7476 title.textContent='â Send Email â '+c.name; 7477 subjRow.style.display=''; 7478 sendBtn.style.display=''; 7479 copyBtn.style.display='none'; 7480 sel.innerHTML=`<option value="email_creator">Creator (EN)</option><option value="email_advisor">Advisor (EN)</option><option value="email_crypto">Crypto (EN)</option><optgroup label="ââ Español ââ"><option value="es_newsletter">Newsletter ES</option><option value="es_advisor">Asesor ES</option><option value="es_association">Asociación ES</option><option value="es_cold">Cold outreach ES</option></optgroup>`; 7481 // pick default variant based on segment 7482 const def=c.segment==='advisor'?'email_advisor':c.segment==='crypto'?'email_crypto':'email_creator'; 7483 sel.value=def; 7484 const tpl=_ocTpl(def); 7485 subj.value=_ocFill(tpl.subject,c); 7486 ta.value=_ocFill(tpl.body,c); 7487 sendBtn.onclick=()=>sendOutreachEmail(c.id); 7488 } else { 7489 title.textContent='ð Copy X DM â '+c.name; 7490 subjRow.style.display='none'; 7491 sendBtn.style.display='none'; 7492 copyBtn.style.display=''; 7493 sel.innerHTML=`<option value="x_creator">Creator</option><option value="x_crypto">Crypto</option><option value="x_advisor">Advisor</option>`; 7494 const def=c.segment==='crypto'?'x_crypto':c.segment==='advisor'?'x_advisor':'x_creator'; 7495 sel.value=def; 7496 ta.value=_ocFill(_ocTpl(def),c); 7497 copyBtn.onclick=()=>copyOutreachDM(c.id); 7498 } 7499 7500 sel.onchange=()=>{ 7501 const tpl=_ocTpl(sel.value); 7502 if(mode==='email'&&tpl&&tpl.body){ 7503 subj.value=_ocFill(tpl.subject,c); 7504 ta.value=_ocFill(tpl.body,c); 7505 } else { 7506 ta.value=_ocFill(tpl,c); 7507 } 7508 }; 7509} 7510 7511function closeOutreachModal(){$('oc-modal').style.display='none';_ocModalContact=null;} 7512 7513async function sendOutreachEmail(id){ 7514 const subj=($('oc-modal-subj').value||'').trim(); 7515 const body=($('oc-modal-body').value||'').trim(); 7516 const res=$('oc-modal-result'); 7517 if(!subj||!body){res.innerHTML='<span style="color:var(--red)">Subject and body required</span>';return;} 7518 const btn=$('oc-modal-send'); 7519 btn.disabled=true;btn.textContent='Sending...'; 7520 try{ 7521 const r=await api('POST',`/api/admin/outreach/${id}/email`,{subject:subj,body}); 7522 const d=await r.json(); 7523 if(r.ok){ 7524 res.innerHTML='<span style="color:var(--green)">â Email sent!</span>'; 7525 setTimeout(closeOutreachModal,1200); 7526 await loadOutreachContacts(); 7527 }else{res.innerHTML=`<span style="color:var(--red)">${d.detail||'Send failed'}</span>`;} 7528 }catch(e){res.innerHTML='<span style="color:var(--red)">Connection error</span>';} 7529 finally{btn.disabled=false;btn.textContent='Send Email';} 7530} 7531 7532async function copyOutreachDM(id){ 7533 const text=$('oc-modal-body').value||''; 7534 try{await navigator.clipboard.writeText(text);}catch{ 7535 const ta=document.createElement('textarea');ta.value=text;document.body.appendChild(ta);ta.select();document.execCommand('copy');ta.remove(); 7536 } 7537 toast('Copied â paste in X DM','ok'); 7538 // log as contacted 7539 await api('PUT',`/api/admin/outreach/${id}`,{status:'contacted',last_platform:'x',last_contacted_at:new Date().toISOString()}); 7540 await loadOutreachContacts(); 7541 setTimeout(closeOutreachModal,800); 7542} 7543 7544function openOutreachForm(){ 7545 ['oc-name','oc-handle','oc-email','oc-notes','oc-topic','oc-handle-x','oc-handle-ig','oc-handle-tg','oc-handle-li','oc-handle-tt','oc-affiliate'].forEach(id=>{const el=$(id);if(el)el.value='';}); 7546 ['oc-platform','oc-segment'].forEach(id=>{const el=$(id);if(el)el.selectedIndex=0;}); 7547 $('oc-result').innerHTML='';
7548 $('outreach-form-panel').style.display=''; 7549 const body=$('as-outreach'); 7550 if(body&&body.style.display==='none'){body.style.display='';const arr=$('as-outreach-arrow');if(arr)arr.textContent='â¾';} 7551} 7552 7553async function saveOutreachContact(){ 7554 const btn=$('oc-save-btn'); 7555 const res=$('oc-result'); 7556 const name=($('oc-name').value||'').trim(); 7557 if(!name){res.innerHTML='<span style="color:var(--red)">Name is required</span>';return;} 7558 btn.disabled=true; 7559 try{ 7560 const body={ 7561 name, 7562 email: ($('oc-email').value||'').trim()||null, 7563 handle: ($('oc-handle').value||'').trim()||null, 7564 handle_x: ($('oc-handle-x').value||'').trim()||null, 7565 handle_ig:($('oc-handle-ig').value||'').trim()||null, 7566 handle_telegram:($('oc-handle-tg').value||'').trim()||null, 7567 handle_linkedin:($('oc-handle-li').value||'').trim()||null, 7568 handle_tiktok: ($('oc-handle-tt').value||'').trim()||null, 7569 platform: $('oc-platform').value, 7570 segment: $('oc-segment').value, 7571 topic: ($('oc-topic').value||'').trim()||null, 7572 notes: ($('oc-notes').value||'').trim()||null, 7573 affiliate_link: ($('oc-affiliate').value||'').trim()||null, 7574 }; 7575 const r=await api('POST','/api/admin/outreach',body); 7576 const d=await r.json(); 7577 if(r.ok){ 7578 res.innerHTML='<span style="color:var(--green)">â Saved</span>'; 7579 $('outreach-form-panel').style.display='none'; 7580 await loadOutreachContacts(); 7581 }else{res.innerHTML=`<span style="color:var(--red)">${d.detail||'Error'}</span>`;} 7582 }catch(e){res.innerHTML='<span style="color:var(--red)">Connection error</span>';} 7583 finally{btn.disabled=false;} 7584} 7585 7586async function updateOutreachStatus(id,status){ 7587 try{ 7588 const payload={status}; 7589 if(status==='contacted'){payload.last_contacted_at=new Date().toISOString();} 7590 const r=await api('PUT',`/api/admin/outreach/${id}`,payload); 7591 if(r.ok)await loadOutreachContacts(); 7592 }catch(e){} 7593} 7594 7595async function deleteOutreachContact(id){ 7596 if(!confirm('Delete this contact?'))return; 7597 const r=await api('DELETE',`/api/admin/outreach/${id}`); 7598 if(r.ok){toast('Deleted','ok');await loadOutreachContacts();} 7599} 7600 7601async function loadOutreachContacts(){ 7602 const el=$('outreach-contact-list'); 7603 if(!el)return; 7604 try{ 7605 const r=await api('GET','/api/admin/outreach'); 7606 const d=await r.json(); 7607 const contacts=d.contacts||[]; 7608 if(!contacts.length){el.innerHTML='<p style="font-size:.73rem;color:var(--muted);padding:8px 0">No prospects yet. Click "+ Add Prospect" to start.</p>';return;} 7609 el.innerHTML=contacts.map(c=>{ 7610 const statusBadge=OC_STATUS_BADGE[c.status]||'badge-muted'; 7611 const statusLabel=OC_STATUS_LABEL[c.status]||c.status; 7612 const seg=OC_SEGMENT_LABEL[c.segment]||c.segment; 7613 const lastContact=c.last_contacted_at?`Last: ${new Date(c.last_contacted_at).toLocaleDateString('en-US',{month:'short',day:'numeric'})}${c.last_platform?' via '+c.last_platform:''} · `:''; 7614 const added=c.created_at?new Date(c.created_at).toLocaleDateString('en-US',{month:'short',day:'numeric'}):''; 7615 const handles=[c.handle_x&&`ð${c.handle_x}`,c.handle_ig&&`IG ${c.handle_ig}`,c.handle_telegram&&`TG ${c.handle_telegram}`,c.handle_linkedin&&`LI ${c.handle_linkedin}`,c.handle_tiktok&&`TT ${c.handle_tiktok}`].filter(Boolean).join(' · '); 7616 const quickBtns=['contacted','replied','trial','converted','lost'].filter(s=>s!==c.status).map(s=> 7617 `<button class="btn btn-ghost" style="font-size:.6rem;padding:2px 7px" onclick="updateOutreachStatus('${c.id}','${s}')">${OC_STATUS_LABEL[s]}</button>` 7618 ).join(''); 7619 const cStr=JSON.stringify(c).replace(/'/g,"\\'"); 7620 return `<div style="background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:12px 14px;margin-bottom:8px"> 7621 <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap"> 7622 <div style="flex:1;min-width:0"> 7623 <div style="font-size:.8rem;font-weight:700;color:var(--text)">${c.name}${c.topic?` <span style="color:var(--muted);font-weight:400;font-size:.72rem">· ${c.topic}</span>`:''}</div> 7624 <div style="font-size:.63rem;color:var(--muted);margin-top:1px">${seg} · ${lastContact}Added ${added}</div> 7625 ${handles?`<div style="font-size:.6rem;color:var(--teal);margin-top:2px">${handles}</div>`:''} 7626 ${c.email?`<div style="font-size:.6rem;color:var(--muted);margin-top:1px">â ${c.email}</div>`:''} 7627 </div>
7628 <span class="badge ${statusBadge}" style="font-size:.6rem">${statusLabel}</span> 7629 <button onclick="deleteOutreachContact('${c.id}')" style="background:none;border:none;color:#f87171;cursor:pointer;font-size:.75rem;padding:2px 4px">â</button> 7630 </div> 7631 ${c.notes?`<div style="font-size:.7rem;color:var(--text2);margin-top:6px;padding:5px 8px;background:var(--bg3);border-radius:5px">${c.notes}</div>`:''} 7632 <div style="display:flex;gap:5px;flex-wrap:wrap;margin-top:8px;align-items:center"> 7633 ${c.handle_x||c.handle?`<button class="btn btn-ghost" style="font-size:.6rem;padding:2px 8px" onclick='openOutreachModal(${cStr},"x")'>ð Copy DM</button>`:''} 7634 ${c.email?`<button class="btn btn-ghost" style="font-size:.6rem;padding:2px 8px" onclick='openOutreachModal(${cStr},"email")'>â Email</button>`:''} 7635 <span style="flex:1"></span> 7636 ${quickBtns} 7637 </div> 7638 </div>`; 7639 }).join(''); 7640 }catch(e){el.innerHTML='<p style="color:var(--red);font-size:.73rem">Failed to load contacts.</p>';} 7641} 7642// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 7643 7644async function loadAdminNews(){ 7645 loadNewsletterSubCount(); 7646 const el=$('admin-news-list'); 7647 if(!el)return; 7648 try{ 7649 const res=await api('GET','/api/admin/news'); 7650 const d=await res.json(); 7651 if(!res.ok||!d.items?.length){el.innerHTML='<div style="font-size:.72rem;color:var(--muted);font-family:var(--mono);padding:8px 0">No news items pushed yet.</div>';return;} 7652 const drafts=d.items.filter(n=>!n.is_active); 7653 const live=d.items.filter(n=>n.is_active); 7654 7655 const draftHtml=drafts.map(n=>{ 7656 const date=n.created_at?new Date(n.created_at).toLocaleDateString('en-US',{month:'short',day:'numeric'}):''; 7657 return `<div class="news-admin-row" style="flex-direction:column;align-items:stretch;border-left:3px solid var(--yellow);padding-left:10px;gap:6px;margin-bottom:10px"> 7658 ${n.image_url?`<img src="${n.image_url}" style="width:100%;max-height:180px;object-fit:cover;border-radius:6px;border:1px solid var(--border)" onerror="this.style.display='none'">` : ''} 7659 <div id="dt-${n.id}" contenteditable="true" spellcheck="false" style="font-size:.82rem;font-weight:600;color:var(--text);outline:none;border-bottom:1px solid var(--border);padding-bottom:4px">${n.title}</div> 7660 <div id="dc-${n.id}" contenteditable="true" spellcheck="false" style="font-size:.72rem;color:var(--text2);outline:none;line-height:1.5;max-height:100px;overflow-y:auto">${n.caption||''}</div> 7661 <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap"> 7662 <span class="badge badge-yellow" style="font-size:.55rem">ð¥ DRAFT</span> 7663 <button class="btn btn-ghost" style="font-size:.65rem;padding:3px 9px;color:var(--green)" onclick="publishDraft('${n.id}')">â Publish to Analysis</button> 7664 <button class="btn btn-red" style="padding:3px 9px;font-size:.65rem" onclick="removeNews('${n.id}')">â Delete</button> 7665 <span style="font-size:.6rem;color:var(--muted);margin-left:auto">${date}</span> 7666 </div> 7667 </div>`; 7668 }).join(''); 7669 7670 const liveHtml=live.map(n=>{ 7671 const icon=NEWS_TYPE_ICON[n.content_type]||'ð'; 7672 return `<div class="news-admin-row"> 7673 <span style="font-size:.8rem">${icon}</span> 7674 <div class="news-admin-title">${n.title}</div> 7675 <div class="news-admin-date">${n.created_at?new Date(n.created_at).toLocaleDateString('en-US',{month:'short',day:'numeric'}):''}</div> 7676 <span class="badge badge-green" style="font-size:.58rem">Live</span> 7677 <button class="btn btn-red" style="padding:3px 9px;font-size:.65rem" onclick="removeNews('${n.id}')">â</button> 7678 </div>`; 7679 }).join(''); 7680 7681 el.innerHTML=(drafts.length?`<div style="font-size:.6rem;color:var(--yellow);font-family:var(--mono);letter-spacing:2px;margin-bottom:8px">DRAFTS (${drafts.length})</div>`+draftHtml:'')+liveHtml; 7682 }catch(e){} 7683} 7684 7685async function publishDraft(id){ 7686 const title=document.getElementById(`dt-${id}`)?.innerText.trim(); 7687 const caption=document.getElementById(`dc-${id}`)?.innerText.trim(); 7688 const res=await api('PATCH',`/api/admin/news/${id}`,{is_active:true,title,caption}); 7689 if(res.ok){toast('Published to Analysis feed!','ok');loadAdminNews();} 7690 else toast('Failed to publish','err'); 7691} 7692 7693async function removeNews(id){ 7694 if(!confirm('Permanently delete this news item? This cannot be undone.'))return; 7695 const res=await api('DELETE',`/api/admin/news/${id}`); 7696 if(res.ok){toast('News item deleted','ok');loadAdminNews();} 7697 else toast('Failed','err'); 7698} 7699 7700 7701// ââ ADMIN DASHBOARD USERS ââââââââââââââââââââââ 7702let editingUserId=null; 7703 7704function openNewUser(){ 7705 editingUserId=null; 7706 $('user-form-title').textContent='New Dashboard User'; 7707 $('uf-save-btn').textContent='ð¾ Create User'; 7708 $('uf-email').value='';$('uf-name').value='';$('uf-password').value=''; 7709 $('uf-tier').value='pro'; 7710 $('uf-result').innerHTML='';
7711 $('uf-email').disabled=false; 7712 $('user-form-panel').style.display=''; 7713} 7714 7715function openEditUser(u){ 7716 editingUserId=u.id; 7717 $('user-form-title').textContent='Edit User â '+u.email; 7718 $('uf-save-btn').textContent='ð¾ Update User'; 7719 $('uf-email').value=u.email;$('uf-name').value=u.name||''; 7720 $('uf-password').value=''; 7721 $('uf-tier').value=u.tier; 7722 $('uf-result').innerHTML=''; 7723 $('uf-email').disabled=true; 7724 $('user-form-panel').style.display=''; 7725} 7726 7727async function saveUser(){ 7728 const btn=$('uf-save-btn'); 7729 const res_el=$('uf-result'); 7730 btn.disabled=true; 7731 btn.innerHTML='<span class="spin"></span>'; 7732 try{ 7733 if(editingUserId){ 7734 // Update tier/name 7735 const body={tier:$('uf-tier').value}; 7736 if($('uf-name').value.trim())body.name=$('uf-name').value.trim(); 7737 let res=await api('PUT',`/api/admin/users/${editingUserId}`,body); 7738 let d=await res.json(); 7739 if(!res.ok){res_el.innerHTML=`<span style="color:var(--red)">${d.detail||'Update failed'}</span>`;return;} 7740 // Set password if provided 7741 if($('uf-password').value){ 7742 const pr=await api('POST',`/api/admin/users/${editingUserId}/set-password`,{password:$('uf-password').value}); 7743 if(!pr.ok){const pd=await pr.json();res_el.innerHTML=`<span style="color:var(--red)">${pd.detail||'Password update failed'}</span>`;return;} 7744 } 7745 res_el.innerHTML='<span style="color:var(--green)">â User updated</span>'; 7746 }else{ 7747 if(!$('uf-password').value){res_el.innerHTML='<span style="color:var(--red)">Password is required</span>';return;} 7748 const body={email:$('uf-email').value,password:$('uf-password').value,tier:$('uf-tier').value}; 7749 if($('uf-name').value.trim())body.name=$('uf-name').value.trim(); 7750 let res=await api('POST','/api/admin/users',body); 7751 let d=await res.json(); 7752 if(!res.ok){res_el.innerHTML=`<span style="color:var(--red)">${d.detail||'Create failed'}</span>`;return;} 7753 res_el.innerHTML=`<span style="color:var(--green)">â User created â ${d.email} (${d.tier})</span>`; 7754 $('uf-email').value='';$('uf-name').value='';$('uf-password').value=''; 7755 } 7756 loadAdminUsers(); 7757 }catch(e){res_el.innerHTML='<span style="color:var(--red)">Connection error</span>';} 7758 finally{btn.innerHTML=editingUserId?'ð¾ Update User':'ð¾ Create User';btn.disabled=false;} 7759} 7760 7761async function loadAdminUsers(){ 7762 const el=$('user-list');if(!el)return; 7763 try{ 7764 const res=await api('GET','/api/admin/users'); 7765 const d=await res.json(); 7766 if(!res.ok){el.innerHTML='';return;} 7767 if(!d.users||!d.users.length){el.innerHTML='<div style="font-size:.73rem;color:var(--muted)">No dashboard users yet.</div>';return;} 7768 el.innerHTML=d.users.map(u=>` 7769 <div style="background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:8px;display:flex;align-items:center;gap:16px;flex-wrap:wrap"> 7770 <div style="flex:1;min-width:0"> 7771 <div style="font-size:.78rem;font-weight:600;color:var(--text)">${u.email}</div> 7772 <div style="font-size:.65rem;color:var(--muted);margin-top:2px">${u.name||''} · <span style="color:var(--yellow)">${u.tier}</span> · ${u.is_active?'<span style="color:var(--green)">active</span>':'<span style="color:var(--red)">inactive</span>'} · <span style="font-family:var(--mono);color:#6e7681">${u.last_ip||'â'}</span></div> 7773 <div style="margin-top:5px;display:flex;gap:6px;flex-wrap:wrap"> 7774 ${u.tier!=='free'?`<span style="font-size:.62rem;padding:2px 7px;border-radius:4px;background:${u.is_paid?'rgba(0,229,180,.15)':'rgba(255,90,90,.12)'};color:${u.is_paid?'var(--green)':'var(--red)'}"> 7775 ${u.is_paid?'â Paid':'â Unpaid'} 7776 </span>`:''} 7777 <span style="font-size:.62rem;padding:2px 7px;border-radius:4px;background:${u.email_verified?'rgba(0,229,180,.15)':'rgba(255,90,90,.12)'};color:${u.email_verified?'var(--green)':'var(--red)'}"> 7778 ${u.email_verified?'â Verified':'â Unverified'} 7779 </span> 7780 <span style="font-size:.62rem;padding:2px 7px;border-radius:4px;background:rgba(45,212,191,.12);color:var(--teal)"> 7781 ð ${(u.level||'beginner').charAt(0).toUpperCase()+(u.level||'beginner').slice(1)} · ${u.xp_total||0} XP 7782 </span> 7783 ${u.is_quick_entry?'<span style="font-size:.62rem;padding:2px 7px;border-radius:4px;background:rgba(226,194,117,.15);color:var(--yellow)">â Quick Entry</span>':''} 7784 ${u.signup_source==='factoz_financials'?'<span style="font-size:.62rem;padding:2px 7px;border-radius:4px;background:rgba(226,194,117,.15);color:var(--teal)">ð Factoz Financials</span>':''} 7785 </div> 7786 </div> 7787 <div style="display:flex;gap:6px;flex-wrap:wrap"> 7788 ${!u.email_verified?` 7789 <button class="btn btn-ghost" style="font-size:.65rem;padding:3px 9px" onclick="toggleUserField('${u.id}','email_verified',true,'${u.email}')">â Verify</button> 7790 <button class="btn btn-ghost" style="font-size:.65rem;padding:3px 9px" onclick="adminResendVerification('${u.id}','${u.email}')">⺠Resend</button> 7791 `:''} 7792 <button class="btn btn-ghost" style="font-size:.65rem;padding:3px 9px" onclick="toggleUserField('${u.id}','is_paid',${!u.is_paid},'${u.email}')">${u.is_paid?'Revoke Pay':'â Activate'}</button> 7793 <button class="btn btn-ghost" style="font-size:.65rem;padding:3px 9px" onclick='openEditUser(${JSON.stringify(u)})'>â Edit</button> 7794 ${USER.tier==='super_admin'&&u.id!==USER.user_id?`<button class="btn btn-ghost" style="font-size:.65rem;padding:3px 9px;color:var(--red)" onclick="deleteUser('${u.id}','${u.email}')">ð Delete</button>`:''} 7795 </div> 7796 </div>`).join(''); 7797 }catch(_){} 7798} 7799 7800async function deleteUser(userId,email){ 7801 if(!confirm(`Permanently delete ${email}? This cannot be undone.`))return; 7802 const res=await api('DELETE',`/api/admin/users/${userId}`); 7803 if(res.ok){toast(`${email} deleted`,'ok');loadAdminUsers();} 7804 else{const d=await res.json();toast(d.detail||'Delete failed','err');} 7805} 7806 7807async function adminResendVerification(userId,email){ 7808 const res=await api('POST',`/api/admin/users/${userId}/resend-verification`); 7809 const d=await res.json(); 7810 if(res.ok)toast(d.message||`Verification email sent to ${email}`,'ok'); 7811 else toast(d.detail||'Resend failed','err'); 7812} 7813async function toggleUserField(userId,field,value,email){ 7814 const label=field==='is_paid'?(value?'activate payment':'revoke payment'):(value?'verify email':'unverify email'); 7815 // When activating payment also set tier to pro; when revoking set tier back to free 7816 const payload = {[field]:value}; 7817 if(field==='is_paid') payload.tier = value ? 'pro' : 'free'; 7818 const res=await api('PUT',`/api/admin/users/${userId}`,payload); 7819 if(res.ok){toast(`${email}: ${label} done`,'ok');loadAdminUsers();} 7820 else{const d=await res.json();toast(d.detail||'Update failed','err');} 7821} 7822 7823// ââ ADMIN CREDITS ââââââââââââââââââââââââââââââ 7824async function loadAdminCredits(){ 7825 try{ 7826 const res=await api('GET','/api/admin/credits'); 7827 if(!res.ok)return; 7828 const d=await res.json(); 7829 7830 $('credits-month').textContent=d.month||''; 7831 $('crd-claude-calls').textContent=d.claude?.calls??'â'; 7832 $('crd-claude-cost').textContent=d.claude?.cost_usd!=null?'$'+d.claude.cost_usd.toFixed(4):'â'; 7833 $('crd-tmpl').textContent=d.templated?.renders??'â'; 7834 const cld=d.cloudinary||{}; 7835 $('crd-cld-storage').textContent=cld.storage_mb!=null?cld.storage_mb+' MB':'â'; 7836 $('crd-cld-res').textContent=cld.resources!=null?cld.resources:'â'; 7837 const sn=d.sonar||{}; 7838 $('crd-sonar-calls').textContent=sn.calls??'â'; 7839 $('crd-sonar-cost').textContent=sn.cost_usd!=null?'$'+sn.cost_usd.toFixed(4):'â'; 7840 const oa=d.openai||{}; 7841 $('crd-openai-calls').textContent=oa.calls??'â'; 7842 $('crd-openai-cost').textContent=oa.cost_usd!=null?'$'+oa.cost_usd.toFixed(4):'â'; 7843 7844 // Detail panel 7845 const c=d.claude||{}; 7846 $('crd-claude-detail').innerHTML= 7847 `Calls: <strong style="color:var(--text)">${c.calls||0}</strong><br>`+ 7848 `Input tokens: <strong style="color:var(--text)">
7848${(c.tokens_in||0).toLocaleString()}</strong><br>`+ 7849 `Output tokens: <strong style="color:var(--text)">${(c.tokens_out||0).toLocaleString()}</strong><br>`+ 7850 `Est. cost: <strong style="color:var(--yellow)">$${(c.cost_usd||0).toFixed(4)}</strong><br>`+ 7851 Object.entries(c.by_action||{}).map(([a,n])=>`${a}: ${n}`).join('<br>'); 7852 7853 $('crd-sonar-detail').innerHTML= 7854 `Calls: <strong style="color:var(--text)">${sn.calls||0}</strong><br>`+ 7855 `Input tokens: <strong style="color:var(--text)">${(sn.tokens_in||0).toLocaleString()}</strong><br>`+ 7856 `Output tokens: <strong style="color:var(--text)">${(sn.tokens_out||0).toLocaleString()}</strong><br>`+ 7857 `Est. cost: <strong style="color:var(--yellow)">$${(sn.cost_usd||0).toFixed(4)}</strong><br>`+ 7858 (Object.entries(sn.by_user||{}).map(([uid,n])=>`${uid.slice(0,20)}â¦: <strong>${n}</strong>`).join('<br>')||'<span style="color:var(--muted)">No extension calls yet</span>'); 7859 7860 $('crd-openai-detail').innerHTML= 7861 `Calls: <strong style="color:var(--text)">${oa.calls||0}</strong><br>`+ 7862 `Est. cost: <strong style="color:var(--yellow)">$${(oa.cost_usd||0).toFixed(4)}</strong><br>`+ 7863 (Object.entries(oa.by_action||{}).map(([a,n])=>`${a}: ${n}`).join('<br>')||'<span style="color:var(--muted)">No DALL-E calls yet</span>'); 7864 7865 const xPerUser=d.x_posts?.per_user||{}; 7866 const xTotal=d.x_posts?.total_this_month||0; 7867 $('crd-x-detail').innerHTML= 7868 `Total: <strong style="color:var(--text)">${xTotal}</strong> posts<br>`+ 7869 (Object.entries(xPerUser).map(([uid,n])=>`${uid.slice(0,20)}â¦: <strong>${n}</strong>`).join('<br>')|| 7870 '<span style="color:var(--muted)">No X posts yet</span>'); 7871 7872 $('credits-detail').style.display='block'; 7873 loadSonarLimits(); 7874 }catch(e){console.error('loadAdminCredits failed',e);} 7875} 7876 7877async function loadSonarLimits(){ 7878 try{ 7879 const res=await api('GET','/api/admin/sonar-limits'); 7880 if(!res.ok)return; 7881 const d=await res.json(); 7882 if($('sonar-limit-beginner')) $('sonar-limit-beginner').value = d.beginner ?? ''; 7883 if($('sonar-limit-intermediate')) $('sonar-limit-intermediate').value = d.intermediate ?? ''; 7884 if($('sonar-limit-advanced')) $('sonar-limit-advanced').value = d.advanced ?? ''; 7885 if($('sonar-limit-expert')) $('sonar-limit-expert').value = d.expert ?? ''; 7886 }catch(e){console.warn('loadSonarLimits failed',e);} 7887} 7888 7889async function saveSonarLimits(){ 7890 const msg=$('sonar-limits-msg'); 7891 const body={}; 7892 const beginner=parseInt($('sonar-limit-beginner')?.value); 7893 const intermediate=parseInt($('sonar-limit-intermediate')?.value); 7894 const advanced=parseInt($('sonar-limit-advanced')?.value); 7895 const expert=parseInt($('sonar-limit-expert')?.value); 7896 if(!isNaN(beginner)) body.beginner=beginner; 7897 if(!isNaN(intermediate)) body.intermediate=intermediate; 7898 if(!isNaN(advanced)) body.advanced=advanced; 7899 if(!isNaN(expert)) body.expert=expert; 7900 try{ 7901 const res=await api('PUT','/api/admin/sonar-limits',body); 7902 if(res.ok){ 7903 if(msg){msg.textContent='Saved â';msg.style.color='var(--green)';setTimeout(()=>{msg.textContent='';},2500);} 7904 } else { 7905 if(msg){msg.textContent='Error saving';msg.style.color='var(--red)';} 7906 } 7907 }catch(e){if(msg){msg.textContent='Error';msg.style.color='var(--red)';}} 7908} 7909 7910// ââ ADMIN ACTIVITY âââââââââââââââââââââââââââââ 7911async function loadAdminActivity(){ 7912 const el=$('admin-activity-list'); 7913 if(!el)return; 7914 try{ 7915 const res=await api('GET','/api/admin/activity'); 7916 const d=await res.json(); 7917 if(!res.ok){el.innerHTML='';return;} 7918 const posts=d.recent_posts||[]; 7919 const signups=d.recent_signups||[]; 7920 let html=''; 7921 if(!posts.length&&!signups.length){el.innerHTML='<div style="font-size:.72rem;color:var(--muted);font-family:var(--mono);padding:8px 0">No activity yet.</div>';return;} 7922 if(signups.length){ 7923 html+='<div style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:6px">RECENT SIGNUPS</div>'; 7924 html+=signups.slice(0,5).map(u=>`<div class="news-admin-row">
7925 <span style="font-size:.8rem">ð¤</span> 7926 <div class="news-admin-title">${u.email}</div> 7927 <span class="badge badge-muted" style="font-size:.55rem">${u.tier||'creator'}</span> 7928 <div class="news-admin-date">${u.created_at?new Date(u.created_at).toLocaleDateString('en-US',{month:'short',day:'numeric'}):''}</div> 7929 </div>`).join(''); 7930 } 7931 if(posts.length){ 7932 html+='<div style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin:12px 0 6px">RECENT POSTS</div>'; 7933 html+=posts.slice(0,10).map(p=>`<div class="news-admin-row"> 7934 <span style="font-size:.8rem">${p.platform==='x'?'ð¦':p.platform==='instagram'?'ð¸':'ð¤'}</span> 7935 <div class="news-admin-title" style="font-size:.7rem">${p.caption||'â'}</div> 7936 <span class="badge ${p.status==='success'?'badge-green':'badge-red'}" style="font-size:.55rem">${p.status}</span> 7937 <div class="news-admin-date">${p.posted_at?new Date(p.posted_at).toLocaleDateString('en-US',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):''}</div> 7938 </div>`).join(''); 7939 } 7940 el.innerHTML=html; 7941 }catch(e){$('admin-activity-list').innerHTML='';} 7942} 7943 7944// ââ DAILY BRIEFS (admin) ââââââââââââââââââââââââ 7945 7946const _previewLock = {}; 7947function _loadingPage(name, est){ 7948 return `<!DOCTYPE html><html><head> 7949 <meta charset="UTF-8"><title>Factoz â Generating ${name} Preview</title> 7950 <style> 7951 *{box-sizing:border-box;margin:0;padding:0} 7952 body{display:flex;flex-direction:column;align-items:center;justify-content:center; 7953 min-height:100vh;background:#0d1526;font-family:Inter,-apple-system,sans-serif;color:#eceef4} 7954 .logo{font-size:26px;font-weight:700;letter-spacing:-1px;color:#eceef4;margin-bottom:36px} 7955 .ring{width:52px;height:52px;border:4px solid rgba(46,204,142,.18); 7956 border-top-color:#2ecc8e;border-radius:50%;animation:spin .85s linear infinite;margin-bottom:28px} 7957 @keyframes spin{to{transform:rotate(360deg)}} 7958 .msg{font-size:15px;color:#8b93a8;margin-bottom:8px} 7959 .est{font-size:12px;color:#55607a;letter-spacing:.5px} 7960 </style> 7961 </head><body> 7962 <div class="logo">FACTOZ</div> 7963 <div class="ring"></div> 7964 <p class="msg">Generating ${name} previewâ¦</p> 7965 <p class="est">Claude is writing â ${est}</p> 7966 </body></html>`; 7967} 7968 7969async function previewEmailInTab(type){ 7970 if(_previewLock[type]){ toast('Already generating â please wait','warning'); return; } 7971 7972 const names = { daily:'Daily', weekly:'Weekly', monthly:'Monthly' }; 7973 const ests = { daily:'usually a few seconds', weekly:'usually ~30 seconds', monthly:'usually ~45 seconds' }; 7974 const btnId = { daily:'btn-preview-daily', weekly:'btn-preview-weekly', monthly:'btn-preview-monthly' }; 7975 const btn = document.getElementById(btnId[type]); 7976 const orig = btn ? btn.innerHTML : ''; 7977 7978 // Daily still works as a fast direct fetch 7979 if(type === 'daily'){ 7980 const tab = window.open('','_blank'); 7981 if(!tab){ toast('Pop-up blocked','error'); return; } 7982 tab.document.write(_loadingPage('Daily','usually a few seconds')); 7983 tab.document.close(); 7984 if(btn){ btn.disabled=true; btn.innerHTML='â³ Loadingâ¦'; } 7985 try{ 7986 const res = await api('GET','/api/briefs/preview/daily-email'); 7987 if(!res.ok){ let m=`Preview failed (${res.status})`; try{const e=await res.json();m=e.detail||m;}catch{} tab.document.open();tab.document.write(`<body style="font-family:sans-serif;padding:48px;color:#c00"><h3>Preview failed</h3><p>${m}</p></body>`);tab.document.close();toast(m,'error');return;} 7988 const html=await res.text();tab.document.open();tab.document.write(html);tab.document.close(); 7989 }finally{ if(btn){btn.disabled=false;btn.innerHTML=orig;} } 7990 return; 7991 } 7992 7993 // Weekly / Monthly â background task + polling 7994 _previewLock[type] = true; 7995 if(btn){ btn.disabled=true; btn.innerHTML='â³ Generatingâ¦'; } 7996 const tab = window.open('','_blank'); 7997 if(!tab){ _previewLock[type]=false; if(btn){btn.disabled=false;btn.innerHTML=orig;} toast('Pop-up blocked','error'); return; } 7998 tab.document.write(_loadingPage(names[type], ests[type])); 7999 tab.document.close(); 8000 8001 try{ 8002 // Start background task 8003 const startRes = await api('POST', `/api/briefs/preview/${type}/start`); 8004 if(!startRes.ok){ throw new Error(`Start failed (${startRes.status})`); } 8005 8006 // Poll every 4 seconds until ready or error 8007 while(true){ 8008 await new Promise(r => setTimeout(r, 4000)); 8009 const pollRes = await api('GET', `/api/briefs/preview/${type}/status`); 8010 if(!pollRes.ok) throw new Error(`Poll failed (${pollRes.status})`); 8011 const {status, error} = await pollRes.json(); 8012 if(status === 'ready') break; 8013 if(status === 'error') throw new Error(error || 'Generation failed'); 8014 } 8015 8016 // Fetch the cached HTML 8017 const htmlRes = await api('GET', `/api/briefs/preview/${type}/html`); 8018 if(!htmlRes.ok) throw new Error(`HTML fetch failed (${htmlRes.status})`); 8019 const html = await htmlRes.text(); 8020 tab.document.open(); 8021 tab.document.write(html); 8022 tab.document.close(); 8023 8024 }catch(err){ 8025 const msg = err.message || 'Preview failed'; 8026 tab.document.open(); 8027 tab.document.write(`<!DOCTYPE html><html><body style="font-family:sans-serif;padding:48px;color:#c00"><h3>Preview failed</h3><p>${msg}</p></body></html>`); 8028 tab.document.close(); 8029 toast(msg,'error'); 8030 }finally{ 8031 _previewLock[type] = false;
8032 if(btn){ btn.disabled=false; btn.innerHTML=orig; } 8033 } 8034} 8035 8036async function adminRunJob(jobName,btn,successLabel){ 8037 const orig=btn?btn.textContent:''; 8038 if(btn){btn.disabled=true;btn.textContent='â³ Runningâ¦';} 8039 try{ 8040 const res=await api('POST',`/api/admin/run-job?job=${encodeURIComponent(jobName)}`); 8041 const d=await res.json(); 8042 if(res.ok)toast(successLabel||'Job triggered','ok'); 8043 else toast(d.detail||'Job failed','err'); 8044 }catch(e){ 8045 toast('Job failed','err'); 8046 }finally{ 8047 if(btn){btn.disabled=false;btn.textContent=orig;} 8048 } 8049} 8050async function adminGenerateBrief(){ 8051 const btn = document.querySelector('[onclick*="adminGenerateBrief"]'); 8052 const resultEl = $('brief-gen-result'); 8053 if(btn){ btn.disabled=true; btn.textContent='â³ Startingâ¦'; } 8054 if(resultEl) resultEl.innerHTML='<span style="color:var(--muted);font-size:.78rem">Starting generationâ¦</span>'; 8055 try{ 8056 const res = await api('POST','/api/briefs/generate'); 8057 const d = await res.json(); 8058 if(!res.ok){ 8059 if(resultEl) resultEl.innerHTML=`<span style="color:var(--red);font-size:.78rem">â ${d.detail||'Failed to start'}</span>`; 8060 if(btn){ btn.disabled=false; btn.textContent='â¡ Generate Today\'s Brief'; } 8061 return; 8062 } 8063 if(d.status==='running'){ 8064 if(resultEl) resultEl.innerHTML=`<span style="color:var(--amber);font-size:.78rem">â³ ${d.message}</span>`; 8065 if(btn){ btn.disabled=false; btn.textContent='â¡ Generate Today\'s Brief'; } 8066 return; 8067 } 8068 // Poll until done 8069 if(resultEl) resultEl.innerHTML='<span style="color:var(--muted);font-size:.78rem">â³ Claude is writing the brief â checking every 10 secondsâ¦</span>'; 8070 if(btn) btn.textContent='â³ Generatingâ¦'; 8071 let attempts = 0; 8072 const poll = setInterval(async ()=>{ 8073 attempts++;
8074 try{ 8075 const sr = await api('GET','/api/briefs/generate/status'); 8076 const sd = await sr.json(); 8077 if(sd.last_error){ 8078 clearInterval(poll); 8079 if(resultEl) resultEl.innerHTML=`<span style="color:var(--red);font-size:.78rem">â ${sd.last_error}</span>`; 8080 if(btn){ btn.disabled=false; btn.textContent='â¡ Generate Today\'s Brief'; } 8081 } else if(!sd.running){ 8082 clearInterval(poll); 8083 const sent = sd.emails_sent!=null?` · ${sd.emails_sent} email(s) sent`:''; 8084 const xLine = sd.x_url?`<br><a href="${sd.x_url}" target="_blank" style="color:#1DA1F2;font-size:.75rem">ð¦ Posted to X â</a>`: 8085 sd.x_error?`<br><span style="color:var(--amber);font-size:.72rem">â ï¸ X post failed: ${sd.x_error}</span>`:''; 8086 const dlBtn = `<br><button class="btn btn-ghost" style="font-size:.7rem;padding:4px 12px;margin-top:6px" onclick="downloadBriefCard()">⬠Download Card PNG</button>`; 8087 if(resultEl) resultEl.innerHTML=`<span style="color:var(--green);font-size:.78rem">â Brief generated successfully${sent}</span>${xLine}${dlBtn}`; 8088 if(btn){ btn.disabled=false; btn.textContent='â¡ Generate Today\'s Brief'; } 8089 loadAdminBriefs(); 8090 } else if(attempts >= 18){ 8091 clearInterval(poll); 8092 if(resultEl) resultEl.innerHTML='<span style="color:var(--amber);font-size:.78rem">â³ Still generating â refresh the list manually in a moment.</span>'; 8093 if(btn){ btn.disabled=false; btn.textContent='â¡ Generate Today\'s Brief'; } 8094 loadAdminBriefs(); 8095 } 8096 } catch(e){ clearInterval(poll); } 8097 }, 10000); 8098 } catch(e){ 8099 if(resultEl) resultEl.innerHTML='<span style="color:var(--red);font-size:.78rem">â Request failed</span>'; 8100 if(btn){ btn.disabled=false; btn.textContent='â¡ Generate Today\'s Brief'; } 8101 } 8102} 8103 8104// ââ NEWSLETTER SUBSCRIBERS ââââââââââââââââââââââââââââââââââââââââââââââââââ 8105 8106/* Built from the current origin rather than hardcoded, so it stays correct 8107 on the Railway URL, the custom domain, and locally without edits. */ 8108function _signupUrl(){ return location.origin + '/suscribete'; } 8109 8110function _renderSignupUrl(){ 8111 const el=$('sub-signup-url'), link=$('sub-signup-open'); 8112 if(el) el.textContent=_signupUrl(); 8113 if(link) link.href=_signupUrl(); 8114} 8115 8116async function copySignupUrl(btn){ 8117 const url=_signupUrl(); 8118 const done=()=>{ if(btn){const o=btn.textContent;btn.textContent='â Copiado';setTimeout(()=>btn.textContent=o,1600);} }; 8119 try{ 8120 await navigator.clipboard.writeText(url); done(); 8121 }catch(e){ 8122 // clipboard API needs HTTPS and a user gesture; fall back so the 8123 // button still works rather than silently doing nothing. 8124 const t=document.createElement('textarea'); 8125 t.value=url; document.body.appendChild(t); t.select(); 8126 try{ document.execCommand('copy'); done(); }catch(_){ prompt('Copia el link:', url); } 8127 document.body.removeChild(t); 8128 } 8129} 8130 8131async function loadSubscriberList(){ 8132 _renderSignupUrl(); 8133 const el=$('sub-list'), badge=$('as-subscribers-badge'); 8134 if(!el) return; 8135 try{ 8136 const r=await api('GET','/api/admin/newsletter/subscribers'); 8137 const d=await r.json(); 8138 if(badge) badge.textContent=`(${d.active} active)`; 8139 if(!d.subscribers?.length){ 8140 el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">No subscribers yet.</div>'; 8141 return; 8142 } 8143 el.innerHTML=d.subscribers.map(s=>` 8144 <div style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg3);border:1px solid var(--border);border-radius:5px;margin-bottom:4px;font-size:.73rem"> 8145 <span style="flex:1;color:${s.is_active?'var(--text)':'var(--muted)'};${s.is_active?'':'text-decoration:line-through'}">${s.email}</span> 8146 <span style="font-size:.6rem;color:var(--muted);font-family:var(--mono)">${s.subscribed_at}</span> 8147 ${s.is_active?`<button class="btn btn-ghost" style="font-size:.6rem;padding:2px 8px;color:var(--red)" onclick="removeSubscriber('${s.email}')">Remove</button>`:'<span style="font-size:.6rem;color:var(--red)">Inactive</span>'} 8148 </div>`).join(''); 8149 }catch(e){el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">Failed to load.</div>';} 8150} 8151 8152async function addSubscriber(){ 8153 const input=$('sub-email-input'); 8154 const result=$('sub-add-result'); 8155 const email=(input?.value||'').trim(); 8156 if(!email){if(result)result.innerHTML='<span style="color:var(--red)">Enter an email address.</span>';return;} 8157 const r=await api('POST','/api/admin/newsletter/subscribers',{email}); 8158 const d=await r.json(); 8159 if(r.ok){ 8160 if(result)result.innerHTML=`<span style="color:var(--green)">â ${d.added} added, ${d.skipped} already subscribed.</span>`;
8161 if(input)input.value=''; 8162 loadSubscriberList(); 8163 } else { 8164 if(result)result.innerHTML=`<span style="color:var(--red)">â ${d.detail||'Error'}</span>`; 8165 } 8166} 8167 8168async function addSubscribersBulk(){ 8169 const textarea=$('sub-bulk-input'); 8170 const result=$('sub-add-result'); 8171 const raw=(textarea?.value||''); 8172 const emails=raw.split(/[\n,]+/).map(e=>e.trim()).filter(e=>e&&e.includes('@')); 8173 if(!emails.length){if(result)result.innerHTML='<span style="color:var(--red)">No valid emails found.</span>';return;} 8174 const r=await api('POST','/api/admin/newsletter/subscribers',{emails}); 8175 const d=await r.json(); 8176 if(r.ok){ 8177 if(result)result.innerHTML=`<span style="color:var(--green)">â ${d.added} added, ${d.skipped} already subscribed.</span>`; 8178 if(textarea)textarea.value=''; 8179 loadSubscriberList(); 8180 } else { 8181 if(result)result.innerHTML=`<span style="color:var(--red)">â ${d.detail||'Error'}</span>`; 8182 } 8183} 8184 8185async function removeSubscriber(email){ 8186 if(!confirm(`Remove ${email} from newsletter?`)) return; 8187 const r=await api('DELETE',`/api/admin/newsletter/subscribers/${encodeURIComponent(email)}`); 8188 if(r.ok) loadSubscriberList(); 8189 else toast('Failed to remove subscriber','error'); 8190} 8191 8192async function downloadBriefCard(){ 8193 try{ 8194 const res = await api('GET', '/api/briefs/card/download'); 8195 if(!res.ok){ 8196 const d = await res.json(); 8197 toast(d.detail || 'Card not available â generate a brief first', 'error'); 8198 return; 8199 } 8200 const blob = await res.blob(); 8201 const url = URL.createObjectURL(blob); 8202 const a = document.createElement('a'); 8203 a.href = url; 8204 a.download = `factoz_brief_${new Date().toISOString().slice(0,10)}.png`; 8205 document.body.appendChild(a); 8206 a.click(); 8207 document.body.removeChild(a); 8208 URL.revokeObjectURL(url); 8209 } catch(e){ 8210 toast('Download failed', 'error'); 8211 } 8212} 8213 8214async function loadAdminBriefs(){ 8215 const el = $('brief-list'); 8216 if(!el) return; 8217 try{ 8218 const res = await api('GET','/api/briefs?limit=10'); 8219 const d = await res.json(); 8220 if(!res.ok || !d.length){ 8221 el.innerHTML='<div style="font-size:.72rem;color:var(--muted);font-family:var(--mono);padding:8px 0">No briefs generated yet.</div>'; 8222 return; 8223 } 8224 el.innerHTML = d.map(b => ` 8225 <div class="news-admin-row" style="justify-content:space-between"> 8226 <span style="font-size:.8rem">ð°</span> 8227 <div class="news-admin-title">${b.title||b.edition_date}</div> 8228 <span class="badge badge-muted" style="font-size:.55rem;text-transform:uppercase">${b.edition_type}</span> 8229 <div class="news-admin-date">${b.edition_date}</div> 8230 <button class="btn btn-ghost" style="font-size:.6rem;padding:2px 8px;flex-shrink:0" 8231 onclick="previewBrief('${b.id}','${b.edition_date}')">Preview</button> 8232 ${b.edition_date===new Date().toISOString().slice(0,10)? 8233 `<button class="btn btn-ghost" style="font-size:.6rem;padding:2px 8px;flex-shrink:0" onclick="downloadBriefCard()">⬠Card</button>`:''} 8234 </div>`).join(''); 8235 } catch(e){ 8236 el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">Failed to load briefs.</div>'; 8237 } 8238} 8239 8240async function previewBrief(id, date){ 8241 const wrap = $('brief-preview-wrap'); 8242 const content = $('brief-preview-content'); 8243 if(!wrap||!content) return; 8244 content.innerHTML='<div style="color:#fff;padding:40px;text-align:center">Loadingâ¦</div>'; 8245 wrap.style.display='block'; 8246 try{ 8247 const res = await api('GET',`/api/briefs/${date}`); 8248 const d = await res.json(); 8249 if(res.ok){ 8250 content.innerHTML = d.html_content; 8251 } else { 8252 content.innerHTML=`<div style="color:#e05252;padding:20px">${d.detail||'Not found'}</div>`; 8253 } 8254 } catch(e){ 8255 content.innerHTML='<div style="color:#e05252;padding:20px">Failed to load brief.</div>'; 8256 } 8257} 8258 8259// ââ PERSON LIBRARY ââââââââââââââââââââââââââââââ 8260async function loadPersons(){ 8261 const el=$('person-grid'); 8262 if(!el)return; 8263 try{ 8264 const res=await api('GET','/api/carousel/persons'); 8265 const d=await res.json(); 8266 if(!res.ok||!d.persons.length){ 8267 el.innerHTML='<div style="font-size:.73rem;color:var(--muted);font-family:var(--mono);padding:8px 0;grid-column:1/-1">No persons uploaded yet.</div>'; 8268 return; 8269 } 8270 el.innerHTML=d.persons.map(p=>` 8271 <div style="background:var(--bg3);border:1px solid var(--border);border-radius:9px;overflow:hidden;position:relative;text-align:center"> 8272 <div style="font-size:2.4rem;padding:24px 0 8px;background:var(--bg4)">ð¤</div> 8273 <div style="padding:8px 10px 10px"> 8274 <div style="font-weight:700;font-size:.78rem;color:#e2eaf2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${p.name}</div> 8275 <div style="font-size:.6rem;color:var(--muted);font-family:var(--mono);margin-top:2px">${p.filename}</div> 8276 <button class="btn btn-ghost" style="font-size:.62rem;padding:4px 8px;margin-top:8px;color:var(--red);border-color:rgba(255,68,68,.3)" onclick="deletePerson('${p.id}','${p.name}')">ð Remove</button> 8277 </div> 8278 </div>`).join(''); 8279 }catch(e){el.innerHTML='';} 8280} 8281 8282async function uploadPerson(){ 8283 const name=$('person-name').value.trim(); 8284 const file=$('person-file').files[0]; 8285 if(!name){toast('Enter a display name','err');return;} 8286 if(!file){toast('Select a photo','err');return;} 8287 const fd=new FormData(); 8288 fd.append('file',file); 8289 fd.append('display_name',name); 8290 const res=await fetch('/api/carousel/persons/upload',{method:'POST',headers:hdr(),body:fd}); 8291 const d=await res.json(); 8292 if(res.ok){ 8293 $('person-upload-result').innerHTML=`<span style="color:var(--green);font-size:.75rem">â Uploaded: ${d.name}</span>`;
8294 $('person-name').value=''; 8295 $('person-file').value=''; 8296 loadPersons(); 8297 }else{ 8298 $('person-upload-result').innerHTML=`<span style="color:var(--red);font-size:.75rem">${d.detail||'Upload failed'}</span>`; 8299 } 8300} 8301 8302async function deletePerson(id,name){ 8303 if(!confirm(`Remove "${name}" from the person library?`))return; 8304 const res=await api('DELETE',`/api/carousel/persons/${id}`); 8305 if(res.ok){toast(`Removed ${name}`,'ok');loadPersons();} 8306 else toast('Delete failed','err'); 8307} 8308 8309// âââ Movers âââââââââââââââââââââââââââââââââââââââââââââââââ 8310async function loadMovers(){ 8311 const grid=$('movers-grid'); 8312 const meta=$('movers-meta'); 8313 if(!grid)return; 8314 grid.innerHTML='<p style="font-size:.8rem;color:var(--text2);grid-column:1/-1">Loading...</p>'; 8315 try{ 8316 const r=await api('GET','/api/movers/today'); 8317 if(!r.ok){ 8318 const d=await r.json(); 8319 grid.innerHTML=`<p style="font-size:.8rem;color:var(--text2);grid-column:1/-1">${d.detail||'No movers data yet â check back after 8am EST'}</p>`; 8320 return; 8321 } 8322 const d=await r.json(); 8323 if(meta) meta.textContent=`${d.date} · ${(d.total_scanned||0).toLocaleString()} tickers scanned · fetched ${new Date(d.fetched_at).toLocaleTimeString()}`; 8324 const row=(m,color)=>` 8325 <div style="display:flex;align-items:center;gap:10px;padding:8px 10px;background:var(--bg3);border-radius:6px;font-size:.78rem"> 8326 <span style="font-family:var(--mono);font-weight:700;color:#e2eaf2;min-width:52px">${m.ticker}</span> 8327 <span style="flex:1;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${m.name||''}</span> 8328 <span style="font-family:var(--mono);font-weight:700;color:${color};white-space:nowrap">${m.pct>=0?'+':''}${m.pct.toFixed(2)}%</span> 8329 <span style="font-family:var(--mono);color:var(--muted);white-space:nowrap">$${m.price.toFixed(2)}</span> 8330 </div>`; 8331 const gainersHtml=(d.gainers||[]).map(m=>row(m,'#00c853')).join(''); 8332 const losersHtml=(d.losers||[]).map(m=>row(m,'#f23645')).join(''); 8333 grid.innerHTML=` 8334 <div> 8335 <div style="font-size:.6rem;font-family:var(--mono);letter-spacing:2px;color:var(--muted);margin-bottom:8px;color:#00c853">TOP GAINERS</div> 8336 <div style="display:flex;flex-direction:column;gap:4px">${gainersHtml||'<span style="font-size:.75rem;color:var(--muted)">No data</span>'}</div> 8337 </div> 8338 <div> 8339 <div style="font-size:.6rem;font-family:var(--mono);letter-spacing:2px;color:#f23645;margin-bottom:8px">TOP LOSERS</div> 8340 <div style="display:flex;flex-direction:column;gap:4px">${losersHtml||'<span style="font-size:.75rem;color:var(--muted)">No data</span>'}</div> 8341 </div>`; 8342 }catch(e){grid.innerHTML=`<p style="font-size:.8rem;color:var(--red);grid-column:1/-1">Error: ${e.message}</p>`;} 8343} 8344 8345async function loadSessionPrints(){ 8346 const el=$('ov-session-prints'); 8347 if(!el)return; 8348 try{ 8349 const r=await api('GET','/api/te/session-prints'); 8350 if(!r.ok){ 8351 const e=await r.json().catch(()=>({})); 8352 el.innerHTML=`<div style="font-size:.72rem;color:var(--muted)">${e.detail||'Data unavailable.'}</div>`; 8353 return; 8354 } 8355 const d=await r.json(); 8356 const items=d.items||[]; 8357 if(!items.length){ 8358 el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">No major releases today.</div>'; 8359 return; 8360 } 8361 el.innerHTML='<div style="font-size:.58rem;font-family:var(--mono);letter-spacing:1px;color:var(--muted);margin-bottom:4px">ðºð¸ US FRED DATA</div>'+ 8362 items.map(item=>{ 8363 const act=parseFloat(item.actual),prev=parseFloat(item.previous); 8364 const up=!isNaN(act)&&!isNaN(prev)&&act>prev; 8365 const dn=!isNaN(act)&&!isNaN(prev)&&act<prev; 8366 const color=up?'var(--green)':dn?'var(--red)':'var(--text)'; 8367 return `<div style="display:flex;align-items:center;gap:6px;padding:4px 8px;background:var(--bg3);border:1px solid var(--border);border-radius:5px;margin-bottom:3px;font-size:.68rem">
8368 <span style="flex:1;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${item.event}</span> 8369 <span style="font-family:var(--mono);font-weight:700;color:${color};white-space:nowrap">${item.actual}</span> 8370 <span style="color:var(--muted);font-family:var(--mono);white-space:nowrap;font-size:.6rem">prev ${item.previous}</span> 8371 </div>`; 8372 }).join(''); 8373 }catch(e){el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">â</div>';} 8374} 8375 8376async function loadAnalystRatings(){ 8377 const el=$('ov-analyst-panel'); if(!el)return; 8378 let d=null; 8379 try{ 8380 const r=await api('GET','/api/news/analyst-ratings'); 8381 if(r.ok)d=await r.json(); 8382 }catch(e){} 8383 if(!d||!d.ratings||!d.ratings.length){el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">No ratings available.</div>';return;} 8384 const actionColor=a=>a==='upgrade'?'var(--green)':a==='downgrade'?'var(--red)':'var(--muted)'; 8385 const actionLabel=a=>a==='upgrade'?'â² UPGRADE':a==='downgrade'?'â¼ DOWNGRADE':a.toUpperCase(); 8386 el.innerHTML=d.ratings.slice(0,8).map(r=>` 8387 <div onclick="openTickerModal('${r.ticker}')" class="cluster-row" style="cursor:pointer;gap:6px;padding:5px 0;border-bottom:1px solid var(--border);transition:opacity .15s" onmouseover="this.style.opacity='.75'" onmouseout="this.style.opacity='1'"> 8388 <span style="font-weight:700;font-size:.75rem;color:var(--text);min-width:42px">${r.ticker}</span> 8389 <span style="font-size:.6rem;font-weight:700;color:${actionColor(r.action)};min-width:80px">${actionLabel(r.action)}</span> 8390 <span style="font-size:.62rem;color:var(--text2);flex:1;overflow:hidden;white-space:nowrap;text-overflow:ellipsis">${r.firm}</span> 8391 <span style="font-size:.6rem;color:var(--muted);white-space:nowrap">${r.date?r.date.slice(5):''}</span> 8392 </div>`).join(''); 8393} 8394 8395// ââ Ad Slots ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 8396let _ads = {}; 8397 8398async function loadAds(){ 8399 try{ 8400 const resp = await fetch('/api/ads'); 8401 if(!resp.ok) return; 8402 _ads = await resp.json(); 8403 // Render each known slot 8404 const slots = { 8405 'overview-top': 'ad-overview-top', 8406 'news-sidebar': 'ad-news-sidebar', 8407 'scanner-bottom': 'ad-scanner-bottom', 8408 'carousel-side': 'ad-carousel-side', 8409 }; 8410 for(const [name, elId] of Object.entries(slots)){ 8411 const el = document.getElementById(elId); 8412 if(!el) continue; 8413 if(_ads[name]){ 8414 const a = _ads[name]; 8415 el.innerHTML = `<a href="${a.link_url||'#'}" target="_blank" rel="noopener sponsored" style="display:block"> 8416 <img src="${a.image_url}" alt="${a.label||'Advertisement'}" style="width:100%;border-radius:8px;display:block"> 8417 </a>`; 8418 } else { 8419 el.innerHTML = ''; 8420 } 8421 } 8422 }catch(e){} 8423} 8424 8425async function loadAdminAds(){ 8426 const list = $('ad-slot-list'); if(!list) return; 8427 const d = await api('GET','/api/admin/ads'); 8428 if(!d||!d.slots||!d.slots.length){ 8429 list.innerHTML='<div style="font-size:.75rem;color:var(--muted);padding:10px 0">No ad slots yet. Click "+ New Ad" to add one.</div>'; 8430 return; 8431 } 8432 list.innerHTML = d.slots.map(s=>` 8433 <div style="display:flex;align-items:center;gap:12px;padding:10px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;margin-bottom:8px"> 8434 <img src="${s.image_url}" style="width:80px;height:40px;object-fit:cover;border-radius:4px;flex-shrink:0"> 8435 <div style="flex:1;min-width:0"> 8436 <div style="font-size:.75rem;font-weight:700;color:var(--text)">${s.slot_name}</div> 8437 ${s.label?`<div style="font-size:.65rem;color:var(--muted)">${s.label}</div>`:''} 8438 ${s.link_url?`<div style="font-size:.65rem;color:var(--teal);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${s.link_url}</div>`:''} 8439 </div> 8440 <div style="display:flex;align-items:center;gap:6px;flex-shrink:0"> 8441 <button onclick="toggleAdSlot('${s.id}',${!s.is_active})" class="btn btn-ghost" style="font-size:.65rem;padding:3px 8px;color:${s.is_active?'var(--green)':'var(--muted)'}">${s.is_active?'â Live':'â Off'}</button> 8442 <button onclick="deleteAdSlot('${s.id}')" class="btn btn-ghost" style="font-size:.65rem;padding:3px 8px;color:var(--red)">â</button> 8443 </div> 8444 </div>`).join(''); 8445} 8446 8447async function uploadAdSlot(){ 8448 const btn=$('ad-upload-btn'), res=$('ad-upload-result'); 8449 const file = $('ad-file').files[0]; 8450 const slot_name = $('ad-slot-name').value; 8451 const link_url = $('ad-link-url').value.trim(); 8452 const label = $('ad-label').value.trim(); 8453 if(!file){res.innerHTML='<span style="color:var(--red)">Select an image file.</span>';return;} 8454 btn.disabled=true; btn.textContent='Uploadingâ¦'; 8455 const fd = new FormData(); 8456 fd.append('file', file); 8457 fd.append('slot_name', slot_name); 8458 fd.append('link_url', link_url); 8459 fd.append('label', label); 8460 try{ 8461 const resp = await fetch('/api/admin/ads',{method:'POST',headers:{'Authorization':'Bearer '+_token},body:fd}); 8462 const data = await resp.json(); 8463 if(!resp.ok) throw new Error(data.detail||'Upload failed'); 8464 res.innerHTML='<span style="color:var(--green)">â Ad slot created.</span>'; 8465 $('ad-upload-panel').style.display='none'; 8466 loadAdminAds(); 8467 loadAds(); 8468 }catch(e){res.innerHTML=`<span style="color:var(--red)">${e.message}</span>`;} 8469 finally{btn.disabled=false;btn.textContent='ð¤ Upload Ad';} 8470} 8471 8472async function toggleAdSlot(id, newState){ 8473 await api('PATCH',`/api/admin/ads/${id}?is_active=${newState}`); 8474 loadAdminAds(); 8475 loadAds(); 8476} 8477 8478async function deleteAdSlot(id){ 8479 if(!confirm('Delete this ad slot? This cannot be undone.')) return; 8480 await fetch(`/api/admin/ads/${id}`,{method:'DELETE',headers:{'Authorization':'Bearer '+_token}}); 8481 loadAdminAds(); 8482 loadAds(); 8483} 8484 8485async function loadOptionsFlow(){ 8486 const el=$('ov-options-panel'); 8487 const badge=$('ov-options-badge'); 8488 if(!el)return; 8489 try{ 8490 const r=await api('GET','/api/options/flow'); 8491 if(!r.ok){el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">No options data yet â scanner runs MonâFri 4Ã/day.</div>';return;} 8492 const d=await r.json(); 8493 const items=(d.items||[]).slice(0,8); 8494 if(badge){badge.textContent=`${d.count} hits`;badge.style.display=d.count?'inline':'none';} 8495 if(!items.length){ 8496 el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">No unusual activity detected yet today.</div>'; 8497 return; 8498 } 8499 el.innerHTML=items.map(o=>{ 8500 const isCall=o.option_type==='call'; 8501 const premium=o.premium_usd>
8501=1e6?`$${(o.premium_usd/1e6).toFixed(1)}M`:o.premium_usd>=1e3?`$${(o.premium_usd/1e3).toFixed(0)}K`:`$${o.premium_usd.toFixed(0)}`; 8502 const ratio=o.vol_oi_ratio?`${o.vol_oi_ratio.toFixed(1)}Ã`:'â'; 8503 const expiry=o.expiry?o.expiry.slice(5):'â'; 8504 return `<div onclick="go('options')" style="cursor:pointer;display:flex;align-items:center;gap:6px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);border-radius:5px;margin-bottom:4px;font-size:.7rem"> 8505 <span style="font-weight:700;font-family:var(--mono);min-width:44px;color:var(--text)">${o.ticker}</span> 8506 <span style="padding:1px 6px;border-radius:3px;font-size:.6rem;font-weight:700;background:${isCall?'rgba(46,204,142,.15)':'rgba(224,82,82,.15)'};color:${isCall?'#2ecc8e':'#e05252'}">${o.option_type.toUpperCase()}</span> 8507 <span style="color:var(--text2);font-family:var(--mono);font-size:.68rem">$${o.strike} · ${expiry}</span> 8508 <div style="margin-left:auto;text-align:right;line-height:1.3"> 8509 <div style="color:var(--text);font-family:var(--mono);font-size:.7rem;font-weight:600">${premium}</div> 8510 <div style="color:var(--muted);font-size:.6rem">${ratio} vol/oi</div> 8511 </div> 8512 </div>`; 8513 }).join(''); 8514 }catch(e){el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">â</div>';} 8515} 8516 8517let _optSortCol='premium_usd', _optSortDir=-1; 8518 8519function optSetType(t){ 8520 ['all','call','put'].forEach(x=>{ const b=$('opt-f-'+x); if(b){b.style.background='transparent';b.style.color='var(--text2)';b.style.fontWeight='';} }); 8521 const active=$('opt-f-'+(t||'all')); 8522 if(active){active.style.background='var(--teal)';active.style.color='#000';active.style.fontWeight='700';} 8523 $('opt-f-ticker') && ($('opt-f-ticker').value=''); 8524 loadOptionsPage(); 8525} 8526 8527function _fmtPremium(v){return v>=1e6?`$${(v/1e6).toFixed(2)}M`:v>=1e3?`$${(v/1e3).toFixed(0)}K`:`$${(v||0).toFixed(0)}`;} 8528function _fmtNum(v){return v==null?'â':v>=1e6?`${(v/1e6).toFixed(1)}M`:v>=1e3?`${(v/1e3).toFixed(0)}K`:String(v);} 8529 8530function _optSort(col){ 8531 if(_optSortCol===col) _optSortDir*=-1; else { _optSortCol=col; _optSortDir=-1; } 8532 document.querySelectorAll('.opt-th').forEach(th=>{ th.style.color='var(--muted)'; th.textContent=th.dataset.label; }); 8533 const th=document.querySelector(`.opt-th[data-col="${col}"]`); 8534 if(th){th.style.color='var(--teal)';th.textContent=th.dataset.label+(_optSortDir===-1?' â':' â');} 8535 const tbody=document.querySelector('#opt-page-panel tbody'); 8536 if(!tbody)return; 8537 const rows=[...tbody.querySelectorAll('tr')]; 8538 rows.sort((a,b)=>{ 8539 const av=parseFloat(a.dataset[col]||0), bv=parseFloat(b.dataset[col]||0); 8540 return _optSortDir*(av-bv); 8541 }); 8542 rows.forEach(r=>tbody.appendChild(r)); 8543} 8544 8545async function loadOptionsPage(){ 8546 const el=$('opt-page-panel'), cnt=$('opt-page-count'); 8547 if(!el)return; 8548 8549 const typeFilter = (['call','put'].includes(document.querySelector('.opt-f-active')?.dataset?.t||'')) 8550 ? document.querySelector('.opt-f-active').dataset.t : ''; 8551 const tickerFilter = ($('opt-f-ticker')?.value||'').trim().toUpperCase(); 8552 const minPremium = parseFloat($('opt-f-premium')?.value||0)||0; 8553 const hours = parseInt($('opt-f-hours')?.value||24)||24; 8554 8555 // determine active type filter from button styles 8556 let activeType=''; 8557 ['call','put'].forEach(t=>{ const b=$('opt-f-'+t); if(b&&b.style.background&&b.style.background!=='transparent')activeType=t; }); 8558 8559 let url=`/api/options/flow?limit=500&hours=${hours}`; 8560 if(activeType) url+=`&option_type=${activeType}`; 8561 if(tickerFilter) url+=`&ticker=${tickerFilter}`; 8562 if(minPremium>0) url+=`&min_premium=${minPremium}`; 8563 8564 try{ 8565 const r=await api('GET',url); 8566 if(!r.ok){el.innerHTML='<div style="color:var(--muted);font-size:.8rem;padding:20px 0">No options data yet â scanner runs MonâFri 4Ã/day.</div>';return;} 8567 const d=await r.json(); 8568 const items=d.items||[]; 8569 if(cnt) cnt.textContent=`${items.length} contracts`; 8570 8571 // Sentiment bar 8572 const sb=$('opt-sentiment-bar'); 8573 if(sb && (d.call_premium||d.put_premium)){ 8574 sb.style.display='flex'; 8575 $('opt-call-total').textContent=_fmtPremium(d.call_premium); 8576 $('opt-put-total').textContent=_fmtPremium(d.put_premium); 8577 const bullish=d.call_premium>d.put_premium; 8578 const ratio=d.call_premium&&d.put_premium?(Math.max(d.call_premium,d.put_premium)/Math.min(d.call_premium,d.put_premium)).toFixed(1):'â'; 8579 const sl=$('opt-sentiment-label'); 8580 sl.textContent=bullish?`BULLISH ${ratio}Ã`:`BEARISH ${ratio}Ã`; 8581 sl.style.color=bullish?'var(--green)':'var(--red)'; 8582 } 8583 8584 if(!items.length){el.innerHTML='<div style="color:var(--muted);font-size:.8rem;padding:20px 0">
8584No unusual activity for the selected filters.</div>';return;} 8585 8586 const thStyle='padding:7px 8px;cursor:pointer;white-space:nowrap;user-select:none;font-family:var(--mono);font-size:.62rem;text-transform:uppercase;color:var(--muted)'; 8587 el.innerHTML=`<table style="width:100%;border-collapse:collapse;font-size:.78rem;min-width:860px"> 8588 <thead><tr style="border-bottom:1px solid var(--border)"> 8589 <th style="${thStyle};text-align:left" class="opt-th" data-col="ticker" data-label="Ticker" onclick="_optSort('ticker')">Ticker</th> 8590 <th style="${thStyle};text-align:left" class="opt-th" data-col="option_type" data-label="Type" onclick="_optSort('option_type')">Type</th> 8591 <th style="${thStyle};text-align:right" class="opt-th" data-col="strike" data-label="Strike" onclick="_optSort('strike')">Strike</th> 8592 <th style="${thStyle};text-align:right" class="opt-th" data-col="days_to_expiry" data-label="DTE" onclick="_optSort('days_to_expiry')">DTE</th> 8593 <th style="${thStyle};text-align:right" class="opt-th" data-col="premium_usd" data-label="Premium â" onclick="_optSort('premium_usd')" style="${thStyle};color:var(--teal)">Premium â</th> 8594 <th style="${thStyle};text-align:right" class="opt-th" data-col="volume" data-label="Volume" onclick="_optSort('volume')">Volume</th> 8595 <th style="${thStyle};text-align:right" class="opt-th" data-col="open_interest" data-label="Open Int." onclick="_optSort('open_interest')">Open Int.</th> 8596 <th style="${thStyle};text-align:right" class="opt-th" data-col="vol_oi_ratio" data-label="Vol/OI" onclick="_optSort('vol_oi_ratio')">Vol/OI</th> 8597 <th style="${thStyle};text-align:right" class="opt-th" data-col="implied_vol" data-label="IV" onclick="_optSort('implied_vol')">IV</th> 8598 <th style="${thStyle};text-align:right" class="opt-th" data-col="underlying_price" data-label="Underlying" onclick="_optSort('underlying_price')">Underlying</th> 8599 <th style="${thStyle};text-align:center">Result</th> 8600 <th style="${thStyle};text-align:right">Flagged</th> 8601 </tr></thead> 8602 <tbody>${items.map(o=>{ 8603 const isCall=o.option_type==='call'; 8604 const flagged=o.flagged_at?(new Date(o.flagged_at)).toLocaleString('en-US',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}):'â'; 8605 const iv=o.implied_vol!=null?`${(o.implied_vol*100).toFixed(0)}%`:'â'; 8606 const dte=o.days_to_expiry!=null?o.days_to_expiry+'d':'â'; 8607 const dteColor=o.days_to_expiry!=null&&o.days_to_expiry<=7?'var(--red)':o.days_to_expiry!=null&&o.days_to_expiry<=30?'var(--gold)':'var(--text2)'; 8608 const ratio=o.vol_oi_ratio!=null?`${o.vol_oi_ratio.toFixed(1)}Ã`:'â'; 8609 const ratioColor=o.vol_oi_ratio>=10?'var(--red)':o.vol_oi_ratio>=5?'var(--gold)':'var(--teal)'; 8610 8611 // Resolution badge 8612 let resBadge='<span style="font-size:.6rem;color:var(--muted)">â³ open</span>'; 8613 if(o.resolution_type==='expired_itm'){ 8614 const move=o.pnl_pct!=null?` ${o.pnl_pct>0?'+':''}${o.pnl_pct.toFixed(1)}%`:''; 8615 resBadge=`<span style="font-size:.6rem;font-weight:700;color:#2ecc8e;background:rgba(46,204,142,.12);padding:2px 6px;border-radius:3px">â ITM${move}</span>`; 8616 } else if(o.resolution_type==='expired_otm'){ 8617 const move=o.pnl_pct!=null?` ${o.pnl_pct.toFixed(1)}%`:''; 8618 // For puts: OTM means seller won (bullish) 8619 const putSellerWon=!isCall&&o.resolution_type==='expired_otm'; 8620 resBadge=putSellerWon 8621 ?`<span style="font-size:.6rem;font-weight:700;color:#2ecc8e;background:rgba(46,204,142,.12);padding:2px 6px;border-radius:3px">â PUT SOLD${move}</span>` 8622 :`<span style="font-size:.6rem;font-weight:700;color:#e05252;background:rgba(224,82,82,.12);padding:2px 6px;border-radius:3px">â OTM${move}</span>`; 8623 } 8624 8625 return `<tr style="border-bottom:1px solid var(--border20)" 8626 data-ticker="${o.ticker}" data-option_type="${o.option_type==='call'?1:0}" 8627 data-strike="${o.strike}" data-days_to_expiry="${o.days_to_expiry||9999}" 8628 data-premium_usd="${o.premium_usd||0}" data-volume="${o.volume||0}" 8629 data-open_interest="${o.open_interest||0}" data-vol_oi_ratio="${o.vol_oi_ratio||0}" 8630 data-implied_vol="${o.implied_vol||0}" data-underlying_price="${o.underlying_price||0}"> 8631 <td style="padding:8px;font-weight:700;font-family:var(--mono);color:var(--text)">${o.ticker}</td> 8632 <td style="padding:8px"><span style="padding:2px 8px;border-radius:3px;font-size:.65rem;font-weight:700;background:${isCall?'rgba(46,204,142,.15)':'rgba(224,82,82,.15)'};color:${isCall?'#2ecc8e':'#e05252'}">${o.option_type.toUpperCase()}</span></td> 8633 <td style="padding:8px;text-align:right;font-family:var(--mono);color:var(--text2)">$${o.strike}</td> 8634 <td style="padding:8px;text-align:right;font-family:var(--mono);color:${dteColor}">${dte}</td> 8635 <td style="padding:8px;text-align:right;font-family:var(--mono);font-weight:700;color:var(--text)">${_fmtPremium(o.premium_usd)}</td> 8636 <td style="padding:8px;text-align:right;font-family:var(--mono);color:var(--text2)">${_fmtNum(o.volume)}</td> 8637 <td style="padding:8px;text-align:right;font-family:var(--mono);color:var(--text2)">${_fmtNum(o.open_interest)}</td> 8638 <td style="padding:8px;text-align:right;font-family:var(--mono);color:${ratioColor};font-weight:600">${ratio}</td> 8639 <td style="padding:8px;text-align:right;font-family:var(--mono);color:var(--muted)">${iv}</td> 8640 <td style="padding:8px;text-align:right;font-family:var(--mono);color:var(--text2)">${o.underlying_price?'$'+o.underlying_price.toFixed(2):'â'}</td> 8641 <td style="padding:8px;text-align:center">${resBadge}</td> 8642 <td style="padding:8px;text-align:right;color:var(--muted);font-size:.68rem;white-space:nowrap">${flagged}</td> 8643 </tr>`; 8644 }).join('')}</tbody> 8645 </table>`; 8646 }catch(e){el.innerHTML='<div style="color:var(--muted);font-size:.8rem;padding:20px 0">Error loading options flow.</div>';} 8647} 8648 8649async function loadInsiderFlow(){ 8650 const el=$('ov-insider-panel'); 8651 if(!el)return; 8652 try{ 8653 const r=await api('GET','/api/news/insider-recent'); 8654 if(r.ok){ 8655 const d=await r.json(); 8656 const items=(d.entries||[]).slice(0,8); 8657 if(!items.length){el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">No recent filings.</div>';return;} 8658 el.innerHTML=items.map(t=>{ 8659 const buy=t.transaction_code==='P'; 8660 const val=t.value?(Math.abs(t.value)>=1e6?`$${(t.value/1e6).toFixed(1)}M`:`$${(t.value/1e3).toFixed(0)}K`):'â'; 8661 const shs=t.shares?(t.shares>=1e6?`${(t.shares/1e6).toFixed(1)}M`:(t.shares>=1e3?`${Math.round(t.shares/1e3)}K`:String(t.shares))+' shs'):''; 8662 const nm=(t.name||'').length>18?(t.name||'').slice(0,17)+'â¦':(t.name||'â'); 8663 return `<div style="display:flex;align-items:center;gap:6px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);border-radius:5px;margin-bottom:4px;font-size:.7rem">
8664 <span style="font-weight:700;font-family:var(--mono);min-width:44px;color:var(--text)">${t.symbol}</span> 8665 <span style="padding:1px 5px;border-radius:3px;font-size:.6rem;background:${buy?'rgba(0,200,83,.15)':'rgba(255,68,68,.15)'};color:${buy?'var(--green)':'var(--red)'}">${buy?'BUY':'SELL'}</span> 8666 <span style="flex:1;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${nm}</span> 8667 <div style="text-align:right;line-height:1.2"> 8668 <div style="color:var(--text2);font-family:var(--mono);white-space:nowrap;font-size:.68rem">${val}</div> 8669 ${shs?`<div style="color:var(--text2);font-size:.62rem;white-space:nowrap">${shs}</div>`:''} 8670 </div> 8671 </div>`; 8672 }).join(''); 8673 } 8674 }catch(e){el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">â</div>';} 8675} 8676 8677let _ovNewsItems=[]; 8678 8679function _renderOvNews(items){ 8680 const el=$('ov-news-preview'); 8681 if(!el)return; 8682 if(!items.length){el.innerHTML='<div style="font-size:.72rem;color:var(--muted)">No results.</div>';return;} 8683 el.innerHTML=items.map(n=>` 8684 <div style="display:flex;align-items:center;gap:8px;padding:8px 10px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;margin-bottom:6px"> 8685 ${n.image_url?`<img src="${n.image_url}" style="width:36px;height:36px;object-fit:cover;border-radius:4px;flex-shrink:0" onerror="this.style.display='none'">`:''} 8686 <div style="flex:1;min-width:0"> 8687 <div style="font-size:.75rem;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${n.title||'â'}</div> 8688 <div style="font-size:.62rem;color:#8b9cb8;font-family:var(--mono)">${n.created_at?new Date(n.created_at).toLocaleDateString('en-US',{month:'short',day:'numeric'}):''}</div> 8689 </div> 8690 </div>`).join(''); 8691} 8692 8693function filterOvNews(q){ 8694 if(!q.trim()){_renderOvNews(_ovNewsItems.slice(0,5));return;} 8695 const term=q.toLowerCase(); 8696 const hits=_ovNewsItems.filter(n=> 8697 (n.title||'').toLowerCase().includes(term)|| 8698 (n.summary||'').toLowerCase().includes(term)|| 8699 (n.source||'').toLowerCase().includes(term) 8700 ); 8701 _renderOvNews(hits.slice(0,10)); 8702} 8703 8704async function loadOverviewMovers(){ 8705 const grid=$('ov-movers-grid'); 8706 if(!grid)return; 8707 grid.innerHTML='<div style="font-size:.72rem;color:var(--muted);grid-column:1/-1">Loading...</div>'; 8708 try{ 8709 const r=await api('GET','/api/movers/today'); 8710 if(!r.ok){ 8711 const d=await r.json().catch(()=>({})); 8712 grid.innerHTML=`<div style="font-size:.72rem;color:var(--muted);grid-column:1/-1">${d.detail||'No data yet â check back after 8am EST'}</div>`; 8713 return; 8714 } 8715 const d=await r.json(); 8716 const row=(m,color)=>` 8717 <div style="display:flex;align-items:center;gap:6px;padding:5px 8px;background:var(--bg3);border-radius:6px;font-size:.72rem"> 8718 <span style="font-family:var(--mono);font-weight:700;color:#e2eaf2;min-width:44px">${m.ticker}</span> 8719 <span style="flex:1;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:.65rem">${m.name||''}</span> 8720 <span style="font-family:var(--mono);font-weight:700;color:${color};white-space:nowrap">${m.pct>=0?'+':''}${m.pct.toFixed(2)}%</span> 8721 </div>`; 8722 const gainers=(d.gainers||[]).slice(0,8).map(m=>row(m,'#00c853')).join(''); 8723 const losers=(d.losers||[]).slice(0,8).map(m=>row(m,'#f23645')).join(''); 8724 grid.innerHTML=` 8725 <div> 8726 <div style="font-size:.58rem;font-family:var(--mono);letter-spacing:1px;color:#00c853;margin-bottom:5px">GAINERS</div> 8727 <div style="display:flex;flex-direction:column;gap:3px">${gainers||'<span style="font-size:.7rem;color:var(--muted)">â</span>'}</div> 8728 </div> 8729 <div> 8730 <div style="font-size:.58rem;font-family:var(--mono);letter-spacing:1px;color:#f23645;margin-bottom:5px">LOSERS</div> 8731 <div style="display:flex;flex-direction:column;gap:3px">${losers||'<span style="font-size:.7rem;color:var(--muted)">â</span>'}</div> 8732 </div>`; 8733 }catch(e){ 8734 grid.innerHTML=`<div style="font-size:.72rem;color:var(--red);grid-column:1/-1">Error: ${e.message}</div>`; 8735 } 8736} 8737 8738// âââ Trends ââââââââââââââââââââââââââââââââââââââââââââââââââ 8739let _trendsLoaded = false;
8740async function loadTrends(force){ 8741 const list=$('trends-list'); 8742 const meta=$('trends-meta'); 8743 const geo=($('trends-geo')||{value:'US'}).value||'US'; 8744 if(!list)return; 8745 if(!force&&_trendsLoaded)return; 8746 _trendsLoaded=true; 8747 list.innerHTML='<p style="font-size:.8rem;color:var(--text2)">Loading...</p>'; 8748 try{ 8749 const r=await api('GET',`/api/trends?geo=${geo}`); 8750 if(!r.ok){ 8751 const d=await r.json().catch(()=>({})); 8752 list.innerHTML=`<p style="font-size:.8rem;color:var(--text2)">${d.detail||'Failed to load trends.'}</p>`; 8753 return; 8754 } 8755 const d=await r.json(); 8756 if(meta)meta.textContent=`${d.geo} · ${d.trends.length} trends`; 8757 list.innerHTML=d.trends.map(t=>` 8758 <div style="display:flex;align-items:flex-start;gap:12px;padding:10px 12px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;margin-bottom:8px"> 8759 <div style="font-family:var(--mono);font-size:.7rem;color:var(--muted);min-width:22px;padding-top:2px">#${t.rank}</div> 8760 <div style="flex:1;min-width:0"> 8761 <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"> 8762 <a href="https://trends.google.com/trends/explore?q=${encodeURIComponent(t.keyword)}&geo=${d.geo}" target="_blank" rel="noopener" style="font-weight:600;font-size:.85rem;color:var(--text);text-decoration:none">${t.keyword}</a> 8763 ${t.traffic?`<span style="font-family:var(--mono);font-size:.68rem;color:var(--teal);background:rgba(226,194,117,.1);padding:1px 6px;border-radius:4px">${t.traffic} searches</span>`:''} 8764 </div> 8765 ${t.news_title?`<a href="${t.news_url||'#'}" target="_blank" rel="noopener" style="display:block;margin-top:4px;font-size:.75rem;color:var(--text2);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${t.news_title}${t.news_source?` <span style="color:var(--muted)">· ${t.news_source}</span>`:''}</a>`:''} 8766 </div> 8767 </div>`).join(''); 8768 }catch(e){ 8769 _trendsLoaded=false; 8770 list.innerHTML=`<p style="font-size:.8rem;color:var(--red)">Error: ${e.message}</p>`; 8771 } 8772} 8773 8774async function generateTrendImage(evt, keyword){ 8775 const btn = evt.currentTarget; 8776 btn.textContent = 'â³ Generatingâ¦'; btn.disabled = true; 8777 try { 8778 const res = await api('POST', '/api/trends/generate-image', {keyword}); 8779 const d = await res.json(); 8780 if(res.ok){ toast('Image saved to gallery!', 'ok'); } 8781 else toast(d.detail || 'Failed', 'err'); 8782 } catch(e){ toast('Error: '+e.message, 'err'); } 8783 finally { btn.textContent = 'ð¨ AI Image'; btn.disabled = false; } 8784} 8785 8786// âââ Gallery âââââââââââââââââââââââââââââââââââââââââââââââââ 8787let _galleryFilter = ''; 8788let _galleryOffset = 0; 8789const _GALLERY_LIMIT = 60; 8790 8791async function loadGallery(type, reset){ 8792 if(reset===undefined) reset=true; 8793 _galleryFilter = type; 8794 if(reset) _galleryOffset = 0; 8795 document.querySelectorAll('.gallery-filter').forEach(b=>{ 8796 b.classList.toggle('active-filter', b.dataset.type === type); 8797 b.classList.toggle('btn-ghost', b.dataset.type !== type); 8798 }); 8799 const el = $('gallery-grid'); 8800 if(!el) return; 8801 if(reset) el.innerHTML = '<div style="color:var(--muted);font-size:.8rem">Loadingâ¦</div>'; 8802 const qs = new URLSearchParams({limit: _GALLERY_LIMIT, offset: _galleryOffset}); 8803 if(type) qs.set('output_type', type); 8804 try { 8805 const res = await api('GET', `/api/gallery?${qs}`); 8806 const d = await res.json(); 8807 const items = (d.items || []); 8808 if(reset) el.innerHTML = ''; 8809 if(!items.length && reset){ el.innerHTML = '<div style="color:var(--muted);font-size:.78rem">No images yet.</div>'; } 8810 else el.innerHTML += items.map(galleryThumb).join(''); 8811 $('gallery-load-more').style.display = items.length === _GALLERY_LIMIT ? 'block' : 'none'; 8812 } catch(e){ 8813 if(reset) el.innerHTML = `<div style="color:var(--red);font-size:.78rem">Error: ${e.message}</div>`; 8814 } 8815} 8816 8817const _galleryItems = {}; 8818 8819function galleryThumb(item){ 8820 const typeLabel = {carousel_pil:'Carousel',carousel_templated:'Carousel',collage:'Collage',design_studio:'Studio',ai_image:'AI',upload:'Upload'}[item.output_type]||item.output_type; 8821 const thumb = item.thumbnail || ''; 8822 const id = item.id || ''; 8823 _galleryItems[id] = item; 8824 return `<div style="position:relative;border-radius:8px;overflow:hidden;background:var(--card);border:1px solid var(--border)"> 8825 <img src="${thumb}" loading="lazy" onclick="openGalleryLightbox('${id}')" 8826 style="width:100%;aspect-ratio:1;object-fit:cover;display:block;cursor:zoom-in" onerror="this.style.display='none'"> 8827 <div style="padding:6px 8px;display:flex;justify-content:space-between;align-items:center">
8828 <span style="font-size:.58rem;color:var(--muted)">${typeLabel}</span> 8829 <button onclick="deleteGalleryItem('${id}',this)" style="background:none;border:none;cursor:pointer;font-size:.7rem;color:var(--muted);padding:0">ð</button> 8830 </div> 8831 </div>`; 8832} 8833 8834async function uploadGalleryFiles(input){ 8835 const status = $('gallery-upload-status'); 8836 const files = [...input.files]; 8837 if(!files.length) return; 8838 status.textContent = `Uploading ${files.length} file(s)â¦`; 8839 let ok = 0; 8840 for(const f of files){ 8841 const fd = new FormData(); fd.append('file', f); 8842 try { 8843 const res = await fetch('/api/gallery/upload', { 8844 method:'POST', headers:{Authorization:'Bearer '+localStorage.getItem('fz_token')}, body: fd 8845 }); 8846 if(res.ok) ok++; 8847 } catch(e){} 8848 } 8849 status.textContent = `${ok}/${files.length} uploaded`; 8850 input.value = ''; 8851 loadGallery(_galleryFilter); 8852} 8853 8854async function deleteGalleryItem(id, btn){ 8855 btn.textContent = 'â³'; 8856 try { 8857 const res = await api('DELETE', `/api/gallery/${id}`); 8858 if(res.ok || res.status === 204) btn.closest('div').remove(); 8859 else { btn.textContent = 'ð'; toast('Delete failed','err'); } 8860 } catch(e){ btn.textContent = 'ð'; toast('Error','err'); } 8861} 8862 8863function loadGalleryMore(){ 8864 _galleryOffset += _GALLERY_LIMIT; 8865 loadGallery(_galleryFilter, false); 8866} 8867 8868// âââ Gallery Picker for slide slots ââââââââââââââââââââââââââ 8869let _galleryPickTarget = null; 8870let _slideSelections = {1:null,2:null,3:null}; 8871 8872function clearSlotSelection(n){ 8873 _slideSelections[n]=null; 8874 const lbl=$(`slide-sel-${n}`); if(lbl) lbl.style.display='none'; 8875 const prev=$(`slide-preview-${n}`); if(prev) prev.style.display='none'; 8876} 8877 8878async function openGalleryPicker(slideNum){ 8879 _galleryPickTarget=slideNum; 8880 $('gp-slot-label').textContent=slideNum; 8881 $('gallery-picker-modal').style.display='flex'; 8882 const grid=$('gp-grid'); 8883 grid.innerHTML='<div style="font-size:.72rem;color:var(--muted);padding:8px">Loading galleryâ¦</div>'; 8884 try{ 8885 const res=await api('GET','/api/outputs'); 8886 const data=await res.json(); 8887 const outputs=Array.isArray(data)?data:(data.outputs||[]); 8888 if(!outputs.length){grid.innerHTML='<div style="font-size:.72rem;color:var(--muted);padding:8px">No recent outputs found. Generate some images first.</div>';return;} 8889 grid.innerHTML=outputs.flatMap(o=>{ 8890 const slides=(o.slides||[]); 8891 if(!slides.length&&o.thumbnail) return [`<div onclick="selectGalleryImage('${o.thumbnail}',${slideNum})" style="cursor:pointer;border:2px solid transparent;border-radius:8px;overflow:hidden;transition:border .15s" onmouseover="this.style.borderColor='#E2C275'" onmouseout="this.style.borderColor='transparent'"><img src="${o.thumbnail}" style="width:100%;aspect-ratio:1;object-fit:cover" onerror="this.parentElement.style.display='none'"><div style="font-size:.6rem;color:var(--muted);padding:3px 5px">${new Date(o.created_at).toLocaleDateString()}</div></div>`]; 8892 return slides.map(s=>{const url=s.cloudinary_url||s.url||s.render_url;if(!url)return'';return`<div onclick="selectGalleryImage('${url}',${slideNum})" style="cursor:pointer;border:2px solid transparent;border-radius:8px;overflow:hidden;transition:border .15s" onmouseover="this.style.borderColor='#E2C275'" onmouseout="this.style.borderColor='transparent'"><img src="${url}" style="width:100%;aspect-ratio:1;object-fit:cover" onerror="this.parentElement.style.display='none'"><div style="font-size:.6rem;color:var(--muted);padding:3px 5px">Slide ${s.slide||''}</div></div>`;}); 8893 }).join(''); 8894 }catch(e){grid.innerHTML='<div style="font-size:.72rem;color:var(--red);padding:8px">Failed to load gallery</div>';} 8895} 8896 8897function selectGalleryImage(url,slideNum){ 8898 _slideSelections[slideNum]={type:'url',value:url}; 8899 const lbl=$(`slide-sel-${slideNum}`); 8900 if(lbl) lbl.style.display='inline'; 8901 const prev=$(`slide-preview-${slideNum}`); 8902 if(prev){prev.querySelector('img').src=url;prev.style.display='block';} 8903 closeGalleryPicker(); 8904 toast(`Image set for Slide ${slideNum}`,'ok'); 8905} 8906 8907function closeGalleryPicker(){ 8908 $('gallery-picker-modal').style.display='none'; 8909} 8910 8911function useAiImageForSlot(n){ 8912 const img=$('ai-chart-img'); 8913 if(!img||!img.src) return; 8914 selectGalleryImage(img.src,n); 8915} 8916 8917// âââ AI Chart Designer (Factoz AI Image Generator) âââââââââââ 8918async function runAiChart(caseType){ 8919 const allBtns = ['ai-chart-bull-btn','ai-chart-bear-btn','ai-chart-neutral-btn']; 8920 allBtns.forEach(id=>{ const el=$(id); if(el) el.disabled=true; }); 8921 const status = $('ai-chart-status'); 8922 status.textContent = 'â³ Generating AI art⦠(10â20s)'; 8923 $('ai-chart-result').style.display = 'none'; 8924 const fd = new FormData(); 8925 fd.append('case', caseType); 8926 try { 8927 const res = await fetch('/api/ai-chart/analyze', { 8928 method: 'POST', 8929 headers: {Authorization: 'Bearer ' + localStorage.getItem('fz_token')}, 8930 body: fd, 8931 }); 8932 const d = await res.json(); 8933 if(res.ok){ 8934 $('ai-chart-img').src = d.url; 8935 $('ai-chart-result').style.display = 'block'; 8936 status.textContent = ''; 8937 toast('AI art saved to gallery!', 'ok'); 8938 } else { 8939 status.textContent = 'â ' + (d.detail || 'Failed'); 8940 toast(d.detail || 'Failed', 'err'); 8941 } 8942 } catch(e){ 8943 status.textContent = 'â Error: ' + e.message; 8944 } 8945 allBtns.forEach(id=>{ const el=$(id); if(el) el.disabled=false; }); 8946} 8947 8948function downloadAiChartImage(){ 8949 const src = $('ai-chart-img').src; 8950 if(!src) return; 8951 const a = document.createElement('a'); 8952 a.href = src; 8953 a.download = 'factoz_ai_chart_' + Date.now() + '.png'; 8954 a.click(); 8955} 8956 8957// âââ Gallery lightbox ââââââââââââââââââââââââââââââââââââââââ 8958let _lbSlides = []; 8959let _lbIndex = 0; 8960 8961function openGalleryLightbox(id){ 8962 const item = _galleryItems[id]; 8963 if(!item) return; 8964 const typeLabel = {carousel_pil:'Carousel',carousel_templated:'Carousel',collage:'Collage',design_studio:'Studio',ai_image:'AI Image',upload:'Upload'}[item.output_type]||item.output_type; 8965 _lbSlides = (item.slides && item.slides.length) ? item.slides.map(s=>
8965s.url).filter(Boolean) : [item.thumbnail]; 8966 _lbIndex = 0; 8967 $('lb-type-label').textContent = typeLabel.toUpperCase(); 8968 lbRender(); 8969 const lb = $('gallery-lightbox'); 8970 lb.style.display = 'flex'; 8971 document.addEventListener('keydown', _lbKeyHandler); 8972} 8973 8974function lbRender(){ 8975 $('lb-img').src = _lbSlides[_lbIndex] || ''; 8976 const multi = _lbSlides.length > 1; 8977 $('lb-slide-counter').textContent = multi ? `${_lbIndex+1} / ${_lbSlides.length}` : ''; 8978 const nav = $('lb-nav'); 8979 nav.style.display = multi ? 'flex' : 'none'; 8980 if(multi){ 8981 $('lb-dots').innerHTML = _lbSlides.map((_,i)=> 8982 `<div onclick="lbGoto(${i})" style="width:7px;height:7px;border-radius:50%;cursor:pointer;background:${i===_lbIndex?'#fff':'rgba(255,255,255,.3)'}"></div>` 8983 ).join(''); 8984 } 8985} 8986 8987function lbNav(dir){ 8988 _lbIndex = (_lbIndex + dir + _lbSlides.length) % _lbSlides.length; 8989 lbRender(); 8990} 8991 8992function lbGoto(i){ 8993 _lbIndex = i; 8994 lbRender(); 8995} 8996 8997function closeGalleryLightbox(){ 8998 $('gallery-lightbox').style.display = 'none'; 8999 document.removeEventListener('keydown', _lbKeyHandler); 9000} 9001 9002function _lbKeyHandler(e){ 9003 if(e.key==='Escape') closeGalleryLightbox(); 9004 else if(e.key==='ArrowRight') lbNav(1); 9005 else if(e.key==='ArrowLeft') lbNav(-1); 9006} 9007 9008async function downloadGalleryImage(){ 9009 const url = _lbSlides[_lbIndex]; 9010 if(!url) return; 9011 try { 9012 const res = await fetch(url); 9013 const blob = await res.blob(); 9014 const a = document.createElement('a'); 9015 a.href = URL.createObjectURL(blob); 9016 const ext = url.split('.').pop().split('?')[0] || 'jpg'; 9017 a.download = `factoz_image_${Date.now()}.${ext}`; 9018 a.click(); 9019 URL.revokeObjectURL(a.href); 9020 } catch(e){ 9021 // Cloudinary cross-origin fallback â open in new tab 9022 window.open(url, '_blank'); 9023 } 9024} 9025 9026// âââ Financial Statements ââââââââââââââââââââââââââââââââââââ 9027let _finData = null; 9028let _finTab = 'balance'; 9029let _finMode = 'public'; 9030let _finHistory = []; 9031let _finCompareA = null; 9032let _finCompareB = null; 9033let _finCompareTab = 'balance'; 9034 9035function initFinancials(){ setFinMode('public'); loadFinHistory(false); } 9036 9037function newFinStatement(){ 9038 _finData = null; 9039 $('fin-results').style.display = 'none'; 9040 $('fin-status').textContent = ''; 9041 loadFinHistory(false); 9042 $('page-financials').scrollIntoView({behavior:'smooth', block:'start'}); 9043} 9044 9045// ââ SECTOR HEATMAP âââââââââââââââââââââââââââââââââââââââââ 9046// âââ PORTFOLIO RISK / VaR ââââââââââââââââââââââââââââââââââââââââââââââââââââ 9047let _varTickers=[{ticker:'AAPL',weight:60},{ticker:'MSFT',weight:40}]; 9048 9049function initVarPage(){ 9050 renderVarTickerRows(); 9051 $('var-results').style.display='none'; 9052 $('var-loading').style.display='none'; 9053 $('var-error').style.display='none'; 9054 fetchVarPrices(); 9055} 9056 9057async function fetchVarPrices(){ 9058 const tickers=_varTickers.map(r=>r.ticker.trim().toUpperCase()).filter(Boolean); 9059 if(!tickers.length)return; 9060 const container=$('var-price-rows'); 9061 if(!container)return; 9062 container.innerHTML='<div style="font-size:.75rem;color:var(--muted)">Loadingâ¦</div>'; 9063 try{ 9064 const res=await api('GET',`/api/var/prices?tickers=${tickers.join(',')}`); 9065 const data=await res.json(); 9066 if(!res.ok){container.innerHTML='<div style="font-size:.75rem;color:var(--muted)">â</div>';return;} 9067 container.innerHTML=tickers.map(tk=>{ 9068 const d=data[tk]||{}; 9069 const price=d.price!=null?`$${d.price.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`:'â'; 9070 const chg=d.change_pct??null; 9071 const chgColor=chg==null?'var(--muted)':chg>=0?'var(--green)':'var(--red)'; 9072 const chgStr=chg==null?'':` ${chg>=0?'+':''}${chg.toFixed(2)}%`; 9073 const name=d.name&&d.name!==tk?`<div style="font-size:.65rem;color:var(--muted);margin-top:1px">${d.name}</div>`:''; 9074 return `<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border)"> 9075 <div><span style="font-family:var(--mono);font-size:.82rem;font-weight:600;color:var(--text)">${tk}</span>${name}</div> 9076 <div style="text-align:right"> 9077 <div style="font-family:var(--mono);font-size:.88rem;color:var(--text)">${price}</div> 9078 <div style="font-family:var(--mono);font-size:.72rem;color:${chgColor}">${chgStr}</div> 9079 </div> 9080 </div>`; 9081 }).join(''); 9082 }catch(e){ 9083 container.innerHTML='<div style="font-size:.75rem;color:var(--muted)">â</div>'; 9084 } 9085} 9086 9087function renderVarTickerRows(){ 9088 const container=$('var-ticker-rows'); 9089 if(!container)return; 9090 container.innerHTML=_varTickers.map((row,i)=>` 9091 <div style="display:flex;gap:8px;align-items:center"> 9092 <input type="text" value="${row.ticker}" placeholder="TICKER" 9093 style="width:90px;font-family:var(--mono);font-size:.8rem" 9094 class="input" oninput="_varTickers[${i}].ticker=this.value.toUpperCase()" 9095 onblur="if(this.value.trim())fetchVarPrices()"> 9096 <input type="number" value="${row.weight}" min="0" max="100" step="1" 9097 style="width:80px;font-family:var(--mono);font-size:.8rem" 9098 class="input" oninput="_varTickers[${i}].weight=parseFloat(this.value)||0">
9099 <span style="font-size:.75rem;color:var(--muted)">%</span> 9100 ${_varTickers.length>1?`<button class="btn btn-ghost" style="font-size:.7rem;padding:4px 8px" onclick="removeVarTicker(${i})">â</button>`:''} 9101 </div>`).join(''); 9102} 9103 9104function addVarTicker(){ 9105 if(_varTickers.length>=10){toast('Maximum 10 tickers','err');return;} 9106 _varTickers.push({ticker:'',weight:0}); 9107 renderVarTickerRows(); 9108 fetchVarPrices(); 9109} 9110 9111function removeVarTicker(i){ 9112 _varTickers.splice(i,1); 9113 renderVarTickerRows(); 9114 fetchVarPrices(); 9115} 9116 9117async function runVarAnalysis(){ 9118 const errEl=$('var-error'); 9119 errEl.style.display='none'; 9120 9121 // Validate 9122 const tickers=_varTickers.map(r=>r.ticker.trim().toUpperCase()).filter(Boolean); 9123 const weights=_varTickers.filter(r=>r.ticker.trim()).map(r=>r.weight/100); 9124 const totalWeight=weights.reduce((a,b)=>a+b,0); 9125 9126 if(tickers.length===0){errEl.textContent='Add at least one ticker.';errEl.style.display='';return;} 9127 if(Math.abs(totalWeight-1)>0.02){ 9128 errEl.textContent=`Weights must sum to 100% (currently ${(totalWeight*100).toFixed(1)}%).`; 9129 errEl.style.display='';return; 9130 } 9131 9132 const portfolioValue=parseFloat($('var-portfolio-value').value)||10000; 9133 const confidence=parseFloat($('var-confidence').value)||0.99; 9134 9135 const _runBtn=$('var-run-btn'); 9136 if(_runBtn){_runBtn.innerHTML='<span class="spin"></span> Analyzingâ¦';_runBtn.disabled=true;} 9137 $('var-loading').style.display=''; 9138 $('var-results').style.display='none'; 9139 9140 try{ 9141 const res=await api('POST','/api/var/analyze',{ 9142 tickers,weights,portfolio_value:portfolioValue, 9143 confidence,simulations:5000 9144 }); 9145 const data=await res.json(); 9146 if(!res.ok||data.historical_var_pct===undefined){ 9147 throw new Error(data&&data.detail?data.detail:'Calculation failed â check your tickers and try again'); 9148 } 9149 renderVarResults(data); 9150 fetchVarPrices(); 9151 }catch(e){ 9152 errEl.textContent=e.message||'Analysis failed. Check your tickers.'; 9153 errEl.style.display=''; 9154 }finally{ 9155 $('var-loading').style.display='none'; 9156 if(_runBtn){_runBtn.innerHTML='Run Analysis';_runBtn.disabled=false;} 9157 } 9158} 9159 9160function renderVarResults(d){ 9161 // Metrics grid 9162 const pct=v=>`${v>0?'+':''}${v.toFixed(2)}%`; 9163 const usd=v=>`$${Math.abs(v).toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2})}`; 9164 const metrics=[ 9165 {label:'Historical VaR',value:usd(d.historical_var_usd),sub:`${Math.abs(d.historical_var_pct).toFixed(2)}% of portfolio`,color:'var(--red)'}, 9166 {label:'Monte Carlo VaR',value:usd(d.monte_carlo_var_usd),sub:`${Math.abs(d.monte_carlo_var_pct).toFixed(2)}% of portfolio`,color:'var(--red)'}, 9167 {label:'CVaR (Exp. Shortfall)',value:usd(d.cvar_usd),sub:`${Math.abs(d.cvar_pct).toFixed(2)}% of portfolio`,color:'#ff6b6b'}, 9168 {label:'Max Drawdown',value:`${d.max_drawdown_pct.toFixed(2)}%`,sub:'1-year period',color:'var(--yellow)'}, 9169 {label:'Annual Return',value:pct(d.annual_return_pct),sub:'1-year',color:d.annual_return_pct>=0?'var(--green)':'var(--red)'}, 9170 {label:'Annual Volatility',value:`${d.annual_volatility_pct.toFixed(2)}%`,sub:'1-year',color:'var(--text2)'}, 9171 {label:'Sharpe Ratio',value:d.sharpe_ratio.toFixed(3),sub:'Risk-adjusted return',color:d.sharpe_ratio>=1?'var(--green)':d.sharpe_ratio>=0?'var(--yellow)':'var(--red)'}, 9172 {label:'Portfolio',value:d.tickers.map((t,i)=>`${t} ${(d.weights[i]*100).toFixed(0)}%`).join(' · '),sub:'allocation',color:'var(--text2)'}, 9173 ]; 9174 $('var-metrics-grid').innerHTML=metrics.map(m=>` 9175 <div class="card" style="padding:16px"> 9176 <div style="font-size:.72rem;color:var(--muted);font-family:var(--mono);letter-spacing:1px;margin-bottom:6px">${m.label.toUpperCase()}</div> 9177 <div style="font-size:1.35rem;font-weight:700;font-family:var(--mono);color:${m.color}">${m.value}</div> 9178 <div style="font-size:.75rem;color:var(--text2);margin-top:4px">${m.sub}</div> 9179 </div>`).join(''); 9180 9181 // Monte Carlo paths chart 9182 const days=Array.from({length:d.mc_paths[0].length},(_,i)=>i+1); 9183 // Sort paths by final portfolio value so we can label worst/median/best 9184 const sortedPaths=[...d.mc_paths].sort((a,b)=>a[a.length-1]-b[b.length-1]); 9185 const midIdx=Math.floor(sortedPaths.length/2); 9186 const pathTraces=sortedPaths.map((path,i)=>{ 9187 const isBest=i===sortedPaths.length-1; 9188 const isWorst=i===0; 9189 const isMiddle=i===midIdx; 9190 const label=isBest?'Optimistic ð':isWorst?'Pessimistic ð':isMiddle?'Base Case':''; 9191 return{ 9192 x:days,y:path,mode:'lines', 9193 name:label, 9194 showlegend:isBest||isWorst||isMiddle, 9195 line:{ 9196 width:isBest||isWorst||isMiddle?2:0.8, 9197 color:isBest?'rgba(0,230,100,0.9)':isWorst?'rgba(220,80,80,0.8)':isMiddle?'rgba(0,200,83,0.8)':'rgba(0,180,80,0.18)', 9198 dash:isWorst?'dot':'solid', 9199 }, 9200 hovertemplate:(isBest||isWorst||isMiddle) 9201 ?`${isBest?'Optimistic':isWorst?'Pessimistic':'Base Case'} â Day %{x}: $%{y:,.0f}<extra></extra>` 9202 :'Day %{x}: $%{y:,.0f}<extra></extra>', 9203 }; 9204 }); 9205 const varLine=d.monte_carlo_var_usd; 9206 pathTraces.push({ 9207 x:[1,days.length],y:[d.portfolio_value-varLine,d.portfolio_value-varLine], 9208 mode:'lines',line:{color:'rgba(242,54,69,0.75)',width:1.5,dash:'dot'},
9209 name:`99% VaR ($${varLine.toLocaleString('en-US',{maximumFractionDigits:0})})`, 9210 hovertemplate:`VaR floor: $%{y:,.0f}<extra></extra>` 9211 }); 9212 Plotly.newPlot('var-chart-paths',pathTraces,{ 9213 paper_bgcolor:'transparent',plot_bgcolor:'transparent', 9214 font:{color:'#a0a0a0',size:11}, 9215 xaxis:{title:'Trading Days',dtick:50,tick0:0,gridcolor:'rgba(255,255,255,0.05)',zeroline:false}, 9216 yaxis:{title:'Portfolio Value (USD)',gridcolor:'rgba(255,255,255,0.05)',zeroline:false,tickformat:'$,.0f'}, 9217 margin:{l:60,r:20,t:10,b:40}, 9218 legend:{x:0,y:1,bgcolor:'transparent'} 9219 },{responsive:true,displayModeBar:false}); 9220 9221 // Return distribution histogram 9222 Plotly.newPlot('var-chart-dist',[{ 9223 x:d.return_distribution,type:'histogram',nbinsx:60, 9224 marker:{color:'rgba(0,200,83,0.5)',line:{color:'rgba(0,200,83,0.8)',width:0.5}}, 9225 name:'Daily Returns',hovertemplate:'Return: %{x:.2f}%<extra></extra>' 9226 }],{ 9227 paper_bgcolor:'transparent',plot_bgcolor:'transparent', 9228 font:{color:'#a0a0a0',size:11}, 9229 xaxis:{title:'Daily Return (%)',gridcolor:'rgba(255,255,255,0.05)',zeroline:false}, 9230 yaxis:{title:'Frequency',gridcolor:'rgba(255,255,255,0.05)',zeroline:false}, 9231 margin:{l:50,r:20,t:30,b:40}, 9232 bargap:0.05, 9233 showlegend:false, 9234 shapes:[ 9235 {type:'line',x0:d.historical_var_pct,x1:d.historical_var_pct,y0:0,y1:1, 9236 xref:'x',yref:'paper',line:{color:'#f23645',width:2,dash:'dash'}}, 9237 {type:'line',x0:d.cvar_pct,x1:d.cvar_pct,y0:0,y1:1, 9238 xref:'x',yref:'paper',line:{color:'#ff9900',width:2,dash:'dot'}}, 9239 ], 9240 annotations:[ 9241 {x:d.historical_var_pct,y:1,xref:'x',yref:'paper', 9242 text:`VaR ${d.historical_var_pct.toFixed(2)}%`, 9243 showarrow:true,arrowhead:2,arrowcolor:'#f23645',arrowsize:0.8, 9244 font:{color:'#f23645',size:10},ax:30,ay:-20, 9245 bgcolor:'rgba(0,0,0,0.55)',borderpad:3}, 9246 {x:d.cvar_pct,y:0.78,xref:'x',yref:'paper', 9247 text:`CVaR ${d.cvar_pct.toFixed(2)}%`, 9248 showarrow:true,arrowhead:2,arrowcolor:'#ff9900',arrowsize:0.8, 9249 font:{color:'#ff9900',size:10},ax:-35,ay:-20, 9250 bgcolor:'rgba(0,0,0,0.55)',borderpad:3}, 9251 ] 9252 },{responsive:true,displayModeBar:false}); 9253 9254 $('var-results').style.display=''; 9255} 9256async function runPortfolioOptimize(){ 9257 const tickers=_varTickers.map(r=>r.ticker.trim().toUpperCase()).filter(Boolean); 9258 const errEl=$('var-opt-error'); 9259 errEl.style.display='none'; 9260 if(tickers.length<2){errEl.textContent='Add at least 2 tickers to optimize.';errEl.style.display='';return;} 9261 const _optBtn=$('var-opt-btn'); 9262 if(_optBtn){_optBtn.innerHTML='<span class="spin"></span> Optimizingâ¦';_optBtn.disabled=true;} 9263 $('var-opt-loading').style.display=''; 9264 $('var-optimize-results').style.display='none'; 9265 try{ 9266 const res=await api('POST','/api/var/optimize',{tickers}); 9267 if(!res.ok){const e=await res.json().catch(()=>({}));throw new Error(e.detail||'Optimization failed');} 9268 const d=await res.json(); 9269 renderOptimizeResults(d,tickers); 9270 }catch(e){errEl.textContent=e.message||'Optimization error';errEl.style.display='';} 9271 finally{ 9272 $('var-opt-loading').style.display='none'; 9273 if(_optBtn){_optBtn.innerHTML='â¡ Optimize';_optBtn.disabled=false;} 9274 } 9275} 9276 9277function renderOptimizeResults(d,currentTickers){ 9278 const cur=Object.fromEntries((_varTickers||[]).map(r=>[r.ticker.toUpperCase(),r.weight])); 9279 // ââ Metric cards âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 9280 const card=(label,data,color)=>` 9281 <div class="card" style="border-color:${color}20"> 9282 <div style="font-size:.6rem;color:${color};font-family:var(--mono);letter-spacing:1px;font-weight:700;margin-bottom:10px">${label}</div> 9283 <div style="display:flex;flex-direction:column;gap:5px;font-size:.78rem"> 9284 <div style="display:flex;justify-content:space-between"><span style="color:var(--muted)">Annual Return</span><span style="color:${color};font-weight:700">${data.annual_return_pct}%</span></div> 9285 <div style="display:flex;justify-content:space-between"><span style="color:var(--muted)">Volatility</span><span style="font-weight:700">${data.annual_volatility_pct}%</span></div> 9286 <div style="display:flex;justify-content:space-between"><span style="color:var(--muted)">Sharpe Ratio</span><span style="font-weight:700">${data.sharpe_ratio}</span></div> 9287 </div> 9288 </div>`; 9289 $('var-opt-cards').innerHTML=card('MAX SHARPE RATIO',d.max_sharpe,'#e2c275')+card('MIN VOLATILITY',d.min_volatility,'var(--teal)'); 9290 9291 // ââ Efficient frontier chart ââââââââââââââââââââââââââââââââââââââââââââââ 9292 const frontier={x:d.frontier.x,y:d.frontier.y,mode:'lines',name:'Efficient Frontier',line:{color:'rgba(226,194,117,0.6)',width:2}}; 9293 const spPoint={x:[d.max_sharpe.annual_volatility_pct],y:[d.max_sharpe.annual_return_pct],mode:'markers',name:'Max Sharpe',marker:{color:'#e2c275',size:12,symbol:'star'}}; 9294 const mvPoint={x:[d.min_volatility.annual_volatility_pct],y:[d.min_volatility.annual_return_pct],mode:'markers',name:'Min Volatility',marker:{color:'#26c6da',size:12,symbol:'diamond'}}; 9295 const traces=[frontier,spPoint,mvPoint]; 9296 // Current portfolio point if VaR was run 9297 const curVol=$('var-metrics-grid')?.querySelector?.('[data-metric="vol"]')?.dataset?.val; 9298 const curRet=$('var-metrics-grid')?.querySelector?.('[data-metric="ret"]')?.dataset?.val; 9299 if(curVol&&curRet) traces.push({x:[parseFloat(curVol)],y:[parseFloat(curRet)],mode:'markers',name:'Current',marker:{color:'#ef4444',size:10,symbol:'circle'}}); 9300 Plotly.newPlot('var-chart-frontier',traces,{ 9301 paper_bgcolor:'transparent',plot_bgcolor:'transparent', 9302 font:{color:'#b0c4de',size:11}, 9303 xaxis:{title:'Volatility (%)',gridcolor:'#1e2a45',zerolinecolor:'#1e2a45'}, 9304 yaxis:{title:'Annual Return (%)',gridcolor:'#1e2a45',zerolinecolor:'#1e2a45'}, 9305 legend:{bgcolor:'transparent'},margin:{t:10,b:50,l:50,r:10}, 9306 },{responsive:true,displayModeBar:false}); 9307 9308 // ââ Weight comparison table âââââââââââââââââââââââââââââââââââââââââââââââ 9309 const allTickers=[...new Set([...currentTickers,...Object.keys(d.max_sharpe.weights),...Object.keys(d.min_volatility.weights)])]; 9310 const rows=allTickers.map(tk=>` 9311 <tr style="border-bottom:1px solid var(--border)"> 9312 <td style="padding:7px 10px;font-family:var(--mono);font-weight:700;color:var(--text)">${tk}</td> 9313 <td style="padding:7px 10px;text-align:right;color:var(--muted)">${(cur[tk]||0).toFixed(1)}%</td> 9314 <td style="padding:7px 10px;text-align:right;color:#e2c275;font-weight:700">${(d.max_sharpe.weights[tk]||0).toFixed(1)}%</td> 9315 <td style="padding:7px 10px;text-align:right;color:#26c6da;font-weight:700">${(d.min_volatility.weights[tk]||0).toFixed(1)}%</td> 9316 </tr>`).join(''); 9317 $('var-opt-table').innerHTML=` 9318 <table style="width:100%;border-collapse:collapse;font-size:.78rem"> 9319 <thead><tr style="border-bottom:2px solid var(--border)"> 9320 <th style="padding:7px 10px;text-align:left;color:var(--muted);font-weight:600">TICKER</th> 9321 <th style="padding:7px 10px;text-align:right;color:var(--muted);font-weight:600">CURRENT</th> 9322 <th style="padding:7px 10px;text-align:right;color:#e2c275;font-weight:600">MAX SHARPE</th> 9323 <th style="padding:7px 10px;text-align:right;color:#26c6da;font-weight:600">MIN VOL</th> 9324 </tr></thead> 9325 <tbody>${rows}</tbody> 9326 </table>`; 9327 9328 $('var-optimize-results').style.display=''; 9329} 9330 9331// âââ END PORTFOLIO RISK âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 9332 9333// âââ PADDLE PORTAL ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 9334async function manageSubscription() { 9335 const btn = $('manage-sub-btn'); 9336 const orig = btn ? btn.textContent : ''; 9337 if (btn) btn.textContent = 'â³ loadingâ¦'; 9338 try { 9339 const res = await api('GET', '/api/paddle/portal-url'); 9340 const data = await res.json(); 9341 if (data.portal_url) { 9342 window.open(data.portal_url, '_blank'); 9343 } else { 9344 toast(data.detail||'Could not load subscription portal','err'); 9345 } 9346 } catch(e) { 9347 toast('Connection error â please try again','err'); 9348 } finally { 9349 if (btn) btn.textContent = orig; 9350 } 9351} 9352 9353// âââ PADDLE SUBSCRIPTION CHECKOUT ââââââââââââââââââââââââââââââââââââââââââââ 9354function paddleCheckout(plan, emailOverride) { 9355 const email = emailOverride || USER.email || ''; 9356 const priceIds = { 9357 creator: 'pri_01kkn2vffa3v1e5ttkjakq0xwc', 9358 pro: 'pri_01kkn2x451ydvjegg0w6kzrp6d', 9359 stellar: window._paddleStellarId || '', 9360 }; 9361 const priceId = priceIds[plan] || priceIds.creator; 9362 if (!priceId) { toast('Stellar checkout not yet configured â check back soon', 'err'); return; } 9363 try { 9364 Paddle.Checkout.open({ 9365 items: [{ priceId, quantity: 1 }], 9366 customer: email ? { email } : undefined, 9367 settings: { successUrl: window.location.origin + '/?paid=1' }, 9368 }); 9369 } catch(e) { 9370 // Paddle.js not ready â fallback to hosted checkout 9371 const base = `https://buy.paddle.com/checkout?items[0][price_id]=${priceId}&items[0][quantity]=1`; 9372 window.open(email ? `${base}&customer_email=${encodeURIComponent(email)}` : base, '_blank'); 9373 } 9374} 9375 9376// âââ CRYPTO SUBSCRIPTION CHECKOUT ââââââââââââââââââââââââââââââââââââââââââââ 9377async function cryptoCheckout(plan) { 9378 const btn = event.currentTarget; 9379 const orig = btn.textContent; 9380 btn.textContent = 'Creating invoiceâ¦'; 9381 btn.disabled = true; 9382 try { 9383 const res = await api('GET', `/api/payments/subscription-checkout?plan=${plan}`); 9384 const data = await res.json(); 9385 if (data.invoice_url) { 9386 window.open(data.invoice_url, '_blank'); 9387 } else { 9388 toast('Could not create invoice â try again','err'); 9389 } 9390 } catch(e) { 9391 toast('Payment provider error â try again','err'); 9392 } finally { 9393 btn.textContent = orig; 9394 btn.disabled = false;
9395 } 9396} 9397 9398async function loadHeatmap(){ 9399 const status=$('heatmap-status'); 9400 const grid=$('heatmap-grid'); 9401 status.textContent='â³ Loading sector dataâ¦'; 9402 grid.innerHTML=''; 9403 try{ 9404 const res=await api('GET','/api/scanner/heatmap'); 9405 const d=await res.json(); 9406 if(!res.ok){status.textContent='â '+(d.detail||'Failed');return;} 9407 status.textContent=''; 9408 const asOf=new Date(d.as_of); 9409 $('heatmap-as-of').textContent='Updated '+asOf.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit'}); 9410 grid.innerHTML=d.sectors.map(s=>{ 9411 const c=s.change_pct; 9412 const bg=c>=1.5?'#1a4731':c>=0?'#163a25':c>=-1.5?'#3d1a1a':'#5c1010'; 9413 const col=c>=0?'#4caf82':'#ef5350'; 9414 const sign=c>=0?'+':''; 9415 const mv=JSON.stringify(s.movers||{}).replace(/'/g,'''); 9416 return `<div style="background:${bg};border:1px solid ${col}33;border-radius:10px;padding:14px 12px;cursor:default" data-movers='${mv}' data-sector="${s.sector}"> 9417 <div style="font-size:.65rem;color:var(--muted);font-family:var(--mono);margin-bottom:4px">${s.ticker}</div> 9418 <div style="font-size:.82rem;font-weight:700;color:var(--text);margin-bottom:6px">${s.sector}</div> 9419 <div style="font-size:1.1rem;font-weight:800;color:${col}">${sign}${c.toFixed(2)}%</div> 9420 <div style="font-size:.68rem;color:var(--muted);margin-top:3px">$${s.price.toFixed(2)}</div> 9421 </div>`; 9422 }).join(''); 9423 _attachHeatmapTooltips(); 9424 }catch(e){status.textContent='â '+e.message;} 9425} 9426 9427function _attachHeatmapTooltips(){ 9428 const tip=document.getElementById('hm-tooltip'); 9429 if(!tip) return; 9430 document.querySelectorAll('#heatmap-grid > div').forEach(cell=>{ 9431 cell.addEventListener('mouseenter',e=>{
9432 try{ 9433 const m=JSON.parse(cell.dataset.movers||'{}'); 9434 const sector=cell.dataset.sector||''; 9435 if(!m.winners&&!m.losers) return; 9436 const winRows=(m.winners||[]).map(x=>`<div class="hmt-row"><span class="hmt-ticker hmt-up">${x.ticker}</span><span class="hmt-up">+${x.change_pct.toFixed(2)}%</span></div>`).join(''); 9437 const loseRows=(m.losers||[]).map(x=>`<div class="hmt-row"><span class="hmt-ticker hmt-dn">${x.ticker}</span><span class="hmt-dn">${x.change_pct.toFixed(2)}%</span></div>`).join(''); 9438 tip.innerHTML=`<div class="hmt-sector">${sector.toUpperCase()}</div><div class="hmt-label">â² WINNERS</div>${winRows}<div class="hmt-label">â¼ LOSERS</div>${loseRows}`; 9439 tip.style.display='block'; 9440 _moveHmTip(e); 9441 }catch(_){} 9442 }); 9443 cell.addEventListener('mousemove',_moveHmTip); 9444 cell.addEventListener('mouseleave',()=>{tip.style.display='none';}); 9445 }); 9446} 9447function _moveHmTip(e){ 9448 const tip=document.getElementById('hm-tooltip'); 9449 if(!tip) return; 9450 const x=e.clientX+14,y=e.clientY+14; 9451 tip.style.left=(x+tip.offsetWidth>window.innerWidth?x-tip.offsetWidth-28:x)+'px'; 9452 tip.style.top=(y+tip.offsetHeight>window.innerHeight?y-tip.offsetHeight-28:y)+'px'; 9453} 9454 9455function clearFinForm(mode){ 9456 if(mode==='public'){ 9457 $('fin-company').value=''; 9458 $('fin-period').value='annual'; 9459 } else if(mode==='private'){ 9460 $('fin-private-company').value=''; 9461 $('fin-private-period').value='annual'; 9462 $('fin-file').value=''; 9463 $('fin-file-name').textContent=''; 9464 $('fin-scan-status').textContent=''; 9465 } else if(mode==='raw'){ 9466 $('fin-raw-company').value=''; 9467 $('fin-raw-period').value='annual'; 9468 $('fin-raw-text').value=''; 9469 } 9470 $('fin-results').style.display='none'; 9471 $('fin-status').textContent=''; 9472 _finData=null; 9473} 9474 9475let _finHistOffset=0; 9476const _FIN_HIST_LIMIT=10; 9477 9478async function loadFinHistory(more){ 9479 if(!more){ _finHistOffset=0; _finHistory=[]; } 9480 try{ 9481 const res=await api('GET',`/api/financials/history?limit=${_FIN_HIST_LIMIT}&offset=${_finHistOffset}`); 9482 const d=await res.json(); 9483 if(!res.ok) return; 9484 const items=d.items||[]; 9485 _finHistory = more ? [..._finHistory, ...items] : items; 9486 renderFinHistoryList(); 9487 $('fin-history-wrap').style.display=d.total>0?'block':'none'; 9488 $('fin-history-more').style.display=(d.total>_finHistOffset+_FIN_HIST_LIMIT)?'block':'none'; 9489 _finHistOffset+=items.length; 9490 }catch(e){} 9491} 9492 9493function renderFinHistoryList(){ 9494 const modeIcon={public:'ð¢',private:'ð',raw:'ð'}; 9495 const aId=_finCompareA?.id, bId=_finCompareB?.id; 9496 $('fin-history-list').innerHTML=_finHistory.map(s=>` 9497 <div class="hist-row" style="${s.id===aId?'border-color:var(--teal);':s.id===bId?'border-color:var(--green);':''}align-items:center"> 9498 <span style="font-size:.7rem;color:var(--muted);font-family:var(--mono)">${modeIcon[s.mode]||'ð'}</span> 9499 <span style="flex:1;font-size:.8rem;font-weight:600;color:var(--text);cursor:pointer" onclick='restoreFinStatement(${JSON.stringify(JSON.stringify(s))})'>${s.company}</span> 9500 <span style="font-size:.72rem;color:var(--text2)">${s.period}</span> 9501 <span style="font-size:.65rem;color:var(--muted);font-family:var(--mono)">${new Date(s.created_at).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'})}</span> 9502 <button class="btn btn-ghost" style="font-size:.62rem;padding:3px 7px;${s.id===aId?'border-color:var(--teal);color:var(--teal)':''}" onclick="selectFinCompareSlot('${s.id}','A',event)">A</button> 9503 <button class="btn btn-ghost" style="font-size:.62rem;padding:3px 7px;${s.id===bId?'border-color:var(--green);color:var(--green)':''}" onclick="selectFinCompareSlot('${s.id}','B',event)">B</button> 9504 <button class="btn btn-ghost" style="font-size:.62rem;padding:3px 7px;color:var(--red)" onclick="deleteFinStatement('${s.id}',event)">ð</button> 9505 </div>`).join(''); 9506} 9507 9508async function selectFinCompareSlot(id, slot, evt){ 9509 evt.stopPropagation(); 9510 const item=_finHistory.find(h=>h.id===id); 9511 if(!item?.data){ toast('No data for this statement','err'); return; } 9512 if(slot==='A') _finCompareA=item; else _finCompareB=item; 9513 renderFinHistoryList(); 9514 if(_finCompareA && _finCompareB) renderFinComparison(); 9515 else{ $('fin-compare-wrap').style.display='none'; toast(`Set as ${slot} â now pick ${slot==='A'?'B':'A'}`, 'ok'); } 9516} 9517 9518function renderFinComparison(){ 9519 const a=_finCompareA, b=_finCompareB; 9520 if(!a||!b) return; 9521 $('fin-compare-label').textContent=`${a.company}
9521 · ${a.period} vs ${b.company} · ${b.period}`; 9522 $('fin-compare-wrap').style.display='block'; 9523 showFinCompareTab(_finCompareTab); 9524 $('fin-compare-wrap').scrollIntoView({behavior:'smooth',block:'nearest'}); 9525} 9526 9527function showFinCompareTab(tab){ 9528 _finCompareTab=tab; 9529 ['balance','income','cashflow','equity'].forEach(t=>{ 9530 const btn=$('fcmp-tab-'+t); 9531 if(btn){ btn.className=t===tab?'btn active-filter':'btn btn-ghost'; btn.style.fontSize='.7rem'; btn.style.padding='4px 10px'; } 9532 }); 9533 const a=_finCompareA, b=_finCompareB; 9534 if(!a||!b){ $('fin-compare-table').innerHTML=''; return; } 9535 const secMap={balance:['balance_sheet','Balance Sheet'],income:['income_statement','Income Statement'],cashflow:['cash_flow','Cash Flow'],equity:['equity_statement',"Shareholders' Equity"]}; 9536 const [key,title]=secMap[tab]||secMap.balance; 9537 const rowsA=flattenFinObj(a.data[key]||{}); 9538 const rowsB=flattenFinObj(b.data[key]||{}); 9539 const mapB={}; rowsB.forEach(r=>mapB[r.label]=r.value); 9540 const fmt=v=>typeof v==='number'?v.toLocaleString():(v||'â'); 9541 const rows=rowsA.map(r=>{ 9542 const vA=typeof r.value==='number'?r.value:0; 9543 const vB=typeof mapB[r.label]==='number'?mapB[r.label]:0; 9544 const delta=vB-vA; 9545 const pct=vA?((delta/Math.abs(vA))*100).toFixed(1)+'%':'â'; 9546 const col=delta===0?'var(--muted)':delta>0?'var(--green)':'var(--red)'; 9547 return `<tr style="border-bottom:1px solid var(--border)"> 9548 <td style="padding:6px 8px;color:var(--text2);font-size:.78rem">${r.label}</td> 9549 <td style="padding:6px 8px;text-align:right;font-family:var(--mono);font-size:.78rem">${fmt(r.value)}</td> 9550 <td style="padding:6px 8px;text-align:right;font-family:var(--mono);font-size:.78rem">${fmt(mapB[r.label]??'â')}</td> 9551 <td style="padding:6px 8px;text-align:right;font-family:var(--mono);font-size:.78rem;color:${col}">${delta===0?'â':(delta>0?'+':'')+delta.toLocaleString()}</td> 9552 <td style="padding:6px 8px;text-align:right;font-family:var(--mono);font-size:.78rem;color:${col}">${pct}</td> 9553 </tr>`; 9554 }).join(''); 9555 $('fin-compare-table').innerHTML=` 9556 <div style="font-size:.65rem;color:var(--muted);letter-spacing:1px;margin-bottom:8px">${title.toUpperCase()}</div> 9557 <div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse"> 9558 <thead><tr style="border-bottom:1px solid var(--border)"> 9559 <th style="padding:6px 8px;text-align:left;font-size:.65rem;color:var(--muted);letter-spacing:1px">LINE ITEM</th> 9560 <th style="padding:6px 8px;text-align:right;font-size:.65rem;color:var(--teal);letter-spacing:1px">A</th> 9561 <th style="padding:6px 8px;text-align:right;font-size:.65rem;color:var(--green);letter-spacing:1px">B</th> 9562 <th style="padding:6px 8px;text-align:right;font-size:.65rem;color:var(--muted);letter-spacing:1px">DELTA</th> 9563 <th style="padding:6px 8px;text-align:right;font-size:.65rem;color:var(--muted);letter-spacing:1px">%</th> 9564 </tr></thead> 9565 <tbody>${rows}</tbody> 9566 </table></div>`; 9567} 9568 9569function clearFinCompare(){ 9570 _finCompareA=null; _finCompareB=null; 9571 $('fin-compare-wrap').style.display='none'; 9572 renderFinHistoryList(); 9573} 9574 9575async function deleteFinStatement(id, evt){ 9576 evt.stopPropagation(); 9577 if(!confirm('Delete this statement?')) return; 9578 const res=await api('DELETE',`/api/financials/history/${id}`); 9579 if(res.ok||res.status===204){ 9580 toast('Deleted','ok'); 9581 if(_finCompareA?.id===id) _finCompareA=null; 9582 if(_finCompareB?.id===id) _finCompareB=null; 9583 loadFinHistory(false); 9584 } else toast('Delete failed','err'); 9585} 9586 9587function downloadFinCompareCSV(){ 9588 if(!_finCompareA||!_finCompareB) return; 9589 const secMap={balance:'balance_sheet',income:'income_statement',cashflow:'cash_flow',equity:'equity_statement'}; 9590 const key=secMap[_finCompareTab]||'balance_sheet';
9591 const rowsA=flattenFinObj(_finCompareA.data[key]||{}); 9592 const rowsB=flattenFinObj(_finCompareB.data[key]||{}); 9593 const mapB={}; rowsB.forEach(r=>mapB[r.label]=r.value); 9594 const labelA=`${_finCompareA.company} (${_finCompareA.period})`; 9595 const labelB=`${_finCompareB.company} (${_finCompareB.period})`; 9596 const csv=`"Line Item","${labelA}","${labelB}","Delta","% Change"\n`+rowsA.map(r=>{ 9597 const vA=typeof r.value==='number'?r.value:0; 9598 const vB=typeof mapB[r.label]==='number'?mapB[r.label]:0; 9599 const delta=vB-vA; 9600 const pct=vA?((delta/Math.abs(vA))*100).toFixed(1)+'%':'â'; 9601 return `"${r.label}",${vA},${vB},${delta},"${pct}"`; 9602 }).join('\n'); 9603 const a=document.createElement('a'); 9604 a.href='data:text/csv;charset=utf-8,'+encodeURIComponent(csv); 9605 a.download=`compare_${_finCompareTab}_${Date.now()}.csv`; 9606 a.click(); 9607} 9608 9609function restoreFinStatement(sJson){ 9610 const s=JSON.parse(sJson); 9611 _finData=s.data; 9612 $('fin-status').textContent=`â ${s.company} · ${s.period} · ${s.data.currency||'USD'} (${s.data.unit||''})`; 9613 showFinTab('balance'); 9614 $('fin-results').style.display='block'; 9615 $('fin-results').scrollIntoView({behavior:'smooth',block:'nearest'}); 9616} 9617 9618function setFinMode(mode){ 9619 _finMode = mode; 9620 $('fin-public-form').style.display = mode==='public' ? 'block' : 'none'; 9621 $('fin-private-form').style.display = mode==='private' ? 'block' : 'none'; 9622 $('fin-raw-form').style.display = mode==='raw' ? 'block' : 'none'; 9623 $('fin-mode-public').className = mode==='public' ? 'btn active-filter' : 'btn btn-ghost'; 9624 $('fin-mode-private').className = mode==='private' ? 'btn active-filter' : 'btn btn-ghost'; 9625 $('fin-mode-raw').className = mode==='raw' ? 'btn active-filter' : 'btn btn-ghost'; 9626 $('fin-results').style.display = 'none'; 9627 $('fin-status').textContent = ''; 9628} 9629 9630async function generateFinancials(){ 9631 const company = ($('fin-company').value||'').trim(); 9632 if(!company){ toast('Enter a company or ticker','err'); return; } 9633 $('fin-status').textContent = 'â³ Fetching financial statements via Sonar AIâ¦'; 9634 $('fin-results').style.display = 'none'; 9635 try { 9636 const res = await api('POST', '/api/financials/public', { company, period: $('fin-period').value }); 9637 const d = await res.json(); 9638 if(!res.ok){ $('fin-status').textContent = 'â ' + (d.detail||'Failed'); return; } 9639 _finData = d; 9640 $('fin-status').textContent = `â ${d.company||company} · ${d.period||''} · ${d.currency||'USD'} (${d.unit||''})`; 9641 showFinTab('balance'); 9642 $('fin-results').style.display = 'block'; 9643 } catch(e){ $('fin-status').textContent = 'â Error: '+e.message; } 9644} 9645 9646async function generateFinancialsPrivate(){ 9647 const company = ($('fin-private-company').value||'').trim(); 9648 const fileInput = $('fin-file'); 9649 if(!company){ toast('Enter company name','err'); return; } 9650 if(!fileInput.files[0]){ toast('Upload a file','err'); return; } 9651 $('fin-status').textContent = 'â³ Processing file and generating statementsâ¦'; 9652 $('fin-results').style.display = 'none'; 9653 const fd = new FormData(); 9654 fd.append('file', fileInput.files[0]); 9655 fd.append('company_name', company); 9656 fd.append('period', $('fin-private-period').value); 9657 try { 9658 const res = await fetch('/api/financials/private', { 9659 method:'POST', headers:{Authorization:'Bearer '+localStorage.getItem('fz_token')}, body: fd 9660 }); 9661 const d = await res.json(); 9662 if(!res.ok){ $('fin-status').textContent = 'â ' + (d.detail||'Failed'); return; } 9663 _finData = d; 9664 $('fin-status').textContent = `â ${d.company||company} · ${d.period||''} · ${d.currency||'USD'} (${d.unit||''})`; 9665 showFinTab('balance'); 9666 $('fin-results').style.display = 'block'; 9667 } catch(e){ $('fin-status').textContent = 'â Error: '+e.message; } 9668} 9669 9670async function generateFinancialsRaw(){ 9671 const company = ($('fin-raw-company').value||'').trim(); 9672 const rawText = ($('fin-raw-text').value||'').trim(); 9673 if(!company){ toast('Enter company name','err'); return; } 9674 if(!rawText){ toast('Paste some financial data first','err'); return; } 9675 $('fin-status').textContent = 'â³ Mapping data to GAAP statements via Sonar AIâ¦'; 9676 $('fin-results').style.display = 'none'; 9677 try { 9678 const res = await api('POST', '/api/financials/raw', { 9679 company, period: $('fin-raw-period').value, raw_text: rawText 9680 }); 9681 const d = await res.json(); 9682 if(!res.ok){ $('fin-status').textContent = 'â ' + (d.detail||'Failed'); return; } 9683 _finData = d; 9684 $('fin-status').textContent = `â ${d.company||company}
9684 · ${d.period||''} · ${d.currency||'USD'} (${d.unit||''})`; 9685 showFinTab('balance'); 9686 $('fin-results').style.display = 'block'; 9687 } catch(e){ $('fin-status').textContent = 'â Error: '+e.message; } 9688} 9689 9690function showFinTab(tab){ 9691 _finTab = tab; 9692 document.querySelectorAll('.fin-tab').forEach(b=>{ 9693 b.classList.toggle('active-filter', b.dataset.tab===tab); 9694 b.classList.toggle('btn-ghost', b.dataset.tab!==tab); 9695 }); 9696 const el = $('fin-tab-content'); 9697 if(!_finData){ el.innerHTML=''; return; } 9698 if(tab==='balance') el.innerHTML = renderFinTable('Balance Sheet', flattenFinObj(_finData.balance_sheet||{})); 9699 if(tab==='income') el.innerHTML = renderFinTable('Income Statement', flattenFinObj(_finData.income_statement||{})); 9700 if(tab==='cashflow') el.innerHTML = renderFinTable('Cash Flow Statement', flattenFinObj(_finData.cash_flow||{})); 9701 if(tab==='equity') el.innerHTML = renderFinTable("Shareholders' Equity", flattenFinObj(_finData.equity_statement||{})); 9702 if(tab==='summary') el.innerHTML = renderFinSummary(_finData); 9703} 9704 9705function flattenFinObj(obj, prefix=''){ 9706 const rows = []; 9707 for(const [k, v] of Object.entries(obj)){ 9708 const label = (prefix ? prefix+' · ' : '') + k.replace(/_/g,' ').replace(/\b\w/g,c=>c.toUpperCase()); 9709 if(typeof v==='object' && v!==null) rows.push(...flattenFinObj(v, label)); 9710 else rows.push({label, value: v}); 9711 } 9712 return rows; 9713} 9714 9715function renderFinTable(title, rows){ 9716 const currency = _finData?.currency||'USD'; 9717 const unit = _finData?.unit||''; 9718 return `<div style="font-size:.65rem;color:var(--muted);letter-spacing:1px;margin-bottom:10px">${title.toUpperCase()} · ${currency} · ${unit}</div> 9719 <table style="width:100%;border-collapse:collapse;font-size:.8rem"> 9720 ${rows.map(r=>`<tr style="border-bottom:1px solid var(--border)"> 9721 <td style="padding:6px 8px;color:var(--text2)">${r.label}</td> 9722 <td style="padding:6px 8px;text-align:right;color:var(--text);font-family:var(--mono)">${typeof r.value==='number'?r.value.toLocaleString():r.value}</td> 9723 </tr>`).join('')} 9724 </table>`; 9725} 9726 9727function renderFinSummary(data){ 9728 if(!data) return ''; 9729 const currency = data.currency||'USD'; 9730 const unit = data.unit||''; 9731 const sections = [ 9732 {key:'balance_sheet', label:'Balance Sheet', id:'fin-acc-bs'}, 9733 {key:'income_statement', label:'Income Statement', id:'fin-acc-is'}, 9734 {key:'cash_flow', label:'Cash Flow Statement', id:'fin-acc-cf'}, 9735 {key:'equity_statement', label:"Shareholders' Equity", id:'fin-acc-eq'}, 9736 ]; 9737 return sections.map((s,i)=>{ 9738 const rows = flattenFinObj(data[s.key]||{}); 9739 const tbl = rows.map(r=>`<tr style="border-bottom:1px solid var(--border)"> 9740 <td style="padding:6px 8px;color:var(--text2)">${r.label}</td> 9741 <td style="padding:6px 8px;text-align:right;color:var(--text);font-family:var(--mono)">${typeof r.value==='number'?r.value.toLocaleString():r.value}</td> 9742 </tr>`).join(''); 9743 const open = i===0; 9744 return `<div style="border:1px solid var(--border);border-radius:8px;margin-bottom:8px;overflow:hidden"> 9745 <div onclick="toggleFinAcc('${s.id}')" style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;cursor:pointer;background:var(--bg2);user-select:none"> 9746 <span style="font-size:.7rem;font-family:var(--mono);letter-spacing:2px;color:var(--text);font-weight:700">${s.label.toUpperCase()}</span> 9747 <span style="display:flex;align-items:center;gap:8px"> 9748 <span style="font-size:.65rem;color:var(--muted)">${currency} · ${unit}</span> 9749 <span id="${s.id}-arrow" style="color:var(--muted);font-size:.75rem">${open?'â²':'â¼'}</span> 9750 </span> 9751 </div> 9752 <div id="${s.id}" style="display:${open?'block':'none'}"> 9753 <table style="width:100%;border-collapse:collapse;font-size:.8rem">${tbl}</table> 9754 </div> 9755 </div>`; 9756 }).join(''); 9757} 9758 9759function toggleFinAcc(id){ 9760 const el=$(id), arrow=$(id+'-arrow'); 9761 if(!el) return; 9762 const open = el.style.display!=='none'; 9763 el.style.display = open?'none':'block'; 9764 if(arrow) arrow.textContent = open?'â¼':'â²'; 9765} 9766 9767function downloadFinTab(){ 9768 if(!_finData||!_finTab) return; 9769 let rows=[]; 9770 if(_finTab==='balance') rows=flattenFinObj(_finData.balance_sheet||{}); 9771 if(_finTab==='income') rows=flattenFinObj(_finData.income_statement||{}); 9772 if(_finTab==='cashflow') rows=flattenFinObj(_finData.cash_flow||{}); 9773 if(_finTab==='equity') rows=flattenFinObj(_finData.equity_statement||{}); 9774 if(_finTab==='summary') rows=[ 9775 ...flattenFinObj(_finData.balance_sheet||{}).map(r=>({label:'Balance Sheet · '+r.label,value:r.value})), 9776 ...flattenFinObj(_finData.income_statement||{}).map(r=>({label:'Income Statement · '+r.label,value:r.value})), 9777 ...flattenFinObj(_finData.cash_flow||{}).map(r=>({label:'Cash Flow · '+r.label,value:r.value})), 9778 ...flattenFinObj(_finData.equity_statement||{}).map(r=>({label:"Shareholders Equity · "+r.label,value:r.value})), 9779 ]; 9780 const csv='Line Item,Value\n'+rows.map(r=>`"${r.label}","${r.value}"`).join('\n'); 9781 const a=document.createElement('a'); 9782 a.href='data:text/csv;charset=utf-8,'+encodeURIComponent(csv); 9783 a.download=`${_finData.company||'company'}_${_finTab}_${Date.now()}.csv`; 9784 a.click(); 9785} 9786 9787async function scanFinancialPhoto(input){ 9788 if(!input.files[0]) return; 9789 $('fin-scan-status').textContent = 'â³ Reading handwritingâ¦'; 9790 const fd = new FormData(); 9791 fd.append('file', input.files[0]); 9792 try { 9793 const res = await fetch('/api/financials/scan', { 9794 method:'POST', headers:{Authorization:'Bearer '+localStorage.getItem('fz_token')}, body: fd 9795 }); 9796 if(!res.ok){ 9797 const d = await res.json(); 9798 $('fin-scan-status').textContent = 'â ' + (d.detail||'Scan failed'); 9799 return; 9800 } 9801 const blob = await res.blob(); 9802 const a = document.createElement('a'); 9803 a.href = URL.createObjectURL(blob); 9804 a.download = 'factoz_scanned_financials.xlsx'; 9805 a.click(); 9806 URL.revokeObjectURL(a.href); 9807 $('fin-scan-status').textContent = 'â Downloaded â review, then upload above'; 9808 } catch(e){ 9809 $('fin-scan-status').textContent = 'â Error: '+e.message; 9810 } 9811 input.value = ''; 9812} 9813 9814// âââ Polymarket v2 âââââââââââââââââââââââââââââââââââââââââââ 9815 9816// state 9817let _pmCurrentTab = 'markets'; 9818let _pmMarkets = []; 9819let _pmVolChart = null; 9820let _pmSSE = null; 9821let _pmVolTimer = null; 9822let _pmWatches = []; 9823 9824// tab switch 9825function pmTab(name){ 9826 ['markets','volume','book','alerts'].forEach(t=>{ 9827 const btn=$(`pm-tab-${t}`), panel=$(`pm-panel-${t}`); 9828 if(!btn||!panel)return; 9829 const active=(t===name); 9830 panel.style.display=active?'':'none'; 9831 btn.classList.toggle('pm-tab-active',active); 9832 }); 9833 _pmCurrentTab=name; 9834 if(name==='markets' && !_pmMarkets.length) loadPmMarkets(); 9835 if(name==='volume') loadPmVolumeChart(); 9836 if(name==='alerts' ){ loadPmAlerts(); loadPmWatches(); } 9837} 9838 9839// ââ MYFXBOOK / TRADING âââââââââââââââââââââââââââââââââââââââââââââââââââââ 9840async function mfxInit(){ 9841 // Check connection status and show the right panel 9842 try{ 9843 const r=await fetch('/api/myfxbook/status',{headers:{'Authorization':'Bearer '+TOKEN}}); 9844 if(!r.ok){_mfxShowConnect();return;} 9845 const d=await r.json(); 9846 if(d.connected){ 9847 $('mfx-connected-info').textContent=`Connected as ${d.email}`; 9848 _mfxShowConnected(); 9849 mfxLoadAccounts(); 9850 }else{ 9851 _mfxShowConnect(); 9852 } 9853 }catch(e){_mfxShowConnect();} 9854} 9855 9856function _mfxShowConnect(){ 9857 $('mfx-connect-section').style.display='block'; 9858 $('mfx-connected-section').style.display='none'; 9859 $('mfx-status').style.display='none'; 9860} 9861function _mfxShowConnected(){ 9862 $('mfx-connect-section').style.display='none'; 9863 $('mfx-connected-section').style.display='block'; 9864} 9865 9866async function mfxConnect(){ 9867 const email=$('mfx-email').value.trim(); 9868 const pw=$('mfx-password').value; 9869 if(!email||!pw){toast('Enter email and password','err');return;} 9870 const btn=document.querySelector('#mfx-connect-section .btn-primary'); 9871 if(btn){btn.disabled=true;btn.textContent='Connecting...';} 9872 try{ 9873 const r=await fetch('/api/myfxbook/connect',{ 9874 method:'POST', 9875 headers:{'Authorization':'Bearer '+TOKEN,'Content-Type':'application/json'}, 9876 body:JSON.stringify({email,password:pw}) 9877 }); 9878 const d=await r.json(); 9879 if(r.ok){ 9880 toast('MyFXBook connected!','ok'); 9881 $('mfx-password').value=''; 9882 $('mfx-connected-info').textContent=`Connected as ${email}`; 9883 _mfxShowConnected(); 9884 mfxLoadAccounts(); 9885 }else{ 9886 toast(d.detail||'Connection failed','err'); 9887 } 9888 }catch(e){toast('Connection error','err');} 9889 finally{if(btn){btn.disabled=false;btn.textContent='Connect MyFXBook â';}} 9890} 9891 9892async function mfxDisconnect(){ 9893 if(!confirm('Disconnect MyFXBook? You can reconnect anytime.'))return; 9894 try{ 9895 const r=await fetch('/api/myfxbook/disconnect',{method:'DELETE',headers:{'Authorization':'Bearer '+TOKEN}}); 9896 if(r.ok){toast('Disconnected','ok');_mfxShowConnect();} 9897 }catch(e){toast('Error','err');} 9898} 9899 9900async function mfxLoadAccounts(){ 9901 const grid=$('mfx-accounts-grid'); 9902 const status=$('mfx-status'); 9903 grid.innerHTML='';
9904 status.style.display='block'; 9905 status.textContent='Loading accounts...'; 9906 try{ 9907 const r=await fetch('/api/myfxbook/accounts',{headers:{'Authorization':'Bearer '+TOKEN}}); 9908 if(r.status===401){ 9909 const d=await r.json(); 9910 if(d.detail==='session_expired'){ 9911 status.textContent='Session expired â please reconnect.'; 9912 _mfxShowConnect(); 9913 return; 9914 } 9915 } 9916 if(!r.ok){status.textContent='Could not load accounts.';return;} 9917 const d=await r.json(); 9918 const accs=d.accounts||[]; 9919 status.style.display='none'; 9920 if(!accs.length){status.style.display='block';status.textContent='No trading accounts found on this MyFXBook profile.';return;} 9921 accs.forEach(acc=>{ grid.insertAdjacentHTML('beforeend',_mfxAccountCard(acc)); }); 9922 }catch(e){status.textContent='Error loading accounts.';} 9923} 9924 9925function _mfxAccountCard(acc){ 9926 const gain=parseFloat(acc.gain||0); 9927 const dd=parseFloat(acc.drawdown||0); 9928 const bal=parseFloat(acc.balance||0); 9929 const gainColor=gain>=0?'var(--green)':'#f85149'; 9930 const gainSign=gain>=0?'+':''; 9931 const currency=acc.currency||'USD'; 9932 return `<div style="background:var(--bg2);border:1px solid var(--border);border-radius:12px;padding:20px"> 9933 <div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:14px"> 9934 <div> 9935 <div style="font-weight:700;font-size:.92rem;color:var(--text)">${acc.name||'Account'}</div> 9936 <div style="font-size:.72rem;color:var(--muted);margin-top:2px">${acc.broker||''} · ${acc.demo?'Demo':'Live'}</div> 9937 </div> 9938 <div style="text-align:right"> 9939 <div style="font-size:1.6rem;font-weight:800;font-family:var(--mono);color:${gainColor}">${gainSign}${gain.toFixed(2)}%</div> 9940 <div style="font-size:.65rem;color:var(--muted);font-family:var(--mono)">TOTAL GAIN</div> 9941 </div> 9942 </div> 9943 <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:16px"> 9944 <div style="background:var(--bg3);border-radius:8px;padding:10px"> 9945 <div style="font-size:.65rem;color:var(--muted);font-family:var(--mono);margin-bottom:3px">BALANCE</div> 9946 <div style="font-size:.95rem;font-weight:700;color:var(--text);font-family:var(--mono)">${currency} ${bal.toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}</div> 9947 </div> 9948 <div style="background:var(--bg3);border-radius:8px;padding:10px"> 9949 <div style="font-size:.65rem;color:var(--muted);font-family:var(--mono);margin-bottom:3px">MAX DRAWDOWN</div> 9950 <div style="font-size:.95rem;font-weight:700;color:#f85149;font-family:var(--mono)">${dd.toFixed(2)}%</div> 9951 </div> 9952 </div> 9953 <div style="display:flex;gap:8px"> 9954 <button class="btn btn-ghost" style="flex:1;font-size:.75rem;justify-content:center" onclick="mfxViewTrades(${acc.id})">Open Trades</button> 9955 <button class="btn btn-ghost" style="flex:1;font-size:.75rem;justify-content:center" onclick="mfxRoiChart(${JSON.stringify(acc).replace(/"/g,'"')})">ð ROI Chart</button> 9956 <button class="btn btn-primary" style="flex:1;font-size:.75rem;justify-content:center" onclick="mfxGenerateCard(${JSON.stringify(acc).replace(/"/g,'"')})">ð¤ Share Card</button> 9957 </div> 9958 </div>`; 9959} 9960 9961async function mfxViewTrades(accountId){ 9962 try{ 9963 const r=await fetch(`/api/myfxbook/accounts/${accountId}/trades`,{headers:{'Authorization':'Bearer '+TOKEN}}); 9964 if(!r.ok){toast('Could not load trades','err');return;} 9965 const d=await r.json(); 9966 const trades=d.trades||[]; 9967 if(!trades.length){toast('No open trades','ok');return;} 9968 const rows=trades.map(t=>`<tr> 9969 <td style="padding:8px 12px">${t.symbol||t.pair||'â'}</td> 9970 <td style="padding:8px 12px">${t.type||'â'}</td> 9971 <td style="padding:8px 12px;font-family:var(--mono)">${t.openPrice||'â'}</td> 9972 <td style="padding:8px 12px;font-family:var(--mono);color:${parseFloat(t.profit||0)>=0?'var(--green)':'#f85149'}">${t.profit||'â'}</td> 9973 </tr>`).join(''); 9974 const html=`<table style="width:100%;border-collapse:collapse;font-size:.82rem"> 9975 <thead><tr style="border-bottom:1px solid var(--border);color:var(--muted)"> 9976 <th style="padding:8px 12px;text-align:left">Symbol</th><th style="padding:8px 12px;text-align:left">Type</th> 9977 <th style="padding:8px 12px;text-align:left">Open</th><th style="padding:8px 12px;text-align:left">P&L</th> 9978 </tr></thead><tbody>${rows}</tbody></table>`; 9979 _mfxModal('Open Trades', html); 9980 }catch(e){toast('Error','err');} 9981} 9982 9983function _mfxModal(title, bodyHtml){ 9984 let m=document.getElementById('mfx-modal'); 9985 if(!m){
9986 m=document.createElement('div'); 9987 m.id='mfx-modal'; 9988 m.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:9000;display:flex;align-items:center;justify-content:center;padding:20px'; 9989 m.onclick=e=>{if(e.target===m)m.remove();}; 9990 document.body.appendChild(m); 9991 } 9992 m.innerHTML=`<div style="background:var(--bg2);border:1px solid var(--border);border-radius:14px;padding:24px;max-width:600px;width:100%;max-height:80vh;overflow-y:auto"> 9993 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"> 9994 <div style="font-weight:700;font-size:.95rem">${title}</div> 9995 <button onclick="document.getElementById('mfx-modal').remove()" style="background:none;border:none;color:var(--muted);font-size:1.2rem;cursor:pointer">â</button> 9996 </div> 9997 ${bodyHtml} 9998 </div>`; 9999 m.style.display='flex'; 10000} 10001 10002async function mfxGenerateCard(acc){ 10003 if(typeof acc==='string') acc=JSON.parse(acc); 10004 // Fetch equity curve for sparkline 10005 let gainData=[]; 10006 try{ 10007 const r=await fetch(`/api/myfxbook/accounts/${acc.id}/gain`,{headers:{'Authorization':'Bearer '+TOKEN}}); 10008 if(r.ok){const d=await r.json();gainData=d.gain||[];} 10009 }catch(e){} 10010 10011 const canvas=$('mfx-card-canvas'); 10012 canvas.width=1080; canvas.height=1080; 10013 const ctx=canvas.getContext('2d'); 10014 10015 // Background 10016 ctx.fillStyle='#080808'; 10017 ctx.fillRect(0,0,1080,1080); 10018 10019 // Border accent 10020 ctx.strokeStyle='#00c853'; 10021 ctx.lineWidth=3; 10022 ctx.strokeRect(20,20,1040,1040); 10023 10024 // Header band 10025 ctx.fillStyle='rgba(0,200,83,.08)'; 10026 ctx.fillRect(20,20,1040,120); 10027 10028 // FACTOZ wordmark 10029 ctx.fillStyle='#00c853'; 10030 ctx.font='bold 28px monospace'; 10031 ctx.fillText('FACTOZ',48,72); 10032 10033 // MyFXBook label 10034 ctx.fillStyle='rgba(255,255,255,.3)'; 10035 ctx.font='14px monospace'; 10036 ctx.textAlign='right'; 10037 ctx.fillText('via MyFXBook',1032,72); 10038 ctx.textAlign='left'; 10039 10040 // Account name 10041 ctx.fillStyle='rgba(255,255,255,.85)'; 10042 ctx.font='bold 20px monospace'; 10043 ctx.fillText((acc.name||'Trading Account').toUpperCase(),48,115); 10044 10045 // Broker / demo badge 10046 ctx.fillStyle='rgba(255,255,255,.35)'; 10047 ctx.font='14px monospace'; 10048 ctx.textAlign='right'; 10049 ctx.fillText(`${acc.broker||'Broker'} · ${acc.demo?'Demo':'Live'}`,1032,115); 10050 ctx.textAlign='left'; 10051 10052 // Big gain number 10053 const gain=parseFloat(acc.gain||0); 10054 const gainSign=gain>=0?'+':''; 10055 ctx.fillStyle=gain>=0?'#00c853':'#f85149'; 10056 ctx.font='bold 160px monospace'; 10057 ctx.textAlign='center'; 10058 ctx.fillText(`${gainSign}${gain.toFixed(2)}%`,540,360); 10059 10060 ctx.fillStyle='rgba(255,255,255,.35)'; 10061 ctx.font='20px monospace'; 10062 ctx.fillText('TOTAL GAIN',540,400); 10063 ctx.textAlign='left'; 10064 10065 // Stats row 10066 const currency=acc.currency||'USD'; 10067 const bal=parseFloat(acc.balance||0); 10068 const dd=parseFloat(acc.drawdown||0); 10069 const eq=parseFloat(acc.equity||bal); 10070 const stats=[ 10071 {label:'BALANCE',value:`${currency} ${bal.toLocaleString(undefined,{maximumFractionDigits:0})}`,color:'#e2eaf2'}, 10072 {label:'EQUITY',value:`${currency} ${eq.toLocaleString(undefined,{maximumFractionDigits:0})}`,color:'#e2eaf2'}, 10073 {label:'MAX DD',value:`${dd.toFixed(2)}%`,color:'#f85149'}, 10074 ]; 10075 const sw=1040/3;
10076 stats.forEach((s,i)=>{ 10077 const x=20+sw*i; 10078 ctx.fillStyle='rgba(255,255,255,.04)'; 10079 ctx.fillRect(x+1,470,sw-2,130); 10080 ctx.fillStyle='rgba(255,255,255,.3)'; 10081 ctx.font='14px monospace'; 10082 ctx.textAlign='center'; 10083 ctx.fillText(s.label,x+sw/2,510); 10084 ctx.fillStyle=s.color; 10085 ctx.font='bold 30px monospace'; 10086 ctx.fillText(s.value,x+sw/2,560); 10087 }); 10088 ctx.textAlign='left'; 10089 10090 // Sparkline equity curve 10091 if(gainData.length>1){ 10092 const vals=gainData.map(p=>Array.isArray(p)?parseFloat(p[1]):parseFloat(p.value||0)); 10093 const minV=Math.min(...vals), maxV=Math.max(...vals); 10094 const range=maxV-minV||1; 10095 const spX=48, spY=660, spW=984, spH=180; 10096 ctx.fillStyle='rgba(0,200,83,.06)'; 10097 ctx.fillRect(spX,spY,spW,spH+30); 10098 ctx.beginPath(); 10099 vals.forEach((v,i)=>{ 10100 const px=spX+i/(vals.length-1)*spW; 10101 const py=spY+spH-(v-minV)/range*spH; 10102 i===0?ctx.moveTo(px,py):ctx.lineTo(px,py); 10103 }); 10104 ctx.strokeStyle=gain>=0?'#00c853':'#f85149'; 10105 ctx.lineWidth=3; 10106 ctx.stroke(); 10107 ctx.fillStyle='rgba(255,255,255,.25)'; 10108 ctx.font='13px monospace'; 10109 ctx.fillText('90-DAY EQUITY CURVE',spX+8,spY+spH+22); 10110 } 10111 10112 // Date stamp 10113 const today=new Date().toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}); 10114 ctx.fillStyle='rgba(255,255,255,.2)'; 10115 ctx.font='14px monospace'; 10116 ctx.textAlign='right'; 10117 ctx.fillText(today,1032,980); 10118 ctx.fillStyle='rgba(0,200,83,.6)'; 10119 ctx.fillText('factozcentral.com',1032,1005); 10120 ctx.textAlign='left'; 10121 10122 // Preview in modal + offer download + post 10123 const dataUrl=canvas.toDataURL('image/png'); 10124 const html=`<div style="text-align:center"> 10125 <img src="${dataUrl}" style="max-width:100%;border-radius:8px;margin-bottom:16px"> 10126 <div style="display:flex;gap:10px;justify-content:center"> 10127 <a href="${dataUrl}" download="trading-card.png" class="btn btn-ghost" style="font-size:.8rem">⬠Download PNG</a> 10128 </div> 10129 </div>`; 10130 _mfxModal(`${acc.name} â Performance Card`, html); 10131} 10132 10133async function mfxPostCard(){ 10134 const dataUrl=window._mfxCardDataUrl; 10135 if(!dataUrl){toast('Generate card first','err');return;} 10136 // Convert data URL to blob and pre-load post modal 10137 const res=await fetch(dataUrl); 10138 const blob=await res.blob(); 10139 const file=new File([blob],'trading-card.png',{type:'image/png'}); 10140 document.getElementById('mfx-modal')?.remove(); 10141 // Pre-fill the post modal if it exists, otherwise offer download 10142 const postInput=document.getElementById('post-image'); 10143 const captionInput=document.getElementById('post-caption'); 10144 if(postInput&&captionInput){ 10145 const dt=new DataTransfer();dt.items.add(file);postInput.files=dt.files; 10146 const gain=parseFloat(window._mfxLastAcc?.gain||0); 10147 const sign=gain>=0?'+':''; 10148 captionInput.value=`${sign}${gain.toFixed(2)}% â sharing my trading performance. Track yours at myfxbook.com #forex #trading #myfxbook`; 10149 go('post'); 10150 toast('Card loaded in Post tab â add platforms and publish','ok'); 10151 }else{ 10152 const a=document.createElement('a');a.href=dataUrl;a.download='trading-card.png';a.click(); 10153 toast('Card downloaded','ok'); 10154 } 10155} 10156 10157async function mfxRoiChart(acc){ 10158 if(typeof acc==='string') acc=JSON.parse(acc); 10159 10160 // Show loading modal immediately 10161 _mfxModal(`${acc.name} â ROI Chart`, `<div style="text-align:center;padding:40px;color:var(--muted)">Loading equity data...</div>`); 10162 10163 // Fetch 90-day gain data 10164 let gainData=[]; 10165 try{ 10166 const r=await fetch(`/api/myfxbook/accounts/${acc.id}/gain`,{headers:{'Authorization':'Bearer '+TOKEN}}); 10167 if(r.ok){const d=await r.json();gainData=d.gain||[];} 10168 }catch(e){} 10169 10170 if(!gainData.length){ 10171 _mfxModal(`${acc.name} â ROI Chart`,`<div style="text-align:center;padding:32px;color:var(--muted)">No equity data available for this account.</div>`); 10172 return; 10173 } 10174 10175 // Parse data points: MyFXBook returns [[date, value], ...] or [{date, value}, ...] 10176 const points=gainData.map(p=>({ 10177 date: Array.isArray(p)?p[0]:p.date||p[0], 10178 val: Array.isArray(p)?parseFloat(p[1]):parseFloat(p.value||p[1]||0) 10179 })).filter(p=>!isNaN(p.val)); 10180 10181 if(points.length<2){ 10182 _mfxModal(`${acc.name} â ROI Chart`,`<div style="text-align:center;padding:32px;color:var(--muted)">Not enough data points to draw chart.</div>`); 10183 return; 10184 } 10185 10186 // Draw on canvas 1200Ã675 (16:9 â great for social sharing) 10187 const canvas=$('mfx-card-canvas'); 10188 canvas.width=1200; canvas.height=675; 10189 const ctx=canvas.getContext('2d'); 10190 const W=1200, H=675; 10191 const PAD={top:80,right:60,bottom:90,left:90}; 10192 const cW=W-PAD.left-PAD.right, cH=H-PAD.top-PAD.bottom; 10193
10194 // Background 10195 ctx.fillStyle='#080808'; ctx.fillRect(0,0,W,H); 10196 10197 // Header bar 10198 ctx.fillStyle='rgba(0,200,83,.07)'; ctx.fillRect(0,0,W,70); 10199 ctx.fillStyle='#00c853'; ctx.font='bold 22px monospace'; 10200 ctx.fillText('FACTOZ',24,44); 10201 const gain=parseFloat(acc.gain||0); 10202 const gainStr=(gain>=0?'+':'')+gain.toFixed(2)+'%'; 10203 ctx.fillStyle=gain>=0?'#00c853':'#f85149'; 10204 ctx.font='bold 22px monospace'; 10205 ctx.textAlign='right'; 10206 ctx.fillText(gainStr,W-24,44); 10207 ctx.fillStyle='rgba(255,255,255,.5)'; 10208 ctx.font='13px monospace'; 10209 ctx.textAlign='center'; 10210 ctx.fillText((acc.name||'Account').toUpperCase()+' â 90-DAY ROI CURVE',W/2,44); 10211 ctx.textAlign='left'; 10212 10213 // Compute Y range with padding 10214 const vals=points.map(p=>p.val); 10215 const minV=Math.min(...vals), maxV=Math.max(...vals); 10216 const vRange=(maxV-minV)||1; 10217 const vPad=vRange*0.12; 10218 const yMin=minV-vPad, yMax=maxV+vPad; 10219 10220 const toX=i=>PAD.left+i/(points.length-1)*cW; 10221 const toY=v=>PAD.top+cH-(v-yMin)/(yMax-yMin)*cH; 10222 10223 // Horizontal grid lines 10224 const gridCount=5; 10225 for(let i=0;i<=gridCount;i++){ 10226 const v=yMin+i/gridCount*(yMax-yMin); 10227 const y=toY(v); 10228 ctx.strokeStyle='rgba(255,255,255,.07)'; 10229 ctx.lineWidth=1; 10230 ctx.beginPath(); ctx.moveTo(PAD.left,y); ctx.lineTo(PAD.left+cW,y); ctx.stroke(); 10231 ctx.fillStyle='rgba(255,255,255,.35)'; 10232 ctx.font='11px monospace'; 10233 ctx.textAlign='right'; 10234 ctx.fillText((v>=0?'+':'')+v.toFixed(1)+'%',PAD.left-8,y+4); 10235 } 10236 ctx.textAlign='left'; 10237 10238 // X axis date labels (show ~6 evenly spaced) 10239 const labelCount=6; 10240 for(let i=0;i<=labelCount;i++){ 10241 const idx=Math.round(i/labelCount*(points.length-1)); 10242 const x=toX(idx); 10243 const label=points[idx].date?(points[idx].date+'').substring(0,10):''; 10244 ctx.fillStyle='rgba(255,255,255,.3)'; 10245 ctx.font='10px monospace'; 10246 ctx.textAlign='center'; 10247 ctx.fillText(label,x,PAD.top+cH+28); 10248 ctx.strokeStyle='rgba(255,255,255,.05)'; 10249 ctx.lineWidth=1; 10250 ctx.beginPath(); ctx.moveTo(x,PAD.top); ctx.lineTo(x,PAD.top+cH); ctx.stroke(); 10251 } 10252 ctx.textAlign='left'; 10253 10254 // Zero line (if 0 is in range) 10255 if(yMin<0&&yMax>0){ 10256 const y0=toY(0); 10257 ctx.strokeStyle='rgba(255,255,255,.2)'; 10258 ctx.setLineDash([4,6]); ctx.lineWidth=1; 10259 ctx.beginPath(); ctx.moveTo(PAD.left,y0); ctx.lineTo(PAD.left+cW,y0); ctx.stroke(); 10260 ctx.setLineDash([]); 10261 } 10262 10263 // Fill area under curve 10264 ctx.beginPath(); 10265 ctx.moveTo(toX(0),toY(points[0].val)); 10266 points.forEach((p,i)=>ctx.lineTo(toX(i),toY(p.val))); 10267 ctx.lineTo(toX(points.length-1),PAD.top+cH); 10268 ctx.lineTo(PAD.left,PAD.top+cH); 10269 ctx.closePath(); 10270 const grad=ctx.createLinearGradient(0,PAD.top,0,PAD.top+cH); 10271 grad.addColorStop(0,gain>=0?'rgba(0,200,83,.25)':'rgba(248,81,73,.25)'); 10272 grad.addColorStop(1,'rgba(0,0,0,0)'); 10273 ctx.fillStyle=grad; ctx.fill(); 10274 10275 // Main curve line 10276 ctx.beginPath(); 10277 points.forEach((p,i)=>{ 10278 i===0?ctx.moveTo(toX(i),toY(p.val)):ctx.lineTo(toX(i),toY(p.val)); 10279 }); 10280 ctx.strokeStyle=gain>=0?'#00c853':'#f85149'; 10281 ctx.lineWidth=2.5; ctx.stroke(); 10282 10283 // Start/end dots 10284 ctx.fillStyle=gain>=0?'#00c853':'#f85149'; 10285 [[0,points[0].val],[points.length-1,points[points.length-1].val]].forEach(([i,v])=>{ 10286 ctx.beginPath(); ctx.arc(toX(i),toY(v),5,0,Math.PI*2); ctx.fill(); 10287 }); 10288 10289 // Footer 10290 const today=new Date().toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}); 10291 ctx.fillStyle='rgba(255,255,255,.18)'; ctx.font='12px monospace'; 10292 ctx.textAlign='right'; 10293 ctx.fillText('via myfxbook.com · '+today,W-24,H-18); 10294 ctx.fillStyle='rgba(0,200,83,.5)'; 10295 ctx.fillText('factozcentral.com',W-24,H-36); 10296 ctx.textAlign='left'; 10297 10298 // Show in modal 10299 const dataUrl=canvas.toDataURL('image/png'); 10300 const html=`<div style="text-align:center"> 10301 <img src="${dataUrl}" style="max-width:100%;border-radius:8px;margin-bottom:16px"> 10302 <div style="display:flex;gap:10px;justify-content:center"> 10303 <a href="${dataUrl}" download="${(acc.name||'roi-chart').replace(/\s+/g,'-').toLowerCase()}.png" class="btn btn-ghost" style="font-size:.8rem">⬠Download PNG</a> 10304 </div> 10305 </div>`; 10306 _mfxModal(`${acc.name} â ROI Chart`, html); 10307} 10308 10309// ââ END MYFXBOOK âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 10310 10311// status bar
10312async function pmLoadStatus(){ 10313 try{ 10314 const r=await api('GET','/api/polymarket/status'); 10315 if(!r.ok)return; 10316 const d=await r.json(); 10317 const dot=$('pm-monitor-dot'); 10318 const lbl=$('pm-alerts-today-label'); 10319 if(dot) dot.innerHTML=`<span style="color:${d.running?'#00c853':'#888'}">${d.running?'● LIVE':'○ offline'}</span>`; 10320 if(lbl) lbl.textContent=`Alerts today: ${d.alerts_today||0}`; 10321 }catch(e){} 10322} 10323 10324// full refresh (called by Refresh button + go('polymarket')) 10325async function pmRefreshAll(){ 10326 pmLoadStatus(); 10327 if(_pmCurrentTab==='markets') loadPmMarkets(); 10328 if(_pmCurrentTab==='volume') loadPmVolumeChart(); 10329 if(_pmCurrentTab==='alerts'){ loadPmAlerts(); loadPmWatches(); } 10330} 10331 10332// ââ Markets tab ââââââââââââââââââââââââââââââââââââââââââââââ 10333async function loadPmMarkets(){ 10334 const list=$('pm-markets-list'); 10335 if(!list)return; 10336 list.innerHTML='<p style="font-size:.8rem;color:var(--text2)">Loading marketsâ¦</p>'; 10337 try{ 10338 const r=await api('GET','/api/polymarket/markets?limit=50'); 10339 const d=await r.json(); 10340 if(!r.ok){list.innerHTML=`<p style="font-size:.8rem;color:var(--red)">${d.detail||'Failed'}</p>`;return;} 10341 _pmMarkets=d; 10342 // populate book select 10343 const sel=$('pm-book-select'); 10344 if(sel){ 10345 const tids=d.filter(m=>m.clobTokenIds); 10346 sel.innerHTML='<option value="">â select a market â</option>'+ 10347 tids.map(m=>{ 10348 let tids2=m.clobTokenIds; 10349 if(typeof tids2==='string'){try{tids2=JSON.parse(tids2);}catch(e){tids2=[];}} 10350 const tid=Array.isArray(tids2)?tids2[0]:''; 10351 return `<option value="${tid}">${m.question.substring(0,70)}</option>`; 10352 }).join(''); 10353 } 10354 if(!d.length){list.innerHTML='<p style="font-size:.8rem;color:var(--text2)">No active markets found.</p>';return;} 10355 const watchIds=new Set(_pmWatches.map(w=>w.market_id)); 10356 const fmt=v=>v>=1e6?'$'+(v/1e6).toFixed(1)+'M':v>=1e3?'$'+(v/1e3).toFixed(0)+'K':'$'+v.toFixed(0); 10357 list.innerHTML=d.map(m=>{ 10358 const slug=m.slug||''; 10359 const url=`https://polymarket.com/market/${slug}`; 10360 const v24=parseFloat(m.volume24hr||m.volume||0); 10361 const liq=parseFloat(m.liquidity||0); 10362 let yesPrice=null; 10363 if(m.outcomePrices){try{const op=JSON.parse(m.outcomePrices);yesPrice=parseFloat(op[0]);}catch(e){}} 10364 const cid=m.conditionId||m.condition_id||m.id; 10365 const watching=watchIds.has(cid)||watchIds.has(m.id); 10366 const yesColor=yesPrice!=null?(yesPrice>.5?'#00c853':'#f23645'):'#888'; 10367 const yesPct=yesPrice!=null?(yesPrice*100).toFixed(1)+'%':'â'; 10368 // probability bar width 10369 const barW=yesPrice!=null?Math.round(yesPrice*100):0; 10370 return ` 10371 <div style="background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin-bottom:10px;transition:border-color .15s" onmouseenter="this.style.borderColor='rgba(0,200,83,.35)'" onmouseleave="this.style.borderColor='var(--border)'"> 10372 <div style="display:flex;align-items:flex-start;gap:12px;margin-bottom:10px"> 10373 <!-- probability pill --> 10374 <div style="flex-shrink:0;display:flex;flex-direction:column;align-items:center;gap:3px;min-width:46px"> 10375 <div style="font-size:1rem;font-weight:800;font-family:var(--mono);color:${yesColor};line-height:1">${yesPct}</div> 10376 <div style="font-size:.58rem;color:var(--muted);letter-spacing:1px">YES</div> 10377 <div style="width:100%;height:3px;background:var(--bg3);border-radius:2px;margin-top:2px"> 10378 <div style="width:${barW}%;height:100%;background:${yesColor};border-radius:2px;transition:width .3s"></div> 10379 </div> 10380 </div> 10381 <!-- question --> 10382 <a href="${url}" target="_blank" style="flex:1;font-size:.83rem;font-weight:600;color:#e2eaf2;text-decoration:none;line-height:1.4">${m.question}</a> 10383 <!-- watch button --> 10384 <button class="btn btn-ghost" style="flex-shrink:0;font-size:.68rem;padding:4px 12px;${watching?'color:var(--teal);border-color:var(--teal);background:rgba(0,200,83,.07)':''}" 10385 data-mid="${cid||m.id}" onclick="pmToggleWatch(this)"> 10386 ${watching?'â Watching':'Watch'} 10387 </button> 10388 </div> 10389 <!-- stats row --> 10390 <div style="display:flex;gap:18px;font-size:.71rem;font-family:var(--mono);color:var(--text2);padding-top:8px;border-top:1px solid var(--border20)">
10391 <span>24h Vol <b style="color:#c4d0e0">${fmt(v24)}</b></span> 10392 <span>Liq <b style="color:#c4d0e0">${fmt(liq)}</b></span> 10393 <span style="margin-left:auto"><a href="${url}" target="_blank" style="color:var(--teal);text-decoration:none">Trade â</a></span> 10394 </div> 10395 </div>`; 10396 }).join(''); 10397 }catch(e){if(list)list.innerHTML=`<p style="font-size:.8rem;color:var(--red)">Error: ${e.message}</p>`;} 10398} 10399 10400// ââ Volume chart tab âââââââââââââââââââââââââââââââââââââââââ 10401async function loadPmVolumeChart(){ 10402 const canvas=$('pm-vol-chart'); 10403 const empty=$('pm-vol-empty'); 10404 if(!canvas)return; 10405 try{ 10406 const r=await api('GET','/api/polymarket/markets?limit=10'); 10407 const d=await r.json(); 10408 if(!r.ok||!d.length){if(empty)empty.style.display='';canvas.style.display='none';return;} 10409 const labels=d.map(m=>m.question.substring(0,30)+'â¦'); 10410 const values=d.map(m=>parseFloat(m.volume24hr||m.volume||0)); 10411 if(empty)empty.style.display='none'; 10412 canvas.style.display=''; 10413 if(_pmVolChart){_pmVolChart.destroy();_pmVolChart=null;} 10414 _pmVolChart=new Chart(canvas,{ 10415 type:'bar', 10416 data:{ 10417 labels, 10418 datasets:[{ 10419 label:'24h Volume ($)', 10420 data:values, 10421 backgroundColor:'rgba(0,200,83,0.25)', 10422 borderColor:'rgba(0,200,83,0.8)', 10423 borderWidth:1, 10424 borderRadius:4, 10425 }] 10426 }, 10427 options:{ 10428 indexAxis:'y', 10429 responsive:true, 10430 plugins:{legend:{display:false},tooltip:{callbacks:{label:ctx=>'$'+(ctx.raw||0).toLocaleString()}}}, 10431 scales:{ 10432 x:{ticks:{color:'#8898aa',font:{size:10},callback:v=>v>=1e6?'$'+(v/1e6).toFixed(1)+'M':v>=1e3?'$'+(v/1e3).toFixed(0)+'K':'$'+v},grid:{color:'rgba(255,255,255,.04)'}}, 10433 y:{ticks:{color:'#c4d0e0',font:{size:10}},grid:{display:false}} 10434 } 10435 } 10436 }); 10437 }catch(e){console.error('pmVolChart',e);} 10438} 10439 10440// ââ Order Book tab âââââââââââââââââââââââââââââââââââââââââââ 10441async function loadPmBook(){ 10442 const sel=$('pm-book-select'); 10443 const res=$('pm-book-result'); 10444 if(!sel||!res)return; 10445 const tid=sel.value; 10446 if(!tid){res.innerHTML='<p style="font-size:.8rem;color:var(--text2)">Select a market above.</p>';return;} 10447 res.innerHTML='<p style="font-size:.8rem;color:var(--text2)">Fetching order bookâ¦</p>'; 10448 try{ 10449 const r=await api('GET',`/api/polymarket/book?token=${encodeURIComponent(tid)}`); 10450 const d=await r.json(); 10451 if(!r.ok){res.innerHTML=`<p style="font-size:.8rem;color:var(--red)">${d.detail||'Failed'}</p>`;return;} 10452 const bids=d.bids||[]; 10453 const asks=d.asks||[]; 10454 const top=10; 10455 const bestBid=bids.length?parseFloat(bids[0].price):null; 10456 const bestAsk=asks.length?parseFloat(asks[0].price):null; 10457 const mid=bestBid&&bestAsk?(bestBid+bestAsk)/2:null; 10458 const spreadPct=mid&&bestBid&&bestAsk?((bestAsk-bestBid)/mid*100).toFixed(2):null; 10459 const bidDepth=bids.slice(0,5).reduce((s,l)=>s+parseFloat(l.size||0),0); 10460 const askDepth=asks.slice(0,5).reduce((s,l)=>s+parseFloat(l.size||0),0); 10461 const imbalance=askDepth>0?(bidDepth/askDepth).toFixed(2):null; 10462 res.innerHTML=` 10463 <div style="display:flex;gap:16px;flex-wrap:wrap;font-size:.75rem;font-family:var(--mono);margin-bottom:14px;color:var(--text2)"> 10464 ${bestBid!=null?`<span>Best Bid <b style="color:#00c853">${(bestBid*100).toFixed(1)}¢</b>`:''} 10465 ${bestAsk!=null?`<span>Best Ask <b style="color:#f23645">${(bestAsk*100).toFixed(1)}¢</b>`:''} 10466 ${spreadPct?`<span>Spread <b style="color:#ffc107">${spreadPct}%</b>`:''} 10467 ${imbalance?`<span>Bid/Ask Depth <b style="color:#e2eaf2">${imbalance}Ã</b>`:''} 10468 </div> 10469 <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px"> 10470 <div> 10471 <div style="font-size:.6rem;color:var(--muted);letter-spacing:2px;font-family:var(--mono);margin-bottom:6px">BIDS (YES buyers)</div> 10472 <table style="width:100%;border-collapse:collapse;font-size:.74rem"> 10473 <tr style="color:var(--muted)"><th style="padding:3px 6px;text-align:right">Price</th><th style="padding:3px 6px;text-align:right">Size</th></tr> 10474 ${bids.slice(0,top).map(l=>`<tr> 10475 <td style="padding:3px 6px;text-align:right;color:#00c853;font-family:var(--mono)">${(parseFloat(l.price)*100).toFixed(1)}¢</td> 10476 <td style="padding:3px 6px;text-align:right;color:var(--text2);font-family:var(--mono)">${parseFloat(l.size).toFixed(0)}</td> 10477 </tr>`).join('')} 10478 </table> 10479 </div> 10480 <div> 10481 <div style="font-size:.6rem;color:var(--muted);letter-spacing:2px;font-family:var(--mono);margin-bottom:6px">ASKS (YES sellers)</div> 10482 <table style="width:100%;border-collapse:collapse;font-size:.74rem"> 10483 <tr style="color:var(--muted)"><th style="padding:3px 6px;text-align:right">Price</th><th style="padding:3px 6px;text-align:right">Size</th></tr> 10484 ${asks.slice(0,top).map(l=>`<tr> 10485 <td style="padding:3px 6px;text-align:right;color:#f23645;font-family:var(--mono)">${(parseFloat(l.price)*100).toFixed(1)}¢</td> 10486 <td style="padding:3px 6px;text-align:right;color:var(--text2);font-family:var(--mono)">${parseFloat(l.size).toFixed(0)}</td> 10487 </tr>`).join('')} 10488 </table> 10489 </div> 10490 </div>`; 10491 }catch(e){if(res)res.innerHTML=`<p style="font-size:.8rem;color:var(--red)">Error: ${e.message}</p>`;} 10492} 10493 10494// ââ Alerts tab âââââââââââââââââââââââââââââââââââââââââââââââ 10495async function loadPmAlerts(){ 10496 const list=$('pm-alerts-list'); 10497 if(!list)return; 10498 try{ 10499 const r=await api('GET','/api/polymarket/alerts?limit=50'); 10500 const d=await r.json(); 10501 if(!r.ok){list.innerHTML=`<p style="font-size:.8rem;color:var(--red)">${d.detail||'Failed'}</p>`;return;} 10502 if(!d.length){list.innerHTML='<p style="font-size:.8rem;color:var(--text2)">No alerts fired yet. The monitor checks OI, volume, and order books every 60 s.</p>';return;} 10503 list.innerHTML=d.map(a=>_pmAlertCard(a)).join(''); 10504 }catch(e){if(list)list.innerHTML=`<p style="font-size:.8rem;color:var(--red)">Error: ${e.message}</p>`;} 10505} 10506 10507function _pmAlertCard(a){ 10508 const typeColor={oi_spike:'#a78bfa',volume_surge:'#00c853',book_imbalance:'#fbbf24',spread_spike:'#f97316'}; 10509 const typeLabel={oi_spike:'OI SPIKE',volume_surge:'VOL SURGE',book_imbalance:'BOOK IMBALANCE',spread_spike:'SPREAD SPIKE'}; 10510 const tc=typeColor[a.alert_type]||'#888'; 10511 const tl=typeLabel[a.alert_type]||a.alert_type||'ALERT'; 10512 let detail=''; 10513 if(a.oi_change_pct!=null) detail+=`<span>OI Î <b style="color:#e2eaf2">+${a.oi_change_pct.toFixed(1)}%</b></span>`;
10514 if(a.live_volume!=null) detail+=`<span>Vol <b style="color:#e2eaf2">$${(a.live_volume||0).toLocaleString(undefined,{maximumFractionDigits:0})}</b></span>`; 10515 if(a.spike_ratio!=null) detail+=`<span><b style="color:#e2eaf2">${a.spike_ratio.toFixed(1)}Ã</b> baseline</span>`; 10516 if(a.book_imbalance!=null)detail+=`<span>Imbalance <b style="color:#e2eaf2">${a.book_imbalance.toFixed(1)}Ã</b></span>`; 10517 if(a.spread_pct!=null) detail+=`<span>Spread <b style="color:#e2eaf2">${a.spread_pct.toFixed(1)}%</b></span>`; 10518 if(a.flow_direction&&a.flow_direction!=='BOTH') detail+=`<span>Flow <b style="color:${a.flow_direction==='YES'?'#00c853':'#f23645'}">${a.flow_direction}</b></span>`; 10519 return ` 10520 <div style="background:var(--bg2);border:1px solid var(--border);border-left:3px solid ${tc};border-radius:8px;padding:11px 14px;margin-bottom:8px"> 10521 <div style="display:flex;align-items:flex-start;gap:8px;margin-bottom:5px"> 10522 <span style="font-size:.64rem;font-weight:700;padding:2px 7px;border-radius:4px;background:${tc}22;color:${tc};border:1px solid ${tc}44;white-space:nowrap;flex-shrink:0">${tl}</span> 10523 <div style="font-size:.81rem;font-weight:600;color:#e2eaf2;line-height:1.3;flex:1">${a.question}</div> 10524 <span style="font-size:.68rem;color:var(--muted);white-space:nowrap;flex-shrink:0">${new Date(a.created_at).toLocaleTimeString()}</span> 10525 </div> 10526 <div style="display:flex;gap:14px;font-size:.71rem;color:var(--text2);font-family:var(--mono);flex-wrap:wrap;margin-bottom:6px">${detail}</div> 10527 <a href="${a.market_url}" target="_blank" style="font-size:.7rem;color:var(--teal);text-decoration:none;font-family:var(--mono)">Trade on Polymarket â</a> 10528 </div>`; 10529} 10530 10531// ââ Watches ââââââââââââââââââââââââââââââââââââââââââââââââââ 10532async function loadPmWatches(){ 10533 const list=$('pm-watches-list'); 10534 if(!list)return; 10535 try{ 10536 const r=await api('GET','/api/polymarket/watches'); 10537 const d=await r.json(); 10538 _pmWatches=r.ok?d:[]; 10539 if(!r.ok||!d.length){list.innerHTML='<p style="font-size:.8rem;color:var(--text2)">No watched markets yet. Go to Markets tab and click Watch.</p>';return;} 10540 list.innerHTML=d.map(w=>` 10541 <div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border20)"> 10542 <a href="https://polymarket.com/market/${w.market_id}" target="_blank" style="flex:1;font-size:.8rem;color:var(--text);text-decoration:none">${w.question}</a> 10543 <button class="btn btn-ghost" style="font-size:.68rem;padding:3px 9px;color:var(--red);border-color:rgba(255,68,68,.3)" onclick="pmRemoveWatch('${w.id}',this)">Remove</button> 10544 </div>`).join(''); 10545 }catch(e){if(list)list.innerHTML=`<p style="font-size:.8rem;color:var(--red)">Error</p>`;} 10546} 10547 10548async function pmToggleWatch(btn){ 10549 const marketId=btn.dataset.mid; 10550 if(!marketId)return; 10551 try{ 10552 const existing=_pmWatches.find(w=>w.market_id===marketId); 10553 if(existing){ 10554 await pmRemoveWatch(existing.id,null); 10555 btn.textContent='Watch';btn.style.color='';btn.style.borderColor=''; 10556 }else{ 10557 const m=_pmMarkets.find(m=>(m.conditionId||m.condition_id||m.id)===marketId); 10558 const question=m?m.question:''; 10559 let tids=m?m.clobTokenIds:null; 10560 if(typeof tids==='string'){try{tids=JSON.parse(tids);}catch(e){tids=null;}} 10561 const token_id=Array.isArray(tids)?tids[0]:null; 10562 const eid=m?(m.events&&m.events[0]?m.events[0].id:null):null; 10563 const r=await api('POST','/api/polymarket/watch',{market_id:marketId,question,condition_id:marketId,token_id,event_id:eid}); 10564 if(r.ok){ 10565 btn.textContent='â Watching';btn.style.color='var(--teal)';btn.style.borderColor='var(--teal)'; 10566 await loadPmWatches(); 10567 toast('Market added to watch list','ok'); 10568 }else{const d=await r.json();toast(d.detail||'Failed to add watch','err');} 10569 } 10570 }catch(e){toast('Error: '+e.message,'err');} 10571} 10572 10573async function pmRemoveWatch(watchId,btn){ 10574 try{ 10575 const r=await api('DELETE',`/api/polymarket/watch/${watchId}`); 10576 if(r.ok){ 10577 _pmWatches=_pmWatches.filter(w=>w.id!==watchId); 10578 loadPmWatches(); 10579 if(btn)btn.closest('div[style*="padding:8px"]')?.remove(); 10580 } 10581 }catch(e){} 10582} 10583 10584// ââ SSE stream âââââââââââââââââââââââââââââââââââââââââââââââ 10585function pmStartSSE(){ 10586 if(_pmSSE){_pmSSE.close();_pmSSE=null;} 10587 try{ 10588 _pmSSE=new EventSource('/api/polymarket/stream?token='+encodeURIComponent(TOKEN)); 10589 _pmSSE.onmessage=e=>{
10590 try{ 10591 const a=JSON.parse(e.data); 10592 if(a.type==='connected')return; 10593 // live strip 10594 const strip=$('pm-live-strip'); 10595 const txt=$('pm-live-text'); 10596 if(strip&&txt){ 10597 const typeLabel={oi_spike:'OI Spike',volume_surge:'Vol Surge',book_imbalance:'Book Imbalance',spread_spike:'Spread Spike'}; 10598 txt.textContent=`[${typeLabel[a.alert_type]||'Alert'}] ${a.question.substring(0,60)}⦠â ${new Date(a.created_at).toLocaleTimeString()}`; 10599 strip.style.display=''; 10600 } 10601 // if on alerts tab, prepend card 10602 if(_pmCurrentTab==='alerts'){ 10603 const list=$('pm-alerts-list'); 10604 if(list&&list.querySelector('p'))list.innerHTML=''; 10605 if(list) list.insertAdjacentHTML('afterbegin',_pmAlertCard(a)); 10606 } 10607 // update today count 10608 pmLoadStatus(); 10609 }catch(err){} 10610 }; 10611 _pmSSE.onerror=async()=>{ 10612 if(_pmSSE){_pmSSE.close();_pmSSE=null;} 10613 // Verify token is still valid before reconnecting â stops the 403 retry loop 10614 try{ 10615 const r=await fetch('/api/auth/me',{headers:{'Authorization':'Bearer '+TOKEN}}); 10616 if(!r.ok){logout();return;} 10617 }catch(e){} 10618 setTimeout(pmStartSSE,30000); 10619 }; 10620 }catch(e){} 10621} 10622 10623function pmStopSSE(){ 10624 if(_pmSSE){_pmSSE.close();_pmSSE=null;} 10625} 10626 10627// ââ Admin config helpers (updated for v2 fields) âââââââââââââ 10628async function loadPolymarketConfig(){ 10629 try{ 10630 const r=await api('GET','/api/admin/polymarket/config'); 10631 if(!r.ok)return; 10632 const d=await r.json(); 10633 const s=(id,v)=>{const el=$(id);if(el)el.value=v??'';}; 10634 const sc=(id,v)=>{const el=$(id);if(el)el.checked=!!v;}; 10635 s('pm-oi-chg', d.min_oi_change_pct); 10636 s('pm-vol-mult', d.volume_multiplier); 10637 s('pm-book-imb', d.min_book_imbalance); 10638 s('pm-spread-pct',d.max_spread_pct); 10639 s('pm-min-vol24', d.min_volume_24h); 10640 s('pm-max-markets',d.max_markets); 10641 s('pm-cooldown', d.alert_cooldown_sec); 10642 s('pm-poll-interval',d.polling_interval_sec); 10643 s('pm-include-kw',d.include_keywords||''); 10644 s('pm-exclude-kw',d.exclude_keywords||''); 10645 sc('pm-enabled', d.enabled); 10646 }catch(e){console.error('loadPolymarketConfig',e);} 10647} 10648 10649async function savePolymarketConfig(){ 10650 const g=(id)=>{const el=$(id);return el?el.value:null;}; 10651 const gc=(id)=>{const el=$(id);return el?el.checked:false;}; 10652 const payload={ 10653 enabled: gc('pm-enabled'), 10654 min_oi_change_pct: parseFloat(g('pm-oi-chg')) ||10.0, 10655 volume_multiplier: parseFloat(g('pm-vol-mult')) ||1.5, 10656 min_book_imbalance: parseFloat(g('pm-book-imb')) ||3.0, 10657 max_spread_pct: parseFloat(g('pm-spread-pct')) ||5.0, 10658 min_volume_24h: parseFloat(g('pm-min-vol24')) ||10000, 10659 max_markets: parseInt(g('pm-max-markets')) ||50, 10660 alert_cooldown_sec: parseInt(g('pm-cooldown')) ||3600, 10661 polling_interval_sec: parseInt(g('pm-poll-interval'))||60, 10662 include_keywords: g('pm-include-kw')||'', 10663 exclude_keywords: g('pm-exclude-kw')||'', 10664 }; 10665 const res=$('pm-config-result'); 10666 try{ 10667 const r=await api('PUT','/api/admin/polymarket/config',payload); 10668 const d=await r.json(); 10669 if(r.ok){if(res)res.innerHTML='<span style="color:var(--green)">Saved â takes effect within 10 min</span>';toast('Config saved','ok');} 10670 else{if(res)res.innerHTML=`<span style="color:var(--red)">${d.detail||'Save failed'}</span>`;} 10671 }catch(e){if(res)res.innerHTML='<span style="color:var(--red)">Error saving config</span>';} 10672} 10673 10674async function loadDailyBriefConfig(){ 10675 try{ 10676 const r=await api('GET','/api/admin/daily-brief/config'); 10677 if(!r.ok)return; 10678 const d=await r.json(); 10679 const el=$('brief-auto-enabled'); 10680 if(el)el.checked=!!d.enabled; 10681 }catch(e){console.error('loadDailyBriefConfig',e);} 10682} 10683 10684async function saveDailyBriefConfig(){ 10685 const el=$('brief-auto-enabled'); 10686 const enabled=el?el.checked:true; 10687 const res=$('brief-auto-result'); 10688 try{ 10689 const r=await api('PUT','/api/admin/daily-brief/config',{enabled}); 10690 const d=await r.json(); 10691 if(r.ok){if(res)res.textContent=enabled?'Saved â automatic generation on':'Saved â automatic generation off';
10691toast(enabled?'Daily brief automation on':'Daily brief automation off','ok');} 10692 else{if(res)res.textContent=d.detail||'Save failed';} 10693 }catch(e){if(res)res.textContent='Error saving';} 10694} 10695 10696/* ââ NEWSROOM ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 10697 Review queue for world-sourced content. Nothing here posts on its own: 10698 approve stages a draft, publish sends it, and both are explicit clicks. */ 10699function _nrEsc(s){return (s==null?'':String(s)).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));} 10700function _nrSay(msg){const el=$('nr-status');if(el)el.textContent=msg||'';} 10701 10702async function newsroomDiscover(beat){ 10703 _nrSay('Searching for stories⦠this takes 30-60s.'); 10704 try{ 10705 const r=await api('POST','/api/admin/newsroom/discover'+(beat?('?beat='+encodeURIComponent(beat)):'')); 10706 if(!r.ok){_nrSay('Discovery failed to start.');return;} 10707 _nrSay('Discovery running in the background â hit Refresh in about a minute.'); 10708 }catch(e){_nrSay('Discovery error: '+e.message);} 10709} 10710 10711let NR_SHOW_ALL=false; 10712function nrToggleAll(){NR_SHOW_ALL=!NR_SHOW_ALL;loadNewsroom();} 10713 10714async function loadNewsroom(){ 10715 // "pending,approved,partial,failed" is the working queue; NR_SHOW_ALL 10716 // drops the filter so posted and rejected drafts are inspectable too â 10717 // without it there is no way to look at anything already published. 10718 const st = NR_SHOW_ALL ? 'all' : 'pending,approved,partial,failed'; 10719 try{ 10720 const [ri,rd]=await Promise.all([ 10721 api('GET','/api/admin/newsroom/ideas?status=pending&limit=40'), 10722 api('GET','/api/admin/newsroom/drafts?status='+st+'&limit=30') 10723 ]); 10724 // A failed request must not render as "no drafts yet" â that hid a 10725 // server error behind an empty-state message and looked like data loss. 10726 if(!rd.ok){ 10727 const err=await rd.text().catch(()=>''); 10728 _nrSay('Error cargando drafts ('+rd.status+'): '+err.slice(0,200)); 10729 _nrRenderDrafts([]); 10730 }else{ 10731 _nrRenderDrafts(await rd.json()); 10732 } 10733 if(!ri.ok){ 10734 const err=await ri.text().catch(()=>''); 10735 _nrSay('Error cargando ideas ('+ri.status+'): '+err.slice(0,200)); 10736 _nrRenderIdeas([]); 10737 }else{ 10738 _nrRenderIdeas(await ri.json()); 10739 } 10740 }catch(e){_nrSay('Could not load newsroom: '+e.message);} 10741} 10742 10743function _nrRenderIdeas(ideas){ 10744 const wrap=$('nr-ideas'), empty=$('nr-ideas-empty'); 10745 if(!wrap)return; 10746 if(!ideas.length){wrap.innerHTML='';if(empty)empty.style.display='block';return;} 10747 if(empty)empty.style.display='none'; 10748 wrap.innerHTML=ideas.map(i=>` 10749 <div style="border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:11px 13px;margin-bottom:8px"> 10750 <div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-start"> 10751 <div style="flex:1"> 10752 <div style="font-size:.8rem;color:var(--text);margin-bottom:3px">${_nrEsc(i.headline)}</div> 10753 <div style="font-size:.71rem;color:var(--muted)">${_nrEsc((i.summary||'').slice(0,190))}</div> 10754 <div style="font-size:.68rem;color:var(--muted);margin-top:5px;font-family:var(--mono)"> 10755 ${_nrEsc(i.beat)} · score ${i.score} · ${_nrEsc(i.origin||'')} 10756 ${(i.sources&&i.sources.length)?` · <a href="${_nrEsc(i.sources[0])}" target="_blank" rel="noopener" style="color:var(--accent)">fuente</a>`:''} 10757 </div> 10758 </div> 10759 <div style="display:flex;flex-direction:column;gap:5px"> 10760 <button class="btn btn-ghost" style="font-size:.68rem;padding:4px 9px" onclick="nrDraft('${i.id}','carousel',this)">Carousel</button> 10761 <button class="btn btn-ghost" style="font-size:.68rem;padding:4px 9px" onclick="nrDraft('${i.id}','reel',this)">Reel</button> 10762 <button class="btn btn-ghost" style="font-size:.68rem;padding:4px 9px" onclick="nrDraft('${i.id}','card',this)">Card (X)</button> 10763 <button class="btn btn-ghost" style="font-size:.68rem;padding:4px 9px" onclick="nrDismiss('${i.id}',this)">Descartar</button> 10764 </div> 10765 </div> 10766 </div>`).join(''); 10767} 10768
10769function _nrRenderDrafts(drafts){ 10770 const wrap=$('nr-drafts'), empty=$('nr-drafts-empty'); 10771 if(!wrap)return; 10772 if(!drafts.length){wrap.innerHTML='';if(empty)empty.style.display='block';return;} 10773 if(empty)empty.style.display='none'; 10774 wrap.innerHTML=drafts.map(d=>{ 10775 const slides=(d.slides||[]).map((s,n)=>` 10776 <div style="font-size:.71rem;color:var(--muted);padding:3px 0"> 10777 <span style="font-family:var(--mono);color:var(--accent)">${n+1}</span> 10778 <strong style="color:var(--text)">${_nrEsc(s.headline)}</strong> 10779 ${s.body?(' â '+_nrEsc(s.body)):''} 10780 ${s.chart_spec?' <span style="color:var(--accent)">[chart]</span>':''} 10781 </div>`).join(''); 10782 // asset_urls is {lang: [urls]} since X renders its own English images; 10783 // older drafts stored a bare list, so accept both. 10784 // Spanish only. Flattening every language showed a pre-English-removal 10785 // draft twice â 6 Spanish slides followed by 6 English ones. 10786 const au=d.asset_urls; 10787 const urlList=Array.isArray(au)?au:((au&&typeof au==='object')?(au.es||au.en||[]):[]); 10788 // A reel's asset is an mp4, so it needs a <video>, not an <img>. 10789 const imgs=urlList.map(u=> 10790 /\.mp4($|\?)/i.test(u) 10791 ? `<video src="${_nrEsc(u)}" controls playsinline style="width:190px;border-radius:6px;border:1px solid rgba(255,255,255,.1)"></video>` 10792 : `<img src="${_nrEsc(u)}" style="width:104px;border-radius:6px;border:1px solid rgba(255,255,255,.1)">`).join(''); 10793 // Approved drafts stay in the list â they are precisely the ones 10794 // waiting to be published, so hiding them stranded them. 10795 const anim = _nrAnimatedUrl(d); 10796 const pex = _nrPexelsUrl(d); 10797 const les = _nrKeyUrl(d,'leccion'); 10798 const isLesson = (d.slides||[]).some(x=>x&&x.lesson); 10799 NR_ANIM[d.id]=anim; 10800 NR_ANIM_PX[d.id]=pex; 10801 NR_PROMOTED[d.id]=_nrPromotedKey(d); 10802 const approved = d.status==='approved'; 10803 const retryable = d.status==='partial' || d.status==='failed'; 10804 // A posted draft used to fall through to "PENDIENTE DE REVISIÃN" with a 10805 // live Approve button, which then 409s as already_posted â the UI was 10806 // offering an action the server always refuses. 10807 const posted = d.status==='posted'; 10808 const rejected = d.status==='rejected'; 10809 const badge = 10810 d.status==='partial' ? '<span style="color:#ffb020;font-weight:600">â PARCIAL â faltan canales</span>' 10811 : d.status==='failed' ? '<span style="color:#ff6b6b;font-weight:600">â FALLà AL PUBLICAR</span>' 10812 : posted ? '<span style="color:#8c94a8;font-weight:600">â YA PUBLICADO â no se vuelve a enviar</span>' 10813 : rejected ? '<span style="color:#8c94a8;font-weight:600">â RECHAZADO</span>' 10814 : approved ? '<span style="color:#00c864;font-weight:600">â APROBADO â listo para publicar</span>' 10815 : '<span style="color:var(--muted)">PENDIENTE DE REVISIÃN</span>'; 10816 const edge = retryable ? 'rgba(255,176,32,.45)' 10817 : (posted||rejected) ? 'rgba(140,148,168,.28)' 10818 : (approved ? 'rgba(0,200,100,.4)' : 'rgba(0,188,212,.22)'); 10819 return ` 10820 <div style="border:1px solid ${edge};border-radius:8px;padding:13px;margin-bottom:12px"> 10821 <div style="font-size:.68rem;color:var(--muted);font-family:var(--mono);margin-bottom:5px"> 10822 ${_nrEsc(d.beat)} · ${_nrEsc(d.content_format)} · ${(d.slides||[]).length} slides · ${badge} 10823 </div> 10824 <div style="font-size:.86rem;color:var(--text);margin-bottom:2px">${_nrEsc(d.hook_es||'')}</div> 10825 <div style="font-size:.72rem;color:var(--muted);margin-bottom:8px">EN: ${_nrEsc(d.hook_en||'')}</div> 10826 ${d.review_note?`<div style="font-size:.72rem;color:#ffb020;background:rgba(255,176,32,.08);border:1px solid rgba(255,176,32,.3);border-radius:6px;padding:7px 9px;margin-bottom:8px;white-space:pre-wrap">${_nrEsc(d.review_note)}</div>`:''} 10827 <div style="margin:8px 0">${slides}</div> 10828 ${imgs?`<div style="display:flex;gap:6px;flex-wrap:wrap;margin:10px 0">${imgs}</div>`:''} 10829 ${[['animado',anim,'REEL ANIMADO','#ffb020'],['pexels',pex,'REEL CON METRAJE (PEXELS)','#00bcd4'],['leccion',les,'LECCIÃN NARRADA','#8b5cf6']] 10830 .filter(([,u])=>u).map(([k,u,label,col])=>`<div style="margin:10px 0"> 10831 <div style="font-size:.66rem;font-family:var(--mono);color:${col};margin-bottom:4px">PRUEBA · ${label} (no se publica solo)</div> 10832 <video src="${_nrEsc(u)}" controls playsinline style="width:190px;border-radius:6px;border:1px solid ${col}77"></video> 10833 <div style="margin-top:6px"> 10834 ${NR_PROMOTED[d.id]===k 10835 ? `<span style="font-size:.68rem;color:#00c864">â Este es el que se publica â IG y TikTok como reel.</span>` 10836 : `<button class="btn btn-ghost" style="font-size:.69rem;padding:4px 10px;border-color:${col}77" onclick="nrUseAnimated('${d.id}',this,'${k}')">⬠Usar este para publicar (IG + TikTok como reel)</button>`} 10837 </div> 10838 </div>`).join('')} 10839 <details style="margin:8px 0"> 10840 <summary style="font-size:.71rem;color:var(--muted);cursor:pointer">Captions</summary> 10841 <div style="font-size:.71rem;color:var(--muted);margin-top:6px;white-space:pre-wrap">${_nrEsc(d.caption_es||'')}</div> 10842 <div style="font-size:.71rem;color:var(--muted);margin-top:8px;white-space:pre-wrap">EN â ${_nrEsc(d.caption_en||'')}</div> 10843 </details> 10844 <div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:10px"> 10845 <button class="btn btn-ghost" style="font-size:.69rem;padding:4px 10px" onclick="nrRender('${d.id}',this)">ð¼ Preview</button> 10846 <button class="btn btn-ghost" style="font-size:.69rem;padding:4px 10px;border-color:rgba(255,176,32,.45)" onclick="nrRenderAnimated('${d.id}',this)">ð¬ Reel animado (prueba)</button> 10847 <button class="btn btn-ghost" style="font-size:.69rem;padding:4px 10px;border-color:rgba(0,188,212,.5)" onclick="nrRenderPexels('${d.id}',this)" title="Texto sobre metraje real de Pexels. Gratis, pero tarda 3-5 min.">ð Reel con metraje (prueba)</button> 10848 ${isLesson?`<button class="btn btn-ghost" style="font-size:.69rem;padding:4px 10px;border-color:rgba(139,92,246,.6)" onclick="nrRenderLesson('${d.id}',this)" title="Dibuja la gráfica animada y la narra. Tarda varios minutos.">ð Renderizar lección</button>`:''} 10849 ${posted 10850 ? `<span style="font-size:.68rem;color:var(--muted);align-self:center">Ya se envió a Buffer â para publicar otra versión, parte de un draft nuevo.</span>` 10851 : retryable 10852 ? `<button class="btn" style="font-size:.69rem;padding:4px 10px" onclick="nrPublish('${d.id}',this)">ð Reintentar canales faltantes</button>` 10853 : approved 10854 ? `<button class="btn" style="font-size:.69rem;padding:4px 10px" onclick="nrPublish('${d.id}',this)">ð¤ Publicar a Buffer</button>` 10855 : rejected 10856 // A rejected draft can still be rendered and brought back â 10857 // approve only refuses drafts already posted. The label says so, 10858 // because "Aprobar" under a RECHAZADO badge reads like a dead 10859 // button and left Abraham unsure whether a render would even run. 10860 ? `<button class="btn btn-ghost" style="font-size:.69rem;padding:4px 10px" onclick="nrApprove('${d.id}',this)">
10860â© Recuperar y aprobar</button>` 10861 : `<button class="btn btn-ghost" style="font-size:.69rem;padding:4px 10px" onclick="nrApprove('${d.id}',this)">â Aprobar</button>`} 10862 ${(posted||rejected)?'':`<button class="btn btn-ghost" style="font-size:.69rem;padding:4px 10px" onclick="nrReject('${d.id}',this)">â Rechazar</button>`} 10863 </div> 10864 <div id="nr-msg-${d.id}" style="font-size:.7rem;color:var(--muted);margin-top:6px"></div> 10865 </div>`; 10866 }).join(''); 10867} 10868 10869function _nrBusy(btn,label){if(btn){btn.disabled=true;btn._old=btn.textContent;btn.textContent=label;}} 10870function _nrDone(btn){if(btn){btn.disabled=false;if(btn._old)btn.textContent=btn._old;}} 10871 10872async function nrDraft(ideaId,fmt,btn){ 10873 _nrBusy(btn,'Draftingâ¦'); 10874 try{ 10875 const r=await api('POST','/api/admin/newsroom/ideas/'+ideaId+'/draft',{content_format:fmt,deep_research:true}); 10876 if(!r.ok){const e=await r.json().catch(()=>({}));_nrSay('Draft failed: '+(e.detail||r.status));} 10877 else{_nrSay('Draft created.');await loadNewsroom();} 10878 }catch(e){_nrSay('Draft error: '+e.message);} 10879 _nrDone(btn); 10880} 10881 10882async function nrDismiss(ideaId,btn){ 10883 _nrBusy(btn,'â¦'); 10884 try{await api('POST','/api/admin/newsroom/ideas/'+ideaId+'/dismiss');await loadNewsroom();} 10885 catch(e){_nrSay('Error: '+e.message);} 10886 _nrDone(btn); 10887} 10888 10889function _nrAssetCount(d){ 10890 const au=d&&d.asset_urls; 10891 if(Array.isArray(au))return au.length; 10892 // The experimental renders ('animado', 'pexels'), the '*_prev' copies a 10893 // promotion leaves behind, and 'music' (a string, not a list) are all 10894 // excluded â otherwise a draft that already has them makes the normal 10895 // render poll think it finished instantly. 10896 if(au&&typeof au==='object')return Object.entries(au) 10897 .filter(([k,v])=>Array.isArray(v)&&k!=='animado'&&k!=='pexels'&&!/_prev$/.test(k)) 10898 .reduce((n,[,v])=>n+v.length,0); 10899 return 0; 10900} 10901 10902function _nrKeyUrl(d,key){ 10903 const au=d&&d.asset_urls; 10904 return (au&&!Array.isArray(au)&&typeof au==='object'&&(au[key]||[])[0])||''; 10905} 10906function _nrAnimatedUrl(d){ return _nrKeyUrl(d,'animado'); } 10907function _nrPexelsUrl(d){ return _nrKeyUrl(d,'pexels'); } 10908 10909/* Last known animated URL per draft, refreshed every time the list renders. 10910 The re-render poll needs to know what was there BEFORE it started. */ 10911const NR_ANIM={}; 10912 10913/* True when the animated video is the one currently set to publish. The 10914 plain Preview re-renders into that same slot, so running it would 10915 silently swap the animated cut back for the static one. */ 10916function _nrPromotedKey(d){ 10917 const au=d&&d.asset_urls; 10918 if(!au||Array.isArray(au)||typeof au!=='object')return ''; 10919 const cur=(au.es||[])[0]||''; 10920 if(cur&&cur===_nrAnimatedUrl(d))return 'animado'; 10921 if(cur&&cur===_nrPexelsUrl(d))return 'pexels'; 10922 if(cur&&cur===_nrKeyUrl(d,'leccion'))return 'leccion'; 10923 return ''; 10924} 10925const NR_PROMOTED={}; 10926const NR_ANIM_PX={}; 10927 10928/* Same background-task pattern as the normal reel render, but watching the 10929 'animado' key so the two polls can't be confused for one another. 10930 10931 `prev` is the URL that was there when the render started, and the poll 10932 waits for a DIFFERENT one. Waiting for "any animado url" finished on the 10933 first tick whenever the draft had been rendered before â it reloaded 10934 instantly and showed the previous video as if it were the new one, which 10935 made a working re-render look like it had been ignored. Every upload gets 10936 its own timestamped Cloudinary URL, so a change is a reliable signal. */ 10937async function _nrPollAnimated(id,tries,prev){ 10938 tries=tries||0; 10939 const msg=()=>$('nr-msg-'+id); 10940 // 10 minutes. The animated render redraws text across the reveal window 10941 // of every beat, so a 6-slide draft is ~600 frames and Railway's CPU is 10942 // several times slower than a laptop â 4 minutes gave up on renders that 10943 // were still perfectly healthy. 10944 if(tries>60){const m=msg();if(m)m.textContent='El reel animado está tardando más de 10 min â revisa los logs de Railway.';return;} 10945 await new Promise(r=>setTimeout(r,10000)); 10946 try{ 10947 const r=await api('GET','/api/admin/newsroom/drafts?status=pending,approved,partial,failed&limit=30'); 10948 if(r.ok){ 10949 const d=(await r.json()).find(x=>x.id===id); 10950 if(d&&d.error){const m=msg();
10950if(m)m.textContent='Render falló: '+d.error;return;} 10951 const now=_nrAnimatedUrl(d); 10952 if(now&&now!==prev){await loadNewsroom();return;} 10953 } 10954 }catch(e){} 10955 const m=msg(); 10956 if(m)m.textContent='Renderizando el reel animado⦠('+((tries+1)*10)+'s)' 10957 +(prev?' â abajo sigue el anterior hasta que termine.':''); 10958 _nrPollAnimated(id,tries+1,prev); 10959} 10960 10961/* Builds a "Cómo gana dinero X" draft. Blank ticker = next company in the 10962 watchlist not used in the last 45 days. Costs no LLM credits, so it is 10963 safe to run repeatedly while tuning. */ 10964async function nrMoneyFlow(btn){ 10965 const raw=prompt('Ticker (vacÃo = siguiente de la lista de 88 empresas):',''); 10966 if(raw===null)return; // Cancel used to fall through and build one 10967 const t=raw.trim().toUpperCase(); 10968 _nrSay('Armando el gráfico de flujo de dineroâ¦'); 10969 try{ 10970 const q=t?('?ticker='+encodeURIComponent(t)):''; 10971 const r=await api('POST','/api/admin/newsroom/money-flow'+q); 10972 const d=await r.json().catch(()=>({})); 10973 if(!r.ok){_nrSay('No se pudo: '+(d.detail||r.status));return;} 10974 _nrSay('Listo â '+d.company+' ('+d.ticker+', '+d.period+'). Está en la cola, sin publicar.'); 10975 await loadNewsroom(); 10976 }catch(e){_nrSay('Error: '+e.message);} 10977} 10978 10979/* "¿Cuánto tendrÃas hoy?" â reel por defecto. Sin costo de LLM, asà que 10980 se puede repetir mientras se afina. */ 10981async function nrGrowth(){ 10982 // The null from Cancel has to be checked BEFORE normalising: the old 10983 // `(prompt(...)||'')` turned it into an empty string, so cancelling the 10984 // dialog built a draft anyway with whatever the list had next. 10985 const raw=prompt('Ticker (vacÃo = siguiente de la lista de 88 empresas):',''); 10986 if(raw===null)return; 10987 const t=raw.trim().toUpperCase(); 10988 const rawY=prompt('¿Cuántos años atrás?','10'); 10989 if(rawY===null)return; 10990 const y=parseInt(rawY||'10',10); 10991 if(!y||y<1||y>40){_nrSay('Años inválidos (1-40).');return;} 10992 _nrSay('Calculando el crecimientoâ¦'); 10993 try{ 10994 const p=new URLSearchParams({years:String(y)}); 10995 if(t)p.set('ticker',t); 10996 const r=await api('POST','/api/admin/newsroom/growth?'+p.toString()); 10997 const d=await r.json().catch(()=>({})); 10998 if(!r.ok){_nrSay('No se pudo: '+(d.detail||r.status));return;} 10999 _nrSay('Listo â '+d.company+': x'+d.multiple.toFixed(1) 11000 +' con una caÃda máxima de '+Math.round(d.drawdown*100)+'%. En la cola, sin publicar.'); 11001 await loadNewsroom(); 11002 }catch(e){_nrSay('Error: '+e.message);} 11003} 11004 11005/* Lección de trading. Sin cron â solo sale cuando Abraham la pide. */ 11006async function nrLesson(){ 11007 const raw=prompt( 11008 'Número de lección (vacÃo = la siguiente sin usar):\n\n' 11009 +'1 · Un soporte que aguantó tres veces\n' 11010 +'2 · Dónde va el stop, y por qué no a un 2%\n' 11011 +'3 · Cuando la tendencia cambia de verdad\n' 11012 +'4 · Por qué puedes acertar el 40% y aun asà ganar\n' 11013 +'5 · La misma ruptura, con volumen y sin volumen\n' 11014 +'6 · Por qué el precio vuelve al nivel que rompió',''); 11015 if(raw===null)return; 11016 const n=raw.trim(); 11017 if(n && !/^[1-6]$/.test(n)){_nrSay('Número inválido (1-6).');return;} 11018 _nrSay('Buscando un caso real en el históricoâ¦'); 11019 try{ 11020 const r=await api('POST','/api/admin/newsroom/lesson'+(n?('?lesson='+n):'')); 11021 const d=await r.json().catch(()=>({})); 11022 if(!r.ok){_nrSay('No se pudo: '+(d.detail||r.status));return;} 11023 _nrSay('Listo â "'+d.title+'" con '+d.ticker+' ('+d.date+'). ' 11024 +'Dale a ð Renderizar lección en el draft para generar el video.'); 11025 await loadNewsroom(); 11026 }catch(e){_nrSay('Error: '+e.message);} 11027} 11028 11029/* El render narrado dibuja ~1.200 cuadros y sintetiza la voz, asà que 11030 tiene su propio contador, más largo que el de los otros reels. */ 11031async function _nrPollLesson(id,tries,prev){ 11032 tries=tries||0; 11033 const msg=()=>$('nr-msg-'+id); 11034 if(tries>90){const m=msg();if(m)m.textContent='La lección tarda más de 15 min â revisa los logs.';return;} 11035 await new Promise(r=>setTimeout(r,10000)); 11036 try{ 11037 const r=await api('GET','/api/admin/newsroom/drafts?status=pending,approved,partial,failed&limit=30'); 11038 if(r.ok){ 11039 const d=(await r.json()).find(x=>x.id===id); 11040 if(d&&d.error){const m=msg();
11040if(m)m.textContent='Render falló: '+d.error;return;} 11041 const now=_nrKeyUrl(d,'leccion'); 11042 if(now&&now!==prev){await loadNewsroom();return;} 11043 } 11044 }catch(e){} 11045 const m=msg(); 11046 if(m)m.textContent='Narrando y dibujando la gráfica⦠('+((tries+1)*10)+'s)'; 11047 _nrPollLesson(id,tries+1,prev); 11048} 11049 11050async function nrRenderLesson(id,btn){ 11051 _nrBusy(btn,'Narrandoâ¦'); 11052 const msg=$('nr-msg-'+id); 11053 const prev=_nrKeyUrl({asset_urls:{}},'leccion'); 11054 try{ 11055 const r=await api('POST','/api/admin/newsroom/drafts/'+id+'/render-lesson'); 11056 const d=await r.json().catch(()=>({})); 11057 if(!r.ok){if(msg)msg.textContent='No se pudo: '+(d.detail||r.status);} 11058 else{ 11059 if(msg)msg.textContent='Narrando y dibujando la gráfica⦠tarda unos minutos.'; 11060 _nrPollLesson(id,0,prev); 11061 } 11062 }catch(e){if(msg)msg.textContent='Error: '+e.message;} 11063 _nrDone(btn); 11064} 11065 11066async function nrUseAnimated(id,btn,key){ 11067 _nrBusy(btn,'â¦'); 11068 const msg=$('nr-msg-'+id); 11069 try{ 11070 const r=await api('POST','/api/admin/newsroom/drafts/'+id+'/use-animated?key='+(key||'animado')); 11071 const d=await r.json().catch(()=>({})); 11072 if(!r.ok){if(msg)msg.textContent='No se pudo usar: '+(d.detail||r.status);} 11073 else{ 11074 if(msg)msg.textContent=d.converted 11075 ? 'Listo â convertido a REEL: IG y TikTok reciben el animado (el reel llega a no-seguidores). Las imágenes quedan guardadas. Falta aprobar y publicar.' 11076 : 'Listo â IG y TikTok recibirán el animado. Falta aprobar y publicar.'; 11077 await loadNewsroom(); 11078 } 11079 }catch(e){if(msg)msg.textContent='Error: '+e.message;} 11080 _nrDone(btn); 11081} 11082 11083/* Same shape as the animated poll, watching the 'pexels' key. Given its 11084 own timer because decoding footage per frame is slower â a run that is 11085 still healthy at 4 minutes would look failed on the animated ceiling. */ 11086async function _nrPollPexels(id,tries,prev){ 11087 tries=tries||0; 11088 const msg=()=>$('nr-msg-'+id); 11089 if(tries>90){const m=msg();if(m)m.textContent='El reel con metraje tarda más de 15 min â revisa los logs.';return;} 11090 await new Promise(r=>setTimeout(r,10000)); 11091 try{ 11092 const r=await api('GET','/api/admin/newsroom/drafts?status=pending,approved,partial,failed&limit=30'); 11093 if(r.ok){ 11094 const d=(await r.json()).find(x=>x.id===id); 11095 if(d&&d.error){const m=msg();if(m)m.textContent='Render falló: '+d.error;return;} 11096 const now=_nrPexelsUrl(d); 11097 if(now&&now!==prev){await loadNewsroom();return;} 11098 } 11099 }catch(e){} 11100 const m=msg(); 11101 if(m)m.textContent='Buscando metraje y renderizando⦠('+((tries+1)*10)+'s)'; 11102 _nrPollPexels(id,tries+1,prev); 11103} 11104 11105async function nrRenderPexels(id,btn){ 11106 _nrBusy(btn,'Metrajeâ¦'); 11107 const msg=$('nr-msg-'+id); 11108 const prev=NR_ANIM_PX[id]||''; 11109 try{ 11110 const r=await api('POST','/api/admin/newsroom/drafts/'+id+'/render-video'); 11111 const d=await r.json().catch(()=>({})); 11112 if(!r.ok){if(msg)msg.textContent='No se pudo: '+(d.detail||r.status);} 11113 else{ 11114 if(msg)msg.textContent='Buscando metraje y renderizando⦠tarda 3-5 min.'; 11115 _nrPollPexels(id,0,prev); 11116 } 11117 }catch(e){if(msg)msg.textContent='Error: '+e.message;} 11118 _nrDone(btn); 11119} 11120 11121async function nrRenderAnimated(id,btn){ 11122 _nrBusy(btn,'Animandoâ¦'); 11123 const msg=$('nr-msg-'+id); 11124 const prev=NR_ANIM[id]||''; 11125 try{ 11126 const r=await api('POST','/api/admin/newsroom/drafts/'+id+'/render-animated'); 11127 const d=await r.json().catch(()=>({})); 11128 if(!r.ok){if(msg)msg.textContent='Render animado falló: '+(d.detail||r.status);} 11129 else{ 11130 if(msg)msg.textContent='Renderizando el reel animado⦠se actualiza solo al terminar.' 11131 +(prev?' El de abajo es el anterior.':''); 11132 _nrPollAnimated(id,0,prev); 11133 } 11134 }catch(e){if(msg)msg.textContent='Error: '+e.message;} 11135 _nrDone(btn); 11136} 11137 11138/* Reel encoding runs in a background task, so the render endpoint returns 11139 before the video exists. Polling here rather than telling the user to 11140 guess when to refresh â they cannot know, and asking them to keep 11141 clicking is not a reasonable answer. */ 11142async function _nrPollAssets(id,tries){ 11143 tries=tries||0; 11144 const msg=()=>$('nr-msg-'+id); 11145 if(tries>18){const m=msg();if(m)m.textContent='El render está tardando más de lo normal â usa â» Refresh.';return;} 11146 await new Promise(r=>setTimeout(r,10000)); 11147 try{ 11148 const r=await api('GET','/api/admin/newsroom/drafts?status=pending,approved,partial,failed&limit=30'); 11149 if(r.ok){ 11150 const d=(await r.json()).find(x=>x.id===id); 11151 if(d&&d.error){const m=msg();
11151if(m)m.textContent='Render falló: '+d.error;return;} 11152 if(_nrAssetCount(d)>0){await loadNewsroom();return;} 11153 } 11154 }catch(e){} 11155 const m=msg(); 11156 if(m)m.textContent='Renderizando el video⦠('+((tries+1)*10)+'s)'; 11157 _nrPollAssets(id,tries+1); 11158} 11159 11160async function nrRender(id,btn){ 11161 // Preview writes into the same slot the promoted animated video lives 11162 // in, so running it after "Usar este para publicar" would quietly put 11163 // the static version back. 11164 if(NR_PROMOTED[id] && !confirm( 11165 'Este draft está publicando el REEL ANIMADO.\n\n' 11166 +'Volver a renderizar lo reemplaza por la versión estática.\n\n¿Continuar?'))return; 11167 _nrBusy(btn,'Renderingâ¦'); 11168 const msg=$('nr-msg-'+id); 11169 try{ 11170 const r=await api('POST','/api/admin/newsroom/drafts/'+id+'/render'); 11171 const d=await r.json().catch(()=>({})); 11172 if(!r.ok){if(msg)msg.textContent='Render failed: '+(d.detail||r.status);} 11173 else if(d.status==='rendering'){ 11174 if(msg)msg.textContent='Renderizando el video⦠se actualiza solo al terminar.'; 11175 _nrPollAssets(id,0); 11176 } 11177 else{if(msg)msg.textContent='Rendered '+d.count+' asset(s).';await loadNewsroom();} 11178 }catch(e){if(msg)msg.textContent='Render error: '+e.message;} 11179 _nrDone(btn); 11180} 11181 11182async function nrApprove(id,btn){ 11183 _nrBusy(btn,'â¦'); 11184 const msg=$('nr-msg-'+id); 11185 try{ 11186 const r=await api('POST','/api/admin/newsroom/drafts/'+id+'/approve'); 11187 if(r.ok){ 11188 if(msg)msg.textContent='Aprobado â aún no se publica. Usa âPublicar a Bufferâ.'; 11189 await loadNewsroom(); 11190 }else{ 11191 // Report the status and body. "Approve failed." on its own told 11192 // neither Abraham nor me anything about what to fix. 11193 const body=await r.text().catch(()=>''); 11194 if(msg)msg.textContent='Aprobar falló ('+r.status+'): '+body.slice(0,220); 11195 } 11196 }catch(e){if(msg)msg.textContent='Error: '+e.message;} 11197 _nrDone(btn); 11198} 11199 11200async function nrPublish(id,btn){ 11201 if(!confirm('Publicar a Buffer? Los canales que ya recibieron este draft se omiten.'))return; 11202 _nrBusy(btn,'Publicandoâ¦'); 11203 const msg=$('nr-msg-'+id); 11204 try{ 11205 const r=await api('POST','/api/admin/newsroom/drafts/'+id+'/publish?mode=addToQueue'); 11206 const d=await r.json().catch(()=>({})); 11207 if(!r.ok){if(msg)msg.textContent='Publish failed: '+(d.detail||r.status);} 11208 else{ 11209 if(msg)msg.textContent='Enviado a '+d.posted+' canal(es)' 11210 +((d.skipped)?(' · '+d.skipped+' ya estaban publicados'):'') 11211 +((d.errors&&d.errors.length)?(' · errores: '+d.errors.join('; ')):''); 11212 await loadNewsroom(); 11213 } 11214 }catch(e){if(msg)msg.textContent='Publish error: '+e.message;} 11215 _nrDone(btn); 11216} 11217 11218async function nrReject(id,btn){ 11219 _nrBusy(btn,'â¦'); 11220 try{ 11221 const r=await api('POST','/api/admin/newsroom/drafts/'+id+'/reject'); 11222 if(!r.ok){ 11223 const body=await r.text().catch(()=>''); 11224 const m=$('nr-msg-'+id); if(m)m.textContent='Rechazar falló ('+r.status+'): '+body.slice(0,220); 11225 }else{ 11226 // Say what the cleanup actually did. "Rejected" alone left it 11227 // unclear whether the Cloudinary space was reclaimed or not. 11228 const d=await r.json().catch(()=>({})); 11229 if(d.kept_because_posted) _nrSay('Rechazado. Los archivos se conservan porque ya se publicó en algún canal.'); 11230 else if(d.assets_deleted) _nrSay('Rechazado y '+d.assets_deleted+' archivo(s) borrados de Cloudinary.' 11231 +(d.assets_failed?(' '+d.assets_failed+' no se pudieron borrar.'):'')); 11232 else _nrSay('Rechazado.'); 11233 await loadNewsroom(); 11234 } 11235 }catch(e){_nrSay('Error: '+e.message);} 11236 _nrDone(btn); 11237} 11238 11239async function loadSocialPillars(){ 11240 try{ 11241 const r=await api('GET','/api/admin/social-pillars/config'); 11242 if(!r.ok)return; 11243 const d=await r.json(); 11244 const sc=(id,v)=>{const el=$(id);if(el)el.checked=!!v;}; 11245 sc('sp-tradingview', d.tradingview_enabled); 11246 sc('sp-eps', d.eps_enabled); 11247 sc('sp-movers', d.movers_enabled); 11248 sc('sp-options', d.options_enabled); 11249 sc('sp-headline', d.headline_enabled); 11250 }catch(e){console.error('loadSocialPillars',e);} 11251 11252 try{ 11253 const r=await api('GET','/api/admin/social-pillars/recent-posts?limit=30'); 11254 const posts=r.ok?await r.json():[]; 11255 const tb=$('sp-posts-body'), emp=$('sp-posts-empty'); 11256 if(!posts.length){ if(tb)tb.innerHTML=''; if(emp)emp.style.display=''; return; } 11257 if(emp)emp.style.display='none'; 11258 if(tb)tb.innerHTML=posts.map(p=>`<tr style="border-bottom:1px solid var(--border)"> 11259 <td style="padding:5px 8px;color:var(--teal);font-weight:700;text-transform:uppercase;font-size:.68rem">${p.pillar}</td> 11260 <td style="padding:5px 8px;color:var(--text);max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${(p.text||'').replace(/</g,'<')}</td> 11261 <td style="padding:5px 8px;color:var(--muted);font-size:.68rem">${p.channel_id}</td> 11262 <td style="padding:5px 8px;color:var(--muted);font-size:.68rem">
11262${p.posted_at?new Date(p.posted_at).toLocaleString():'â'}</td> 11263 </tr>`).join(''); 11264 }catch(e){console.error('loadSocialPillars posts',e);} 11265} 11266 11267async function triggerSocialPillar(pillar){ 11268 const res=$('sp-trigger-result'); 11269 if(res)res.textContent=`Triggering ${pillar}â¦`; 11270 try{ 11271 const r=await api('POST',`/api/admin/social-pillars/trigger/${pillar}`); 11272 const d=await r.json(); 11273 if(r.ok){ 11274 if(res)res.textContent=`${pillar} started â check Recent Posts below in ~30-60s.`; 11275 toast(`${pillar} triggered`,'ok'); 11276 setTimeout(loadSocialPillars, 8000); 11277 } else { 11278 if(res)res.textContent=d.detail||'Trigger failed'; 11279 } 11280 }catch(e){if(res)res.textContent='Error triggering';} 11281} 11282 11283async function saveSocialPillarConfig(){ 11284 const gc=(id)=>{const el=$(id);return el?el.checked:false;}; 11285 const payload={ 11286 tradingview_enabled: gc('sp-tradingview'), 11287 eps_enabled: gc('sp-eps'), 11288 movers_enabled: gc('sp-movers'), 11289 options_enabled: gc('sp-options'), 11290 headline_enabled: gc('sp-headline'), 11291 }; 11292 const res=$('sp-config-result'); 11293 try{ 11294 const r=await api('PUT','/api/admin/social-pillars/config',payload); 11295 const d=await r.json(); 11296 if(r.ok){if(res)res.innerHTML='<span style="color:var(--green)">Saved</span>';toast('Social pillar config saved','ok');} 11297 else{if(res)res.innerHTML=`<span style="color:var(--red)">${d.detail||'Save failed'}</span>`;} 11298 }catch(e){if(res)res.innerHTML='<span style="color:var(--red)">Error saving</span>';} 11299} 11300 11301async function loadAdminPolymarketAlerts(){ 11302 const list=$('pm-admin-alerts-list'); 11303 if(!list)return; 11304 list.innerHTML='<span style="font-size:.72rem;color:var(--muted)">Loadingâ¦</span>'; 11305 try{ 11306 const r=await api('GET','/api/admin/polymarket/alerts'); 11307 const d=await r.json(); 11308 if(!r.ok||!d.length){list.innerHTML='<span style="font-size:.72rem;color:var(--muted)">No alerts in DB.</span>';return;} 11309 const typeLabel={oi_spike:'OI',volume_surge:'VOL',book_imbalance:'BOOK',spread_spike:'SPR'}; 11310 const typeColor={oi_spike:'#a78bfa',volume_surge:'#00c853',book_imbalance:'#fbbf24',spread_spike:'#f97316'}; 11311 list.innerHTML=d.map(a=>` 11312 <div style="display:flex;gap:8px;align-items:flex-start;padding:6px 0;border-bottom:1px solid var(--border);font-size:.72rem"> 11313 <span style="color:${typeColor[a.alert_type]||'#aaa'};font-weight:700;min-width:36px;flex-shrink:0">${typeLabel[a.alert_type]||'?'}</span> 11314 <span style="flex:1;color:var(--text2);line-height:1.3">${a.question.substring(0,70)}${a.question.length>70?'…':''}</span> 11315 <span style="font-family:var(--mono);color:var(--muted);white-space:nowrap">${new Date(a.created_at).toLocaleDateString()}</span> 11316 </div>`).join(''); 11317 }catch(e){list.innerHTML=`<span style="font-size:.72rem;color:var(--red)">Error: ${e.message}</span>`;} 11318} 11319 11320async function clearPolymarketAlerts(){ 11321 if(!confirm('Delete all Polymarket alert history from DB?'))return; 11322 try{ 11323 const r=await api('DELETE','/api/admin/polymarket/alerts'); 11324 if(r.ok){toast('Alert history cleared','ok');const l=$('pm-admin-alerts-list');if(l)l.innerHTML='';} 11325 else toast('Failed to clear','err'); 11326 }catch(e){toast('Error','err');} 11327} 11328 11329function _initDjtToggle(){ 11330 const btn=$('djt-toggle-btn'); 11331 if(!btn)return; 11332 const on=!!(USER&&USER.djt_alerts); 11333 btn.textContent=on?'ON':'OFF'; 11334 btn.className=on?'btn btn-primary':'btn btn-ghost'; 11335 const fb=$('djt-filter-btn'); 11336 if(fb)fb.style.display=on?'':'none'; 11337} 11338 11339async function toggleDjtSubscribe(){ 11340 try{ 11341 const r=await api('POST','/api/news/djt-subscribe'); 11342 const d=await r.json(); 11343 if(!r.ok){toast(d.detail||'Failed','err');return;} 11344 if(USER)USER.djt_alerts=d.djt_alerts; 11345 const btn=$('djt-toggle-btn'); 11346 if(btn){btn.textContent=d.djt_alerts?'ON':'OFF';btn.className=d.djt_alerts?'btn btn-primary':'btn btn-ghost';} 11347 const fb=$('djt-filter-btn'); 11348 if(fb)fb.style.display=d.djt_alerts?'':'none'; 11349 toast(d.djt_alerts?'DJT feed enabled':'DJT feed disabled','ok'); 11350 }catch(e){toast('Error','err');} 11351} 11352 11353async function adminDjtRefresh(btn){ 11354 const res=$('djt-refresh-result'); 11355 if(btn)btn.disabled=true; 11356 if(res)res.innerHTML='<span class="spin"></span> Fetchingâ¦'; 11357 try{ 11358 const r=await api('POST','/api/news/djt-refresh'); 11359 const d=await r.json(); 11360 if(!r.ok){ 11361 if(res)res.textContent='Error: '+(d.detail||r.status); 11362 toast(d.detail||'DJT refresh failed','err'); 11363 return; 11364 } 11365 if(res)res.textContent='â Triggered â check Railway logs for result'; 11366 toast('DJT fetch triggered','ok'); 11367 }catch(e){ 11368 if(res)res.textContent='Network error'; 11369 toast('Network error','err'); 11370 }finally{ 11371 if(btn)btn.disabled=false; 11372 } 11373} 11374 11375// âââ DESIGN LAB (Fabric.js) âââââââââââââââââââââââââââââââââââââââââââââââââââ 11376let _dlCanvas=null; 11377let _dlFont='Inter'; 11378let _dlBgTab='gradients'; 11379 11380const DL_GRADIENTS=[ 11381 {label:'Midnight Finance',stops:[{offset:0,color:'#0b132b'},{offset:1,color:'#1a2a4a'}
11381]}, 11382 {label:'Dark Navy', stops:[{offset:0,color:'#0d1b2a'},{offset:1,color:'#1b2838'}]}, 11383 {label:'Deep Forest', stops:[{offset:0,color:'#0a1a0f'},{offset:1,color:'#1a3a20'}]}, 11384 {label:'Charcoal', stops:[{offset:0,color:'#1a1a1a'},{offset:1,color:'#2d2d2d'}]}, 11385 {label:'Burgundy Dark', stops:[{offset:0,color:'#1a0a0a'},{offset:1,color:'#3a1515'}]}, 11386 {label:'Deep Purple', stops:[{offset:0,color:'#12001f'},{offset:1,color:'#2a0a4a'}]}, 11387 {label:'Gold Dark', stops:[{offset:0,color:'#1a1200'},{offset:1,color:'#2d2000'}]}, 11388 {label:'Slate Blue', stops:[{offset:0,color:'#0f1524'},{offset:1,color:'#1e2d4a'}]}, 11389]; 11390 11391const DL_PHOTOS=[ 11392 // Finance & Markets 11393 {cat:'finance', label:'Trading Floor', url:'https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3?w=1080&q=80'}, 11394 {cat:'finance', label:'Wall Street', url:'https://images.unsplash.com/photo-1526304640581-d334cdbbf45e?w=1080&q=80'}, 11395 {cat:'finance', label:'Gold Chart', url:'https://images.unsplash.com/photo-1642790551116-18e150f248e3?w=1080&q=80'}, 11396 {cat:'finance', label:'City Night', url:'https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=1080&q=80'}, 11397 {cat:'finance', label:'Stock Screen', url:'https://images.unsplash.com/photo-1611974789855-9c2a0a7236a3?w=1080&q=80'}, 11398 {cat:'finance', label:'Trading Desk', url:'https://images.unsplash.com/photo-1450101499163-c8848c66ca85?w=1080&q=80'}, 11399 {cat:'finance', label:'Bull Market', url:'https://images.unsplash.com/photo-1543286386-713bdd548da4?w=1080&q=80'}, 11400 {cat:'finance', label:'Dollar Bills', url:'https://images.unsplash.com/photo-1580048915913-4fb768f76a0f?w=1080&q=80'}, 11401 // Abstract & Dark 11402 {cat:'abstract', label:'Data Globe', url:'https://images.unsplash.com/photo-1451187580459-43490279c0fa?w=1080&q=80'}, 11403 {cat:'abstract', label:'Dark Bokeh', url:'https://images.unsplash.com/photo-1557682224-5b8590cd9ec5?w=1080&q=80'}, 11404 {cat:'abstract', label:'Tech Lines', url:'https://images.unsplash.com/photo-1518770660439-4636190af475?w=1080&q=80'}, 11405 {cat:'abstract', label:'Purple Haze', url:'https://images.unsplash.com/photo-1579548122080-c35fd6820ecb?w=1080&q=80'}, 11406 {cat:'abstract', label:'Neon Glow', url:'https://images.unsplash.com/photo-1550745165-9bc0b252726f?w=1080&q=80'}, 11407 {cat:'abstract', label:'Deep Space', url:'https://images.unsplash.com/photo-1462331940025-496dfbfc7564?w=1080&q=80'}, 11408 {cat:'abstract', label:'Dark Waves', url:'https://images.unsplash.com/photo-1464802686167-b01fd7bdc50b?w=1080&q=80'}, 11409 {cat:'abstract', label:'Blue Circuit', url:'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?w=1080&q=80'}, 11410 {cat:'abstract', label:'Matrix Code', url:'https://images.unsplash.com/photo-1526374965328-7f61d4dc18c5?w=1080&q=80'}, 11411 {cat:'abstract', label:'Dark Minimal', url:'https://images.unsplash.com/photo-1508739773434-c26b3d09e071?w=1080&q=80'}, 11412 {cat:'abstract', label:'Laser Beams', url:'https://images.unsplash.com/photo-1550684848-fac9c2a1a574?w=1080&q=80'}, 11413 {cat:'abstract', label:'Liquid Paint', url:'https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?w=1080&q=80'}, 11414 {cat:'abstract', label:'Gold Fluid', url:'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1080&q=80'}, 11415 {cat:'abstract', label:'Dark Smoke', url:'https://images.unsplash.com/photo-1567359781514-3b964e2b04d6?w=1080&q=80'}, 11416 // Textures 11417 {cat:'texture', label:'Dark Marble', url:'https://images.unsplash.com/photo-1541123437800-1bb1317badc2?w=1080&q=80'}, 11418 {cat:'texture', label:'White Marble', url:'https://images.unsplash.com/photo-1558618048-fbd6ed5c06fd?w=1080&q=80'}, 11419 {cat:'texture', label:'Carbon Fiber', url:'https://images.unsplash.com/photo-1553356084-58ef4a67b2a7?w=1080&q=80'}, 11420 {cat:'texture', label:'Dark Fabric', url:'https://images.unsplash.com/photo-1528360983277-13d401cdc186?w=1080&q=80'}, 11421 {cat:'texture', label:'Concrete Wall', url:'https://images.unsplash.com/photo-1484154218962-a197022b5858?w=1080&q=80'}, 11422 {cat:'texture', label:'Dark Wood', url:'https://images.unsplash.com/photo-1541014741259-de529411b96a?w=1080&q=80'}, 11423 {cat:'texture', label:'Sand Dunes', url:'https://images.unsplash.com/photo-1509316785289-025f5b846b35?w=1080&q=80'}, 11424 {cat:'texture', label:'Water Ripple', url:'https://images.unsplash.com/photo-1559827291-72ee739d0d9a?w=1080&q=80'}, 11425 {cat:'texture', label:'Cracked Earth', url:'https://images.unsplash.com/photo-1508193638397-1c4234db14d8?w=1080&q=80'}, 11426 {cat:'texture', label:'Rust Metal', url:'https://images.unsplash.com/photo-1504328345606-18bbc8c9d7d1?w=1080&q=80'}, 11427 {cat:'texture', label:'Brushed Metal', url:'https://images.unsplash.com/photo-1519389950473-47ba0277781c?w=1080&q=80'}, 11428 {cat:'texture', label:'Dark Ink', url:'https://images.unsplash.com/photo-1553356084-58ef4a67b2a7?w=1080&q=80'}, 11429 // Architecture 11430 {cat:'architecture', label:'Glass Tower', url:'https://images.unsplash.com/photo-1486325212027-8081e485255e?w=1080&q=80'}, 11431 {cat:'architecture', label:'Night City', url:'https://images.unsplash.com/photo-1477959858617-67f85cf4f1df?w=1080&q=80'}, 11432 {cat:'architecture', label:'City Lights', url:'https://images.unsplash.com/photo-1480714378408-67cf0d13bc1b?w=1080&q=80'}, 11433 {cat:'architecture', label:'Skyscraper', url:'https://images.unsplash.com/photo-1486406646926-a93f4929a2cc?w=1080&q=80'}, 11434 {cat:'architecture', label:'Dark Office', url:'https://images.unsplash.com/photo-1497366216548-37526070297c?w=1080&q=80'}, 11435 {cat:'architecture', label:'Tokyo Night', url:'https://images.unsplash.com/photo-1512917774080-9991f1c4c750?w=1080&q=80'}, 11436 // Nature 11437 {cat:'nature', label:'Dark Ocean', url:'https://images.unsplash.com/photo-1501854140801-50d01698950b?w=1080&q=80'}, 11438 {cat:'nature', label:'Misty Mountain', url:'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?w=1080&q=80'}, 11439 {cat:'nature', label:'Dark Forest', url:'https://images.unsplash.com/photo-1448375240586-882707db888b?w=1080&q=80'}, 11440 {cat:'nature', label:'Night Sky', url:'https://images.unsplash.com/photo-1419242902214-272b3f66ee7a?w=1080&q=80'}, 11441 {cat:'nature', label:'Storm Clouds', url:'https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?w=1080&q=80'}, 11442 {cat:'nature', label:'Volcano', url:'https://images.unsplash.com/photo-1455218873509-8097305ee378?w=1080&q=80'}, 11443 {cat:'nature', label:'Dark Sea', url:'https://images.unsplash.com/photo-1505118380757-91f5f5632de0?w=1080&q=80'}, 11444 {cat:'nature', label:'Green Hills', url:'https://images.unsplash.com/photo-1500534314209-a25ddb2bd429?w=1080&q=80'}, 11445]; 11446 11447const DL_FONTS=[ 11448 'Inter','Roboto','Open Sans','Montserrat','Raleway', 11449 'Playfair Display','Merriweather','Bebas Neue','Anton','Oswald', 11450 'Space Mono','IBM Plex Mono','Poppins','Nunito','Lato', 11451 'Barlow Condensed','Syne','Cinzel','Cormorant Garamond','DM Sans', 11452]; 11453 11454const DL_COLOR_SWATCHES=['#0b132b','#1a1a1a','#ffffff','#e2c275','#22c55e','#ef4444','#3b82f6','#a855f7']; 11455 11456function _dlBuildGallery(){ 11457 // Gradients 11458 const gWrap=document.getElementById('dl-bg-gradients'); 11459 if(gWrap&&!gWrap.childElementCount){
11460 DL_GRADIENTS.forEach((g,i)=>{ 11461 const btn=document.createElement('button'); 11462 btn.title=g.label; 11463 btn.onclick=()=>dlApplyGradient(i); 11464 btn.style.cssText='width:64px;height:42px;border:2px solid #202e56;border-radius:6px;cursor:pointer;padding:0;overflow:hidden;transition:border-color .15s'; 11465 btn.onmouseover=()=>{btn.style.borderColor='#e2c275';}; 11466 btn.onmouseout=()=>{btn.style.borderColor='#202e56';}; 11467 const c=document.createElement('canvas');c.width=64;c.height=42; 11468 const ctx=c.getContext('2d'); 11469 const gr=ctx.createLinearGradient(0,0,0,42); 11470 g.stops.forEach(s=>gr.addColorStop(s.offset,s.color)); 11471 ctx.fillStyle=gr;ctx.fillRect(0,0,64,42); 11472 btn.appendChild(c); 11473 gWrap.appendChild(btn); 11474 }); 11475 } 11476 // Photos + category filters 11477 const pGrid=document.getElementById('dl-photo-grid'); 11478 const pCats=document.getElementById('dl-photo-cats'); 11479 if(pGrid&&!pGrid.childElementCount){ 11480 // Category filter buttons 11481 const cats=['all','finance','abstract','texture','architecture','nature']; 11482 const catLabels={all:'All',finance:'Finance',abstract:'Abstract',texture:'Textures',architecture:'Cities',nature:'Nature'}; 11483 cats.forEach(cat=>{ 11484 const fb=document.createElement('button'); 11485 fb.className='btn btn-ghost'; 11486 fb.dataset.pcat=cat; 11487 fb.textContent=catLabels[cat]; 11488 fb.style.cssText='font-size:.65rem;padding:3px 10px;'+(cat==='all'?'border-color:var(--teal);color:var(--teal)':''); 11489 fb.onclick=()=>dlFilterPhotos(cat); 11490 pCats.appendChild(fb); 11491 }); 11492 // Photo thumbnails 11493 DL_PHOTOS.forEach(p=>{ 11494 const btn=document.createElement('button'); 11495 btn.title=p.label; 11496 btn.dataset.pcat=p.cat; 11497 btn.onclick=()=>dlApplyPhoto(p.url); 11498 btn.style.cssText='width:64px;height:64px;border:2px solid #202e56;border-radius:6px;cursor:pointer;padding:0;overflow:hidden;transition:border-color .15s;background:#0b132b;flex-shrink:0'; 11499 btn.onmouseover=()=>{btn.style.borderColor='#e2c275';}; 11500 btn.onmouseout=()=>{btn.style.borderColor='#202e56';}; 11501 const img=document.createElement('img'); 11502 img.src=p.url.replace('w=1080','w=128'); 11503 img.style.cssText='width:100%;height:100%;object-fit:cover;display:block'; 11504 img.loading='lazy'; 11505 img.onerror=()=>{btn.style.display='none';}; 11506 btn.appendChild(img); 11507 pGrid.appendChild(btn); 11508 }); 11509 } 11510 // Color swatches 11511 const cWrap=document.getElementById('dl-color-swatches'); 11512 if(cWrap&&!cWrap.childElementCount){ 11513 DL_COLOR_SWATCHES.forEach(hex=>{ 11514 const btn=document.createElement('button'); 11515 btn.title=hex; 11516 btn.onclick=()=>{dlSetBg(hex);document.getElementById('dl-bg-color').value=hex;}; 11517 btn.style.cssText=`width:28px;height:28px;background:${hex};border:2px solid #202e56;border-radius:50%;cursor:pointer;transition:border-color .15s`; 11518 btn.onmouseover=()=>{btn.style.borderColor='#e2c275';}; 11519 btn.onmouseout=()=>{btn.style.borderColor='#202e56';}; 11520 cWrap.appendChild(btn); 11521 }); 11522 } 11523 // Font select 11524 const sel=document.getElementById('dl-font-select'); 11525 if(sel&&!sel.childElementCount){ 11526 DL_FONTS.forEach(f=>{ 11527 const opt=document.createElement('option');opt.value=f;opt.textContent=f; 11528 if(f===_dlFont)opt.selected=true; 11529 sel.appendChild(opt); 11530 }); 11531 dlLoadFont(_dlFont); 11532 } 11533} 11534 11535function dlFilterPhotos(cat){ 11536 const pGrid=document.getElementById('dl-photo-grid'); 11537 if(!pGrid)return; 11538 pGrid.querySelectorAll('button[data-pcat]').forEach(btn=>{ 11539 btn.style.display=(cat==='all'||btn.dataset.pcat===cat)?'':'none'; 11540 }); 11541 const pCats=document.getElementById('dl-photo-cats'); 11542 if(pCats)pCats.querySelectorAll('button[data-pcat]').forEach(fb=>{ 11543 const active=fb.dataset.pcat===cat; 11544 fb.style.borderColor=active?'var(--teal)':''; 11545 fb.style.color=active?'var(--teal)':''; 11546 }); 11547} 11548 11549function dlSetBgTab(tab){ 11550 _dlBgTab=tab; 11551 ['gradients','photos','colors','mybgs'].forEach(t=>{ 11552 const panel=document.getElementById('dl-bg-'+t); 11553 const btn=document.getElementById('dl-tab-'+t); 11554 if(!panel||!btn)return; 11555 const active=(t===tab); 11556 panel.style.display=active?(t==='mybgs'?'flex':'flex'):'none'; 11557 btn.style.borderBottomColor=active?'var(--teal)':'transparent'; 11558 btn.style.color=active?'var(--teal)':'var(--muted)'; 11559 });
11560 if(tab==='mybgs') dlLoadMyBgs(); 11561} 11562 11563function dlApplyGradient(idx){ 11564 if(!_dlCanvas)return; 11565 const g=DL_GRADIENTS[idx]; 11566 const h=_dlCanvas._naturalH||_dlCanvas.getHeight(); 11567 const grad=new fabric.Gradient({ 11568 type:'linear', 11569 coords:{x1:0,y1:0,x2:0,y2:h}, 11570 colorStops:g.stops, 11571 }); 11572 _dlCanvas.setBackgroundColor(grad,_dlCanvas.renderAll.bind(_dlCanvas)); 11573 toast('Gradient applied','ok'); 11574} 11575 11576function dlApplyPhoto(url){ 11577 if(!_dlCanvas)return; 11578 fabric.Image.fromURL(url,img=>{ 11579 const nw=_dlCanvas._naturalW||_dlCanvas.getWidth(); 11580 const nh=_dlCanvas._naturalH||_dlCanvas.getHeight(); 11581 _dlCanvas.setBackgroundImage(img,()=>{_dlCanvas.renderAll();toast('Background set','ok');},{ 11582 scaleX:nw/img.width,scaleY:nh/img.height,originX:'left',originY:'top', 11583 }); 11584 },{crossOrigin:'anonymous'}); 11585} 11586 11587function dlLoadFont(fontFamily){ 11588 _dlFont=fontFamily; 11589 const id='gf-'+fontFamily.replace(/\s/g,'-'); 11590 if(!document.getElementById(id)){ 11591 const link=document.createElement('link'); 11592 link.id=id;link.rel='stylesheet'; 11593 link.href=`https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontFamily)}:wght@400;700&display=swap`; 11594 document.head.appendChild(link); 11595 } 11596} 11597 11598function _dlCalcScale(w,h){ 11599 const availW=window.innerWidth-270; 11600 const availH=window.innerHeight-240; 11601 return Math.min(1,availW/w,availH/h); 11602} 11603function initDesignLab(){ 11604 if(_dlCanvas)return; 11605 const scale=_dlCalcScale(1080,1080); 11606 _dlCanvas=new fabric.Canvas('dl-canvas',{width:Math.round(1080*scale),height:Math.round(1080*scale),backgroundColor:'#0b132b'}); 11607 _dlCanvas._naturalW=1080;_dlCanvas._naturalH=1080; 11608 _dlCanvas.setZoom(scale); 11609 _dlBuildGallery(); 11610 window.addEventListener('resize',()=>{if(_dlCanvas)dlResize(_dlCanvas._naturalW||1080,_dlCanvas._naturalH||1080);}); 11611} 11612 11613function dlAddText(){ 11614 if(!_dlCanvas)return; 11615 const font=document.getElementById('dl-font-select')?.value||_dlFont||'Inter'; 11616 const size=parseInt(document.getElementById('dl-font-size')?.value||72); 11617 const t=new fabric.IText('Edit me',{ 11618 left:80,top:80, 11619 fill:document.getElementById('dl-fill-color').value, 11620 fontSize:Math.round(size*_dlCanvas.getZoom()), 11621 fontFamily:font,fontWeight:'700', 11622 }); 11623 _dlCanvas.add(t);_dlCanvas.setActiveObject(t);_dlCanvas.renderAll(); 11624 toast('Text added â click to edit','ok'); 11625} 11626function dlAddRect(){ 11627 if(!_dlCanvas)return; 11628 const r=new fabric.Rect({left:120,top:120,width:200,height:120,fill:document.getElementById('dl-fill-color').value}); 11629 _dlCanvas.add(r);_dlCanvas.setActiveObject(r);_dlCanvas.renderAll(); 11630} 11631function dlAddCircle(){ 11632 if(!_dlCanvas)return; 11633 const c=new fabric.Circle({left:160,top:160,radius:80,fill:document.getElementById('dl-fill-color').value}); 11634 _dlCanvas.add(c);_dlCanvas.setActiveObject(c);_dlCanvas.renderAll(); 11635} 11636function dlSetBg(color){ 11637 if(!_dlCanvas)return; 11638 _dlCanvas.setBackgroundImage(null,()=>{}); 11639 _dlCanvas.setBackgroundColor(color,_dlCanvas.renderAll.bind(_dlCanvas)); 11640} 11641function dlSendToBack(){ 11642 if(!_dlCanvas)return; 11643 const o=_dlCanvas.getActiveObject(); 11644 if(o){_dlCanvas.sendToBack(o);_dlCanvas.renderAll();toast('Sent to back','ok');} 11645 else{toast('Select an object first','err');} 11646} 11647function dlBringToFront(){ 11648 if(!_dlCanvas)return; 11649 const o=_dlCanvas.getActiveObject(); 11650 if(o){_dlCanvas.bringToFront(o);_dlCanvas.renderAll();toast('Brought to front','ok');} 11651 else{toast('Select an object first','err');} 11652} 11653function dlDeleteSelected(){ 11654 if(!_dlCanvas)return; 11655 const obj=_dlCanvas.getActiveObject(); 11656 if(obj){_dlCanvas.remove(obj);_dlCanvas.renderAll();} 11657} 11658function dlAddImageFile(input){ 11659 if(!_dlCanvas||!input.files[0])return; 11660 const reader=new FileReader(); 11661 reader.onload=e=>fabric.Image.fromURL(e.target.result,img=>{ 11662 img.scaleToWidth(Math.round(400*_dlCanvas.getZoom())); 11663 img.set({left:60,top:60}); 11664 _dlCanvas.add(img);_dlCanvas.setActiveObject(img);_dlCanvas.renderAll(); 11665 },{crossOrigin:'anonymous'}); 11666 reader.readAsDataURL(input.files[0]); 11667 input.value=''; 11668} 11669function dlResize(w,h){ 11670 if(!_dlCanvas)return; 11671 if(_dlCanvas.isDrawingMode){ 11672 _dlCanvas.isDrawingMode=false; 11673 const b=$('dl-draw-btn');if(b){b.style.background='';b.style.borderColor='';} 11674 const s=$('dl-brush-size');if(s)s.style.display='none'; 11675 const t=$('dl-brush-type');if(t)t.style.display='none'; 11676 } 11677 const scale=_dlCalcScale(w,h); 11678 _dlCanvas._naturalW=w;_dlCanvas._naturalH=h; 11679 _dlCanvas.setZoom(scale); 11680 _dlCanvas.setWidth(Math.round(w*scale)); 11681 _dlCanvas.setHeight(Math.round(h*scale)); 11682 _dlCanvas.renderAll(); 11683} 11684function dlToggleDraw(){ 11685 if(!_dlCanvas)return; 11686 const on=!_dlCanvas.isDrawingMode; 11687 _dlCanvas.isDrawingMode=on; 11688 const btn=$('dl-draw-btn'); 11689 const sizeEl=$('dl-brush-size'); 11690 const typeEl=$('dl-brush-type'); 11691 if(btn){btn.style.background=on?'rgba(226,194,117,.2)':'';btn.style.borderColor=on?'var(--teal)':'';} 11692 if(sizeEl)sizeEl.style.display=on?'inline-block':'none'; 11693 if(typeEl)typeEl.style.display=on?'inline-block':'none'; 11694 if(on)dlUpdateBrush(); 11695 toast(on?'Draw mode on â use your finger or stylus':'Draw mode off','ok'); 11696} 11697function dlUpdateBrush(){ 11698 if(!_dlCanvas||!_dlCanvas.isDrawingMode)return; 11699 const size=parseInt($('dl-brush-size')?.value||3); 11700 const type=$('dl-brush-type')?.value||'pencil'; 11701 const color=$('dl-fill-color')?.value||'#e2c275'; 11702 let brush; 11703 if(type==='spray') brush=new fabric.SprayBrush(_dlCanvas); 11704 else brush=new fabric.PencilBrush(_dlCanvas); 11705 brush.color=color; 11706 brush.width=type==='marker'?size*3:size; 11707 if(type==='spray'){brush.density=10;brush.dotWidth=size;} 11708 _dlCanvas.freeDrawingBrush=brush; 11709} 11710function dlExportPng(){ 11711 if(!_dlCanvas)return; 11712 toast('Downloading designâ¦','ok'); 11713 const zoom=_dlCanvas.getZoom(); 11714 const w=_dlCanvas._naturalW||Math.round(_dlCanvas.getWidth()/zoom); 11715 const h=_dlCanvas._naturalH||Math.round(_dlCanvas.getHeight()/zoom); 11716 const url=_dlCanvas.toDataURL({format:'png',quality:1,multiplier:1/zoom,width:w,height:h}); 11717 const a=document.createElement('a');a.href=url;a.download='design.png';a.click(); 11718} 11719let _dlGalleryOffset=0; 11720const _DL_GALLERY_LIMIT=40; 11721 11722async function dlOpenGalleryPicker(){ 11723 const modal=document.getElementById('dl-gallery-modal'); 11724 if(!modal)return; 11725 modal.style.display='block'; 11726 _dlGalleryOffset=0; 11727 await _dlLoadGalleryPickerPage(true); 11728} 11729 11730async function _dlLoadGalleryPickerPage(reset){ 11731 const grid=document.getElementById('dl-gallery-picker-grid'); 11732 const moreBtn=document.getElementById('dl-gallery-picker-more'); 11733 if(!grid)return; 11734 if(reset){grid.innerHTML='<div style="color:var(--muted);font-size:.78rem;padding:20px">Loadingâ¦</div>';} 11735 try{ 11736 const qs=new URLSearchParams({limit:_DL_GALLERY_LIMIT,offset:_dlGalleryOffset}); 11737 const res=await api('GET',`/api/gallery?${qs}`); 11738 const d=await res.json(); 11739 const items=d.items||[]; 11740 if(reset)grid.innerHTML='';
11741 if(!items.length&&reset){grid.innerHTML='<div style="color:var(--muted);font-size:.78rem;padding:20px">No images in your gallery yet.</div>';return;} 11742 items.forEach(item=>{ 11743 const url=item.url||item.thumbnail||''; 11744 if(!url)return; 11745 const card=document.createElement('div'); 11746 card.style.cssText='border-radius:8px;overflow:hidden;background:var(--bg3);border:1px solid var(--border)'; 11747 const img=document.createElement('img'); 11748 img.src=item.thumbnail||url; 11749 img.loading='lazy'; 11750 img.style.cssText='width:100%;aspect-ratio:1;object-fit:cover;display:block'; 11751 img.onerror=()=>{card.style.display='none';}; 11752 const btnRow=document.createElement('div'); 11753 btnRow.style.cssText='display:flex;gap:4px;padding:4px'; 11754 const btnLayer=document.createElement('button'); 11755 btnLayer.className='btn btn-ghost'; 11756 btnLayer.style.cssText='flex:1;font-size:.62rem;padding:3px 0'; 11757 btnLayer.textContent='ï¼ Layer'; 11758 btnLayer.onclick=()=>dlPickGalleryItem(url); 11759 const btnBg=document.createElement('button'); 11760 btnBg.className='btn btn-ghost'; 11761 btnBg.style.cssText='flex:1;font-size:.62rem;padding:3px 0'; 11762 btnBg.textContent='ð BG'; 11763 btnBg.onclick=()=>{dlApplyPhoto(url);document.getElementById('dl-gallery-modal').style.display='none';}; 11764 btnRow.appendChild(btnLayer); 11765 btnRow.appendChild(btnBg); 11766 card.appendChild(img); 11767 card.appendChild(btnRow); 11768 grid.appendChild(card); 11769 }); 11770 if(moreBtn)moreBtn.style.display=items.length===_DL_GALLERY_LIMIT?'block':'none'; 11771 }catch(e){ 11772 if(reset)grid.innerHTML=`<div style="color:var(--red);font-size:.78rem;padding:20px">Error: ${e.message}</div>`; 11773 } 11774} 11775 11776async function dlLoadGalleryPickerMore(){ 11777 _dlGalleryOffset+=_DL_GALLERY_LIMIT; 11778 await _dlLoadGalleryPickerPage(false); 11779} 11780 11781function dlPickGalleryItem(url){ 11782 if(!_dlCanvas)return; 11783 document.getElementById('dl-gallery-modal').style.display='none'; 11784 fabric.Image.fromURL(url,img=>{ 11785 const maxW=Math.round((_dlCanvas._naturalW||_dlCanvas.getWidth())*0.6*_dlCanvas.getZoom()); 11786 img.scaleToWidth(maxW); 11787 img.set({left:60,top:60}); 11788 _dlCanvas.add(img);_dlCanvas.setActiveObject(img);_dlCanvas.renderAll(); 11789 },{crossOrigin:'anonymous'}); 11790} 11791let _dlMyBgsLoaded=false; 11792async function dlLoadMyBgs(){ 11793 if(_dlMyBgsLoaded)return; 11794 _dlMyBgsLoaded=true; 11795 const grid=document.getElementById('dl-mybg-grid'); 11796 if(!grid)return; 11797 grid.innerHTML='<span style="font-size:.72rem;color:var(--muted)">Loadingâ¦</span>'; 11798 try{ 11799 const res=await api('GET','/api/gallery?limit=60'); 11800 const d=await res.json(); 11801 const items=d.items||[]; 11802 grid.innerHTML=''; 11803 if(!items.length){ 11804 grid.innerHTML='<span style="font-size:.72rem;color:var(--muted)">No uploads yet â click ï¼ Upload Background to add your own.</span>'; 11805 return; 11806 } 11807 items.forEach(item=>{ 11808 const url=item.url||item.thumbnail||''; 11809 if(!url)return; 11810 const btn=document.createElement('button'); 11811 btn.title='Set as background'; 11812 btn.onclick=()=>dlApplyPhoto(url); 11813 btn.style.cssText='width:80px;height:80px;border:2px solid #202e56;border-radius:6px;cursor:pointer;padding:0;overflow:hidden;background:#0b132b;flex-shrink:0;transition:border-color .15s'; 11814 btn.onmouseover=()=>{btn.style.borderColor='#e2c275';}; 11815 btn.onmouseout=()=>{btn.style.borderColor='#202e56';}; 11816 const img=document.createElement('img'); 11817 img.src=item.thumbnail||url; 11818 img.style.cssText='width:100%;height:100%;object-fit:cover;display:block'; 11819 img.loading='lazy'; 11820 img.onerror=()=>{btn.style.display='none';}; 11821 btn.appendChild(img); 11822 grid.appendChild(btn); 11823 }); 11824 }catch(e){ 11825 grid.innerHTML=`<span style="font-size:.72rem;color:var(--red)">Error: ${e.message}</span>`; 11826 } 11827} 11828async function dlUploadMyBg(input){ 11829 if(!input.files[0])return; 11830 const fd=new FormData(); 11831 fd.append('file',input.files[0]); 11832 try{ 11833 const res=await fetch('/api/gallery/upload',{ 11834 method:'POST', 11835 headers:{Authorization:'Bearer '+localStorage.getItem('fz_token')}, 11836 body:fd, 11837 }); 11838 if(!res.ok){toast('Upload failed','err');return;} 11839 const item=await res.json(); 11840 const url=item.url||item.thumbnail||''; 11841 if(url)dlApplyPhoto(url); 11842 _dlMyBgsLoaded=false; 11843 dlLoadMyBgs();
11844 toast('Background saved to gallery','ok'); 11845 }catch(e){toast('Upload error','err');} 11846 input.value=''; 11847} 11848// âââ END DESIGN LAB âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 11849 11850// legacy toggle (kept for compat) 11851async function togglePolymarketSubscribe(){ 11852 try{ 11853 const r=await api('POST','/api/polymarket/subscribe'); 11854 const d=await r.json(); 11855 if(!r.ok){toast(d.detail||'Failed','err');return;} 11856 toast(d.polymarket_alerts?'Alerts enabled':'Alerts disabled','ok'); 11857 }catch(e){toast('Error','err');} 11858} 11859 11860// ââ TAX SESSIONS (admin) ââ 11861async function loadTaxSessions(){ 11862 const list=$('tax-sessions-list'); 11863 try{ 11864 const [cfgR,sessR]=await Promise.all([ 11865 api('GET','/api/admin/tax/config'), 11866 api('GET','/api/admin/tax/sessions'), 11867 ]); 11868 if(cfgR.ok){ 11869 const cfg=await cfgR.json(); 11870 const inp=$('tax-cpa-emails'); 11871 if(inp)inp.value=cfg.cpa_emails||''; 11872 const qrInp=$('tax-yappy-qr-url'); 11873 if(qrInp)qrInp.value=cfg.yappy_qr_url||''; 11874 } 11875 const d=await sessR.json(); 11876 if(!sessR.ok){list.innerHTML=`<p style="padding:16px;font-size:.8rem;color:var(--red)">${d.detail||'Failed to load'}</p>`;return;} 11877 const sessions = d.sessions || []; 11878 const badge=$('tax-paid-badge'); 11879 if(badge) badge.textContent=`${d.paid_count||0} paid / ${d.total||0} total`; 11880 window._adminTaxSessionsCache = sessions; 11881 if(!sessions.length){list.innerHTML='<div style="padding:20px;text-align:center;color:var(--muted);font-size:.8rem">No tax sessions yet.</div>';return;} 11882 list.innerHTML=`<table style="width:100%;border-collapse:collapse;font-size:.78rem"> 11883 <thead><tr style="border-bottom:1px solid var(--border)"> 11884 <th style="padding:10px 14px;text-align:left;color:var(--muted);font-weight:500">User</th> 11885 <th style="padding:10px 8px;text-align:left;color:var(--muted);font-weight:500">Label</th> 11886 <th style="padding:10px 8px;text-align:left;color:var(--muted);font-weight:500">Period</th> 11887 <th style="padding:10px 8px;text-align:left;color:var(--muted);font-weight:500">Date</th> 11888 <th style="padding:10px 8px;text-align:center;color:var(--muted);font-weight:500">Result</th> 11889 <th style="padding:10px 8px;text-align:center;color:var(--muted);font-weight:500">Paid</th> 11890 <th style="padding:10px 14px"></th> 11891 </tr></thead> 11892 <tbody>${sessions.map(s=>`<tr style="border-bottom:1px solid var(--border20);${s.is_paid?'background:rgba(0,230,118,.03)':''}"> 11893 <td style="padding:9px 14px"> 11894 <div style="font-weight:500">${s.user_name||''}</div> 11895 <div style="font-size:.7rem;color:var(--muted)">${s.user_email}</div> 11896 </td> 11897 <td style="padding:9px 8px">${s.label||'â'}</td> 11898 <td style="padding:9px 8px">${s.period||'â'}</td> 11899 <td style="padding:9px 8px;color:var(--muted)">${s.created_at?new Date(s.created_at).toLocaleDateString():''}</td> 11900 <td style="padding:9px 8px;text-align:center">${s.has_result?'<span style="color:var(--green)">â</span>':'<span style="color:var(--muted)">â</span>'}</td> 11901 <td style="padding:9px 8px;text-align:center">${s.is_paid?'<span style="color:var(--green);font-size:.7rem">â Paid</span>':'<span style="color:var(--muted);font-size:.7rem">â</span>'}</td> 11902 <td style="padding:9px 14px;text-align:right;white-space:nowrap"> 11903 <button class="btn btn-ghost" style="font-size:.68rem;padding:3px 9px" onclick="viewTaxSession('${s.id}')">View</button> 11904 <button class="btn btn-ghost" style="font-size:.68rem;padding:3px 9px;margin-left:4px;${s.is_paid?'color:var(--green);border-color:var(--green)':''}" onclick="toggleTaxPaid('${s.id}')">${s.is_paid?'â Paid':'Mark Paid'}</button> 11905 </td> 11906 </tr>`).join('')}</tbody> 11907 </table>`; 11908 }catch(e){if(list)list.innerHTML=`<p style="padding:16px;font-size:.8rem;color:var(--red)">Error: ${e.message}</p>`;} 11909} 11910 11911async function toggleTaxPaid(id){ 11912 try{ 11913 await api('PATCH',`/api/admin/tax/sessions/${id}/paid`); 11914 loadTaxSessions(); 11915 }catch(e){} 11916} 11917 11918async function toggleTaxPaidModal(id){ 11919 try{ 11920 const r=await api('PATCH',`/api/admin/tax/sessions/${id}/paid`); 11921 const d=await r.json(); 11922 const isPaid=d.is_paid;
11923 const badge=document.getElementById('modal-paid-badge'); 11924 const btn=document.getElementById('modal-paid-btn'); 11925 if(badge){badge.textContent=isPaid?'â Paid':'Unpaid';badge.style.color=isPaid?'var(--green)':'var(--muted)';} 11926 if(btn){btn.textContent=isPaid?'Mark Unpaid':'Mark Paid';btn.style.color=isPaid?'var(--green)':'';btn.style.borderColor=isPaid?'var(--green)':'';} 11927 loadTaxSessions(); 11928 }catch(e){} 11929} 11930 11931async function saveTaxConfig(){ 11932 const inp=$('tax-cpa-emails'); 11933 const result=$('tax-config-result'); 11934 if(!inp||!inp.value.trim()){if(result)result.innerHTML='<span style="color:var(--red)">Enter at least one email</span>';return;} 11935 const qrUrl=($('tax-yappy-qr-url')||{}).value||''; 11936 try{ 11937 const r=await api('PUT','/api/admin/tax/config',{cpa_emails:inp.value.trim(), yappy_qr_url:qrUrl.trim()||null}); 11938 const d=await r.json(); 11939 if(!r.ok){if(result)result.innerHTML=`<span style="color:var(--red)">${d.detail||'Failed'}</span>`;return;} 11940 if(result)result.innerHTML='<span style="color:var(--green)">Saved â</span>'; 11941 setTimeout(()=>{if(result)result.innerHTML='';},3000); 11942 }catch(e){if(result)result.innerHTML=`<span style="color:var(--red)">${e.message}</span>`;} 11943} 11944 11945async function viewTaxSession(id){ 11946 const overlay=$('tax-modal-overlay'); 11947 const body=$('tax-modal-body'); 11948 if(!overlay||!body)return; 11949 body.innerHTML='<p style="text-align:center;color:var(--muted)">Loading...</p>'; 11950 overlay.style.display='block'; 11951 try{ 11952 const r=await api('GET',`/api/admin/tax/sessions/${id}`); 11953 const s=await r.json(); 11954 if(!r.ok){body.innerHTML=`<p style="color:var(--red)">${s.detail||'Failed to load'}</p>`;return;} 11955 11956 const result=s.result||[]; 11957 const sections={}; 11958 result.forEach(e=>{ 11959 const sec=e.section||'OTHER'; 11960 if(!sections[sec]){sections[sec]={total:0,items:[]};} 11961 sections[sec].total+=e.amount||0; 11962 sections[sec].items.push(e); 11963 }); 11964 11965 const summaryRows=Object.entries(sections).map(([sec,data])=> 11966 `<tr style="border-bottom:1px solid var(--border20)"> 11967 <td style="padding:6px 0;color:var(--muted);font-size:.78rem">${sec}</td> 11968 <td style="padding:6px 0;font-weight:600;text-align:right">$${data.total.toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}</td> 11969 </tr>` 11970 ).join(''); 11971 11972 const itemRows=result.map(e=> 11973 `<tr style="border-bottom:1px solid var(--border20)"> 11974 <td style="padding:5px 6px;color:var(--muted);font-family:var(--mono);font-size:.72rem">${e.line}</td> 11975 <td style="padding:5px 6px;font-size:.75rem">${e.description}</td> 11976 <td style="padding:5px 6px;font-size:.72rem;color:var(--teal)">${e.section}</td> 11977 <td style="padding:5px 6px;text-align:right;font-family:var(--mono);font-size:.75rem">$${(e.amount||0).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}</td> 11978 </tr>` 11979 ).join(''); 11980 11981 // Build compare options from same-user sessions in the already-loaded list 11982 const _adminTaxSessions = window._adminTaxSessionsCache || []; 11983 const comparableOthers = _adminTaxSessions.filter(x=>x.id!==id && x.has_result && x.user_email===s.user_email); 11984 const compareSelect = comparableOthers.length ? ` 11985 <div style="border-top:1px solid var(--border);margin-top:20px;padding-top:16px"> 11986 <p style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:8px">COMPARE WITH ANOTHER PERIOD</p> 11987 <div style="display:flex;gap:8px;align-items:center"> 11988 <select id="tax-compare-select" style="flex:1;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:7px 10px;color:var(--text);font-size:.78rem"> 11989 <option value="">â select period â</option> 11990 ${comparableOthers.map(x=>`<option value="${x.id}">${x.label||'Untitled'} · ${x.period||''}</option>`).join('')} 11991 </select> 11992 <button class="btn btn-ghost" style="font-size:.75rem" onclick="adminCompareTaxSessions('${id}')">Compare â</button> 11993 </div> 11994 <div id="tax-admin-compare-wrap" style="margin-top:14px"></div> 11995 </div>` : ''; 11996 11997 body.innerHTML=` 11998 <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:8px"> 11999 <h3 style="margin:0">Tax Session â ${s.label||'Untitled'}</h3> 12000 <div style="display:flex;align-items:center;gap:8px">
12001 <span id="modal-paid-badge" style="font-size:.72rem;${s.is_paid?'color:var(--green)':'color:var(--muted)'}"> 12002 ${s.is_paid?'â Paid':'Unpaid'} 12003 </span> 12004 <button class="btn btn-ghost" id="modal-paid-btn" style="font-size:.72rem;padding:4px 10px;${s.is_paid?'color:var(--green);border-color:var(--green)':''}" 12005 onclick="toggleTaxPaidModal('${id}')"> 12006 ${s.is_paid?'Mark Unpaid':'Mark Paid'} 12007 </button> 12008 </div> 12009 </div> 12010 <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px;font-size:.78rem"> 12011 <div><span style="color:var(--muted)">User:</span> ${s.user_name||''} <${s.user_email}></div> 12012 <div><span style="color:var(--muted)">Period:</span> ${s.period||'â'}</div> 12013 <div><span style="color:var(--muted)">Date:</span> ${s.created_at?new Date(s.created_at).toLocaleString():''}</div> 12014 <div><span style="color:var(--muted)">Documents:</span> ${s.documents.length}</div> 12015 </div> 12016 12017 ${summaryRows?`<p style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-top:16px">SUMMARY</p> 12018 <table style="width:100%;border-collapse:collapse;margin-bottom:16px">${summaryRows}</table>`:''} 12019 12020 ${s.documents&&s.documents.length?` 12021 <div style="margin-top:16px;border-top:1px solid var(--border);padding-top:12px"> 12022 <p style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:8px">SUPPORTING DOCUMENTS (${s.documents.length})</p> 12023 <div style="display:flex;flex-wrap:wrap;gap:10px"> 12024 ${s.documents.map(url=>{ 12025 const isPdf=url.toLowerCase().includes('.pdf')||url.includes('/raw/'); 12026 return isPdf 12027 ? `<a href="${url}" target="_blank" style="display:inline-flex;align-items:center;justify-content:center;width:72px;height:72px;border:1px solid var(--border);border-radius:6px;background:var(--bg2);text-decoration:none;font-size:1.6rem">ð</a>` 12028 : `<a href="${url}" target="_blank"><img src="${url}" style="width:72px;height:72px;object-fit:cover;border-radius:6px;border:1px solid var(--border)"></a>`; 12029 }).join('')} 12030 </div> 12031 </div>` : ''} 12032 12033 ${itemRows?`<p style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-top:16px">CATEGORIZED ITEMS</p> 12034 <div style="overflow-x:auto"> 12035 <table style="width:100%;border-collapse:collapse;font-size:.78rem"> 12036 <thead><tr style="border-bottom:1px solid var(--border)"> 12037 <th style="padding:5px 6px;text-align:left;color:var(--muted);font-weight:500">Line</th> 12038 <th style="padding:5px 6px;text-align:left;color:var(--muted);font-weight:500">Form Description</th> 12039 <th style="padding:5px 6px;text-align:left;color:var(--muted);font-weight:500">Section</th> 12040 <th style="padding:5px 6px;text-align:right;color:var(--muted);font-weight:500">Amount</th> 12041 </tr></thead> 12042 <tbody>${itemRows}</tbody> 12043 </table></div>`:'<p style="color:var(--muted);font-size:.8rem">No categorized result yet.</p>'} 12044 12045 ${compareSelect} 12046 `; 12047 // Store current session data for comparison 12048 window._adminTaxSessionA = s; 12049 }catch(e){body.innerHTML=`<p style="color:var(--red)">${e.message}</p>`;} 12050} 12051 12052async function adminCompareTaxSessions(idA){ 12053 const sel=document.getElementById('tax-compare-select'); 12054 const wrap=document.getElementById('tax-admin-compare-wrap'); 12055 if(!sel||!sel.value||!wrap)return; 12056 wrap.innerHTML='<p style="font-size:.78rem;color:var(--muted)">Loading...</p>'; 12057 try{ 12058 const r=await api('GET',`/api/admin/tax/sessions/${sel.value}`); 12059 const sB=await r.json(); 12060 if(!r.ok){wrap.innerHTML=`<p style="color:var(--red)">${sB.detail}</p>`;return;} 12061 const sA=window._adminTaxSessionA; 12062 wrap.innerHTML = buildAdminCompareHTML(sA, sB); 12063 }catch(e){wrap.innerHTML=`<p style="color:var(--red)">${e.message}</p>`;} 12064} 12065 12066function buildAdminCompareHTML(a, b){ 12067 const mapA={}, mapB={}; 12068 (a.result||[]).forEach(e=>mapA[e.line]=e);
12069 (b.result||[]).forEach(e=>mapB[e.line]=e); 12070 const allLines=[...new Set([...Object.keys(mapA),...Object.keys(mapB)])].map(Number).sort((x,y)=>x-y); 12071 const labelA=a.label||a.period||'A', labelB=b.label||b.period||'B'; 12072 12073 const rows=allLines.map(line=>{ 12074 const ea=mapA[line],eb=mapB[line]; 12075 const desc=(eb||ea).description, sec=(eb||ea).section; 12076 const amtA=ea?ea.amount:0, amtB=eb?eb.amount:0, delta=amtB-amtA; 12077 const pct=amtA?((delta/amtA)*100).toFixed(1)+'%':(amtB?'new':'â'); 12078 const isGood=(sec==='INGRESOS'&&delta>0)||(sec!=='INGRESOS'&&delta<0); 12079 const dc=delta===0?'var(--muted)':isGood?'var(--green)':'var(--red)'; 12080 const bg=delta===0?'':isGood?'background:rgba(0,230,118,.04)':'background:rgba(255,68,68,.04)'; 12081 const fmt=n=>n?`$${n.toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}`:'â'; 12082 return `<tr style="border-bottom:1px solid rgba(255,255,255,.03);${bg}"> 12083 <td style="padding:5px 4px;color:var(--muted);font-family:monospace;font-size:.7rem">L${line}</td> 12084 <td style="padding:5px 4px;font-size:.73rem">${desc}</td> 12085 <td style="padding:5px 4px;font-size:.68rem;color:var(--text2)">${sec}</td> 12086 <td style="padding:5px 8px;text-align:right;font-family:monospace;font-size:.73rem">${fmt(amtA)}</td> 12087 <td style="padding:5px 8px;text-align:right;font-family:monospace;font-size:.73rem">${fmt(amtB)}</td> 12088 <td style="padding:5px 8px;text-align:right;font-family:monospace;font-size:.73rem;color:${dc}">${delta>=0?'+':''}${fmt(delta)}</td> 12089 <td style="padding:5px 4px;text-align:right;font-size:.7rem;color:${dc}">${pct}</td> 12090 </tr>`; 12091 }).join(''); 12092 12093 return `<p style="font-size:.6rem;color:var(--muted);font-family:var(--mono);letter-spacing:2px;margin-bottom:8px">PERIOD COMPARISON: ${labelA} vs ${labelB}</p> 12094 <div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:.76rem"> 12095 <thead><tr style="border-bottom:1px solid var(--border)"> 12096 <th style="padding:5px 4px;text-align:left;color:var(--muted);font-weight:400">Line</th> 12097 <th style="padding:5px 4px;text-align:left;color:var(--muted);font-weight:400">Description</th> 12098 <th style="padding:5px 4px;text-align:left;color:var(--muted);font-weight:400">Section</th> 12099 <th style="padding:5px 8px;text-align:right;color:var(--teal);font-weight:400">${labelA}</th> 12100 <th style="padding:5px 8px;text-align:right;color:var(--green);font-weight:400">${labelB}</th> 12101 <th style="padding:5px 8px;text-align:right;color:var(--muted);font-weight:400">Î</th> 12102 <th style="padding:5px 4px;text-align:right;color:var(--muted);font-weight:400">%</th> 12103 </tr></thead> 12104 <tbody>${rows}</tbody> 12105 </table></div>`; 12106} 12107 12108function closeTaxModal(){ 12109 const overlay=$('tax-modal-overlay'); 12110 if(overlay)overlay.style.display='none'; 12111} 12112 12113// âââ Chart lightbox âââââââââââââââââââââââââââââââââââââââââââ 12114function showChartModal(idx){ 12115 const c=lastScanCharts[idx]; 12116 if(!c)return; 12117 _modalChart=c; 12118 const src=`data:image/png;base64,${c.chart_b64}`; 12119 document.getElementById('chart-modal-img').src=src; 12120 const dl=document.getElementById('chart-modal-dl'); 12121 dl.href=src;dl.download=`${c.ticker}_chart.png`; 12122 const m=document.getElementById('chart-modal'); 12123 m.style.display='flex'; 12124 document.body.style.overflow='hidden'; 12125} 12126function closeChartModal(){ 12127 document.getElementById('chart-modal').style.display='none'; 12128 document.body.style.overflow=''; 12129} 12130function openImgPreview(url){ 12131 $('img-preview-src').src=url; 12132 $('img-preview-dl').href=url; 12133 $('img-preview-modal').style.display='flex'; 12134 document.body.style.overflow='hidden'; 12135} 12136function closeImgPreview(){ 12137 $('img-preview-modal').style.display='none'; 12138 $('img-preview-src').src=''; 12139 document.body.style.overflow=''; 12140} 12141function toggleChartSelect(idx){ 12142 const thumb=document.getElementById(`cthumb-${idx}`); 12143 const chk=document.getElementById(`cchk-${idx}`); 12144 if(_selectedChartIdxs.has(idx)){ 12145 _selectedChartIdxs.delete(idx); 12146 thumb.style.outline=''; 12147 chk.textContent='â';chk.style.borderColor='#888';
12148 } else { 12149 _selectedChartIdxs.add(idx); 12150 thumb.style.outline='2px solid #00d4e8'; 12151 chk.textContent='â';chk.style.borderColor='#00d4e8'; 12152 } 12153 const bar=$('chart-selection-bar'); 12154 const n=_selectedChartIdxs.size; 12155 if(n===0){bar.style.display='none';} 12156 else{bar.style.display='flex';$('chart-sel-count').textContent=`${n} chart${n>1?'s':''} selected`;} 12157} 12158function clearChartSelection(){ 12159 _selectedChartIdxs.forEach(i=>{ 12160 const t=document.getElementById(`cthumb-${i}`); 12161 const c=document.getElementById(`cchk-${i}`); 12162 if(t)t.style.outline=''; 12163 if(c){c.textContent='â';c.style.borderColor='#888';} 12164 }); 12165 _selectedChartIdxs.clear(); 12166 $('chart-selection-bar').style.display='none'; 12167} 12168function sendSelectedToCollage(){ 12169 if(!_selectedChartIdxs.size)return; 12170 const dt=new DataTransfer(); 12171 _selectedChartIdxs.forEach(i=>{ 12172 const c=lastScanCharts[i]; 12173 dt.items.add(new File([b64toBlob(c.chart_b64)],`${c.ticker}_chart.png`,{type:'image/png'})); 12174 }); 12175 $('collage-files').files=dt.files; 12176 const n=dt.files.length; 12177 clearChartSelection();go('collage');setCollageStep(2); 12178 toast(`${n} chart${n>1?'s':''} sent to Collage`,'ok'); 12179} 12180function sendSelectedToCarousel(){ 12181 if(!_selectedChartIdxs.size)return; 12182 const dt=new DataTransfer(); 12183 [..._selectedChartIdxs].slice(0,4).forEach(i=>{ 12184 const c=lastScanCharts[i]; 12185 dt.items.add(new File([b64toBlob(c.chart_b64)],`${c.ticker}_chart.png`,{type:'image/png'})); 12186 }); 12187 $('carousel-files').files=dt.files; 12188 const n=dt.files.length; 12189 clearChartSelection();go('carousel');setCarouselStep(2); 12190 toast(`${n} chart${n>1?'s':''} sent to Carousel`,'ok'); 12191} 12192function sendModalToCollage(){ 12193 if(!_modalChart)return; 12194 const file=new File([b64toBlob(_modalChart.chart_b64)],`${_modalChart.ticker}_chart.png`,{type:'image/png'}); 12195 const dt=new DataTransfer();dt.items.add(file); 12196 $('collage-files').files=dt.files; 12197 closeChartModal();go('collage');setCollageStep(2); 12198 toast(`${_modalChart.ticker} sent to Collage`,'ok'); 12199} 12200function sendModalToCarousel(){ 12201 if(!_modalChart)return; 12202 const file=new File([b64toBlob(_modalChart.chart_b64)],`${_modalChart.ticker}_chart.png`,{type:'image/png'}); 12203 const dt=new DataTransfer();dt.items.add(file); 12204 $('carousel-files').files=dt.files; 12205 closeChartModal();go('carousel');setCarouselStep(2); 12206 toast(`${_modalChart.ticker} sent to Carousel`,'ok'); 12207} 12208document.addEventListener('keydown',e=>{if(e.key==='Escape'){closeChartModal();closeArticleModal();}}); 12209 12210// ââ Article Accordion ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 12211function toggleArticle(id){ 12212 const body=document.getElementById('art-body-'+id); 12213 const preview=document.getElementById('art-preview-'+id); 12214 const lbl=document.getElementById('art-toggle-'+id); 12215 if(!body)return; 12216 const isOpen=body.style.display==='block'; 12217 if(isOpen){ 12218 body.style.display='none'; 12219 if(preview)preview.style.display=''; 12220 if(lbl)lbl.textContent='READ â'; 12221 return; 12222 } 12223 if(!body.children.length){ 12224 const n=_newsItems.find(x=>String(x.id)===String(id)); 12225 if(!n||!n.summary)return; 12226 const f=document.createElement('iframe'); 12227 f.style.cssText='width:100%;min-height:500px;border:none;display:block'; 12228 f.onload=()=>{try{f.style.height=(f.contentDocument.body.scrollHeight+20)+'px';}catch(e){}}; 12229 body.appendChild(f); 12230 f.srcdoc=n.summary; 12231 } 12232 if(preview)preview.style.display='none'; 12233 body.style.display='block'; 12234 if(lbl)lbl.textContent='READ â'; 12235 body.scrollIntoView({behavior:'smooth',block:'nearest'}); 12236} 12237 12238// ââ Article Reader (legacy, unused) ââââââââââââââââââââââââââââââââââââââââ 12239function openArticle(id){ 12240 const n=_newsItems.find(x=>String(x.id)===String(id)); 12241 if(!n)return; 12242 const badge=NEWS_TYPE_BADGE[n.content_type]||'badge-muted'; 12243 const icon=NEWS_TYPE_ICON[n.content_type]||'ð'; 12244 const label=(n.content_type||'news').replace('_',' ').toUpperCase(); 12245 const date=n.created_at?new Date(n.created_at).toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}):''; 12246 $('article-reader-meta').innerHTML=`<span class="badge ${badge}" style="font-size:.58rem">${icon} ${label}</span>${date?`<span style="font-size:.72rem;color:var(--muted)">${date}</span>`:''}`; 12247 $('article-reader-title').textContent=n.title||''; 12248 const _arBody=$('article-reader-body'); 12249 _arBody.innerHTML='';
12250 if(n.summary&&n.summary.includes('<')){ 12251 const _arFrame=document.createElement('iframe'); 12252 _arFrame.style.cssText='width:100%;min-height:500px;border:none;border-radius:6px;display:block'; 12253 _arFrame.onload=()=>{try{_arFrame.style.height=(_arFrame.contentDocument.body.scrollHeight+40)+'px';}catch(e){}}; 12254 _arBody.appendChild(_arFrame); 12255 _arFrame.srcdoc=n.summary; 12256 } else { 12257 _arBody.innerHTML=n.summary||'<p style="color:var(--muted)">No content.</p>'; 12258 } 12259 const acts=[ 12260 n.caption?`<button class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px" onclick="copyNewsCaption('${n.id}')">ð Copy</button>`:'', 12261 `<button class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px" onclick="closeArticleModal();sendToPost('${n.id}')">âï¸ Edit & Post</button>`, 12262 `<button class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px;color:#1DA1F2" onclick="postNewsItem('${n.id}','x')" id="ar-post-x-${n.id}">ð¦ Post X</button>`, 12263 `<button class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px;color:#E1306C" onclick="postNewsItem('${n.id}','instagram')" id="ar-post-ig-${n.id}">ð¸ Post IG</button>`, 12264 n.source_url?`<a href="${n.source_url}" target="_blank" rel="noopener" class="btn btn-ghost" style="font-size:.7rem;padding:5px 10px;text-decoration:none">ð Source</a>`:'' 12265 ].filter(Boolean).join(''); 12266 $('article-reader-actions').innerHTML=acts; 12267 $('article-reader-modal').style.display='block'; 12268 document.body.style.overflow='hidden'; 12269} 12270function closeArticleModal(){ 12271 $('article-reader-modal').style.display='none'; 12272 document.body.style.overflow=''; 12273} 12274 12275// ââ Guide accordion ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 12276function toggleGuide(id){ 12277 const body=document.getElementById(id); 12278 const arrow=document.getElementById(id+'-arrow'); 12279 const open=body.style.display==='block'; 12280 body.style.display=open?'none':'block'; 12281 if(arrow)arrow.classList.toggle('open',!open); 12282} 12283 12284// ââ Admin panel collapsible sections âââââââââââââââââââââââââââââââââââââââ 12285function openBriefModal(id){ 12286 const modal = document.getElementById('brief-modal'); 12287 const frame = document.getElementById('brief-modal-frame'); 12288 const titleEl = document.getElementById('brief-modal-title'); 12289 if(!modal||!frame) return; 12290 const item = _newsItems.find(n=>n.id===id); 12291 titleEl.textContent = (item&&item.title) || 'Daily Intelligence Brief'; 12292 frame.srcdoc = (item&&item.html_content) || '<p style="padding:20px;font-family:sans-serif">No content available.</p>'; 12293 modal.style.display = 'flex'; 12294 document.body.style.overflow = 'hidden'; 12295} 12296function closeBriefModal(){ 12297 const modal = document.getElementById('brief-modal'); 12298 if(modal) modal.style.display = 'none'; 12299 document.body.style.overflow = ''; 12300} 12301 12302function toggleAdminSection(id){ 12303 const body=document.getElementById(id); 12304 const arrow=document.getElementById(id+'-arrow'); 12305 if(!body)return; 12306 const collapsed=body.style.display==='none'; 12307 body.style.display=collapsed?'':'none'; 12308 if(arrow)arrow.textContent=collapsed?'â¾':'â¸'; 12309 if(collapsed && id==='as-opts-perf') loadOptionsPerformance(); 12310 if(collapsed && id==='as-scan-perf') loadScannerPerformance(); 12311 // Lazy â the newsroom queue costs two API calls, and the section is 12312 // collapsed by default, so don't pay for it on every admin page load. 12313 if(collapsed && id==='as-newsroom') loadNewsroom(); 12314} 12315 12316async function loadOptionsPerformance(){ 12317 const weeks = (document.getElementById('opts-perf-weeks')||{value:'12'}).value || 12; 12318 const ticker = ((document.getElementById('opts-perf-ticker')||{value:''}).value || '').trim(); 12319 const token = localStorage.getItem('factoz_token'); 12320 if(!token) return; 12321 12322 let url = `/api/admin/options/performance?weeks=${weeks}`; 12323 if(ticker) url += `&ticker=${encodeURIComponent(ticker)}`; 12324 12325 let data; 12326 try { 12327 const r = await fetch(url,{headers:{Authorization:'Bearer '+token}}); 12328 if(!r.ok) throw new Error(await r.text()); 12329 data = await r.json(); 12330 } catch(e) { 12331 console.error('opts-perf load failed',e); 12332 return; 12333 } 12334 12335 // Summary 12336 const wr = data.overall_win_rate; 12337 document.getElementById('opts-perf-winrate').textContent = wr != null ? wr+'%' : 'N/A'; 12338 document.getElementById('opts-perf-winrate').style.color = wr==null?'var(--muted)':wr>=60?'var(--green)':wr>=40?'var(--yellow)':'var(--red)'; 12339 document.getElementById('opts-perf-total').textContent = data.total_archived ?? 'â'; 12340 document.getElementById('opts-perf-resolved').textContent = data.total_resolved ?? 'â'; 12341 12342 // Top tickers chips 12343 const tc = document.getElementById('opts-perf-tickers'); 12344 tc.innerHTML = (data.top_tickers||[]).slice(0,6).map(t=> 12345 `<span style="background:var(--bg4);border:1px solid var(--border);border-radius:4px;padding:2px 8px;font-size:.65rem;color:var(--teal);cursor:pointer" 12346 onclick="document.getElementById('opts-perf-ticker').value='${t.ticker}';loadOptionsPerformance()">${t.ticker} <span style="color:var(--muted)">${t.count}</span></span>` 12347 ).join(''); 12348 12349 // Weekly table 12350 const wb = document.getElementById('opts-perf-weekly-body'); 12351 const weekly = data.weekly || []; 12352 if(!weekly.length){ 12353 wb.innerHTML='<tr><td colspan="7" style="padding:12px 8px;color:var(--muted);font-size:.72rem">No weekly data yet.</td></tr>'; 12354 } else { 12355 wb.innerHTML = weekly.map(w=>{ 12356 const wr = w.win_rate; 12357 const wrColor = wr==null?'var(--muted)':wr>=60?'var(--green)':wr>=40?'var(--yellow)':'var(--red)'; 12358 return `<tr style="border-bottom:1px solid var(--border)"> 12359 <td style="padding:6px 8px;color:var(--text)">${w.week_label||w.week_start}</td> 12360 <td style="padding:6px 8px;text-align:right;color:var(--text)">${w.total}</td> 12361 <td style="padding:6px 8px;text-align:right;color:var(--text)">${w.resolved}</td> 12362 <td style="padding:6px 8px;text-align:right;color:var(--green)">${w.win}</td> 12363 <td style="padding:6px 8px;text-align:right;color:${wrColor};font-weight:700">${wr!=null?wr+'%':'â'}</td> 12364 <td style="padding:6px 8px;text-align:right;color:var(--text)">${w.call_itm}</td> 12365 <td style="padding:6px 8px;text-align:right;color:var(--text)">${w.put_otm}</td> 12366 </tr>`; 12367 }).join(''); 12368 } 12369 12370 // Contracts table 12371 const cb = document.getElementById('opts-perf-contracts-body'); 12372 const emp = document.getElementById('opts-perf-empty'); 12373 const contracts = data.contracts || []; 12374 if(!contracts.length){ 12375 cb.innerHTML='';
12376 if(emp) emp.style.display=''; 12377 } else { 12378 if(emp) emp.style.display='none'; 12379 cb.innerHTML = contracts.map(c=>{ 12380 const res = c.resolution_type; 12381 let resultLabel='', resultColor='var(--muted)'; 12382 if(res==='expired_itm'){ resultLabel='ITM â'; resultColor='var(--green)'; } 12383 else if(res==='expired_otm' && c.option_type==='put'){ resultLabel='PUT OTM â'; resultColor='var(--green)'; } 12384 else if(res==='expired_otm'){ resultLabel='OTM â'; resultColor='var(--red)'; } 12385 else if(!res){ resultLabel='Pending'; resultColor='var(--muted)'; } 12386 const pnl = c.pnl_pct != null ? (c.pnl_pct>0?'+':'')+c.pnl_pct.toFixed(1)+'%' : 'â'; 12387 const pnlColor = c.pnl_pct==null?'var(--muted)':c.pnl_pct>0?'var(--green)':'var(--red)'; 12388 const premium = c.premium_usd ? '$'+(c.premium_usd>=1e6?(c.premium_usd/1e6).toFixed(2)+'M':(c.premium_usd/1e3).toFixed(0)+'K') : 'â'; 12389 const typeColor = c.option_type==='call'?'var(--green)':'var(--red)'; 12390 return `<tr style="border-bottom:1px solid var(--border)"> 12391 <td style="padding:5px 8px;color:var(--muted);font-size:.68rem">${c.week_label||'â'}</td> 12392 <td style="padding:5px 8px;font-weight:700;color:var(--teal)">${c.ticker}</td> 12393 <td style="padding:5px 8px;text-transform:uppercase;color:${typeColor};font-weight:700;font-size:.68rem">${c.option_type}</td> 12394 <td style="padding:5px 8px;text-align:right">$${c.strike}</td> 12395 <td style="padding:5px 8px;color:var(--muted)">${c.expiry||'â'}</td> 12396 <td style="padding:5px 8px;text-align:right;color:var(--yellow)">${premium}</td> 12397 <td style="padding:5px 8px;text-align:right">${c.vol_oi_ratio!=null?c.vol_oi_ratio.toFixed(1)+'x':'â'}</td> 12398 <td style="padding:5px 8px;font-weight:700;color:${resultColor}">${resultLabel||'â'}</td> 12399 <td style="padding:5px 8px;text-align:right;color:${pnlColor}">${pnl}</td> 12400 <td style="padding:5px 8px;color:var(--muted);font-size:.68rem">${c.flagged_at||'â'}</td> 12401 </tr>`; 12402 }).join(''); 12403 } 12404} 12405 12406async function loadScannerPerformance(){ 12407 const days = (document.getElementById('scan-perf-days')||{value:'180'}).value || 180; 12408 const strategy = (document.getElementById('scan-perf-strategy')||{value:''}).value || ''; 12409 const ticker = ((document.getElementById('scan-perf-ticker')||{value:''}).value || '').trim(); 12410 const token = localStorage.getItem('factoz_token'); 12411 if(!token) return; 12412 12413 let url = `/api/admin/scanner/performance?days=${days}`; 12414 if(strategy) url += `&strategy=${encodeURIComponent(strategy)}`; 12415 if(ticker) url += `&ticker=${encodeURIComponent(ticker)}`; 12416 12417 let data; 12418 try { 12419 const r = await fetch(url,{headers:{Authorization:'Bearer '+token}}); 12420 if(!r.ok) throw new Error(await r.text()); 12421 data = await r.json(); 12422 } catch(e) { 12423 console.error('scan-perf load failed',e); 12424 return; 12425 } 12426 12427 const STRATEGY_LABELS = {rsi_extremes:'RSI Extremes (Pre-Market)', premarket_movers:'Pre-Market Movers (retired)', breakout_52wk:'52-Week Breakout', gap_and_hold:'Gap & Hold'}; 12428 12429 // Summary â lead with 30d overall 12430 const d30 = (data.overall||{})['30d'] || {}; 12431 const wr = d30.win_rate; 12432 document.getElementById('scan-perf-winrate').textContent = wr != null ? wr+'%' : 'N/A'; 12433 document.getElementById('scan-perf-winrate').style.color = wr==null?'var(--muted)':wr>=55?'var(--green)':wr>=45?'var(--yellow)':'var(--red)'; 12434 document.getElementById('scan-perf-total').textContent = data.total_picks ?? 'â'; 12435 document.getElementById('scan-perf-resolved').textContent = d30.resolved ?? 'â'; 12436 12437 // Horizon breakdown table â one row per strategy per horizon 12438 const hb = document.getElementById('scan-perf-horizon-body'); 12439 const byStrategy = data.by_strategy || {}; 12440 const strategies = Object.keys(byStrategy); 12441 if(!strategies.length){ 12442 hb.innerHTML = '<tr><td colspan="7" style="padding:12px 8px;color:var(--muted);font-size:.72rem">No scan picks yet.</td></tr>'; 12443 } else { 12444 const rowsHtml = []; 12445 for(const strat of strategies){ 12446 for(const h of ['30d','60d','90d','120d']){ 12447 const b = byStrategy[strat][h] || {}; 12448 if(!b.resolved && !b.pending) continue; 12449 const wrColor = b.win_rate==null?'var(--muted)':b.win_rate>=55?'var(--green)':b.win_rate>=45?'var(--yellow)':'var(--red)'; 12450 const ci = (b.ci_low!=null && b.ci_high!=null) ? `${b.ci_low}â${b.ci_high}%` : 'â'; 12451 const med = b.median_excess_return; 12452 const medColor = med==null?'var(--muted)':med>0?'var(--green)':'var(--red)'; 12453 rowsHtml.push(`<tr style="border-bottom:1px solid var(--border)"> 12454 <td style="padding:6px 8px;color:var(--text)">${STRATEGY_LABELS[strat]||strat}</td> 12455 <td style="padding:6px 8px;color:var(--muted)">${h}</td> 12456 <td style="padding:6px 8px;text-align:right;color:var(--text)">${b.resolved ?? 0}</td> 12457 <td style="padding:6px 8px;text-align:right;color:var(--muted)">${b.pending ?? 0}</td> 12458 <td style="padding:6px 8px;text-align:right;color:${wrColor};font-weight:700">${b.win_rate!=null?b.win_rate+'%':'â'}</td> 12459 <td style="padding:6px 8px;text-align:right;color:var(--muted);font-size:.68rem">${ci}</td> 12460 <td style="padding:6px 8px;text-align:right;color:${medColor}">${med!=null?(med>0?'+':'')+med+'%':'â'}</td> 12461 </tr>`); 12462 } 12463 } 12464 hb.innerHTML = rowsHtml.join('') || '<tr><td colspan="7" style="padding:12px 8px;color:var(--muted);font-size:.72rem">No resolved or pending picks in this window.</td></tr>'; 12465 } 12466 12467 // Recent picks table 12468 const pb = document.getElementById('scan-perf-picks-body');
12469 const emp = document.getElementById('scan-perf-empty'); 12470 const picks = data.picks || []; 12471 if(!picks.length){ 12472 pb.innerHTML = ''; 12473 if(emp) emp.style.display = ''; 12474 } else { 12475 if(emp) emp.style.display = 'none'; 12476 pb.innerHTML = picks.slice(0,100).map(p=>{ 12477 const excess = p.excess_return_30d; 12478 const excessColor = excess==null?'var(--muted)':excess>0?'var(--green)':'var(--red)'; 12479 const dirColor = p.direction==='BULLISH'?'var(--green)':'var(--red)'; 12480 return `<tr style="border-bottom:1px solid var(--border)"> 12481 <td style="padding:5px 8px;font-weight:700;color:var(--teal)">${p.ticker}</td> 12482 <td style="padding:5px 8px;color:var(--muted);font-size:.68rem">${STRATEGY_LABELS[p.strategy]||p.strategy}</td> 12483 <td style="padding:5px 8px;text-transform:uppercase;color:${dirColor};font-weight:700;font-size:.68rem">${p.direction}</td> 12484 <td style="padding:5px 8px;color:var(--muted)">${p.call_date}</td> 12485 <td style="padding:5px 8px;text-align:right">$${p.entry_price}</td> 12486 <td style="padding:5px 8px;text-align:right;color:${excessColor}">${excess!=null?(excess>0?'+':'')+excess+'%':'pending'}</td> 12487 </tr>`; 12488 }).join(''); 12489 } 12490} 12491 12492// ââ AI Financial Advisor Chat ââââââââââââââââââââââââââââââââââââââââââââââ 12493function chipPrompt(template){ 12494 const ticker = lastScanCharts.length ? lastScanCharts[0].ticker : 'TICKER'; 12495 const msg = template.replace('[ticker]', ticker); 12496 const inp = document.getElementById('chat-input'); 12497 inp.value = msg; 12498 inp.focus(); 12499} 12500function appendMessage(role, text){ 12501 const log = document.getElementById('chat-log'); 12502 const div = document.createElement('div'); 12503 div.className = 'chat-msg ' + role; 12504 div.textContent = text; 12505 log.appendChild(div); 12506 log.scrollTop = log.scrollHeight; 12507} 12508function clearChat(){ 12509 document.getElementById('chat-log').innerHTML = ''; 12510 document.getElementById('chat-status').textContent = ''; 12511} 12512async function sendChat(){ 12513 const inp = document.getElementById('chat-input'); 12514 const msg = inp.value.trim(); 12515 if(!msg) return; 12516 inp.value = ''; 12517 appendMessage('user', msg); 12518 12519 const context = { 12520 tickers: lastScanCharts.map(c=>c.ticker), 12521 signals: lastScanCharts.map(c=>({ticker:c.ticker, direction:c.direction, price:c.price})) 12522 }; 12523 12524 const btn = document.getElementById('chat-btn'); 12525 const status = document.getElementById('chat-status'); 12526 btn.disabled = true; 12527 status.textContent = 'â³ Thinkingâ¦'; 12528 12529 try { 12530 const r = await fetch('/api/chat/ask', { 12531 method: 'POST', 12532 headers: {'Content-Type':'application/json', 'Authorization': `Bearer ${TOKEN}`}, 12533 body: JSON.stringify({message: msg, context}) 12534 }); 12535 const data = await r.json(); 12536 if(!r.ok){ 12537 const detail = data.detail; 12538 if(typeof detail === 'object' && detail.cooldown_seconds){ 12539 status.textContent = `â± Rate limit â try again in ${detail.cooldown_seconds}s`; 12540 } else { 12541 appendMessage('err', (typeof detail === 'string' ? detail : detail?.message) || 'Error â try again.'); 12542 status.textContent = ''; 12543 } 12544 } else { 12545 appendMessage('ai', data.reply); 12546 if(data.remaining > 0){ 12547 status.textContent = `${data.remaining} of ${data.limit} questions remaining this window`; 12548 } else { 12549 status.textContent = `Limit reached â resets in ${data.cooldown_seconds}s`; 12550 } 12551 } 12552 } catch(e){ 12553 appendMessage('err', 'Connection error. Try again.'); 12554 status.textContent = ''; 12555 } finally { 12556 btn.disabled = false; 12557 } 12558} 12559 12560function toggleSidebar(){ 12561 const s=$('sidebar'), b=document.getElementById('mob-backdrop'); 12562 const open=s.classList.toggle('mob-open'); 12563 b.classList.toggle('visible',open); 12564} 12565// Close sidebar when a nav item is tapped on mobile 12566document.addEventListener('click',function(e){ 12567 if(e.target.closest('.nav-item')&&window.innerWidth<=768)toggleSidebar(); 12568}); 12569 12570// âââ Design Studio ââââââââââââââââââââââââââââââââââââââââââââââââ 12571let _studioInited = false;
12572 12573async function initDesignStudio() { 12574 if (_studioInited) return; 12575 try { 12576 const r = await api('GET', '/api/contentdrips/config'); 12577 const d = await r.json(); 12578 if (!d.configured || typeof contentdrips === 'undefined') return; 12579 contentdrips.init({ 12580 client_id: d.client_id, 12581 email: USER.email, 12582 user_id: String(USER.id), 12583 environment: 'production', 12584 }); 12585 if (window._brandKit) { 12586 contentdrips.profile.set({ 12587 name: _brandKit.name || '', 12588 handle: _brandKit.handle || '', 12589 avatar: _brandKit.logo_url || '', 12590 website: _brandKit.website_url || '', 12591 bio: _brandKit.bio || '', 12592 }); 12593 } 12594 contentdrips.on('image_export', async (data) => { 12595 await saveStudioExport('image', [data.image_url]); 12596 }); 12597 contentdrips.on('carousel_export', async (data) => { 12598 await saveStudioExport('carousel', data.images); 12599 }); 12600 _studioInited = true; 12601 } catch(e) { /* non-fatal â SDK may not be loaded yet */ } 12602} 12603 12604async function openDesignStudio() { 12605 await initDesignStudio(); 12606 if (typeof contentdrips === 'undefined') { 12607 toast('Design Studio not configured â contact admin to activate', 'err'); 12608 return; 12609 } 12610 contentdrips.open({}); 12611} 12612 12613async function saveStudioExport(type, images) { 12614 const saving = $('studio-saving'); 12615 if (saving) saving.style.display = ''; 12616 try { 12617 const r = await api('POST', '/api/contentdrips/save', { type, images }); 12618 const d = await r.json(); 12619 if (r.ok) { 12620 toast('Design saved!', 'ok'); 12621 await loadStudioGallery(); 12622 } else { 12623 toast(d.detail || 'Save failed', 'err'); 12624 } 12625 } catch(e) { 12626 toast('Error saving design', 'err'); 12627 } finally { 12628 if (saving) saving.style.display = 'none'; 12629 } 12630} 12631 12632async function loadStudioGallery() { 12633 const grid = $('studio-gallery'); 12634 const empty = $('studio-empty'); 12635 if (!grid) return; 12636 try { 12637 const r = await api('GET', '/api/contentdrips/gallery?limit=40'); 12638 const d = await r.json(); 12639 const designs = d.designs || []; 12640 if (!designs.length) { 12641 grid.innerHTML = ''; 12642 if (empty) empty.style.display = ''; 12643 return; 12644 } 12645 if (empty) empty.style.display = 'none'; 12646 grid.innerHTML = designs.map(ds => { 12647 const slideLinks = ds.slides.length > 1 12648 ? ds.slides.map((s, i) => 12649 `<a href="${s.url}" target="_blank" download style="font-size:.62rem" class="btn btn-ghost">Slide ${i+1}</a>` 12650 ).join('') 12651 : ''; 12652 return `<div class="card" style="padding:0;overflow:hidden;position:relative"> 12653 <img src="${ds.thumbnail}" style="width:100%;aspect-ratio:1;object-fit:cover;display:block" 12654 onerror="this.style.background='var(--bg2)';this.removeAttribute('src')"> 12655 <div style="padding:8px 10px"> 12656 <div style="font-size:.68rem;color:var(--muted);margin-bottom:6px"> 12657 ${ds.slides.length} slide${ds.slides.length > 1 ? 's' : ''} · ${new Date(ds.created_at).toLocaleDateString()} 12658 </div> 12659 <div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center"> 12660 <a href="${ds.thumbnail}" target="_blank" download style="font-size:.65rem" class="btn btn-ghost">â ${ds.slides.length > 1 ? 'Cover' : 'Download'}</a> 12661 ${slideLinks} 12662 <button onclick="deleteStudioDesign('${ds.id}')" 12663 style="font-size:.62rem;color:#f87171;background:none;border:none;cursor:pointer;padding:2px 4px;margin-left:auto">â</button> 12664 </div> 12665 </div> 12666 </div>`; 12667 }).join(''); 12668 } catch(e) { 12669 if (grid) grid.innerHTML = '<p style="font-size:.73rem;color:#f87171">Failed to load gallery.</p>'; 12670 } 12671} 12672 12673async function deleteStudioDesign(id) { 12674 if (!confirm('Delete this design?')) return; 12675 const r = await api('DELETE', `/api/contentdrips/designs/${id}`); 12676 if (r.ok) { toast('Deleted', 'ok'); await loadStudioGallery(); } 12677 else toast('Delete failed', 'err'); 12678} 12679// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 12680 12681// âââ Factoz Graphs ââââââââââââââââââââââââââââââââââââââââââââââââ 12682const FACTOZ_GRAPHS = [ 12683 // Demographics 12684 { id:'pop', cat:'Demographics', icon:'ð¥', title:'World Population', 12685 iframe:'https://ourworldindata.org/grapher/population-with-un-projections?tab=line', 12686 slug:'population-with-un-projections' }, 12687 { id:'pop-growth', cat:'Demographics', icon:'ð', title:'Population Growth Rate', 12688 iframe:'https://ourworldindata.org/grapher/population-growth-rates?tab=chart', 12689 slug:'population-growth-rates' }, 12690 { id:'life-exp', cat:'Health', icon:'â¤ï¸', title:'Life Expectancy', 12691 iframe:'https://ourworldindata.org/grapher/life-expectancy?country=OWID_WRL~Americas~OWID_EUR~OWID_AFR&tab=line', 12692 slug:'life-expectancy' }, 12693 { id:'hunger', cat:'Health', icon:'ð½ï¸', title:'Share Undernourished', 12694 iframe:'https://ourworldindata.org/grapher/prevalence-of-undern
12694ourishment?tab=line', 12695 slug:'prevalence-of-undernourishment' }, 12696 // Economy 12697 { id:'gdp', cat:'Economy', icon:'ð°', title:'GDP per Capita', 12698 iframe:'https://ourworldindata.org/grapher/gdp-per-capita-worldbank?tab=chart', 12699 slug:'gdp-per-capita-worldbank' }, 12700 // Energy 12701 { id:'energy-pc', cat:'Energy', icon:'â¡', title:'Energy Use per Person', 12702 iframe:'https://ourworldindata.org/grapher/per-capita-energy-use?tab=map', 12703 slug:'per-capita-energy-use' }, 12704 { id:'elec-src', cat:'Energy', icon:'ð', title:'Electricity by Source', 12705 iframe:'https://ourworldindata.org/grapher/electricity-prod-source-stacked?tab=chart', 12706 slug:'electricity-prod-source-stacked' }, 12707 // Technology 12708 { id:'internet', cat:'Technology', icon:'ð', title:'Internet Users', 12709 iframe:'https://ourworldindata.org/grapher/share-of-individuals-using-the-internet?country=WB_SA~WB_NA~WB_SSA~WB_EAP&tab=map', 12710 slug:'share-of-individuals-using-the-internet' }, 12711 { id:'space', cat:'Technology', icon:'ð', title:'Annual Space Launches', 12712 iframe:'https://ourworldindata.org/grapher/yearly-number-of-objects-launched-into-outer-space?tab=line', 12713 slug:'yearly-number-of-objects-launched-into-outer-space' }, 12714 // Energy 12715 { id:'elec-access', cat:'Energy', icon:'ð¡', title:'Electricity Access', 12716 iframe:'https://ourworldindata.org/grapher/share-of-the-population-with-access-to-electricity?tab=map', 12717 slug:'share-of-the-population-with-access-to-electricity' }, 12718 // Health 12719 { id:'vaccines', cat:'Health', icon:'ð', title:'Global Vaccination Coverage', 12720 iframe:'https://ourworldindata.org/grapher/global-vaccination-coverage?tab=line', 12721 slug:'global-vaccination-coverage' }, 12722 // Conflict 12723 { id:'conflicts', cat:'Conflict', icon:'âï¸', title:'Deaths in Armed Conflicts', 12724 iframe:'https://ourworldindata.org/grapher/deaths-in-armed-conflicts-by-region?tab=chart', 12725 slug:'deaths-in-armed-conflicts-by-region' }, 12726 // Society 12727 { id:'womens-rights', cat:'Society', icon:'âï¸', title:"Women's Rights by Country", 12728 iframe:'https://ourworldindata.org/grapher/number-of-countries-with-key-economic-and-social-rights-for-women?tab=line', 12729 slug:'number-of-countries-with-key-economic-and-social-rights-for-women' }, 12730]; 12731 12732let _activeGraphFilter = 'All'; 12733let _currentGraph = null; 12734 12735// ââ LIVE CHARTS ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 12736let _lcInited = false; 12737 12738function initChartsPage(){ 12739 if(_lcInited) return; 12740 _lcInited = true; 12741 renderPineLibrary(); 12742 loadLiveChart(); 12743} 12744 12745function loadLiveChart(symbol){ 12746 symbol = (symbol || 'NASDAQ:AAPL').toUpperCase(); 12747 if(!symbol.includes(':')) symbol = 'NASDAQ:' + symbol; 12748 const wrap = $('tv-chart-wrap'); 12749 if(!wrap) return; 12750 wrap.innerHTML = ''; 12751 12752 const container = document.createElement('div'); 12753 container.className = 'tradingview-widget-container'; 12754 container.style.cssText = 'height:100%;width:100%'; 12755 12756 const widget = document.createElement('div'); 12757 widget.className = 'tradingview-widget-container__widget'; 12758 widget.style.cssText = 'height:100%;width:100%'; 12759 12760 const config = { 12761 allow_symbol_change: true, 12762 calendar: false, 12763 details: false, 12764 hide_side_toolbar: false, 12765 hide_top_toolbar: false, 12766 hide_legend: false, 12767 hide_volume: false, 12768 hotlist: false, 12769 interval: '240', 12770 locale: 'en', 12771 save_image: true, 12772 style: '1', 12773 symbol: symbol, 12774 theme: 'dark', 12775 timezone: 'Etc/UTC', 12776 backgroundColor: '#0b132b', 12777 gridColor: 'rgba(32, 46, 86, 0.5)', 12778 withdateranges: true, 12779 compareSymbols: [], 12780 studies: [], 12781 autosize: true, 12782 }; 12783 12784 const script = document.createElement('script'); 12785 script.type = 'text/javascript'; 12786 script.src = 'https://s3.tradingview.com/external-embedding/embed-widget-advanced-chart.js'; 12787 script.async = true; 12788 script.textContent = JSON.stringify(config); 12789 12790 const copy = document.createElement('div'); 12791 copy.style.cssText = 'font-size:.6rem;color:#3d4f63;opacity:.5;padding:2px 0'; 12792 copy.innerHTML = `<a href="https://www.tradingview.com/" rel="noopener nofollow" target="_blank" style="color:#3d4f63;text-decoration:none">Advanced Chart by TradingView</a>`; 12793 12794 const tvRef = document.createElement('div'); 12795 tvRef.style.cssText = 'text-align:right;margin-bottom:6px'; 12796 tvRef.innerHTML = `<a href="https://in.tradingview.com/?aff_id=164648" target="_blank" rel="noopener" style="display:inline-block;background:#1a2340;border:1px solid #E2C275;border-radius:20px;padding:4px 12px;font-size:.68rem;color:#E2C275;text-decoration:none;letter-spacing:.3px">ð Get <strong>$15 USD off</strong> TradingView â Open here â</a>`; 12797 12798 container.appendChild(widget); 12799 container.appendChild(copy); 12800 container.appendChild(script); 12801 wrap.appendChild(tvRef); 12802 wrap.appendChild(container); 12803} 12804 12805const PINE_INDICATORS = [ 12806 {name:'Supertrend', category:'Trend', desc:'Dynamic support/resistance that flips with trend direction', file:'supertrend.pine'}, 12807 {name:'Chandelier Exit', category:'Trend', desc:'ATR-based trailing stop to protect open gains', file:'chandelier_exit.pine'}, 12808 {name:'HalfTrend', category:'Trend', desc:'Smooth trend-following indicator with buy/sell signals', file:'halftrend.pine'}, 12809 {name:'Parabolic SAR', category:'Trend', desc:'Classic stop and reverse â catches trend reversals', file:'parabolic_sar.pine'}, 12810 {name:'Hull Moving Average', category:'Moving Avg', desc:'Reduced-lag MA by Alan Hull â faster and smoother', file:'hull_moving_average.pine'}, 12811 {name:'Kaufman Adaptive MA', category:'Moving Avg', desc:'Adapts speed to market noise using efficiency ratio', file:'kaufman_adaptive_moving_average.pine'}, 12812 {name:'Zero Lag EMA', category:'Moving Avg', desc:'EMA with lag elimination for faster signal response', file:'zero_lag_ema.pine'}, 12813 {name:'Jurik Moving Average', category:'Moving Avg', desc:'Low-lag, low-noise adaptive filter by Mark Jurik', file:'jurik_moving_average.pine'}, 12814 {name:'RSI', category:'Oscillator', desc:'
12814Classic relative strength index â momentum & overbought/oversold', file:'rsi.pine'}, 12815 {name:'Laguerre RSI', category:'Oscillator', desc:'Lower-noise RSI variant using the Laguerre transform', file:'laguerre_rsi.pine'}, 12816 {name:'Schaff Trend Cycle', category:'Oscillator', desc:'Faster MACD-based cycle oscillator with cleaner signals', file:'schaff_trend_cycle.pine'}, 12817 {name:'Stochastic Momentum Index', category:'Oscillator', desc:'Refined stochastic with smoother signal line', file:'stochastic_momentum_index.pine'}, 12818 {name:'Awesome Oscillator', category:'Oscillator', desc:'Bill Williams momentum oscillator using SMA difference', file:'awesome_oscillator.pine'}, 12819 {name:'Jurik RSX', category:'Oscillator', desc:'Ultra-smooth RSI replacement with minimal lag', file:'jurik_rsx.pine'}, 12820 {name:'On Balance Volume', category:'Volume', desc:'Cumulative volume â confirms or contradicts price direction', file:'on_balance_volume.pine'}, 12821 {name:'Klinger Volume Oscillator', category:'Volume', desc:'Long-term money flow trend vs short-term fluctuations', file:'klinger_volume_oscillator.pine'}, 12822]; 12823 12824const _PINE_CAT_COLORS = { 12825 'Trend': {bg:'rgba(0,229,180,.12)', border:'rgba(0,229,180,.4)', text:'#00e5b4'}, 12826 'Moving Avg': {bg:'rgba(33,150,243,.12)', border:'rgba(33,150,243,.4)', text:'#42a5f5'}, 12827 'Oscillator': {bg:'rgba(156,39,176,.12)', border:'rgba(156,39,176,.4)', text:'#ce93d8'}, 12828 'Volume': {bg:'rgba(255,152,0,.12)', border:'rgba(255,152,0,.4)', text:'#ffb74d'}, 12829}; 12830 12831function renderPineLibrary(){ 12832 const wrap = $('pine-library-wrap'); 12833 if(!wrap) return; 12834 const hasPro = USER && (USER.tier==='pro'||USER.tier==='stellar'||USER.tier==='enterprise_api'||USER.tier==='admin'||USER.tier==='super_admin'); 12835 if(!hasPro){ 12836 wrap.innerHTML=`<div style="background:#161b22;border:1px solid #30363d;border-radius:12px;padding:32px;text-align:center"> 12837 <div style="font-size:1.8rem;margin-bottom:12px">ð</div> 12838 <div style="font-weight:700;margin-bottom:8px">Pine Script Library â Pro plan required</div> 12839 <p style="color:#8b949e;font-size:.88rem;margin-bottom:18px">Upgrade to Pro to download all 16 curated Pine Script indicators.</p> 12840 <a href="https://factozcentral.com/register" style="display:inline-block;padding:11px 26px;background:#00e5b4;color:#000;border-radius:7px;font-weight:700;text-decoration:none;font-size:.9rem">Upgrade to Pro â</a> 12841 </div>`; 12842 return; 12843 } 12844 const cats = [...new Set(PINE_INDICATORS.map(i=>i.category))]; 12845 let html = ''; 12846 cats.forEach(cat => { 12847 const c = _PINE_CAT_COLORS[cat] || {bg:'rgba(255,255,255,.06)',border:'rgba(255,255,255,.2)',text:'#c9d1d9'}; 12848 html += `<div style="margin-bottom:24px"> 12849 <div style="font-size:.65rem;font-family:var(--mono);letter-spacing:2px;color:${c.text};margin-bottom:10px;text-transform:uppercase">${cat}</div> 12850 <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:10px">`; 12851 PINE_INDICATORS.filter(i=>i.category===cat).forEach(ind => { 12852 html += `<div style="background:#161b22;border:1px solid #30363d;border-radius:10px;padding:16px;display:flex;flex-direction:column;gap:8px"> 12853 <div style="display:flex;align-items:center;justify-content:space-between;gap:8px"> 12854 <span style="font-weight:700;font-size:.9rem;color:var(--text)">${ind.name}</span> 12855 <span style="font-size:.6rem;font-family:var(--mono);padding:2px 8px;border-radius:20px;background:${c.bg};border:1px solid ${c.border};color:${c.text};white-space:nowrap">${ind.category}</span> 12856 </div> 12857 <p style="font-size:.78rem;color:#8b949e;margin:0;line-height:1.5">${ind.desc}</p> 12858 <a href="/pine/${ind.file}" download style="margin-top:4px;padding:7px 14px;background:transparent;border:1px solid #30363d;border-radius:6px;color:#c9d1d9;font-size:.78rem;font-weight:600;text-decoration:none;text-align:center;display:block;transition:border-color .2s" 12859 onmouseover="this.style.borderColor='#00e5b4';this.style.color='#00e5b4'" 12860 onmouseout="this.style.borderColor='#30363d';this.style.color='#c9d1d9'"> 12861 ⬠Download .pine 12862 </a> 12863 </div>`; 12864 }); 12865 html += `</div></div>`; 12866 }); 12867 wrap.innerHTML = html; 12868} 12869// ââ END LIVE CHARTS âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 12870 12871function renderGraphsPage() { 12872 const filters = $('graphs-filters'); 12873 const grid = $('graphs-grid'); 12874 if (!filters || !grid) return; 12875 12876 const cats = ['All', ...new Set(FACTOZ_GRAPHS.map(g => g.cat))]; 12877 filters.innerHTML = cats.map(c => ` 12878 <button onclick="filterGraphs('${c}')" 12879 style="font-size:.68rem;padding:4px 12px;border-radius:20px;cursor:pointer; 12880 border:1px solid var(--border); 12881 background:${c===_activeGraphFilter?'var(--accent)':'var(--card)'}; 12882 color:${c===_activeGraphFilter?'#000':'var(--text)'}"> 12883 ${c} 12884 </button>`).join(''); 12885 12886 const filtered = _activeGraphFilter === 'All' 12887 ? FACTOZ_GRAPHS 12888 : FACTOZ_GRAPHS.filter(g => g.cat === _activeGraphFilter); 12889 12890 grid.innerHTML = filtered.map(g => `
12891 <div class="card" onclick="openGraphModal('${g.id}')" 12892 style="cursor:pointer;padding:16px 12px;text-align:center; 12893 transition:transform .15s;user-select:none" 12894 onmouseover="this.style.transform='translateY(-2px)'" 12895 onmouseout="this.style.transform=''"> 12896 <div style="font-size:2rem;margin-bottom:8px">${g.icon}</div> 12897 <div style="font-size:.72rem;font-weight:600;line-height:1.3">${g.title}</div> 12898 <div style="font-size:.62rem;color:var(--muted);margin-top:4px">${g.cat}</div> 12899 </div>`).join(''); 12900} 12901 12902function filterGraphs(cat) { 12903 _activeGraphFilter = cat; 12904 renderGraphsPage(); 12905} 12906 12907function openGraphModal(id) { 12908 _currentGraph = FACTOZ_GRAPHS.find(g => g.id === id); 12909 if (!_currentGraph) return; 12910 $('graph-modal-title').textContent = _currentGraph.icon + ' ' + _currentGraph.title; 12911 $('graph-modal-iframe').src = _currentGraph.iframe; 12912 $('graph-modal-source').href = 'https://ourworldindata.org/grapher/' + _currentGraph.slug; 12913 $('graph-modal-overlay').style.display = 'flex'; 12914} 12915 12916function closeGraphModal(e) { 12917 if (e && e.target !== $('graph-modal-overlay')) return; 12918 $('graph-modal-overlay').style.display = 'none'; 12919 $('graph-modal-iframe').src = ''; 12920 _currentGraph = null; 12921} 12922 12923function downloadGraphPNG() { 12924 if (!_currentGraph) return; 12925 // Our World in Data's chart PNGs are public â no backend proxy needed 12926 const a = document.createElement('a'); 12927 a.href = 'https://ourworldindata.org/grapher/' + _currentGraph.slug + '.png'; 12928 a.target = '_blank'; 12929 a.rel = 'noopener'; 12930 a.download = _currentGraph.slug + '.png'; 12931 document.body.appendChild(a); 12932 a.click(); 12933 document.body.removeChild(a); 12934} 12935// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 12936</script>
12936 12937 12938<!-- Generic image preview modal --> 12939<div id="img-preview-modal" onclick="if(event.target===this)closeImgPreview()" 12940 style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.9);z-index:9999; 12941 align-items:center;justify-content:center;flex-direction:column;gap:14px"> 12942 <img id="img-preview-src" style="max-width:92vw;max-height:80vh;border-radius:8px;box-shadow:0 4px 32px #000"> 12943 <div style="display:flex;gap:12px"> 12944 <button onclick="closeImgPreview()" 12945 style="background:#1e2130;color:#d1d4dc;border:1px solid #2a2e40;border-radius:6px; 12946 padding:8px 20px;cursor:pointer;font-size:13px">â Close</button> 12947 <a id="img-preview-dl" download="image.png" 12948 style="background:#0d7c3d;color:#fff;border-radius:6px;padding:8px 20px; 12949 text-decoration:none;font-size:13px;cursor:pointer;display:inline-flex;align-items:center">⬠Download</a> 12950 </div> 12951</div> 12952 12953<!-- Chart lightbox modal --> 12954<div id="chart-modal" onclick="if(event.target===this)closeChartModal()" 12955 style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.88);z-index:9999; 12956 align-items:center;justify-content:center;flex-direction:column;gap:14px"> 12957 <img id="chart-modal-img" style="max-width:96vw;max-height:90vh;border-radius:8px;box-shadow:0 4px 32px #000"> 12958 <div style="display:flex;gap:12px;flex-wrap:wrap;justify-content:center"> 12959 <button onclick="closeChartModal()" 12960 style="background:#1e2130;color:#d1d4dc;border:1px solid #2a2e40;border-radius:6px; 12961 padding:8px 20px;cursor:pointer;font-size:13px">â Close</button> 12962 <a id="chart-modal-dl" download="chart.png" 12963 style="background:#364fc7;color:#fff;border-radius:6px;padding:8px 20px; 12964 text-decoration:none;font-size:13px;cursor:pointer;display:inline-flex;align-items:center">⬠Download</a> 12965 </div> 12966</div> 12967 12968<!-- ââ MACRO DASHBOARD JS ââ -->
12969<script> 12970let _macroSeriesCache = null; 12971let _macroCurrentChartB64 = null; 12972let _macroCompareB64 = null; 12973let _macroScorecardLoaded = false; 12974 12975async function _loadMacroSeries(){ 12976 if(_macroSeriesCache) return _macroSeriesCache; 12977 try { 12978 const _r = await api('GET','/api/fred/series-list'); 12979 const d = await _r.json(); 12980 _macroSeriesCache = d.series||[]; 12981 } catch(e){ _macroSeriesCache = []; } 12982 const opts = _macroSeriesCache.map(s=>`<option value="${s.id}">${s.id} â ${s.label}</option>`).join(''); 12983 const emptyOpt = '<option value="">â optional â</option>'; 12984 ['macro-chart-series','macro-cmp-s1','fred-alert-series'].forEach(id=>{ 12985 const el=document.getElementById(id); if(el) el.innerHTML=opts; 12986 }); 12987 ['macro-cmp-s2','macro-cmp-s3'].forEach(id=>{ 12988 const el=document.getElementById(id); if(el) el.innerHTML=emptyOpt+opts; 12989 }); 12990 return _macroSeriesCache; 12991} 12992 12993function scannerTab(name){ 12994 document.querySelectorAll('#page-scanner .macro-tab').forEach(b=>b.classList.remove('active')); 12995 document.querySelectorAll('#page-scanner .macro-pane').forEach(p=>p.classList.remove('active')); 12996 const btn=document.querySelector(`#page-scanner .macro-tab[onclick*="'${name}'"]`); 12997 if(btn)btn.classList.add('active'); 12998 const pane=document.getElementById('scanner-pane-'+name); 12999 if(pane)pane.classList.add('active'); 13000} 13001 13002function heatmapTab(name){ 13003 document.querySelectorAll('#page-heatmap .macro-tab').forEach(b=>b.classList.remove('active')); 13004 document.querySelectorAll('#page-heatmap .macro-pane').forEach(p=>p.classList.remove('active')); 13005 const btn=document.querySelector(`#page-heatmap .macro-tab[onclick*="'${name}'"]`); 13006 if(btn)btn.classList.add('active'); 13007 const pane=document.getElementById('heatmap-pane-'+name); 13008 if(pane)pane.classList.add('active'); 13009} 13010 13011function macroTab(name){ 13012 document.querySelectorAll('#page-macro .macro-tab').forEach(b=>b.classList.remove('active')); 13013 document.querySelectorAll('#page-macro .macro-pane').forEach(p=>p.classList.remove('active')); 13014 const btn=document.querySelector(`#page-macro .macro-tab[onclick*="'${name}'"]`); 13015 if(btn) btn.classList.add('active'); 13016 const pane=document.getElementById('macro-pane-'+name); 13017 if(pane) pane.classList.add('active'); 13018 if(name==='releases' && !document.getElementById('macro-releases-list')?.dataset.loaded) loadMacroReleases(); 13019 if(name==='alerts'){ loadFredAlerts(); checkFredAlerts(); } 13020} 13021 13022async function loadMacroScorecard(force){ 13023 await _loadMacroSeries(); 13024 if(_macroScorecardLoaded && !force) return; 13025 const grid=document.getElementById('macro-scorecard-grid'); 13026 if(!grid) return; 13027 grid.innerHTML='<p style="font-size:.8rem;color:var(--text2);grid-column:1/-1">Fetching FRED data...</p>'; 13028 try { 13029 const _r=await api('GET','/api/fred/scorecard'); 13030 const d=await _r.json(); 13031 const cards=d.cards||[]; 13032 if(!cards.length){ 13033 grid.innerHTML='<p style="color:var(--text2);font-size:.8rem;grid-column:1/-1">No data. Is FRED_API_KEY set?</p>'; 13034 return; 13035 } 13036 grid.innerHTML=cards.map(c=>{ 13037 const arrow=c.arrow||''; 13038 const delta=c.delta!=null?(c.delta>0?'+':'')+c.delta.toFixed(2):''; 13039 const hist=JSON.stringify(c.history||[]).replace(/'/g,'''); 13040 return `<div class="macro-card" data-history='${hist}' data-label="${c.label}"> 13041 <div class="mc-label">${c.label}</div> 13042 <div class="mc-val">${c.display}</div> 13043 <div class="mc-sub">${arrow} ${delta} ${c.date}</div> 13044 <div><span class="mc-badge ${c.status}">${c.status_label}</span></div> 13045 </div>`; 13046 }).join(''); 13047 _attachMacroTooltips(); 13048 _macroScorecardLoaded=true; 13049 } catch(e){ 13050 grid.innerHTML=`<p style="color:var(--red);font-size:.8rem;grid-column:1/-1">Error: ${e.message||e}</p>`; 13051 } 13052} 13053 13054function _attachMacroTooltips(){ 13055 const tip=document.getElementById('mc-tooltip'); 13056 if(!tip) return; 13057 document.querySelectorAll('.macro-card').forEach(card=>{ 13058 card.addEventListener('mouseenter',e=>{
13059 try { 13060 const history=JSON.parse(card.dataset.history||'[]'); 13061 const label=card.dataset.label||''; 13062 if(!history.length) return; 13063 const rows=history.map(h=>`<div class="mct-row"><span class="mct-date">${h.date}</span><span class="mct-val">${h.display}</span></div>`).join(''); 13064 tip.innerHTML=`<div class="mct-title">${label.toUpperCase()}</div>${rows}`; 13065 tip.style.display='block'; 13066 _moveMacroTip(e); 13067 } catch(_){} 13068 }); 13069 card.addEventListener('mousemove',_moveMacroTip); 13070 card.addEventListener('mouseleave',()=>{ tip.style.display='none'; }); 13071 }); 13072} 13073function _moveMacroTip(e){ 13074 const tip=document.getElementById('mc-tooltip'); 13075 if(!tip) return; 13076 const x=e.clientX+14, y=e.clientY+14; 13077 tip.style.left=(x+tip.offsetWidth>window.innerWidth ? x-tip.offsetWidth-28 : x)+'px'; 13078 tip.style.top =(y+tip.offsetHeight>window.innerHeight ? y-tip.offsetHeight-28 : y)+'px'; 13079} 13080 13081let _macroCurrentObs=[]; 13082 13083async function loadMacroChart(){ 13084 const sid =document.getElementById('macro-chart-series')?.value; 13085 const trans =document.getElementById('macro-chart-transform')?.value||'level'; 13086 const limit =document.getElementById('macro-chart-limit')?.value||'120'; 13087 const rec =document.getElementById('macro-chart-recession')?.checked?1:0; 13088 if(!sid){ toast('Select a series','err'); return; } 13089 const wrap=document.getElementById('macro-chart-wrap'); 13090 wrap.style.justifyContent='center'; 13091 wrap.innerHTML='<p style="color:var(--text2);font-size:.8rem">Rendering chart...</p>'; 13092 document.getElementById('macro-chart-table-wrap').style.display='none'; 13093 try { 13094 const [chartRes,obsRes]=await Promise.all([ 13095 api('GET',`/api/fred/chart/${sid}?transform=${trans}&limit=${limit}&recession=${rec}`), 13096 api('GET',`/api/fred/series/${sid}?transform=${trans}&limit=${limit}`) 13097 ]); 13098 const chartData=await chartRes.json(); 13099 const obsData=await obsRes.json(); 13100 _macroCurrentChartB64=chartData.image_b64; 13101 wrap.style.justifyContent=''; 13102 wrap.innerHTML=`<img src="data:image/png;base64,${chartData.image_b64}" style="width:100%;border-radius:8px;display:block">`; 13103 document.getElementById('macro-chart-export').style.display=''; 13104 _macroCurrentObs=obsData.observations||[]; 13105 _renderFredTable(obsData,trans); 13106 } catch(e){ 13107 wrap.innerHTML=`<p style="color:var(--red);font-size:.8rem;padding:20px">${e.message||'Chart failed'}</p>`; 13108 } 13109} 13110 13111function _renderFredTable(obsData,trans){ 13112 const obs=(obsData.observations||[]).slice().reverse(); 13113 const info=obsData.info||{}; 13114 const wrap=document.getElementById('macro-chart-table-wrap'); 13115 const body=document.getElementById('macro-chart-table-body'); 13116 const meta=document.getElementById('macro-table-meta'); 13117 const uhdr=document.getElementById('macro-table-unit-hdr'); 13118 if(!obs.length){ wrap.style.display='none'; return; } 13119 const unitLabel=trans==='yoy'?'YoY %':trans==='mom'?'MoM %':trans==='mom_abs'?'MoM Change':(info.units||'Value'); 13120 meta.textContent=`${info.title||obsData.series_id} · ${obs.length} OBSERVATIONS`; 13121 uhdr.textContent=unitLabel.toUpperCase(); 13122 body.innerHTML=obs.map((r,i)=>` 13123 <tr style="border-bottom:1px solid var(--border);${i%2===0?'background:var(--bg2)':''}"> 13124 <td style="padding:5px 14px;color:var(--text2)">${r.date}</td> 13125 <td style="padding:5px 14px;text-align:right;color:var(--text)">${r.value.toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2})}</td> 13126 </tr>`).join(''); 13127 wrap.style.display='block'; 13128} 13129 13130function downloadFredCSV(){ 13131 if(!_macroCurrentObs.length){ toast('Draw a chart first','err'); return; } 13132 const sid =document.getElementById('macro-chart-series')?.value||'FRED'; 13133 const trans=document.getElementById('macro-chart-transform')?.value||'level'; 13134 const lbl =trans==='yoy'?'YoY %':trans==='mom'?'MoM %':trans==='mom_abs'?'MoM Change':'Value'; 13135 const csv =`Date,${lbl}\n`+_macroCurrentObs.map(r=>`${r.date},${r.value}`).join('\n'); 13136 const blob =new Blob([csv],{type:'text/csv'}); 13137 const url =URL.createObjectURL(blob); 13138 const a =document.createElement('a'); 13139 a.href=url; a.download=`${sid}_${trans}.csv`; a.click(); 13140 URL.revokeObjectURL(url); 13141} 13142 13143function downloadMacroChart(){ 13144 if(!_macroCurrentChartB64){ toast('Generate a chart first','err'); return; } 13145 const sid=document.getElementById('macro-chart-series')?.value||'chart'; 13146 const trans=document.getElementById('macro-chart-transform')?.value||'level'; 13147 const a=document.createElement('a'); 13148 a.href='data:image/png;base64,'+_macroCurrentChartB64; 13149 a.download=`FRED_${sid}_${trans}.png`; 13150 a.click(); 13151} 13152 13153function downloadMacroCompare(){ 13154 if(!_macroCompareB64){ toast('Run a comparison first','err'); return; } 13155 const s1=document.getElementById('macro-cmp-s1')?.value||''; 13156 const s2=document.getElementById('macro-cmp-s2')?.value||''; 13157 const s3=document.getElementById('macro-cmp-s3')?.value||'';
13158 const label=[s1,s2,s3].filter(Boolean).join('-'); 13159 const a=document.createElement('a'); 13160 a.href='data:image/png;base64,'+_macroCompareB64; 13161 a.download=`FRED_compare_${label}.png`; 13162 a.click(); 13163} 13164 13165async function loadMacroCompare(){ 13166 const s1 =document.getElementById('macro-cmp-s1')?.value; 13167 const s2 =document.getElementById('macro-cmp-s2')?.value; 13168 const s3 =document.getElementById('macro-cmp-s3')?.value; 13169 const trans=document.getElementById('macro-cmp-transform')?.value||'level'; 13170 const ids=[s1,s2,s3].filter(Boolean); 13171 if(!ids.length){ toast('Select at least one series','err'); return; } 13172 const wrap=document.getElementById('macro-compare-wrap'); 13173 wrap.style.justifyContent='center'; 13174 wrap.innerHTML='<p style="color:var(--text2);font-size:.8rem">Rendering comparison...</p>'; 13175 try { 13176 const _r=await api('GET',`/api/fred/compare?ids=${ids.join(',')}&transform=${trans}`); 13177 const d=await _r.json(); 13178 wrap.style.justifyContent=''; 13179 wrap.innerHTML=`<img src="data:image/png;base64,${d.image_b64}" style="width:100%;border-radius:8px;display:block">`; 13180 _macroCompareB64=d.image_b64; 13181 document.getElementById('macro-compare-dl').style.display=''; 13182 } catch(e){ 13183 wrap.innerHTML=`<p style="color:var(--red);font-size:.8rem;padding:20px">${e.message||'Compare failed'}</p>`; 13184 } 13185} 13186 13187async function loadMacroAIBrief(){ 13188 const btn=document.getElementById('macro-brief-btn'); 13189 const out=document.getElementById('macro-brief-out'); 13190 const dout=document.getElementById('macro-brief-data'); 13191 btn.disabled=true; btn.textContent='Generating...'; 13192 out.style.display='none'; dout.style.display='none'; 13193 try { 13194 const _r=await api('GET','/api/fred/ai-brief'); 13195 const d=await _r.json(); 13196 out.textContent=d.brief||''; 13197 out.style.display='block'; 13198 if(d.data_used?.length){ 13199 dout.innerHTML='<div style="font-size:.58rem;letter-spacing:2px;color:var(--muted);margin-bottom:6px">DATA USED</div>' 13200 +d.data_used.map(r=>`<div>${r}</div>`).join(''); 13201 dout.style.display='block'; 13202 } 13203 } catch(e){ 13204 out.textContent=`Error: ${e.message||e}`; out.style.display='block'; 13205 } finally { 13206 btn.disabled=false; btn.textContent='Generate Macro Brief'; 13207 } 13208} 13209 13210async function loadMacroReleases(){ 13211 const el=document.getElementById('macro-releases-list'); 13212 if(!el) return; 13213 el.innerHTML='<p style="font-size:.8rem;color:var(--text2)">Loading...</p>'; 13214 el.dataset.loaded='1'; 13215 try { 13216 const _r=await api('GET','/api/fred/releases?days=30'); 13217 const d=await _r.json(); 13218 const releases=d.releases||[]; 13219 if(!releases.length){ el.innerHTML='<p style="font-size:.8rem;color:var(--text2)">No upcoming releases found.</p>'; return; } 13220 const byDate={}; 13221 releases.forEach(r=>{ (byDate[r.date]=byDate[r.date]||[]).push(r.release_name); }); 13222 const todayStr=new Date().toISOString().slice(0,10); 13223 el.innerHTML=Object.keys(byDate).sort().map(dt=>{ 13224 const names=byDate[dt]; 13225 const diff=(new Date(dt)-new Date())/86400000; 13226 const badge=dt===todayStr 13227 ?'<span class="mc-badge alert" style="margin-left:8px">TODAY</span>' 13228 :diff<=7?'<span class="mc-badge neutral" style="margin-left:8px">SOON</span>':''; 13229 return `<div style="display:flex;gap:14px;align-items:flex-start;padding:10px 0;border-bottom:1px solid var(--border)"> 13230 <div style="font-family:var(--mono);font-size:.75rem;color:var(--teal);min-width:100px">${dt}${badge}</div> 13231 <div style="font-size:.78rem;color:var(--text);line-height:1.6">${names.join(' · ')}</div> 13232 </div>`; 13233 }).join(''); 13234 } catch(e){ 13235 el.innerHTML=`<p style="color:var(--red);font-size:.8rem">${e.message||e}</p>`; 13236 } 13237} 13238 13239async function loadFredAlerts(){ 13240 const el=document.getElementById('fred-alerts-list'); 13241 if(!el) return; 13242 el.innerHTML='<p style="font-size:.8rem;color:var(--text2)">Loading...</p>'; 13243 try { 13244 const _r=await api('GET','/api/fred/alerts'); 13245 const d=await _r.json(); 13246 if(!d.length){ el.innerHTML='<p style="font-size:.8rem;color:var(--text2)">No alerts set.</p>'; return; } 13247 el.innerHTML=d.map(a=>` 13248 <div style="display:flex;align-items:center;gap:12px;padding:10px 14px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;margin-bottom:8px"> 13249 <div style="flex:1"> 13250 <div style="font-size:.78rem;font-weight:600">${a.series_title||a.series_id}</div> 13251 <div style="font-size:.68rem;color:var(--text2);font-family:var(--mono);margin-top:2px"> 13252 ${a.transform.toUpperCase()} ${a.condition==='gt'?'â² above':'â¼ below'} <strong>${a.threshold}</strong> 13253 ${a.last_triggered?' · last fired '+a.last_triggered.slice(0,10):''} 13254 </div> 13255 </div> 13256 <button onclick="deleteFredAlert('${a.id}')" style="background:none;border:1px solid var(--border);color:var(--muted);border-radius:6px;padding:3px 10px;font-size:.7rem;cursor:pointer">â Delete</button> 13257 </div>`).join(''); 13258 } catch(e){ 13259 el.innerHTML=`<p style="color:var(--red);font-size:.8rem">${e.message||e}</p>`; 13260 } 13261} 13262 13263async function createFredAlert(){ 13264 const series =document.getElementById('fred-alert-series')?.value; 13265 const transform =document.getElementById('fred-alert-transform')?.value||'level'; 13266 const condition =document.getElementById('fred-alert-condition')?.value||'gt'; 13267 const threshold =parseFloat(document.getElementById('fred-alert-threshold')?.value); 13268 if(!series||isNaN(threshold)){ toast('Fill in all alert fields','err'); return; } 13269 const label=(_macroSeriesCache||[]).find(s=>
13269s.id===series)?.label||series; 13270 try { 13271 await api('POST','/api/fred/alerts',{series_id:series,series_title:label,condition,threshold,transform}); 13272 toast('Alert saved','ok'); 13273 document.getElementById('fred-alert-threshold').value=''; 13274 loadFredAlerts(); 13275 } catch(e){ toast(e.message||'Failed to save alert','err'); } 13276} 13277 13278async function deleteFredAlert(id){ 13279 try { 13280 await api('DELETE',`/api/fred/alerts/${id}`); 13281 toast('Alert deleted','ok'); 13282 loadFredAlerts(); 13283 } catch(e){ toast(e.message||'Failed to delete','err'); } 13284} 13285 13286async function checkFredAlerts(){ 13287 try { 13288 const _r=await api('GET','/api/fred/alerts/check'); 13289 const d=await _r.json(); 13290 const triggered=d.triggered||[]; 13291 const el=document.getElementById('fred-alerts-triggered'); 13292 if(!triggered.length){ if(el) el.style.display='none'; return; } 13293 if(el){ 13294 el.style.display='block'; 13295 el.innerHTML='<strong>ALERTS TRIGGERED</strong><br>'+triggered.map(t=> 13296 `${t.series_title||t.series_id}: ${t.transform.toUpperCase()} is ${t.current_value?.toFixed(2)} (${t.condition==='gt'?'above':'below'} ${t.threshold}) as of ${t.date}` 13297 ).join('<br>'); 13298 } 13299 toast(`${triggered.length} macro alert(s) triggered`,'err'); 13300 } catch(e){ /* silently ignore on non-pro tiers */ } 13301} 13302</script>
13302 13303 13304<!-- ââ PRE-MARKET CHART MODAL (TradingView) ââ --> 13305<div id="pm-chart-modal" onclick="closePmChart()" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.7);align-items:center;justify-content:center"> 13306 <div onclick="event.stopPropagation()" style="position:relative;width:min(360px,90vw)"> 13307 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px"> 13308 <div id="pm-chart-caption" style="font-family:var(--mono);font-size:.7rem;color:var(--text2)"></div> 13309 <button onclick="closePmChart()" style="background:none;border:none;color:var(--text2);font-size:1rem;cursor:pointer;padding:0 0 0 12px">â</button> 13310 </div> 13311 <div style="border-radius:8px;overflow:hidden"> 13312 <div id="pm-chart-container"></div> 13313 </div> 13314 </div> 13315</div> 13316 13317<!-- ââ LIGHTBOX ââ --> 13318<div id="lightbox" onclick="closeLightbox()" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.92);align-items:center;justify-content:center;gap:16px"> 13319 <button onclick="event.stopPropagation();lbPrev()" style="background:none;border:1px solid var(--border);color:var(--text);font-size:1.6rem;padding:8px 14px;border-radius:6px;cursor:pointer;flex-shrink:0">â¹</button> 13320 <div onclick="event.stopPropagation()" style="max-width:min(90vw,700px);display:flex;flex-direction:column;align-items:center;gap:10px"> 13321 <button onclick="closeLightbox()" style="align-self:flex-end;background:none;border:none;color:var(--muted);font-size:1.2rem;cursor:pointer;padding:0">â</button> 13322 <img id="lb-img" src="" style="max-width:100%;max-height:80vh;border-radius:8px;object-fit:contain"> 13323 <div style="display:flex;align-items:center;gap:16px"> 13324 <div id="lb-caption" style="font-family:var(--mono);font-size:.65rem;color:var(--muted)"></div> 13325 <a id="lb-dl" href="#" download style="font-family:var(--mono);font-size:.65rem;color:var(--teal)">â Download</a> 13326 <a id="lb-tv-link" href="#" target="_blank" rel="noopener" style="font-family:var(--mono);font-size:.65rem;color:var(--text2);display:none">â TradingView</a> 13327 </div> 13328 </div> 13329 <button onclick="event.stopPropagation();lbNext()" style="background:none;border:1px solid var(--border);color:var(--text);font-size:1.6rem;padding:8px 14px;border-radius:6px;cursor:pointer;flex-shrink:0">âº</button> 13330</div>
13331<script type="module" src="https://widgets.tradingview-widget.com/w/en/tv-mini-chart.js"></script>
vendor: 1 bytes, line 13331
13331
13332<script src="https://assets.contentdrips.io/embed.js"></script>
vendor: 1 bytes, line 13332
13332
13333<script src="https://cdn.plot.ly/plotly-basic-2.26.0.min.js"></script>
vendor: 1 bytes, line 13333
13333
13334<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
13334 13335<!-- lightweight-charts removed â Live Charts now uses TradingView Advanced Chart widget -->
13336<script> 13337// Init Paddle.js with client token from backend 13338fetch(API + '/api/paddle/config') 13339 .then(r => r.json()) 13340 .then(d => { 13341 if (d.client_token) Paddle.Setup({ token: d.client_token }); 13342 if (d.stellar_price_id) { 13343 window._paddleStellarId = d.stellar_price_id; 13344 document.querySelectorAll('.stellar-paddle-btn').forEach(b => b.style.display = 'block'); 13345 } 13346 }) 13347 .catch(() => {}); 13348</script>
13348 13349 13350<!-- PayPal Hosted Button SDK -->
13351<script src="https://www.paypal.com/sdk/js?client-id=BAA2PaqZ__QcJLQKIxfJE49M6SAarwmXwpN38Xi72Nudi_Fp46D46ZISVgQ7ggNd-5iCHGIQQWUurUI16E&components=hosted-buttons&disable-funding=venmo¤cy=USD" data-sdk-integration-source="button-factory"></script>
vendor: 1 bytes, line 13351
13351
13352<script> 13353// Render PayPal hosted button(s) wherever the container exists 13354function _initPayPalButton() { 13355 var containers = document.querySelectorAll('.paypal-hosted-btn-container'); 13356 containers.forEach(function(el) { 13357 if (el.dataset.rendered) return; 13358 el.dataset.rendered = '1'; 13359 try { 13360 paypal.HostedButtons({ hostedButtonId: 'D3PHKERK9D9NU' }).render('#' + el.id); 13361 } catch(e) { /* SDK not ready yet */ } 13362 }); 13363} 13364// Try immediately + on SDK ready 13365if (window.paypal) { _initPayPalButton(); } 13366else { document.currentScript && document.currentScript.addEventListener 13367 ? null 13368 : setTimeout(_initPayPalButton, 2000); } 13369window.addEventListener('load', _initPayPalButton); 13370 13371// If user lands back with ?paypal_paid=1 show a pending toast and poll status 13372(function() { 13373 if (!/paypal_paid=1/.test(location.search)) return; 13374 history.replaceState(null, '', location.pathname); 13375 setTimeout(function() { 13376 toast('Payment received â activating your account, please wait a moment...', 'ok'); 13377 // Poll user status every 5 s for up to 30 s 13378 var tries = 0; 13379 var poll = setInterval(async function() { 13380 tries++;
13381 try { 13382 var r = await fetch(API + '/api/auth/me', { headers: { Authorization: 'Bearer ' + localStorage.getItem('fz_token') } }); 13383 var d = await r.json(); 13384 if (d && d.is_paid) { 13385 clearInterval(poll); 13386 toast('Pro access activated!', 'ok'); 13387 setTimeout(() => location.reload(), 1200); 13388 } 13389 } catch(e) {} 13390 if (tries >= 6) clearInterval(poll); 13391 }, 5000); 13392 }, 1500); 13393})(); 13394</script>
13394 13395</body> 13396</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.