1(() => { 2 'use strict'; 3 4 const menu = document.getElementById('storeMenu'); 5 const trigger = document.getElementById('storeBtn'); 6 const dropdown = document.getElementById('storeDropdown'); 7 const root = document.getElementById('channelStore'); 8 if (!menu || !trigger || !dropdown || !root) return; 9 10 let currentView = 'panel'; 11 const API_URL = ''; 12 let account = null; 13 let identityIndex = 0; 14 let pairingMonitor = null; 15 let uiAudioContext = null; 16 let viewEpoch = 0; 17 let inboxSignature = ''; 18 const adminMetricsState = { loaded: false, chestType: '', prize: '', sort: 'recent', page: 1 }; 19 const chestOpenInFlight = new Set(); 20 21 function stopPairingMonitor() { 22 if (pairingMonitor) clearInterval(pairingMonitor); 23 pairingMonitor = null; 24 } 25 26 function monitorPairing() { 27 stopPairingMonitor(); 28 pairingMonitor = setInterval(() => { 29 fetch(`${API_URL}/api/rewards/me`, { credentials: 'include' }) 30 .then((response) => response.ok ? response.json() : null) 31 .then((data) => { 32 if (!data?.user?.extension_connected) return; 33 account = data.user; 34 stopPairingMonitor(); 35 renderPanel(); 36 }) 37 .catch(() => {}); 38 }, 5000); 39 setTimeout(stopPairingMonitor, 10 * 60 * 1000); 40 } 41 42 const loginMessages = { 43 google_token_error: 'O Google recusou as credenciais do aplicativo. Tente novamente após a correção.', 44 youtube_api_error: 'O Google autorizou, mas a API do YouTube recusou a consulta do canal.', 45 no_youtube_channel: 'Essa conta Google não possui um canal do YouTube acessÃvel.', 46 account_conflict: 'Essa conta já está ligada a outro perfil e não pode ser usada para misturar saldos.' 47 }; 48 49 function escapeHtml(value) { 50 return String(value ?? '').replace(/[&<>'"]/g, (character) => ({ 51 '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' 52 })[character]); 53 } 54 55 const chestMeta = { 56 twitch: { label: 'Baú Twitch', image: '/assets/rewards/chest_common_green.png' }, 57 twitch_sub: { label: 'Baú Twitch Sub', image: '/assets/rewards/chest_rare_blue.png' }, 58 youtube: { label: 'Baú YouTube', image: '/assets/rewards/chest_epic_red.png' }, 59 legendary: { label: 'Baú Lendário', image: '/assets/rewards/chest_legendary_gold.png' } 60 }; 61 const adminMetricPrizeMeta = { 62 rare: { label: 'Raro', fallbackTierLabel: 'Recompensa rara' }, 63 special: { label: 'Especial', fallbackTierLabel: 'Prêmio especial' }, 64 maximum: { label: 'Máximo', fallbackTierLabel: 'Prêmio máximo' } 65 }; 66 const qq = (value) => Number(value || 0).toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); 67 const decimalInput = (value) => String(Number(value || 0)); 68 const parseDecimal = (value) => Number(String(value ?? '').trim().replace(',', '.')); 69 const coin = '<img class="qq-coin" src="/assets/rewards/keke_coin.png" alt="KEKE Coin">'; 70 const rubiniCoin = '<img class="rubini-coin-icon rubini-prize-icon" src="/assets/rubini-coin.gif" alt="Rubini Coin">'; 71 72 function rafflePrizeHtml(raffle, compact = false) { 73 const winners = Number(raffle.configured_winners || 1); 74 if (raffle.prize_type === 'legendary_chest') { 75 return `<div class="raffle-prize-display legendary"><img src="${chestMeta.legendary.image}" alt="Baú Lendário"><div><span>Prêmio automático</span><strong>1 Baú Lendário por vencedor</strong>${compact ? '' : `<small>${winners} vencedor(es) único(s)</small>`}</div></div>`; 76 } 77 if (raffle.prize_type === 'rubini_coins') { 78 return `<div class="raffle-prize-display rubini">${rubiniCoin}<div><span>Rubini Coins</span><strong>${Number(raffle.prize_rubini_amount || 0)} por vencedor</strong>${compact ? '' : `<small>${winners} vencedor(es) único(s)</small>`}</div></div>`; 79 } 80 return `<div class="raffle-prize-display manual"><span class="manual-prize-icon">â </span><div><span>Prêmio manual</span><strong>${escapeHtml(raffle.description || 'Premiação informada pelo administrador')}</strong>${compact ? '' : `<small>${winners} vencedor(es) único(s)</small>`}</div></div>`; 81 } 82 83 function connectedPlatformsLabel() { 84 if (account?.has_twitch && account?.has_youtube) return 'Twitch & YouTube conectados'; 85 if (account?.has_twitch) return 'Somente Twitch conectada'; 86 if (account?.has_youtube) return 'Somente YouTube conectado'; 87 return 'Não conectada'; 88 } 89 90 const EXTENSION_INSTALL_URL = 'https://chromewebstore.google.com/detail/hifmddhhgoebeingllcpkdmnaifhmgio?utm_source=item-share-cb'; 91 // TODO(vÃdeo tutorial recompensas): preencher simultaneamente no main e develop quando o vÃdeo for publicado. 92 const REWARDS_TUTORIAL_VIDEO_URL = 'https://youtu.be/v6_FfuuchIA'; 93 94 function tutorialVideoControl(className = '') { 95 if (REWARDS_TUTORIAL_VIDEO_URL) { 96 const thumbnail = 'https://i.ytimg.com/vi/v6_FfuuchIA/hqdefault.jpg'; 97 return '<a class="' + className + ' has-video" href="' + escapeHtml(REWARDS_TUTORIAL_VIDEO_URL) + '" target="_blank" rel="noopener" aria-label="Assistir ao tutorial de recompensas"><img class="tutorial-thumbnail" src="' + thumbnail + '" alt=""><span class="tutorial-video-shade" aria-hidden="true"></span><span class="tutorial-play" aria-hidden="true">â¶</span><small>Assistir tutorial</small></a>'; 98 } 99 return '<button class="' + className + '" type="button" disabled aria-disabled="true" title="O vÃdeo tutorial será publicado em breve"><span class="tutorial-play" aria-hidden="true">â¶</span><small>VÃdeo tutorial em breve</small></button>'; 100 } 101 102 function inboxAction(message) { 103 if (!message.action_label) return ''; 104 return message.action_url 105 ? '<a class="message-action" href="' + escapeHtml(message.action_url) + '" target="_blank" rel="noopener">' + escapeHtml(message.action_label) + '</a>' 106 : '<button type="button" class="message-action" disabled title="Este conteúdo será publicado em breve">' + escapeHtml(message.action_label) + '</button>'; 107 } 108 109 function attentionNotice(context = 'top') { 110 if (!account) return ''; 111 const classes = context === 'inline' ? 'important-notice inline' : 'important-notice'; 112 if (!account.extension_connected) { 113 return '<div class="' + classes + '" role="status"><span class="important-notice-icon" aria-hidden="true">!</span><div><strong>Instale e conecte a extensão</strong><p>Abra a área de conexão, instale a extensão, gere o código e vincule seu perfil para liberar os baús.</p></div><div class="important-notice-actions"><button type="button" class="notice-action primary" data-extension-setup>Conectar extensão</button><button type="button" class="notice-action" data-extension-help>Como funciona</button></div></div>'; 114 } 115 if (!account.game_character_name) { 116 return '<div class="' + classes + '" role="status"><span class="important-notice-icon" aria-hidden="true">!</span><div><strong>Cadastre o nome do seu personagem</strong><p>Precisamos do nick para entregar corretamente resgates e premiações manuais. O nome exibido no site continua opcional.</p></div><div class="important-notice-actions"><button type="button" class="notice-action primary" data-update-profile>Atualizar cadastro</button></div></div>'; 117 } 118 return ''; 119 } 120 121 function openExtensionHelp() { 122 document.getElementById('extensionHelpModal')?.remove(); 123 const modal = document.createElement('div'); 124 modal.id = 'extensionHelpModal'; 125 modal.className = 'extension-help-modal'; 126 modal.innerHTML = '<div class="extension-help-dialog" role="dialog" aria-modal="true" aria-labelledby="extensionHelpTitle">' + 127 '<button type="button" class="extension-help-close" data-close-extension-help aria-label="Fechar tutorial">
127Ã</button>' + 128 '<span class="store-eyebrow">Tutorial da extensão</span><h3 id="extensionHelpTitle">Como receber baús assistindo à s lives</h3>' + 129 '<div class="extension-help-steps">' + 130 '<section><b>1</b><div><strong>Instale a extensão</strong><p>Use o botão âInstalar extensãoâ. Depois da instalação, fixe o Ãcone na barra do navegador para acessá-lo rapidamente.</p></div></section>' + 131 '<section><b>2</b><div><strong>Vincule seu perfil</strong><p>No painel, clique em âGerar código da extensãoâ. Abra o popup da extensão, digite o código de 8 caracteres e confirme. Cada instalação pode ficar vinculada a apenas um perfil por vez.</p></div></section>' + 132 '<section><b>3</b><div><strong>Abra a live oficial</strong><p>A contagem funciona somente nas transmissões oficiais do Keke de Patinete na Twitch ou no YouTube. Lives de outros canais não geram tempo nem baús.</p></div></section>' + 133 '<section><b>4</b><div><strong>Mantenha a transmissão reproduzindo</strong><p>A live não pode estar pausada ou encerrada. Se o YouTube já estava aberto antes da extensão reconhecer a transmissão, pressione F5 uma vez e aguarde a validação.</p></div></section>' + 134 '<section><b>5</b><div><strong>Pular para o ao vivo</strong><p>No YouTube, a extensão verifica se o player ficou mais de 15 segundos atrasado. Quando possÃvel e sem anúncio em reprodução, ela avança automaticamente para cerca de 2 segundos da transmissão ao vivo. O recurso não atua com o vÃdeo pausado.</p></div></section>' + 135 '</div><div class="extension-help-note"><strong>Importante:</strong> a extensão apenas observa o player. A confirmação do canal, da live, do tempo e das recompensas é sempre feita pelo servidor.</div>' + 136 '<div class="extension-help-footer"><a class="notice-action primary" href="' + EXTENSION_INSTALL_URL + '" target="_blank" rel="noopener">Instalar extensão</a><button type="button" class="notice-action" data-close-extension-help>Entendi</button></div></div>'; 137 document.body.appendChild(modal); 138 document.body.classList.add('extension-help-open'); 139 modal.querySelector('.extension-help-close')?.focus(); 140 } 141 142 function reminderFormHtml(reminders = {}, context = 'profile') { 143 return `<article class="store-card reminder-card ${context === 'panel' ? 'wide' : ''}"><h3>Lembretes de sorteios</h3><p>Receba uma mensagem no site quando um novo sorteio marcado como semanal ou mensal for aberto.</p><form class="reminder-form" data-reminder-form="${context}"><label><input type="checkbox" name="weekly" ${reminders.weekly ? 'checked' : ''}> Avisar sobre sorteios semanais</label><label><input type="checkbox" name="monthly" ${reminders.monthly ? 'checked' : ''}> Avisar sobre sorteios mensais</label><button class="profile-save" type="submit">Salvar lembretes</button><span class="profile-save-status" aria-live="polite"></span></form></article>`; 144 } 145 146 const raffleCategoryLabel = (type) => ({ weekly_rc: 'Semanal', monthly_rc: 'Mensal', generic: 'Comum' })[type] || 'Comum'; 147 148 async function loadPanelReminders() { 149 const target = root.querySelector('[data-panel-reminders]'); 150 if (!target || !account) return; 151 try { 152 const response = await fetch(`${API_URL}/api/me/reminders`, { credentials: 'include', cache: 'no-store' }); 153 if (!response.ok) throw new Error('reminders_failed'); 154 target.outerHTML = reminderFormHtml(await response.json(), 'panel'); 155 } catch (_) { 156 target.innerHTML = '<h3>Lembretes de sorteios</h3><p>Não foi possÃvel carregar suas preferências agora.</p>'; 157 } 158 } 159 160 function identities() { 161 const profiles = []; 162 if (account?.has_twitch) profiles.push({ 163 provider: 'Twitch', note: 'Identidade principal', 164 username: account.twitch_username, avatar: account.twitch_avatar_url 165 }); 166 if (account?.has_youtube) profiles.push({ 167 provider: 'YouTube', note: account?.has_twitch ? 'Canal conectado' : 'Identidade principal', 168 username: account.youtube_username, avatar: account.youtube_avatar_url 169 }); 170 return profiles.sort((a, b) => (a.provider.toLowerCase() === account?.preferred_provider ? -1 : b.provider.toLowerCase() === account?.preferred_provider ? 1 : 0)); 171 } 172 173 function updateIdentityCarousel(nextIndex) { 174 const profiles = identities(); 175 if (!profiles.length) return; 176 identityIndex = (nextIndex + profiles.length) % profiles.length; 177 root.querySelectorAll('[data-identity-slide]').forEach((slide, index) => { 178 slide.hidden = index !== identityIndex; 179 }); 180 root.querySelectorAll('[data-identity-dot]').forEach((dot, index) => { 181 dot.classList.toggle('active', index === identityIndex); 182 }); 183 } 184 185 const labels = { 186 panel: 'Meu painel', 187 inventory: 'Inventário de baús', 188 redemptions: 'Resgates', 189 ranking: 'Ranking', 190 raffles: 'Sorteios', 191 profile: 'Perfil', 192 inbox: 'Caixa de entrada', 193 admin: 'Administração' 194 }; 195 const storePaths = { 196 panel: '/loja/painel', inventory: '/loja/inventario', redemptions: '/loja/resgates', 197 ranking: '/loja/ranking', raffles: '/loja/sorteios', profile: '/loja/perfil', 198 inbox: '/loja/mensagens', admin: '/loja/admin' 199 }; 200 const storeViewFromPath = () => ({ 201 painel: 'panel', inventario: 'inventory', resgates: 'redemptions', ranking: 'ranking', 202 sorteios: 'raffles', perfil: 'profile', mensagens: 'inbox', admin: 'admin' 203 })[location.pathname.split('/')[2]] || 'panel'; 204 205 function closeMenu() { 206 dropdown.hidden = true; 207 trigger.setAttribute('aria-expanded', 'false'); 208 document.body.classList.remove('store-dropdown-open'); 209 } 210 211 function toggleMenu() { 212 const willOpen = dropdown.hidden; 213 dropdown.hidden = !willOpen; 214 trigger.setAttribute('aria-expanded', String(willOpen)); 215 document.body.classList.toggle('store-dropdown-open', willOpen); 216 } 217 218 function shell(content) { 219 return ` 220 <div class="store-hero ${currentView === 'panel' ? '' : 'store-hero-compact'}"> 221 <div> 222 <div class="store-eyebrow">Sistema de recompensas Keke de Patinete</div> 223 <h2>${labels[currentView]}</h2> 224 <p>Assista à s lives, ganhe baús, abra recompensas e acumule KEKE Coins.</p> 225 </div> 226 <div class="store-status">â ${account ? 'Conta conectada' : 'Integrações seguras'}</div> 227 </div> 228 <div class="store-tabs-shell"> 229 <div class="store-tabs-label"><span>Menu da Loja do Canal</span><small>Escolha uma área</small></div> 230 <nav class="store-tabs" aria-label="Ãreas da Loja do Canal"> 231 <a class="store-tab ${currentView === 'panel' ? 'active' : ''}" data-store-tab="panel" href="${storePaths.panel}"><span class="nav-pixel-icon icon-panel" aria-hidden="true"></span>Meu painel</a> 232 <a class="store-tab ${currentView === 'inventory' ? 'active' : ''}" data-store-tab="inventory" href="${storePaths.inventory}"><span class="nav-pixel-icon icon-inventory" aria-hidden="true"></span>Inventário</a> 233 <a class="store-tab ${currentView === 'redemptions' ? 'active' : ''}" data-store-tab="redemptions" href="${storePaths.redemptions}"><span class="nav-pixel-icon icon-redemptions" aria-hidden="true"></span>Resgates</a> 234 <a class="store-tab ${currentView === 'ranking' ? 'active' : ''}" data-store-tab="ranking" href="${storePaths.ranking}"><span class="nav-pixel-icon icon-ranking" aria-hidden="true"></span>Ranking</a> 235 <a class="store-tab ${currentView === 'raffles' ? 'active' : ''}" data-store-tab="raffles" href="${storePaths.raffles}"><span class="nav-pixel-icon icon-raffles" aria-hidden="true"></span>Sorteios</a> 236 ${account ? `<a class="store-tab ${currentView === 'profile' ? 'active' : ''}" data-store-tab="profile" href="${storePaths.profile}"><span class="nav-pixel-icon icon-panel" aria-hidden="true"></span>Perfil</a> 237 <a class="store-tab ${currentView === 'inbox' ? 'active' : ''} ${Number(account.unread_messages || 0) ? 'has-unread' : ''}" data-store-tab="inbox" href="${storePaths.inbox}"><span class="nav-pixel-icon icon-messages" aria-hidden="true"></span>Mensagens${Number(account.unread_messages || 0) ? ` <b class="unread-badge">${Number(account.unread_messages)}</b>` : ''}</a>` : ''}
238 ${account?.is_admin ? `<a class="store-tab ${currentView === 'admin' ? 'active' : ''}" data-store-tab="admin" href="${storePaths.admin}"><span class="nav-pixel-icon icon-tools" aria-hidden="true"></span>Administração</a>` : ''} 239 </nav> 240 </div> 241 <div class="store-tabs-hint" aria-hidden="true"><span>Deslize para ver mais opções</span><b>â</b></div> 242 ${content} 243 `; 244 } 245 246 function renderPanel() { 247 root.innerHTML = shell(` 248 ${loginMessages[new URLSearchParams(location.search).get('login')] ? `<div class="store-alert">${loginMessages[new URLSearchParams(location.search).get('login')]}</div>` : ''} 249 ${attentionNotice('top')} 250 <div class="store-grid panel-layout"> 251 <article class="store-card wide account-connect-card panel-account"> 252 <h3>${account ? 'Contas conectadas' : 'Conecte sua conta'}</h3> 253 ${!account ? `<p>Escolha Twitch ou YouTube para criar seu perfil. A segunda plataforma será opcional.</p><div class="connect-row"> 254 <button class="connect-btn twitch" data-auth="twitch" type="button"><img src="/assets/icon-twitch.svg" alt="">
254 Entrar com Twitch</button> 255 <button class="connect-btn youtube" data-auth="google" type="button"><img src="/assets/icon-youtube.svg" alt=""> Entrar com YouTube</button> 256 </div>` : `<div class="linked-identities"> 257 ${account.has_twitch ? '<span><img src="/assets/icon-twitch.svg" alt=""> Twitch conectada</span>' : ''} 258 ${account.has_youtube ? '<span><img src="/assets/icon-youtube.svg" alt=""> YouTube conectado</span>' : ''} 259 </div> 260 ${!account.has_twitch || !account.has_youtube ? `<div class="optional-link"><b>Opcional:</b> vincule também sua conta ${account.has_twitch ? 'do YouTube' : 'da Twitch'} para salvar nome e foto das duas plataformas.<div class="connect-row">${account.has_twitch ? '<button class="connect-btn youtube" data-auth="google" type="button"><img src="/assets/icon-youtube.svg" alt=""> Vincular YouTube</button>' : '<button class="connect-btn twitch" data-auth="twitch" type="button"><img src="/assets/icon-twitch.svg" alt=""> Vincular Twitch</button>'}</div></div>` : '<div class="store-note">As duas identidades pertencem ao mesmo perfil e compartilham o mesmo saldo.</div>'} 261 <div class="account-session-actions"><button type="button" data-logout>Sair da conta</button><span>Encerra apenas este login. Seus dados, baús e contas vinculadas continuam salvos.</span></div> 262 <details class="account-management"><summary>Gerenciar contas vinculadas</summary><p>Ao desvincular uma identidade, ela deixa este perfil. Se entrar novamente com ela, começará zerada. Os dados permanecem somente na identidade que continuar vinculada.</p> 263 <div class="identity-disconnect-row">${account.has_twitch && account.has_youtube ? `${account.has_twitch ? '<button type="button" data-disconnect-provider="twitch">Desvincular Twitch</button>' : ''}${account.has_youtube ? '<button type="button" data-disconnect-provider="youtube">Desvincular YouTube</button>' : ''}` : '<button type="button" data-delete-account>Excluir conta e dados de acesso</button>'}</div> 264 </details>`} 265 </article> 266 <article class="store-card tutorial-rewards-card panel-tutorial"> 267 <h3>Aprenda a receber recompensas assistindo às lives</h3> 268 <p>Veja como conectar seu perfil, validar a transmissão oficial e liberar seus baús.</p> 269 ${tutorialVideoControl('tutorial-video-placeholder')} 270 </article> 271 <article class="store-card wide extension-setup-card panel-status" id="extensionSetupCard" tabindex="-1"> 272 <h3>Status para receber baús</h3> 273 <div class="status-list"> 274 <div class="status-item"><span>Conta Twitch ou YouTube</span><span class="status-pending" data-platform-status>${connectedPlatformsLabel()}</span></div> 275 <div class="status-item"><span>Extensão do navegador</span><span class="status-pending" data-extension-status>${account?.extension_connected ? 'Conectada' : 'Não conectada'}</span></div> 276 <div class="status-item"><span>Nick do personagem</span><span class="status-pending">${account?.game_character_name ? escapeHtml(account.game_character_name) : 'Não informado'}</span></div> 277 </div> 278 ${account ? `<div class="extension-pair-box"> 279 <a class="profile-save extension-install-link" href="${EXTENSION_INSTALL_URL}" target="_blank" rel="noopener">Instalar extensão</a> 280 <button type="button" class="profile-save extension-help-button" data-extension-help>Como funciona</button> 281 <button type="button" class="profile-save" data-generate-pair>${account.extension_connected ? 'Conectar novamente' : 'Gerar código da extensão'}</button> 282 ${account.extension_connected ? '<button type="button" class="extension-disconnect" data-disconnect-extension>Desconectar extensão</button>' : ''} 283 <div class="pair-code" data-pair-code hidden></div> 284 <small>${account.extension_connected ? 'Ao gerar um novo código, esta extensão será desconectada imediatamente.' : 'O código vale por 10 minutos e só pode ser usado uma vez.'}</small> 285 </div>` : ''} 286 </article> 287 ${account ? `<article class="store-card compact-profile-card panel-identity"> 288 <h3>Como você quer ser identificado</h3> 289 <form class="profile-form compact-profile-form" id="profileForm"> 290 <label>Nome exibido <span>opcional</span><input name="customDisplayName" maxlength="40" value="${escapeHtml(account.custom_display_name || '')}" placeholder="Ex.: Keke"></label> 291 <label>Nick do personagem <span>usado nos resgates</span><input name="gameCharacterName" maxlength="60" value="${escapeHtml(account.game_character_name || '')}" placeholder="Ex.: Keke RP"></label> 292 ${account.has_twitch && account.has_youtube ? `<label>Perfil principal <span>foto e identidade exibidas</span><select name="preferredProvider"><option value="twitch" ${account.preferred_provider !== 'youtube' ? 'selected' : ''}>Twitch</option><option value="youtube" ${account.preferred_provider === 'youtube' ? 'selected' : ''}>YouTube</option></select></label>` : ''} 293 <button class="profile-save" type="submit">Salvar perfil</button>
293<span class="profile-save-status" aria-live="polite"></span> 294 </form> 295 ${account.game_character_name ? '' : '<div class="char-warning important-inline-warning">Cadastre seu personagem para receber resgates e premiações.</div>'} 296 </article>` : ''} 297 <article class="store-card panel-balance"> 298 <h3>Saldo disponÃvel</h3><div class="metric coin-metric">${coin}<span data-account-balance>${account ? qq(account.balance) : 'â'}</span> <small>KEKE Coins</small></div><p>Valor que poderá ser usado nos resgates.</p> 299 </article> 300 <article class="store-card panel-earned"> 301 <h3>Total conquistado</h3><div class="metric coin-metric">${coin}<span data-account-earned>${account ? qq(account.total_earned) : 'â'}</span> <small>KEKE Coins</small></div><p>Histórico total, mesmo depois de gastar.</p> 302 </article> 303 <article class="store-card panel-hours"> 304 <h3>Tempo assistido</h3><div class="metric"><span data-account-hours>${account ? (Number(account.watched_seconds || 0) / 3600).toFixed(1) : 'â'}</span> <small>horas</small></div><p>Somente perÃodos validados pela extensão.</p> 305 </article> 306 ${account ? '<article class="store-card panel-reminders reminder-card" data-panel-reminders><h3>Lembretes de sorteios</h3><p>Carregando preferênciasâ¦</p></article>' : ''} 307 </div> 308 `); 309 loadPanelReminders(); 310 } 311 312 function renderRedemptions() { 313 const epoch = viewEpoch; 314 root.innerHTML = shell('<div class="inventory-loading">Carregando resgatesâ¦</div>'); 315 fetch(`${API_URL}/api/shop/rubini`, { credentials: 'include', cache: 'no-store' }) 316 .then((response) => response.ok ? response.json() : Promise.reject()) 317 .then((data) => { 318 if (epoch !== viewEpoch || currentView !== 'redemptions') return; 319 const last = data.lastRedemption; 320 const cooldown = last && new Date(last.available_at) > new Date(); 321 const availableAt = cooldown ? new Date(last.available_at).toLocaleString('pt-BR') : ''; 322 const rubiniAmount = Number(data.product.rubiniAmount); 323 const kekeCost = Number(data.product.kekeCost); 324 root.innerHTML = shell(`<div class="redemption-grid"><article class="store-card redemption-product"> 325 <div class="rubini-badge">${rubiniCoin}</div><div><span class="store-eyebrow">Resgate RubinOT</span><h3>${rubiniAmount.toLocaleString('pt-BR')} Rubini Coins</h3> 326 <p>Troque suas KEKE Coins por Rubini Coins entregues no personagem cadastrado.</p></div> 327 <div class="redemption-price">${coin}<strong>${kekeCost.toLocaleString('pt-BR', { maximumFractionDigits: 2 })}</strong><span>KEKE Coins</span></div> 328 ${data.product.dailyLimit > 0 ? `<div class="redemption-limit"><b>${Number(data.dailyRemaining)}</b> resgates disponÃveis hoje, reseta à s 00:00</div>` : ''} 329 ${cooldown ? `<div class="store-alert">Seu próximo resgate estará disponÃvel em ${escapeHtml(availableAt)}.</div>` : ''} 330 ${attentionNotice('inline')} 331 <button type="button" class="profile-save redemption-button" data-redeem-rubini data-rubini-amount="${rubiniAmount}" data-keke-cost="${kekeCost}" data-cooldown-days="${Number(data.product.cooldownDays)}" ${!account || cooldown || (data.product.dailyLimit > 0 && data.dailyRemaining < 1) ? 'disabled' : ''}>${rubiniCoin}<span>${account ? `Resgatar ${rubiniAmount.toLocaleString('pt-BR')}` : 'Entrar para resgatar'}</span></button> 332 <span class="profile-save-status" data-redemption-status></span> 333 </article></div>`); 334 }).catch(() => { if (epoch === viewEpoch && currentView === 'redemptions') root.innerHTML = shell('<div class="empty-state"><h3>Não foi possÃvel carregar a loja.</h3></div>'); }); 335 } 336 337 async function renderInventory() { 338 const epoch = viewEpoch; 339 if (!account) { 340 root.innerHTML = shell(`<div class="empty-state"><div class="empty-icon">ð§°</div><h3>Entre para acessar seus baús</h3><p>Conecte sua Twitch ou seu YouTube no painel.</p></div>`); 341 return; 342 } 343 root.innerHTML = shell(`<div class="inventory-loading">Carregando inventárioâ¦</div>`); 344 await refreshAccount({ renderAfter: false }).catch(() => {}); 345 if (epoch !== viewEpoch || currentView !== 'inventory') return; 346 fetch(`${API_URL}
346/api/public/chest-config`, { cache: 'no-store' }) 347 .then((response) => response.ok ? response.json() : Promise.reject()) 348 .then((config) => { 349 if (epoch !== viewEpoch || currentView !== 'inventory') return; 350 root.dataset.chestConfig = JSON.stringify(config); 351 root.innerHTML = shell(` 352 ${attentionNotice('top')} 353 <div class="chest-grid"> 354 ${Object.entries(chestMeta).map(([type, meta]) => { 355 const quantity = Number(account.chests?.[type] || 0); 356 const tiers = config.chests?.[type] || []; 357 return `<article class="chest-card chest-${type}" data-chest-card="${type}"> 358 <div class="chest-visual"><span class="chest-rays"></span><img src="${meta.image}" alt="${meta.label}"></div> 359 <h3>${meta.label}</h3><div class="chest-count">${quantity} ${quantity === 1 ? 'disponÃvel' : 'disponÃveis'}</div> 360 <div class="chance-list">${tiers.map((tier, index) => `<div class="reward-rarity rarity-${index + 1}"><span data-reward-label="${escapeHtml(`${tier.label} · ${qq(tier.qqCoins)} KEKE`)}">${escapeHtml(tier.label)} · ${qq(tier.qqCoins)} KEKE</span><strong>${Number(tier.probability).toLocaleString('pt-BR',{maximumFractionDigits:4})}%</strong></div>`).join('')}</div> 361 <div class="chest-actions"> 362 <button type="button" class="open-chest" data-open-chest="${type}" ${quantity < 1 ? 'disabled' : ''}>${quantity < 1 ? 'Nenhum baú' : 'Abrir baú'}</button> 363 <button type="button" class="open-all-chests" data-open-all-chests="${type}" ${quantity < 1 ? 'disabled' : ''}>${quantity < 1 ? 'Nenhum baú' : `Abrir todos (${quantity})`}</button> 364 </div> 365 <div class="chest-result" data-chest-result="${type}" aria-live="polite"></div> 366 </article>`; 367 }).join('')} 368 </div>`); 369 }) 370 .catch(() => { if (epoch === viewEpoch && currentView === 'inventory') root.innerHTML = shell('<div class="empty-state"><h3>Não foi possÃvel carregar o inventário agora.</h3></div>'); }); 371 } 372 373 function syncChestCardState(type) { 374 const card = root.querySelector(`[data-chest-card="${type}"]`); 375 if (!card) return; 376 const quantity = Math.max(0, Number(account?.chests?.[type] || 0)); 377 const pending = chestOpenInFlight.has(type); 378 const counter = card.querySelector('.chest-count'); 379 const singleButton = card.querySelector('[data-open-chest]'); 380 const allButton = card.querySelector('[data-open-all-chests]'); 381 if (counter) counter.textContent = `${quantity} ${quantity === 1 ? 'disponÃvel' : 'disponÃveis'}`; 382 if (singleButton) { 383 singleButton.disabled = pending || quantity < 1; 384 singleButton.textContent = pending ? 'Aguardeâ¦' : quantity < 1 ? 'Nenhum baú' : 'Abrir baú'; 385 } 386 if (allButton) { 387 allButton.disabled = pending || quantity < 1; 388 allButton.textContent = pending ? 'Processandoâ¦' : quantity < 1 ? 'Nenhum baú' : `Abrir todos (${quantity})`; 389 } 390 } 391 392 function rankingStateFromUrl(){ 393 const params=new URLSearchParams(location.search),allowedTypes=new Set(['totalHours','qqCoins','totalEarned']),allowedSizes=new Set([10,20,50,100]); 394 const type=allowedTypes.has(params.get('rankType'))?params.get('rankType'):'totalHours',page=Math.max(1,Number(params.get('rankPage'))||1),size=Number(params.get('rankSize')); 395 return {type,page,pageSize:allowedSizes.has(size)?size:null}; 396 } 397 function syncRankingUrl(type,page,pageSize){ 398 if(currentView!=='ranking'||location.pathname!=='/loja/ranking')return; 399 const params=new URLSearchParams(); 400 if(type&&type!=='totalHours')params.set('rankType',type); 401 if(Number(page)>1)params.set('rankPage',String(Math.max(1,Math.floor(Number(page))))); 402 if(Number(pageSize)!==20)params.set('rankSize',String(Number(pageSize))); 403 const qs=params.toString(),path=`/loja/ranking${qs?`?${qs}`:''}`; 404 if(`${location.pathname}${location.search}`!==path)history.replaceState({...history.state,view:'store',storeView:'ranking'},'',path); 405 } 406 407 function renderRanking() { 408 const initialRankingState=rankingStateFromUrl(); 409 const savedPageSize=initialRankingState.pageSize||Number(sessionStorage.getItem('keke-ranking-page-size') || 20); 410 root.innerHTML = shell(` 411 <div class="ranking-card"> 412 <div class="ranking-head"><div><h3>Ranking da comunidade</h3><span>Até 100 usuários por página</span></div><label class="ranking-page-size">Exibir <select data-ranking-page-size>${[10,20,50,100].map((size) => `<option value="${size}" ${size === savedPageSize ? 'selected' : ''}>${size}</option>`).join('')}</select></label></div> 413 <div class="ranking-switch"> 414 <button type="button" class="active" data-ranking-type="totalHours">Horas Assistidas</button> 415 <button type="button" data-ranking-type="qqCoins">Keke Coins</button> 416 <button type="button" data-ranking-type="totalEarned">Total conquistado</button> 417 </div> 418 <div class="ranking-list" id="rankingList"><div class="ranking-loading">Carregando rankingâ¦</div></div> 419 <div class="ranking-pagination" data-ranking-pagination></div> 420 </div> 421 `); 422 paintRanking(initialRankingState.type, initialRankingState.page); 423 } 424 425 async function paintRanking(type, page = 1) { 426 const epoch = viewEpoch; 427 const list = document.getElementById('rankingList'); 428 if (!list) return; 429 root.querySelectorAll('[data-ranking-type]').forEach((button) => button.classList.toggle('active', button.dataset.rankingType === type)); 430 list.innerHTML = '<div class="ranking-loading">Atualizando rankingâ¦</div>'; 431 const pageSize = Number(root.querySelector('[data-ranking-page-size]')?.value || 20); 432 const requestedPage = Math.max(1, Number(page) || 1); 433 const requestToken = String(Number(root.dataset.rankingRequestToken || 0) + 1); 434 root.dataset.rankingRequestToken = requestToken; 435 try { 436 // O ranking público é sempre Top 100. Buscamos o conjunto uma vez e fazemos 437 // a paginação 10/20/50/100 no navegador para evitar regressões de página 2. 438 const response = await fetch(`${API_URL}
438/api/public/rankings-v2?type=${encodeURIComponent(type)}&page=1&pageSize=100`, { cache: 'no-store' }); 439 if (!response.ok) throw new Error('ranking_failed'); 440 let data = await response.json(); 441 if (epoch !== viewEpoch || currentView !== 'ranking' || !list.isConnected || root.dataset.rankingRequestToken !== requestToken) return; 442 const allUsers = Array.isArray(data.users) ? data.users : []; 443 const total = Math.min(100, Math.max(0, Number(data.total || allUsers.length))); 444 const totalPages = Math.max(1, Math.ceil(total / pageSize)); 445 const safePage = Math.min(requestedPage, totalPages); 446 const start = (safePage - 1) * pageSize; 447 const pageUsers = allUsers.slice(start, start + pageSize); 448 root.dataset.rankingType = type; 449 root.dataset.rankingPage = String(safePage); 450 syncRankingUrl(type,safePage,pageSize); 451 list.innerHTML = pageUsers.length ? pageUsers.map((entry) => { 452 const value = type === 'totalHours' ? `${(Number(entry.total_seconds) / 3600).toFixed(1)} <small>horas</small>` : `${coin}${qq(type === 'totalEarned' ? entry.total_earned : entry.balance)} <small>KEKE</small>`; 453 const subtitle = type === 'totalHours' ? 'Tempo total' : type === 'totalEarned' ? 'Total conquistado' : 'Saldo disponÃvel'; 454 const rankingAvatar = entry.avatar_url ? `<img src="${escapeHtml(entry.avatar_url)}" alt="Foto de ${escapeHtml(entry.display_name)}">` : 'K'; 455 return `<a class="ranking-row" href="/loja/perfil/${entry.id}" data-public-profile="${entry.id}"><strong class="ranking-position">${entry.position}</strong><div class="ranking-avatar">${rankingAvatar}</div><div class="ranking-name"><strong>${escapeHtml(entry.display_name)}</strong><span>${subtitle}</span></div><div class="ranking-points">${value}</div></a>`; 456 }).join('') : '<div class="ranking-loading">O ranking começa quando houver dados validados.</div>'; 457 root.querySelector('[data-ranking-pagination]').innerHTML = `<button type="button" data-ranking-page="${safePage - 1}" ${safePage <= 1 ? 'disabled' : ''}>Anterior</button><span>Página ${safePage} de ${totalPages} · ${total} usuários</span><button type="button" data-ranking-page="${safePage + 1}" ${safePage >= totalPages ? 'disabled' : ''}>Próxima</button>`; 458 } catch (_) { 459 if (epoch === viewEpoch && currentView === 'ranking' && list.isConnected && root.dataset.rankingRequestToken === requestToken) list.innerHTML = '<div class="ranking-loading">Não foi possÃvel carregar o ranking agora.</div>'; 460 } 461 } 462 463 async function renderProfile(profileId = location.pathname.split('/')[3] || account?.id) { 464 if (!profileId) return open('panel'); 465 root.innerHTML = shell('<div class="inventory-loading">Carregando perfilâ¦</div>'); 466 try { 467 const [profileResponse, reminderResponse] = await Promise.all([ 468 fetch(`${API_URL}/api/public/profiles/${encodeURIComponent(profileId)}`, { credentials: 'include', cache: 'no-store' }), 469 account?.id === profileId ? fetch(`${API_URL}/api/me/reminders`, { credentials: 'include', cache: 'no-store' }) : Promise.resolve(null) 470 ]); 471 if (!profileResponse.ok) throw new Error('profile_not_found'); 472 const { profile, adminView } = await profileResponse.json(); 473 const reminders = reminderResponse?.ok ? await reminderResponse.json() : null; 474 const opened = Object.values(profile.chests_opened_30d || {}).reduce((sum, value) => sum + Number(value), 0); 475 const profileAvatar = profile.avatar_url ? `<img src="${escapeHtml(profile.avatar_url)}" alt="Foto de ${escapeHtml(profile.display_name)}">` : 'K'; 476 root.innerHTML = shell(`<div class="public-profile"><article class="store-card profile-hero-card"><div class="ranking-avatar profile-avatar-large">${profileAvatar}</div><div><span class="store-eyebrow">
476Perfil da comunidade</span><h3>${escapeHtml(profile.display_name)}</h3><p>Membro do sistema de recompensas Keke de Patinete.</p></div></article><div class="profile-stat-grid"> 477 <article class="store-card"><h3>Horas assistidas</h3><div class="metric">${(Number(profile.total_seconds) / 3600).toFixed(1)} <small>horas</small></div></article><article class="store-card"><h3>Saldo atual</h3><div class="metric coin-metric">${coin}${qq(profile.balance)}</div></article><article class="store-card"><h3>Total conquistado</h3><div class="metric coin-metric">${coin}${qq(profile.total_earned)}</div></article><article class="store-card"><h3>Rubini Coins resgatadas</h3><div class="metric rubini-metric">${rubiniCoin}<span>${Number(profile.rubini_redeemed || 0)}</span></div></article><article class="store-card"><h3>Sequência de lives</h3><div class="metric">${Number(profile.live_streak_current || 0)} <small>lives</small></div></article><article class="store-card"><h3>Sorteios ganhos</h3><div class="metric">${Number(profile.raffles_won || 0)}</div></article></div> 478 <article class="store-card chest-history-card"><h3>Baús abertos nos últimos 30 dias</h3><div>${Object.entries(chestMeta).map(([type, meta]) => `<span><img src="${meta.image}" alt="">${escapeHtml(meta.label)} <b>${Number(profile.chests_opened_30d?.[type] || 0)}</b></span>`).join('')}<strong>Total: ${opened}</strong></div></article> 479 ${adminView ? `<article class="store-card admin-profile-private"><h3>Dados privados â somente administração</h3><p>Personagem: <b>${escapeHtml(profile.game_character_name || 'Não informado')}</b> · Twitch: <b>${escapeHtml(profile.twitch_username || 'â')}</b> · YouTube: <b>${escapeHtml(profile.youtube_username || 'â')}</b></p></article>` : ''} 480 ${reminders ? `${account?.game_character_name ? '' : '<div class="char-warning">Cadastre o nome do seu personagem no Meu painel para receber resgates e premiações manuais.</div>'}${reminderFormHtml(reminders, 'profile')}` : ''}</div>`); 481 } catch (_) { root.innerHTML = shell('<div class="empty-state"><h3>Perfil não encontrado.</h3></div>'); } 482 } 483 484 async function renderInbox() { 485 if (!account) return open('panel'); 486 root.innerHTML = shell('<div class="inventory-loading">Carregando mensagensâ¦</div>'); 487 try { 488 const response = await fetch(`${API_URL}/api/me/messages`, { credentials: 'include', cache: 'no-store' }); 489 if (!response.ok) throw new Error('messages_failed'); 490 const data = await response.json(); 491 inboxSignature = JSON.stringify([Number(data.unread || 0), ...(data.messages || []).map((message) => [message.id, message.read_at || null])]); 492 if (account) account.unread_messages = Number(data.unread || 0); 493 root.innerHTML = shell(`<div class="inbox-list"><div class="ranking-head inbox-head ${Number(data.unread) ? 'has-unread' : ''}"><h3>Caixa de entrada</h3><span>${Number(data.unread) ? `â ${data.unread} não lida(s)` : 'Nenhuma mensagem não lida'}</span></div>${data.messages.length ? data.messages.map((message) => `<article class="store-card inbox-message ${message.read_at ? '' : 'unread'}"><div><span class="message-type">${escapeHtml(message.message_type)}</span><time>${new Date(message.created_at).toLocaleString('pt-BR')}</time></div><h3>${escapeHtml(message.title)}</h3><p>${escapeHtml(message.content)}</p>${inboxAction(message)}${message.read_at ? '' : `<button type="button" data-message-read="${message.id}">Marcar como lida</button>`}</article>`).join('') : '<div class="empty-state"><h3>Nenhuma mensagem ainda.</h3></div>'}</div>`); 494 } catch (_) { root.innerHTML = shell('<div class="empty-state"><h3>Não foi possÃvel carregar as mensagens.</h3></div>'); } 495 } 496 497 function renderRaffles() { 498 const epoch = viewEpoch; 499 root.innerHTML = shell('<div class="inventory-loading">Carregando sorteiosâ¦</div>'); 500 fetch(`${API_URL}/api/raffles`, { credentials: 'include' }) 501 .then((response) => response.ok ? response.json() : Promise.reject()) 502 .then(({ raffles, history }) => { 503 if (epoch !== viewEpoch || currentView !== 'raffles') return; 504 root.innerHTML = shell(`<div class="raffle-grid">${raffles?.length ? raffles.map((raffle) => ` 505 <article class="store-card raffle-card raffle-${escapeHtml(raffle.raffle_type || 'generic')}"> 506 <div class="raffle-status ${raffle.status}">${raffle.status === 'open' ? 'Inscrições abertas' : raffle.status === 'drawn' ? 'Sorteio encerrado' : 'Inscrições encerradas'} · ${raffleCategoryLabel(raffle.raffle_type)}</div> 507 <h3>${escapeHtml(raffle.title)}</h3>${raffle.prize_type === 'manual' ? '' : `<p>${escapeHtml(raffle.description || '')}</p>`} 508 ${rafflePrizeHtml(raffle)} 509 <div class="raffle-cost">${Number(raffle.ticket_cost) > 0 ? `${coin}<strong>${qq(raffle.ticket_cost)}</strong> KEKE Coins por ticket` : '<strong>Entrada gratuita · 1 por pessoa</strong>'}</div> 510 <div class="raffle-stats"><span>Seus tickets: <b>${Number(raffle.my_tickets || 0)}</b></span><span>Total: <b>${Number(raffle.total_tickets || 0)}</b></span></div> 511 ${raffle.status === 'open' ? (Number(raffle.ticket_cost) > 0 ? `<form class="raffle-buy-form" data-raffle-buy="${raffle.id}"><input type="number" name="quantity" min="1" max="10000" value="1"><button type="submit" ${account ? '' : 'disabled'}>Comprar tickets</button><span class="profile-save-status"></span></form>` : `<form class="raffle-buy-form raffle-free-entry-form" data-raffle-buy="${raffle.id}" data-free-raffle="true"><button type="submit" ${account && Number(raffle.my_tickets || 0) < 1 ? '' : 'disabled'}>${Number(raffle.my_tickets || 0) >= 1 ? 'Você já está participando' : 'Participar grátis'}</button><span class="profile-save-status">${Number(raffle.my_tickets || 0) >= 1 ? 'Sua entrada única já está confirmada.' : ''}</span></form>`) : ''} 512 ${account?.is_admin ? `<button class="raffle-config-button" type="button" data-raffle-config="${raffle.id}">Configurações do sorteio</button>` : ''} 513 </article>`).join('') : '<div class="empty-state"><div class="empty-icon">ðï¸</div><h3>Nenhum sorteio aberto</h3><p>Os próximos sorteios aparecerão aqui.</p></div>'}</div>${history?.length ? `<section class="raffle-history"><h3>Histórico de sorteios</h3>${history.map((raffle) => `<article class="store-card"><div class="raffle-history-head"><strong>${escapeHtml(raffle.title)}</strong><span>
513${new Date(raffle.drawn_at || raffle.created_at).toLocaleString('pt-BR')}</span></div>${rafflePrizeHtml(raffle, true)}${raffle.winners?.length ? `<p>Vencedor(es): ${raffle.winners.map((winner) => `<a href="/loja/perfil/${winner.userId}" data-public-profile="${winner.userId}">${winner.position}º ${escapeHtml(winner.displayName)}</a> â ${escapeHtml(winner.rewardType || '')} ${Number(winner.rewardQuantity || 0)} · ${escapeHtml(winner.deliveryStatus || '')}`).join('<br>')}</p>` : '<p>Sem vencedores registrados.</p>'}${account?.is_admin ? `<div class="raffle-history-actions"><button type="button" data-raffle-config="${raffle.id}">Apresentar / configurações</button><button type="button" data-raffle-delete="${raffle.id}" data-raffle-history-delete>Excluir do histórico</button></div>` : ''}</article>`).join('')}</section>` : ''}`); 514 }) 515 .catch(() => { if (epoch === viewEpoch && currentView === 'raffles') root.innerHTML = shell('<div class="empty-state"><h3>Não foi possÃvel carregar os sorteios.</h3></div>'); }); 516 } 517 518 function showTemporaryButtonState(button, message, originalText, duration = 2600) { 519 if (!button) return; 520 button.disabled = true; 521 button.textContent = message; 522 window.setTimeout(() => { 523 if (!button.isConnected) return; 524 button.textContent = originalText; 525 button.disabled = false; 526 }, duration); 527 } 528 529 function playUiTone(kind = 'tick') { 530 try { 531 const AudioContextClass = window.AudioContext || window.webkitAudioContext; 532 if (!AudioContextClass) return; 533 uiAudioContext ||= new AudioContextClass(); 534 const context = uiAudioContext; 535 if (context.state === 'suspended') context.resume().catch(() => {}); 536 const notes = kind === 'winner' ? [523.25, 659.25, 783.99] : kind === 'chest' ? [220, 330, 440, 660] : [320]; 537 notes.forEach((frequency, index) => { 538 const oscillator = context.createOscillator(); 539 const gain = context.createGain(); 540 oscillator.type = kind === 'tick' ? 'square' : 'sine'; 541 oscillator.frequency.value = frequency; 542 gain.gain.setValueAtTime(kind === 'tick' ? 0.018 : 0.045, context.currentTime + index * 0.1); 543 gain.gain.exponentialRampToValueAtTime(0.001, context.currentTime + index * 0.1 + 0.18); 544 oscillator.connect(gain).connect(context.destination); 545 oscillator.start(context.currentTime + index * 0.1); 546 oscillator.stop(context.currentTime + index * 0.1 + 0.2); 547 }); 548 } catch (_) {} 549 } 550 551 function animateReelWithTicks(reel, { finalOffset, duration, easing, onDone }) { 552 reel.getAnimations().forEach((animation) => animation.cancel()); 553 reel.style.transform = 'translateY(0px)'; 554 void reel.offsetHeight; 555 const animation = reel.animate( 556 [{ transform: 'translateY(0px)' }, { transform: `translateY(${finalOffset}px)` }], 557 { duration, easing, fill: 'forwards' } 558 ); 559 let lastItem = -1; 560 const follow = () => { 561 const transform = getComputedStyle(reel).transform; 562 const offset = transform && transform !== 'none' ? new DOMMatrixReadOnly(transform).m42 : 0; 563 const item = Math.max(0, Math.floor(Math.abs(offset) / 52)); 564 if (item !== lastItem) { 565 if (lastItem >= 0) playUiTone('tick'); 566 lastItem = item; 567 } 568 if (animation.playState !== 'finished') requestAnimationFrame(follow); 569 }; 570 requestAnimationFrame(follow); 571 animation.finished.then(() => { 572 reel.style.transform = `translateY(${finalOffset}px)`; 573 animation.cancel(); 574 onDone?.(); 575 }).catch(() => {}); 576 } 577 578 function showChestOpening(chestType, reward) { 579 document.getElementById('chestOpeningModal')?.remove(); 580 const config = JSON.parse(root.dataset.chestConfig || '{}'); 581 const tiers = config.chests?.[chestType] || []; 582 const weightedLabels = tiers.flatMap((tier) => Array.from({ length: Math.max(1, Math.min(80, Math.round(Number(tier.probability) * 2))) }, () => tier.label)); 583 const visualItems = Array.from({ length: 36 }, (_, index) => weightedLabels[(crypto.getRandomValues(new Uint32Array(1))[0] + index) % Math.max(1, weightedLabels.length)] || reward.label); 584 const targetIndex = visualItems.length; 585 visualItems.push(reward.label); 586 for (let index = 0; index < 7; index += 1) visualItems.push(weightedLabels[(crypto.getRandomValues(new Uint32Array(1))[0] + index) % Math.max(1, weightedLabels.length)] || reward.label); 587 // Visual only: the backend has already chosen the real reward. Most spins
588 // tease one of the three highest configured rarities next to the winner, 589 // without changing probabilities, inventory limits or the selected tier. 590 const nearMissRoll = crypto.getRandomValues(new Uint32Array(1))[0] % 100; 591 if (tiers.length >= 3 && nearMissRoll < 82) { 592 const notable = tiers.slice(-3); 593 const rarityRoll = crypto.getRandomValues(new Uint32Array(1))[0] % 100; 594 const notableIndex = rarityRoll < 55 ? 0 : rarityRoll < 85 ? 1 : 2; 595 const nearMiss = notable[notableIndex]?.label; 596 const side = crypto.getRandomValues(new Uint32Array(1))[0] % 2 ? -1 : 1; 597 visualItems[targetIndex + side] = nearMiss; 598 } 599 const modal = document.createElement('div'); 600 modal.id = 'chestOpeningModal'; 601 modal.className = `chest-opening-modal chest-opening-${chestType}`; 602 modal.innerHTML = `<div class="chest-opening-dialog" role="dialog" aria-modal="true" aria-label="Abertura do baú"> 603 <div class="chest-opening-glow"></div><img class="chest-opening-image" src="${chestMeta[chestType].image}" alt="${chestMeta[chestType].label}"> 604 <h3>Abrindo ${chestMeta[chestType].label}â¦</h3> 605 <div class="chest-reel-window"><div class="chest-reel-indicator"><span>seu prêmio</span></div><div class="chest-reel">${visualItems.map((label) => `<div class="rarity-${Math.max(1, tiers.findIndex((tier) => tier.label === label) + 1)}">${escapeHtml(label)}</div>`).join('')}</div></div> 606 <div class="chest-opening-result" data-chest-opening-result></div> 607 </div>`; 608 root.appendChild(modal); 609 document.body.classList.add('reward-modal-open'); 610 const reel = modal.querySelector('.chest-reel'); 611 animateReelWithTicks(reel, { 612 finalOffset: 58 - (targetIndex * 52), 613 duration: 3500, 614 easing: 'cubic-bezier(.1,.68,.08,1)', 615 onDone: () => { 616 playUiTone('winner'); 617 const rewardRarity = Math.max(1, tiers.findIndex((tier) => tier.id === reward.tierId) + 1); 618 modal.querySelector('[data-chest-opening-result]').innerHTML = `<span>Você ganhou</span><strong class="rarity-text-${rewardRarity}">${escapeHtml(reward.label)}</strong><div>${coin}<b>${qq(reward.qqCoins)} KEKE Coins</b></div><button type="button" data-close-chest-modal>Receber prêmio</button>`; 619 modal.querySelector('.chest-opening-dialog').classList.add('won'); 620 } 621 }); 622 } 623 624 function showChestBulkSummary(chestType, result) { 625 document.getElementById('chestOpeningModal')?.remove(); 626 const config = JSON.parse(root.dataset.chestConfig || '{}'); 627 const tiers = config.chests?.[chestType] || []; 628 const summary = Array.isArray(result.summary) ? result.summary : []; 629 const opened = Math.max(0, Number(result.opened || 0)); 630 const modal = document.createElement('div'); 631 modal.id = 'chestOpeningModal'; 632 modal.className = `chest-opening-modal chest-opening-${chestType}`; 633 modal.innerHTML = `<div class="chest-opening-dialog chest-bulk-summary-dialog won" role="dialog" aria-modal="true" aria-label="Resumo da abertura dos baús"> 634 <div class="chest-opening-glow"></div> 635 <img class="chest-opening-image" src="${chestMeta[chestType].image}" alt="${chestMeta[chestType].label}"> 636 <span class="chest-bulk-eyebrow">Abertura concluÃda</span> 637 <h3>${opened} ${opened === 1 ? 'baú aberto' : 'baús abertos'}</h3> 638 <p class="chest-bulk-description">Todos os prêmios foram processados e salvos na sua conta.</p> 639 <div class="chest-bulk-summary-list">${summary.map((item) => { 640 const rarity = Math.max(1, tiers.findIndex((tier) => tier.id === item.tierId) + 1); 641 const count = Math.max(0, Number(item.count || 0)); 642 return `<div class="chest-bulk-summary-row rarity-${rarity}"><div><strong>${escapeHtml(item.label)}</strong><span>${qq(item.qqCoins)} KEKE cada</span></div><b>${count > 0 ? `${count} ${count === 1 ? 'vez' : 'vezes'}` : 'Não foi dessa vez'}</b></div>`; 643 }).join('')}</div> 644 <div class="chest-bulk-total">${coin}<div><span>Total recebido</span><strong>${qq(result.totalCoins)} KEKE Coins</strong></div></div> 645 <button type="button" class="chest-bulk-close" data-close-chest-modal>Concluir</button> 646 </div>`; 647 root.appendChild(modal); 648 document.body.classList.add('reward-modal-open'); 649 playUiTone('winner'); 650 } 651 652 async function openRaffleConfiguration(raffleId) { 653 const previous = document.getElementById('raffleAdminModal'); 654 if (previous) previous.remove(); 655 const modal = document.createElement('div'); 656 modal.id = 'raffleAdminModal'; 657 modal.className = 'raffle-admin-modal'; 658 modal.innerHTML = `<div class="raffle-admin-dialog" role="dialog" aria-modal="true" aria-label="Configurações do sorteio"><button class="raffle-modal-close" type="button" data-close-raffle-modal aria-label="Fechar">Ã</button><div class="inventory-loading">Carregando participantesâ¦</div></div>`; 659 root.appendChild(modal); 660 document.body.classList.add('reward-modal-open'); 661 try { 662 const response = await fetch(`${API_URL}
662/api/admin/raffles/${raffleId}`, { credentials: 'include', cache: 'no-store' }); 663 if (!response.ok) throw new Error(response.status === 403 ? 'Acesso administrativo recusado.' : 'Não foi possÃvel carregar.'); 664 const data = await response.json(); 665 const raffle = data.raffle; 666 const participants = data.participants || []; 667 const winners = data.winners || []; 668 modal.querySelector('.raffle-admin-dialog').innerHTML = ` 669 <button class="raffle-modal-close" type="button" data-close-raffle-modal aria-label="Fechar">Ã</button> 670 <div class="raffle-private-label">Ãrea privada da conta administrativa</div> 671 <h3>${escapeHtml(raffle.title)}</h3> 672 <p>O sorteio permanece aberto sem prazo automático. Você encerra quando quiser.</p> 673 ${rafflePrizeHtml(raffle)} 674 <div class="raffle-admin-summary"><span><b>${participants.length}</b> participantes</span><span><b>${Number(raffle.total_tickets || 0)}</b> tickets</span><span class="raffle-summary-status ${escapeHtml(raffle.status)}"><b>${escapeHtml(raffle.status)}</b></span></div> 675 <div class="raffle-participants"><h4>Participantes</h4>${participants.length ? participants.map((entry) => `<div><span>${escapeHtml(entry.display_name)}${entry.game_character_name ? ` · ${escapeHtml(entry.game_character_name)}` : ''}</span><strong>${Number(entry.quantity)} ticket(s)</strong></div>`).join('') : '<p>Nenhum participante ainda.</p>'}</div> 676 ${winners.length ? `<div class="raffle-winners"><h4>Vencedores</h4>${winners.map((winner) => `<div><a href="/loja/perfil/${winner.user_id}" data-public-profile="${winner.user_id}">${winner.position}º ${escapeHtml(winner.display_name)}</a><span>${escapeHtml(winner.reward_type)} · ${escapeHtml(winner.delivery_status)}${winner.delivery_status !== 'delivered' && winner.reward_type === 'manual' ? ` <button type="button" data-raffle-delivery="${raffle.id}" data-raffle-winner="${winner.user_id}">Marcar entregue</button>` : ''}</span></div>`).join('')}</div>` : ''} 677 ${raffle.status !== 'drawn' ? `<div class="raffle-private-actions"> 678 ${raffle.status === 'open' ? `<button type="button" data-raffle-status="closed" data-raffle-id="${raffle.id}" data-raffle-modal-action>Encerrar entradas</button>` : `${winners.length ? '' : `<button type="button" data-raffle-status="open" data-raffle-id="${raffle.id}" data-raffle-modal-action>Reabrir entradas</button>`}`} 679 <button type="button" data-raffle-refund-close="${raffle.id}" data-raffle-modal-action>Fechar e reembolsar</button> 680 ${raffle.status === 'closed' ? `<button class="raffle-start-button" type="button" data-raffle-animated-draw="${raffle.id}" ${participants.length >= Number(raffle.configured_winners || 1) ? '' : 'disabled'}>Começar sorteio de ${Number(raffle.configured_winners || 1)} vencedor(es)</button> 681 ${participants.length < Number(raffle.configured_winners || 1) ? `<span class="raffle-close-hint">São necessários ${Number(raffle.configured_winners || 1)} participantes únicos; existem ${participants.length}.</span>` : ''}` : '<span class="raffle-close-hint">Encerre as entradas antes de sortear.</span>'} 682 </div>` : ''} 683 ${raffle.status === 'drawn' && winners.length ? `<div class="raffle-private-actions"><button class="raffle-start-button" type="button" data-raffle-replay="${raffle.id}">Apresentar vencedores novamente</button></div>` : ''} 684 <div class="raffle-private-actions"><button type="button" data-raffle-delete="${raffle.id}">Excluir sorteio</button></div>`; 685 const winnerIds = new Set(winners.map((winner) => winner.user_id)); 686 modal.dataset.participants = JSON.stringify(participants.filter((participant) => !winnerIds.has(participant.user_id))); 687 } catch (error) { 688 modal.querySelector('.raffle-admin-dialog').innerHTML = `<button class="raffle-modal-close" type="button" data-close-raffle-modal>Ã</button><div class="store-alert">${escapeHtml(error.message)}</div>`; 689 } 690 } 691 692 async function runAnimatedDraw(button) { 693 const modal = document.getElementById('raffleAdminModal'); 694 const participants = JSON.parse(modal?.dataset.participants || '[]'); 695 if (!participants.length) return; 696 button.disabled = true; 697 const response = await fetch(`${API_URL}
697/api/admin/raffles/${button.dataset.raffleAnimatedDraw}/draw`, { 698 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}' 699 }); 700 const result = await response.json().catch(() => ({})); 701 if (!response.ok) { 702 button.disabled = false; 703 const messages = { 704 raffle_must_be_closed: 'Encerre as entradas antes de realizar o sorteio.', 705 not_enough_unique_participants: `Não há participantes únicos suficientes. Necessários: ${result.required || 'mais usuários'}; disponÃveis: ${result.participants ?? 0}.`, 706 all_winners_already_selected: 'Todos os vencedores deste sorteio já foram definidos.' 707 }; 708 alert(messages[result.error] || 'Não foi possÃvel realizar o sorteio. Tente novamente.'); 709 return; 710 } 711 showRaffleWinnerSequence(button.dataset.raffleAnimatedDraw, participants, result.winners || []); 712 } 713 714 async function notifyRevealedRaffleWinner(raffleId, winner) { 715 if (!winner?.user_id) return; 716 try { await fetch(`${API_URL}/api/admin/raffles/${raffleId}/winners/${winner.user_id}/reveal`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}' }); } catch (_) {} 717 } 718 719 function showRaffleWinnerSequence(raffleId, participants, winners, revealIndex = 0, notifyWinner = true) { 720 const modal = document.getElementById('raffleAdminModal'); 721 if (!modal || !winners.length) return; 722 modal.dataset.revealParticipants = JSON.stringify(participants); 723 modal.dataset.revealWinners = JSON.stringify(winners); 724 modal.dataset.revealIndex = String(revealIndex); 725 modal.dataset.revealNotify = notifyWinner ? 'true' : 'false'; 726 const winner = winners[revealIndex]; 727 const visualPool = participants.filter((entry) => !winners.slice(0, revealIndex).some((past) => past.user_id === entry.user_id)).flatMap((entry) => Array.from({ length: Math.min(Number(entry.quantity), 100) }, () => entry.display_name)); 728 const dialog = modal.querySelector('.raffle-admin-dialog'); 729 dialog.innerHTML = `<div class="raffle-draw-stage"><div class="raffle-draw-progress">Vencedor ${revealIndex + 1} de ${winners.length}</div><div class="raffle-draw-title">Sorteandoâ¦</div><div class="raffle-reel-window"><div class="raffle-reel-indicator"><span>selecionado</span></div><div class="raffle-reel" data-raffle-reel></div></div><div class="raffle-draw-result" data-raffle-result></div></div>`; 730 const reel = dialog.querySelector('[data-raffle-reel]'); 731 const shuffled = Array.from({ length: 42 }, (_, index) => visualPool[(crypto.getRandomValues(new Uint32Array(1))[0] + index) % visualPool.length] || winner.display_name); 732 const winnerIndex = shuffled.length; 733 shuffled.push(winner.display_name); 734 for (let index = 0; index < 6; index += 1) shuffled.push(visualPool[(crypto.getRandomValues(new Uint32Array(1))[0] + index) % Math.max(1, visualPool.length)] || winner.display_name); 735 reel.innerHTML = shuffled.map((name) => `<div>${escapeHtml(name)}</div>`).join(''); 736 animateReelWithTicks(reel, { 737 finalOffset: 109 - (winnerIndex * 52), 738 duration: 3700, 739 easing: 'cubic-bezier(.12,.72,.12,1)', 740 onDone: () => { 741 playUiTone('winner'); 742 if (notifyWinner) notifyRevealedRaffleWinner(raffleId, winner); 743 const hasNext = revealIndex + 1 < winners.length; 744 dialog.querySelector('[data-raffle-result]').innerHTML = `<span>${winner.position}º vencedor</span><strong>${escapeHtml(winner.display_name)}</strong><div class="raffle-result-actions">${hasNext ? `<button type="button" data-raffle-reveal-next="${raffleId}">Revelar próximo vencedor</button>` : `<button type="button" data-raffle-next="${raffleId}">Ver resultado completo</button>`}</div>`; 745 } 746 }); 747 } 748 749 function adminMetricsDate(value) { 750 if (!value) return 'â'; 751 const date = new Date(value); 752 if (Number.isNaN(date.getTime())) return 'â'; 753 return new Intl.DateTimeFormat('pt-BR', { 754 dateStyle: 'short', 755 timeStyle: 'short' 756 }).format(date); 757 } 758 759 function renderAdminMetricsSummary(rows = []) { 760 const container = root.querySelector('[data-admin-metrics-summary]'); 761 if (!container) return; 762 container.innerHTML = Object.entries(chestMeta).map(([chestType, meta]) => { 763 const prizes = Object.entries(adminMetricPrizeMeta).map(([prize, prizeMeta]) => {
764 const row = rows.find((item) => item.chest_type === chestType && item.prize === prize); 765 const total = Number(row?.total_awarded || 0); 766 const tierLabel = row?.label || prizeMeta.fallbackTierLabel; 767 const selected = adminMetricsState.chestType === chestType && adminMetricsState.prize === prize; 768 return `<button type="button" class="admin-metrics-prize ${selected ? 'active' : ''}" data-admin-metric-chest="${chestType}" data-admin-metric-prize="${prize}" aria-pressed="${selected}"> 769 <span>${prizeMeta.label}</span><strong>${total.toLocaleString('pt-BR')}</strong><small>${escapeHtml(tierLabel)}</small> 770 </button>`; 771 }).join(''); 772 return `<section class="admin-metrics-chest"> 773 <div class="admin-metrics-chest-title"><img src="${meta.image}" alt=""><div><strong>${meta.label}</strong><span>Total histórico de prêmios entregues</span></div></div> 774 <div class="admin-metrics-prizes">${prizes}</div> 775 </section>`; 776 }).join(''); 777 } 778 779 function adminMetricsIdentityLine(user) { 780 const parts = []; 781 if (user.twitch_username) parts.push(`Twitch: ${escapeHtml(user.twitch_username)}`); 782 if (user.youtube_username) parts.push(`YouTube: ${escapeHtml(user.youtube_username)}`); 783 if (user.game_character_name) parts.push(`Personagem: ${escapeHtml(user.game_character_name)}`); 784 return parts.length ? parts.join(' · ') : 'Sem identificadores adicionais'; 785 } 786 787 function adminMetricsPaginationHtml(pagination = {}) { 788 const current = Number(pagination.page || 1); 789 const total = Number(pagination.totalPages || 0); 790 if (total <= 1) return ''; 791 const visible = [...new Set([1, total, current - 2, current - 1, current, current + 1, current + 2])] 792 .filter((page) => page >= 1 && page <= total) 793 .sort((a, b) => a - b); 794 let previous = 0; 795 const numbered = visible.map((page) => { 796 const gap = previous && page - previous > 1 ? '<span class="admin-metrics-page-gap">â¦</span>' : ''; 797 previous = page; 798 return `${gap}<button type="button" data-admin-metrics-page="${page}" class="${page === current ? 'active' : ''}" ${page === current ? 'aria-current="page"' : ''}>${page}</button>`; 799 }).join(''); 800 return `<nav class="admin-metrics-pagination" aria-label="Paginação das métricas"> 801 <button type="button" data-admin-metrics-page="${current - 1}" ${current <= 1 ? 'disabled' : ''}>Anterior</button> 802 <div>${numbered}</div> 803 <button type="button" data-admin-metrics-page="${current + 1}" ${current >= total ? 'disabled' : ''}>Próxima</button> 804 </nav>`; 805 } 806 807 function renderAdminMetricsDetail(data) { 808 const detail = root.querySelector('[data-admin-metrics-detail]'); 809 if (!detail) return; 810 const selection = data.selection; 811 if (!selection) { 812 detail.hidden = true; 813 detail.innerHTML = ''; 814 return; 815 } 816 const prizeMeta = adminMetricPrizeMeta[selection.prize]; 817 const chest = chestMeta[selection.chestType]; 818 const users = Array.isArray(data.users) ? data.users : []; 819 const pagination = data.pagination || {}; 820 const sort = data.sort || 'recent'; 821 detail.hidden = false; 822 detail.innerHTML = ` 823 <div class="admin-metrics-detail-head"> 824 <div><span>Lista selecionada</span><h4>${chest.label} · ${prizeMeta.label}</h4><p><strong>${Number(selection.totalAwarded || 0).toLocaleString('pt-BR')}</strong> prêmio(s) no total · ${Number(pagination.totalUsers || 0).toLocaleString('pt-BR')} usuário(s) premiado(s)</p></div> 825 <button type="button" data-admin-metrics-close aria-label="Fechar lista">Ã</button> 826 </div> 827 <div class="admin-metrics-sort" aria-label="Ordenar lista"> 828 <span>Ordenar por:</span> 829 <button type="button" data-admin-metrics-sort="recent" class="${sort === 'recent' ? 'active' : ''}">Mais recente</button> 830 <button type="button" data-admin-metrics-sort="count" class="${sort === 'count' ? 'active' : ''}">Maior número</button> 831 <button type="button" data-admin-metrics-sort="name" class="${sort === 'name' ? 'active' : ''}">Ordem alfabética</button> 832 </div> 833 <div class="admin-metrics-list"> 834 <div class="admin-metrics-row admin-metrics-row-head"><span>Usuário</span><span>
834Quantidade</span><span>Ãltimo prêmio</span></div> 835 ${users.length ? users.map((user) => `<div class="admin-metrics-row"> 836 <div class="admin-metrics-user"><strong>${escapeHtml(user.display_name || 'Usuário')}</strong><small>${adminMetricsIdentityLine(user)}</small></div> 837 <strong class="admin-metrics-count">${Number(user.wins || 0).toLocaleString('pt-BR')}Ã</strong> 838 <time class="admin-metrics-date" datetime="${escapeHtml(user.latest_win_at || '')}">${adminMetricsDate(user.latest_win_at)}</time> 839 </div>`).join('') : '<div class="admin-metrics-empty">Nenhum usuário ganhou este prêmio neste baú até agora.</div>'} 840 </div> 841 ${adminMetricsPaginationHtml(pagination)} 842 `; 843 } 844 845 async function loadAdminRewardMetrics({ chestType = '', prize = '', sort = 'recent', page = 1 } = {}) { 846 const summary = root.querySelector('[data-admin-metrics-summary]'); 847 const detail = root.querySelector('[data-admin-metrics-detail]'); 848 if (!summary) return; 849 const hasSelection = Boolean(chestType && prize); 850 if (!hasSelection) summary.innerHTML = '<div class="admin-metrics-loading">Carregando métricas históricasâ¦</div>'; 851 else if (detail) { 852 detail.hidden = false; 853 detail.innerHTML = '<div class="admin-metrics-loading">Carregando lista de premiadosâ¦</div>'; 854 } 855 856 const params = new URLSearchParams(); 857 if (hasSelection) { 858 params.set('chestType', chestType); 859 params.set('prize', prize); 860 params.set('sort', sort); 861 params.set('page', String(page)); 862 params.set('pageSize', '20'); 863 } 864 const url = `${API_URL}/api/admin/rewards/metrics${params.size ? `?${params}` : ''}`; 865 const response = await fetch(url, { credentials: 'include', cache: 'no-store' }); 866 if (!response.ok) throw new Error('metrics_load_failed'); 867 const data = await response.json(); 868 869 adminMetricsState.loaded = true; 870 if (hasSelection) { 871 adminMetricsState.chestType = chestType; 872 adminMetricsState.prize = prize; 873 adminMetricsState.sort = sort; 874 adminMetricsState.page = page; 875 } 876 renderAdminMetricsSummary(data.summary || []); 877 if (hasSelection) renderAdminMetricsDetail(data); 878 } 879 880 function renderAdmin() { 881 if (!account?.is_admin) return open('panel'); 882 Object.assign(adminMetricsState, { loaded: false, chestType: '', prize: '', sort: 'recent', page: 1 }); 883 root.innerHTML = shell(`<div class="admin-loading" id="adminRoot">Carregando controles administrativosâ¦</div>`); 884 fetch(`${API_URL}/api/admin/rewards/overview`, { credentials: 'include', cache: 'no-store' }) 885 .then((response) => response.ok ? response.json() : Promise.reject()) 886 .then((data) => { 887 const rules = data.rules || {}; 888 const groupedTiers = Object.fromEntries(Object.keys(chestMeta).map((type) => [type, data.tiers.filter((tier) => tier.chest_type === type)])); 889 document.getElementById('adminRoot').outerHTML = ` 890 <div class="admin-grid"> 891 <article class="store-card"><h3>Usuários</h3><div class="metric">${data.counts.users}</div></article> 892 <article class="store-card"><h3>Extensões conectadas</h3><div class="metric">${data.counts.extensions}</div></article> 893 <article class="store-card"><h3>Sessões ativas</h3><div class="metric">${data.counts.active_sessions}</div></article> 894 <nav class="admin-section-nav admin-wide" aria-label="Seções administrativas"> 895 <button type="button" data-admin-panel-button="metrics">Métricas</button> 896 <button type="button" data-admin-panel-button="rubini">Resgates RubinOT</button> 897 <button type="button" data-admin-panel-button="delivery">Entrega</button> 898 <button type="button" data-admin-panel-button="settings">Configurações</button> 899 <button type="button" data-admin-panel-button="gifts">Presentes</button> 900 <button type="button" data-admin-panel-button="raffles">Sorteios</button> 901 <button type="button" data-admin-panel-button="messages">Mensagens</button> 902 <button type="button" data-admin-panel-button="resets">Redefinições</button> 903 </nav> 904 <article class="store-card admin-wide admin-section" data-admin-panel="metrics" hidden> 905 <h3>Métricas dos baús</h3> 906 <p>Totais históricos de prêmios Raro, Especial e Máximo. Clique em uma categoria para ver quem ganhou, quantas vezes e quando foi o prêmio mais recente.</p> 907 <div class="admin-metrics-summary" data-admin-metrics-summary><div class="admin-metrics-loading">Abra esta seção para carregar as métricas.</div></div> 908 <div class="admin-metrics-detail" data-admin-metrics-detail hidden></div> 909 </article> 910 <article class="store-card admin-wide admin-section" data-admin-panel="rubini" hidden> 911 <h3>Fila de entrega RubinOT</h3><p>Os pedidos pendentes aparecem aqui sem precisar expor outras configurações durante a live.</p> 912 <div class="admin-search-results" data-rubini-admin-list><button type="button" data-load-rubini-redemptions>Carregar pedidos</button></div> 913 </article> 914 <article class="store-card admin-wide admin-section" data-admin-panel="delivery" hidden> 915 <h3>Regras de entrega dos baús</h3> 916 <form class="admin-settings" id="adminSettingsForm"> 917 <label>Intervalo (minutos)<input type="number" name="intervalMinutes" min="1" max="1440" value="${Number(rules.intervalMinutes || 60)}"></label> 918 <label>Baús Twitch por intervalo<input type="number" name="twitchChests" min="0" max="100" value="${Number(rules.twitchChests || 1)}"></label> 919 <label>Baús Twitch Sub por intervalo<input type="number" name="twitchSubChests" min="0" max="100" value="${Number(rules.twitchSubChests || 1)}"></label> 920 <label>Baús YouTube por intervalo<input type="number" name="youtubeChests" min="0" max="100" value="${Number(rules.youtubeChests || 1)}"></label> 921 <button class="profile-save" type="submit">Salvar regras</button><span class="profile-save-status"></span> 922 </form> 923 </article> 924 <article class="store-card admin-wide admin-section admin-sensitive" data-admin-panel="settings" hidden> 925 <h3>Configurações dos baús</h3> 926 <p>O total ativo de cada baú deve ser exatamente 100%. Limite 0 significa estoque infinito.</p> 927 <div class="tier-groups">${Object.entries(groupedTiers).map(([type, tiers]) => ` 928 <form class="tier-form" data-tier-form="${type}"> 929 <div class="tier-title"><img src="${chestMeta[type].image}" alt=""><strong>${chestMeta[type].label}</strong><button type="button" data-reset-cycle="${type}">Repor estoque do ciclo</button></div> 930 <div class="tier-table">${tiers.map((tier) => `<div class="tier-row" data-tier-id="${tier.id}"> 931 <input name="label" value="${escapeHtml(tier.label)}
931" maxlength="80" aria-label="Nome do prêmio"> 932 <label>KEKE Coins<input name="qqCoins" type="text" inputmode="decimal" value="${decimalInput(tier.qq_coins)}"></label> 933 <label>Chance %<input name="probability" type="text" inputmode="decimal" value="${decimalInput(tier.probability)}"></label> 934 <label>Limite<input name="cycleLimit" type="number" min="0" step="1" value="${Number(tier.cycle_limit)}"></label> 935 <span class="tier-stock">Entregues: ${Number(tier.awarded_in_cycle)}</span> 936 </div>`).join('')}</div> 937 <div class="tier-footer"><strong data-tier-total>Total: 100%</strong><button class="profile-save" type="submit">Salvar ${chestMeta[type].label}</button><span class="profile-save-status"></span></div> 938 </form>`).join('')}</div> 939 </article> 940 <article class="store-card admin-wide admin-section" data-admin-panel="gifts" hidden> 941 <h3>Presentear usuários</h3> 942 <p>Envie um kit com todos os baús, Baús Lendários ou KEKE Coins para sua conta ou para outro usuário.</p> 943 <div class="admin-search-results"> 944 <div> 945 <div> 946 <strong>Baú Lendário para todos</strong> 947 <span>Escolha quantos Baús Lendários cada usuário cadastrado no site receberá.</span> 948 </div> 949 <div class="admin-search-actions"> 950 <input id="legendaryGiftAllQuantity" type="number" min="1" max="100" step="1" value="1" inputmode="numeric" aria-label="Quantidade de Baús Lendários por usuário"> 951 <button class="profile-save" type="button" data-gift-legendary-all>Enviar para todos</button> 952 </div> 953 </div> 954 <p class="profile-save-status" data-gift-legendary-all-status aria-live="polite"></p> 955 </div> 956 <div class="admin-search-results"> 957 <div><div><strong>${escapeHtml(account.custom_display_name || account.username || 'Minha conta')}</strong><span>Sua conta administrativa</span></div><div class="admin-search-actions"><button type="button" data-gift-all-chests="${account.id}">Kit teste: 1 de cada</button><button type="button" data-gift-legendary="${account.id}">Baú Lendário</button><button type="button" data-gift-coins="${account.id}">KEKE Coins</button></div></div> 958 </div> 959 <form class="admin-search" id="legendarySearchForm"><input name="query" minlength="2" placeholder="Nome exibido ou nick do personagem"><button class="profile-save" type="submit">Pesquisar</button></form> 960 <div class="admin-search-results" id="legendarySearchResults"></div> 961 </article> 962 <article class="store-card admin-wide admin-section" data-admin-panel="raffles" hidden> 963 <h3>Criar sorteio</h3> 964 <form class="raffle-admin-form raffle-create-form" id="raffleAdminForm"> 965 <label>Tipo de prêmio<select name="prizeType" data-raffle-prize-type><option value="manual">Manual</option><option value="legendary_chest">Baú Lendário</option><option value="rubini_coins">Rubini Coins</option></select></label> 966 <label>Categoria do sorteio<select name="raffleType"><option value="generic">Comum</option><option value="weekly_rc">Semanal</option><option value="monthly_rc">Mensal</option></select></label> 967 <label>Nome do sorteio<input name="title" maxlength="120" required placeholder="Ex.: Sorteio especial da live"></label> 968 <label data-raffle-description>Descrição / prêmio<input name="description" maxlength="1000" required placeholder="Descreva exatamente o prêmio"></label> 969 <label>Custo por ticket em KEKE Coins<input name="ticketCost" type="text" inputmode="decimal" required value="0"><span class="free-raffle-admin-hint" data-free-raffle-admin-hint>Custo 0: este será um sorteio gratuito, limitado a 1 entrada por pessoa.</span></label> 970 <label>Número de vencedores<input name="configuredWinners" type="number" min="1" max="100" value="1" required></label> 971 <label data-raffle-rubini hidden>Rubini Coins por vencedor<input name="prizeRubiniAmount" type="number" min="1" max="100000" value="25"></label> 972 <label class="admin-check"><input name="autoCreateNext" type="checkbox">
972 Criar o próximo automaticamente ao finalizar</label> 973 <button class="profile-save" type="submit">Abrir sorteio</button><span class="profile-save-status"></span> 974 </form> 975 <div class="admin-raffles">${data.raffles.length ? data.raffles.map((raffle) => `<div class="admin-raffle-row"><div><strong>${escapeHtml(raffle.title)}</strong><span>${raffleCategoryLabel(raffle.raffle_type)} · ${raffle.status} · ${Number(raffle.total_tickets)} tickets · ${qq(raffle.ticket_cost)} KEKE · ${Number(raffle.configured_winners || 1)} vencedor(es)</span></div><div><button type="button" data-raffle-config="${raffle.id}">Configurações</button>${raffle.status === 'open' ? `<button type="button" data-raffle-status="closed" data-raffle-id="${raffle.id}">Encerrar entradas</button>` : raffle.status === 'closed' ? `<button type="button" data-raffle-status="open" data-raffle-id="${raffle.id}">Reabrir</button>` : ''}${raffle.status !== 'drawn' ? `<button type="button" data-raffle-refund-close="${raffle.id}">Fechar e reembolsar</button>` : ''}<button type="button" data-raffle-delete="${raffle.id}" ${raffle.status === 'drawn' ? 'data-raffle-history-delete' : ''}>Excluir</button></div></div>`).join('') : '<p>Nenhum sorteio criado.</p>'}</div> 976 </article> 977 <article class="store-card admin-wide admin-section admin-message-section" data-admin-panel="messages" hidden> 978 <h3>Enviar mensagem</h3> 979 <p>Envie um aviso para todos os perfis ativos ou selecione uma pessoa especÃfica.</p> 980 <div class="admin-scope-switch"><button type="button" class="active" data-message-target="all">Geral â todos</button><button type="button" data-message-target="user">Usuário especÃfico</button></div> 981 <form class="admin-search" id="messageUserSearchForm" hidden><input name="query" minlength="2" placeholder="Nome do perfil, Twitch, YouTube ou personagem"><button class="profile-save" type="submit">Pesquisar</button></form> 982 <div class="admin-search-results" id="messageUserSearchResults"></div><input type="hidden" data-selected-message-user> 983 <form class="admin-message-form" id="adminMessageForm"> 984 <label>Tipo<select name="messageType"><option value="info">Informação</option><option value="success">Sucesso</option><option value="warning">Aviso importante</option><option value="announcement">Comunicado</option></select></label> 985 <label>TÃtulo<input name="title" maxlength="120" required placeholder="Ex.: Novidade na Loja do Canal"></label> 986 <label class="admin-message-content">Mensagem<textarea name="content" maxlength="2000" required rows="5" placeholder="Escreva a mensagem que aparecerá na caixa de entrada."></textarea></label> 987 <label>Texto do botão <small>(opcional)</small><input name="actionLabel" maxlength="60" placeholder="Ex.: Assistir agora"></label> 988 <label>Link do botão <small>(opcional)</small><input name="actionUrl" type="url" maxlength="2048" placeholder="https://..."></label> 989 <button class="profile-save" type="submit">Enviar mensagem</button><span class="profile-save-status" aria-live="polite"></span> 990 </form> 991 </article> 992 <article class="store-card admin-wide danger-zone admin-section" data-admin-panel="resets" hidden> 993 <h3>Redefinições administrativas</h3><p>Escolha todos os usuários ou pesquise uma conta especÃfica. Todas as ações ficam registradas.</p> 994 <div class="admin-scope-switch"><button type="button" class="active" data-reset-target="all">Geral â todos</button><button type="button" data-reset-target="user">Usuário especÃfico</button></div> 995 <form class="admin-search" id="resetUserSearchForm" hidden><input name="query" minlength="2" placeholder="Nome do perfil, Twitch, YouTube ou personagem"><button class="profile-save" type="submit">Pesquisar</button></form> 996 <div class="admin-search-results" id="resetUserSearchResults"></div><input type="hidden" data-selected-reset-user> 997 <div class="admin-actions reset-actions"><button type="button" data-admin-reset="watch_hours">Resetar horas assistidas</button><button type="button" data-admin-reset="balance">Zerar saldo disponÃvel</button><button type="button" data-admin-reset="total_earned">Zerar total conquistado</button><button type="button" data-admin-reset="chests">Zerar baús disponÃveis</button><button type="button" data-admin-reset="chest_history_30d">Resetar histórico de baús (30 dias)</button><button type="button" data-reset-cycle="all">Repor prêmios limitados</button></div> 998 <div class="profile-save-status" data-admin-status></div> 999 <hr><h3>Loja</h3><p>Controle os bloqueios de resgate sem apagar o histórico de entregas.</p> 1000 <div class="admin-actions reset-actions"><button type="button" data-shop-reset="cooldowns">
1000Liberar cooldown de todos</button><button type="button" data-shop-reset="daily">Resetar resgates de hoje</button></div> 1001 <form class="admin-settings admin-shop-settings" id="adminShopSettingsForm"> 1002 <label>Rubini Coins por resgate<input type="number" name="rubiniAmount" min="1" max="1000000" step="1" value="25"></label> 1003 <label>Preço em KEKE Coins<input type="number" name="kekeCost" min="0.01" max="1000000000" step="0.01" value="375"></label> 1004 <label>Cooldown por usuário (dias)<input type="number" name="cooldownDays" min="0" max="3650" step="1" value="30"><span>Use 0 para não aplicar cooldown.</span></label> 1005 <label>Limite diário de resgates RC<input type="number" name="dailyLimit" min="0" max="100000" value="4"><span>Use 0 para não ter limite diário.</span></label> 1006 <button class="profile-save" type="submit">Salvar loja</button><span class="profile-save-status"></span> 1007 </form> 1008 <hr><h3>Banir ou liberar usuário</h3><p>Usuários banidos não podem abrir baús, comprar tickets, usar saldo, resgatar na loja ou continuar usando a extensão.</p> 1009 <form class="admin-search" id="banUserSearchForm"><input name="query" minlength="2" placeholder="Nome do perfil, Twitch, YouTube ou personagem"><button class="profile-save" type="submit">Pesquisar</button></form> 1010 <div class="admin-search-results" id="banUserSearchResults"></div> 1011 </article> 1012 </div>`; 1013 root.querySelectorAll('.tier-form').forEach(updateTierTotal); 1014 fetch(`${API_URL}/api/admin/shop/settings`, { credentials: 'include', cache: 'no-store' }).then((response) => response.ok ? response.json() : Promise.reject()).then((shop) => { 1015 const form = document.getElementById('adminShopSettingsForm'); 1016 if (!form) return; 1017 form.elements.rubiniAmount.value = Number(shop.settings?.rubiniAmount ?? 25); 1018 form.elements.kekeCost.value = Number(shop.settings?.kekeCost ?? 375); 1019 form.elements.cooldownDays.value = Number(shop.settings?.cooldownDays ?? 30); 1020 form.elements.dailyLimit.value = Number(shop.settings?.dailyLimit ?? 4); 1021 }).catch(() => {}); 1022 }) 1023 .catch(() => { document.getElementById('adminRoot').textContent = 'Não foi possÃvel carregar a administração.'; }); 1024 } 1025 1026 function updateTierTotal(form) { 1027 const total = [...form.querySelectorAll('[name="probability"]')].reduce((sum, input) => sum + (parseDecimal(input.value) || 0), 0); 1028 const output = form.querySelector('[data-tier-total]'); 1029 const difference = 100 - total; 1030 output.textContent = Math.abs(difference) < 0.00005 ? 'Total: 100% â' : `Total: ${total.toFixed(4)}% · ${difference > 0 ? 'faltam' : 'remova'} ${Math.abs(difference).toFixed(4)}%`; 1031 output.classList.toggle('invalid', Math.abs(difference) >= 0.00005); 1032 } 1033 1034 function render() { 1035 if (currentView === 'inventory') renderInventory(); 1036 else if (currentView === 'redemptions') renderRedemptions(); 1037 else if (currentView === 'ranking') renderRanking(); 1038 else if (currentView === 'raffles') renderRaffles(); 1039 else if (currentView === 'profile') renderProfile(); 1040 else if (currentView === 'inbox') renderInbox(); 1041 else if (currentView === 'admin') renderAdmin(); 1042 else renderPanel(); 1043 } 1044 1045 function open(view, { updateUrl = true, refreshData = true } = {}) { 1046 const routeAliases = { 1047 painel: 'panel', 1048 inventario: 'inventory', 1049 resgates: 'redemptions', 1050 sorteios: 'raffles', perfil: 'profile', mensagens: 'inbox' 1051 }; 1052 const normalizedView = routeAliases[view] || view; 1053 currentView = labels[normalizedView] ? normalizedView : 'panel'; 1054 viewEpoch += 1; 1055 const openingEpoch = viewEpoch; 1056 document.documentElement.classList.add('route-store'); 1057 closeMenu(); 1058 if (typeof window.setNav === 'function') window.setNav('store', { updateUrl: false }); 1059 else if (typeof setNav === 'function') setNav('store', { updateUrl: false }); 1060 if (updateUrl) history.pushState({ view: 'store', storeView: currentView }, '', storePaths[currentView]); 1061 if (refreshData && ['panel', 'inventory'].includes(currentView)) { 1062 root.innerHTML = shell('<div class="inventory-loading">Atualizando seus dadosâ¦</div>'); 1063 refreshAccount({ renderAfter: false }).finally(() => { 1064 if (openingEpoch === viewEpoch) render(); 1065 }); 1066 } else { 1067 render(); 1068 } 1069 window.scrollTo({ top: 0, behavior: 'auto' }); 1070 } 1071 1072 trigger.addEventListener('click', toggleMenu); 1073 dropdown.addEventListener('click', (event) => { 1074 const item = event.target.closest('[data-store-view]'); 1075 if (item && !(event.ctrlKey || event.metaKey || event.shiftKey || event.button !== 0)) { event.preventDefault(); open(item.dataset.storeView); } 1076 }); 1077 // Controles administrativos essenciais usam captura para não serem interrompidos por ações assÃncronas de outras seções. 1078 root.addEventListener('click', (event) => { 1079 const adminPanelButton = event.target.closest('[data-admin-panel-button]'); 1080 if (adminPanelButton) { 1081 event.preventDefault(); 1082 event.stopPropagation(); 1083 const selected = adminPanelButton.dataset.adminPanelButton; 1084 root.querySelectorAll('[data-admin-panel-button]').forEach((button) => button.classList.toggle('active', button === adminPanelButton)); 1085 root.querySelectorAll('[data-admin-panel]').forEach((panel) => { panel.hidden = panel.dataset.adminPanel !== selected; }); 1086 if (selected === 'metrics' && !adminMetricsState.loaded) { 1087 loadAdminRewardMetrics().catch(() => { 1088 const output = root.querySelector('[data-admin-metrics-summary]'); 1089 if (output) output.innerHTML = '<div class="admin-metrics-error">Não foi possÃvel carregar as métricas.</div>'; 1090 }); 1091 } 1092 return; 1093 } 1094 const targetButton = event.target.closest('[data-message-target], [data-reset-target]'); 1095 if (targetButton) { 1096 event.preventDefault(); 1097 event.stopPropagation(); 1098 const isMessage = targetButton.hasAttribute('data-message-target'); 1099 const attribute = isMessage ? 'data-message-target' : 'data-reset-target'; 1100 const value = isMessage ? targetButton.dataset.messageTarget : targetButton.dataset.resetTarget; 1101 root.querySelectorAll(`[${attribute}]`).forEach((button) => button.classList.toggle('active', button === targetButton)); 1102 const form = document.getElementById(isMessage ? 'messageUserSearchForm' : 'resetUserSearchForm'); 1103 const results = document.getElementById(isMessage ? 'messageUserSearchResults' : 'resetUserSearchResults'); 1104 const selected = root.querySelector(isMessage ? '[data-selected-message-user]' : '[data-selected-reset-user]'); 1105 const isUserTarget = value === 'user'; 1106 if (form) { 1107 form.hidden = !isUserTarget; 1108 form.setAttribute('aria-hidden', String(!isUserTarget)); 1109 } 1110 if (results) results.innerHTML = ''; 1111 if (selected) selected.value = ''; 1112 if (!isMessage) { 1113 const status = root.querySelector('[data-admin-status]'); 1114 if (status) status.textContent = isUserTarget 1115 ? 'Modo atual: somente o usuário que você selecionar.' 1116 : 'Modo atual: todos os usuários do site.'; 1117 } 1118 return; 1119 } 1120 const selectedUser = event.target.closest('[data-select-message-user], [data-select-reset-user]'); 1121 if (selectedUser) { 1122 event.preventDefault(); 1123 event.stopPropagation(); 1124 const isMessage = selectedUser.hasAttribute('data-select-message-user'); 1125 const selector = isMessage ? '[data-select-message-user]' : '[data-select-reset-user]'; 1126 const hidden = root.querySelector(isMessage ? '[data-selected-message-user]' : '[data-selected-reset-user]'); 1127 if (hidden) hidden.value = isMessage ? selectedUser.dataset.selectMessageUser : selectedUser.dataset.selectResetUser;
1128 root.querySelectorAll(selector).forEach((button) => { 1129 button.classList.toggle('selected', button === selectedUser); 1130 button.textContent = button === selectedUser ? 'Selecionado â' : 'Selecionar'; 1131 }); 1132 } 1133 }, true); 1134 1135 function normalizeAdminResetConfirmation(value) { 1136 return String(value ?? '').trim().toUpperCase(); 1137 } 1138 1139 async function readAdminResetResponse(response) { 1140 const data = await response.json().catch(() => ({})); 1141 if (response.ok) return data; 1142 const error = new Error(data?.message || data?.error || `HTTP ${response.status}`); 1143 error.code = data?.error || ''; 1144 error.httpStatus = Number(response.status || 0); 1145 error.retryAfter = Number(response.headers.get('retry-after') || response.headers.get('x-ratelimit-reset') || 0); 1146 throw error; 1147 } 1148 1149 function adminResetErrorMessage(error) { 1150 const status = Number(error?.httpStatus || 0); 1151 if (status === 429) { 1152 const waitMinutes = Number(error?.retryAfter || 0) > 0 ? Math.max(1, Math.ceil(Number(error.retryAfter) / 60)) : 0; 1153 return waitMinutes ? `Limite temporário de segurança atingido. Tente novamente em cerca de ${waitMinutes} min.` : 'Limite temporário de segurança atingido. Aguarde um pouco e tente novamente.'; 1154 } 1155 if (status === 401) return 'Sua sessão expirou. Entre novamente antes de redefinir.'; 1156 if (status === 403) return 'A sessão atual não tem permissão administrativa para esta ação.'; 1157 const messages = { 1158 invalid_target_mode: 'O escopo da redefinição ficou inválido. Atualize a página e tente novamente.', 1159 target_user_required: 'Selecione um usuário antes de continuar.', 1160 invalid_target_user: 'O usuário selecionado não é válido. Pesquise e selecione novamente.', 1161 invalid_reset_confirmation: 'A confirmação não foi aceita. Digite RESETAR para continuar.', 1162 invalid_shop_reset: 'A redefinição da loja não foi aceita. Atualize a página e tente novamente.' 1163 }; 1164 return messages[error?.code] || 'Não foi possÃvel concluir a redefinição. Atualize a página e tente novamente.'; 1165 } 1166 1167 const handleStoreClick = async (event) => { 1168 if (event.target.closest('[data-extension-help]')) { 1169 openExtensionHelp(); 1170 return; 1171 } 1172 if (event.target.closest('[data-extension-setup]')) { 1173 if (currentView !== 'panel') open('panel'); 1174 requestAnimationFrame(() => setTimeout(() => { 1175 const setup = document.getElementById('extensionSetupCard'); 1176 if (setup) { 1177 setup.scrollIntoView({ behavior: 'smooth', block: 'center' }); 1178 setup.focus({ preventScroll: true }); 1179 setup.classList.remove('setup-highlight'); 1180 requestAnimationFrame(() => setup.classList.add('setup-highlight')); 1181 } 1182 }, 60)); 1183 return; 1184 } 1185 if (event.target.closest('[data-update-profile]')) { 1186 if (currentView !== 'panel') open('panel'); 1187 requestAnimationFrame(() => setTimeout(() => { 1188 const form = document.getElementById('profileForm'); 1189 if (form) { 1190 form.scrollIntoView({ behavior: 'smooth', block: 'center' }); 1191 form.querySelector('[name="gameCharacterName"]')?.focus({ preventScroll: true }); 1192 } 1193 }, 60)); 1194 return; 1195 } 1196 const storeTab = event.target.closest('[data-store-tab]'); 1197 if (storeTab && !(event.ctrlKey || event.metaKey || event.shiftKey || event.button !== 0)) { 1198 event.preventDefault(); 1199 event.stopPropagation(); 1200 open(storeTab.dataset.storeTab); 1201 return; 1202 } 1203 if (event.target.closest('[data-close-chest-modal]') || event.target.classList.contains('chest-opening-modal')) { 1204 document.getElementById('chestOpeningModal')?.remove(); 1205 document.body.classList.remove('reward-modal-open'); 1206 return; 1207 } 1208 const adminPanelButton = event.target.closest('[data-admin-panel-button]'); 1209 if (adminPanelButton) { 1210 const selected = adminPanelButton.dataset.adminPanelButton; 1211 root.querySelectorAll('[data-admin-panel-button]').forEach((button) => button.classList.toggle('active', button === adminPanelButton)); 1212 root.querySelectorAll('[data-admin-panel]').forEach((panel) => { panel.hidden = panel.dataset.adminPanel !== selected; }); 1213 return; 1214 } 1215 const metricPrize = event.target.closest('[data-admin-metric-chest][data-admin-metric-prize]'); 1216 if (metricPrize) { 1217 await loadAdminRewardMetrics({ 1218 chestType: metricPrize.dataset.adminMetricChest, 1219 prize: metricPrize.dataset.adminMetricPrize, 1220 sort: 'recent', 1221 page: 1 1222 }).catch(() => { 1223 const detail = root.querySelector('[data-admin-metrics-detail]'); 1224 if (detail) { 1225 detail.hidden = false;
1226 detail.innerHTML = '<div class="admin-metrics-error">Não foi possÃvel carregar esta lista.</div>'; 1227 } 1228 }); 1229 return; 1230 } 1231 const metricSort = event.target.closest('[data-admin-metrics-sort]'); 1232 if (metricSort && adminMetricsState.chestType && adminMetricsState.prize) { 1233 await loadAdminRewardMetrics({ 1234 chestType: adminMetricsState.chestType, 1235 prize: adminMetricsState.prize, 1236 sort: metricSort.dataset.adminMetricsSort, 1237 page: 1 1238 }).catch(() => {}); 1239 return; 1240 } 1241 const metricPage = event.target.closest('[data-admin-metrics-page]'); 1242 if (metricPage && !metricPage.disabled && adminMetricsState.chestType && adminMetricsState.prize) { 1243 await loadAdminRewardMetrics({ 1244 chestType: adminMetricsState.chestType, 1245 prize: adminMetricsState.prize, 1246 sort: adminMetricsState.sort, 1247 page: Number(metricPage.dataset.adminMetricsPage || 1) 1248 }).catch(() => {}); 1249 return; 1250 } 1251 if (event.target.closest('[data-admin-metrics-close]')) { 1252 adminMetricsState.chestType = ''; 1253 adminMetricsState.prize = ''; 1254 adminMetricsState.page = 1; 1255 const detail = root.querySelector('[data-admin-metrics-detail]'); 1256 if (detail) { 1257 detail.hidden = true; 1258 detail.innerHTML = ''; 1259 } 1260 root.querySelectorAll('[data-admin-metrics-summary] [aria-pressed]').forEach((button) => { 1261 button.classList.remove('active'); 1262 button.setAttribute('aria-pressed', 'false'); 1263 }); 1264 return; 1265 } 1266 const loadRubini = event.target.closest('[data-load-rubini-redemptions]'); 1267 if (loadRubini) { 1268 const output = root.querySelector('[data-rubini-admin-list]'); output.textContent = 'Carregandoâ¦'; 1269 fetch(`${API_URL}/api/admin/rubini-redemptions`, { credentials: 'include', cache: 'no-store' }).then((response) => response.ok ? response.json() : Promise.reject()).then((data) => { 1270 output.innerHTML = data.redemptions.length ? data.redemptions.map((item) => `<div data-redemption-row="${item.id}"><div><strong>${escapeHtml(item.display_name)}</strong><span class="redemption-source ${item.redemption_source === 'raffle' ? 'raffle' : 'shop'}">${item.redemption_source === 'raffle' ? `Sorteio · ${escapeHtml(item.raffle_title || 'Sorteio')}` : 'Loja RubinOT'}</span><span>Personagem da entrega: ${escapeHtml(item.delivery_character_name || 'Sem personagem registrado')} · ${Number(item.rubini_amount)} RC · <b data-redemption-status>${escapeHtml(item.status)}</b></span>${item.character_name_snapshot ? `<small>Nick salvo no momento do resgate: ${escapeHtml(item.character_name_snapshot)}</small>` : ''}</div><div class="admin-search-actions" data-redemption-actions>${item.status === 'pending' ? (item.delivery_character_name ? `<button type="button" data-rubini-delivered="${item.id}">Confirmar entrega</button>` : `<button type="button" data-rubini-missing-nick="${item.id}">${item.missing_nick_reported_at ? 'Avisar falta de nick novamente' : 'Reportar falta de nick'}</button>`) : `<span class="delivery-success">â Entregue</span>`}</div></div>`).join('') : '<p>Nenhum pedido registrado.</p>'; 1271 }).catch(() => { output.textContent = 'Não foi possÃvel carregar os pedidos.'; }); 1272 return; 1273 } 1274 const rubiniDelivered = event.target.closest('[data-rubini-delivered]'); 1275 if (rubiniDelivered) { 1276 rubiniDelivered.disabled = true; 1277 rubiniDelivered.textContent = 'Confirmandoâ¦'; 1278 fetch(`${API_URL}/api/admin/rubini-redemptions/${rubiniDelivered.dataset.rubiniDelivered}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'delivered' }) }) 1279 .then(async (response) => { if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error || 'delivery_failed'); return response.json(); }) 1280 .then(() => { 1281 const row = rubiniDelivered.closest('[data-redemption-row]'); 1282 const status = row?.querySelector('[data-redemption-status]'); 1283 const actions = row?.querySelector('[data-redemption-actions]'); 1284 if (status) { status.textContent = 'delivered'; status.classList.add('delivery-status-done'); } 1285 if (actions) actions.innerHTML = '<span class="delivery-success">â Entregue</span>'; 1286 }).catch(() => { rubiniDelivered.disabled = false; rubiniDelivered.textContent = 'Não foi possÃvel confirmar'; rubiniDelivered.classList.add('action-error'); }); 1287 return; 1288 } 1289 const rubiniMissingNick = event.target.closest('[data-rubini-missing-nick]'); 1290 if (rubiniMissingNick) { 1291 rubiniMissingNick.disabled = true; 1292 fetch(`${API_URL}
1292/api/admin/rubini-redemptions/${rubiniMissingNick.dataset.rubiniMissingNick}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'missing_nick' }) }) 1293 .then((response) => response.ok ? root.querySelector('[data-load-rubini-redemptions]')?.click() : Promise.reject()) 1294 .catch(() => { rubiniMissingNick.disabled = false; rubiniMissingNick.textContent = 'Não foi possÃvel avisar'; }); 1295 return; 1296 } 1297 const raffleDelivery = event.target.closest('[data-raffle-delivery]'); 1298 if (raffleDelivery) { 1299 raffleDelivery.disabled = true; 1300 fetch(`${API_URL}/api/admin/raffles/${raffleDelivery.dataset.raffleDelivery}/winners/${raffleDelivery.dataset.raffleWinner}/delivery`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'delivered' }) }) 1301 .then((response) => response.ok ? openRaffleConfiguration(raffleDelivery.dataset.raffleDelivery) : Promise.reject()).catch(() => { raffleDelivery.disabled = false; alert('Não foi possÃvel registrar a entrega.'); }); 1302 return; 1303 } 1304 const raffleConfig = event.target.closest('[data-raffle-config]'); 1305 if (raffleConfig) { openRaffleConfiguration(raffleConfig.dataset.raffleConfig); return; } 1306 if (event.target.closest('[data-close-raffle-modal]') || (event.target.classList.contains('raffle-admin-modal'))) { 1307 document.getElementById('raffleAdminModal')?.remove(); 1308 document.body.classList.remove('reward-modal-open'); 1309 if (currentView === 'raffles') renderRaffles(); 1310 return; 1311 } 1312 const animatedDraw = event.target.closest('[data-raffle-animated-draw]'); 1313 if (animatedDraw) { runAnimatedDraw(animatedDraw); return; } 1314 const raffleNext = event.target.closest('[data-raffle-next]'); 1315 if (raffleNext) { openRaffleConfiguration(raffleNext.dataset.raffleNext); return; } 1316 const raffleRevealNext = event.target.closest('[data-raffle-reveal-next]'); 1317 if (raffleRevealNext) { 1318 const modal = document.getElementById('raffleAdminModal'); 1319 showRaffleWinnerSequence(raffleRevealNext.dataset.raffleRevealNext, JSON.parse(modal?.dataset.revealParticipants || '[]'), JSON.parse(modal?.dataset.revealWinners || '[]'), Number(modal?.dataset.revealIndex || 0) + 1, modal?.dataset.revealNotify !== 'false'); 1320 return; 1321 } 1322 const raffleReplay = event.target.closest('[data-raffle-replay]'); 1323 if (raffleReplay) { 1324 const modal = document.getElementById('raffleAdminModal'); 1325 const participants = JSON.parse(modal?.dataset.participants || '[]'); 1326 const response = await fetch(`${API_URL}/api/admin/raffles/${raffleReplay.dataset.raffleReplay}`, { credentials: 'include', cache: 'no-store' }); 1327 const data = response.ok ? await response.json() : null; 1328 if (data?.winners?.length) showRaffleWinnerSequence(raffleReplay.dataset.raffleReplay, data.participants || participants, data.winners, 0, false); 1329 return; 1330 } 1331 const raffleFinalize = event.target.closest('[data-raffle-finalize]'); 1332 if (raffleFinalize) { 1333 if (!confirm('Finalizar definitivamente este sorteio? Depois disso não será possÃvel puxar novos vencedores.')) return; 1334 raffleFinalize.disabled = true; 1335 fetch(`${API_URL}/api/admin/raffles/${raffleFinalize.dataset.raffleFinalize}/finalize`, { 1336 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' } 1337 }).then(async (response) => { 1338 if (!response.ok) throw new Error('finalize_failed'); 1339 document.getElementById('raffleAdminModal')?.remove(); 1340 document.body.classList.remove('reward-modal-open'); 1341 renderRaffles(); 1342 }).catch(() => { raffleFinalize.disabled = false; alert('Não foi possÃvel finalizar o sorteio.'); }); 1343 return; 1344 } 1345 const rankingType = event.target.closest('[data-ranking-type]'); 1346 if (rankingType) { 1347 paintRanking(rankingType.dataset.rankingType, 1); 1348 return; 1349 } 1350 const rankingPage = event.target.closest('[data-ranking-page]'); 1351 if (rankingPage && !rankingPage.disabled) { 1352 paintRanking(root.dataset.rankingType || 'totalHours', Number(rankingPage.dataset.rankingPage)); 1353 return; 1354 } 1355 const publicProfile = event.target.closest('[data-public-profile]'); 1356 if (publicProfile && !(event.ctrlKey || event.metaKey || event.shiftKey || event.button !== 0)) { 1357 event.preventDefault(); 1358 currentView = 'profile'; 1359 history.pushState({ view: 'store', storeView: 'profile' }, '', `/loja/perfil/${publicProfile.dataset.publicProfile}`); 1360 renderProfile(publicProfile.dataset.publicProfile); 1361 window.scrollTo({ top: 0, behavior: 'smooth' }); 1362 return; 1363 } 1364 // As ações da Caixa de entrada pertencem exclusivamente a
1364inbox-messages.js. 1365 const redeemRubini = event.target.closest('[data-redeem-rubini]'); 1366 if (redeemRubini) { 1367 const kekeCost = Number(redeemRubini.dataset.kekeCost).toLocaleString('pt-BR', { maximumFractionDigits: 2 }); 1368 const rubiniAmount = Number(redeemRubini.dataset.rubiniAmount).toLocaleString('pt-BR'); 1369 const cooldownDays = Number(redeemRubini.dataset.cooldownDays); 1370 if (!confirm(`Trocar ${kekeCost} KEKE Coins por ${rubiniAmount} Rubini Coins? O resgate entra na fila de entrega.`)) return; 1371 const status = root.querySelector('[data-redemption-status]'); 1372 redeemRubini.disabled = true; status.textContent = 'Registrando resgateâ¦'; 1373 fetch(`${API_URL}/api/shop/rubini/redeem`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ requestId: crypto.randomUUID().replaceAll('-', '') }) }) 1374 .then(async (response) => { const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error(result.error || 'redeem_failed'); return refreshAccount({ renderAfter: false }); }) 1375 .then(() => { status.textContent = 'Resgate registrado com segurança â'; setTimeout(renderRedemptions, 700); }) 1376 .catch((error) => { redeemRubini.disabled = false; status.textContent = error.message === 'daily_global_limit_reached' ? 'O limite global de hoje foi atingido.' : error.message === 'redemption_cooldown' ? `Você ainda está no intervalo de ${cooldownDays} dias.` : error.message === 'insufficient_keke_coins' ? 'Saldo insuficiente.' : 'Não foi possÃvel resgatar agora.'; }); 1377 return; 1378 } 1379 const openAllChests = event.target.closest('[data-open-all-chests]'); 1380 if (openAllChests) { 1381 const type = openAllChests.dataset.openAllChests; 1382 const quantity = Math.max(0, Number(account?.chests?.[type] || 0)); 1383 const output = root.querySelector(`[data-chest-result="${type}"]`); 1384 if (chestOpenInFlight.has(type) || quantity < 1) return; 1385 if (!confirm(`Abrir todos os ${quantity} ${quantity === 1 ? 'baú' : 'baús'} de ${chestMeta[type].label}? Cada abertura será sorteada e registrada individualmente.`)) return; 1386 chestOpenInFlight.add(type); 1387 syncChestCardState(type); 1388 output.textContent = `Processando ${quantity} ${quantity === 1 ? 'baú' : 'baús'}â¦`; 1389 fetch(`${API_URL}/api/chests/open-all`, { 1390 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, 1391 body: JSON.stringify({ chestType: type, requestId: crypto.randomUUID().replaceAll('-', '') }) 1392 }).then(async (response) => { 1393 const result = await response.json().catch(() => ({})); 1394 if (!response.ok) { 1395 const error = new Error(result.error || 'open_all_failed'); 1396 error.limit = result.limit; 1397 throw error; 1398 } 1399 output.textContent = `${Number(result.opened || 0)} baús processados com segurança â`; 1400 showChestBulkSummary(type, result); 1401 return refreshAccount({ renderAfter: false }); 1402 }).catch((error) => { 1403 output.textContent = error.message === 'bulk_open_limit_exceeded' 1404 ? `Há baús demais para uma única operação segura. Limite atual: ${Number(error.limit || 5000).toLocaleString('pt-BR')}.` 1405 : error.message === 'chest_not_available' ? 'Você não possui mais baús desse tipo.' : 'Não foi possÃvel abrir todos os baús agora.'; 1406 }).finally(() => { 1407 chestOpenInFlight.delete(type); 1408 syncChestCardState(type); 1409 }); 1410 return; 1411 } 1412 const openChest = event.target.closest('[data-open-chest]'); 1413 if (openChest) { 1414 const type = openChest.dataset.openChest; 1415 const output = root.querySelector(`[data-chest-result="${type}"]`); 1416 if (chestOpenInFlight.has(type)) return; 1417 chestOpenInFlight.add(type); 1418 syncChestCardState(type); 1419 output.textContent = 'Abrindoâ¦'; 1420 fetch(`${API_URL}/api/chests/open`, { 1421 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, 1422 body: JSON.stringify({ chestType: type, requestId: crypto.randomUUID().replaceAll('-', '') }) 1423 }).then(async (response) => { 1424 const result = await response.json().catch(() => ({})); 1425 if (!response.ok) throw new Error(result.error || 'open_failed'); 1426 output.textContent = 'Prêmio sorteado â'; 1427 showChestOpening(type, result.reward); 1428 return refreshAccount({ renderAfter: false }); 1429 }).catch(() => { 1430 output.textContent = 'Não foi possÃvel abrir o baú agora.'; 1431 }).finally(() => { 1432 chestOpenInFlight.delete(type); 1433 syncChestCardState(type); 1434 }); 1435 return; 1436 } 1437 if (event.target.closest('[data-identity-prev]')) updateIdentityCarousel(identityIndex - 1); 1438 if (event.target.closest('[data-identity-next]')) updateIdentityCarousel(identityIndex + 1); 1439 const identityDot = event.target.closest('[data-identity-dot]'); 1440 if (identityDot) updateIdentityCarousel(Number(identityDot.dataset.identityDot)); 1441 const auth = event.target.closest('[data-auth="twitch"]'); 1442 if (auth && !account?.has_twitch) { 1443 window.location.href = `${API_URL}
1443/auth/twitch`; 1444 return; 1445 } 1446 const googleAuth = event.target.closest('[data-auth="google"]'); 1447 if (googleAuth && !account?.has_youtube) { 1448 window.location.href = `${API_URL}/auth/google`; 1449 return; 1450 } 1451 const logout = event.target.closest('[data-logout]'); 1452 if (logout) { 1453 logout.disabled = true; 1454 logout.textContent = 'Saindoâ¦'; 1455 fetch(`${API_URL}/auth/logout`, { method: 'POST', credentials: 'include' }) 1456 .then((response) => response.ok ? response.json() : Promise.reject()) 1457 .then(() => { 1458 account = null; 1459 history.replaceState({ view: 'store', storeView: 'panel' }, '', storePaths.panel); 1460 renderPanel(); 1461 }) 1462 .catch(() => { 1463 logout.disabled = false; 1464 logout.textContent = 'Não foi possÃvel sair'; 1465 }); 1466 return; 1467 } 1468 const providerDisconnect = event.target.closest('[data-disconnect-provider]'); 1469 if (providerDisconnect) { 1470 const provider = providerDisconnect.dataset.disconnectProvider; 1471 const label = provider === 'twitch' ? 'Twitch' : 'YouTube'; 1472 if (!confirm(`Desvincular ${label}? Essa identidade começará zerada se for usada novamente. O saldo ficará somente na conta que permanecer conectada.`)) return; 1473 providerDisconnect.disabled = true; 1474 providerDisconnect.textContent = 'Desconectandoâ¦'; 1475 fetch(`${API_URL}/api/me/identities/${provider}`, { method: 'DELETE', credentials: 'include' }) 1476 .then((response) => response.ok ? response.json() : Promise.reject()) 1477 .then(() => refreshAccount()) 1478 .catch(() => { 1479 providerDisconnect.disabled = false; 1480 providerDisconnect.textContent = `Não foi possÃvel desconectar ${label}`; 1481 }); 1482 return; 1483 } 1484 const deleteAccount = event.target.closest('[data-delete-account]'); 1485 if (deleteAccount) { 1486 const confirmation = prompt('Esta ação remove seu acesso e não pode ser desfeita. Digite EXCLUIR para confirmar:'); 1487 if (confirmation !== 'EXCLUIR') return; 1488 deleteAccount.disabled = true; 1489 fetch(`${API_URL}/api/me/account`, { method: 'DELETE', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirmation }) }) 1490 .then((response) => response.ok ? response.json() : Promise.reject()) 1491 .then(() => { account = null; open('panel'); }) 1492 .catch(() => { deleteAccount.disabled = false; deleteAccount.textContent = 'Não foi possÃvel excluir'; }); 1493 return; 1494 } 1495 if (event.target.closest('[data-generate-pair]')) { 1496 const button = event.target.closest('[data-generate-pair]'); 1497 const output = root.querySelector('[data-pair-code]'); 1498 button.disabled = true; 1499 button.textContent = 'Gerandoâ¦'; 1500 fetch(`${API_URL}/api/extension/pair`, { method: 'POST', credentials: 'include' }) 1501 .then((response) => response.ok ? response.json() : Promise.reject()) 1502 .then((result) => { 1503 account.extension_connected = false; 1504 output.textContent = result.code; 1505 output.hidden = false; 1506 button.textContent = 'Gerar outro código'; 1507 const extensionStatus = root.querySelectorAll('.status-item')[1]?.querySelector('.status-pending'); 1508 if (extensionStatus) extensionStatus.textContent = 'Desconectada â aguardando novo código'; 1509 root.querySelector('[data-disconnect-extension]')?.remove(); 1510 const note = root.querySelector('.extension-pair-box small'); 1511 if (note) note.textContent = 'A extensão anterior foi desconectada. Digite este código na extensão; o painel atualizará sozinho.'; 1512 monitorPairing(); 1513 }) 1514 .catch(() => { button.textContent = 'Não foi possÃvel gerar'; }) 1515 .finally(() => { button.disabled = false; }); 1516 } 1517 if (event.target.closest('[data-disconnect-extension]')) { 1518 const button = event.target.closest('[data-disconnect-extension]'); 1519 button.disabled = true; 1520 button.textContent = 'Desconectandoâ¦'; 1521 fetch(`${API_URL}/api/extension/disconnect`, { method: 'POST', credentials: 'include' }) 1522 .then((response) => response.ok ? response.json() : Promise.reject()) 1523 .then(() => { 1524 stopPairingMonitor(); 1525 account.extension_connected = false;
1526 renderPanel(); 1527 }) 1528 .catch(() => { 1529 button.disabled = false; 1530 button.textContent = 'Não foi possÃvel desconectar'; 1531 }); 1532 } 1533 const resetButton = event.target.closest('[data-admin-reset]'); 1534 if (resetButton) { 1535 const scope = resetButton.dataset.adminReset; 1536 const targetMode = root.querySelector('[data-reset-target].active')?.dataset.resetTarget || 'all'; 1537 const targetUserId = targetMode === 'user' ? root.querySelector('[data-selected-reset-user]')?.value : ''; 1538 if (targetMode === 'user' && !targetUserId) { root.querySelector('[data-admin-status]').textContent = 'Selecione um usuário antes de continuar.'; return; } 1539 const confirmationInput = prompt(scope === 'chests' ? 'Esta ação zera todos os baús disponÃveis no escopo selecionado, sem apagar o histórico. Digite RESETAR para confirmar:' : scope === 'chest_history_30d' ? 'Esta ação zera somente o histórico de baús abertos nos últimos 30 dias exibido no perfil, sem remover recompensas nem registros de auditoria. Digite RESETAR para confirmar:' : 'Esta ação altera dados reais. Digite RESETAR para confirmar:'); 1540 const confirmation = normalizeAdminResetConfirmation(confirmationInput); 1541 const status = root.querySelector('[data-admin-status]'); 1542 if (confirmationInput !== null && confirmation !== 'RESETAR') { status.textContent = 'Digite RESETAR para confirmar a redefinição.'; return; } 1543 if (confirmation !== 'RESETAR') return; 1544 const originalText = resetButton.textContent; 1545 resetButton.disabled = true; 1546 resetButton.textContent = 'Processandoâ¦'; 1547 status.textContent = 'Processandoâ¦'; 1548 fetch(`${API_URL}/api/admin/rewards/reset`, { 1549 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, 1550 body: JSON.stringify({ scope, confirmation, targetMode, targetUserId: targetUserId || null }) 1551 }).then(readAdminResetResponse) 1552 .then(() => { 1553 status.textContent = 'Reinicialização concluÃda â'; 1554 showTemporaryButtonState(resetButton, 'ConcluÃdo â', originalText); 1555 window.setTimeout(() => { 1556 if (!status.isConnected) return; 1557 status.textContent = targetMode === 'user' 1558 ? 'Modo atual: somente o usuário selecionado.' 1559 : 'Modo atual: todos os usuários do site.'; 1560 }, 3000); 1561 }) 1562 .catch((error) => { 1563 status.textContent = adminResetErrorMessage(error); 1564 resetButton.disabled = false; 1565 resetButton.textContent = originalText; 1566 }); 1567 return; 1568 } 1569 const messageTarget = event.target.closest('[data-message-target]'); 1570 if (messageTarget) { 1571 root.querySelectorAll('[data-message-target]').forEach((button) => button.classList.toggle('active', button === messageTarget)); 1572 const isUserTarget = messageTarget.dataset.messageTarget === 'user'; 1573 const form = document.getElementById('messageUserSearchForm'); 1574 form.hidden = !isUserTarget; 1575 form.setAttribute('aria-hidden', String(!isUserTarget)); 1576 document.getElementById('messageUserSearchResults').innerHTML = ''; 1577 root.querySelector('[data-selected-message-user]').value = ''; 1578 return; 1579 } 1580 const selectMessageUser = event.target.closest('[data-select-message-user]'); 1581 if (selectMessageUser) { 1582 root.querySelector('[data-selected-message-user]').value = selectMessageUser.dataset.selectMessageUser; 1583 root.querySelectorAll('[data-select-message-user]').forEach((button) => { 1584 button.classList.toggle('selected', button === selectMessageUser); 1585 button.textContent = button === selectMessageUser ? 'Selecionado â' : 'Selecionar'; 1586 }); 1587 return; 1588 } 1589 const resetTarget = event.target.closest('[data-reset-target]'); 1590 if (resetTarget) { 1591 root.querySelectorAll('[data-reset-target]').forEach((button) => button.classList.toggle('active', button === resetTarget)); 1592 const form = document.getElementById('resetUserSearchForm'); 1593 const isUserTarget = resetTarget.dataset.resetTarget === 'user'; 1594 form.hidden = !isUserTarget; 1595 form.setAttribute('aria-hidden', String(!isUserTarget)); 1596 document.getElementById('resetUserSearchResults').innerHTML = ''; 1597 root.querySelector('[data-selected-reset-user]').value = ''; 1598 const status = root.querySelector('[data-admin-status]'); 1599 if (status) status.textContent = isUserTarget 1600 ? 'Modo atual: somente o usuário que você selecionar.' 1601 : 'Modo atual: todos os usuários do site.'; 1602 return; 1603 } 1604 const shopReset = event.target.closest('[data-shop-reset]'); 1605 if (shopReset) { 1606 const confirmationInput = prompt('Digite RESETAR para confirmar esta liberação da loja:'); 1607 const confirmation = normalizeAdminResetConfirmation(confirmationInput); 1608 const status = root.querySelector('[data-admin-status]'); 1609 if (confirmationInput !== null && confirmation !== 'RESETAR') { if (status) status.textContent = 'Digite RESETAR para confirmar a redefinição da loja.'; return; } 1610 if (confirmation !== 'RESETAR') return; 1611 const originalText = shopReset.textContent; 1612 shopReset.disabled = true; 1613 fetch(`${API_URL}
1613/api/admin/shop/reset`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope: shopReset.dataset.shopReset, confirmation }) }) 1614 .then(readAdminResetResponse) 1615 .then(() => { if (status) status.textContent = 'Redefinição da loja concluÃda â'; showTemporaryButtonState(shopReset, 'ConcluÃdo â', originalText); }) 1616 .catch((error) => { if (status) status.textContent = adminResetErrorMessage(error); shopReset.disabled = false; shopReset.textContent = originalText; }); 1617 return; 1618 } 1619 const selectResetUser = event.target.closest('[data-select-reset-user]'); 1620 if (selectResetUser) { 1621 root.querySelector('[data-selected-reset-user]').value = selectResetUser.dataset.selectResetUser; 1622 root.querySelectorAll('[data-select-reset-user]').forEach((button) => { button.classList.toggle('selected', button === selectResetUser); button.textContent = button === selectResetUser ? 'Selecionado â' : 'Selecionar'; }); 1623 return; 1624 } 1625 const suspendUser = event.target.closest('[data-suspend-user]'); 1626 if (suspendUser) { 1627 const suspended = suspendUser.dataset.suspended !== 'true'; 1628 suspendUser.disabled = true; 1629 fetch(`${API_URL}/api/admin/rewards/users/${suspendUser.dataset.suspendUser}/suspension`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ suspended }) }) 1630 .then((response) => response.ok ? response.json() : Promise.reject()) 1631 .then(() => { suspendUser.dataset.suspended = String(suspended); suspendUser.textContent = suspended ? 'Liberar usuário' : 'Banir usuário'; suspendUser.disabled = false; }) 1632 .catch(() => { suspendUser.disabled = false; suspendUser.textContent = 'Ação não concluÃda'; }); 1633 return; 1634 } 1635 const resetCycle = event.target.closest('[data-reset-cycle]'); 1636 if (resetCycle) { 1637 if (!confirm('Repor o estoque dos prêmios limitados deste ciclo?')) return; 1638 fetch(`${API_URL}/api/admin/rewards/reset-cycle`, { 1639 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, 1640 body: JSON.stringify({ chestType: resetCycle.dataset.resetCycle }) 1641 }).then(readAdminResetResponse) 1642 .then(() => { 1643 const selector = resetCycle.dataset.resetCycle === 'all' 1644 ? '.tier-stock' : `[data-tier-form="${resetCycle.dataset.resetCycle}"] .tier-stock`; 1645 root.querySelectorAll(selector).forEach((stock) => { stock.textContent = 'Entregues: 0'; }); 1646 const status = root.querySelector('[data-admin-status]'); 1647 if (status) status.textContent = 'Estoque reposto â entregues voltou para 0 â'; 1648 setTimeout(renderAdmin, 500); 1649 }) 1650 .catch((error) => { const status = root.querySelector('[data-admin-status]'); if (status) status.textContent = adminResetErrorMessage(error); else alert(adminResetErrorMessage(error)); }); 1651 return; 1652 } 1653 const bulkLegendaryGiftButton = event.target.closest('[data-gift-legendary-all]'); 1654 if (bulkLegendaryGiftButton) { 1655 const quantityInput = document.getElementById('legendaryGiftAllQuantity'); 1656 const quantity = Number(quantityInput?.value); 1657 const status = root.querySelector('[data-gift-legendary-all-status]'); 1658 if (!Number.isInteger(quantity) || quantity < 1 || quantity > 100) { 1659 if (status) status.textContent = 'Informe uma quantidade inteira entre 1 e 100.'; 1660 return; 1661 } 1662 const confirmation = prompt(`Você vai enviar ${quantity} Baú${quantity === 1 ? '' : 's'} Lendário${quantity === 1 ? '' : 's'} para TODOS os usuários cadastrados no site. Digite CONFIRMAR para continuar:`); 1663 if (confirmation !== 'CONFIRMAR') { 1664 if (status) status.textContent = 'Envio cancelado.'; 1665 return; 1666 } 1667 const originalText = bulkLegendaryGiftButton.textContent; 1668 const requestId = crypto.randomUUID().replaceAll('-', ''); 1669 bulkLegendaryGiftButton.disabled = true; 1670 bulkLegendaryGiftButton.textContent = 'Enviandoâ¦'; 1671 if (status) status.textContent = 'Processando a entrega para todos os usuáriosâ¦'; 1672 fetch(`${API_URL}
1672/api/admin/rewards/gift-legendary-all`, { 1673 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, 1674 body: JSON.stringify({ quantity, requestId }) 1675 }).then(async (response) => { 1676 const payload = await response.json().catch(() => ({})); 1677 if (!response.ok) throw new Error(payload.error || 'bulk_gift_failed'); 1678 return payload; 1679 }).then((result) => { 1680 bulkLegendaryGiftButton.disabled = false; 1681 bulkLegendaryGiftButton.textContent = originalText; 1682 if (status) status.textContent = `${Number(result.recipientCount || 0)} usuário(s) receberam ${quantity} Baú${quantity === 1 ? '' : 's'} Lendário${quantity === 1 ? '' : 's'} cada.`; 1683 refreshAccount({ renderAfter: false }); 1684 }).catch(() => { 1685 bulkLegendaryGiftButton.disabled = false; 1686 bulkLegendaryGiftButton.textContent = originalText; 1687 if (status) status.textContent = 'Não foi possÃvel concluir o envio. Nenhuma nova tentativa é feita automaticamente.'; 1688 }); 1689 return; 1690 } 1691 const giftButton = event.target.closest('[data-gift-legendary]'); 1692 if (giftButton) { 1693 const quantity = Number(prompt('Quantos Baús Lendários deseja enviar?', '1')); 1694 if (!Number.isInteger(quantity) || quantity < 1) return; 1695 const originalText = giftButton.textContent; 1696 giftButton.disabled = true; 1697 fetch(`${API_URL}/api/admin/rewards/gift-legendary`, { 1698 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, 1699 body: JSON.stringify({ userId: giftButton.dataset.giftLegendary, quantity }) 1700 }).then((response) => response.ok ? response.json() : Promise.reject()) 1701 .then(() => { showTemporaryButtonState(giftButton, 'Presente enviado â', originalText); }) 1702 .catch(() => { giftButton.disabled = false; giftButton.textContent = originalText; }); 1703 return; 1704 } 1705 const allChestsButton = event.target.closest('[data-gift-all-chests]'); 1706 if (allChestsButton) { 1707 if (!confirm('Enviar 1 Baú Twitch, 1 Baú Twitch Sub, 1 Baú YouTube e 1 Baú Lendário para este usuário?')) return; 1708 const originalText = allChestsButton.textContent; 1709 allChestsButton.disabled = true; 1710 fetch(`${API_URL}/api/admin/rewards/gift-all-chests`, { 1711 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, 1712 body: JSON.stringify({ userId: allChestsButton.dataset.giftAllChests, quantity: 1, requestId: crypto.randomUUID().replaceAll('-', '') }) 1713 }).then((response) => response.ok ? response.json() : Promise.reject()) 1714 .then((result) => { 1715 showTemporaryButtonState(allChestsButton, '4 baús enviados â', originalText); 1716 if (result.userId === account.id) refreshAccount({ renderAfter: false }); 1717 }) 1718 .catch(() => { allChestsButton.disabled = false; allChestsButton.textContent = originalText; }); 1719 return; 1720 } 1721 const coinGiftButton = event.target.closest('[data-gift-coins]'); 1722 if (coinGiftButton) { 1723 const rawAmount = prompt('Quantas KEKE Coins deseja enviar? Você pode usar vÃrgula ou ponto.', '10'); 1724 if (rawAmount === null) return; 1725 const amount = parseDecimal(rawAmount); 1726 if (!Number.isFinite(amount) || amount <= 0 || Math.abs(Math.round(amount * 100) - amount * 100) > 0.000001) { 1727 alert('Informe uma quantidade positiva com no máximo duas casas decimais.'); 1728 return; 1729 } 1730 const originalText = coinGiftButton.textContent; 1731 coinGiftButton.disabled = true; 1732 fetch(`${API_URL}/api/admin/rewards/gift-coins`, { 1733 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, 1734 body: JSON.stringify({ userId: coinGiftButton.dataset.giftCoins, amount, requestId: crypto.randomUUID().replaceAll('-', '') }) 1735 }).then((response) => response.ok ? response.json() : Promise.reject()) 1736 .then((result) => { 1737 showTemporaryButtonState(coinGiftButton, `${qq(result.amount)} KEKE enviadas â`, originalText); 1738 if (result.userId === account.id) refreshAccount({ renderAfter: false }); 1739 }) 1740 .catch(() => { coinGiftButton.disabled = false; coinGiftButton.textContent = originalText; }); 1741 return; 1742 } 1743 const raffleRefundClose = event.target.closest('[data-raffle-refund-close]'); 1744 if (raffleRefundClose) { 1745 const raffleId = String(raffleRefundClose.dataset.raffleRefundClose || ''); 1746 if (!raffleId) return; 1747 if (!window.confirm('Fechar este sorteio sem vencedores e devolver todos os KEKE Coins usados na compra de tickets? Esta ação não pode ser desfeita.')) return; 1748 const originalText = raffleRefundClose.textContent; 1749 raffleRefundClose.disabled = true; 1750 raffleRefundClose.textContent = 'Reembolsandoâ¦'; 1751 fetch(`${API_URL}
1751/api/admin/raffles/${raffleId}/refund-close`, { 1752 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: '{}' 1753 }).then(async (response) => { 1754 const result = await response.json().catch(() => ({})); 1755 if (!response.ok) throw new Error(result.error || 'raffle_refund_failed'); 1756 const refundedUsers = Number(result.refundedUsers || 0); 1757 const refundedTotal = Number(result.refundedTotal || 0); 1758 alert(`Sorteio finalizado sem vencedores. ${refundedUsers} participante(s) recebeu/receberam ${qq(refundedTotal)} KEKE Coins de volta.`); 1759 document.getElementById('raffleAdminModal')?.remove(); 1760 if (currentView === 'admin') renderAdmin(); 1761 else renderRaffles(); 1762 }).catch((error) => { 1763 raffleRefundClose.disabled = false; 1764 raffleRefundClose.textContent = originalText; 1765 const messages = { 1766 raffle_already_has_winners: 'Este sorteio já possui vencedor(es) e não pode ser encerrado com reembolso.', 1767 raffle_already_finalized: 'Este sorteio já foi finalizado.', 1768 raffle_not_found: 'Sorteio não encontrado.' 1769 }; 1770 alert(messages[error.message] || 'Não foi possÃvel fechar e reembolsar o sorteio.'); 1771 }); 1772 return; 1773 } 1774 const raffleStatus = event.target.closest('[data-raffle-status]'); 1775 if (raffleStatus) { 1776 fetch(`${API_URL}/api/admin/raffles/${raffleStatus.dataset.raffleId}/status`, { 1777 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, 1778 body: JSON.stringify({ status: raffleStatus.dataset.raffleStatus }) 1779 }).then((response) => { 1780 if (!response.ok) return response.json().catch(() => ({})).then((error) => Promise.reject(error)); 1781 if (raffleStatus.hasAttribute('data-raffle-modal-action')) openRaffleConfiguration(raffleStatus.dataset.raffleId); 1782 else renderAdmin(); 1783 }).catch((error) => alert(error?.error === 'raffle_already_has_winners' ? 'Este sorteio já tem vencedores e não pode ser reaberto.' : 'Não foi possÃvel alterar o sorteio. Atualize a página e tente novamente.')); 1784 return; 1785 } 1786 const raffleDelete = event.target.closest('[data-raffle-delete]'); 1787 if (raffleDelete) { 1788 const deletingHistory = raffleDelete.hasAttribute('data-raffle-history-delete'); 1789 const deleteMessage = deletingHistory 1790 ? 'Excluir este sorteio do histórico? KEKE Coins e prêmios já processados não serão alterados.' 1791 : 'Excluir este sorteio definitivamente? Como ele ainda não foi finalizado, as KEKE Coins gastas em tickets serão reembolsadas automaticamente.'; 1792 if (!confirm(deleteMessage)) return; 1793 raffleDelete.disabled = true; 1794 fetch(`${API_URL}/api/admin/raffles/${raffleDelete.dataset.raffleDelete}`, { 1795 method: 'DELETE', credentials: 'include' 1796 }).then((response) => response.ok ? response.json() : Promise.reject()) 1797 .then(() => { 1798 document.getElementById('raffleAdminModal')?.remove(); 1799 document.body.classList.remove('reward-modal-open'); 1800 renderAdmin(); 1801 }) 1802 .catch(() => { raffleDelete.disabled = false; alert('Não foi possÃvel excluir o sorteio.'); }); 1803 return; 1804 } 1805 const raffleDraw = event.target.closest('[data-raffle-draw]'); 1806 if (raffleDraw) { 1807 const winnerCount = Number(prompt('Quantos vencedores únicos deseja sortear?', '1')); 1808 if (!Number.isInteger(winnerCount) || winnerCount < 1) return; 1809 if (!confirm(`Confirmar o sorteio de ${winnerCount} vencedor(es)? O mesmo usuário não poderá ganhar duas vezes.`)) return; 1810 fetch(`${API_URL}/api/admin/raffles/${raffleDraw.dataset.raffleDraw}/draw`, { 1811 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, 1812 body: JSON.stringify({ winnerCount }) 1813 }).then(async (response) => { 1814 const result = await response.json(); 1815 if (!response.ok) throw new Error(result.error); 1816 alert(`Vencedores:\n${result.winners.map((winner) => `${winner.position}
1816º ${winner.display_name}`).join('\n')}`); 1817 renderAdmin(); 1818 }).catch(() => alert('Não foi possÃvel realizar o sorteio. Verifique a quantidade de participantes únicos.')); 1819 return; 1820 } 1821 }; 1822 root.addEventListener('click', handleStoreClick); 1823 root.addEventListener('click', (event) => { 1824 if (!(event.target instanceof Element)) return; 1825 const adminAction = event.target.closest('[data-gift-all-chests],[data-gift-legendary],[data-gift-coins],[data-admin-reset],[data-reset-cycle],[data-shop-reset],[data-suspend-user]'); 1826 if (!adminAction || !root.contains(adminAction)) return; 1827 event.preventDefault(); 1828 event.stopImmediatePropagation(); 1829 void handleStoreClick(event); 1830 }, true); 1831 root.addEventListener('input', (event) => { 1832 const form = event.target.closest('.tier-form'); 1833 if (form) updateTierTotal(form); 1834 if (event.target.matches('#raffleAdminForm [name=\"ticketCost\"]')) { const hint = event.target.closest('#raffleAdminForm')?.querySelector('[data-free-raffle-admin-hint]'); if (hint) hint.hidden = !(Number.isFinite(parseDecimal(event.target.value)) && parseDecimal(event.target.value) === 0); } 1835 }); 1836 root.addEventListener('change', (event) => { 1837 if (event.target.matches('[data-raffle-prize-type]')) { 1838 const form = event.target.closest('#raffleAdminForm'); 1839 const type = event.target.value; 1840 const description = form?.querySelector('[data-raffle-description]'); 1841 const rubini = form?.querySelector('[data-raffle-rubini]'); 1842 if (description) { 1843 description.hidden = type !== 'manual'; 1844 description.querySelector('input').required = type === 'manual'; 1845 } 1846 if (rubini) { 1847 rubini.hidden = type !== 'rubini_coins'; 1848 rubini.querySelector('input').required = type === 'rubini_coins'; 1849 } 1850 return; 1851 } 1852 if (event.target.matches('[data-ranking-page-size]')) { 1853 sessionStorage.setItem('keke-ranking-page-size', event.target.value); 1854 paintRanking(root.dataset.rankingType || 'totalHours', 1); 1855 } 1856 }); 1857 root.addEventListener('submit', async (event) => { 1858 if (event.target.matches('[data-reminder-form]')) { 1859 event.preventDefault(); 1860 const form = event.target; const status = form.querySelector('.profile-save-status'); 1861 status.textContent = 'Salvandoâ¦'; 1862 const response = await fetch(`${API_URL}/api/me/reminders`, { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ weekly: form.elements.weekly.checked, monthly: form.elements.monthly.checked }) }); 1863 status.textContent = response.ok ? 'Lembretes salvos â' : 'Não foi possÃvel salvar.'; 1864 return; 1865 } 1866 const raffleBuy = event.target.closest('[data-raffle-buy]'); 1867 if (raffleBuy) { 1868 event.preventDefault(); 1869 const status = raffleBuy.querySelector('.profile-save-status'); 1870 const isFreeRaffle = raffleBuy.dataset.freeRaffle === 'true'; 1871 const quantity = isFreeRaffle ? 1 : Number(raffleBuy.elements.quantity?.value); 1872 status.textContent = isFreeRaffle ? 'Confirmando participaçãoâ¦' : 'Comprandoâ¦'; 1873 try { 1874 const response = await fetch(`${API_URL}/api/raffles/${raffleBuy.dataset.raffleBuy}/entries`, { 1875 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, 1876 body: JSON.stringify({ quantity, requestId: crypto.randomUUID().replaceAll('-', '') }) 1877 }); 1878 const result = await response.json().catch(() => ({})); 1879 if (!response.ok) throw new Error(result.error || 'purchase_failed'); 1880 status.textContent = isFreeRaffle ? 'Participação gratuita confirmada â' : `${result.ticketsAdded} ticket(s) comprado(s) â`; 1881 await refreshAccount({ renderAfter: false }); 1882 setTimeout(renderRaffles, 700); 1883 } catch (error) { 1884 status.textContent = error.message === 'free_raffle_already_entered' ? 'Você já está participando deste sorteio.' : error.message === 'insufficient_qq_coins' ? 'Saldo de KEKE Coins insuficiente.' : (isFreeRaffle ? 'Não foi possÃvel confirmar sua participação.' : 'Não foi possÃvel comprar.'); 1885 } 1886 return; 1887 } 1888 const tierForm = event.target.closest('[data-tier-form]'); 1889 if (tierForm) { 1890 event.preventDefault(); 1891 const status = tierForm.querySelector('.profile-save-status'); 1892 const rows = [...tierForm.querySelectorAll('[data-tier-id]')]; 1893 const tiers = rows.map((row) => ({ 1894 id: row.dataset.tierId, label: row.querySelector('[name="label"]').value, 1895 qqCoins: parseDecimal(row.querySelector('[name="qqCoins"]').value), 1896 probability: parseDecimal(row.querySelector('[name="probability"]').value), 1897 cycleLimit: Number(row.querySelector('[name="cycleLimit"]').value), isActive: true 1898 })); 1899 const total = tiers.reduce((sum, tier) => sum + tier.probability, 0); 1900 if (Math.abs(total - 100) >= 0.00005) { 1901 status.textContent = `Corrija o total: ${total.toFixed(4)}%.`; 1902 return; 1903 } 1904 status.textContent = 'Salvandoâ¦'; 1905 const response = await fetch(`${API_URL}
1905/api/admin/rewards/tiers/${tierForm.dataset.tierForm}`, { 1906 method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tiers }) 1907 }); 1908 if (response.ok) { 1909 status.textContent = 'Valores e probabilidades salvos â'; 1910 root.dataset.chestConfigUpdatedAt = String(Date.now()); 1911 } else { 1912 const error = await response.json().catch(() => ({})); 1913 status.textContent = error.error === 'invalid_probability_total' 1914 ? `A soma precisa ser 100%. Diferença: ${Number(error.difference || 0).toLocaleString('pt-BR')}%.` 1915 : 'Não foi possÃvel salvar.'; 1916 } 1917 return; 1918 } 1919 if (event.target.id === 'legendarySearchForm') { 1920 event.preventDefault(); 1921 const query = event.target.elements.query.value.trim(); 1922 const output = document.getElementById('legendarySearchResults'); 1923 output.textContent = 'Pesquisandoâ¦'; 1924 const response = await fetch(`${API_URL}/api/admin/rewards/users?q=${encodeURIComponent(query)}`, { credentials: 'include', cache: 'no-store' }); 1925 const result = response.ok ? await response.json() : { users: [] }; 1926 output.innerHTML = result.users.length ? result.users.map((user) => `<div><div><strong>${escapeHtml(user.display_name)}</strong><span>${escapeHtml(user.game_character_name || 'Sem nick de personagem')}</span></div><div class="admin-search-actions"><button type="button" data-gift-all-chests="${user.id}">Kit teste: 1 de cada</button><button type="button" data-gift-legendary="${user.id}">Baú Lendário</button><button type="button" data-gift-coins="${user.id}">KEKE Coins</button></div></div>`).join('') : '<p>Nenhum usuário encontrado.</p>'; 1927 return; 1928 } 1929 if (['resetUserSearchForm', 'banUserSearchForm', 'messageUserSearchForm'].includes(event.target.id)) { 1930 event.preventDefault(); 1931 const isBan = event.target.id === 'banUserSearchForm'; 1932 const isMessage = event.target.id === 'messageUserSearchForm'; 1933 const output = document.getElementById(isBan ? 'banUserSearchResults' : isMessage ? 'messageUserSearchResults' : 'resetUserSearchResults'); 1934 const query = event.target.elements.query.value.trim(); 1935 output.textContent = 'Pesquisandoâ¦'; 1936 const response = await fetch(`${API_URL}/api/admin/rewards/users?q=${encodeURIComponent(query)}`, { credentials: 'include', cache: 'no-store' }); 1937 const result = response.ok ? await response.json() : { users: [] }; 1938 output.innerHTML = result.users.length ? result.users.map((user) => `<div><div><strong>${escapeHtml(user.display_name)}</strong><span>Twitch: ${escapeHtml(user.twitch_username || 'â')} · YouTube: ${escapeHtml(user.youtube_username || 'â')} · Personagem: ${escapeHtml(user.game_character_name || 'â')}</span></div><div class="admin-search-actions">${isBan ? `<button type="button" data-suspend-user="${user.id}" data-suspended="${Boolean(user.is_suspended)}">${user.is_suspended ? 'Liberar usuário' : 'Banir usuário'}</button>` : isMessage ? `<button type="button" data-select-message-user="${user.id}">Selecionar</button>` : `<button type="button" data-select-reset-user="${user.id}">Selecionar</button>`}</div></div>`).join('') : '<p>Nenhum usuário encontrado.</p>'; 1939 return; 1940 } 1941 if (event.target.id === 'raffleAdminForm') { 1942 event.preventDefault(); 1943 const form = event.target; 1944 const status = form.querySelector('.profile-save-status'); 1945 status.textContent = 'Criandoâ¦'; 1946 const response = await fetch(`${API_URL}/api/admin/raffles`, { 1947 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, 1948 body: JSON.stringify({ title: form.elements.title.value, description: form.elements.prizeType.value === 'manual' ? form.elements.description.value : '', ticketCost: parseDecimal(form.elements.ticketCost.value), prizeType: form.elements.prizeType.value, raffleType: form.elements.raffleType.value, prizeQuantity: 1, prizeRubiniAmount: form.elements.prizeType.value === 'rubini_coins' ? Number(form.elements.prizeRubiniAmount.value) : 0, configuredWinners: Number(form.elements.configuredWinners.value), autoCreateNext: form.elements.autoCreateNext.checked }) 1949 }); 1950 if (response.ok) { 1951 renderAdmin(); 1952 } else { 1953 const result = await response.json().catch(() => ({})); 1954 const messages = { 1955 invalid_raffle: 'Confira o nome, custo, número de vencedores e valor do prÃ
1955ªmio.', 1956 forbidden: 'Sua sessão administrativa expirou. Entre novamente.', 1957 unauthorized: 'Sua sessão expirou. Entre novamente.' 1958 }; 1959 status.textContent = messages[result.error] || 'Não foi possÃvel criar o sorteio agora.'; 1960 } 1961 return; 1962 } 1963 if (event.target.id === 'adminSettingsForm') { 1964 event.preventDefault(); 1965 const form = event.target; 1966 const status = form.querySelector('.profile-save-status'); 1967 const body = Object.fromEntries(new FormData(form).entries()); 1968 for (const key of Object.keys(body)) body[key] = Number(body[key]); 1969 status.textContent = 'Salvandoâ¦'; 1970 try { 1971 const response = await fetch(`${API_URL}/api/admin/rewards/rules`, { 1972 method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) 1973 }); 1974 if (!response.ok) throw new Error('save_failed'); 1975 status.textContent = 'Regras salvas â'; 1976 } catch (_) { status.textContent = 'Não foi possÃvel salvar.'; } 1977 return; 1978 } 1979 if (event.target.id === 'adminShopSettingsForm') { 1980 event.preventDefault(); 1981 const form = event.target; 1982 const status = form.querySelector('.profile-save-status'); 1983 status.textContent = 'Salvandoâ¦'; 1984 const response = await fetch(`${API_URL}/api/admin/shop/settings`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 1985 rubiniAmount: Number(form.elements.rubiniAmount.value), 1986 kekeCost: Number(form.elements.kekeCost.value), 1987 cooldownDays: Number(form.elements.cooldownDays.value), 1988 dailyLimit: Number(form.elements.dailyLimit.value) 1989 }) }); 1990 status.textContent = response.ok ? 'Loja salva â' : 'Não foi possÃvel salvar.'; 1991 return; 1992 } 1993 if (event.target.id === 'adminMessageForm') { 1994 event.preventDefault(); 1995 const form = event.target; 1996 const status = form.querySelector('.profile-save-status'); 1997 const submit = form.querySelector('button[type="submit"]'); 1998 const targetMode = root.querySelector('[data-message-target].active')?.dataset.messageTarget || 'all'; 1999 const targetUserId = targetMode === 'user' ? root.querySelector('[data-selected-message-user]')?.value : ''; 2000 if (targetMode === 'user' && !targetUserId) { 2001 status.textContent = 'Selecione um usuário antes de enviar.'; 2002 return; 2003 } 2004 submit.disabled = true; 2005 status.textContent = targetMode === 'all' ? 'Enviando para todosâ¦' : 'Enviandoâ¦'; 2006 const response = await fetch(`${API_URL}/api/admin/messages`, { 2007 method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, 2008 body: JSON.stringify({ 2009 targetMode, targetUserId: targetUserId || null, 2010 messageType: form.elements.messageType.value, 2011 title: form.elements.title.value, 2012 content: form.elements.content.value, 2013 actionLabel: form.elements.actionLabel.value, 2014 actionUrl: form.elements.actionUrl.value 2015 }) 2016 }); 2017 const result = await response.json().catch(() => ({})); 2018 submit.disabled = false; 2019 if (!response.ok) { 2020 status.textContent = result.error === 'invalid_action_url' ? 'Use um link HTTPS válido.' : 'Não foi possÃvel enviar a mensagem.'; 2021 return; 2022 } 2023 status.textContent = `Mensagem enviada para ${Number(result.recipients)} usuário(s) â`; 2024 form.reset(); 2025 window.setTimeout(() => { if (status.isConnected) status.textContent = ''; }, 4000); 2026 return; 2027 } 2028 if (event.target.id !== 'profileForm') return; 2029 event.preventDefault(); 2030 const form = event.target; 2031 const status = form.querySelector('.profile-save-status'); 2032 const submit = form.querySelector('button[type="submit"]'); 2033 submit.disabled = true; 2034 status.textContent = 'Salvandoâ¦'; 2035 try { 2036 const response = await fetch(`${API_URL}/api/me`, { 2037 method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, 2038 body: JSON.stringify({ 2039 customDisplayName: form.elements.customDisplayName.value, 2040 gameCharacterName: form.elements.gameCharacterName.value, 2041 preferredProvider: form.elements.preferredProvider?.value || null 2042 }) 2043 }); 2044 if (!response.ok) throw new Error('save_failed'); 2045 account.custom_display_name = form.elements.customDisplayName.value.trim() || null; 2046 account.game_character_name = form.elements.gameCharacterName.value.trim() || null; 2047 if (form.elements.preferredProvider) account.preferred_provider = form.elements.preferredProvider.value; 2048 status.textContent = 'Perfil salvo â'; 2049 setTimeout(renderPanel, 700); 2050 } catch (_) { 2051 status.textContent = 'Não foi possÃvel salvar. Tente novamente.'; 2052 } finally { 2053 submit.disabled = false;
2054 } 2055 }); 2056 document.addEventListener('click', (event) => { 2057 const helpModal = event.target.closest('.extension-help-modal'); 2058 if (event.target.closest('[data-close-extension-help]') || (helpModal && event.target === helpModal)) { 2059 document.getElementById('extensionHelpModal')?.remove(); 2060 document.body.classList.remove('extension-help-open'); 2061 return; 2062 } 2063 if (!menu.contains(event.target)) closeMenu(); 2064 }); 2065 document.addEventListener('keydown', (event) => { 2066 if (event.key === 'Escape' && document.getElementById('extensionHelpModal')) { 2067 document.getElementById('extensionHelpModal')?.remove(); 2068 document.body.classList.remove('extension-help-open'); 2069 return; 2070 } 2071 if (event.key === 'Escape') closeMenu(); 2072 }); 2073 2074 window.ChannelStore = { open }; 2075 function refreshAccount({ renderAfter = true } = {}) { 2076 return fetch(`${API_URL}/api/rewards/me`, { credentials: 'include' }) 2077 .then((response) => response.ok ? response.json() : null) 2078 .then((data) => { 2079 account = data?.user || null; 2080 if (renderAfter) render(); 2081 return account; 2082 }) 2083 .catch(() => { if (renderAfter) render(); }); 2084 } 2085 2086 function refreshAccountMetrics() { 2087 if (!account || !['panel','inventory'].includes(currentView) || document.hidden) return; 2088 fetch(`${API_URL}/api/rewards/me`, { credentials: 'include' }) 2089 .then((response) => response.ok ? response.json() : null) 2090 .then((data) => { 2091 if (!data?.user) return; 2092 const previousIdentity = `${account.has_twitch}:${account.has_youtube}:${account.extension_connected}`; 2093 const nextIdentity = `${data.user.has_twitch}:${data.user.has_youtube}:${data.user.extension_connected}`; 2094 account = data.user; 2095 if (currentView === 'inventory') { 2096 Object.keys(chestMeta).forEach(syncChestCardState); 2097 return; 2098 } 2099 if (previousIdentity !== nextIdentity) return renderPanel(); 2100 const balance = root.querySelector('[data-account-balance]'); 2101 const earned = root.querySelector('[data-account-earned]'); 2102 const hours = root.querySelector('[data-account-hours]'); 2103 if (balance) balance.textContent = qq(account.balance); 2104 if (earned) earned.textContent = qq(account.total_earned); 2105 if (hours) hours.textContent = (Number(account.watched_seconds || 0) / 3600).toFixed(1); 2106 }) 2107 .catch(() => {}); 2108 } 2109 2110 function refreshMessageNotifications() { 2111 if (!account || document.hidden) return; 2112 fetch(`${API_URL}/api/me/messages`, { credentials: 'include', cache: 'no-store' }) 2113 .then((response) => response.ok ? response.json() : null) 2114 .then((data) => { 2115 if (!data) return; 2116 const unread = Number(data.unread || 0); 2117 const nextSignature = JSON.stringify([unread, ...(data.messages || []).map((message) => [message.id, message.read_at || null])]); 2118 account.unread_messages = unread; 2119 const inboxTab = root.querySelector('[data-store-tab="inbox"]'); 2120 if (inboxTab) { 2121 inboxTab.classList.toggle('has-unread', unread > 0); 2122 const badge = inboxTab.querySelector('.unread-badge'); 2123 if (unread > 0) { 2124 if (badge) badge.textContent = String(unread); 2125 else inboxTab.insertAdjacentHTML('beforeend', ` <b class="unread-badge">${unread}</b>`); 2126 } else { 2127 badge?.remove(); 2128 } 2129 } 2130 if (currentView === 'inbox' && inboxSignature && nextSignature !== inboxSignature) renderInbox(); 2131 else inboxSignature = nextSignature; 2132 }) 2133 .catch(() => {}); 2134 } 2135 2136 refreshAccount({ renderAfter: false }).then(() => { 2137 if (location.pathname === '/loja' || location.pathname.startsWith('/loja/')) { 2138 if (location.pathname === '/loja') history.replaceState({ view: 'store', storeView: 'panel' }, '', '/loja/painel'); 2139 // The initial authenticated fetch is already fresh; do not immediately repeat it. 2140 open(storeViewFromPath(), { updateUrl: false, refreshData: false }); 2141 } 2142 else if (new URLSearchParams(location.search).get('store') === 'panel') open('panel', { updateUrl: false, refreshData: false }); 2143 }); 2144 2145 setInterval(refreshAccountMetrics, 20_000); 2146 setInterval(refreshMessageNotifications, 10_000); 2147 2148 setInterval(() => { 2149 if (currentView === 'panel' && identities().length > 1) updateIdentityCarousel(identityIndex + 1); 2150 }, 5500); 2151})();
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.