PageSourceSearch

https://kekedepatinete.com.br/assets/channel-store.js?v=34

js kekedepatinete.com.br collected 2026-10-02 01:18:13 UTC 151,606 bytes, 2,151 lines download raw bytes

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      '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
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.