<?php
/**
 * Plugin Name:       1MZ For Artists
 * Plugin URI:        https://1mzrecords.com/
 * Description:       Plataforma de gestão de artistas, submissão de músicas, contratos de videoclipes e distribuição digital para a 1MZ Records.
 * Version: 1.5.540
 * Author:            1MZ Records
 * Author URI:        https://1mzrecords.com/
 * License:           GPL v2 or later
 * Text Domain:       1mz-for-artists
 * Requires at least: 6.0
 * Requires PHP:      7.4
 *
 * == 1.5.540 (2026-07) - Anúncios e músicas patrocinadas unificados ==
 * - Integra o Programa de Músicas Patrocinadas no Gestor de Anúncios & Campanhas.
 * - Reorganiza faixas, orçamento, integridade, pagamentos e configurações em Material 3.
 * - Substitui a área duplicada do Music Player por um atalho para a nova central.
 *
 * == 1.5.539 (2026-07) - Configurações de música unificadas ==
 * - Move as configurações ativas do módulo Artists para Configurações 1MZ For Artists.
 * - Retira o submenu legado e os controlos substituídos de PRO, pagamentos e impulsionamento.
 * - Mantém redirecionamento dos URLs antigos e compacta a paginação dos downloads.
 *
 * == 1.5.538 (2026-07) - Alinhamento dos cartões de downloads ==
 * - Carrega localmente as regras de traço, tamanho e enquadramento dos ícones SVG.
 * - Uniformiza altura, grelha, alinhamento e números dos cartões de resumo.
 *

 * == 1.5.537 (2026-07) - Downloads nativos na Visão Geral ==
 * - Substitui a tabela antiga por uma página Material 3 integrada na administração 1MZ.
 * - Adiciona pesquisa, filtros por origem, país e datas, paginação e exportação CSV segura.
 * - Apresenta capa, música, localização, origem, dispositivo e métricas dos registos.
 * - Redireciona URLs antigas antes do cabeçalho e elimina o aviso strip_tags(null).
 *

 * == 1.5.536 (2026-07) - Visão Geral e Estatísticas unificadas ==
 * - Integra as Estatísticas da Plataforma na Visão Geral administrativa.
 * - Adiciona filtros de 7, 30 e 90 dias, histórico completo e intervalo personalizado.
 * - Reúne operações, finanças, app, catálogo, monetização, rankings, alertas e sistema.
 * - Moderniza o painel com cartões tonais, gráfico SVG nativo e layout responsivo.
 * - Mantém os logs e os URLs antigos sem apresentar um menu duplicado.
 *

 * == 1.5.535 (2026-07) - Monetizacao nativa por download valido ==
 * - Substitui o pedido antigo por link pela ativacao direta das faixas principais do Art Track.
 * - Exige Programa de Parceria aprovado e credita ganhos liquidados na carteira oficial 1MZ.
 * - Adiciona tarifa global ou por musica, retencao, antifraude, limites, auditoria e notificacoes.
 * - Cria uma central administrativa para catalogo, eventos, liquidacoes e saldos antigos preservados.
 * - Inclui migracao e limpeza confirmada das tabelas, pedidos e metadados da monetizacao anterior.
 *
 * == 1.5.534 (2026-07) - Tipografia mais legível em Músicas & Ganhos ==
 * - Aumenta textos auxiliares, chips, filtros, métricas e detalhes sem ampliar os cartões.
 * - Aplica a mesma escala ao modal de impulsionamento em desktop e telemóvel.
 *
 * == 1.5.533 (2026-07) - Impulsionamento exclusivo do artista ==
 * - Encerra o formulário público, os comprovativos e o pagamento M-Pesa do impulsionamento.
 * - Cria campanhas diretamente em Músicas & Ganhos, com duração escolhida e débito da carteira 1MZ.
 * - Valida sessão, Art Track, propriedade da música, saldo, idempotência e reversão automática.
 * - Ativa posts patrocinados, downloads, recomendações e o selo azul apenas durante a campanha.
 * * == 1.5.532 (2026-07) - Músicas & Ganhos nativo ==
 * - Substitui o painel Artists legado por uma experiência integrada no novo tema.
 * - Remove o método de pagamento e o saque paralelos; a carteira 1MZ passa a ser a única fonte financeira.
 * - Acrescenta pesquisa, filtros, saúde do catálogo e fichas completas por música.
 * - Mostra downloads, reproduções, impulsionamentos, vendas, ganhos, lojas, proteção e limites.
 * * == 1.5.531 (2026-07) - Cartões de decisão responsivos ==
 * - Reorganiza candidaturas em resumo e área administrativa de decisão.
 * - Corrige o aviso Deprecated causado por notas internas nulas no PHP atual.
 * - Adiciona rótulos claros e responsividade aos campos e ações da parceria.
 * * == 1.5.530 (2026-07) - Art Tracks nativos e Parceria full ==
 * - Move identidades, catálogo, candidaturas e auditoria para tabelas próprias da 1MZ.
 * - Migra os Art Tracks legados e mantém apenas uma ponte de compatibilidade com o WordPress.
 * - Renova o Programa de Parceria com aprovação, rejeição, suspensão, pesquisa, filtros e detalhes de segurança.
 * - Impede identidades duplicadas por telefone ou email e regista todas as decisões administrativas.
 * * == 1.5.529 (2026-07) - Estado de análise Material 3 ==
 * - Substitui a ampulheta pelo símbolo de revisão pendente.
 * - Centraliza o ícone numa caixa tonal responsiva e isolada do tema.
 *
 * == 1.5.528 (2026-07) - Serviços aprofundados e identidade 1MusicMoz ==
 * - Abre detalhes completos de cada serviço num diálogo Material 3 acessível e responsivo.
 * - Explica público, conteúdo, etapas e condições dos seis serviços.
 * - Converte todo o showcase para o padrão monocromático preto e branco da 1MusicMoz.
 *
 * == 1.5.527 (2026-07) - Serviços da 1MZ For Artists ==
 * - Acrescenta ao showcase uma área dedicada aos seis serviços principais da plataforma.
 * - Inclui navegação, informações concretas, condições e layout responsivo Material 3.
 *
 * == 1.5.526 (2026-07) - Showcase público sem aviso Dashicons ==
 * - Remove a dependência inválida de Dashicons para visitantes sem sessão.
 * - Usa o recurso do núcleo quando disponível e ícones locais como reserva segura.
 *
 * == 1.5.525 (2026-07) - Estado ativo da autenticação ==
 * - Mostra em verde o indicador pulsante do 1MZ For Artists.
 *
 * == 1.5.524 (2026-07) - Identidade Minha Conta ==
 * - Uniformiza os títulos destacados da autenticação como 1MZ For Artists.
 *
 * == 1.5.523 (2026-07) - Minha Conta Material 3 monocromática ==
 * - Redesenha entrada, registo e recuperação com uma interface moderna em preto e branco.
 * - Melhora a experiência no telefone, contraste, alvos de toque, movimento e navegação por teclado.
 * - Mantém intactos os fluxos, nonces e ações de autenticação existentes.
 *
 * == 1.5.522 (2026-07) - Showcase Material 3 ==
 * - Renova o shortcode [1mz_showcase] com identidade visual Material 3 responsiva.
 * - Adiciona filtros de recursos, pré-visualização interativa, pesquisa nas dúvidas e partilha nativa.
 * - Separa estrutura, estilos e comportamento para melhorar desempenho e manutenção.
 *
 * == 1.5.521 (2026-07) - Contagem S3 completa no admin ==
 * - Remove o corte em 1.000 objetos que mostrava apenas 343 de 685 músicas.
 * - Alinha o resumo da conta com o catálogo real da nuvem e com o leitor web.
 *
 * == 1.5.520 (2026-07) - YouTube Free protegido e diagnostico ==
 * - Download completo do YouTube exige VIP; Free usa apenas cache inicial limitado.
 * - Admin controla o tamanho do pre-buffer e as tentativas de renovacao do stream.
 * - Novo diagnostico do telefone com analise opcional pela Assistente, sem credenciais.
 * == 1.5.519 (2026-07) - Exclusoes sincronizadas na nuvem ==
 * - A listagem S3 informa versao e se o catalogo devolvido esta completo.
 * - Musicas apagadas num dispositivo desaparecem nos restantes apos sincronizar.
 * - Mantem compatibilidade conservadora com servidores antigos e listas truncadas.
 *
 * == 1.5.518 (2026-07) - Alinhamento completo da Parceria ==
 * - Centraliza os ícones dos benefícios e das declarações obrigatórias.
 * - Protege estes blocos contra estilos globais do tema.
 *
 * == 1.5.517 (2026-07) - Ícones alinhados na Parceria ==
 * - Isola os cartões dos requisitos contra estilos globais do tema.
 * - Mantém ícone, conteúdo e estado alinhados, com o estado fixo à direita.
 *
 * == 1.5.516 (2026-07) - Requisitos e alinhamento da Parceria ==
 * - Candidatura exige subscrição VIP ativa de qualquer pacote.
 * - Catálogo mínimo aumentado para três músicas principais publicadas.
 * - Cartões, progresso, declarações e ação receberam alinhamento responsivo.
 *
 * == 1.5.515 (2026-07) - Publicação validada e Programa de Parceria ==
 * - Submissões para o site criam ou reutilizam o Art Track oficial da conta.
 * - O admin finaliza apenas ao associar uma publicação WordPress real e publicada.
 * - Músicas & Ganhos exige aprovação no Programa de Parceria 1MZ For Artists.
 *
 * == 1.5.514 (2026-07) - Art Tracks vinculados à conta ==
 * - Artistas do 1MZ Admin passam a ser geridos como Art Tracks com catálogo próprio.
 * - Telefone ou email ligam automaticamente a futura conta 1MZ ao Art Track correto.
 * - Nova área Art Tracks mostra no painel todas as músicas publicadas e participações.
 *
 * == 1.5.513 (2026-07) - Administracoes separadas ==
 * - O menu principal 1MZ For Artists mantém a organização existente.
 * - O módulo Artists volta a ter o menu independente 1MZ Admin.
 * - A conta única 1MZ e a preservação do histórico continuam ativas.
 *
 * == 1.5.512 (2026-07) - Artists integrado na conta 1MZ ==
 * - Telemovel e PIN da 1MZ passam a ser a unica autenticacao do modulo Artists.
 * - Contas antigas sao ligadas por telemovel ou email, preservando autoria e historico.
 * - VIP, bloqueios, perfil e acesso profissional usam os dados canonicos da wallet 1MZ.
 * - Nova area Musicas & Ganhos no painel e modulo Artists incluido no mesmo pacote.
 *
 * == 1.5.511 (2026-07) - Definicoes em modais independentes ==
 * - Os cinco modulos da aplicacao abrem configuracoes em dialogos proprios.
 * - Cartoes compactos mostram estado e resumo sem expor formularios extensos.
 * - Cancelar restaura os valores; foco, Escape e navegacao por teclado protegidos.
 *
 * == 1.5.510 (2026-07) - Painel do leitor reorganizado ==
 * - Definicoes do app agrupadas em grelhas responsivas e navegacao por secoes.
 * - Cartoes de anuncios, formatos, VIP e S3 modernizados para ecras largos e moveis.
 * - Estados principais visiveis no topo e barra de gravacao mais clara.
 *
 * == 1.5.509 (2026-07) - Catalogo web escalavel e reproducao coerente ==
 * - Catalogo S3 paginado para bibliotecas grandes, sem o limite global de 10 mil objetos.
 * - URLs temporarias apenas para capas, metadados e faixas realmente usados pelo leitor.
 * - Cache progressivo da biblioteca e protecao contra trocas de faixa fora de ordem.
 *
 * == 1.5.508 (2026-07) - Subscricao e seguranca da conta ==
 * - Centro VIP com validade, renovacao, pedidos pendentes, historico e gestao dos aparelhos.
 * - Limite protegido de cinco telefones pagos, aviso de lotacao e substituicao confirmada pelo utilizador.
 * - Recuperacao de senha por link unico, sessoes inativas expiram e pagamentos sao aprovados uma unica vez.
 *
 * == 1.5.507 (2026-07) - Sessoes VIP em cinco dispositivos ==
 * - Cada telefone tem uma sessao propria; entrar noutro aparelho nao expulsa os anteriores.
 * - VIP pago/admin mantem ate cinco dispositivos ativos; contas normais e VIP de anuncio mantem um.
 * - Logout encerra apenas o dispositivo atual e tokens antigos continuam validos durante a migracao.
 *
 * == 1.5.506 (2026-07) - Atualizações e suporte da nuvem ==
 * - Metadados das músicas passam a comparar título, capa, letra, tags e cortes nos dois sentidos.
 * - O app recomenda importar ou exportar apenas os dados, sem reenviar o áudio.
 * - A administração gere, baixa e remove cópias individuais ou todas as cópias de uma conta.
 *
 * == 1.5.505 (2026-07) - Dados VIP por dispositivo ==
 * - Cópias organizadas em players/{conta}/Devices/{slot}/data.json.
 * - Limite fixo de cinco dispositivos VIP, com novo slot ou substituição explícita.
 * - Restauro mantém compatibilidade com backups antigos e permite escolher a origem.
 *
 * == 1.5.504 (2026-07) - Biblioteca da nuvem Material 3 ==
 *   - Gestão da nuvem recebe métricas, utilizadores e estados em Material 3.
 *   - Cada conta abre um modal pesquisável com filtros, capas, metadados e paginação.
 *   - Catálogo S3 passa a ter cache, leitura paralela dos dados e listagem acima de 1.000 objetos.
 *
 * == 1.5.503 (2026-07) - VIP premiado com permissões ==
 *   - Distingue VIP pago, administrativo, premiado e misto sem perder dias acumulados.
 *   - VIP de anúncio sincroniza playlists, gostos e definições, mas não envia áudio por padrão.
 *   - Administração pode autorizar áudio premiado; a API aplica a escolha no servidor.
 *
 * == 1.5.501 (2026-07) - Tipografia de Finanças ==
 *   - Aumenta rótulos, valores, pedidos, filtros e movimentos para leitura confortável.
 *   - Mantém a hierarquia e o comportamento responsivo do ecrã financeiro.
 *
 * == 1.5.500 (2026-07) - Finanças e atividade Material 3 ==
 *   - Corrige a hierarquia dos cartões de atividade sem distribuição digital.
 *   - Separa corretamente título, valor e descrição em desktop e mobile.
 *
 * == 1.5.499 (2026-07) - Finanças Material 3 ==
 *   - Novo resumo da carteira com ações diretas de depósito e levantamento.
 *   - Entradas, saídas, pendências e navegação financeira ganham nova hierarquia.
 *   - Pedidos e extrato ficam compactos, responsivos e sem ícones de emoji.
 *
 * == 1.5.498 (2026-07) - Renovação e upgrade VIP protegidos ==
 *   - O botão do plano abre um modal com a renovação atual e upgrades elegíveis.
 *   - Pacotes inferiores ou que removem serviços deixam de aparecer ao artista.
 *   - O servidor bloqueia downgrades, incluindo a remoção do Assistente de IA.
 *
 * == 1.5.497 (2026-07) - Espaçamento dos pacotes VIP ==
 *   - O complemento de IA fica visualmente separado do botão de ativação ou renovação.
 *
 * == 1.5.496 (2026-07) - Perfil e planos VIP Material 3 ==
 *   - Perfil destaca o plano atual, validade e ações de renovação com melhor hierarquia.
 *   - Centro de segurança organiza sessões móveis, estados e ações por dispositivo.
 *   - Pacotes VIP ganham comparação de serviços, plano atual, duração e add-on de IA.
 *
 * == 1.5.495 (2026-07) - Acesso VIP protegido por serviço ==
 *   - Distribuição, Videoclipes e Envios ocultam dados e formulários fora do pacote.
 *   - Novo ecrã Material 3 explica o bloqueio e encaminha para os planos VIP.
 *   - Distribuição valida a permissão do plano também nas mutações do servidor.
 *   - Início e Estatísticas deixam de expor métricas de serviços não incluídos.
 *
 * == 1.5.494 (2026-07) - Estatísticas adaptadas à atividade ==
 *   - Página de estatísticas recebeu métricas e conquistas compactas em Material 3.
 *   - Sem distribuição digital, mostra videoclipes, envios, carteira, convites e percurso.
 *   - Conquistas de catálogo e streams ficam ocultas quando não são relevantes ao artista.
 *
 * == 1.5.493 (2026-07) - Comprovativo por ID da transação ==
 *   - Modal identifica explicitamente depósitos submetidos por ID da transação.
 *   - Um aviso esclarece quando o código foi usado sem imagem anexada.
 *
 * == 1.5.492 (2026-07) - Comprovativos em ecrã inteiro ==
 *   - Comprovativos de depósito abrem num visualizador fullscreen dentro do painel.
 *   - O modal suporta fecho por botão, tecla Escape e clique fora da imagem.
 *
 * == 1.5.491 (2026-07) - Marca pública dos relatórios ==
 *   - Site, app e assistente mostram apenas 1MZ For Artists como origem dos relatórios.
 *   - O parceiro operacional continua disponível somente na administração e auditoria.
 *
 * == 1.5.490 (2026-07) - Alinhamento dos ícones SOMAS ==
 *   - Ícones dos títulos usam contentores independentes e alinhamento geométrico central.
 *
 * == 1.5.489 (2026-07) - Tipografia mais legível ==
 *   - Textos pequenos do SOMAS, navegação e formulários receberam uma escala maior.
 *   - Início, projetos, distribuição, lançamentos e avisos passam a respeitar um mínimo legível.
 *   - Indicadores móveis mantêm tamanhos próprios para evitar sobreposição.
 *
 * == 1.5.488 (2026-07) - Ícones SOMAS corrigidos ==
 *   - Ícone instável de identificação foi substituído por símbolos Material compatíveis.
 *
 * == 1.5.487 (2026-07) - Formulários SOMAS Material 3 ==
 *   - Validação de membro usa formulário central, grelha responsiva e anexos organizados.
 *   - Novo registo, escolha de percurso e estados SOMAS seguem o design do painel novo.
 *   - Emojis de interface foram substituídos por símbolos Material e mensagens mais limpas.
 *
 * == 1.5.486 (2026-07) - Ação SOMAS alinhada ==
 *   - Botão de criação do registo fica à direita e centrado verticalmente no cartão.
 *   - No telefone, a ação ocupa a largura disponível abaixo da informação.
 *
 * == 1.5.485 (2026-07) - Detalhe do lançamento Material 3 ==
 *   - Cabeçalho reúne capa, estado, metadados, resultados e ações principais.
 *   - Progresso, lojas e histórico usam uma leitura compacta; o histórico inicia recolhido.
 *   - Links, perfis, faixas, dados editoriais e serviços seguem a mesma hierarquia visual.
 *
 * == 1.5.484 (2026-07) - Distribuição digital Material 3 ==
 *   - Visão geral ganhou navegação segmentada, métricas e chamada de lançamento modernas.
 *   - Catálogo usa capas maiores, estados claros, pesquisa e filtros responsivos.
 *   - Perfis DSP e atalhos de relatórios seguem o mesmo sistema visual do dashboard.
 *
 * == 1.5.483 (2026-07) - Dashboard inicial Material 3 ==
 *   - Início reorganizado com hierarquia financeira e cartões responsivos.
 *   - Videoclipes recentes mostram estado e progresso sem espaço vazio excessivo.
 *   - Métricas, cupões, convites e notificações usam componentes Material consistentes.
 *
 * == 1.5.482 (2026-07) - Dívida da produção em Material 3 ==
 *   - Multa mostra total, estado, projeto, período, taxa e início em campos claros.
 *   - Valores anteriores deixam de ser apresentados como uma multiplicação enganadora.
 *   - Janela de pagamento foi redesenhada com resumo da carteira e PIN acessível.
 *
 * == 1.5.481 (2026-07) - Banner VIP responsivo no tema antigo ==
 *   - Convite para o novo painel usa layout compacto e legível no telefone.
 *   - Botão passa para uma linha própria no mobile e mantém área de toque ampla.
 *   - Ícone vetorial e textos curtos eliminam quebras de palavras desorganizadas.
 *
 * == 1.5.480 (2026-07) - Etapas do projeto e conclusão da filmagem ==
 *   - Novo indicador Material 3 destaca Avaliação, Pré-produção, Filmagem, Edição e Concluído.
 *   - Datas desaparecem após a finalização real e dão lugar a um cartão de parabéns.
 *   - Uma data vencida já não marca a gravação como concluída sem ação do admin.
 *
 * == 1.5.479 (2026-07) - Limitações Material 3 e emails transacionais ==
 *   - Limitações da conta usam cartões Material 3 com ícones, motivo e prazo.
 *   - Notificações removem emojis e usam linguagem clara sobre o acesso.
 *   - Emails de aplicação/remoção usam o tema de marca responsivo da 1MZ.
 *
 * == 1.5.478 (2026-07) - Filmagem live no painel do artista ==
 *   - Uma sessao ativa bloqueia o projeto e mostra apenas o cronometro live.
 *   - O bloqueio termina automaticamente quando o admin finaliza a sessao.
 *   - Dias filmados mostram duracao, resultado e participantes com fotografia.
 *
 * == 1.5.477 (2026-07) - Cargos estritos por projeto ==
 *   - O artista ve apenas os cargos escolhidos especificamente para o video.
 *   - Removido o fallback que marcava todos os cargos do perfil da pessoa.
 *   - O departamento geral deixou de aparecer no cartao da equipa.
 *
 * == 1.5.476 (2026-07) - Equipa visivel e projetos em acordeao ==
 *   - O painel do artista mostra a equipa atribuida e os cargos deste projeto.
 *   - Telefones e emails da equipa continuam reservados para a administracao.
 *   - Apenas o projeto mais recente inicia expandido; os restantes ficam compactos.
 *
 * == 1.5.475 (2026-07) - Janela de eliminacao do chat ==
 *   - O artista pode apagar mensagens proprias apenas durante 2 minutos.
 *   - O botao mostra a contagem regressiva e desaparece no fim do prazo.
 *   - O admin pode apagar mensagens dele ou do artista sem limite de tempo.
 *
 * == 1.5.474 (2026-07) - Eliminacao de mensagens no chat live ==
 *   - O artista pode apagar apenas mensagens proprias no novo tema.
 *   - O admin ve o marcador Mensagem apagada quando o artista elimina.
 *   - Mensagens eliminadas pelo admin desaparecem totalmente para o artista.
 *
 * == 1.5.473 (2026-07) - Imagens do chat com visualizacao ampliada ==
 *   - Anexos de imagem aparecem como miniaturas dentro das mensagens.
 *   - Clique abre um modal Material responsivo com acesso ao ficheiro original.
 *   - Estados de carregamento e imagem indisponivel evitam elementos quebrados.
 *
 * == 1.5.472 (2026-07) - Anexos no chat live do artista ==
 *   - A permissao do admin mostra ou oculta o botao de anexo em tempo real.
 *   - Upload de imagem, audio, video e PDF com limite de 15 MB e pre-visualizacao.
 *   - O artista pode enviar texto, ficheiro ou ambos sem recarregar a pagina.
 *
 * == 1.5.471 (2026-07) - Alertas do chat live do artista ==
 *   - Mensagens novas da equipa reproduzem um aviso sonoro sem recarregar.
 *   - O titulo da aba e o cabecalho do chat acumulam mensagens nao lidas.
 *   - O contador e limpo ao regressar a conversa aberta do projeto.
 *
 * == 1.5.457 (2026-07) — Distribuição nativa com paridade integral ==
 *   • Wizard sem perda de metadata: créditos, letras, covers, splits, UPC,
 *     editora e local de gravação são preservados e gravados antes de avançar.
 *   • Estados e permissões unificados no servidor; bloqueios do admin, globais,
 *     VIP e SOMAS protegem todas as mutações, incluindo Content ID e lojas.
 *   • Licenciamento SOMAS funcional no tema novo, com preço por faixa, saldo,
 *     cupões, cobrança, estado do pedido e retorno correto após os formulários.
 *   • Analytics completos por período, faixa, loja, país, import e lançamento;
 *     histórico nativo, filtros de catálogo, áudio e ordenação de faixas.
 *
 * == 1.5.456 (2026-07) — Novo tema completo e ligado aos módulos reais ==
 *   • Centro de notificações sincronizado com a App, ações, preferências de
 *     push, paginação e limpeza canónica; fallback legado preservado.
 *   • Estatísticas com insights reais, períodos, conquistas oficiais e
 *     Assistente IA; envios, levantamentos e sessões com detalhe completo.
 *   • Distribuição nativa com SOMAS, lojas, splits, takedown pré-validado,
 *     Content ID, exportação CSV e estados sem links mortos.
 *   • Carregamento por secção e consultas sob demanda; modais com foco,
 *     teclado, Escape e atributos de acessibilidade.
 *
 * == 1.5.455 (2026-07) — Painel VIP com paridade funcional ==
 *   • Corrigidos Envios (campos reais, capas, links e agendamento UTC),
 *     cancelamentos visíveis durante a retenção e pagamentos legados.
 *   • Finanças com detalhes completos, comprovativo, motivo de rejeição e
 *     paginação; contagens de projetos deixam de depender do limite da lista.
 *   • Chat com remetente, data, estado editado e anexos; distribuição com
 *     painel de correções, eliminação de rascunhos e finalização validada.
 *   • O depósito inicial obrigatório passa a bloquear também o painel VIP.
 *
 * == 1.5.455 (2026-07) — 🔐 Admin: terminar sessões ativas do artista ==
 *   Novo cartão "Sessões ativas" no perfil do artista (admin), com o nº de
 *   sessões da app e a última atividade, e o botão "Terminar sessões
 *   ativas" (com confirmação):
 *   • SITE — novo mecanismo session_epoch: o login guarda o epoch da conta
 *     na sessão PHP e o rmbz_is_user_logged_in()/get_current_user_telemovel
 *     comparam-no com o da wallet (extra_data). O botão grava um epoch novo
 *     e qualquer sessão de browser aberta antes disso morre na próxima
 *     verificação — sem tabelas novas.
 *   • APP — revoga todos os refresh tokens ativos (revoke_reason
 *     admin_revoke, mesmo padrão do controlo de versões) e corta os JWT já
 *     emitidos via rmbz_app_token_mark_revoked (o middleware devolve 401
 *     "sessão terminada pelo administrador").
 *   • PUSH — desativa os tokens FCM (a app volta a registá-los no próximo
 *     login). Ação via admin-post rmbz_admin_update_user_controls
 *     (control_action=terminate_sessions, nonce partilhado do perfil).
 *
 * == 1.5.454 (2026-07) — 💳 Levantar/Depositar iguais ao antigo no painel ==
 *   Auditoria completa contra o dashboard clássico e correção das
 *   diferenças no painel VIP:
 *   • LEVANTAR: o modal agora trava À ENTRADA quando os levantamentos
 *     estão bloqueados na conta ou quando já existe um pedido pendente
 *     (antes deixava preencher os 3 passos e o servidor rejeitava depois
 *     do reload). Nova validação operadora↔prefixo no passo 2 — a mesma
 *     regra do servidor: M-Pesa = Vodacom (84/85), eMola = Movitel
 *     (86/87) — com mensagem clara em vez de falhar em silêncio (era isto
 *     que acontecia a quem tem número 86/87 com o M-Pesa pré-selecionado).
 *   • ROYALTIES: a validação da carteira (mínimo 50 MT) já não corre
 *     sobre o valor em USD — cada fonte valida com as suas regras.
 *   • DEPOSITAR: os cartões das contas 1MZ mostram o NOME DO TITULAR real
 *     das definições de pagamento (rmbz_mpesa_name/rmbz_emola_name, com
 *     fallback ao beneficiário), como no antigo.
 *   • JS guardado quando o form não renderiza (bloqueado/pendente).
 *
 * == 1.5.453 (2026-07) — ✒️ Padrão profissional nos emails e notificações ==
 *   Regra da casa (agora padrão): texto transacional virado ao utilizador
 *   não pode "parecer IA". Reescritos com tom sóbrio e profissional — sem
 *   emojis no corpo, sem palavras em MAIÚSCULAS para ênfase, saudação e
 *   fecho formais ("Com os melhores cumprimentos, Equipa {site_name}"):
 *   • Templates project_giveup_ask / project_giveup_done (sino + email);
 *   • Email de despedida da desistência (com a data da assinatura);
 *   • Email de confirmação ao admin (ficha do projeto em linhas limpas);
 *   • Títulos dos pushes ("Projeto encerrado", "Decisão pendente…").
 *
 * == 1.5.452 (2026-07) — ✨ Badge "Totalmente pago" com ícone M3 ==
 *   O badge do projeto usava o emoji ✅ colado ao texto em maiúsculas —
 *   parecia gerado. Agora é um badge Material limpo: ícone check_circle
 *   preenchido (ou schedule no pendente) + "Totalmente pago" /
 *   "Pagamento pendente" em capitalização normal, alinhado com gap.
 *
 * == 1.5.451 (2026-07) — 🐛 Fix: logout do painel VIP não funcionava ==
 *   O handler do logout exige nonce (proteção SEC-MA5 contra logout
 *   forçado por sites externos), mas o painel usava o URL sem nonce — o
 *   servidor redirecionava em silêncio e a sessão ficava aberta. Fix:
 *   $logout_url agora é wp_nonce_url(..., 'rmbz_logout_nonce'), a cobrir
 *   os dois sítios (ícone na sidebar e "Terminar sessão" no Perfil).
 *
 * == 1.5.450 (2026-07) — 🐛 Fix: warnings no modal de cupão do painel VIP ==
 *   Ao resgatar um cupão com o painel novo ligado, a lista "O que
 *   ganhaste" mostrava "Warning: Array to string conversion" — cada
 *   benefício é um array {icon, text} (como o modal clássico renderiza),
 *   mas o diálogo do painel tratava-o como string. Agora renderiza ícone
 *   + texto, com destaque dourado na linha do Assistente IA (🤖).
 *
 * == 1.5.449 (2026-07) — 📄 Fix: certificado aparecia em branco no modal ==
 *   Os headers de segurança do servidor (X-Frame-Options/CSP) bloqueavam o
 *   URL do PDF dentro do iframe — o modal abria branco embora o PDF
 *   funcionasse em nova aba. Fix: o certificado é agora carregado por
 *   fetch e mostrado como BLOB (URL.createObjectURL) — os headers deixam
 *   de se aplicar e o PDF renderiza dentro do modal, com estado "⏳ a
 *   carregar…" e fallback "usa Abrir/descarregar" se o fetch falhar.
 *
 * == 1.5.448 (2026-07) — 📄 Certificado de desistência abre num modal ==
 *   No bloco "Projeto fechado por desistência" (admin), "Ver certificado"
 *   deixou de navegar para o PDF — abre um modal com o certificado embutido
 *   (iframe, 90% do ecrã), botão "⬇️ Abrir / descarregar" em nova aba e ✕
 *   para fechar.
 *
 * == 1.5.447 (2026-07) — 🕐 Desistir: horas na hora LOCAL do WordPress ==
 *   O "pedido enviado ao artista em …" mostrava a hora em GMT (o registo
 *   usava current_time('mysql', 1)). Agora requested_at e decided_at são
 *   gravados na hora local do WordPress (current_time('mysql'), como o
 *   signed_at já era) e todas as datas do bloco admin aparecem formatadas
 *   "d/m/Y às H:i" via date_i18n (pedido pendente, recusa e fecho).
 *
 * == 1.5.446 (2026-07) — 🇲🇿 Assinatura: +258 fixo no campo do número ==
 *   No passo da assinatura (dois temas), o campo do número da conta ganhou
 *   o prefixo fixo "🇲🇿 +258" — o artista só escreve o número dele (ex:
 *   86 123 4567). O JS compõe o número completo (258…) antes de enviar e
 *   o servidor aceita com ou sem prefixo (mesma validação de conferência).
 *
 * == 1.5.445 (2026-07) — 💬 Desistir: diálogos do tema também no antigo ==
 *   O modal de decisão do TEMA ANTIGO usava o confirm()/alert() cinzento
 *   do browser (aviso da decisão definitiva e validações da assinatura).
 *   Agora tem um diálogo próprio no estilo do cartão clássico (branco,
 *   botão dourado, ícone pelo contexto), com Confirmar/Cancelar baseado
 *   em Promises e re-submit via requestSubmit. O painel VIP novo já usava
 *   o diálogo M3 nativo — os dois temas ficam consistentes.
 *
 * == 1.5.444 (2026-07) — 💰 Editar o valor de mercado na compilação ==
 *   Na compilação da desistência (admin), a linha "Valor de mercado
 *   (nível)" ganhou o botão "✏️ Editar": abre um campo inline, guarda na
 *   opção rmbz_video_market_value_{nível} e fica registado no change_log.
 *   O valor vale para TODOS os projetos desse nível (compilação, conselho
 *   da IA, poupança no modal do artista e certificado).
 *
 * == 1.5.443 (2026-07) — ✍️ Desistir: assinatura, certificado PDF e decisão final ==
 *   O fluxo de decisão do artista ficou completo (nos DOIS temas):
 *   • "💪 Quero continuar" agora avisa que a decisão é DEFINITIVA e não
 *     pode ser invertida — uma futura desistência implica BANIMENTO do
 *     sistema (nunca mais submeter projetos ou músicas na 1MZ For
 *     Artists). Fica registado no change_log como decisão definitiva.
 *   • "Confirmo que desisto" abre a DESPEDIDA: lamentamos não trabalhar
 *     neste projeto; pode submeter outras músicas, mas ESTA música não
 *     será aceite mais (bloqueio real: chave do título + hash do áudio,
 *     o mesmo mecanismo do cron). Para avançar tem de ASSINAR: escrever o
 *     nome completo e confirmar o número da conta (validação no tema +
 *     validação no servidor; IP e hora da assinatura registados).
 *   • Ao assinar: o projeto SAI DA LISTA do artista imediatamente
 *     (artist_hide_after=agora) e no admin fica FECHADO COM CADEADO — o
 *     bloco mostra "🔒 Projeto fechado por desistência — assinada por X
 *     (conta Y)" com apenas o histórico e o link do certificado.
 *   • CERTIFICADO DE DESISTÊNCIA da 1MusicMoz em PDF (gerado com o motor
 *     nativo dos contratos): cabeçalho da marca, declaração com o nome
 *     assinado, EVIDÊNCIAS do projeto (nível, orçamento, pago perdido,
 *     multa, valor de mercado, poupança abdicada), histórico, efeitos das
 *     regras e a assinatura em fonte manuscrita. Guardado em uploads/
 *     rmbz-certificados e ANEXADO ao email de despedida (com o nome
 *     assinado e o número), se o artista tiver email real.
 *
 * == 1.5.442 (2026-07) — 🔔 Desistir: notificação real ao artista + tema antigo ==
 *   • FIX: o artista não recebia nada quando a equipa pedia a decisão — o
 *     módulo usava rmbz_notify_user (tabela das apps), mas o sino 🔔 do
 *     site e o email usam o centro de notificações (rmbz_notify →
 *     rmbz_email_logs + email real). Agora há dois templates EDITÁVEIS no
 *     admin: project_giveup_ask (pedido de decisão) e project_giveup_done
 *     (projeto encerrado, com {perda} quando há valor perdido). Push FCM
 *     mantém-se. Nota: pedidos feitos antes deste fix não notificar —
 *     anula o pedido e volta a pedir para disparar a notificação.
 *   • COMPATIBILIDADE: o modal de decisão do artista agora também aparece
 *     no TEMA ANTIGO (dashboard clássico) — cartão branco com cabeçalho
 *     escuro, os 4 números (pagaste/tua parte/mercado/poupança), multa,
 *     conselho da IA em dourado, regra do valor perdido e os dois botões.
 *     Renderizado no wp_footer só na página da conta; quem usa o painel
 *     VIP novo continua a ver o modal nativo (sem duplicar).
 *
 * == 1.5.441 (2026-07) — 🔐 Desistir: confirmação por EMAIL + botão bonito ==
 *   • SEGURANÇA: escolher "pedir ao artista" ou "desistir forçado" já não
 *     executa nada — envia um EMAIL ao admin (o email da conta wp dele)
 *     com um link de confirmação (token com hash, expira em 30 min, de uso
 *     único — é consumido antes de executar). Só ao abrir o link é que a
 *     via escolhida acontece. O bloco no detalhe mostra "📧 Falta a tua
 *     confirmação por email… expira às HH:MM" com opção de cancelar/
 *     recomeçar, e tudo fica no change_log.
 *   • VISUAL: o bloco "Desistir do projeto" ganhou cara — cartão com
 *     gradiente suave, botão vermelho em gradiente com sombra, aviso de
 *     segurança no modal e botões das duas vias em dourado (pedir ao
 *     artista) e vermelho (forçado).
 *
 * == 1.5.440 (2026-07) — ⚖️ Regra: ao desistir, o valor pago PERDE-SE ==
 *   Correção da regra do 1.5.439: em projetos cancelados por desistência,
 *   o valor que o artista já pagou NÃO é devolvido — perde-se. O módulo
 *   deixa de reembolsar (amount_paid mantém-se na row como registo; o
 *   valor perdido fica em extra_data['giveup_forfeited_amount']). Avisos
 *   atualizados em todo o fluxo: compilação do admin ("PERDE-SE ao
 *   desistir"), confirm do desistir forçado, notificação/push ao artista
 *   ("De acordo com as regras do projeto, o valor pago não é devolvido"),
 *   modal do artista (aviso vermelho + dupla confirmação com o valor) e a
 *   IA recebe o facto valor_perdido_se_desistir_mt com instrução para o
 *   mencionar como fator de ponderação (também nos textos de reserva).
 *
 * == 1.5.439 (2026-07) — 🛑 Desistir do projeto (forçado ou com decisão + IA) ==
 *   Novo módulo includes/project-giveup.php. No admin (Projetos &
 *   Videoclipes → detalhe), a coluna ESQUERDA ganha o bloco "🛑 Desistir do
 *   projeto": abre um modal com a COMPILAÇÃO de tudo (nível, estado,
 *   orçamento/parte do artista, já pago, em falta, multa pendente, valor de
 *   mercado do nível, poupança e últimos acontecimentos do change_log) e as
 *   duas vias:
 *   • 🛑 FORÇADO — cancela já (reembolso do pago à carteira, padrão do
 *     filming-cron), e o artista recebe só o aviso: "a equipa recebeu as
 *     orientações de desistência e desistiu do projeto" (popup + push +
 *     notificação, a respeitar o mute).
 *   • 📨 PEDIR AO ARTISTA — a IA (rmbz_ai_messages, modelo de chat) gera um
 *     conselho personalizado pelo NÍVEL (Básico/Profissional/Cinema) com
 *     factos determinísticos: só poucos convencem a equipa na aprovação
 *     (Cinema=raríssimo), e quanto poupa em DINHEIRO REAL de produção
 *     (valor de mercado por nível — opções rmbz_video_market_value_* — vs
 *     a parte do artista). O texto fica GUARDADO na base de dados
 *     (extra_data['giveup']['ai_text']); sem IA há textos de reserva
 *     DIFERENTES por nível. O artista, ao entrar no Início do painel, vê o
 *     modal com a compilação + conselho e decide: "💪 Quero continuar"
 *     (recusa, projeto segue) ou "Confirmo que desisto" (dupla confirmação
 *     no tema; cancela com reembolso). Tudo fica no change_log e o admin
 *     pode anular um pedido pendente.
 *
 * == 1.5.438 (2026-07) — 🪪 Logo real na sidebar + avisos do antigo mapeados ==
 *   • A marca da sidebar do painel deixa o quadrado amarelo "1MZ" e passa a
 *     usar o LOGO REAL da 1MZ (assets/pwa/icon-192.png).
 *   • Auditoria completa dos avisos do dashboard antigo → o painel já tinha
 *     bloqueios com cronómetro, multas (congelada/a acumular), convite feat,
 *     popup do admin, notices de sessão e e-mail obrigatório. Faltavam os
 *     que viviam no wp_footer e ficavam INVISÍVEIS por baixo do painel
 *     (alguns até eram consumidos sem nunca se verem). Mapeados nativos M3:
 *     1) 🎉 Boas-vindas VIP pós-compra (transient rmbz_vip_congrats_) —
 *        "Bem-vindo ao VIP!" / "👑 Bem-vindo de volta" com o plano e os
 *        benefícios; consumida no painel para o footer não a perder.
 *     2) 🎉 Celebração de cupão resgatado (sessão) — título, mensagem,
 *        "o que ganhaste" e destaque do Assistente IA.
 *     3) ❌ Projeto cancelado (user_meta rmbz_cancel_notices) — popup com o
 *        motivo e reembolso; "Compreendi" dispensa no servidor via
 *        rmbz_dismiss_cancel_notice (mesmo contrato do antigo).
 *
 * == 1.5.437 (2026-07) — ▶️ Serviços exclusivos: logos reais + claim nativo ==
 *   • Os cartões dos serviços exclusivos trocam os emojis por LOGÓTIPOS
 *     REAIS (SVG): YouTube (play vermelho), Facebook (f azul) e TikTok.
 *   • "Pedir remoção de claim" passa a funcionar EXATAMENTE como no
 *     estúdio antigo, mas nativo no tema M3: modal com URL do vídeo
 *     (watch/youtu.be/shorts), aviso automático de pedido pendente para o
 *     mesmo vídeo (rmbz_contentid_check_pending com debounce), seleção de
 *     faixas afetadas em álbuns ("Todas" ou individuais), motivo opcional,
 *     e submissão real via rmbz_contentid_submit (nonce rmbz_cid_submit).
 *   • Histórico de pedidos anteriores no cartão ("Ver histórico (N)"):
 *     link do vídeo, "há X", resposta do admin e pill ⏳ Em análise /
 *     ✓ Aprovado / ✗ Rejeitado — mesmas regras do módulo (só aparece com
 *     o release em Content ID, gate rmbz_contentid_release_is_in_content_id).
 *
 * == 1.5.436 (2026-07) — 💿 Detalhe do release nativo COMPLETO (mapeado 1:1) ==
 *   O detalhe nativo só mostrava o básico; agora mapeia TUDO o que o
 *   estúdio antigo (view_release) tem, no tema M3:
 *   • RELEASE TRACKER: stepper das 5 fases (Submetido → Em revisão →
 *     Aprovado → Entregue → Ao vivo) com o mapa real de status→fase e a
 *     pill de estado (🎉 Ao vivo / 🚀 Entregue / ✓ Aprovado / ⚡ Em revisão /
 *     ⟲ Devolvido / ✗ Rejeitado / 🗑 Removido).
 *   • OUVIR NAS LOJAS: 8 chips com logótipos (links exatos da tabela
 *     rmbz_distro_release_links ou pesquisa 🔍 "a serem confirmados").
 *   • HISTÓRICO: timeline real (rmbz_distro_status_history) com títulos,
 *     descrições, notas do admin ([código] texto) e datas — recente 1.º.
 *   • LINKS DA LOJA: iTunes / Apple Music / Spotify URL / Spotify URI
 *     ("Gerado após distribuição..." quando vazio).
 *   • PERFIS DE ARTISTAS: aviso quando não há nenhum configurado (royalties
 *     podem ir para outro artista) com botão para a vista de perfis.
 *   • DETALHES DO LANÇAMENTO: grid com data original, gravadora, idioma,
 *     género, subgénero, territórios ("Mundo, excluindo N…") e local de
 *     gravação. (UPC continua escondido do artista, como no estúdio.)
 *   • SERVIÇOS EXCLUSIVOS: YouTube Content ID / FB Rights Manager / TikTok
 *     com os estados reais (em análise/aprovado/rejeitado/conflito), notas
 *     da equipa, caixa de conflito e "Pedir remoção de claim" (YT ativo).
 *   • Ações com as regras do estúdio: editar (nativo) só em rascunho/
 *     devolvido; "Edição bloqueada · motivo" nos outros estados; gerir
 *     lojas/splits/retirada/relatório quando live.
 *
 * == 1.5.435 (2026-07) — 💬 Diálogos M3 — adeus alert()/confirm() do browser ==
 *   O "Lançamento enviado para revisão!" (e todos os outros avisos e
 *   confirmações) apareciam no popup CINZENTO do browser. Agora o painel
 *   VIP tem um diálogo próprio no tema (M3, claro/escuro), com ícone
 *   automático a partir do emoji da mensagem: vipAlert() e vipConfirm()
 *   (baseados em Promises). Substituídos TODOS os usos: enviar lançamento
 *   para revisão (+ mensagem melhor e splash no redirect), apagar faixa,
 *   validações do editor de faixa (título/splits 100%), cancelar serviço
 *   exclusivo, limpar notificações, limpar perfil de loja, validações dos
 *   Envios (tamanho/formato/uploads), recusar participação/contrato,
 *   escolher local de gravação, pagar multa e ativar plano com o saldo.
 *   Forms nativos usam data-vip-confirm (intercetado e re-submetido via
 *   requestSubmit — o splash de navegação continua a funcionar).
 *
 * == 1.5.434 (2026-07) — 🎛️ Wizard nativo: editor de faixa COMPLETO + SOMAS ==
 *   O passo 3 (Faixas) do wizard nativo só tinha título/compositor/explícito
 *   — agora mapeia TUDO o que o editor antigo tinha:
 *   • Cartão da faixa: ISRC + duração no cabeçalho, estado "✓ Pronta para
 *     distribuição" / "⚠ Falta: …" (mesmas regras do módulo: título,
 *     ≥1 compositor, letra se marcada, dados do cover, ISRC original de
 *     12 carateres) e botão "✏️ Detalhes".
 *   • Modal "Detalhes da faixa" nativo com as 4 tabs do antigo:
 *     1·Básico (título, idioma, duração auto, ISRC opcional, explícita,
 *     instrumental) · 2·Créditos (compositores, colaboradores com papéis
 *     — feat/produtor/mistura/…, letra, já lançada antes + ISRC original,
 *     cover + título/compositor originais) · 3·Splits (nome/telemóvel/%,
 *     valida soma 100%, convite a contas 1MZ) · 4·Avançado (início do
 *     preview, moods até 5, Dolby Atmos em breve).
 *   • Carrega via rmbz_track_editor_load e grava via rmbz_track_editor_save
 *     com payload COMPLETO (mandar só parte apagava o resto do extra_data).
 *   • Regras de áudio reais no dropzone (WAV 16-bit/44.1kHz+ · FLAC · MP3
 *     320kbps · mín. 30s) e da capa (inclui recomendado 3000×3000 sRGB).
 *   • Passo 4: cartão "🎼 Licenciamento musical (opcional)" — SOMAS, com o
 *     preço real do registo (700 MT por defeito), estado do registo do
 *     artista (nenhum/em análise/aprovado) e link para criar o registo.
 *
 * == 1.5.433 (2026-07) — 🎬 Splash com o logo 1MZ durante os carregamentos ==
 *   Em vez de se ver o site a processar entre páginas, agora há um splash
 *   de marca: (1) ao clicar num link/form do painel que recarrega a página,
 *   aparece IMEDIATAMENTE um ecrã com o fundo do tema (claro/escuro) e o
 *   logo 1MZ a flutuar; (2) a página nova nasce com o MESMO splash pintado
 *   desde o <head> (pseudo-elementos do <html> — renderizam antes de tudo);
 *   (3) quando o painel fica pronto, o logo despede-se com fade suave.
 *   Detalhes: ignora âncoras, nova aba, downloads, mailto/tel, cliques com
 *   Ctrl/Cmd e forms AJAX (defaultPrevented); ao voltar com o botão
 *   "anterior" (bfcache) o splash fecha-se; salvaguarda de 6s remove o
 *   splash se o painel não renderizar, para a página não ficar presa.
 *
 * == 1.5.432 (2026-07) — ✨ Menu do tema do site já não aparece no refresh ==
 *   A cada refresh via-se por um instante o menu/cabeçalho do TEMA do site
 *   antes de o painel VIP o tapar (o painel vem a meio do <body>, e o
 *   browser pinta o que vem antes). Fix: quando o painel VIP vai renderizar,
 *   um estilo no <head> esconde o body inteiro desde o primeiro byte e só o
 *   painel fica visível; o fundo da página respeita o tema guardado
 *   (claro/escuro) para não haver flash de cor. Salvaguarda: se o painel
 *   não aparecer em 4s (erro no render), o estilo é removido para a página
 *   não ficar em branco. Não se aplica no estúdio (?distro_action=).
 *
 * == 1.5.431 (2026-07) — ✨ Sem piscar ao abrir um release (tema + secção) ==
 *   Ao clicar num lançamento (live ou rascunho) a página recarregava e
 *   via-se: (1) um flash do tema ESCURO antes de voltar ao claro, e (2) o
 *   menu Início por um instante antes de saltar para a música. Causas: o
 *   tema guardado e o deep-link ?sect=/?dview= só eram aplicados pelo JS
 *   no FIM do markup. Fixes: (a) script inline logo a seguir à abertura do
 *   painel aplica o tema guardado (localStorage) antes de qualquer conteúdo
 *   renderizar; (b) a secção, a sub-vista da Distribuição, as tabs, o botão
 *   de navegação ativo e o título do cabeçalho passam a ser resolvidos NO
 *   SERVIDOR a partir do URL — a página já nasce aberta no sítio certo.
 *
 * == 1.5.430 (2026-07) — 🐛 Fix: ganhos/streams 0 no detalhe do release ==
 *   O detalhe do lançamento mostrava "0 streams / $0" enquanto as
 *   Estatísticas mostravam valor para a MESMA música. Causa real: a
 *   variável $t_daily (nome da tabela de stats diárias) só era definida
 *   MAIS ABAIXO no código, na secção de Estatísticas — o loader do detalhe
 *   fazia SHOW TABLES com string vazia, o check falhava em silêncio e os
 *   números ficavam sempre 0. Fix: $t_daily definido no topo, junto do
 *   $t_rel. Bónus de robustez: o detalhe e a ranking "por lançamento"
 *   passam a ligar as linhas de stats por release_id direto **ou** pelas
 *   faixas do release (track_id), como faz a análise oficial do estúdio —
 *   apanha imports legacy que só preencheram o track_id.
 *
 * == 1.5.429 (2026-07) — 💿 Detalhe do release NATIVO (moderno) ==
 *   Clicar num lançamento LIVE/pendente deixou de abrir o estúdio antigo —
 *   abre um DETALHE NATIVO no tema do painel (?sect=distro&dview=release&
 *   rid=X): hero com capa grande, título, estado, tipo/género/data, nº de
 *   faixas e, se live, streams + ganhos; lista de FAIXAS (título, ISRC,
 *   duração, explícito); e as LOJAS onde está disponível (com logótipos +
 *   territórios). Ações: Estatísticas (nativo) e, para os fluxos avançados
 *   (gerir lojas / splits / pedir retirada), abre o estúdio nessa gestão.
 *   Rascunhos continuam a abrir o wizard nativo (edição).
 *
 * == 1.5.428 (2026-07) — 🐛 Fix: 2.º serviço exclusivo não abria o aviso ==
 *   Depois de aceitar um serviço exclusivo, o <span> da contagem era
 *   destruído (o botão passava a "✅ Li e aceito"); ao abrir o aviso de
 *   OUTRO serviço, getElementById do span dava null e o .textContent
 *   rebentava — o modal não abria e os outros "não aceitavam marcar". Fix:
 *   o exclOpen recria sempre o botão/contagem antes de a usar.
 *
 * == 1.5.427 (2026-07) — 📄 Serviços exclusivos: aviso + contagem 10s ==
 *   No wizard nativo, ativar um serviço exclusivo (YouTube Content ID,
 *   Facebook/Instagram, TikTok) já não é um simples check — abre o AVISO
 *   completo (texto real rmbz_distro_exclusive_services) num modal com uma
 *   CONTAGEM de 10 segundos (o antigo eram 6); só depois de ler é que o
 *   botão "✅ Li e aceito" desbloqueia. Cada serviço mostra o estado
 *   (não solicitado / solicitado ✅) e permite cancelar. Em modo edição, os
 *   já solicitados aparecem marcados.
 *
 * == 1.5.426 (2026-07) — ✏️ Editar rascunho no wizard NATIVO ==
 *   Voltar a um lançamento em RASCUNHO (ou devolvido/rejeitado) deixou de
 *   abrir o estúdio antigo — abre o wizard NATIVO em modo edição. Os cards
 *   desses estados apontam para ?sect=distro&dview=create&rid=X; o render
 *   carrega o release + lojas/territórios/exclusivos + faixas (título e
 *   compositor) + capa e pré-preenche todos os passos (data-edit JSON). O
 *   release_id é reaproveitado (onemz_save_release_draft faz update). Live/
 *   pendente continuam a abrir o detalhe no estúdio.
 *
 * == 1.5.425 (2026-07) — 🏬 Wizard passo 2 COMPLETO (lojas + territórios) ==
 *   O passo "Lojas" do wizard nativo passou a ter tudo o que o módulo real
 *   tem: as 24 LOJAS por TIER (1 essenciais com logótipos, 2 regionais, 3
 *   nicho/Ásia — catálogo real rmbz_distro_dsp_catalog) com atalhos
 *   Todas/Nenhuma/Só essenciais e contador; TERRITÓRIOS em 3 modos (Todo o
 *   mundo / Excluir / Escolher países) com seletor de países agrupado por
 *   região (rmbz_distro_territory_regions) e "marcar região inteira"; e os
 *   SERVIÇOS EXCLUSIVOS opcionais (YouTube Content ID, Facebook/Instagram,
 *   TikTok — rmbz_distro_exclusive_services). Envia dsps[], territory_mode,
 *   territories[] e os optins yt/fb/tt exatamente como o handler espera.
 *
 * == 1.5.424 (2026-07) — 📅 Wizard: date-picker inteligente no passo 1 ==
 *   O passo "Detalhes" do wizard nativo de criar lançamento passou a usar o
 *   DATE-PICKER INTELIGENTE do módulo (rmbz_render_date_picker) — com a
 *   sugestão "1MZ sugere", calendário, mínimo +7 dias e opção de catálogo
 *   antigo — em vez do input date simples. Os assets do date-picker
 *   (CSS+JS) passam a ser enfileirados na minha-conta quando o layout VIP
 *   está ligado; o modal partilhado é renderizado no wizard; o JS lê os
 *   hidden release_date (Y-m-d) e is_legacy que o picker preenche.
 *
 * == 1.5.423 (2026-07) — 🐛 Fix: nada clicava na Distribuição (colisão CSS) ==
 *   O wizard de criar lançamento reutilizava as classes .rvu-audio/.rvu-drop
 *   do formulário de ENVIOS. O JS dos Envios fazia root.querySelectorAll
 *   dessas classes e apanhava os elementos do wizard, chamando
 *   .addEventListener em null → TypeError que MATAVA o <script> inteiro do
 *   painel (por isso as tabs e o "Novo lançamento" não clicavam). Fix: os
 *   loops dos Envios passaram a rmbz-scopados ao próprio form (rvuForm.query
 *   em vez de root.query), eliminando a colisão.
 *
 * == 1.5.422 (2026-07) — 🎚️ Criar lançamento NATIVO (wizard sem refresh) ==
 *   O "Novo lançamento" deixou de abrir o estúdio — é agora um wizard NATIVO
 *   de 4 passos dentro da secção Distribuição (tema do painel, sem recarregar
 *   entre passos), a chamar EXATAMENTE os mesmos endpoints AJAX do módulo
 *   (nada muda no servidor):
 *     1 Detalhes → onemz_save_release_draft (título, idioma, género, tipo,
 *       data com mín. +7d / catálogo antigo, editora, local) → release_id.
 *     2 Lojas → rmbz_save_release_platforms (dsps[] tier-1 com LOGÓTIPOS
 *       reais, territory_mode=worldwide).
 *     3 Faixas → upload de áudio (onemz_upload_music_chunk, 1 request, com
 *       progresso) → onemz_save_distro_track; cada faixa com título +
 *       compositor + explícito (rmbz_track_editor_save) e apagar
 *       (onemz_delete_distro_track).
 *     4 Capa → validação cliente 1400×1400 quadrada / 100KB–10MB →
 *       rmbz_upload_release_cover → Finalizar (re-guarda rascunho +
 *       onemz_finalize_release). Editar releases existentes continua no
 *       estúdio. Contratos/nonces replicados ao detalhe do módulo real.
 *
 * == 1.5.421 (2026-07) — 🏷️ Perfis nas lojas com LOGÓTIPOS REAIS ==
 *   Os cards dos Perfis nas Lojas (e as contas Spotify/Apple na overview)
 *   passaram a usar os logótipos SVG REAIS das lojas (rmbz_dsp_logo_svg de
 *   dsp-logos.php) em vez de emojis. O dsp-logos.php não estava no bootstrap,
 *   por isso é incluído no render do painel. Fallback ao emoji se faltar.
 *
 * == 1.5.420 (2026-07) — 📊 Distribuição: Estatísticas + Perfis NATIVOS ==
 *   A secção Distribuição do painel VIP ganhou sub-navegação (Visão geral /
 *   Estatísticas / Perfis nas Lojas), tudo NATIVO no tema (sem sair nem
 *   abrir o estúdio):
 *   - ESTATÍSTICAS: ganhos e streams acumulados, gráfico de barras dos
 *     últimos 30 dias (rmbz_distro_daily_stats) e ranking por lançamento
 *     (streams + $). Link para "relatórios detalhados" no estúdio.
 *   - PERFIS NAS LOJAS: grelha das 8 lojas (catálogo real
 *     rmbz_artist_profiles_catalog) com input do link, guardar/limpar via
 *     AJAX rmbz_artist_profile_save (nonce rmbz_distro_nonce), badge
 *     "essencial", dicas e aviso de perfis por configurar.
 *   CRIAR LANÇAMENTO continua no ESTÚDIO full-screen — é um wizard de 4
 *   passos com upload de áudio em chunks e validação de capa; reescrevê-lo
 *   nativo teria risco de partir o pipeline, por isso abre o wizard REAL.
 *
 * == 1.5.419 (2026-07) — 🎬 Distribuição: ESTÚDIO full-screen no painel ==
 *   Os fluxos pesados da distribuição (criar/editar lançamento no wizard,
 *   detalhe, analytics, splits, perfis) deixaram de sair para a página
 *   /dashboard-digital/ — abrem agora num ESTÚDIO em ECRÃ INTEIRO DENTRO da
 *   minha-conta (sem trocar de URL de página). Ao clicar num card/ação, a
 *   overview nativa navega para minha-conta/?distro_action=… e o entry point
 *   renderiza rmbz_vip_render_distro_studio: uma camada full-screen com barra
 *   "← Voltar ao painel" (→ ?sect=distro) e o MÓDULO REAL de distribuição por
 *   baixo. Como é um render de página normal, os assets do módulo
 *   (distribution.css/js + date-picker) carregam em head e o JS do wizard
 *   corre nativamente. A navegação interna do módulo (?distro_action=
 *   relativa) mantém-se no estúdio. Fullbleed neutralizado dentro do estúdio
 *   (sem scroll lateral). Enqueue dos assets condicionado a ?distro_action=.
 *
 * == 1.5.418 (2026-07) — 🌍 Distribuição: OVERVIEW NATIVO no painel VIP ==
 *   A 1.5.417 embebia o dashboard de distribuição inteiro dentro do painel —
 *   ficava uma caixa preta estreita (o módulo é dark/fullbleed) e navegar
 *   nele recarregava tudo. Substituído por uma OVERVIEW NATIVA no tema do
 *   painel (segue claro/escuro): cartões de royalties/streams/lançamentos,
 *   banner "Suas músicas. Seu dinheiro." + Novo Lançamento, contas de
 *   artista (Spotify/Apple), grelha "Os teus lançamentos" (cards nativos de
 *   rmbz_distro_releases com capa + pill de estado) e atalhos para
 *   Estatísticas e Perfis nas Lojas. Os FLUXOS PESADOS (criar/editar
 *   lançamento no wizard, detalhe, analytics, splits) — que são um módulo de
 *   30+ ficheiros multi-passo — abrem na sua PÁGINA DEDICADA em ecrã inteiro
 *   (/dashboard-digital/?distro_action=…), onde funcionam bem, em vez de
 *   espremidos numa caixa. Removido o embed, o enqueue e a ponte da 1.5.417.
 *
 * == 1.5.416 (2026-07) — ✨ PAINEL VIP COMPLETO (paridade com o antigo) ==
 *   O painel VIP desktop passou a agrupar TUDO o que existia no dashboard
 *   antigo (inventário completo portado, sem inventar nada — mesmos
 *   handlers admin-post/AJAX e nonces):
 *   - INÍCIO: bloqueios/limitações com countdown, multa por atraso (banner
 *     + pagar com PIN, rmbz_pay_project_fine), convites de participação
 *     feat (termos + aceitar/recusar), popup do admin (dismiss AJAX),
 *     overlay obrigatório de EMAIL REAL + formulário OTP
 *     (rmbz_update_profile_request / rmbz_verify_profile_otp), cupão
 *     (rmbz_apply_coupon) e referral (código + copiar + stats).
 *   - VIDEOCLIPES: cartão completo por projeto — vistoria bloqueada,
 *     entrega ao editor externo, REC ao vivo com cronómetro, progresso da
 *     filmagem, transferência (termos+aceitar/recusar), projeto trancado
 *     por multa, pendente (regras), rejeitado (motivo), escolha de
 *     localização (onemz_artist_choose_budget), itens negociáveis por
 *     decidir (onemz_decide_pending_items), orçamento (a pagar/pago/falta
 *     + checklist), datas de gravação (confirmar/propor + countdown com o
 *     fix da filmagem terminada), contrato & KYC (ler em modal, prazo,
 *     ACEITAR com foto BI + nome real + decisões de itens
 *     [onemz_accept_contract], recusar), pagamento com saldo
 *     (onemz_pay_project_cost), pitch, plano e CHAT com a equipa
 *     (onemz_update_feedback + histórico em bolhas). Concluídos com
 *     Baixar HD / YouTube.
 *   - DISTRIBUIÇÃO: músicas live/devolvidas + botão real /dashboard-digital.
 *   - ENVIOS: o FORMULÁRIO DE ENVIO REFEITO NATIVO no tema VIP (M3, não o
 *     include antigo que ficava feio) — com todo o comportamento e os
 *     MESMOS name/action/nonces: escolha do tipo (Site/Redes/Vídeo) com
 *     permissões VIP e castigos + motivos, dropzone de áudio com upload
 *     AJAX (rmbz_async_audio_upload, progress bar + estado), álbum/EP com
 *     multi-faixas, capa/thumbnail com preview, sim/não em pills, cenários
 *     (básico/profissional/cinema) com info dinâmica, feat com verificação
 *     "artista válido" em tempo real (rmbz_feat_check_account), validação
 *     de links e submissão para rmbz_handle_frontend_upload. Bloqueio com
 *     blur quando há envio pendente. Por baixo, lista real das submissões
 *     (estados, agendamento, links, motivo de rejeição, CORRIGIR com
 *     upload — rmbz_process_correction). A navegação grava ?sect= na URL
 *     para o redirect pós-submissão voltar à MESMA secção.
 *   - FINANÇAS (separador novo): pedidos de depósito (estado+motivo),
 *     levantamentos (fonte/método/estado) e extrato (30 movimentos).
 *   - NOTIFICAÇÕES: clique expande + marca lida (AJAX), "Limpar tudo".
 *   - PERFIL & VIP: editar email/província (com OTP) e RENOVAR/COMPRAR VIP
 *     com os pacotes reais (durações com desconto + add-on IA,
 *     rmbz_buy_vip_package).
 *   - DIÁLOGOS: depósito REAL (valor, método, nº que enviou, ID da
 *     transação OU imagem — rmbz_processar_deposito) com avisos de
 *     pendente/bloqueado; levantamento com fonte Carteira/Royalties (USD ao
 *     câmbio) e 🐛 BUGFIX: os campos do form estavam com nomes errados
 *     (source/payment_method/payment_details → rmbz_withdrawal_source/
 *     rmbz_payment_method/rmbz_payment_details) e o pedido chegava sem
 *     método/número ao handler.
 *   + heartbeat anti-offline, deep-link ?sect=, countdowns e REC clock.
 *
 * == 1.5.415 (2026-07) — 🔔 Sino do artista sem alertas do SISTEMA ==
 *   Quando o email do artista é o MESMO da administração, o sino (clássico,
 *   painel VIP e VIP desktop) mostrava os alertas internos ("Novo Depósito
 *   Pendente", "Olá Equipa Financeira"…). Solução definitiva: coluna
 *   `audience` ('artist'/'admin') em rmbz_email_logs — marcada na ESCRITA
 *   (rmbz_log_sent_email ganhou o 5.º arg; alertas de depósito, submissões
 *   admin, relatórios de importação e verificação de eliminação marcam
 *   'admin') e filtrada na LEITURA (audience <> 'admin' nas 3 vistas).
 *   Migração automática no init (ALTER + BACKFILL do histórico por padrões:
 *   Novo Depósito Pendente, Equipa Financeira, Painel de Audições, etc.).
 *   Também: painel VIP desktop com z-index máximo + re-parent para o <body>
 *   (o menu do tema cobria o topo por stacking context de wrappers com
 *   transform).
 *
 * == 1.5.414 (2026-07) — ✨ PAINEL VIP DESKTOP DO ARTISTA (Material Design 3) ==
 *   Novo layout de painel do artista, MODERNO e FULL DESKTOP (ocupa o ecrã
 *   todo): sidebar fixa à esquerda + conteúdo largo. EXCLUSIVO de artistas
 *   VIP e OPT-IN — o artista VIP vê um banner "✨ Experimentar o novo painel"
 *   no dashboard antigo; ao ligar passa a ver o novo e pode voltar ao antigo
 *   quando quiser (preferência em extra_data['vip_layout'] de rmbz_wallets).
 *   - Dois temas: escuro OLED (preto puro) e claro, com o dourado 1MZ;
 *     alternador na sidebar (guardado em localStorage).
 *   - Separadores: Início (património + saldo retirável + royalties USD,
 *     botões Depositar/Levantar), Videoclipes (projetos reais + progresso +
 *     atalho para o chat), Distribuição, Envios, Estatísticas + conquistas,
 *     Notificações e Perfil & VIP.
 *   - LEVANTAMENTO em 3 passos (valor com chips → eMola/M-Pesa + número →
 *     confirmação) que REUTILIZA o handler existente
 *     admin_post rmbz_request_withdrawal (nonce rmbz_withdrawal_nonce,
 *     source=wallet) — zero duplicação de lógica de dinheiro.
 *   - Dados 100% reais: saldos (rmbz_wallets.balance +
 *     rmbz_get_withdrawable_balance), VIP (rmbz_user_has_active_subscription
 *     + rmbz_get_subscription_expiry), projetos (rmbz_artist_projects),
 *     notificações (rmbz_email_logs), royalties (rmbz_distro_stats se existir).
 *     Todas as queries a tabelas opcionais são defensivas (SHOW TABLES).
 *   FICHEIROS:
 *     - 1mz-for-artists.php (require + version + changelog)
 *     - includes/my-account-vip.php (NOVO — elegíbilidade, opt-in, render)
 *     - includes/my-account-page.php (branch VIP em rmbz_exibir_my_account_page)
 *
 * == 1.5.413 (2026-07) — 💼 PAINEL M3 CLARO DA EQUIPA + CARTEIRA (eMola/M-Pesa) ==
 *   O portal da equipa (minha-conta) foi redesenhado em MATERIAL 3 CLARO
 *   (dourado 1MZ como cor primária, Roboto Flex + Material Symbols) com
 *   navegação por separadores: Início, Projetos, Sessões, Agenda,
 *   Pagamentos e Perfil.
 *   - CARTEIRA DA EQUIPA (novo ficheiro includes/team-portal-withdrawals.php):
 *     ledger próprio por membro (tabela rmbz_team_payouts) em Metical,
 *     separado da carteira dos artistas. A administração CREDITA ganhos
 *     (vistorias pagas, subsídios, bónus) na página admin nova
 *     "Equipa 💼 Carteira" (com bolha de pendentes no menu).
 *   - LEVANTAMENTO eMola / M-Pesa no portal: fluxo em 3 passos (valor com
 *     chips rápidos → escolher carteira móvel + número de 9 dígitos →
 *     confirmação com taxa). Taxa % configurável (default 2%, mínimo
 *     10 MT) + levantamento mínimo configurável (default 100 MT).
 *     O pedido fica PENDENTE (desconta logo do saldo) até o admin pagar
 *     na carteira móvel e marcar "Já paguei" (ou rejeitar → o valor
 *     volta ao saldo). Regras: 1 pedido pendente de cada vez, valor
 *     dentro do saldo, número validado.
 *   - INÍCIO: cartão hero do saldo disponível (+ pendente + total ganho),
 *     REC ao vivo, próxima gravação (agenda automática das datas de
 *     filmagem/continuação), resumo do trabalho e últimos movimentos.
 *   - SESSÕES: cada dia de gravação dos projetos dele com duração e
 *     presenças por categoria; AGENDA: próximas gravações; PAGAMENTOS:
 *     extrato completo (créditos + levantamentos com taxa e estado).
 *   FICHEIROS:
 *     - 1mz-for-artists.php (require + version + changelog)
 *     - includes/team-portal-withdrawals.php (NOVO — carteira + admin)
 *     - includes/team-portal-page.php (render M3 claro + dialog 3 passos)
 *
 * == 1.5.412 (2026-07) — 🎬 PORTAL DA EQUIPA DE PRODUÇÃO (site) ==
 *   As pessoas de "Equipas & Pessoas" passam a ter LOGIN no site (a mesma
 *   página minha-conta): o login reconhece que o número é da EQUIPA.
 *   - ACESSO: telefone do perfil da pessoa + PIN inicial definido pelo ADMIN
 *     (campo novo no editor de pessoa; hash com wp_hash_password; schema da
 *     equipa 1.5: pin_hash, pin_must_change, pin_updated_at, last_login_at).
 *     No PRIMEIRO login a pessoa é OBRIGADA a definir um PIN novo só dela.
 *   - LOGIN: o form normal do site tenta artista primeiro; se falhar,
 *     reconhece a equipa (rmbz_team_find_by_phone, últimos 9 dígitos) e abre
 *     a sessão do portal (rate-limit do login já cobre as duas vias).
 *   - PAINEL M3 (novo ficheiro includes/team-portal-page.php): hero com foto
 *     e cargos, stats (produções, em curso, dias filmados, valores), lista
 *     das produções onde está atribuída — estado, datas de filmagem, dias
 *     filmados + horas no terreno, REC ao vivo, % em falta e próxima data,
 *     o papel dela em cada projeto, DINHEIRO por produção (vistorias em nome
 *     dela + estado: paga/por cobrar/bloqueado) e SUBSÍDIO das multas de
 *     atraso pagas no projeto (repartido pela equipa; novo registo
 *     extra['fine_paid_log'] gravado ao limpar/pagar multa), trocar PIN e
 *     sair. Valores marcados como informativos (pagamento confirmado pela
 *     administração).
 *   BUGFIX "Datas de Gravação" a contar depois da filmagem acabar: quando a
 *   equipa marca 100% ANTES da data-fim (projeto passa a EDIÇÃO), o cartão
 *   do site continuava "Dia X — filmagens a decorrer" com o countdown
 *   TEMPO ATÉ AO FIM a contar. Agora o estado do projeto (edição/concluído)
 *   manda: barra 100% verde + "Filmagens 100% concluídas em N dia(s) 🎬" e
 *   sem countdown — no site (my-account) e na app do artista 1.1.9
 *   (FilmingProgressTimer com finishedEarly + dias filmados).
 *   CARD DO PROJETO (site) MODERNIZADO — M3 Expressive: parabéns com troféu
 *   em círculo tonal e sombra suave; stepper novo (✓ nos passos feitos,
 *   passo ativo dourado com anel, linha de progresso gradiente); caixa do
 *   contrato em card translúcido arredondado; chat com input pill + botão
 *   ➤ Enviar dourado e histórico em card M3. "Ver Cópia do Contrato
 *   Original" deixou de navegar para a URL: abre um MODAL com o documento
 *   embebido (PDF via <object>, imagem inline; fallback + ⬇️ Descarregar);
 *   no fluxo de aceitação, "📜 Ler o Contrato" usa o mesmo modal.
 *
 * == 1.5.411 (2026-07) — 📦 Entrega ao EDITOR EXTERNO (pós-pagamento) ==
 *   O passo a seguir ao pagamento da taxa do editor externo, em 2 fases
 *   (núcleo em project-team.php, extra_data['editor_delivery']):
 *   1) FICHEIROS PRONTOS: com a taxa paga e a filmagem terminada, o admin
 *      regista QUANTOS ficheiros são, quantos GB e a QUALIDADE (painel novo
 *      no WP admin dentro da Equipa do Videoclipe; card "Entrega ao editor
 *      externo" no app admin 0.9.7/vc 30, ação editor_delivery_info). O
 *      artista é NOTIFICADO com os dados em todos os canais (push+sino app
 *      via rmbz_notify_user; email+sino site via template editor_files_ready).
 *   2) ENTREGUE AO EDITOR: só depois do passo 1 desbloqueia o botão
 *      "🚚 Entregue ao editor" (WP admin + app admin, editor_delivery_done).
 *      Ao marcar, o artista é avisado que o editor JÁ LEVOU TODOS os
 *      ficheiros e a 1MZ só AGUARDA A EDIÇÃO para aprovar o vídeo final
 *      (template editor_files_delivered).
 *   O ARTISTA vê o estado no site (card 📦/🚚 no projeto) e na app 1.1.9/
 *   vc 22 (EditorDeliveryCard no detalhe; bloco editor_delivery na API).
 *   Tudo com registo no histórico do projeto e auditoria (quem/quando).
 *   BUGFIX (app admin): a ação filming_people_set faltava na lista aceite
 *   pelo servidor — a edição de participantes de um dia pelo app falhava.
 *
 * == 1.5.410 (2026-07) — 👥 Presenças na gravação: categorias + árvore ==
 *   O registo de quem está na filmagem cresceu (WP admin + app admin 0.9.5):
 *   - CATEGORIAS PREDEFINIDAS (rmbz_fs_categories): Artista principal,
 *     Artista Feat, Modelo, Dançarino, Figurante, Realizador, Operador de
 *     Câmara, Drone, Iluminação, Som, Maquilhagem, Guarda-Roupa, Produção,
 *     Segurança, Motorista, Outro — no site é um select, na app são chips;
 *     o campo "papel" passou a detalhe opcional.
 *   - EDIÇÃO TOTAL AUDITADA: depois de o dia terminar o admin pode mudar
 *     TUDO (nomes, categorias, remover, acrescentar) — núcleo
 *     rmbz_filming_session_people_set grava "modificado N× · última vez X
 *     por Y" na sessão, mostra no painel/card e regista no histórico do
 *     projeto. WP admin: editor por dia; app: diálogo "✏️ Editar" (ação
 *     filming_people_set).
 *   - RESUMO + ÁRVORE: cada dia mostra "No dia N participaram X pessoa(s)"
 *     e uma árvore genealógica dos participantes ligados por categoria
 *     (tronco da categoria → ramos com foto/nome), no WP admin (CSS tree)
 *     e no app (nós com linhas).
 *   - FOTO NO EDITOR: cada pessoa do editor tem 📷 — no WP admin miniatura
 *     atual + input file (câmara no telemóvel; pp_photo_file[] por linha,
 *     núcleo rmbz_fs_store_photo_tmp); no app (0.9.6/vc 29) o 📷 abre a
 *     câmara para aquela linha e a foto sobe logo (fica 📷✓).
 *   MULTA "PAROU DE CONTAR" — BUGFIX + DIAGNÓSTICO: quando o artista
 *   renovava o VIP e voltava a expirar (novo lapso), o total fazia
 *   max(dívida antiga, contagem nova) — ficava PRESO na dívida antiga
 *   (ex.: 1.740 MT com só 17h × 15 = 255 MT ao vivo) até a contagem nova a
 *   ultrapassar (dias!), parecendo parado. Agora a dívida de lapsos
 *   anteriores é CARRY e SOMA-SE à contagem nova (overdue_info + freeze;
 *   campo carry persistido; banner mostra "+ X MT de dívida anterior").
 *   Novo painel "🔎 Diagnóstico da multa" no projeto (WP admin): mostra
 *   porta a porta porque conta/não conta (taxa, projeto escolhido pelo
 *   motor, VIP, video_access_lost_at, fine_paid_until, resultado).
 *   APP ADMIN — arrumação financeira: o editor do orçamento passou a
 *   card-resumo "Orçamento e localização" (total + nº de itens + vistorias)
 *   com botão ✏️ Editar que abre o MODAL com tudo editável. CONTROLO
 *   FINANCEIRO atualizado com a taxa de equipa: o dashboard soma as
 *   VISTORIAS POR COBRAR (summary vistoria_blocked_projects +
 *   vistoria_pending_value; faixa vermelha no "Fluxo pendente") e o bloco
 *   Pagamento do projeto mostra "Taxa de vistoria / em falta / estado
 *   (🔒 bloqueado ou ✅ paga)".
 *   AVISO DO EDITOR EXTERNO: quando a filmagem acaba a 100% e há taxa de
 *   EDITOR EXTERNO (vistoria de Pós-Produção) por pagar, o aviso passa a
 *   ser específico — "os ficheiros NÃO vão ser entregues ao editor sem a
 *   taxa regularizada; entra no projeto e paga para a equipa preparar os
 *   ficheiros e entregar ao editor". rmbz_team_vistoria_status devolve
 *   editor=true quando há vistoria com dept 'pos'; o push/email/sino usam
 *   título "🔒 Ficheiros retidos — taxa do editor por pagar" + {editor_nota}
 *   no template vistoria_due; banner do site, card 🔒 da app do artista
 *   (1.1.8/vc 21) e estado_vistoria do app admin mostram o parágrafo ⚠️.
 *   LISTA DE PROJETOS (WP admin) — estado de pagamento AO VIVO no card:
 *   deixou de ler a coluna is_paid (ficava velha e mostrava "✓ Pago" com
 *   dívida) e passou a fazer a MESMA conta do painel Pagamento do detalhe
 *   (obligatory_cost vs amount_paid) — mostra "✓ Pago", "⏳ Falta X MT"
 *   ou "✗ Não pago".
 *
 * == 1.5.409 (2026-07) — 🔒 Vistoria vence no FIM da filmagem (bloqueio) ==
 *   A taxa de vistoria passa a VENCER quando a filmagem termina. Novo núcleo
 *   rmbz_team_vistoria_status: os pagamentos do projeto cobrem a vistoria em
 *   ÚLTIMO lugar, por isso o valor em falta calcula-se sem endpoint novo —
 *   o artista paga pelo fluxo normal de pagamento do projeto.
 *   - Ao passar FILMAGEM→EDIÇÃO (fim automático da gravação ou mudança
 *     manual) com vistoria por pagar: projeto BLOQUEADO, artista notificado
 *     em TODOS os canais (template vistoria_due: "a equipa não pode editar
 *     nem entregar até pagares") e registo no histórico.
 *   - EQUIPA TRAVADA: o handler de guardar não deixa concluir ("done") nem
 *     guardar os links de entrega (HD/YouTube) enquanto bloqueado — devolve
 *     o motivo na mensagem.
 *   - PAINEL DO ARTISTA: banner 🔒 no cartão do projeto no site + card no
 *     detalhe do app (1.1.6/vc 19) + banner urgente no Home — explica o
 *     porquê ("não consegues acompanhar o estado") e o valor em falta.
 *   - API: bloco `vistoria` no detalhe e flag `vistoria_blocked` na lista.
 *   - Também: uma vistoria POR DEPARTAMENTO dos cargos que a pessoa externa
 *     vai exercer no projeto (painel organizado por pessoa, valores
 *     editáveis por linha; itens do orçamento legíveis "Vistoria de X —
 *     Pós-Produção (Editor de Vídeo)").
 *   ORÇAMENTO EM 3 FRENTES:
 *   - WP admin: tabela de itens redesenhada em M3 (linhas-cartão com
 *     respiro, larguras mínimas por coluna + scroll horizontal do contentor
 *     — os inputs deixam de ficar encolhidos; focus ring, remover redondo,
 *     "+ Adicionar item" pill).
 *   - APP ADMIN (0.9.4/vc 27): novo card "Orçamento" no detalhe — editar
 *     itens (categoria, descrição, custo, Obrigatório/Negociável),
 *     adicionar/remover e guardar (ação budget_items_set recalcula o total
 *     e regista no histórico; preserva marcas de vistoria).
 *   - APP DO ARTISTA (1.1.7/vc 20): escolher a LOCALIZAÇÃO em 2 passos —
 *     lista compacta só com os locais (total + nº de itens); ao escolher
 *     abre a produção desse local e o artista decide item a item quem
 *     cobre ("💪 Eu cubro" / "🏢 A 1MZ trata") com resumo em tempo real
 *     ("A pagar à 1MZ" / "Cobres tu"); FINALIZAR só ativa com tudo
 *     decidido e atualiza o projeto de uma vez (choose-budget ganhou
 *     item_decisions; proposed_budgets expõe must_pay).
 *
 * == 1.5.408 (2026-07) — 👥 Equipas & Pessoas: MÚLTIPLOS CARGOS + M3 ==
 *   A mesma pessoa pode agora exercer VÁRIOS cargos (ex.: Editor de Vídeo +
 *   Realizador), mesmo de departamentos diferentes:
 *   - Form novo: CHIPS de cargos multi-seleção agrupados por departamento
 *     (com contador) + campo "outros cargos" livre separado por vírgulas.
 *   - BD (schema 1.4): role alargado p/ 255 + nova coluna `departments`
 *     (CSV de todos os departamentos da pessoa). O motor de requisitos
 *     (rmbz_team_validate_project) passa a contar TODOS os departamentos —
 *     um Editor+Realizador satisfaz Pós-Produção E Direção. Compat total:
 *     `role` continua a string única ("Editor · Realizador") que o site,
 *     app e cartões já mostram; `department` fica o primeiro (agrupamento
 *     e taxa de vistoria).
 *   - Página "Equipas & Pessoas" redesenhada em M3 Expressive: tabs pill,
 *     cards arredondados, origem como cartões selecionáveis, inputs com
 *     focus ring, cartões de pessoa com cargos em pills e botões pill.
 *   - CARGOS POR PROJETO: ao marcar a pessoa na "Equipa do Videoclipe"
 *     aparecem os cargos dela em chips — escolhes O QUE ELA VAI FAZER
 *     NESTE projeto (extra team_project_roles) e o ARTISTA vê só esses
 *     (rmbz_team_for_project substitui o role). No WP admin e no APP ADMIN
 *     (0.9.3/vc 26 — chips com scroll horizontal, mínimo 1 cargo; team_set
 *     ganhou o mapa roles).
 *
 * == 1.5.407 (2026-07) — 🔐 OTP anti-spam + email obrigatório no APP ==
 *   O email do CÓDIGO de confirmação estava a cair no Spam do Gmail
 *   (os outros entravam bem): era curto, vago e só com um número — padrão
 *   clássico de spam/phishing. Reescrito (rmbz_api_send_otp_email, agora
 *   partilhado pelo site e pela API): CÓDIGO NO ASSUNTO ("123456 é o teu
 *   código de confirmação — {site}"), corpo completo e pessoal (nome do
 *   artista, porquê recebeu, referência ao site, validade 15 min, nota de
 *   segurança) com o template M3.
 *   APP DO ARTISTA (1.1.5/vc 18): o modal BLOQUEANTE a pedir email quando o
 *   projeto é aprovado e a conta tem email placeholder passou a aparecer
 *   logo no HOME (igual ao site) — antes só aparecia se o artista abrisse o
 *   detalhe do projeto. Fluxo completo email → OTP no próprio modal.
 *   INICIAR GRAVAÇÃO com confirmação: no app admin o botão abre o diálogo
 *   "📍 Estás no local da filmagem?" e depois pede a IMPRESSÃO DIGITAL antes
 *   de iniciar e notificar o artista; no WP admin o mesmo diálogo (sem
 *   biometria). Gravação ao Vivo só aparece DENTRO da janela de datas (ou
 *   na fase Filmagem/com histórico) e ganhou design M3 Expressive (hero
 *   escuro com cronómetro, chips de presenças, radio-cards no finalizar).
 *   Histórico de alterações redesenhado (pills tonais, diff em chips
 *   antes→depois em vez de riscado).
 *   EQUIPA DO VIDEOCLIPE — BUGFIX GRANDE: as checkboxes NUNCA eram
 *   guardadas (o handler ignorava team_member_ids) e o <form> ANINHADO da
 *   taxa de vistoria partia o HTML, deixando a secção fora do form
 *   principal. Agora: save real (team_member_ids + team_requested_by com
 *   marcador team_section_present), o painel da taxa submete via form
 *   construído em JS (HTML válido) e o bloco foi redesenhado em M3
 *   (banners tonais, cartões de membro com avatar, segmented "quem pediu").
 *   APP ADMIN (0.9.2/vc 25): novo card "Equipa do Videoclipe" — selecionar
 *   do banco de pessoas quem trabalha no projeto (externos com 1MZ/Artista);
 *   servidor expõe team_info no detalhe + ação team_set.
 *
 * == 1.5.406 (2026-07) — ☁️ ARMAZENAMENTO S3 para os ficheiros ==
 *   Novo includes/s3-storage.php: os ficheiros do 1MZ (comprovativos, fotos
 *   KYC, demos, capas, fotos de filmagem e contratos PDF) podem ir para um
 *   bucket compatível com S3 — AWS S3, Cloudflare R2, Backblaze B2,
 *   DigitalOcean Spaces, Wasabi… SEM SDKs: assinatura AWS SigV4 escrita em
 *   PHP puro sobre wp_remote_request (validada contra o vetor de teste
 *   oficial da AWS), pedidos path-style (funciona em todos os fornecedores).
 *   - Aba "☁️ S3" nas Configurações 1MZ: endpoint, região, bucket, prefixo,
 *     URL pública/CDN, credenciais (secret guardada ENCRIPTADA como a chave
 *     da IA, ou constantes RMBZ_S3_KEY_ID/RMBZ_S3_SECRET no wp-config),
 *     ACL public-read opcional, "enviar todos os uploads 1MZ" e "apagar
 *     cópia local". Botão "🧪 Testar ligação" (PUT + leitura pública + DELETE
 *     com diagnóstico claro).
 *   - Integração: filtro wp_handle_upload SÓ nos fluxos 1MZ (a média do WP
 *     fica local); writes diretos (contratos PDF do gerador, fotos de
 *     filmagem site+app) fazem offload; rmbz_destruir_ficheiro_fisico apaga
 *     também no bucket quando a URL é do S3. Objetos organizados em
 *     prefixo/ANO/MES/ficheiro.
 *
 * == 1.5.405 (2026-07) — ⏱️ O ARTISTA vê a gravação a contar (app + site) ==
 *   Quando a equipa carrega em INICIAR GRAVAÇÃO, o artista passa a VER a
 *   sessão em direto:
 *   - APP (1.1.4/vc 17): card "● REC — A gravação do teu videoclipe está a
 *     decorrer!" no detalhe do projeto com CRONÓMETRO a contar (sincronizado
 *     com o relógio do servidor) + banner "Gravação a decorrer 🎬" no Home
 *     que abre o detalhe. Entre dias mostra "falta X% — continua no dia Y
 *     (N dias já filmados)" e no fim "🎉 100% concluída — em edição".
 *   - SITE: o cartão do projeto na conta ganha o mesmo painel REC com o
 *     relógio em JS a contar + os estados de progresso/conclusão.
 *   - API: bloco filming_live no detalhe (GET /projects/{id}) e flag
 *     filming_active na lista (banner do Home). Também corrigido o histórico
 *     que registava "📅 Atualizou a data de lançamento: 0000-00-00 →
 *     (vazio)" em todos os guardares do projeto (datas agora normalizadas
 *     antes de comparar).
 *
 * == 1.5.404 (2026-07) — 🎬 GRAVAÇÃO AO VIVO (Iniciar Gravação) ==
 *   Novo includes/filming-sessions.php — quando chega a data de gravação, o
 *   app admin mostra "🎬 INICIAR GRAVAÇÃO":
 *   - INICIAR: artista notificado em todos os canais ("a tua filmagem já
 *     começou", template filming_live_started) e o PARTICIPANTE (feat) com
 *     conta 1MZ também (push + sino); 1.º dia move o projeto para FILMAGEM.
 *   - CRONÓMETRO REC no app admin a contar o tempo da sessão (sincronizado
 *     com o relógio do servidor).
 *   - QUEM ESTÁ NA FILMAGEM: a equipa tira FOTO pela câmara + nome + papel
 *     ("essa pessoa é X, é modelo") — upload próprio (rota
 *     /admin-app/projects/{id}/filming-photo) e registo na sessão + histórico.
 *   - "🏁 FILMAGEM DO DIA FINALIZADA" pergunta se TODO o vídeo já foi
 *     filmado: SIM → projeto passa a EDIÇÃO, artista (+feat) notificados
 *     (filming_live_wrapped); NÃO → admin indica QUANTOS % FALTAM e o
 *     PRÓXIMO DIA — artista (+feat) notificados ("continua no dia X",
 *     filming_live_continues). Duração do dia gravada no histórico.
 *   Sessões em extra_data['filming_sessions']; bloco filming_live no detalhe
 *   do app admin + ações filming_start / filming_add_person / filming_finish.
 *   TAMBÉM NO WP ADMIN: painel "🎬 Gravação ao Vivo" no detalhe do projeto
 *   (depois de Guardar alterações) — botão INICIAR GRAVAÇÃO quando chega o
 *   dia, cronómetro REC em JS, registar presenças com foto (input file com
 *   câmara no telemóvel), finalizar o dia (tudo/continua + % + próxima data)
 *   e histórico dos dias com as fotos das pessoas (abre no lightbox).
 *   Handlers admin-post rmbz_filming_start/_add_person/_finish.
 *   Tudo registado no histórico do projeto. App admin 0.9.0 (vc 23).
 *
 * == 1.5.403 (2026-07) — 🎤 Admin DEFINE a participação (forçada/aceitação) ==
 *   O admin passa a definir/alterar/remover a participação (feat) de um
 *   projeto — núcleo novo rmbz_feat_admin_set com dois modos (como na
 *   transferência):
 *   - FORÇADA: fica ACEITE imediatamente, o participante NÃO precisa de
 *     aceitar — é apenas informado ("a equipa confirmou a tua participação")
 *     e o dono também; histórico regista "definiu (FORÇADA)".
 *   - COM ACEITAÇÃO: o participante recebe o convite normal em todos os
 *     canais (termos da participação; recusar CANCELA o projeto).
 *   Conta 1MZ sempre validada (tem de existir e não pode ser a do dono);
 *   nome vazio/Remover tira a participação. WP admin: mini-form "🎤 Definir
 *   participação…" no bloco Candidatura do projeto (modo por rádio +
 *   Remover). APP ADMIN: novo card "Participação (feat)" no detalhe do
 *   projeto — estado (⏳/✅/✅ equipa/❌) + diálogo definir/alterar com os
 *   dois modos + remover; servidor expõe feat_info no detalhe e a ação
 *   feat_set. App admin 0.8.1 (vc 22).
 *
 * == 1.5.402 (2026-07) — 📄 Gerador de contrato no APP ADMIN ==
 *   O app admin passa a criar contratos com o MESMO fluxo do WP admin:
 *   - Card "Gerador de Contrato" no detalhe do projeto (escondido quando o
 *     contrato já está assinado): modal com todos os campos editáveis
 *     pré-preenchidos (artista, conta, música, nível, valor, N.º do B.I.,
 *     cidade, quem assina pela 1MZ) → gera apenas um RASCUNHO.
 *   - Painel âmbar do rascunho: abrir o PDF para pré-visualizar,
 *     "✅ Enviar" (anexa + notifica o artista por email/push/sino),
 *     "✗ Rejeitar" (apaga, nada é enviado) ou "✏️ Gerar de novo".
 *   Servidor: detalhe do projeto do app admin ganhou o bloco contract_gen
 *   (defaults do modal + rascunho pendente) e as ações contract_generate /
 *   contract_draft_send / contract_draft_discard; a lógica de enviar/rejeitar
 *   foi extraída para núcleos partilhados rmbz_contract_draft_send_core /
 *   _discard_core (os handlers do WP admin usam os mesmos). App admin 0.8.0
 *   (vc 21).
 *
 * == 1.5.401 (2026-07) — 🎤 Participação (FEAT) com consentimento ==
 *   Novo includes/feat-participation.php. Quando o dono submete um projeto
 *   "Ganhar Vídeo" com participação que TEM conta 1MZ:
 *   - VERIFICAÇÃO EM TEMPO REAL nos formulários (site + app): ao digitar o
 *     número aparece "✔ Artista válido — Nome" (AJAX no site; GET
 *     /projects/feat-check na app). Sem verificação válida não dá para
 *     submeter; a conta também é validada no servidor (e não pode ser a
 *     do próprio dono).
 *   - CONVITE ao participante em TODOS os canais (push + sino app + email +
 *     sino site): banner no site ("Foste adicionado como participação…") e
 *     banner inteligente no Home da app, ambos com modal dos TERMOS DA
 *     PARTICIPAÇÃO (9 regras aprofundadas: créditos, imagem/voz, gravações,
 *     pagamentos fora da plataforma, propriedade do projeto, desistir depois
 *     de aceitar, identidade, registo no histórico).
 *   - ACEITAR exige concordar com os termos → dono notificado em todos os
 *     canais + push aos admins + histórico do projeto.
 *   - RECUSAR CANCELA O PROJETO automaticamente (sem o participante não há
 *     como fazer o vídeo): production_status=rejected com motivo claro, dono
 *     notificado em todos os canais ("não foi possível"), admins avisados.
 *   - API: POST /projects/feat-respond; convites pendentes em GET /projects
 *     (feat_invites). Admin: linha "🎤 Feat" do projeto mostra o estado
 *     (⏳ à espera / ✅ aceitou / ❌ recusou). Templates novos feat_invite,
 *     feat_accepted, feat_declined no Centro de Notificações.
 *   App do artista 1.1.3 (vc 16).
 *
 * == 1.5.400 (2026-07) — 🔔 Sino do site SEM o tema do email ==
 *   O modal do sino do artista mostrava o EMAIL COMPLETO (faixa dourada,
 *   ícone, ™, rodapé) cortado — alguns fluxos gravavam em rmbz_email_logs o
 *   HTML construído por rmbz_build_html_email() em vez do conteúdo. Fix em
 *   duas camadas: (1) ESCRITA — o anexar de contrato (form-handler) e o
 *   código de segurança (admin-user-profile) passam a gravar só o conteúdo;
 *   (2) LEITURA — novo helper rmbz_bell_body_clean() (email-manager) usado
 *   pelo sino (lista + modal): se o body gravado for um email completo,
 *   extrai apenas o conteúdo (marcadores novos <!--RMBZ:INNER-->, fallback
 *   por regex para o cartão do template em notificações ANTIGAS já na BD, e
 *   último recurso texto simples). O template do email ganhou os marcadores.
 *
 * == 1.5.399 (2026-07) — 📄 Gerador de CONTRATO em PDF (1 clique) ==
 *   Novo includes/contract-pdf.php: motor de PDF cru (A4, Helvetica com
 *   WinAnsiEncoding — acentos pt, sem dependências) + o Contrato de Produção
 *   de Videoclipe com 12 cláusulas alinhadas às regras da plataforma (KYC,
 *   VIP ativo + multa 15 MT/hora, datas, feat só-tracking, transferência,
 *   foro de Maputo). Botão "⚡ Gerar contrato automático (PDF)" no bloco
 *   "📄 Contrato & identidade" do projeto: pré-preenche ARTISTA + CONTA +
 *   MÚSICA + NÍVEL + VALOR + FEAT (cláusula 7 personalizada) + DATA por
 *   extenso, grava em uploads/rmbz-contracts/ e anexa com o MESMO fluxo do
 *   upload manual — helper novo rmbz_project_contract_attach_url (extraído
 *   de rmbz_admin_upload_contract; agora também manda PUSH ao artista além
 *   do email/sino). Escondido quando o contrato já está assinado; substitui
 *   o ficheiro anterior (apaga o antigo do disco). Regista no histórico.
 *   MODAL DE REVISÃO: o botão abre um modal com TODOS os campos editáveis
 *   antes de gerar (nome, conta, música, nível, valor, N.º do B.I., cidade e
 *   quem ASSINA PELA 1MZ). ASPETO MANUSCRITO A NEGRITO: os valores
 *   preenchidos saem em Times-BoldItalic (caneta carregada sobre o impresso)
 *   e a assinatura da 1MZ é o nome do signatário em itálico grande sobre a
 *   linha. Motor PDF ganhou segmentos de fontes mistas na mesma linha (wrap
 *   por tokens) + larguras corrigidas (maiúsculas/travessões — nada cola).
 *   FLUXO DE RASCUNHO: gerar cria apenas um RASCUNHO (extra contract_draft;
 *   o artista não recebe nada) — o admin vê a PRÉ-VISUALIZAÇÃO (iframe do
 *   PDF no bloco do contrato) e decide: "✅ Concordo — enviar ao artista"
 *   (anexa + email/push/sino, apaga contrato antigo e o rascunho) ou
 *   "✗ Rejeitar rascunho" (apaga, nada é enviado) ou "Gerar de novo"
 *   (substitui o rascunho). Handlers rmbz_contract_draft_send/_discard +
 *   rmbz_contract_draft_get. Tudo registado no histórico do projeto.
 *
 * == 1.5.398 (2026-06) — 🔁 Transferência de projeto entre artistas ==
 *   Quando a equipa descobre que a música pertence a OUTRO artista, o projeto
 *   pode ser transferido. Novo includes/project-transfer.php + bloco
 *   "🔁 Transferência de Projeto" no admin do projeto. Dois modos:
 *   - FORÇADA: muda de dono imediatamente, sem o artista atual saber (exige
 *     escolher o novo dono na lista de artistas com pesquisa).
 *   - COM ACEITAÇÃO: o artista atual recebe pedido (push/sino + banner no site
 *     em "minha conta" com Aceitar/Recusar). Assim que aceita, o admin recebe
 *     push e no admin aparece "escolher novo dono" (datalist de artistas).
 *   Na transferência o admin pode mudar NÍVEL (cenário), TÍTULO e PITCH do
 *   artista. O contrato/KYC do dono antigo são LIMPOS (novo dono re-assina).
 *   Tudo registado no histórico do projeto (change_log); notificações ao novo
 *   dono (sempre) e ao antigo (só no modo com aceitação). Handlers:
 *   rmbz_project_transfer(_cancel/_finalize) [admin] e
 *   rmbz_project_transfer_respond [site do artista].
 *   EXTRA — 📱 APP ADMIN com transferência + player da demo: o detalhe do
 *   projeto no app 1MZ Admin ganhou (a) card "🎧 Demo do artista" com player
 *   (play/pause + progresso, MediaPlayer) — API expõe demo_mp3_url; (b) card
 *   "Transferência de Projeto" com os 2 modos (🤝 pedir ao artista c/ motivo;
 *   ⚡ forçada c/ número), estado pendente c/ cancelar, e estado aceite com a
 *   SUGESTÃO do artista pré-preenchida + "Transferir agora". API admin-app:
 *   detalhe expõe bloco 'transfer'; ações project_transfer_request/_force/
 *   _cancel/_finalize (params JSON via reason). Helpers partilhados novos
 *   rmbz_project_transfer_create_request/_cancel_request (WP admin e app usam
 *   o mesmo código → mesmas notificações multi-canal).
 *   EXTRA — 👤 SUGESTÃO DO NOVO DONO: o modo "com aceitação" existe para o
 *   artista NOS DIZER quem é o verdadeiro dono — ao aceitar (site E app) tem
 *   de indicar NOME + NÚMERO da conta de destino (core valida; código
 *   suggestion_required). A sugestão vai no push ao admin, no histórico do
 *   projeto e aparece no bloco do admin com o campo "novo dono" PRÉ-PREENCHIDO
 *   (o admin confirma na lista ou corrige). Na forçada nada muda (o admin já
 *   sabe o artista). App: campos no modal de termos + cartão "aceite" mostra a
 *   sugestão; API transfer-respond aceita suggested_name/suggested_tel e o
 *   detalhe devolve-os no transfer_request.
 *   EXTRA — 📜 TERMOS DA TRANSFERÊNCIA: ao aceitar, o artista vê um modal com
 *   as 8 regras da 1MZ For Artists (definitiva/irreversível, perda de acesso,
 *   valores ficam no projeto, obrigações passam ao novo dono, contrato/KYC
 *   anulados, etc.) e só consegue aceitar depois de marcar "Li e concordo"
 *   (servidor exige terms_accepted=1 e guarda terms_accepted_at). MIGRAÇÃO
 *   100%: o UPDATE inclui o dono atual no WHERE (não aplica por cima se outro
 *   admin transferiu entretanto) + releitura pós-escrita a confirmar o novo
 *   dono ANTES de histórico/notificações — as notificações futuras do projeto
 *   seguem artist_telemovel, logo vão garantidamente ao novo dono. RESULTADO
 *   EM TODOS OS CANAIS: sucesso → done_new (novo dono) e done_old (antigo);
 *   cancelado/não possível → novo template project_transfer_failed (push +
 *   sino app + email + sino site).
 *   EXTRA — 📧 Template de email reconstruído (robusto p/ mobile): o icon tile
 *   do hero passou de div (line-height) para CÉLULA DE TABELA com altura fixa
 *   (o emoji já não "sai para fora" no Gmail Android) e o "™" do nome da marca
 *   fica pequeno/sobrescrito com apresentação de texto forçada (U+FE0E), em
 *   vez de herdar 25px e virar glifo emoji gigante. Tabelas aninhadas + estilos
 *   inline em todo o layout (pill do rodapé incluída), fixes iOS auto-links e
 *   MSO. rmbz_email_brand_html + rmbz_build_html_email (email-manager.php).
 *
 * == 1.5.397 (2026-06) — ✨ IA escreve o "Pitch da Produção" no admin ==
 *   No projeto de vídeo (admin), o bloco "💡 Pitch da Produção" tem agora um
 *   gerador com IA: o admin escreve ideias soltas da equipa (opcional) e clica
 *   "✨ Gerar Pitch com IA" — a IA (cliente Anthropic existente) combina o PITCH
 *   DO ARTISTA + as ideias da equipa + cenário/localização e escreve a visão
 *   criativa/roteiro em pt-MZ (2-3 parágrafos, sem inventar valores/datas). O
 *   texto cai no textarea para o admin rever/editar antes de guardar. Handler
 *   wp_ajax_rmbz_ai_generate_pitch (admin-handlers.php) + UI no bloco do pitch
 *   (projects-admin-page.php). Só aparece com a chave de IA configurada.
 *
 * == 1.5.396 (2026-06) — 🔔 Push para ADMINS + redesign M3 do app admin ==
 *   Grande atualização do app 1MZ Admin (v0.7.0, redesign Material 3 Expressive:
 *   Plus Jakarta Sans, acentos por módulo, heroes full-bleed, stepper com pulse,
 *   PIN em caixas, biometria em ações sensíveis) + servidor:
 *   - PUSH PARA ADMINS: novo includes/api/fcm/admin-push.php — tokens dos
 *     dispositivos admin em rmbz_app_push_tokens sob '__admin__' (rota POST
 *     /admin-app/push-token). Push em: novo depósito (site+app, hook novo
 *     rmbz_deposit_request_created), novo levantamento (hook existente) e nova
 *     mensagem de artista no chat (hook novo rmbz_pchat_message_inserted em
 *     rmbz_pchat_insert_message).
 *   - Projetos (admin-app): extra 'unread_chat' por projeto (badge na lista).
 *   - Artistas (admin-app): 'artist_stats' (total/vip/suspensos) exatos no
 *     endpoint do módulo — os stats do app deixam de contar só a página.
 *   - Dashboard (admin-app): 'flow_7d' entradas/saídas aprovadas por dia.
 *   - Transações (admin-app): saldo atual/após aprovar (depósitos) e
 *     balance_sufficient (levantamentos); svc_* (estado dos serviços do artista).
 *
 * == 1.5.395 (2026-06) — 📋 App admin: transações no perfil do artista ==
 *   No perfil do artista (app admin) há agora a secção "Transações & Carteira" com
 *   4 listas expansíveis: Pedidos de Depósito, Pedidos de Levantamento, Extrato da
 *   Carteira e Histórico de Envios. Endpoint novo GET /admin-app/artists/{id}/
 *   transactions (rmbz_api_admin_app_artist_transactions); cliente: AdminApi
 *   .artistTransactions + ArtistTransactionsSection no MainActivity.
 *
 * == 1.5.394 (2026-06) — 🛠️ App ADMIN atualizado (estado Aprovado, multa, silenciar) ==
 *   Propagadas ao app admin (android-app/admin) as novidades de gestão do projeto:
 *   - Estado "Pré-Produção" → "Aprovado" (statusLabel + stepper).
 *   - Multa por atraso visível no projeto (lista: extra fine_amount/state; detalhe:
 *     secção "Multa por atraso") + ação "Marcar multa paga" (project_clear_fine).
 *   - "Notificações on/off" do projeto (project_toggle_notifications) + estado.
 *   API admin-app: rmbz_api_admin_app_project_action trata as 2 ações novas; lista
 *   e detalhe expõem multa + notifications_muted. Cliente: AdminApi (secção Multa),
 *   MainActivity (rótulos/ícones/cores das ações + label "Aprovado").
 *
 * == 1.5.393 (2026-06) — 🔕 Admin: "Desativar notificação" por projeto de vídeo ==
 *   No detalhe do projeto (admin) há agora uma opção "Desativar notificação deste
 *   projeto". Quando ligada (extra_data['notifications_muted']=1), NENHUMA mudança
 *   do projeto notifica o artista (push/email/sino): estado, aprovação, correção,
 *   pitch, datas de gravação, novos items, lembrete diário de multa e aviso de
 *   pré-expiração. O conteúdo continua a aparecer no projeto/chat — só não dispara
 *   notificação. Helper rmbz_project_notifications_muted; guardas em
 *   onemz_admin_update_project e nos crons.
 *
 * == 1.5.392 (2026-06) — 🔔 Lembrete diário multi-canal da multa a acumular ==
 *   Enquanto a subscrição com Ganhar Vídeo está expirada, o projeto está em
 *   produção e a multa cresce, o artista passa a ser lembrado ~1×/dia em TODOS os
 *   canais (email + sino + app/push) com a dívida atual e o pedido para renovar o
 *   VIP (para a multa dar pausa). Cron diário rmbz_cron_fine_accruing_reminder
 *   (em rmbz_unlock_funds_hook), com guarda de cadência por projeto
 *   (fine_reminder_last) e intervalo configurável via filtro
 *   rmbz_fine_reminder_interval_hours (default 24h). Template novo 'fine_reminder'.
 *
 * == 1.5.391 (2026-06) — 🔒 Site: projeto TRANCADO pela multa, paga-se só lá ==
 *   No site, quando há multa o projeto fica TRANCADO: mostra só o bloco "Projeto
 *   trancado por multa" (valor + botão Pagar multa + Saiba mais) e esconde o resto
 *   do projeto até ser paga. O botão "Pagar multa" abre um MODAL para introduzir o
 *   PIN (rmbz_render_fine_pay_modal) em vez do campo inline. Removido o "Pagar
 *   multa" da área de subscrição (topo) — o pagamento existe só no próprio projeto.
 *
 * == 1.5.390 (2026-06) — 💳 "Pagar multa" DENTRO do cartão do projeto (site) ==
 *   A multa pertence a um projeto específico, por isso o botão "Pagar multa"
 *   (com PIN) passa a aparecer também DENTRO do cartão desse projeto na Minha
 *   Conta (não só na área de subscrição). Mostra o valor, o atraso e a taxa, e
 *   usa o mesmo handler rmbz_pay_project_fine. Só aparece no projeto a que a
 *   multa corresponde (project_id) e quando há valor a pagar.
 *
 * == 1.5.389 (2026-06) — 💳 "Pagar multa" também no SITE ==
 *   O site (Minha Conta) passa a ter o botão para pagar a multa por atraso com o
 *   saldo da carteira — nos dois avisos (subscrição expirada e multa congelada).
 *   Campo de PIN + confirmação; reutiliza rmbz_project_pay_fine_from_wallet
 *   (valida PIN, debita, limpa a dívida, regista no histórico). Handler novo
 *   rmbz_site_pay_project_fine (admin_post[_nopriv]_rmbz_pay_project_fine).
 *
 * == 1.5.388 (2026-06) — ⏱️ Regista o momento exato da perda de acesso a vídeo ==
 *   A multa por atraso passa a contar a partir do momento REGISTADO em que o
 *   acesso ao Ganhar Vídeo se perdeu (extra_data['video_access_lost_at']), em vez
 *   de depender só da data de fim da subscrição. Assim funciona também quando o
 *   artista tem um VIP ativo SEM o serviço de vídeo (a multa continua a crescer),
 *   nunca falhando. Baseline: fim da subscrição se expirada; senão, início da
 *   subscrição ativa sem vídeo. Limpa-se quando o acesso a vídeo volta. O freeze e
 *   o cálculo ao vivo usam o mesmo start_ts (rmbz_project_fine_start_ts), coerente
 *   com fine_paid_until.
 *
 * == 1.5.387 (2026-06) — ⭐ Só o VIP com Ganhar Vídeo pausa a multa ==
 *   A multa por atraso só é PAUSADA por uma subscrição ativa que INCLUI o serviço
 *   Ganhar Vídeo (permissão 'video') — não basta um VIP qualquer (ex.: um plano só
 *   de Site/Redes não pausa). Helper novo rmbz_user_has_active_video_subscription;
 *   rmbz_project_overdue_info passa a usá-lo em vez de "tem subscrição ativa".
 *   VIP legado sem vip_permissions definidas assume acesso total (não penaliza).
 *
 * == 1.5.386 (2026-06) — ℹ️ "Saiba mais" da multa + regra do VIP Ganhar Vídeo ==
 *   Botão "Saiba mais" nos avisos de multa (site e app) que abre um modal a
 *   explicar: a multa NÃO desaparece ao renovar (terás de a pagar); precisas do
 *   VIP que inclui o serviço Ganhar Vídeo; nunca o deixes expirar com um projeto
 *   de vídeo ativo. Na app, quem tem o Assistente de IA tem o botão para uma
 *   explicação personalizada (explain-fine), agora com estas regras no prompt.
 *   - Site: rmbz_render_fine_info_modal() nos dois avisos (expirado/congelado).
 *   - App: FineInfoDialog (partilhado) na OverdueFineBanner e no ProjectFineLockCard.
 *   - IA: prompt de explain-fine reforça as regras acima.
 *
 * == 1.5.385 (2026-06) — 🧠 Assistente sabe o estado real (multa congelada ≠ expirada) ==
 *   A recomendação do Assistente na Ganhar Vídeo dizia "subscrição expirou / multa
 *   a acumular" mesmo quando o VIP já estava renovado e a multa CONGELADA. Agora:
 *   - App: a recomendação distingue CONGELADA (kind FINE → "Multa pendente por
 *     pagar", abre o projeto) de EXPIRADA (renovar). Os factos enviados à IA
 *     (listTip) passam a incluir o estado exato do VIP/multa + os problemas de
 *     CADA projeto (correção, orçamento, decisões, datas, pagamento, mensagens).
 *   - Servidor: o contexto de atividade da IA (insights-engine) também distingue
 *     multa congelada de expirada — o assistente geral deixa de dar info errada.
 *
 * == 1.5.384 (2026-06) — 💳 Botão "Pagar multa" (carteira) + aviso na Ganhar Vídeo ==
 *   O artista pode agora PAGAR a multa por atraso com o saldo da carteira, na app
 *   (banner da Ganhar Vídeo e cartão de bloqueio do projeto). Confirma com PIN,
 *   debita o valor, limpa a dívida e regista no histórico do projeto. Para não
 *   recobrar o período já pago, grava fine_paid_until (o cálculo ao vivo só conta
 *   a partir do pagamento se o VIP continuar expirado) — aplica-se também ao
 *   "marcar paga" do admin.
 *   - API: POST /projects/pay-fine (PIN); /projects/{id} já trazia overdue_fine.
 *   - project-fine.php: rmbz_project_pay_fine_from_wallet + fine_paid_until.
 *   - App: PayFinePinDialog (partilhado), botão na OverdueFineBanner e no
 *     ProjectFineLockCard; ProjectsViewModel/ProjectDetailViewModel.payFine.
 *
 * == 1.5.383 (2026-06) — ↩️ Multa volta a contar DESDE APROVADO ==
 *   Revertida a regra de 1.5.381 (só Filmagem/Edição), que tinha feito a multa
 *   "desaparecer" dos projetos em Aprovado. Agora rmbz_project_inprogress_states()
 *   = ['pre-production','kyc_review','filming','editing'] — a multa aplica-se a
 *   partir de Aprovado (inclui KYC, Filmagem, Edição). Só "Em Avaliação" (antes da
 *   aprovação) fica livre. Admin/app/site/IA herdam o helper central.
 *
 * == 1.5.382 (2026-06) — 🔒 Projeto TRANCADO na app com multa + alerta da IA ==
 *   Quando há multa por atraso/pendente, o videoclipe fica TRANCADO no detalhe
 *   da app: as ações de progressão (orçamento, contrato, datas, pagamento) ficam
 *   escondidas e aparece um cartão de bloqueio com o valor; o CHAT mantém-se para
 *   o artista combinar o pagamento. Para quem tem o add-on de IA, um botão
 *   "Assistente IA: porquê?" pede uma explicação com tato.
 *   - API: /projects/{id} passa a incluir overdue_fine no detalhe; novo endpoint
 *     POST /me/ai/explain-fine (VIP+IA) que a IA usa para alertar/explicar.
 *   - App: ProjectDetail ganha overdueFine; ProjectFineLockCard + FineExplanationDialog;
 *     ViewModel.explainFine via InsightsRepository.
 *
 * == 1.5.381 (2026-06) — 🎬 Produção (e multa) começa na Filmagem ==
 *   A produção real do vídeo começa na FILMAGEM. A multa por atraso / bloqueio
 *   da equipa passa a contar SÓ em Filmagem e Edição. As fases de preparação
 *   (Em Avaliação, Aprovado, KYC) já NÃO geram multa nem exigem VIP ativo.
 *   - rmbz_project_inprogress_states() = ['filming','editing'] (era 5 estados).
 *   - Admin: banner de multa usa o helper central (mesma regra).
 *   - App: isProduction() = filmagem/edição; Aprovado/KYC contam como preparação.
 *   - IA: conhecimento atualizado (produção começa na filmagem; prep não tem multa).
 *
 * == 1.5.380 (2026-06) — 🏷️ Estado "Pré-Produção" renomeado para "Aprovado" ==
 *   Consistência: o estado pre-production (que já desbloqueia o projeto) passa a
 *   chamar-se "Aprovado" em TODO o lado (a lista do admin já era assim). A chave
 *   interna mantém-se 'pre-production' — sem migração de BD. Atualizado: pills do
 *   detalhe (admin), sublabels dos botões (Aprovar → "passa a Aprovado
 *   (desbloqueia)"), labels das notificações push, recomendação do chat, app
 *   (ProjectsScreen/ProjectDetailScreen) e o estado mostrado ao artista no site
 *   (antes mostrava "PRE-PRODUCTION" cru → agora "Aprovado").
 *
 * == 1.5.379 (2026-06) — 🔒 Multa NÃO desaparece ao renovar o VIP (fica pendente) ==
 *   Antes a multa por atraso era só calculada ao vivo (VIP expirado vs agora);
 *   ao renovar o VIP dava 0 e a dívida desaparecia. Agora a multa PERSISTE no
 *   projeto (extra_data['pending_fine']): acumula ao vivo enquanto expirado
 *   (high-water) e, ao renovar (app/site/carteira chamam
 *   rmbz_project_freeze_pending_fine antes de estender), fica como dívida
 *   PENDENTE/congelada até o admin a marcar como paga. A equipa só deve
 *   continuar depois de resolvida.
 *   - project-fine.php: pending_fine get/set/clear/freeze; overdue_info devolve
 *     'frozen'/'pending'; handler admin rmbz_clear_project_fine (+ log no histórico).
 *   - Admin: banner mostra a multa pendente mesmo com VIP ativo + botão
 *     "Marcar multa como paga".
 *   - Site: aviso de multa pendente no ecrã de VIP ativo.
 *   - App: OverdueFine ganha frozen/pending; banner adapta texto (sem botão de
 *     renovar quando já está VIP).
 *
 * == 1.5.378 (2026-06) — 🎨 Aviso de multa (admin) Material 3, centrado e animado ==
 *   O aviso "Subscrição expirada + multa" no detalhe do projeto (admin) era duas
 *   barras vermelhas planas. Agora é um cartão M3 centrado: ícone com pulso/halo,
 *   título, descrição e a multa em destaque (valor grande com brilho animado +
 *   taxa por hora/dia + selo "informativa"). Animação de entrada suave.
 *
 * == 1.5.377 (2026-06) — 💸 Banner de multa (site) mostra a TAXA por hora/dia ==
 *   No site, o aviso de multa por atraso só mostrava o valor acumulado — que é
 *   0,00 MT na 1.ª hora — parecendo que não havia multa configurada. Agora mostra
 *   a TAXA (ex.: "Multa de 15,00 MT/hora — já acumulou 0,00 MT (0 hora(s) de
 *   atraso)"), tal como já faziam o admin e a app. Lê rmbz_ov['rate'].
 *
 * == 1.5.376 (2026-06) — 💯 VIP respeita o pacote (preço/dias configurados) ==
 *   Revertida a normalização para 30 dias (1.5.375), que com rmbz_sub_days mal
 *   configurado dava preços errados (ex.: 800/15d aparecia 53,33 = 800÷15). Agora
 *   o preço/período CONFIGURADOS são a unidade base e as durações MULTIPLICAM
 *   esse período: 800/15d → 800 (15d), 1600 (30d), 2400 (45d), 3200 (60d),
 *   9600 (180d). Os rótulos mostram os dias reais (e a forma em meses quando é
 *   múltiplo exato de 30: "1 mês", "2 meses", "1 ano"). Helper novo
 *   rmbz_vip_duration_label_days; removido rmbz_vip_month_days. Admin/app/site
 *   atualizados (editor explica "× período"; cartão da app mostra dias reais).
 *
 * == 1.5.375 (2026-06) — 🧠 VIP inteligente: normaliza dias → meses reais ==
 *   O preço das durações deixa de assumir que o pacote é de 30 dias. Agora o
 *   preço configurado (para os `dias` do pacote) é convertido proporcionalmente
 *   para MESES reais (rmbz_vip_month_days = rmbz_sub_days, default 30).
 *   Ex.: pacote de 15 dias por 2000 MT → 1 mês (30 dias) = 4000 MT, 2 meses =
 *   8000 MT, etc. Para pacotes já de 30 dias, nada muda. A escala aplica-se
 *   também ao add-on de IA e aos dias creditados na subscrição.
 *   - Helper novo rmbz_vip_month_days(); rmbz_vip_compute_price usa escala.
 *   - Admin: nota do editor explica a conversão automática.
 *   - App: cartão de plano mostra "desde {preço de 1 mês} · 30 dias".
 *
 * == 1.5.374 (2026-06) — 📱 Checkout VIP: durações bonitas + sem corte ==
 *   Ajustes ao seletor de duração no app (checkout de subscrição):
 *   - Durações passam a uma GRELHA uniforme de 2 colunas (cartões de igual
 *     largura/altura, selo de desconto no canto) em vez do FlowRow desalinhado.
 *   - Cada cartão mostra o preço-base da duração; o add-on de IA é somado à
 *     parte (linha de IA + Total), evitando confusão.
 *   - O conteúdo do diálogo "Como queres pagar?" passa a ser DESLIZÁVEL — com
 *     planos longos (ex.: 1 ano) o botão "Pagar por M-Pesa / E-Mola" deixava
 *     de aparecer por ficar cortado; agora chega-se a ele com scroll.
 *
 * == 1.5.373 (2026-06) — 📜 Histórico do projeto: regista mais + modal completo ==
 *   1) O histórico de alterações do projeto de vídeo só registava ações pontuais
 *      (contrato/KYC/pagamento). Agora o save principal do admin
 *      (onemz_admin_update_project) também regista: mudança de ESTADO da produção
 *      (de→para com rótulos), link do vídeo final, link do YouTube, data de
 *      lançamento, alteração do guião e marcação de pago/não pago.
 *   2) No admin, o bloco "Histórico de alterações" tem agora um botão "Ver tudo"
 *      que abre um MODAL com o histórico completo do projeto (endpoint info?full=1
 *      devolve tudo; sidebar continua a mostrar os 20 recentes). Retenção subiu
 *      de 50 → 200 entradas. rmbz_pchat_get_change_log aceita $limit.
 *
 * == 1.5.372 (2026-06) — 🗓️ VIP por 1/2/3/4/12 meses (com ou sem IA) ==
 *   A subscrição VIP pode agora ser paga por 1, 2, 3, 4 ou 12 meses, com ou sem
 *   o add-on de IA. Preço = preço base × meses (+ IA × meses), com um desconto
 *   opcional por duração definido no admin (pacote['duration_discounts']).
 *   - Helper: rmbz_vip_duration_months/label/package_discount/compute_price/
 *     package_duration_options (subscription-functions.php).
 *   - API: /subscription/plans expõe 'durations' por pacote; /subscription/buy
 *     aceita 'months' e calcula total/dias/IA via helper (estende dias×meses).
 *   - Admin: editor de pacote com desconto % por duração (2/3/4/12 meses).
 *   - Site: paywall com seletor de duração e recálculo do total ao vivo.
 *   - App: chips de duração no checkout + total dinâmico (com desconto e IA).
 *
 * == 1.5.371 (2026-06) — 🎬 Aviso de multa + email Material 3 Expressive ==
 *   1) Quando a subscrição está a 3 dias de expirar E o artista tem um videoclipe
 *      EM PRODUÇÃO, o cron envia agora a notificação 'vip_expiring_project_fine'
 *      (em vez da genérica) a explicar a REGRA da multa por atraso (valor/dia ou
 *      /hora conforme a config) e a pedir renovação antes da data. Helper novo
 *      rmbz_project_fine_rule_text(). Caso não tenha projeto, mantém 'vip_expiring'.
 *   2) Template de email redesenhado em Material 3 Expressive (rmbz_build_html_email):
 *      layout em tabela + estilos inline (sobrevive ao Gmail/Outlook), faixa de
 *      marca dourada com emoji-herói, cartão tonal, dark-mode e barra expressiva.
 *      rmbz_notify passa a embrulhar TODOS os emails de notificação neste template
 *      (emoji/eyebrow vindos do meta da template).
 *
 * == 1.5.370 (2026-06) — ✏️ Taxa de vistoria: valor manual por pessoa ==
 *   Ao aplicar a taxa de vistoria das pessoas externas pedidas pelo artista, o
 *   admin já pode editar o valor de CADA pessoa manualmente (campo MT por linha,
 *   pré-preenchido com o valor sugerido do departamento) antes de adicionar ao
 *   orçamento. O total atualiza ao vivo. Mantém-se o atalho "usar valores
 *   sugeridos" para aplicar tudo com o valor base. Handler rmbz_team_apply_fee
 *   aceita POST fee_manual[member_id] (clamp >=0) ou GET (valores sugeridos).
 *
 * == 1.5.369 (2026-06) — 🏷️ Log de emails: "Só no sino" claro (não parece enviado) ==
 *   O estado 'so_sino' (NÃO enviou email — só o sino do artista) aparecia a
 *   vermelho "SO_SINO" e com destinatário <tel>@1mz.local, parecendo que tinha
 *   enviado. Agora mostra "Só no sino" (azul) e "<tel> — sem email (só sino)".
 *   Confirma que o fix de 1.5.366 funciona: emails a placeholders NÃO são
 *   enviados (só notificação no sino/app).
 *
 * == 1.5.368 (2026-06) — 🐛 Fatal "Equipas & Pessoas": project-team.php não carregava ==
 *   O ficheiro includes/admin/project-team.php (funções rmbz_team_*, schema das
 *   equipas e handlers admin-post de membros) nunca era incluído pelo loader →
 *   Fatal "Call to undefined function rmbz_team_departments()" ao abrir a aba de
 *   equipa, partindo a página (lista de pessoas sumia). Adicionado o
 *   rmbz_carregar_ficheiro('includes/admin/project-team.php').
 *
 * == 1.5.367 (2026-06) — 🩹 Banner de subscrição desalinhava o chat (admin projeto) ==
 *   O aviso de subscrição no detalhe do projeto (admin) era filho direto do grid
 *   sem área → caía numa célula implícita e desalinhava o chat. Agora tem a sua
 *   própria linha "alert" no topo (span total); colapsa quando não há aviso.
 *
 * == 1.5.366 (2026-06) — 📧 Não enviar emails para placeholders (@1mz.local) ==
 *   BUG: emails (ex.: "Subscrição expirada") iam para endereços falsos
 *   <tel>@1mz.local de contas sem email real, e o log marcava SUCESSO. Agora
 *   `rmbz_notify` trata email @1mz.local/ inválido como SEM email (só sino), e há
 *   uma rede de segurança central (filtro pre_wp_mail) que bloqueia qualquer
 *   wp_mail cujos destinatários sejam todos @1mz.local. Emails do admin (reais)
 *   não são afetados.
 *
 * == 1.5.365 (2026-06) — 🧹 Limpar imagens órfãs ao apagar conta/projeto ==
 *   BUG: ao apagar uma conta (expiração 1º depósito 24h OU lixeira do admin) os
 *   comprovativos de depósito e as imagens dos projetos ficavam no servidor E na
 *   biblioteca de média. Novo includes/media-cleanup.php (rmbz_delete_media_by_url
 *   apaga o ANEXO WP — post + ficheiro + tamanhos — ou o ficheiro local;
 *   rmbz_collect_uploads_urls_from_json varre os JSON dos projetos). Ligado à
 *   expiração de 24h e à purga permanente (proofs + anexos de chat + imagens de
 *   projeto). rmbz_deposit_delete_proof_file também passa a apagar o anexo WP.
 *
 * == 1.5.364 (2026-06) — 🛡️ Revisão de segurança: nonce em onemz_update_feedback ==
 *   Passagem aos 130 handlers admin_post + 27 nopriv: todos com capability/sessão
 *   + nonce (e ownership por artist_telemovel). Recovery/PIN-reset bem protegido
 *   (key, hash_equals, limite de tentativas, expiração, rate-limit). ÚNICA falha
 *   real: `onemz_update_feedback` não tinha nonce (CSRF, baixo impacto — só
 *   mensagem no próprio projeto) → corrigido (nonce no form + handler).
 *
 * == 1.5.363 (2026-06) — 🔒 IA do "Próximo pagamento" só para VIP com IA ==
 *   BUG: a análise de IA no Próximo Pagamento aparecia SEM VIP (só verificava a
 *   chave global, não o add-on de IA do utilizador). Agora exige
 *   rmbz_ai_user_is_vip — sem VIP/IA, não é gerada nem enviada.
 *
 * == 1.5.362 (2026-06) — 🔝 Notificações: efeito dourado chega ao topo da sheet ==
 *   APP: o drag handle por defeito ficava acima do efeito → faixa pálida no topo.
 *   Agora usamos um handle PRÓPRIO dentro do glow, e o dourado cobre a sheet toda
 *   até cima. NotificationsSheet.kt.
 *
 * == 1.5.361 (2026-06) — 🌙 Notificações: contraste em modo escuro (sheet dourado) ==
 *   APP: o painel de notificações VIP é dourado CLARO mesmo em modo escuro, mas os
 *   textos usavam a cor do tema (clara no escuro) → ficavam invisíveis ("Tudo em
 *   dia!", título, etc.). Agora o sheet dourado usa texto/acento ESCUROS fixos
 *   (legíveis), independentemente do tema. NotificationsSheet.kt.
 *
 * == 1.5.360 (2026-06) — 🎨 Assistente: animação no card de cima (sem duplicado) ==
 *   Correção do pedido anterior: o efeito de cores a fluir foi para o CARTÃO DE
 *   CIMA (AssistantHero) e removi o card extra que tinha criado — deixava DOIS
 *   "Ainda sem dados para analisar". Agora é um só, animado.
 *
 * == 1.5.359 (2026-06) — 🔐 Fix logout "do nada" (corrida de refresh de token) ==
 *   CAUSA: a Home faz ~9 chamadas em paralelo; ao expirar o access token, todas
 *   recebiam 401 e tentavam refrescar com o MESMO refresh token. A 1ª rotacionava
 *   (servidor revoga o antigo) e as outras recebiam "revogado" → logout súbito.
 *   APP: refresh agora é SINGLE-FLIGHT (só um corre; os outros reusam o token
 *   novo) — AuthInterceptor. SERVIDOR: tolerância de 60s a tokens 'rotated'
 *   (corridas/retries não desligam a sessão; admin/taken_over continuam a barrar).
 *
 * == 1.5.358 (2026-06) — 🎨 Estado "ainda sem dados" do Assistente com cores a fluir ==
 *   APP: o estado vazio "Ainda sem dados para analisar" no Assistente passou a ter
 *   as cores a FLUIR por dentro (dourado no VIP, roxo no resto) + pulsar suave, em
 *   vez do estado liso. AssistantEmptyAnimated em InsightsScreen.kt.
 *
 * == 1.5.357 (2026-06) — ✨ Assistente do Artista (Home): brilho a mover + pulsa + aparece sempre ==
 *   APP: o card "Assistente do Artista" na Home ganha um BRILHO a varrer por
 *   dentro (dourado no VIP, branco no não-VIP) e um pulsar suave. BUG: deixava de
 *   aparecer quando não-VIP/sem relatórios — agora aparece SEMPRE (não precisa de
 *   dados de release). InsightsEntryCard.kt + HomeScreen.kt.
 *
 * == 1.5.356 (2026-06) — 📐 Espaçamento: card "Objetivo" já não cola no "Insights" ==
 *   APP (Assistente IA): o card 🎯 Objetivo passou a ter padding vertical, deixando
 *   de tocar/invadir a linha "Insights" logo abaixo. (ArtistGoalCard.kt)
 *
 * == 1.5.355 (2026-06) — 🌱 Coach diário aparece SEMPRE (mesmo sem releases) ==
 *   APP: a Home deixou de só pedir o Coach quando havia relatórios — agora pede
 *   SEMPRE. Contas novas (sem dados de distribuição) passam a ver o Coach com
 *   motivação + dicas dos serviços que ainda não usaram (servidor já não bloqueia
 *   sem dados, desde 1.5.354).
 *
 * == 1.5.354 (2026-06) — 🌅 Coach diário: todos os serviços, por período, sem repetir ==
 *   O Coach deixou de ser só dos imports/streams: passa a olhar para TODA a
 *   presença (lançamentos, site, redes, videoclipes). Foco em MOTIVAR, com tom
 *   por PERÍODO do dia (manhã/tarde/noite → mensagem muda 3x/dia, cache por
 *   período). NUNCA repete dicas (lista persistente por artista; template escolhe
 *   uma nova, a IA é instruída a evitar as recentes). Textos LOCAIS (sem IA) +
 *   IA gera e guarda em cache local. rmbz_coach_presence/segment/seen_*.
 *
 * == 1.5.353 (2026-06) — 📚 IA aprende as REGRAS importantes da plataforma ==
 *   rmbz_ai_app_knowledge passa a incluir as regras oficiais com valores REAIS da
 *   config: primeiro depósito (mín. + 24h), subscrição VIP (preço/dias), carteira/
 *   royalties (USD→MT, PIN), convites (% ou dias), distribuição, castigos
 *   temporários/permanentes, e o fluxo do Ganhar Vídeo (candidatura→orçamento 24h
 *   →produção, subscrição obrigatória + multa). Instruída a não inventar regras.
 *
 * == 1.5.352 (2026-06) — 🧠 IA sabe da multa por atraso (vídeo sem subscrição) ==
 *   A IA passa a conhecer a regra: videoclipe EM PRODUÇÃO exige subscrição ativa;
 *   se expirar, acumula MULTA por dia/hora (valor real do admin) até renovar.
 *   Adicionado ao conhecimento do app (rmbz_ai_app_knowledge, com o valor/unidade
 *   reais) E ao contexto do artista (rmbz_ai_artist_activity injeta a multa ATIVA
 *   agora, se houver) — para a IA avisar/explicar com números reais.
 *
 * == 1.5.351 (2026-06) — 🤖 Botão Assistente IA no painel só para quem tem IA ==
 *   O botão do Assistente IA no painel de notificações só aparece para quem tem
 *   o add-on de IA (AiEntitlement.canUseAi). Quem não tem não vê o botão.
 *
 * == 1.5.350 (2026-06) — ✨ Painel de notificações VIP dourado + botão Assistente IA ==
 *   APP: o painel do sino, para VIP, fica todo em DOURADO FRACO com um glow a
 *   "respirar" (apaga e vem). Cards brancos com borda dourada para destacarem.
 *   Novo botão de Assistente IA no canto superior direito (abre o Assistente).
 *
 * == 1.5.349 (2026-06) — 🔔 Sino abre painel de notificações (app, sem mudar de página) ==
 *   APP: tocar no sino abre um bottom sheet bonito e fluído com as notificações
 *   (em vez de navegar para uma página). VIP-aware: cabeçalho/acento DOURADO para
 *   VIP, simples para os restantes. Limpar tudo, dispensar uma a uma, abrir
 *   release, carregar mais — tudo no painel. NotificationsSheet.kt.
 *
 * == 1.5.348 (2026-06) — 🗂️ E-mails + Pré-Deploy como abas das Configurações ==
 *   "📧 Configuração de E-mails" e "🛡 Pré-Deploy" deixaram de ser menus próprios
 *   e passaram a ABAS em Configurações 1MZ For Artists. E-mails: sub-abas internas
 *   movidas para `etab` (não colidir com `tab`). Corpos via
 *   rmbz_render_email_settings_body() / rmbz_predeploy_render_body(); redirects e
 *   URLs atualizados. Configurações concentram agora: Geral, PWA, Diagnóstico
 *   Push, Cleanup, E-mails e Pré-Deploy.
 *
 * == 1.5.347 (2026-06) — 🗂️ Cleanup Automático movido para aba das Configurações ==
 *   O "🧹 Cleanup Automático" (releases abandonadas + contas 1º depósito 24h)
 *   deixou de ser menu próprio e passou a ser ABA em Configurações 1MZ For
 *   Artists. As sub-abas internas usam `cltab` (para não colidir com o `tab` das
 *   Configurações). Corpo via rmbz_release_cleanup_render_body(); handlers e
 *   URLs atualizados; compat antigo (rmbz-expiry-log) mantido.
 *
 * == 1.5.346 (2026-06) — 🔀 Push correto ao trocar de conta no mesmo telemóvel ==
 *   Antes, se o utilizador saísse e entrasse com OUTRA conta no mesmo telemóvel
 *   e o logout não chegasse a desregistar (app morta/offline), a conta ANTIGA
 *   continuava a receber push nesse aparelho. Agora ao registar o token, o
 *   servidor remove esse device (device_id) de QUALQUER outra conta e desativa
 *   o mesmo fcm_token noutras contas — um aparelho = uma conta de cada vez.
 *
 * == 1.5.345 (2026-06) — 🗂️ Diagnóstico Push movido para aba das Configurações ==
 *   O "Diagnóstico Push" deixou de ser um menu próprio e passou a ser uma ABA em
 *   Configurações 1MZ For Artists ("🩺 Diagnóstico Push"), junto de Geral e
 *   Notificações PWA. Corpo via rmbz_fcm_diag_render_body(); redirects dos
 *   handlers (teste/reativar) apontam para a aba.
 *
 * == 1.5.344 (2026-06) — 🧹 Push: limpar tokens mortos (NOT_FOUND) ==
 *   Push já funciona (sent>0). Limpeza dos tokens mesmo mortos: desativar também
 *   em NOT_FOUND (404) — antes só UNREGISTERED. INVALID_ARGUMENT só desativa se
 *   a mensagem indicar token inválido ("registration token..."), distinguindo do
 *   nosso erro de payload. Estados antigos limpam-se no próximo envio real.
 *
 * == 1.5.343 (2026-06) — 🎯 CAUSA do push: fcm_options vazio (INVALID_ARGUMENT) ==
 *   O diagnóstico revelou 64 tokens com INVALID_ARGUMENT. Causa: quando a
 *   notificação não tem URL, `array_filter` deixava `webpush.fcm_options` = []
 *   (array vazio); a FCM v1 espera um objeto → rejeitava TODOS os envios sem URL
 *   (a maioria) com INVALID_ARGUMENT, e o código marcava os tokens BONS como
 *   inativos. Fix: nunca emitir fcm_options vazio (só com link real). Defesa:
 *   só UNREGISTERED desativa um token (INVALID_ARGUMENT já não). Botão no
 *   diagnóstico para REATIVAR os tokens apagados por engano.
 *
 * == 1.5.342 (2026-06) — 🩺 Diagnóstico de Push (FCM) no admin ==
 *   Nova página admin "🔔 Diagnóstico Push" que verifica passo-a-passo porque o
 *   push pode não funcionar: constante RMBZ_FCM_CREDENTIALS_PATH, ficheiro do
 *   service account, projeto Firebase (tem de ser 'com-onemzrecords-artists'),
 *   OpenSSL, obtenção real do access token OAuth, e contagem de tokens ativos.
 *   Inclui botão "Enviar push de teste" a um telemóvel. includes/api/fcm/
 *   fcm-diagnostics.php. (App e servidor já usam FCM v1 — não é a API legacy.)
 *
 * == 1.5.341 (2026-06) — 🛟 Fix erro fatal ao guardar castigos (referral blacklist) ==
 *   Guardar os castigos do artista rebentava: `update_option('rmbz_referral_
 *   blacklist', Array)` disparava o sanitize_callback que fazia explode() numa
 *   string — mas recebia um ARRAY → TypeError fatal. `rmbz_sanitize_referral_
 *   blacklist` passa a aceitar array (programático) E string (textarea do admin).
 *
 * == 1.5.340 (2026-06) — 🕐 Fuso horário dos bloqueios + cronómetro no ecrã bloqueado ==
 *   FUSO: a data de desbloqueio era mal interpretada (strtotime no fuso do PHP +
 *   current_time('timestamp') misturado com wp_date) → hora errada. Agora os
 *   `until` são timestamps UNIX REAIS: a data escrita lê-se no fuso do WordPress
 *   (wp_timezone) e as comparações usam time(). Round-trip correto no admin.
 *   APP: quando a Distribuição (ou Vídeo) está bloqueada de forma TEMPORÁRIA, o
 *   ecrã "Acesso Restrito" mostra agora o motivo + o CRONÓMETRO até desbloquear
 *   (restrictions expostas no AccountStatusHolder → AccountBlockGuard).
 *
 * == 1.5.339 (2026-06) — 🐛 "Bloquear Distribuição" agora funciona na app ==
 *   BUG: o castigo block_distribution não tinha efeito na app — o AccountBlockGuard
 *   existia mas nunca era usado; a Distribuição só verificava a PERMISSÃO VIP,
 *   não o castigo. Corrigido: o PermissionGuard passa a aplicar também o castigo
 *   (distribute→distribution, video→video) em TODAS as rotas de distribuição.
 *   Defesa em profundidade no servidor: /releases (criar), /submit e /resubmit
 *   recusam quando bloqueado (rmbz_service_blocked_message, per-user OU global).
 *
 * == 1.5.338 (2026-06) — 🔔 Notificar bloqueio/desbloqueio (email+push+in-app) ==
 *   Ao APLICAR ou LEVANTAR (incl. expiração automática) uma limitação, o artista
 *   é avisado em TODOS os canais: notificação in-app (rmbz_notify_user → push FCM
 *   automático, dispensável) + EMAIL. Mensagens com o motivo do utilizador e a
 *   data de desbloqueio. Só notifica MUDANÇAS reais (não repete em cada save).
 *   App: o banner de limitação no Home, depois de tocado UMA vez, não volta a
 *   aparecer (SeenBannersStore por key:until). rmbz_blocks_notify/_send_email.
 *
 * == 1.5.337 (2026-06) — 🎨 Banner de limitações M3 + modal com IA ==
 *   App: o banner de limitações foi redesenhado (Material 3, cronómetro grande
 *   ao centro) e agora é CLICÁVEL → abre um modal com o motivo + cronómetro. Se
 *   o artista tiver IA, aparece o Assistente a explicar COM TATO porque tem a
 *   limitação (lê o motivo interno no servidor mas não o revela textualmente) e
 *   o que pode fazer. Novo endpoint POST /me/ai/explain-restriction (VIP+IA).
 *
 * == 1.5.336 (2026-06) — 📝 Castigos com motivos (interno+utilizador) + cronómetro ==
 *   Cada limitação/banimento passa a ter DOIS motivos: INTERNO (só admin, vai
 *   para o histórico; OBRIGATÓRIO — sem ele NÃO aplica) e PARA O UTILIZADOR
 *   (mostrado na app e no site). Histórico de castigos (aplicar/levantar/expirar)
 *   por artista no admin. O motivo do utilizador + cronómetro de desbloqueio
 *   aparecem no Home da app (restrictions no /me/profile) e no painel do site,
 *   e na mensagem de "conta suspensa" no login. O motivo interno NUNCA sai do
 *   servidor (rmbz_blocks_strip_internal). Modelo em extra['blocks_meta'].
 *
 * == 1.5.335 (2026-06) — ⛔ Bloqueios temporários/permanentes + Perfil M3 ==
 *   LIMITAÇÕES DE SERVIÇOS (perfil do artista no admin): cada limitação
 *   (banir login, levantamentos, depósitos, distribuição, videoclipe,
 *   promoções, convites) pode agora ser PERMANENTE ou TEMPORÁRIA com DATA de
 *   desbloqueio. Quando a data passa, o bloqueio é limpo automaticamente (sem
 *   cron): aplicado em cada pedido autenticado, no login e ao abrir o perfil.
 *   Novo includes/user-blocks.php (rmbz_apply_block_expiries). Datas em
 *   extra_data['blocks_until'] / ban_until / ban_referral_until. Design do
 *   perfil do artista atualizado para Material 3 (como as Configurações).
 *
 * == 1.5.334 (2026-06) — ⚙️ Configurações Material 3 (abas) + fix chave da API ==
 *   BUG CRÍTICO: a Chave da API (Anthropic) era APAGADA sempre que se guardava
 *   qualquer definição (o campo password submete vazio → o sanitize apagava).
 *   Agora "deixar em branco" MANTÉM a chave (rmbz_ai_sanitize_key). Página de
 *   Configurações modernizada (Material 3): secções separadas em SUB-ABAS num
 *   único formulário (guarda todos os campos de uma vez), cards, inputs e botão
 *   de guardar fixos no estilo M3. rmbz_settings_modern_styles/script.
 *
 * == 1.5.333 (2026-06) — 🔔 Notificação em modal completa + explicação com IA ==
 *   HOME (app): pressão LONGA (segurar ~2s) no banner inteligente abre a
 *   notificação completa num modal — para quando o texto é grande e não cabe.
 *   Com o add-on de IA, o Assistente explica PORQUÊ recebeu a notificação e,
 *   se for um problema, COMO resolver (passo a passo), no contexto real do
 *   artista. Sem IA, mostra um convite discreto para ativar o Assistente.
 *   Novo endpoint: POST /me/ai/explain-notification (VIP+IA, cache 1 dia).
 *
 * == 1.5.332 (2026-06) — 💸 Multa por atraso + recomendações IA mais completas ==
 *   GANHAR VÍDEO: se a subscrição expirar com um videoclipe EM PRODUÇÃO, o
 *   sistema conta automaticamente os dias/horas em atraso e gera uma MULTA
 *   acumulada (valor × tempo), configurável no admin (valor + dia/hora).
 *   Informativa — NÃO debita a carteira; o admin decide se/quando cobra.
 *   Mostrada na app (banner + recomendação), no site (ecrã de renovar) e no
 *   admin (detalhe do projeto). Recomendações de IA do Ganhar Vídeo mais
 *   completas: subscrição/entrega, contactar a equipa em demoras, etc.
 *   Novo: includes/project-fine.php; Defs: rmbz_project_fine_value/unit.
 *
 * == 1.5.331 (2026-06) — 📲 App já na Google Play (showcase) ==
 *   O [1mz_showcase] deixou de dizer "a chegar/a caminho": app JÁ DISPONÍVEL na
 *   Google Play (com.onemzrecords.artists). Links em "App Android" e na FAQ +
 *   botão "Descarregar na Google Play" no CTA.
 *
 * == 1.5.330 (2026-06) — ✨ Showcase modernizado + data-driven ==
 *   Redesign do [1mz_showcase] para não parecer "feito por IA": ícones SVG em
 *   tiles (gradiente índigo), paleta dourado+índigo, cartões em vidro, eyebrows
 *   de secção, FAQ com +/− animado, hero com selo refinado. Conteúdo agora
 *   DATA-DRIVEN (arrays $stats/$features/$highlights/$faqs) — fácil de atualizar.
 *
 * == 1.5.329 (2026-06) — 📦 [1mz_showcase] em ficheiro próprio ==
 *   O showcase passou de includes/shortcodes.php para o seu ficheiro dedicado
 *   includes/showcase-shortcode.php (carregado no plugin). É aí que se atualiza
 *   o conteúdo no futuro.
 *
 * == 1.5.328 (2026-06) — 🏅 Shortcode oficial [1mz_showcase] ==
 *   Página de apresentação oficial da 1MZ For Artists (selo, stats,
 *   funcionalidades, novidades, FAQ em acordeão, CTA) — full-bleed (escape no
 *   desktop, coluna no mobile).
 *
 * == 1.5.325 (2026-06) — 🔐 Página de login full com apresentação ==
 *   O login (my-account-page.php) ganhou um painel de apresentação (o que a 1MZ
 *   For Artists faz) ao lado do card: layout full-bleed em 2 colunas
 *   (.rmbz-auth-wrapper--split + .rmbz-auth-promo), fundo escuro premium. Em
 *   mobile empilha. O container de login é neutralizado para não duplicar caixa.
 *   [1.5.326] Painel redesenhado pro/moderno: features com tiles de ícone
 *   (gradiente roxo), badge com dot "online", stats com vidro + topo dourado.
 *   [1.5.327] Full-bleed igual ao Painel de Envio (.rmbz-fullbleed): escape só
 *   no desktop (left:50%/-50vw/100vw); no mobile respeita a coluna do tema
 *   (width:auto/max-width:100%) — resolve as faixas brancas/assimetria no mobile.
 *
 * == 1.5.324 (2026-06) — 🚀 Landing page da Distribuição (visitantes) ==
 *   O pequeno cartão "Acesso Restrito" foi substituído por um LANDING PAGE
 *   completo e full-bleed (shortcodes.php + .rmbz-distro-lp em 03-components.css):
 *   hero com CTA, marquee de lojas, features, "como funciona" (4 passos),
 *   diferenciais (Moçambique/M-Pesa) e CTA final. Server-rendered (bom p/ SEO),
 *   visual dark premium para convencer e apresentar a plataforma.
 *
 * == 1.5.323 (2026-06) — 🖥️ Gate "Acesso Restrito" full-width no site ==
 *   O gate de login da distribuição (shortcodes.php) ganhou a classe
 *   `rmbz-gate--full`: largura completa no site (em vez de cartão estreito) e
 *   features em grelha responsiva (2-3 colunas). Cabeçalho mantém-se centrado.
 *
 * == 1.5.322 (2026-06) — 🎬 Conceito de vídeo: roteiro estruturado por partes ==
 *   /me/ai/video-concept devolve `sections` [{title,content}] (Conceito, Cenário
 *   e locais, Estética, Cenas principais, Guarda-roupa, Produção), adaptado ao
 *   nível e à ideia. App: modal full-screen moderno, editável PARTE A PARTE;
 *   "Usar roteiro" combina as partes. Resolve o texto desorganizado.
 *
 * == 1.5.321 (2026-06) — 💵 IA no "Próximo pagamento" ==
 *   next-payout devolve `ai_message` (+`ai_enabled`): análise personalizada do
 *   Assistente sobre o caminho até ao próximo pagamento (onde está, quanto
 *   falta, quando chega ao ritmo atual, 1-2 ações). Factos reais, nunca inventa;
 *   para todos quando a IA está ligada (como insights). Cache 12h. App: card
 *   "Análise do Assistente" no NextPayoutScreen.
 *
 * == 1.5.320 (2026-06) — 🎯 "O que perdi offline" preciso (away_since) ==
 *   rmbz_api_touch_last_seen deteta ausência > 20 min e guarda `away_since` na
 *   wallet. rmbz_ai_artist_activity usa-o para marcar "(novo)" os itens que
 *   surgiram desde que o artista ficou offline — o assistente diz exatamente o
 *   que ele perdeu, não só o que existe.
 *
 * == 1.5.319 (2026-06) — 🔔 "Perguntar" sabe novidades / o que perdeste offline ==
 *   rmbz_ai_artist_activity() reúne notificações recentes + avisos/mensagens
 *   gerais do admin (anúncios) + estado dos serviços (permissões) e injeta no
 *   /me/ai/ask. A IA responde "o que perdi offline?", "o admin mandou algo?",
 *   "algum serviço foi desativado?". Cache 5 min.
 *
 * == 1.5.318 (2026-06) — 🎬 Vídeo em produção exige subscrição ativa ==
 *   Regra: um videoclipe EM PRODUÇÃO precisa do artista com subscrição ativa —
 *   a equipa não trabalha sem ela. ADMIN (projects-admin-page detalhe): banner
 *   "Subscrição EXPIRADA — equipa NÃO autorizada a produzir" (ou aviso ≤7 dias).
 *   APP: banner na Home (VIDEO_NEEDS_SUBSCRIPTION) — vídeo em produção +
 *   subscrição expirada/a expirar → renova para a equipa continuar. (Só aviso,
 *   sem pausar automaticamente o projeto.)
 *
 * == 1.5.317 (2026-06) — 👁️ IA vê TUDO do artista (catálogo cross-serviço) ==
 *   rmbz_ai_artist_catalog() reúne lançamentos de distribuição + submissões
 *   (site/redes) + projetos de vídeo (Ganhar Vídeo) num bloco injetado nos
 *   chats (ask + report-chat). A IA passa a responder "quais os meus
 *   lançamentos?", "em que pé está a submissão?" com factos reais. Cache 10 min.
 *
 * == 1.5.316 (2026-06) — 🙂 Mensagens de erro de IA amigáveis ==
 *   rmbz_ai_err_hint mostra ao artista uma mensagem neutra ("Tenta novamente
 *   daqui a pouco / mais tarde") em vez de detalhes técnicos (chave, saldo,
 *   modelo). A causa real é registada via error_log para o admin diagnosticar.
 *
 * == 1.5.315 (2026-06) — 🐛 Fix: Opus 4.8 deprecou `temperature` ==
 *   O Opus 4.8 rejeita o parâmetro `temperature` (causava "não foi possível"
 *   em todas as ferramentas de IA com esse modelo). rmbz_ai_messages deixa de
 *   o enviar a modelos que não o suportam (rmbz_ai_model_supports_temperature).
 *
 * == 1.5.314 (2026-06) — 🩺 Erro de IA mostra a CAUSA (diagnóstico) ==
 *   As ferramentas de IA deixam de dar um genérico "tenta de novo": rmbz_ai_err_hint()
 *   traduz a causa real da API (modelo inválido, chave inválida, sem saldo,
 *   sobrecarga) e mostra-a na app. Ajuda a perceber falhas após trocar o modelo.
 *
 * == 1.5.313 (2026-06) — 🧠 Opus 4.8 nos chats + memória persistente ==
 *   Modelo: adicionado Opus 4.8 às opções; default do "Modelo de conversa"
 *   passou a Opus 4.8 (mais inteligente; recomenda ações proativas). Memória
 *   PERSISTENTE do "Perguntar ao assistente": guardada em
 *   wallet.extra_data['ai_chat_memory'] (cap RMBZ_AI_MEMORY_MAX=30, cross-device)
 *   + helpers get/append/clear; endpoints GET /me/ai/memory e POST
 *   /me/ai/memory/clear. App: AiChatMemoryStore guarda TUDO localmente; servidor
 *   guarda os últimos 30; "Limpar memória" limpa local + servidor.
 *
 * == 1.5.312 (2026-06) — 📚 FAQ alargado (IA, Ganhar Vídeo, segurança, convites…) ==
 *   +10 entradas em rmbz_support_faq_items() (Assistente de IA, limite diário,
 *   carteira vs receita, Ganhar Vídeo, submissões site/redes, recuperar PIN,
 *   biometria, convites). Beneficia o /support/faq E o assistente (mesma fonte).
 *
 * == 1.5.311 (2026-06) — 📚 "Perguntar ao assistente" sabe o app + FAQ ==
 *   O FAQ foi extraído para rmbz_support_faq_items() e o /me/ai/ask passa a
 *   receber rmbz_ai_app_knowledge() — visão geral das secções do app + FAQ
 *   oficial. O assistente responde agora a "como faço X no app" / configurações
 *   (com passos, sem inventar; se não souber, manda para o suporte).
 *
 * == 1.5.310 (2026-06) — 🤖 Admin: uso de IA por artista + revogar limite ==
 *   No perfil do artista (rmbz-historico-utilizador) há um card "Uso do
 *   Assistente de IA (hoje)": quanto usou de cada ferramenta (usado/limite,
 *   "Limite atingido") + botão "Revogar limites de IA" (control_action
 *   reset_ai_limits → rmbz_ai_reset_limits). Helpers em ai-client.php:
 *   rmbz_ai_tool_limits / rmbz_ai_usage_for / rmbz_ai_reset_limits.
 *
 * == 1.5.309 (2026-06) — 🤔 IA raciocina melhor (receita por plataforma + Sonnet) ==
 *   A IA passou a receber a RECEITA por plataforma (streams + USD), não só o
 *   volume de streams — resolve o caso "TikTok tem mais views mas o YouTube
 *   pagou mais". Prompts dos chats reforçados: pensar em DINHEIRO (não views) e
 *   ADAPTAR-SE quando o artista traz factos novos (deixa de repetir conselhos
 *   padronizados). Chats (ask + report-chat) passam a usar o modelo de conversa
 *   (default Sonnet 4.6, melhor raciocínio) — opção no admin
 *   (rmbz_ai_chat_model); insights/coach continuam no modelo barato. Cache
 *   report-chat v4.
 *
 * == 1.5.308 (2026-06) — 🧠 Memória de conversa nos chats de IA ==
 *   /me/ai/ask e /me/ai/report-chat aceitam `history` (turnos anteriores) →
 *   rmbz_ai_format_history injeta a conversa no prompt para continuidade
 *   (últimos 6 turnos). Cache do report-chat desligada quando há memória. App:
 *   envia os turnos anteriores e tem "Limpar memória" (com confirmação) na
 *   barra do chat (Detalhes com IA + Perguntar ao assistente).
 *
 * == 1.5.307 (2026-06) — 💸 IA não confunde carteira com receita da música ==
 *   Perfil do Artista deixa de incluir o saldo da carteira (esse dinheiro é
 *   para PAGAR serviços, não é receita). A IA fala de dinheiro só pelos ganhos
 *   das plataformas (USD dos relatórios). Cache do perfil v2 e do report-chat
 *   v3 para entrar de imediato.
 *
 * == 1.5.306 (2026-06) — 🪞 Tom honesto/realista também no Coach e Insights ==
 *   rmbz_coach_ai e rmbz_insights_ai_narrative: postura de manager honesto —
 *   apontam fraquezas (estagnação, queda, dependência de 1 plataforma/país,
 *   ganhos baixos), não só elogios; sugestões corrigem a fraqueza. Cache v2
 *   (coach + insights) para o novo tom entrar de imediato.
 *
 * == 1.5.305 (2026-06) — 🪞 "Detalhes com IA" mais realista e crítico ==
 *   report-chat: prompt de mentor honesto — aponta o que está mal (estagnação,
 *   queda, dependência de 1 plataforma/país, ganhos baixos), fundamenta com os
 *   números reais e datas, e fecha com 2-3 ações priorizadas. Resposta mais
 *   detalhada (~150-220 palavras, max_tokens 750). Cache v2 (estilo entra já).
 *
 * == 1.5.304 (2026-06) — 🧠 Perfil do Artista: a IA conhece cada artista ==
 *   rmbz_ai_artist_profile() monta um bloco de contexto (objetivo DECLARADO +
 *   factos calculados: fase, mercado, catálogo, trajetória, estatuto/saldo) que
 *   é injetado em TODA a IA (coach, ask, legenda, plano, kit, conceito de vídeo,
 *   report-chat). Só pedimos 1 coisa ao artista: o objetivo principal —
 *   GET/POST /me/ai/profile (guardado em wallet.extra_data['ai_goal']). App:
 *   card "Qual é o teu objetivo?" no Assistente (1 toque, editável).
 *
 * == 1.5.303 (2026-06) — 💬 "Detalhes com IA" por relatório (add-on de IA) ==
 *   POST /me/ai/report-chat — conversa de artista para artista sobre UM
 *   relatório: o artista pergunta em detalhe e a IA responde com os números
 *   REAIS desse relatório + o histórico de toda a vida (compara por data, dá
 *   opinião honesta sobre a evolução). Guard = add-on de IA + rate-limit; cache
 *   por (artista+relatório+pergunta). App: botão "Detalhes com IA" em cada
 *   relatório (LastImportScreen) → AiReportChatScreen.
 *
 * == 1.5.302 (2026-06) — 🔓 Mensagem certa para VIP sem o add-on de IA ==
 *   rmbz_ai_tools_guard distingue "sem subscrição" de "VIP ativo mas sem IA"
 *   (403 ai_addon_required) — a mensagem deixa de dizer "exclusivo VIP" a quem
 *   já é VIP. App: ao tocar numa ferramenta de IA, um VIP sem o add-on vê um
 *   aviso ("a subscrição não inclui IA — compra/renova com a opção de IA") em
 *   vez de ser atirado direto para os planos.
 *
 * == 1.5.301 (2026-06) — ✨ Recomendações das listas com IA opcional (genérico) ==
 *   Endpoint genérico POST /me/ai/list-tip — a app envia os FACTOS reais + a
 *   SITUAÇÃO; o servidor reescreve a dica em bom pt-PT, com cache por estado
 *   (não gasta tokens a cada abertura). Cobre as listas de Submissões
 *   (Site/Redes) E do Ganhar Vídeo (orçamento, decisões, produção, concluído…).
 *   Interruptor no admin: "Recomendações com IA" (rmbz_ai_recommendations_enabled).
 *   Desligado → a app usa textos pré-definidos (gratuitos, género correto).
 *
 * == 1.5.288 (2026-06) — 🐛 Fix: avisos VIP não chegavam a VIPs reais ==
 *   `rmbz_announcements_user_is_vip()` (announcements-controller.php)
 *   verificava `$perms['is_vip']` / `$perms['active_plan']`, mas
 *   `rmbz_api_get_user_vip_permissions()` só devolve as chaves
 *   `distribute/site/social/video` — nunca `is_vip` nem `active_plan`.
 *   Esse branch nunca disparava; o fallback dependia de
 *   `extra_data.active_plan_name` estar populado, o que pode falhar
 *   para um VIP real. Resultado: announcements segmentados a VIP
 *   podiam não chegar a utilizadores VIP reais.
 *
 *   FIX: usar a fonte canónica `rmbz_user_has_active_subscription()`
 *   (verifica `rmbz_subscriptions.end_date > now`), tal como já foi
 *   feito em `rmbz_ai_user_is_vip()` (ai-client.php).
 *
 *   Ficheiros:
 *     - includes/api/controllers/announcements-controller.php
 *     - 1mz-for-artists.php (version + changelog)
 *
 * == 1.5.195 (2026-05) — 🔢 Fix: unread_count nos projetos sempre devolvido a 0 ==
 *   Bug reportado pelo Jaime: o banner "Smart Home" na app não notifica
 *   mensagens novas do chat — só notifica o pagamento de videoclipe.
 *
 *   CAUSA RAIZ:
 *     O ViewModel da Home (`HomeViewModel.computePendingActions`) filtra
 *     projectos por `unreadCount > 0` para gerar `PROJECT_CHAT_MESSAGE`
 *     no banner. O model Kotlin `ProjectListItem` tem o campo
 *     `@SerialName("unread_count") val unreadCount: Int = 0`.
 *
 *     MAS o servidor — em `rmbz_api_proj_format_for_list()` — nunca
 *     incluía o campo `unread_count` na resposta JSON. Como kotlinx
 *     serialization tem default `= 0`, o cliente lia sempre 0 sem
 *     erro de parsing. Filtro `> 0` nunca dava match.
 *
 *     Resultado: a Home detectava perfeitamente "Pagamento do videoclipe"
 *     (lê de `is_paid`, `contract_accepted`, etc. que existem) mas
 *     ignorava completamente mensagens novas.
 *
 *   FIX:
 *     `rmbz_api_handle_projects_list()` faz agora uma query agregada
 *     adicional que conta, por projecto, mensagens com:
 *       - author_type = 'admin'      (só mensagens do admin contam)
 *       - read_by_artist = 0          (não lidas)
 *       - is_deleted = 0              (não apagadas)
 *
 *     Fonte: tabela `rmbz_project_chat_messages`. Mensagens legadas
 *     em `artist_feedback` (string) NÃO entram porque não têm flag
 *     de leitura — para histórico antigo o artista já abriu há tempos.
 *     A partir do fix 1.5.194 todas as mensagens novas convergem
 *     para a tabela nova, pelo que o contador funciona para tráfego
 *     corrente, que é o que interessa para alertas.
 *
 *     A query é única (1 SELECT com GROUP BY) e usa o índice composto
 *     `KEY idx_project (project_id, id)` que já existe na schema.
 *     Para N projectos: 1 query agregada em vez de N queries soltas.
 *
 *   CICLO COMPLETO (validado):
 *     1) Admin envia mensagem via REST `/admin-chat/{id}/send` ou via
 *        UI antiga (já refactorada em 1.5.194 para passar pelo helper)
 *     2) INSERT em `rmbz_project_chat_messages` com `read_by_artist=0`
 *     3) Push FCM para o telefone do artista
 *     4) Artista abre a app — `GET /me/projects` agora devolve
 *        `unread_count: N` para o projecto afectado
 *     5) Banner azul aparece na Home: "Mensagem nova em \"<título>\""
 *     6) Artista clica → abre detalhe do projecto
 *     7) `pullChat(projectId, since=0)` no servidor faz UPDATE marcando
 *        todas as não lidas como lidas (já estava implementado em
 *        `rmbz_pchat_artist_rest_get`, linha 158 do artist-chat.php)
 *     8) Próxima carga da Home: `unread_count` volta a 0, banner some
 *
 *   AFFECTED: includes/api/controllers/projects-controller.php
 *   (1 função alterada: rmbz_api_handle_projects_list).
 *
 *   COMO TESTAR:
 *     1) /wp-admin/ → Projetos & Chat → escreve mensagem num projecto
 *     2) Na app, pull-to-refresh na Home (ou re-abrir)
 *     3) Banner azul "Mensagem nova em \"<título>\"" aparece no topo
 *     4) Tap → abre o detalhe do projecto
 *     5) Voltar à Home → pull-to-refresh → banner desapareceu
 *
 *   CADEIA DE FIXES CONVERGENTE:
 *     1.5.192 — endpoints REST de chat passaram a estar registados
 *     1.5.193 — página admin Projetos & Chat passou a carregar
 *     1.5.194 — todas as escritas convergem para pipeline único com
 *               notificações + push automáticos
 *     1.5.195 — Smart Home da app passa a ver o contador correcto
 *               e a destacar mensagens novas no banner
 *
 *     Agora todo o ciclo funciona ponta a ponta: admin escreve → push
 *     no telefone → sino no centro de notificações → banner na Home →
 *     tap leva ao chat → marca lido → tudo volta a zero.
 *

 *   Bug reportado pelo Jaime: quando o admin respondia no chat de um
 *   projecto, o artista NÃO recebia push nem entrada no sino de
 *   notificações (menu Início da app). Investigação mostrou que o
 *   problema era mais amplo — 5 funções diferentes escreviam directamente
 *   em `artist_feedback` via SQL, pulando o helper centralizado.
 *
 *   CAUSA RAIZ:
 *     `rmbz_pchat_insert_message()` (e a sua variante `_as_artist`) são
 *     o ÚNICO sítio que dispara o pipeline completo de chat:
 *       a) INSERT em `rmbz_project_chat_messages` (fonte de verdade do
 *          chat live na app — polling 3s lê daqui)
 *       b) Espelha no `artist_feedback` legado (para a UI do site
 *          /minha-conta/ continuar a renderizar histórico)
 *       c) `rmbz_notify_create()` → linha em `rmbz_notifications`
 *       d) `do_action('rmbz_notification_created')` → FCM push automático
 *       e) Limpa transient `rmbz_admin_notification_counts` (badge admin)
 *
 *     Antes desta versão, 5 funções escreviam SQL directo a
 *     `artist_feedback` e pulavam (c), (d), (e) — e quando o destinatário
 *     era a app, também (a):
 *
 *       1) admin-handlers.php :: GATILHO 1 em onemz_admin_actualizar_projeto
 *          (rejeição/correção)
 *       2) admin-handlers.php :: onemz_admin_reply_chat (formulário Responder)
 *       3) admin-handlers.php :: onemz_admin_edit_chat (editar mensagem antiga)
 *       4) form-handler.php   :: onemz_update_feedback (artista no site)
 *       5) projects-controller.php :: rmbz_api_handle_project_feedback
 *          (sendFeedback legado da app, pré-1.6.40)
 *
 *     Sintomas em produção:
 *       - Admin responde via UI antiga → artista não recebe push
 *       - Artista envia via site /minha-conta/ → mensagem aparece no
 *         chat mas a app não vê (não está na tabela nova) até abrir
 *         o projecto, e nesse caso o dedup do helper merge mostra-a
 *         a partir do legado com id negativo (UX inconsistente)
 *       - Rejeição/correção: artista não sabe que houve mudança até
 *         entrar no projecto
 *
 *   FIX:
 *     4 funções refactoradas para passar pelo helper. A 3ª
 *     (onemz_admin_edit_chat) ficou intacta porque editar mensagem
 *     antiga não é evento novo e mapear índice legado → id da tabela
 *     nova é complexidade desnecessária.
 *
 *     CONFIRMADO COM JAIME: os artistas continuam a usar /minha-conta/
 *     no site para ver o chat dos videoclipes. Logo, mantemos a coluna
 *     `artist_feedback` e o helper continua a espelhar lá. A UI do
 *     site renderiza a partir desse campo, **não foi alterada** — zero
 *     regressão visual. A diferença é que agora qualquer canal de
 *     entrada (admin REST novo, admin UI antiga, site, app REST novo,
 *     app legado, rejeição automática) converge para o mesmo pipeline:
 *
 *         ┌─────────────────────────────────────┐
 *         │ Admin REST    Admin UI    Site form │  ← entradas
 *         │ App REST      App legado  Sistema   │
 *         └────────────────┬────────────────────┘
 *                          ↓
 *                  rmbz_pchat_insert_message()
 *                          ↓
 *           ┌──────────────┼──────────────────┐
 *           ↓              ↓                  ↓
 *      rmbz_project    artist_feedback   rmbz_notifications
 *      _chat_messages  (espelho)              ↓
 *      (chat live app)                    FCM push + sino
 *                                         + email opcional
 *
 *     Todos os pontos têm fallback `function_exists()` que replica o
 *     comportamento antigo se o helper não estiver carregado (defesa
 *     em profundidade — não devia acontecer porque está no
 *     api-bootstrap.php desde 1.5.192, mas evita fatal error em
 *     edge cases).
 *
 *   AFFECTED:
 *     - includes/admin/admin-handlers.php (2 funções)
 *     - includes/form-handler.php (1 função)
 *     - includes/api/controllers/projects-controller.php (1 função)
 *
 *   COMO TESTAR:
 *     A. Admin responde via UI antiga (/wp-admin/admin.php?page=rmbz-projetos):
 *        - Push chega ao telefone do artista
 *        - Sino no menu Início incrementa
 *        - Notificação aparece no centro com link directo ao projecto
 *
 *     B. Admin rejeita/pede correção:
 *        - Mesma cadeia de notificações
 *        - Mensagem "❌ PROJETO REJEITADO" aparece tanto no site como na app
 *
 *     C. Artista envia mensagem via site /minha-conta/:
 *        - Mensagem aparece imediatamente no chat (UI do site não muda)
 *        - Quando artista abre a app no mesmo projecto, vê a mesma
 *          mensagem (lida da tabela nova, não do legado)
 *        - Badge "mensagens novas" no menu admin é actualizado
 *
 *     D. Artista envia mensagem via app (chat live ou endpoint legado):
 *        - Comportamento já correcto desde 1.5.192, agora também
 *          unificado no mesmo caminho de código (sendFeedback legado
 *          deixa de ter SQL próprio)
 *
 *   COMPLEMENTO DOS FIXES 1.5.192 E 1.5.193:
 *     1.5.192 — endpoints REST de chat passaram a estar registados
 *     1.5.193 — página admin Projetos & Chat passou a carregar
 *     1.5.194 — todas as escritas convergem para um pipeline único
 *
 *     A coluna `artist_feedback` mantém-se viva como espelho legível
 *     pela UI antiga do site. Quando todos os artistas migrarem para a
 *     app, podemos remover a renderização legada do my-account-page.php
 *     e o espelho deixa de ser necessário — mas isso é decisão futura,
 *     não agora.
 *


 *   Sintoma reportado pelo Jaime: ao entrar em "Projetos & Chat" no menu
 *   admin, a página mostra apenas o título + a mensagem de fallback
 *   "Erro: módulo projects-admin-page.php não carregado.".
 *
 *   CAUSA RAIZ:
 *     Mesmo padrão do bug 1.5.192 (chat live REST): o ficheiro
 *     `includes/admin/projects-admin-page.php` existe no plugin desde
 *     1.5.193 (UI v2 full-screen + live chat embedded), define
 *     `rmbz_pjv2_render_page()` que é a função real que pinta a página,
 *     mas NUNCA era `rmbz_carregar_ficheiro()`d no `1mz-for-artists.php`.
 *
 *     Como `admin-views.php::onemz_mostrar_pagina_projetos()` faz
 *     `if ( function_exists('rmbz_pjv2_render_page') )` antes de chamar,
 *     a UI cai no fallback de segurança em vez de fatal error — daí
 *     a mensagem visível em vez de white screen.
 *
 *   FIX:
 *     Adicionado `rmbz_carregar_ficheiro('includes/admin/projects-admin-page.php')`
 *     ao bloco "5. Painel de Administração", imediatamente a seguir a
 *     `admin-views.php` (que tem a função-delegadora) e antes de
 *     `admin-handlers.php` (que processa POSTs disparados desta página).
 *
 *     A página interna chama `rmbz_pchat_emit_inline_config()` para
 *     injectar config do chat live na page — essa função vive em
 *     `admin/project-chat.php`, que já passou a ser carregado em 1.5.192
 *     via api-bootstrap.php. Boa cascata.
 *
 *   COMO TESTAR:
 *     1) Login em /wp-admin/ → 1MZ Manager → Projetos & Chat
 *     2) Antes do fix: mostra só título + "Erro: módulo ... não carregado."
 *     3) Depois do fix: aparece a UI full-screen com lista de projetos
 *        à esquerda e área de detalhe/chat à direita. Clicar num
 *        projeto carrega o chat live com polling 3s.
 *
 *   AFFECTED: 1mz-for-artists.php (1 linha de require adicionada no
 *   bloco admin).
 *
 *   PADRÃO RECORRENTE:
 *     Em 1.5.192 e agora 1.5.193, dois ficheiros prontos a usar mas
 *     nunca incluídos. Faz sentido fazer uma auditoria a ficheiros sob
 *     `includes/` (em qualquer subpasta) que não tenham nenhum
 *     `rmbz_carregar_ficheiro` ou `require_once` a apontar para eles —
 *     provavelmente há mais features mortas escondidas. Fica para sessão
 *     de hardening.
 *
 * == 1.5.192 (2026-05) — 🚨 Critical fix: chat live nunca esteve registado ==
 *   Bug reportado pelo Jaime: na app, no detalhe de um projecto, o chat
 *   live "não lê as mensagens do projecto e mandar também nega". Confirmado
 *   também que o painel admin novo de chat estava sem rotas.
 *
 *   CAUSA RAIZ:
 *     Os ficheiros `includes/admin/project-chat.php` (engine + helpers +
 *     rotas /1mz/v1/admin-chat/*) e `includes/api/artist-chat.php` (rotas
 *     /1mz/v1/artist-chat/*) existem no zip desde 1.5.193/1.5.197, mas
 *     NUNCA foram `require_once`d em lado nenhum:
 *
 *       - `1mz-for-artists.php` carrega ~30 ficheiros admin via
 *         `rmbz_carregar_ficheiro()` mas não inclui `admin/project-chat.php`.
 *       - `includes/api/api-bootstrap.php` carrega todos os controllers
 *         REST mas não inclui `artist-chat.php`.
 *
 *     Resultado: as rotas REST nunca eram registadas. Qualquer chamada
 *     da app a `GET /1mz/v1/artist-chat/{id}` ou `POST .../send` devolvia
 *     404 (`rest_no_route`). O polling 3s da app silencia erros (`is
 *     ApiResult.Error -> Unit` no ViewModel para não fazer toasts a cada
 *     tick), pelo que o sintoma do utilizador era apenas "chat vazio" e
 *     "enviar não faz nada".
 *
 *     O admin do site continuava a escrever no campo legado
 *     `artist_feedback` através de `admin-handlers.php` (UI antiga, NÃO
 *     via REST), o que explica porque é que o histórico antigo continuava
 *     visível na página /minha-conta/ mas a app — que esperava o endpoint
 *     novo — ficava com chat completamente vazio.
 *
 *   FIX:
 *     `includes/api/api-bootstrap.php` agora faz `require_once` de:
 *       1) `dirname(__DIR__) . '/admin/project-chat.php'`  ← helpers + admin REST
 *       2) `__DIR__ . '/artist-chat.php'`                  ← artist REST
 *
 *     Ordem importa: o artist-chat.php chama `rmbz_pchat_get_messages()`,
 *     `rmbz_pchat_get_artist_can_attach()`, `rmbz_pchat_handle_upload()`
 *     e `rmbz_pchat_insert_message()` (esta última indirectamente) — todas
 *     definidas em `admin/project-chat.php`. Logo este vem primeiro.
 *
 *     `admin/project-chat.php` não tem gates de admin (`is_admin()`) no
 *     topo — define funções no namespace global e usa
 *     `add_action('rest_api_init', ...)` para as rotas, pelo que funciona
 *     correctamente carregado a partir do bootstrap REST (que corre tanto
 *     no admin como no frontend). O `add_action('init', ..., 5)` do
 *     schema da tabela também passa a correr — mas `dbDelta` é idempotente,
 *     pelo que para instalações onde a tabela já existe (porque algum
 *     dia esteve a ser carregado via outro caminho durante testes), não
 *     há efeito.
 *
 *   COMO TESTAR:
 *     1) GET /wp-json/1mz/v1/artist-chat/123/info (com JWT do artista
 *        dono do projecto 123) deve devolver 200 com
 *        `{success:true, data:{artist_can_attach:false, server_time:"..."}}`.
 *        Antes devolvia 404 `rest_no_route`.
 *     2) Abrir o detalhe de um projecto na app — agora o chat carrega
 *        o histórico legado (mensagens antigas em `artist_feedback` com
 *        ids negativos -1, -2, ...) e o admin pode enviar/receber em
 *        tempo real via polling 3s.
 *     3) Enviar uma mensagem da app — deve aparecer imediatamente no
 *        próprio chat (servidor devolve a mensagem inserida no `/send`)
 *        e ser espelhada em `artist_feedback` para back-compat com a UI
 *        antiga em /minha-conta/.
 *
 *   AFFECTED: includes/api/api-bootstrap.php (1 bloco novo de require_once).
 *
 *   FOLLOW-UP (não nesta versão):
 *     - O admin do site escreve em `artist_feedback` directamente via
 *       admin-handlers.php em vez de passar pelo `rmbz_pchat_insert_message`.
 *       Isto significa que mensagens enviadas pelo admin via UI antiga
 *       não geram push para o artista. O dedup do
 *       `rmbz_pchat_get_messages` já trata da merge para leitura, mas
 *       para o push funcionar precisamos refactorar admin-handlers.php
 *       para usar o helper centralizado. Fica para sessão dedicada
 *       (alinhado com item "Push admin quando artista responde" do
 *       roadmap — é a outra ponta do mesmo refactor).
 *
 *   LIÇÃO:
 *     Quando crio ficheiros novos com `register_rest_route` lá dentro,
 *     confirmar SEMPRE o ponto de entrada que faz `require_once` desse
 *     ficheiro. Um `register_rest_route` num ficheiro nunca incluído é
 *     código zombie — está no disco mas não existe em produção.
 *

 *   Constante `RMBZ_PLUGIN_VERSION` estava hard-coded em '1.5.116',
 *   mas o header já ia em '1.5.190' — 74 versões à frente. Como a
 *   constante é usada para cache-busting de CSS/JS em `wp_enqueue_*`,
 *   o browser podia continuar a servir versões cached em vez dos
 *   assets novos quando se actualizava.
 *
 *   FIX:
 *     Constante agora é lida DINAMICAMENTE do header via
 *     `get_file_data()` (função core do WP). Static cache numa
 *     função `rmbz_get_plugin_version()` evita I/O em cada page load
 *     — leitura acontece UMA vez por request.
 *
 *     Quando subes a versão do header, a constante actualiza-se
 *     automaticamente. Fim do desalinhamento.
 *
 *   AFFECTED: includes/enqueue.php (14 usos de RMBZ_PLUGIN_VERSION
 *   para cache-busting), includes/design-system.php (idem para
 *   assets do design system). Funcionalmente idêntico — só o valor
 *   passou a ser correcto.
 *
 * == 1.5.190 (2026-05) — 🐛 Fix: notif "Ver e corrigir" criava lançamento novo ==
 *   Bug detectado pelo Jaime: ao clicar "Ver e corrigir →" no banner de
 *   "Uma Pausa foi devolvido", abria o wizard a CRIAR um lançamento novo
 *   em vez de editar o existente.
 *
 *   CAUSA RAIZ:
 *     O wizard real lê $_GET['id'] (em distribution-main.php:135), não
 *     $_GET['release_id']. Eu usei o parâmetro errado em todas as URLs
 *     gravadas pelos listeners de notificações desde a 1.5.184.
 *
 *     URL gerado por mim:    ?distro_action=edit_release&release_id=42
 *     URL que wizard espera: ?distro_action=edit_release&id=42
 *
 *     Resultado: parâmetro release_id era ignorado, $release_id ficava
 *     0 no wizard, e o wizard tratava como se fosse criação nova.
 *
 *     Verificado em outros sítios do código que já usam o formato
 *     correcto: distribution-list-view.php:127, distribution-wizard.php
 *     :1873, :1957, :1977, :2230 — todos usam '&id=' não '&release_id='.
 *
 *   FIX EM 4 SÍTIOS DE CÓDIGO:
 *     1) notifications-events.php (3 listeners):
 *        - release_returned    → action_url usa &id= em vez de &release_id=
 *        - release_live        → idem
 *        - release_rejected    → idem
 *
 *     2) notifications-events.php (listener de release_deleted):
 *        - LIKE patterns alargados para apanhar ambos formatos
 *          ('release_id=N' antigo + '&id=N' novo)
 *        - Cuidado especial para evitar match com 'release_id=' por
 *          substring overlap quando se procura '&id='
 *
 *     3) notifications-core.php (rmbz_notify_extract_release_id):
 *        - Regex actualizado para suportar AMBOS formatos:
 *          /[?&]release_id=(\d+)/ (tentado primeiro)
 *          /[?&](?<!_)id=(\d+)/   (lookbehind negativo evita apanhar
 *                                  release_id como id por substring)
 *
 *     4) notifications-admin.php + notifications-core.php docstrings:
 *        - Placeholders e exemplos com URL correcto
 *
 *   MIGRAÇÃO BD AUTOMÁTICA (schema 1.1 → 1.2):
 *     UPDATE rmbz_notifications
 *     SET action_url = REPLACE(action_url, 'release_id=', 'id=')
 *     WHERE action_url LIKE '%release_id=%';
 *
 *     Idempotente: corre só uma vez ao subir schema 1.1 → 1.2. Para
 *     instalações fresh ou já correctas, REPLACE não afecta linhas.
 *
 *     Encadeamento: a migração 1.0→1.1 (introduzida em 1.5.186) e a
 *     1.1→1.2 (esta) ambas correm em sequência se schema_v < 1.2.
 *     Condições reformuladas para ser robusto: '1.0' || '1.1' ||
 *     false || '' (ausência da option em instalações antigas).
 *
 *   CADEIA DE FIXES NESTA SAGA:
 *     1.5.184 (introduziu o sistema)    — bug original (release_id em URL)
 *     1.5.186 (fix slug 'editar')       — corrigi distro_action mas não o param
 *     1.5.190 (este)                    — fix definitivo do parâmetro
 *
 *   LIÇÃO REFORÇADA:
 *     Quando crio links em código para rotas existentes, ler o ROUTER
 *     primeiro para confirmar quais parâmetros são lidos. Verificar nos
 *     sítios onde o link já é usado correctamente noutros pontos do
 *     código. Já documentei isto em 1.5.186 mas não voltei a verificar
 *     o nome do parâmetro — só o slug. Da próxima vez, ambos.
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/notifications/notifications-core.php (regex extract +
 *       schema 1.2 + migração + docstrings)
 *     - includes/notifications/notifications-events.php (3 URLs novos +
 *       LIKE patterns para deleted)
 *     - includes/notifications/notifications-admin.php (placeholder)
 *
 * == 1.5.189 (2026-05) — 🎨 Redesign modal de confirmação ==
 *   Feedback do Jaime na imagem do modal "Confirmar envio": "vamos
 *   melhorar ficar bonito não assim que parece que deixaram de
 *   qualquer maneira". Modal estava feio: card preto liso, tipografia
 *   pequena, botão "Voltar atrás" quase invisível (ghost).
 *
 *   ANTES (problemas identificados):
 *     - Painel preto liso (bg-surface flat), sem profundidade
 *     - Border-top de 3px subtil — variant pouco visível
 *     - Ícone 56px com ring exterior fraco (1px alpha 0.2)
 *     - Título 18px (text-lg) semi-bold — pouca presença
 *     - Mensagem 13px (text-sm) — pequeno demais
 *     - Botão "Voltar atrás" ghost = quase invisível no fundo escuro
 *     - Botão primary sem shadow nem hover lift
 *     - Padding apertado (space-7 / space-6)
 *
 *   AGORA (redesign completo):
 *
 *   PAINEL:
 *     - Background com gradient subtil (#1c1c20 → #18181c)
 *       em vez de flat — dá profundidade
 *     - Sombra 3-layer: 24px ambient + 4px elevation + 1px highlight
 *       interior (efeito vidro)
 *     - Border-top de 4px (era 3) com glow exterior leve por variant
 *       (-2px 16px da cor da variant) — destaca a severidade de relance
 *     - Padding maior: space-8/space-7 (era space-7/space-6)
 *     - max-width 460px (era 420px) — mais respiração
 *
 *   ÍCONE:
 *     - 64px (era 56) com SVG 32px
 *     - Ring exterior em 2 layers (1px + 6px) com alpha mais forte
 *     - Animação de entrada elastic (scale 0.5 → 1.1 → 1)
 *
 *   TIPOGRAFIA:
 *     - Título: text-lg → text-xl (20px) + weight-bold (era semibold)
 *     - Mensagem: text-sm → text-base (15px) + line-height 1.65
 *     - <strong> dentro da mensagem com cor primary destaque
 *     - Texto centrado por defeito (curto fica melhor); quando tem
 *       múltiplos <p> ou listas, alinha à esquerda automaticamente
 *
 *   BOTÕES:
 *     - "Voltar atrás" (cancel) — passou de ghost (transparente sem
 *       border) para outline com border subtil (1px alpha 0.15) +
 *       hover bg + border mais forte. Agora é VISÍVEL.
 *     - Primary com box-shadow brand (4px alpha 0.25) + hover lift
 *       (-1px translate + shadow maior 0.35)
 *     - Danger com mesma técnica (vermelho)
 *     - min-width 130px (era 120) + padding 12/20 + weight-semibold
 *     - Gap entre botões aumentado (space-3 era space-2)
 *
 *   CONTEÚDO MELHOR:
 *     Mensagem actualizada para ser mais útil:
 *       - Título: "Confirmar envio para as lojas" (era "Confirmar envio")
 *       - Linha 1: "Após enviar, este lançamento NÃO poderá ser editado"
 *       - Linha 2: "A nossa equipa vai rever... receberá notificação"
 *     Antes era seco e alarmante; agora é informativo e dá expectativa.
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - assets/css/03-components.css (CSS dialog completo)
 *     - includes/distribution/distribution-wizard.php (mensagem do modal)
 *
 * == 1.5.188 (2026-05) — 🐛 Fix: notif "release devolvido" persiste após resubmit ==
 *   Bug detectado pelo Jaime: quando o artista CORRIGE e re-submete o
 *   lançamento devolvido, a notificação "X foi devolvido" continuava no
 *   topo da Distribuição — não desaparecia automaticamente.
 *
 *   CAUSA RAIZ:
 *     Faltava cleanup do "lado oposto" do ciclo. A 1.5.184 criou o
 *     listener para gerar a notif quando 'returned' acontece, mas
 *     ninguém arquivava quando 'returned' deixa de ser verdade.
 *
 *   FIX EM 2 NÍVEIS (defesa em profundidade):
 *
 *   NÍVEL 1 — Hook específico de resubmit:
 *     Novo hook 'rmbz_release_resubmitted' disparado em
 *     distribution-return-workflow.php:235 (função
 *     rmbz_distro_resubmit_release que é o ponto único usado pelo
 *     form-handler quando artista finaliza um release devolvido).
 *
 *     Listener rmbz_notif_event_release_resubmitted ouve e arquiva
 *     a notif via dedup_key fixo:
 *       UPDATE rmbz_notifications
 *       SET is_archived = 1
 *       WHERE dedup_key = 'release_returned:ID' AND is_archived = 0
 *
 *     Match exacto pelo dedup_key (não LIKE) — mais rápido e seguro.
 *
 *   NÍVEL 2 — Cleanup transversal em status_changed:
 *     O listener rmbz_notif_event_release_status_changed (que já existia
 *     para gerar notifs 'live' e 'rejected') agora também arquiva a
 *     notif 'release_returned' quando $old_status === 'returned' &&
 *     $new_status !== 'returned'.
 *
 *     Cobre casos extra onde o admin força transição manual sem passar
 *     pelo flow de resubmit (ex: admin clica "Aprovar" directo num
 *     release devolvido, ou força para "rejected").
 *
 *   PORQUÊ 2 NÍVEIS:
 *     Nível 1 garante o caso primário (artista corrige + reenvia).
 *     Nível 2 cobre todos os outros caminhos de saída de 'returned'.
 *     Os dois UPDATEs são idempotentes (se notif já está arquivada,
 *     UPDATE não afecta nada).
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/distribution/distribution-return-workflow.php (do_action)
 *     - includes/notifications/notifications-events.php (2 listeners
 *       novos: rmbz_release_resubmitted + cleanup em status_changed)
 *
 *   COMO TESTAR:
 *     1. Admin devolve release X com motivo
 *     2. Artista vê banner DANGER no topo "X foi devolvido"
 *     3. Artista clica "Ver e corrigir →" → wizard de edit
 *     4. Artista corrige + finaliza
 *     5. Banner desaparece automaticamente ✓
 *
 * == 1.5.187 (2026-05) — 🐛 Fix: notificações órfãs (release apagado) ==
 *   Bug detectado pelo Jaime: quando apagou um lançamento, a notificação
 *   "X foi devolvido" continuava no topo da página apesar do release já
 *   não existir. Botão "Ver e corrigir" levava a release inexistente.
 *
 *   CAUSA RAIZ:
 *     Falta de integridade referencial. Quando rmbz_distro_releases é
 *     apagado (frontend ou cleanup cron), as notifs em
 *     rmbz_notifications que referenciavam esse release ficavam órfãs
 *     mas continuavam a ser mostradas.
 *
 *   FIX EM 2 CAMADAS (defesa em profundidade):
 *
 *   CAMADA A — Defesa em runtime:
 *     Em rmbz_notify_get_for_user(), nova função
 *     rmbz_notify_filter_orphans() faz pós-processamento:
 *       1. Extrai release_id de cada notif (de metadata.release_id ou
 *          do action_url ?release_id=X)
 *       2. 1 query batch verifica quais existem ainda em
 *          rmbz_distro_releases
 *       3. Notifs órfãs são REMOVIDAS do resultado E arquivadas em
 *          batch (cleanup oportunístico)
 *
 *     Cobre TODOS os casos (mesmo deletes via SQL directo, deletes
 *     anteriores à 1.5.187, etc.).
 *
 *   CAMADA B — Cleanup proactivo:
 *     Novo hook 'rmbz_release_deleted' disparado nos 2 sítios principais
 *     de delete:
 *       - includes/form-handler.php:1187 (delete pelo artista no frontend)
 *       - includes/distribution/distribution-release-cleanup.php:432
 *         (cleanup cron de releases abandonados)
 *
 *     Listener rmbz_notif_event_release_deleted ouve o hook e arquiva
 *     IMEDIATAMENTE notifs relacionadas via UPDATE in-place.
 *
 *     NÃO foi adicionado em admin-views.php:1281,1351 porque esses são
 *     bulk deletes de utilizador completo — neste caso a Camada A já
 *     trata.
 *
 *   PERFORMANCE:
 *     - 1 query extra por render (SELECT ids existentes via IN clause)
 *     - Com índice PRIMARY na rmbz_distro_releases.id, esta query é O(N)
 *       no nº de releases referenciados nas notifs. Para artistas
 *       típicos (≤5 notifs activas) o overhead é desprezável (<1ms).
 *
 *   COMPATIBILIDADE COM MYSQL:
 *     Em vez de JSON_EXTRACT (5.7+), usado LIKE '%"release_id":N%' que
 *     funciona em qualquer versão do MySQL.
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/notifications/notifications-core.php (Camada A —
 *       filter_orphans + extract_release_id)
 *     - includes/notifications/notifications-events.php (Camada B —
 *       listener para hook rmbz_release_deleted)
 *     - includes/form-handler.php (do_action no delete frontend)
 *     - includes/distribution/distribution-release-cleanup.php
 *       (do_action no cleanup cron)
 *
 * == 1.5.186 (2026-05) — 🐛 Fix: action_url errado em notificações + migração ==
 *   Bug detectado pelo Jaime ao testar 1.5.184: o botão "Ver e corrigir →"
 *   da notificação "Cansei foi devolvido" não levava ao lançamento — caía
 *   no dashboard.
 *
 *   CAUSA RAIZ:
 *     Listeners da 1.5.184 gravavam URLs com slugs INVÁLIDOS:
 *       ?distro_action=editar  ← inexistente
 *       ?distro_action=ver     ← inexistente
 *
 *     As rotas reais (distribution-main.php) usam:
 *       edit_release   (para criar/editar lançamento)
 *       view_release   (para visualizar)
 *       analytics, list, profiles, licensing-somas, artist-data
 *
 *   FIX (3 sítios):
 *     1) notifications-events.php:
 *        - 'editar' → 'edit_release' (release_returned)
 *        - 'ver' → 'view_release' (release_live, release_rejected)
 *
 *     2) notifications-admin.php:
 *        - Placeholder do form admin actualizado para mostrar URL válido
 *          (?distro_action=edit_release&release_id=42)
 *
 *     3) notifications-core.php:
 *        - Comentário do exemplo de teste actualizado
 *
 *   MIGRAÇÃO AUTOMÁTICA (BD):
 *     Notificações já gravadas na BD com URLs antigos teriam continuado
 *     com o bug. Solução: bump RMBZ_NOTIF_SCHEMA_VERSION de '1.0' para
 *     '1.1' e adicionar bloco de migração que corre UMA VEZ:
 *
 *       UPDATE rmbz_notifications
 *       SET action_url = REPLACE(action_url,
 *             'distro_action=editar', 'distro_action=edit_release')
 *       WHERE action_url LIKE '%distro_action=editar%';
 *
 *       UPDATE rmbz_notifications
 *       SET action_url = REPLACE(action_url,
 *             'distro_action=ver&', 'distro_action=view_release&')
 *       WHERE action_url LIKE '%distro_action=ver&%';
 *
 *     IDEMPOTENTE: se já não houver URLs antigos (instalação fresh),
 *     UPDATE não afecta linhas. Migra só uma vez ao subir 1.0→1.1.
 *
 *   PORQUÊ '&' no segundo padrão:
 *     'distro_action=ver' como substring podia apanhar palavras maiores
 *     ('verificar', etc). O '&' garante que só apanha slug terminado.
 *
 *   LIÇÃO:
 *     Quando criar links em código, validar que o slug existe no router
 *     ANTES de gravar na BD. Idealmente hardcoded centralizado em
 *     constants para evitar typos.
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/notifications/notifications-core.php (schema 1.0→1.1 + migração)
 *     - includes/notifications/notifications-events.php (URLs corrigidos)
 *     - includes/notifications/notifications-admin.php (placeholder corrigido)
 *
 * == 1.5.185 (2026-05) — 🐛 Fix: coluna direita extravasava em Comunicação ==
 *   Bug detectado pelo Jaime ao testar a 1.5.183: na página admin
 *   "📢 Comunicação", a coluna direita (lista de notificações) estava
 *   a ser cortada/extravasada do container — só se via um cantinho à
 *   direita.
 *
 *   CAUSA RAIZ:
 *     Grid era `grid-template-columns: 420px 1fr`. O `1fr` por defeito
 *     tem `min-width: auto` que pode empurrar o grid para fora do
 *     container pai se houver children com conteúdo "large min-content"
 *     (ex: textos longos sem quebras, código com URLs longos, etc.).
 *
 *     O wp-admin tem padding nativo + a Distribuição tem o seu próprio
 *     wrapper com overflow restrito, agravando o problema.
 *
 *   FIX (3 ajustes):
 *     1) `minmax(min-content, 420px)` na coluna esquerda — encolhe se
 *        preciso, nunca passa de 420px
 *     2) `minmax(0, 1fr)` na direita — permite encolher até zero, NUNCA
 *        extravasa o container pai
 *     3) `min-width: 0` nos children — garante que conteúdo grande
 *        não empurra
 *     4) Breakpoint mobile sobe de 1100px para 1300px — layout 2-cols
 *        só faz sentido em ecrãs realmente largos
 *
 *   LIÇÃO:
 *     Em CSS Grid com `1fr`, sempre `minmax(0, 1fr)` para evitar overflow
 *     em containers com padding/scroll. É o padrão recomendado pela MDN.
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/notifications/notifications-admin.php (grid fix)
 *
 * == 1.5.184 (2026-05) — 🤖 Notificações automáticas por eventos ==
 *   Terceira e última versão da implementação do sistema de notificações
 *   inline. Esta versão liga eventos REAIS do plugin ao sistema — agora
 *   notificações são criadas automaticamente sem admin precisar de
 *   intervir.
 *
 *   EVENTOS LIGADOS:
 *
 *     RELEASE LIFECYCLE:
 *       rmbz_distro_release_returned     → DANGER  "X foi devolvido"
 *                                           (motivo + nota visíveis no body)
 *       rmbz_distro_status_changed=live  → SUCCESS "🎉 X está ao vivo"
 *                                           (expira em 30 dias)
 *       rmbz_distro_status_changed=rejected → DANGER "X foi rejeitado"
 *
 *     SOMAS LICENSING:
 *       rmbz_somas_registration_approved → SUCCESS "✓ Registo SOMAS aprovado"
 *                                           (mostra o nº atribuído)
 *       rmbz_somas_registration_rejected → DANGER  "Registo SOMAS rejeitado"
 *                                           (motivo + reembolso visível)
 *       rmbz_somas_registration_returned → WARNING "Registo SOMAS precisa correcção"
 *       rmbz_somas_license_approved      → SUCCESS "✓ Licença SOMAS aprovada"
 *       rmbz_somas_license_rejected      → DANGER  "Licença SOMAS rejeitada"
 *
 *     MATCH REVIEW:
 *       rmbz_match_review_group_approved → SUCCESS "💰 Royalties atribuídos
 *                                           à sua música" (com valor agregado)
 *
 *     WITHDRAWALS:
 *       rmbz_withdrawal_approved → SUCCESS "✓ Levantamento aprovado"
 *                                  (com valor formatado em MT)
 *       rmbz_withdrawal_rejected → DANGER  "Levantamento rejeitado"
 *                                  (com info de reembolso)
 *
 *   IDEMPOTÊNCIA via dedup_key:
 *     Cada notif tem dedup_key = "EVENT_TYPE:RESOURCE_ID". Ex:
 *     "release_returned:42", "withdrawal_approved:107". Se o mesmo evento
 *     dispara 2x (admin clica 2x sem dar tempo) o sistema NÃO duplica.
 *
 *   AGREGAÇÃO INTELIGENTE (match review):
 *     Em vez de criar 1 banner por linha aprovada (50 linhas Spotify×países
 *     = 50 banners), agrupa por (artista × título da faixa) e cria 1 banner
 *     com valor total. Ex: "$12.34 atribuídos à sua faixa Cansei (50 linhas)".
 *
 *   HOOKS NOVOS ADICIONADOS NO CÓDIGO:
 *     - distribution-licensing.php:
 *         rmbz_somas_registration_approved  (após approve)
 *         rmbz_somas_registration_returned  (após return)
 *         rmbz_somas_registration_rejected  (após reject)
 *         rmbz_somas_license_approved       (após approve)
 *         rmbz_somas_license_rejected       (após reject)
 *     - admin-match-review.php:
 *         rmbz_match_review_group_approved  (após bulk approve, agregado)
 *     - admin-handlers.php:
 *         rmbz_withdrawal_approved          (após admin aprovar)
 *         rmbz_withdrawal_rejected          (após admin rejeitar — em ambas
 *           rotas: royalties refund e MT refund)
 *
 *   HOOKS REUSADOS (já existiam, só ouvir):
 *     - rmbz_distro_release_returned
 *     - rmbz_distro_status_changed
 *
 *   RESILIÊNCIA:
 *     Se rmbz_notify_create() não existe (módulo desligado), todos os
 *     listeners silenciosamente nada fazem — `if ( ! function_exists(...) ) return;`
 *
 *   COEXISTÊNCIA com sistema antigo:
 *     Os 2 sistemas correm em paralelo:
 *       - Sistema antigo (notifications-distro-bridge): emails + bell counter
 *       - Sistema novo (notifications/events): banners visuais inline
 *     Não há conflito — disparam em hooks diferentes ou em prioridades
 *     diferentes (priority 10 vs 20).
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (require + version + changelog)
 *     - includes/notifications/notifications-events.php (NEW — 350 linhas)
 *     - includes/distribution/distribution-licensing.php (5 do_action novos)
 *     - includes/admin/admin-match-review.php (1 do_action novo + agregação)
 *     - includes/admin/admin-handlers.php (3 do_action novos: 1 approve, 2 reject)
 *
 *   ============================================================================
 *   FIM DA SAGA NOTIFICAÇÕES INLINE (1.5.182 → 1.5.184)
 *   ============================================================================
 *
 *   RESUMO:
 *     - 1.5.182: Núcleo (BD + helpers + render + AJAX dismiss + cron)
 *     - 1.5.183: UI admin (criar/arquivar/repor visibilidade)
 *     - 1.5.184: Automação (eventos do plugin geram notifs automaticamente)
 *
 *     Sistema completo. Agora um artista que tenha um release devolvido vai
 *     ver o banner DANGER no topo da Distribuição assim que entrar — sem
 *     precisar de abrir email, sem precisar de verificar bell counter.
 *
 * == 1.5.183 (2026-05) — 📢 UI admin: criar e gerir notificações ==
 *   Segunda de 3 versões para o sistema de notificações inline.
 *   Esta versão entrega a UI admin — agora o admin pode criar
 *   notificações sem código.
 *
 *   PRÓXIMA: 1.5.184 — automação (eventos do plugin geram notificações
 *   automáticas como "release devolvido", "SOMAS aprovado", etc.)
 *
 *   NOVA TAB ADMIN:
 *     Distribuição → "📢 Comunicação"
 *     Posicionada entre "📋 Auditoria" e "⚙ Configurações".
 *
 *   FUNCIONALIDADES:
 *
 *   1. FORM CRIAR NOTIFICAÇÃO (coluna esquerda):
 *      - Título (obrigatório, max 200)
 *      - Mensagem (opcional, HTML básico permitido)
 *      - Severidade: info / success / warning / danger
 *      - Expira em: 0/1/3/7/14/30 dias (default 7)
 *      - Destinatário:
 *          ° Broadcast (todos os artistas) — default
 *          ° Específico (telemovel)
 *      - Botão CTA: URL + Label (opcional)
 *      - Submit cria via rmbz_notify_create() — fica logo activa
 *
 *   2. LISTA NOTIFICAÇÕES ACTIVAS (coluna direita):
 *      - Cada linha mostra:
 *          ° Severity pill (cor)
 *          ° Título
 *          ° Body (se houver)
 *          ° CTA URL+label (se houver)
 *          ° Meta: tipo (broadcast/user), criada em, expira em,
 *            quantos dispensaram, criada por
 *      - Acções:
 *          ° "📦 Arquivar" — esconder (não apaga, fica em histórico)
 *          ° "🔄 Repor visibilidade" — apaga dismissals, faz reaparecer
 *            para artistas que tinham fechado
 *
 *   3. HISTÓRICO ARQUIVADO (collapsible <details>):
 *      - Notificações arquivadas ou expiradas
 *      - Tag "ARQUIVADA" visível
 *      - Sem acções (read-only — auditoria)
 *
 *   HANDLERS POST (com nonce):
 *     - rmbz_notif_admin_create  → criar
 *     - rmbz_notif_admin_archive → arquivar
 *     - rmbz_notif_admin_reset   → repor visibilidade
 *
 *   FLASH MESSAGES:
 *     created / archived / reset_dismissed → success verde
 *     empty_title / no_target / error → danger vermelho
 *
 *   DESIGN:
 *     - Layout 2 colunas (form | lista) com responsive (1 col em <1100px)
 *     - Cores hardcoded (lição da 1.5.176/177/181/182)
 *     - Tipografia DS (font-display nos titles, font-body no resto)
 *     - Coerente com restantes admin pages refactorizadas
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (require + version + changelog)
 *     - includes/admin/admin-distribution-controller.php (nova tab)
 *     - includes/notifications/notifications-admin.php (NEW — ~600 linhas)
 *
 *   COMO USAR:
 *     1. Vai a Distribuição → 📢 Comunicação
 *     2. Preenche o form da esquerda
 *     3. Clica "Criar e enviar notificação"
 *     4. Notificação fica activa e visível para o(s) artista(s) alvo
 *     5. Vê na coluna direita quantos já dispensaram
 *     6. Arquivar ou repor visibilidade conforme precises
 *
 * == 1.5.182 (2026-05) — 🔔 Sistema de notificações inline (NÚCLEO) ==
 *   Primeira de 3 versões para implementar o sistema de notificações inline
 *   (banners visuais no topo das páginas). Esta versão entrega o NÚCLEO:
 *   schema BD, helpers da API, render visual e AJAX dismiss.
 *
 *   PRÓXIMAS VERSÕES:
 *     - 1.5.183 — UI admin (página para criar broadcasts manuais)
 *     - 1.5.184 — Automação (eventos do plugin geram notificações automáticas)
 *
 *   FILOSOFIA E COEXISTÊNCIA:
 *     Já existe `notifications-center.php` que faz emails + bell counter.
 *     Este sistema NOVO mostra BANNERS VISUAIS proeminentes no topo da
 *     página — diferente propósito: o antigo notifica via email/bell, este
 *     avisa visualmente quando o user já está dentro da plataforma.
 *
 *     Os 2 sistemas vão coexistir.
 *
 *   ARQUITECTURA:
 *
 *   1. SCHEMA (2 tabelas novas):
 *      - rmbz_notifications: id, type, severity (info/success/warning/danger),
 *        title, body, action_url, action_label, target_telemovel (NULL = broadcast),
 *        target_role, dedup_key, metadata, created_at, expires_at, created_by,
 *        is_archived
 *      - rmbz_notifications_dismissed: notification_id + telemovel + dismissed_at
 *        (UNIQUE para idempotência)
 *
 *   2. API HELPERS:
 *      - rmbz_notify_create($args)              — criar (suporta dedup)
 *      - rmbz_notify_user($tel, $title, ...)    — atalho user-specific
 *      - rmbz_notify_broadcast($title, ...)     — atalho broadcast
 *      - rmbz_notify_get_for_user($tel, $opts)  — listar activas
 *      - rmbz_notify_dismiss($id, $tel)         — dispensar
 *      - rmbz_notify_reset_dismissed($tel)      — debug helper
 *
 *   3. RENDERING (2 layouts):
 *      - rmbz_notif_render_dashboard_banner($tel) → stack de cards no topo
 *        da Distribuição. TODAS as severities. Animação de entrada.
 *      - rmbz_notif_render_global_bar($tel) → barra única (mais recente)
 *        no topo de qualquer página do plugin. APENAS warning/danger.
 *
 *   4. CSS:
 *      - 4 cores semânticas com pares hardcoded para light theme:
 *          info    → bg #e8f3fc / text #1e3a8a
 *          success → bg #d4edda / text #14532d
 *          warning → bg #fff3cd / text #78350f
 *          danger  → bg #fde4e2 / text #7f1d1d
 *      - Cores hardcoded (lição reforçada da 1.5.176/177/181: tokens DS de
 *        superfície podem trocar com tema)
 *      - Botão dismiss com animação de saída (opacity + translateY)
 *      - Mobile-friendly (flex-wrap na barra global)
 *      - CSS+JS impressos UMA VEZ por página (flag estática)
 *
 *   5. AJAX:
 *      - wp_ajax_rmbz_notif_dismiss + nopriv (utilizadores logados via plugin)
 *      - Nonce verificado
 *      - Telemovel obtido via rmbz_get_logged_in_telemovel
 *      - JS via fetch() — não bloqueia UI, anim primeiro depois POST
 *
 *   6. CLEANUP CRON (rmbz_notif_daily_cleanup):
 *      - Roda 1x/dia
 *      - Marca como is_archived=1 todas as notifs com expires_at <= NOW
 *      - Não APAGA — fica para auditoria
 *
 *   ONDE APARECEM:
 *     A. Banner no topo do Dashboard de Distribuição (todas as severities)
 *        - Imediatamente antes do header "Olá, X"
 *     B. Barra fixa global em cima de:
 *        - Shortcode 1mz_artist_area (área pessoal do artista)
 *        - Paywall da Distribuição (rmbz_render_distribution_paywall)
 *        - Dashboard de Distribuição (já tem o banner topo, mas a barra
 *          também aparece se houver warning/danger urgente)
 *
 *   COMO TESTAR:
 *     A 1.5.182 NÃO tem UI admin ainda (vem na 1.5.183). Para testar:
 *
 *     1) Carrega o plugin (schema é instalado automaticamente)
 *     2) Adiciona temporariamente ao functions.php:
 *
 *        add_action( 'init', function() {
 *            if ( isset($_GET['rmbz_test_notif']) && current_user_can('manage_options') ) {
 *                rmbz_notify_broadcast(
 *                    'Manutenção programada — Sábado 10/05',
 *                    'Distribuição offline durante ~30 min para upgrade.',
 *                    'info'
 *                );
 *                rmbz_notify_user(
 *                    '258861640807',  // o teu telemovel
 *                    '"Cansei" foi devolvido pela curadoria',
 *                    'Motivo: capa não tem o título legível.',
 *                    'danger',
 *                    [ 'action_url' => '?distro_action=editar', 'action_label' => 'Ver e corrigir' ]
 *                );
 *                wp_die('Notificações de teste criadas.');
 *            }
 *        });
 *
 *     3) Visita /<teu-site>/?rmbz_test_notif=1 (admin logado)
 *     4) Loga-te com o telemovel 258861640807
 *     5) Vai à Distribuição → vais ver os banners
 *
 *     6) Para limpar: var_dump( rmbz_notify_reset_dismissed('258861640807') );
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (require + version + changelog)
 *     - includes/notifications/notifications-core.php (NEW — 700 linhas)
 *     - includes/distribution/distribution-main.php (banner no dashboard +
 *       barra fixa no paywall)
 *     - includes/shortcodes.php (barra fixa em 1mz_artist_area)
 *
 * == 1.5.181 (2026-05) — 🐛 Fix: pills/cards invisíveis em Matches a Rever ==
 *   Bug detectado pelo Jaime: na 1.5.180, no redesign de "Matches a Rever",
 *   os pills "Aprovado", "Rejeitado" e "Todos" (sem contagem) ficavam
 *   INVISÍVEIS — só se via o ícone, o texto desaparecia.
 *
 *   CAUSA RAIZ:
 *     MESMO BUG da 1.5.176/177 (e antes também). Quando usei
 *     var(--rmbz-text-primary, #444) e var(--rmbz-bg-card, #fff) em
 *     elementos light theme, o token resolveu para tons DARK theme
 *     (text-primary = #fff em dark, bg-card = dark surface).
 *
 *     Resultado: pill com fundo BRANCO + texto BRANCO = invisível
 *     (texto resolveu para white via DS).
 *
 *   FIX:
 *     Cores hardcoded em todos os elementos do redesign:
 *       - Pills: bg #fff, text #1a1a1a, border #d8dade
 *       - Pills hover: bg #f5f5f7, border #b0b3b8
 *       - Pills active: var(--rmbz-info) (semântico = previsível)
 *       - Cards: bg #fff, border #e5e5e5
 *       - Text: #1a1a1a (strong), #444 (primary), #666 (muted), #888 (faint)
 *       - Adicionado .rmbz-mr-pill-label { line-height:1.4 } para
 *         garantir altura consistente
 *
 *   LIÇÃO REFORÇADA (3ª vez nesta sessão):
 *     Tokens semânticos (success/danger/info/brand) → SEMPRE OK
 *     Tokens de superfície/text (bg-card/text-primary) → ARRISCADO
 *       em contextos light theme deliberados.
 *
 *     Para wp-admin (que é light theme nativo), preferir cores
 *     hardcoded com contraste calculado.
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/admin-match-review.php (todas as cores hardcoded)
 *
 * == 1.5.180 (2026-05) — 🎨 Redesign moderno: Matches a Rever ==
 *   Página admin "🔍 Matches a Rever" recebeu redesign visual completo.
 *
 *   ANTES:
 *     - container max-width:1280px (não usava monitor todo)
 *     - <h2> e <h3> WP nativos sem hierarquia visual moderna
 *     - botões "Pendente / Aprovado / Rejeitado / Todos" como buttons
 *       WP simples, contagens em badges discretos
 *     - empty state minimalista (caixa cinzenta com texto)
 *     - tabela "widefat striped" sem container card
 *
 *   AGORA:
 *     - container 100% largura (aproveita monitores grandes)
 *     - HEADER MODERNO:
 *         kicker "DISTRIBUIÇÃO · CONTROLO DE QUALIDADE"
 *         h1 com font-display
 *         subtitle 14px max-width 900px
 *     - FILTROS COMO PILLS modernas:
 *         - container card com header + hint
 *         - pills arredondadas com ícone + label + count
 *         - estado activo: azul info com count em pill semi-transparente
 *         - hover: fundo cinza claro + border mais escuro
 *     - EMPTY STATE polido:
 *         - card grande arredondado
 *         - emoji 56px ✨
 *         - título com font-display
 *         - descrição contextual (diferente para pending/approved/rejected/all)
 *     - TABELA EM CARD:
 *         - widefat striped dentro de container com border-radius
 *         - overflow hidden para cantos arredondados perfeitos
 *     - FOOTER NOTE estilizado com code highlighted
 *
 *   COMPATIBILIDADE:
 *     TODA a lógica preservada — handlers, queries, bulk actions, mensagens
 *     de feedback, banner de "matches sem crédito na wallet", etc.
 *     Só CSS + estrutura HTML do header/filtros/empty foram tocados.
 *
 *   TOKENS DS USADOS:
 *     - var(--rmbz-bg-card) — fundo dos cards
 *     - var(--rmbz-border-light) — bordas
 *     - var(--rmbz-text-strong/primary/muted/faint) — hierarquia text
 *     - var(--rmbz-info, info-strong) — pill activo
 *     - var(--rmbz-font-display) — h1, empty-title
 *     - var(--rmbz-font-body) — wrapper
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/admin-match-review.php (header novo + filtros pills
 *       + empty state moderno + tabela em card + style block completo)
 *
 * == 1.5.179 (2026-05) — 🐛 Fix: chevron sobrepõe texto em selects ==
 *   Bug detectado pelo Jaime: no dashboard de Royalties, o select
 *   "Período" (90 dias) tinha o texto a sobrepor o chevron nativo do
 *   browser — ficava ilegível e parecia que o "v" do dropdown estava
 *   colado ao "s" de "dias".
 *
 *   CAUSA RAIZ:
 *     Selects com `padding:4px 8px` (8px de cada lado) não deixam
 *     espaço suficiente à direita para o chevron nativo do dropdown
 *     que browsers modernos renderizam por defeito.
 *
 *   FIX:
 *     padding:4px 8px → padding:4px 28px 4px 8px
 *     (28px à direita acomoda o chevron nativo)
 *
 *   APLICADO EM 2 SÍTIOS:
 *     1) royalties-dashboard.php — select "Período" (90 dias)
 *     2) distribution-release-type.php — .rtb-select (override do tipo
 *        de release: Single/EP/Album)
 *
 *   FILOSOFIA:
 *     Sempre que um select tem padding-right pequeno (<20px), o
 *     chevron pode sobrepor o texto. Padrão: padding:Xpy 28px Xpy 8px.
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/royalties/royalties-dashboard.php
 *     - includes/distribution/distribution-release-type.php
 *
 * == 1.5.178 (2026-05) — 🖥️ 3 dashboards admin ocupam toda a largura ==
 *   Bug detectado pelo Jaime: dashboard de Royalties tinha
 *   max-width:1400px que limitava o uso em monitores grandes (deixava
 *   ~70% da tela vazio). Agora ocupa 100% da largura disponível.
 *
 *   FIX aplicado a 3 dashboards admin:
 *     1) royalties-controller.php — dashboard principal Royalties
 *     2) admin-unclaimed-royalties.php — Não Reclamados
 *     3) admin-withdrawal-guard.php — Forensic review levantamentos
 *
 *   ANTES:
 *     .rmbz-royalties-wrap { max-width: 1400px; }
 *     .rmbz-bb-wrap { max-width: 1400px; ... }
 *     .rmbz-wg-wrap { max-width: 1400px; }
 *
 *   AGORA:
 *     width: 100%;
 *     max-width: 100%;
 *
 *   PORQUÊ ESTAS 3?
 *     São páginas de dashboards/forensic com muitas KPIs, tabelas e
 *     dados que beneficiam de espaço horizontal:
 *       - Royalties: KPIs + activity log + breakdown grids
 *       - Não Reclamados: lista de tracks + valores + ações
 *       - Withdrawal Guard: forensic review com evidências lado-a-lado
 *
 *   PORQUÊ NÃO TIRAR DE TODOS?
 *     Outras páginas admin (admin-match-review, import-export-report,
 *     etc.) têm max-width INTENCIONAL para forms simples — limitar
 *     largura melhora legibilidade. Aqui são DASHBOARDS, lógica
 *     diferente.
 *
 *   FILOSOFIA:
 *     Em monitores 1920x1080+ (típico em workplaces), 1400px deixava
 *     ~25% da tela vazio. Agora os dashboards aproveitam toda a área
 *     útil do wp-admin (que já tem padding nativo do WordPress).
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/royalties/royalties-controller.php
 *     - includes/admin/admin-unclaimed-royalties.php
 *     - includes/admin/admin-withdrawal-guard.php
 *
 * == 1.5.177 (2026-05) — 🐛 Fix REAL do contraste "Bruto importado" ==
 *   Na 1.5.176 fiz fix errado: substitui o background por
 *   var(--rmbz-bg-elevated) pensando que era cinza médio claro (#d8d8d8
 *   como fallback). Mas esse token aponta para PRETO (#0e0e0e) no Design
 *   System (dark theme).
 *
 *   RESULTADO: Barra ficou PRETA, texto preto invisível, ainda pior que
 *   antes.
 *
 *   FIX FINAL:
 *     - Background: cor sólida hardcoded #d8dade (cinza médio claro real)
 *     - Texto: #1a1a1a (preto sólido)
 *     - Label "Bruto importado": #1a1a1a + font-weight:600
 *     - Sem tokens — é precisamente o caso onde não há token semântico
 *       apropriado (cinza neutro de "baseline" para totalizadores)
 *     - Contraste WCAG AAA garantido
 *
 *   LIÇÃO APRENDIDA:
 *     Quando refactorizar CSS, NÃO assumir o que um token resolve para —
 *     verificar no DS antes. Tokens semânticos (success/danger/info)
 *     são previsíveis. Tokens de superfície/elevação dependem do tema
 *     (light vs dark) e podem surpreender.
 *
 *     Para "baseline neutro" em páginas light theme, melhor cores
 *     hardcoded com contraste calculado do que tokens cuja resolução
 *     depende do tema.
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/admin-import-royalties.php (correção do fix anterior)
 *
 * == 1.5.176 (2026-05) — 🐛 Fix: contraste pobre na barra "Bruto importado" ==
 *   Bug detectado pelo Jaime em testes: na "Divisão de receita (1MusicMoz)"
 *   da página de import royalties, a 1ª barra ("Bruto importado · 100%")
 *   estava QUASE INVISÍVEL — texto cinzento (#444) sobre background cinzento
 *   ainda mais claro (linear-gradient #e5e5e5/#f0f0f0).
 *
 *   CAUSA RAIZ:
 *     Esta barra de "Bruto importado" usa cinzento neutro porque é o
 *     baseline (100%, antes de qualquer split). Mas o cinzento original
 *     (#e5e5e5) era CLARO DEMAIS para o texto cinzento por defeito (#444)
 *     da classe .cc-bar — contraste WCAG AA falhou.
 *
 *     A label "Bruto importado" também tinha cor padrão (#444) sem peso
 *     extra, ficando demasiado discreta.
 *
 *   FIX (3 ajustes):
 *     1) Background da barra: de #e5e5e5 (claro demais) para
 *        var(--rmbz-bg-elevated, #d8d8d8) — cinzento mais escuro com
 *        contraste suficiente para texto preto (#1a1a1a).
 *     2) Texto da barra: era cinzento padrão (#444), agora é preto
 *        sólido (var(--rmbz-text-strong, #1a1a1a)) — contraste WCAG AA OK.
 *     3) Label "Bruto importado": agora preto sólido + font-weight:600
 *        — coerente com as outras 2 labels (Taxa 1MZ azul, Artistas verde)
 *        que também têm peso extra.
 *     4) Removido linear-gradient redundante (90deg #e5e5e5 100%, #f0f0f0 100%)
 *        — ambos os stops a 100% nem produziam gradient real, era
 *        equivalente a background simples.
 *
 *   FILOSOFIA:
 *     Cores cinzentas neutras são tentadoras para "bruto/baseline" mas
 *     precisam de cuidado extra com contraste. Em vez de gradient
 *     decorativo, melhor um cinzento mais escuro sólido que garante
 *     legibilidade.
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/admin-import-royalties.php (3 ajustes)
 *
 * == 1.5.175 (2026-05) — 🎨 Polimento UX: distribution-licensing (FINAL) ==
 *   Refactor visual da MAIOR página do plugin (3.944 linhas) — módulo
 *   de Licenciamento Musical SOMAS. Última página de polimento UX
 *   da sessão.
 *
 *   ANTES:
 *     - 131 cores hardcoded
 *     - 3.944 linhas (a maior do plugin)
 *     - 9 style blocks separados
 *     - 54 funções (alguns frontend artista, alguns admin, alguns PDF)
 *     - Mistura de tema dark (frontend artista) + light (admin)
 *
 *   AGORA:
 *     - 119 tokens DS aplicados (91% das cores)
 *     - 12 cores hardcoded restantes (casos especiais sem token):
 *         - #000 puros sobre brand yellow (5x — CTAs deliberados)
 *         - Pares Bootstrap-like nas pills de status SOMAS
 *           (#fff3cd/#856404, #d4edda/#155724, #cce5ff/#004085,
 *            #f8d7da/#721c24)
 *         - #f6f7f7, #e7f3ff (backgrounds neutros únicos)
 *         - #0073aa (azul admin notice WordPress padrão)
 *
 *   COBERTURA:
 *     - Brand (#f0b90b, #f1c40f, #fcc524) → tokens
 *     - Success, Danger, Warning, Info → tokens semânticos
 *     - Hierarquia text completa (primary/secondary/muted/faint)
 *     - Hierarquia bg dark theme (surface/elevated)
 *     - Borders (strong)
 *
 *   CUIDADO ESPECIAL (CRÍTICO):
 *     Esta página gere SERVIÇOS PAGOS (700MT registo SOMAS, 100MT/faixa
 *     licenciamento). Toda a lógica financeira foi DELIBERADAMENTE
 *     preservada:
 *       - Cobrança da carteira (rmbz_secure_transfer)
 *       - Geração de PDFs de pedido SOMAS
 *       - Workflow de aprovação admin
 *       - Estados (pending/approved/returned/rejected/refunded)
 *       - Email notifications
 *     SÓ CSS foi tocado.
 *
 *   FILOSOFIA "BELT AND SUSPENDERS":
 *     - Todos os var() têm fallback
 *     - !important preservado nos casos onde está
 *     - #000 sobre brand preservado para legibilidade
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/distribution/distribution-licensing.php (refactor visual)
 *
 *   ============================================================================
 *   FIM DA SESSÃO DE POLIMENTO UX (1.5.165 → 1.5.175)
 *   ============================================================================
 *
 *   RESUMO TOTAL:
 *     11 páginas refactorizadas, 926 cores hardcoded → 207 (casos especiais)
 *     Redução: 78% — quase 720 hardcodes substituídos por tokens DS.
 *     Identidade visual coerente em todo o plugin.
 *
 *     Páginas refactorizadas (em ordem):
 *       1. admin-unclaimed-royalties (1.5.165)
 *       2. royalties-dashboard (1.5.166)
 *       3. admin-distribution-advanced (1.5.167)
 *       4. admin-withdrawal-guard (1.5.168)
 *       5. royalties-imports (1.5.169)
 *       6. distribution-analytics (1.5.170)
 *       7. admin-user-profile (1.5.171)
 *       8. admin-import-royalties (1.5.172)
 *       9. admin-distribution-releases (1.5.173)
 *       10. admin-views (mega-ficheiro 13 páginas) (1.5.174)
 *       11. distribution-licensing (1.5.175 — esta)
 *
 * == 1.5.174 (2026-05) — 🎨 Polimento UX: admin-views (mega-ficheiro 13 páginas) ==
 *   Refactor visual do MAIOR mega-ficheiro de admin pages do plugin —
 *   contém 13 sub-páginas distintas:
 *     1) Envios para Site (música)
 *     2) Vídeos Redes Sociais
 *     3) Gestão de Projetos & Chat
 *     4) Aprovar Depósitos
 *     5) Pedidos de Levantamento
 *     6) Gestão de Artistas (lista)
 *     7) Ajustar Saldo (form)
 *     8) Redefinir PIN
 *     9) Editar Bloqueio
 *    10) Histórico utilizador
 *    11) Gestão de Cupões
 *    12) Pacotes VIP
 *    13) (...)
 *
 *   ANTES:
 *     - 170 cores hardcoded (mais alto da sessão)
 *     - 1.857 linhas
 *     - 13 funções de render distintas com cada uma seu próprio padrão
 *
 *   AGORA:
 *     - 116 tokens DS aplicados
 *     - 67 cores hardcoded restantes (casos especiais sem token):
 *         Pares Bootstrap-like (badges status):
 *           - #fff3cd/#856404 (warning)
 *           - #d4edda/#155724 (success)
 *           - #f8d7da/#721c24 (danger)
 *           - #ffebee, #fdfcf0, #e0f7fa, #e8f4f8 (variations soft)
 *         Tons únicos (estados de upload, status, badges):
 *           - #f9f9f9 (background neutro recorrente — 15 ocorrências)
 *           - #fdfcf0 (cream tone para destaque)
 *           - #fff3e0, #fdf3e8 (warning tons soft)
 *         Cores Apple Music (#fc3c44) — Apple branding
 *         Cores Facebook (#3b5998) — FB branding
 *         Cores Cyan tons (#00838f, #0277bd, #00bcd4) — para badges info
 *         Cores WP nativas (#1d2327, #646970, #f1f1f1) — integração nativa
 *
 *   COBERTURA:
 *     - Brand, brand-strong (#dca300)
 *     - Success, success-strong, Warning, Danger, Danger-strong
 *     - Info, info-strong (#2271b1)
 *     - Hierarquia text completa
 *     - Backgrounds: card, elevated (#111, #222), brand
 *
 *   CUIDADO ESPECIAL:
 *     Esta página tem TODA a gestão admin de utilizadores, depósitos,
 *     levantamentos, cupões, pacotes VIP — operações críticas.
 *     TODA a lógica foi DELIBERADAMENTE preservada. Só CSS foi tocado.
 *
 *   FILOSOFIA:
 *     - Ratio menor (68%) que outras admin (90%+) é esperado:
 *       13 páginas distintas significam 13 paletas distintas com tons
 *       únicos para cada contexto (depósitos com creme amarelo,
 *       levantamentos com vermelho urgente, cupões com verde, etc.)
 *     - Pares Bootstrap-like preservados (alterar 1 sem outro = mau contraste)
 *     - Cores de marca externa (Apple Music, Facebook) preservadas
 *     - Cores WP admin (#1d2327, #646970) preservadas para integração
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/admin-views.php (refactor visual)
 *
 * == 1.5.173 (2026-05) — 🎨 Polimento UX: admin-distribution-releases ==
 *   Página admin "🎵 Lançamentos" — listagem e detalhe de releases,
 *   aprovar/rejeitar/takedown, gestão de DSPs, splits.
 *
 *   ANTES:
 *     - 110 cores hardcoded
 *     - 1.913 linhas (página grande)
 *     - 3 style blocks (releases list + release detail + approval queue)
 *     - Muitos pares Bootstrap-like e tons únicos para estados de release
 *
 *   AGORA:
 *     - 73 tokens DS aplicados
 *     - 46 cores hardcoded restantes (casos especiais sem token):
 *         Pares Bootstrap-like (badges status):
 *           - #fff3cd/#856404 (warning)
 *           - #d4edda/#155724 (success)
 *           - #f8d7da/#721c24 (danger)
 *           - #cff4fc/#055160 (info)
 *           - #cfe2ff (info azul claro)
 *         Tons únicos (estados muito específicos):
 *           - #fff1f0, #fde4e2 (danger soft variations)
 *           - #e8f5e9 (success soft)
 *           - #f3e5f5, #e3f2fd (accent/info soft)
 *           - #e9ecef, #e2e3e5 (neutral states)
 *           - #f6f7f7, #f0f0f1, #eee (borders subtis)
 *           - #996b00, #641e16, #8a1f11, #921a13 (text accent specifics)
 *           - #1d2327, #646970, #8c8f94 (WP admin grays — manter)
 *
 *   COBERTURA:
 *     - Brand, Success, Danger, Info, Accent → tokens semânticos
 *     - Hierarquia text: strong/primary/secondary/muted/faint
 *     - Background: card/elevated
 *
 *   CUIDADO ESPECIAL:
 *     Esta página tem handlers críticos (approve/reject/takedown que afectam
 *     status de releases ao vivo nas DSPs). TODA a lógica foi DELIBERADAMENTE
 *     preservada — só CSS foi tocado.
 *
 *   FILOSOFIA "BELT AND SUSPENDERS":
 *     - Ratio menor (66%) que outras páginas porque tem MUITOS estados
 *       visuais distintos (released, pending, returned, takedown, draft,
 *       approved-not-distributed, etc.) que precisam de cores únicas
 *     - Pares Bootstrap-like preservados (alterar 1 sem o outro daria
 *       mau contraste)
 *     - Cores WP admin (#1d2327, #646970, #8c8f94) preservadas para
 *       integração nativa com WordPress
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/admin-distribution-releases.php (refactor visual)
 *
 * == 1.5.172 (2026-05) — 🎨 Polimento UX: admin-import-royalties ==
 *   Página admin de "Importar Royalties" — fluxo crítico de upload e
 *   processamento de CSVs mensais que afectam wallets dos artistas.
 *
 *   ANTES:
 *     - 99 cores hardcoded
 *     - 2.022 linhas (página grande)
 *     - Padrão admin antigo com muitos inline styles
 *
 *   AGORA:
 *     - 74 tokens DS aplicados (75% das cores)
 *     - 27 cores hardcoded restantes (todos casos especiais sem token):
 *         Pares Bootstrap-like:
 *           - #fff3cd/#856404 (warning)
 *           - #eef7ff/#1a5490 (info pill)
 *           - #fde4e2/#921a13 (danger pill)
 *         Tons únicos (estados visuais):
 *           - #fffaf0, #f0f0f0, #fafbfc, #f8f9fa
 *           - #fafafa, #faf7fd, #f9f9f9, #f5f5f5, #f5faff
 *           - #d4edda, #e2e3e5, #495057, #fff (bg)
 *
 *   CUIDADO ESPECIAL (CRÍTICO):
 *     Esta página gere IMPORTS de royalties — afecta directamente as
 *     wallets dos artistas. TODA a lógica foi DELIBERADAMENTE preservada:
 *       - Upload e validação de CSV
 *       - Análise (Raio-X) com mapeamento ISRC/UPC
 *       - Aprovação/Rejeição de imports
 *       - Comissões, splits, conversão USD→MT
 *       - Audit log
 *     SÓ CSS foi tocado.
 *
 *   FILOSOFIA "BELT AND SUSPENDERS":
 *     - Todos os var() têm fallback
 *     - Pares Bootstrap-like deixados intencionalmente (alterar 1 sem o
 *       outro daria mau contraste)
 *     - Esta página tem MAIS variedade de tons (estados de import) do que
 *       outras admin — o ratio menor de conversão (75% vs 90%+ de outras)
 *       é normal e esperado
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/admin-import-royalties.php (refactor visual)
 *
 * == 1.5.171 (2026-05) — 🎨 Polimento UX: admin-user-profile ==
 *   Página admin de gestão de artistas (perfil completo do utilizador,
 *   info de conta, depósitos, levantamentos, ações administrativas como
 *   ban/suspend/reset-pin/apagar conta).
 *
 *   ANTES:
 *     - 99 cores hardcoded
 *     - Tema dark com warning específico (#e67e22, laranja)
 *     - Lots de inline styles (padrão admin antigo)
 *
 *   AGORA:
 *     - 96 tokens DS aplicados (97% das cores)
 *     - 3 cores hardcoded restantes (todos casos deliberados):
 *         - #000 puros sobre brand yellow (CTAs)
 *         - #e74c3c dentro de string PHP para email (HTML inline em email,
 *           não processado pelo CSS do plugin)
 *         - Gradient #f0b90b → #d4a000 no avatar (variação hover)
 *
 *   COBERTURA:
 *     - Brand: #f0b90b → var(--rmbz-brand)
 *     - Danger: #e74c3c → var(--rmbz-danger)
 *     - Warning: #e67e22 → var(--rmbz-warning) (laranja)
 *     - Warning-strong: #d35400 → var(--rmbz-warning-strong)
 *     - Success: #2ecc71 → var(--rmbz-success)
 *     - Success-strong: #16a085 → var(--rmbz-success-strong)
 *     - Info: #3498db → var(--rmbz-info)
 *     - Accent: #8e44ad → var(--rmbz-accent)
 *     - Hierarquia bg dark: surface (#1a1a1a), elevated (#111, #161616, #222, #252525)
 *     - Hierarquia text: primary (#fff/#444), secondary (#ccc), muted (#aaa), faint (#888/#777)
 *
 *   IMPACTO:
 *     Esta é uma página que TU vês muito (gestão de artistas — ban,
 *     reset PIN, apagar conta, ver histórico, ajustar saldo). Agora tem
 *     coerência visual com o resto da plataforma.
 *
 *   FILOSOFIA "BELT AND SUSPENDERS":
 *     - Todos os var() têm fallback
 *     - Casos deliberados (#000 sobre brand, gradients, emails) preservados
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/admin-user-profile.php (refactor visual)
 *
 * == 1.5.170 (2026-05) — 🎨 Polimento UX: distribution-analytics ==
 *   Dashboard de analytics da distribuição digital — KPIs, top faixas
 *   com sparklines, top DSPs, top países, gráfico dual-axis, drill-down
 *   por faixa em modal AJAX, export CSV. Página VISTA por artistas
 *   (frontend, não admin) — alto impacto visual.
 *
 *   ANTES:
 *     - 90 cores hardcoded
 *     - Tema dark (#141414, #1a1a1a backgrounds, #fff text)
 *     - Diferente das páginas admin anteriores (light theme)
 *
 *   AGORA:
 *     - 90 tokens DS aplicados (96% das cores)
 *     - 2 cores hardcoded restantes (apenas #000 puros sobre brand yellow
 *       — caso deliberado: branco daria mau contraste)
 *
 *   COBERTURA:
 *     - Brand (#f0b90b) → var(--rmbz-brand)
 *     - Success/Danger/Accent → tokens semânticos
 *     - Hierarquia text dark theme: primary (#fff), secondary (#ccc/#bbb),
 *       muted (#aaa), faint (#888/#666)
 *     - Hierarquia bg dark theme: surface (#1a1a1a), elevated (#141414, #222)
 *     - Info (#3498db) → var(--rmbz-info)
 *
 *   IMPACTO:
 *     Esta é a página onde os artistas vêem o desempenho das suas releases
 *     (streams, ganhos, top tracks). Cada vez que entram, vão ver agora a
 *     mesma identidade visual coerente que já têm em release-tracker e
 *     single-view. Plataforma sente-se uniforme.
 *
 *   FILOSOFIA:
 *     - Todos os var() têm fallback para a cor original
 *     - Casos especiais (#000 sobre brand) preservados para legibilidade
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/distribution/distribution-analytics.php (refactor visual)
 *
 * == 1.5.169 (2026-05) — 🎨 Polimento UX: royalties-imports ==
 *   Página admin "Royalties → Importações" — gestão de imports CSV
 *   mensais com capacidade de reverter (anular). Página financeira
 *   crítica do sistema de royalties.
 *
 *   ANTES:
 *     - 49 cores hardcoded
 *     - Mistura inline + style block
 *
 *   AGORA:
 *     - 40 tokens DS aplicados
 *     - 15 cores hardcoded restantes (casos especiais sem token):
 *         Pares Bootstrap-like (badges status):
 *           - #fff3cd/#856404, #d4edda/#155724,
 *           - #f8d7da/#721c24, #e2e3e5/#383d41
 *         Tons únicos:
 *           - #fff9e6, #fffbef (KPIs priority backgrounds)
 *           - #fff5f5, #fafafa (cards estado)
 *           - #b8860b (gold escuro KPI value)
 *           - #95a5a6 (cinzento neutro rejected)
 *           - #fcc, #eee (borders subtis)
 *           - #f0f0f0 (hash background)
 *           - #111 (link hover)
 *
 *   CUIDADO ESPECIAL:
 *     Esta página gere REVERSÃO de imports (afecta wallets dos artistas).
 *     Toda a lógica de reversão (rmbz_revert_import, ledger soft delete,
 *     stats subtract, audit log) foi DELIBERADAMENTE preservada — só CSS
 *     foi tocado.
 *
 *   FILOSOFIA:
 *     - Pares Bootstrap-like deixados intencionalmente
 *     - Tons únicos preservam diferenciação visual entre estados
 *       (active/pending/reverted/rejected)
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/royalties/royalties-imports.php (refactor visual)
 *
 * == 1.5.168 (2026-05) — 🎨 Polimento UX: admin-withdrawal-guard ==
 *   Página admin "Withdrawal Guard" — anti-fraude e compliance para
 *   aprovação de pedidos de levantamento. Página crítica do sistema
 *   financeiro (similar a FUGA / Symphonic / DistroKid AML compliance).
 *
 *   ANTES:
 *     - 55 cores hardcoded
 *     - Mistura de styles inline (style="...") + style block
 *     - Pares Bootstrap-like nos badges (#fff3cd/#856404, etc.)
 *
 *   AGORA:
 *     - 45 tokens DS aplicados
 *     - 17 cores hardcoded restantes (casos especiais sem token):
 *         Pares de pill Bootstrap-like:
 *           - #fff3cd/#856404 (warning amarelo claro)
 *           - #d1ecf1/#0c5460 (info azul claro)
 *           - #d4edda/#155724 (success verde claro)
 *           - #f8d7da/#721c24 (danger vermelho claro)
 *         Backgrounds tonais únicos:
 *           - #fff5f5, #fffbe5, #fafafa, #f5f9ff
 *           - #f6f7f7, #f9f9f9, #f3f3f3, #d6e3f5
 *         #000 puro (modal close hover)
 *
 *   CUIDADO ESPECIAL:
 *     Esta é uma página de COMPLIANCE FINANCEIRO. Toda a lógica de
 *     anti-fraude, audit log, checklist obrigatório e forense report
 *     foi DELIBERADAMENTE preservada — só foram tocadas regras CSS.
 *     A função rmbz_wg_log() (audit log imutável) e todas as outras
 *     funções financeiras NÃO foram modificadas.
 *
 *   FILOSOFIA "BELT AND SUSPENDERS":
 *     - Todos os var() têm fallback para a cor original
 *     - Pares Bootstrap-like deixados intencionalmente — alterar 1 das
 *       2 cores sem o outro daria mau contraste
 *     - Backgrounds tonais únicos preservam diferenciação visual
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/admin-withdrawal-guard.php (refactor visual)
 *
 * == 1.5.167 (2026-05) — 🎨 Polimento UX: admin-distribution-advanced (Splits) ==
 *   Página admin "🤝 Splits" (Distribuição → tab splits) refactorizada
 *   para usar Design System.
 *
 *   ANTES:
 *     - 39 cores hardcoded (#666, #888, #fff, #2ecc71, #c00, #a00, etc.)
 *     - Mistura de styles inline (style="...") + style block
 *     - Cores Bootstrap-like nos badges (#856404/#fff3cd, #d4edda, #f8d7da)
 *
 *   AGORA:
 *     - 35 tokens DS aplicados
 *     - 8 cores hardcoded restantes (casos especiais sem token equivalente):
 *         - Pares de pill Bootstrap-like para status badges
 *           (#856404/#fff3cd, #d4edda, #f8d7da)
 *         - Backgrounds tonais únicos (#fafbfc, #f8f9fa, #f0f7ff, #f0f0f0)
 *         - #1a5490 — hover específico
 *
 *   ESPECIFICIDADE:
 *     Esta página tem MUITOS styles inline (style="...") em vez de classes
 *     CSS — padrão típico de páginas admin WP antigas. Isto significa que
 *     o sed teve de cobrir 2 formatos (color: #X com espaço E color:#X sem
 *     espaço, comum em style="...").
 *
 *   FILOSOFIA "BELT AND SUSPENDERS":
 *     - Todos os var() têm fallback
 *     - Casos especiais (badges status com pares background/cor)
 *       deixados intencionalmente — alterar 1 dos 2 sem o outro daria
 *       mau contraste
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/admin-distribution-advanced.php (refactor visual)
 *
 * == 1.5.166 (2026-05) — 🎨 Polimento UX: royalties-dashboard ==
 *   Página admin "💰 Royalties" (Distribuição → tab royalties)
 *   refactorizada para usar Design System.
 *
 *   ANTES:
 *     - 43 cores hardcoded (#222, #fff, #888, #2ecc71, #3498db, #9b59b6,
 *       etc.)
 *     - Sem identidade visual da marca nos títulos
 *
 *   AGORA:
 *     - 41 tokens DS aplicados (var(--rmbz-*, fallback))
 *     - 5 cores hardcoded restantes (casos especiais sem token equivalente):
 *         #fff9e6 — background creme único do "pending banner"
 *         #856404, #664d00 — tons castanhos sobre fundo creme (banner)
 *         #7f4ba0 — roxo escurecido específico para "import activity"
 *     - Tipografia DS: font-display nos titles dos cards (.rod-card-head strong)
 *     - Cores semânticas alinhadas:
 *         success-strong → var(--rmbz-success-strong) (#27ae60 → #059669)
 *         info-strong    → var(--rmbz-info-strong) (#2271b1 → #2563eb)
 *         brand-strong   → var(--rmbz-brand-strong) (#d4a309)
 *
 *   PARES DE COR JÁ NO DS:
 *     KPIs com border-left coloridas agora usam tokens semânticos:
 *       big   → accent (roxo)
 *       green → success
 *       blue  → info
 *       amber → brand
 *       red   → danger
 *
 *   FILOSOFIA "BELT AND SUSPENDERS":
 *     - Todos os var() têm fallback para a cor original
 *     - Se DS não carregar, aspecto mantém-se idêntico
 *     - Casos especiais sem token deixados intencionalmente
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/royalties/royalties-dashboard.php (refactor visual)
 *
 * == 1.5.165 (2026-05) — 🎨 Polimento UX: admin-unclaimed-royalties ==
 *   Página admin "⚠️ Não Reclamados" (Distribuição → tab unclaimed)
 *   refactorizada para usar Design System.
 *
 *   ANTES:
 *     - 41 cores hardcoded (#222, #666, #888, #2ecc71, #e74c3c, etc.)
 *     - Fonte system (-apple-system, BlinkMacSystemFont, ...)
 *     - Sem identidade visual da marca
 *
 *   AGORA:
 *     - 41 tokens DS aplicados (var(--rmbz-*, fallback))
 *     - Apenas 5 cores hardcoded restantes (casos especiais sem token
 *       equivalente: backgrounds tonais únicos #f8f9fa, #faf7fd dos
 *       artist chips; backgrounds #f5f5f5/#f0f0f0/#e8f4fd dos badges
 *       de código ISRC/UPC)
 *     - Tipografia DS:
 *         - var(--rmbz-font-body) no container
 *         - var(--rmbz-font-display) no h1
 *     - Cores semânticas consistentes:
 *         success    → var(--rmbz-success)
 *         success+   → var(--rmbz-success-strong) (#27ae60)
 *         danger     → var(--rmbz-danger)
 *         danger+    → var(--rmbz-danger-strong) (#c0392b)
 *         info       → var(--rmbz-info) (#3498db)
 *         brand      → var(--rmbz-brand) (#f0b90b)
 *         accent     → var(--rmbz-accent) (#9b59b6 → roxo)
 *         text-strong→ var(--rmbz-text-strong) (#222)
 *         text-prim  → var(--rmbz-text-primary) (#444)
 *         text-sec   → var(--rmbz-text-secondary) (#555)
 *         text-muted → var(--rmbz-text-muted) (#666)
 *         text-faint → var(--rmbz-text-faint) (#888)
 *         bg-card    → var(--rmbz-bg-card) (#fff)
 *         border-light→var(--rmbz-border-light) (#e5e5e5)
 *
 *   ESCOLHA DE PÁGINA:
 *     Foi escolhida esta para começar polimento de admin pages porque:
 *     - Pequena (482 linhas) e isolada (sem handlers financeiros críticos)
 *     - Sem dependências complexas
 *     - Risco baixo
 *     - Boa para validar estratégia antes de páginas maiores
 *
 *   PRÓXIMOS CANDIDATOS (se quiser continuar polimento admin):
 *     - royalties-dashboard (43 cores, 500 linhas)
 *     - admin-distribution-advanced (39 cores, 749 linhas)
 *     - admin-withdrawal-guard (55 cores, 1392 linhas)
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/admin-unclaimed-royalties.php (refactor visual)
 *
 * == 1.5.164 (2026-05) — 🎨 Release Tracker SVG + Upload de logos custom ==
 *   Dois fixes complementares à 1.5.163, identificados pelo Jaime em testes:
 *
 *   FIX 1: Release Tracker (frontend) ainda mostrava letras coloridas
 *   ──────────────────────────────────────────────────────────────────
 *   Causa: o release-tracker.php tinha a sua PRÓPRIA função
 *   `rmbz_tracker_dsp_icons()` com `'initial' => 'S'/'A'/...` que
 *   renderizava círculos coloridos com primeira letra. Não passava pelo
 *   sistema rmbz_service_logo() criado em 1.5.162.
 *
 *   FIX:
 *     - Mantive a função rmbz_tracker_dsp_icons() (cores ainda úteis para
 *       o CSS hover --dsp-color)
 *     - Adicionei mapping de slugs ('youtube'→'youtube_music',
 *       'amazon'→'amazon_music') para alinhar com o helper
 *     - Render passa a chamar rmbz_service_logo($logo_slug, ['size'=>22])
 *     - Mantém fallback para chip-initial se função não existir
 *     - CSS .chip-logo adicionado (svg block 100% + border-radius 50%)
 *
 *   FIX 2: Adicionar Loja Custom — agora suporta logo via upload OR URL
 *   ──────────────────────────────────────────────────────────────────
 *   Necessidade: admin podia adicionar lojas custom mas sem icone, ficavam
 *   com fallback genérico (círculo + 1ª letra). Agora pode dar logo.
 *
 *   IMPLEMENTAÇÃO:
 *     1) BD — Schema migration:
 *        - Bump rmbz_store_requests_schema_v: 1.0 → 1.1
 *        - Nova coluna: logo_url varchar(500) DEFAULT NULL
 *        - dbDelta detecta e faz ALTER TABLE automaticamente em re-install
 *
 *     2) UI — Form admin "Adicionar Loja Custom":
 *        - Preview circular 48px com pontilhado quando vazio
 *        - Botão "📂 Escolher da Biblioteca" (Media Uploader nativo do WP)
 *        - Botão "🗑 Remover" (só aparece quando há logo)
 *        - Campo "URL externa" (https://...) como alternativa
 *        - Tip explica que Media Library é melhor que URL externa
 *
 *     3) JS — Sincronização entre as 2 fontes:
 *        - jQuery + wp.media() para Media Library nativo
 *        - URL externa actualiza preview ao input/blur
 *        - Hidden field `new_logo_url` consolida o valor final
 *        - wp_enqueue_media() garante que a Media API está carregada
 *
 *     4) Backend — Validação e segurança:
 *        - sanitize via esc_url_raw (rejeita javascript:, data: maliciosos)
 *        - Limite 500 chars (= varchar coluna)
 *        - Mensagens de erro claras se URL inválido
 *
 *     5) Helper rmbz_service_logo() — nova prioridade:
 *        - PRIORIDADE 1: logo_url custom da BD → renderiza <img>
 *        - PRIORIDADE 2: SVG inline built-in (Spotify, Apple Music, etc.)
 *        - PRIORIDADE 3: fallback (círculo + inicial)
 *
 *        Cache estático por request: rmbz_service_logo_custom_url() faz
 *        UMA query à BD na 1ª chamada, cacha em static var, reutiliza
 *        para todas as chamadas seguintes na mesma página.
 *
 *        VANTAGEM EXTRA: admin pode SOBREPOR logos built-in. Se preencher
 *        logo_url para slug 'spotify' (built-in), o custom prevalece sobre
 *        o SVG. Permite actualizar logos com versões mais recentes da
 *        marca sem editar código.
 *
 *   FILOSOFIA "BELT AND SUSPENDERS":
 *     - Verificação defensiva da existência da coluna em information_schema
 *       antes de query (caso schema migration ainda não tenha corrido)
 *     - esc_url_raw() em backend mesmo após validação no JS
 *     - rejeita inserções com URL malicioso (javascript:, data:)
 *     - <img> sempre com loading="lazy" para performance
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/distribution/distribution-release-tracker.php (chip SVG)
 *     - includes/distribution/distribution-service-logos.php (custom_url)
 *     - includes/distribution/distribution-store-requests.php (BD + UI + handler)
 *
 * == 1.5.163 (2026-05) — 🎨 Logos SVG dos 24 DSPs do catálogo + aplicar em todos os sítios ==
 *   Continuação da 1.5.162. Expandido o sistema de logos para cobrir
 *   TODOS os DSPs do catálogo (não apenas os 3 serviços exclusivos).
 *
 *   ANTES (1.5.162): SVG logos só para 3 serviços (YouTube, Meta, TikTok)
 *   AGORA (1.5.163): SVG logos para 27 (3 exclusivos + 24 DSPs do catálogo)
 *
 *   FICHEIRO REESCRITO:
 *     includes/distribution/distribution-service-logos.php
 *
 *   FUNÇÕES DISPONÍVEIS:
 *     - rmbz_service_logo($slug, $opts)  → SVG inline pronto a echo
 *     - rmbz_service_logo_color($slug)   → cor oficial da marca (hex)
 *     - rmbz_service_logo_resolve($slug) → resolve aliases (yt → youtube)
 *     - rmbz_service_logo_fallback()     → círculo + inicial (slugs desconhecidos)
 *
 *   24 DSPs COBERTOS COM SVG OFICIAL:
 *
 *     TIER 1 (essenciais):
 *       spotify, apple_music, youtube_music, amazon_music,
 *       deezer, tidal, tiktok, instagram (= meta)
 *
 *     TIER 2 (regionais):
 *       boomplay, audiomack, anghami, jiosaavn, pandora, iheartradio
 *
 *     TIER 3 (Ásia, audiophile, nicho):
 *       kkbox, line_music, netease, tencent, joox, qobuz,
 *       7digital, gracenote, resso, imusica
 *
 *   APLICADO EM 4 SÍTIOS:
 *
 *     1) Modal de Lojas FRONTEND (artista a editar release)
 *        Ficheiro: distribution-store-requests.php (linha ~584)
 *        Logo 22px à esquerda do nome em cada checkbox.
 *
 *     2) Catálogo de Lojas ADMIN (gestão de DSPs built-in/custom)
 *        Ficheiro: distribution-store-requests.php (~linha 1200)
 *        Logo 24px na coluna "Nome" da tabela.
 *
 *     3) Pedidos de Lojas ADMIN (lista de pendentes/aprovados)
 *        Ficheiro: distribution-store-requests.php (~linha 1020)
 *        Pills com logo 14px + nome para cada DSP em "+ Adicionar"/"- Remover".
 *
 *     4) Detail de Release ADMIN (release que admin abre)
 *        Ficheiro: admin-distribution-releases.php (~linha 1162)
 *        Pills coloridas com logo 18px + nome para todas as DSPs seleccionadas.
 *
 *     BÓNUS — Single-view do release (FRONTEND, secção "Links da loja"):
 *        Ficheiro: distribution-single-view.php (~linha 215)
 *        Logo 16px ao lado do nome (iTunes, Apple Music, Spotify URL/URI).
 *
 *   FILOSOFIA DE DESIGN:
 *     - Logos coloridos (cores oficiais), nunca monocromáticos
 *     - Símbolos distintivos (não letra genérica)
 *     - Marcas pequenas (Resso, iMusica, Gracenote) com simplificação
 *       reconhecível pela cor + forma
 *     - Cores oficiais respeitadas em todos
 *     - Defs de gradient com uniqid() para evitar collisions em DOM com
 *       múltiplos logos do mesmo tipo na mesma página
 *
 *   RESILIÊNCIA:
 *     - Todos os 5 sítios verificam function_exists('rmbz_service_logo')
 *       e fazem fallback para texto plano se o módulo não carregar
 *     - Slug desconhecido cai no rmbz_service_logo_fallback() com cor
 *       de marca + 1ª letra (em vez de erro)
 *
 *   FICHEIROS:
 *     REESCRITO:
 *       - includes/distribution/distribution-service-logos.php (de ~110 → ~370 linhas)
 *     EDITADOS:
 *       - 1mz-for-artists.php (version + changelog)
 *       - includes/distribution/distribution-store-requests.php (3 sítios)
 *       - includes/distribution/distribution-single-view.php (Links da Loja)
 *       - includes/admin/admin-distribution-releases.php (detail release)
 *
 * == 1.5.162 (2026-05) — 🎨 Logos oficiais para Serviços Exclusivos ==
 *   Substituição dos emojis (🎬 📘 📱) por SVGs oficiais com cores
 *   da marca, no painel "Serviços Exclusivos" do single-view do release.
 *
 *   ANTES:
 *     - YouTube Content ID: 🎬 sobre fundo #FF0000
 *     - Facebook/Instagram: 📘 sobre fundo #1877F2
 *     - TikTok Monetization: 📱 sobre fundo #010101
 *
 *   AGORA:
 *     - YouTube: SVG do logo oficial (rectângulo arredondado vermelho + play)
 *     - Meta:    SVG com gradiente Facebook→Instagram (azul→roxo→rosa)
 *     - TikTok:  SVG com efeito chromatic split (cyan + magenta sobre preto)
 *
 *   FICHEIRO NOVO: includes/distribution/distribution-service-logos.php
 *
 *   FUNÇÃO: rmbz_service_logo($service, $opts)
 *     Argumentos:
 *       - $service: slug ou alias ('youtube'/'yt', 'meta'/'fb'/'instagram', 'tiktok'/'tt')
 *       - $opts: ['size' => int (default 28), 'class' => string (CSS extra)]
 *     Devolve: string com <svg> inline, pronto para echo.
 *     Aliases para flexibilidade — ex: 'fb_monet' → 'meta', 'yt_content_id' → 'youtube'.
 *
 *   SVG OFICIAIS — fontes:
 *     - YouTube: simpleicons.org/youtube (path oficial)
 *     - Meta:   logo "f" Facebook com gradient para representar FB+Instagram
 *     - TikTok: simpleicons.org/tiktok (com chromatic split em 3 layers)
 *
 *   CSS .rmbz-svx-icon ajustado:
 *     - Removido font-size (era para emoji)
 *     - Removido background dinâmico (logo SVG já tem cor própria)
 *     - SVG dentro tem border-radius:50% para uniformidade
 *
 *   FILOSOFIA:
 *     - SVG inline (não imagens externas) — sem requests, sem dependências
 *     - Cumpre brand guidelines (cores e proporções oficiais)
 *     - Escalável sem perda de qualidade
 *     - Resiliente: function_exists() guard antes de chamar — fallback
 *       para hex circle se módulo não carregar
 *
 *   FUTURO:
 *     Esta arquitectura está pronta para escalar para os 24+ DSPs do
 *     catálogo (Spotify, Apple Music, etc.) quando precisar — basta
 *     adicionar mais cases na função rmbz_service_logo().
 *
 *   FICHEIROS:
 *     NOVO:
 *       - includes/distribution/distribution-service-logos.php (~110 linhas)
 *     EDITADOS:
 *       - 1mz-for-artists.php (require + version + changelog)
 *       - includes/distribution/distribution-single-view.php (3 mudanças:
 *         array, render, CSS)
 *
 * == 1.5.161 (2026-05) — 🎨 Polimento UX: release-tracker + wizard ==
 *   Continuação do polimento UX da 1.5.160. Mais 2 páginas frontend
 *   (vistas pelo artista) refactorizadas para usar Design System.
 *
 *   PÁGINA 1: distribution-release-tracker.php
 *     Linha do tempo / progresso visual de cada release. Mostra estado
 *     ao artista (incomplete → submitted → live).
 *
 *     ANTES: 38 cores hardcoded (#f0b90b, #2ecc71, #e74c3c, #3498db, etc.)
 *     AGORA: 2 cores restantes (apenas #000 sobre brand/success — caso
 *            especial onde branco daria mau contraste)
 *     TOKENS APLICADOS: 38
 *
 *   PÁGINA 2: distribution-wizard.php
 *     Wizard de 4 passos onde o artista cria nova release. Página crítica
 *     do flow (todos os artistas passam por ela quando lançam música).
 *
 *     ANTES: 10 cores hardcoded (#ffffff, #2ecc71, #e74c3c)
 *     AGORA: 1 cor restante (#000 puro)
 *     TOKENS APLICADOS: 9 nesta sessão (já tinha 336+ de versões anteriores)
 *
 *   NOTA SOBRE && NO WIZARD:
 *     O ficheiro distribution-wizard.php tem `&&` em vários sítios dentro
 *     de scripts inline. Em teoria, isto deveria ser afectado pelo escape
 *     do WordPress (bug documentado na 1.5.119/126) já que o wizard é
 *     renderizado via shortcode. NA PRÁTICA tem funcionado sem problemas
 *     em produção, então deixei intacto.
 *
 *     Decisão: não tocar — funciona, mexer adiciona risco de partir.
 *     Se algum dia falhar, sabe-se o padrão a aplicar.
 *
 *   ESTADO DO POLIMENTO UX (3 páginas frontend feitas):
 *     ✅ distribution-single-view.php (1.5.160) — onde abre release
 *     ✅ distribution-release-tracker.php (1.5.161) — progresso visual
 *     ✅ distribution-wizard.php (1.5.161) — criar nova release
 *
 *     Estes 3 ficheiros cobrem o flow completo do artista no submenu
 *     de Distribuição. Identidade visual agora coerente em toda a
 *     experiência.
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/distribution/distribution-release-tracker.php (refactor)
 *     - includes/distribution/distribution-wizard.php (refactor parcial)
 *
 * == 1.5.160 (2026-05) — 🎨 Polimento UX: distribution-single-view ==
 *   Refactor visual da página single-view (release que o artista abre).
 *   Esta página é vista por TODOS os artistas, todos os dias — máximo
 *   impacto na percepção da marca.
 *
 *   ANTES:
 *     - 60+ cores hardcoded (#f0b90b, #1a1a1a, #fff, #ccc, #aaa, etc.)
 *     - Fonte sistema (-apple-system, BlinkMacSystemFont, ...)
 *     - Sem identidade visual definida (parece template genérico)
 *     - Cada cor ligeiramente diferente das outras páginas (ex:
 *       verde de aprovação era #2ecc71 vs #10b981 do DS)
 *
 *   AGORA:
 *     - 82 tokens DS aplicados (var(--rmbz-*, fallback))
 *     - Apenas 13 cores hardcoded restantes (casos especiais sem
 *       token equivalente: tons de laranja específicos para "conflict",
 *       valores como #d4a000 hover do brand, etc.)
 *     - Tipografia DS:
 *         - var(--rmbz-font-display) no título h1 (Bricolage Grotesque)
 *         - var(--rmbz-font-body) no container raiz (Geist)
 *     - Cores semânticas consistentes com resto da plataforma:
 *         success → var(--rmbz-success)
 *         danger  → var(--rmbz-danger)
 *         warning → var(--rmbz-warning)
 *         brand   → var(--rmbz-brand)
 *         text    → var(--rmbz-text-primary/secondary/muted)
 *         bg      → var(--rmbz-bg-surface/elevated)
 *         border  → var(--rmbz-border-subtle/strong)
 *
 *   ESTRATÉGIA:
 *     - SEMPRE com fallback: var(--rmbz-success, #10b981)
 *     - Substituições conservadoras: só cores SEM ambiguidade
 *     - Casos especiais (laranja conflict, hover do brand) deixados
 *       hardcoded — não há token semântico equivalente
 *     - Cobertura aplicada aos 2 style blocks (linhas 544-635 e 639+)
 *
 *   GANHO:
 *     1) Identidade visual coerente com outras páginas refactorizadas
 *        (modal de Lojas, Health Dashboard, Smoke Tests)
 *     2) Themable: alterar 1 token aplica em todas as páginas
 *     3) Tipografia da marca presente em todo o flow do artista
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/distribution/distribution-single-view.php (refactor visual)
 *
 * == 1.5.159 (2026-05) — 🐛 Fix: ⭐ Serviços Exclusivos ocultava menu de Distribuição ==
 *   Bug detectado pelo Jaime: ao clicar no menu lateral
 *   "⭐ Serviços Exclusivos" debaixo de Distribuição 🌍, o menu inteiro
 *   de Distribuição era ocultado/colapsado.
 *
 *   CAUSA RAIZ:
 *     O ficheiro `admin-exclusive-services.php` registava:
 *       add_submenu_page(
 *         'rmbz-admin-distribution',  // parent
 *         ...
 *         'rmbz-admin-exclusive-services',
 *         'rmbz_render_exclusive_services_page'
 *       );
 *
 *     PROBLEMA: `rmbz-admin-distribution` NÃO é um menu top-level — é em si
 *     um submenu de `rmbz-dashboard` (registado em admin-menus.php:82).
 *
 *     Quando registas um submenu cujo parent é também um submenu, o WP:
 *       1) Adiciona o item ao parent top-level real (rmbz-dashboard)
 *          mas SEM ligar visualmente à hierarquia esperada
 *       2) Ao clicar, o WP não consegue determinar o item active correcto
 *          → COLAPSA/oculta o menu pai (Distribuição) e seu submenus
 *
 *   PORQUÊ AS OUTRAS 16 TABS NÃO TINHAM ESTE BUG?
 *     Porque todas as outras tabs estão dentro do switch case do controller
 *     (rmbz-admin-distribution&tab=X) — não como add_submenu_page separadas.
 *     Esta era a única excepção, registada num add_submenu_page paralelo
 *     que duplicava (mal) a tab `exclusives` do controller.
 *
 *   FIX (3 ações):
 *     1) REMOVIDO o bloco add_submenu_page() (era redundante — o controller
 *        já tem case 'exclusives' → rmbz_render_exclusive_services_page())
 *     2) Handler de cleanup orphans (admin_init) actualizado para detectar
 *        AMBAS as URLs: a antiga (retro-compat para bookmarks) E a nova
 *     3) 5 links de filtros internos (Pendentes/Aprovados/Conflitos/etc.)
 *        actualizados para `?page=rmbz-admin-distribution&tab=exclusives&filter=...`
 *        BÓNUS: cores hardcoded substituídas por tokens DS
 *
 *   ACESSO:
 *     ANTES (bugado):  ?page=rmbz-admin-exclusive-services
 *     AGORA:           ?page=rmbz-admin-distribution&tab=exclusives
 *                      (idêntico aos outros 16 tabs — consistência total)
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/admin-exclusive-services.php (3 fixes)
 *
 * == 1.5.158 (2026-05) — 🐛 Fix: tabs admin "Pedidos Splits" davam Sem permissão ==
 *   MESMO BUG que a 1.5.155 (Pedidos Lojas), agora apanhado em Pedidos Splits.
 *
 *   CAUSA RAIZ:
 *     As tabs Pendentes/Aprovados/Rejeitados/Todos da página de Pedidos
 *     de Splits apontavam para
 *       ?page=rmbz-distribution-advanced&tab=split-requests
 *     mas:
 *       1) `rmbz-distribution-advanced` é um slug que NÃO EXISTE
 *       2) `split-requests` (com hífen) também não — o real é
 *          `split_requests` (com underscore, conforme switch case em
 *          admin-distribution-controller.php:145)
 *
 *     A página correcta é ?page=rmbz-admin-distribution&tab=split_requests.
 *
 *     Bug presente desde 1.5.120 (quando o sistema de Fase 3 de Splits foi
 *     adicionado) — provavelmente porque na altura havia o plano de criar
 *     uma página separada `rmbz-distribution-advanced` que nunca foi feita.
 *
 *   FIX:
 *     1) URL base corrigida para `rmbz-admin-distribution&tab=split_requests`
 *     2) Param renomeado: `filter` → `split_filter` (defesa em profundidade
 *        contra conflito futuro com qualquer outro `filter` consumido pelo
 *        controller principal)
 *     3) Função `rmbz_splits_phases_render_admin_panel()` actualizada para
 *        ler $_GET['split_filter']
 *     4) BÓNUS — corrigido também o mesmo URL em `rmbz_splits_phases_notify_admin()`:
 *        link no email para admin avaliar também levava à página inexistente.
 *        Agora ambos os pontos usam o slug correcto.
 *
 *   VARREDURA DEFENSIVA: confirmado que `rmbz-distribution-advanced` não
 *   existe em mais nenhum sítio do código (só num comentário do próprio fix).
 *
 *   PADRÃO RECORRENTE DE BUG (3ª ocorrência nesta sessão):
 *     - 1.5.150: MANUAL.md com path hardcoded
 *     - 1.5.155: tabs admin Lojas com slug errado
 *     - 1.5.158: tabs admin Splits com slug errado (este)
 *
 *     Lição: ao adicionar nova página admin, ou link entre admin pages,
 *     SEMPRE confirmar o slug + query string esperada pelo controller
 *     antes de gerar links.
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/distribution/distribution-splits-phases.php (3 fixes)
 *
 * == 1.5.157 (2026-05) — 🎨 Polir UX da página "Pedidos de Splits" ==
 *   Página admin de Pedidos de alteração de splits (Fase 3) tinha UX
 *   pobre comparada com a página similar de Pedidos de Lojas:
 *     - Filtros como links simples ("Pendentes Aprovados Rejeitados Todos")
 *     - Sem contagens visíveis
 *     - Sem nav-tabs do WordPress (inconsistência visual)
 *     - "Nenhum pedido encontrado" sem container
 *     - Badges com cores hardcoded (#f0b90b, #27ae60, #c0392b)
 *
 *   FIX (alinhar com padrão de 🏪 Pedidos Lojas):
 *     - Header h1 com descrição explicativa
 *     - <h2 class="nav-tab-wrapper"> com tabs estilo WordPress
 *     - Cada tab mostra contagem entre parênteses: "Pendentes (3)"
 *     - 4 tabs: Pendentes / Aprovados / Rejeitados / Todos
 *     - Empty state com card centrado em vez de <p> solitário
 *     - Badges de estado usam tokens DS:
 *         pending  → var(--rmbz-warning)
 *         approved → var(--rmbz-success-strong)
 *         rejected → var(--rmbz-danger-strong)
 *     - Whitelist explícita de filtros (defesa em profundidade)
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/distribution/distribution-splits-phases.php (UI refactor)
 *
 * == 1.5.156 (2026-05) — 🧹 Cleanup Automático de Releases Abandonadas ==
 *   Sistema novo que apaga automaticamente releases que ficaram para trás:
 *
 *   1. INCOMPLETE há 30+ dias (configurável):
 *      Cenário típico — artista cria release, fecha browser, nunca volta.
 *
 *   2. RETURNED com return_deadline expirada:
 *      Cenário — admin devolveu para correcções, artista ignorou prazo.
 *
 *   FLUXO DE 7 DIAS COM AVISO (ético, evita reclamações):
 *
 *     Dia 0:    Detecção. Release marcada com cleanup_warned_at + scheduled.
 *               Notifica artista (template release_abandoned_warning).
 *     Dia 1-7:  Se artista mexer na release, cancela cleanup automaticamente
 *               (listener em rmbz_distro_release_saved/updated/status_changed).
 *     Dia 7+:   Apaga release + tabelas relacionadas (cascade).
 *               Notifica do delete (template release_abandoned_deleted).
 *
 *   FICHEIRO NOVO: includes/distribution/distribution-release-cleanup.php (~600 linhas)
 *
 *   SALVAGUARDAS CRÍTICAS (defesa em profundidade):
 *     ❌ Nunca apagar release com royalties no rmbz_distro_ledger
 *     ❌ Nunca apagar release com daily_stats (recebeu plays)
 *     ❌ Nunca apagar release com holdings já pagos a colaboradores
 *     ❌ Nunca apagar se status já não é incomplete/returned (mudou entretanto)
 *     ❌ Limite de 50 releases por execução (evita rebentar BD em catch-up)
 *     ✅ Salvaguardas re-validadas no momento de apagar (não só ao marcar)
 *     ✅ Releases bloqueadas ficam marcadas com cleanup_blocked (admin vê)
 *
 *   APAGAR EM CASCATA:
 *     - rmbz_distro_tracks
 *     - rmbz_distro_release_platforms
 *     - rmbz_distro_status_history
 *     - rmbz_splits_holdings
 *     - rmbz_splits_audit
 *     - rmbz_split_change_requests
 *     - rmbz_store_change_requests
 *     - rmbz_licenses
 *     - rmbz_distro_releases (por último)
 *
 *   AUDITORIA — tabela nova rmbz_release_cleanup_log:
 *     id, release_id, artist_telemovel, title, previous_status,
 *     reason (incomplete|returned), deleted_at, manual (cron vs admin)
 *
 *   2 TEMPLATES NOTIFICAÇÃO NOVOS:
 *     - release_abandoned_warning  (vars: titulo_release, release_id,
 *                                   motivo_estado, dias_restantes)
 *     - release_abandoned_deleted  (vars: titulo_release, motivo_estado)
 *     Ambos editáveis em Configurações → tab E-mails 📧.
 *
 *   PÁGINA ADMIN "🧹 Cleanup Releases" (Menu 1MZ Manager):
 *     - Configurações: dias incomplete (mín 7, default 30) + dias aviso (mín 3, default 7)
 *     - Botão "Correr Cleanup Agora" para forçar execução manual
 *     - Lista de pendentes (avisadas, com countdown até apagar)
 *     - Lista de bloqueadas (com motivo — royalties/plays/holdings)
 *     - Histórico das últimas 50 apagadas (cron ou manual)
 *
 *   CRON: hooked em rmbz_unlock_funds_hook (diário) — mesma estratégia
 *   dos outros cleanups (1º depósito, e-mails, projectos rejeitados, etc.).
 *
 *   FICHEIROS:
 *     NOVO:
 *       - includes/distribution/distribution-release-cleanup.php
 *     EDITADOS:
 *       - 1mz-for-artists.php (require + version + changelog)
 *       - includes/notifications-center.php (2 templates novos + defaults)
 *
 * == 1.5.155 (2026-05) — 🐛 Fix: tabs admin "Pedidos Lojas" davam Sem permissão ==
 *   Bug detectado pelo Jaime ao usar a UI: clicar nas tabs
 *   "Pendentes / Aprovados / Rejeitados" da página "Pedidos de Alteração
 *   de Lojas" no admin dava sempre "Sem permissão para acessar esta página".
 *
 *   CAUSA RAIZ:
 *     O link das tabs estava a apontar para
 *       ?page=rmbz-distribution&distro_action=store_requests&tab=...
 *     que é a página do ARTISTA (frontend). Quando o admin clicava nas
 *     tabs, o WordPress redireccionava para essa página (que não existe
 *     no admin) → "Sem permissão".
 *
 *     A página admin correcta é ?page=rmbz-admin-distribution&tab=store_requests.
 *
 *   FIX (2 partes):
 *
 *     1) Link corrigido para apontar à página admin:
 *          ?page=rmbz-admin-distribution&tab=store_requests&store_tab=...
 *
 *        Usei `store_tab` (não `tab`) porque `tab` já é consumido pelo
 *        controller principal para identificar qual sub-página renderizar
 *        (store_requests, splits, etc.). Reutilizar criaria conflito.
 *
 *     2) Função rmbz_store_admin_requests_page() actualizada:
 *          $_GET['tab']  →  $_GET['store_tab']
 *
 *        Adicionei whitelist explícita de valores válidos
 *        (pending|approved|rejected) — defesa em profundidade
 *        em vez de confiar só em $wpdb->prepare.
 *
 *   IMPACTO ANTES DO FIX:
 *     Admin não conseguia filtrar pedidos por estado. Tinha que ver tudo
 *     misturado na página default (pending). Bug presente desde 1.5.144.
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/distribution/distribution-store-requests.php (link + leitura)
 *
 * == 1.5.154 (2026-05) — 🐛 Bug fixes apanhados pelos smoke tests ==
 *   Os smoke tests da 1.5.153 detectaram problemas REAIS na primeira
 *   execução. Esta versão corrige todos.
 *
 *   🚨 BUG 1 (CRÍTICO) — Notificações release_returned + release_live
 *     NUNCA disparavam em produção. Causa raiz: o bridge da 1.5.145 fazia
 *     SELECT da coluna `user_telemovel` em rmbz_distro_releases, mas a
 *     coluna real é `artist_telemovel` (definida em functions-setup.php:155).
 *     SELECT silenciosamente devolvia null e a função saía cedo no
 *     `if ( ! $rel )`.
 *
 *     IMPACTO: durante toda a 1.5.145–1.5.153, sempre que uma release
 *     era devolvida ou ficava live, o artista NÃO recebia notificação
 *     (apesar dos templates editáveis no Centro estarem registados).
 *
 *     FIX: corrigir nome da coluna em ambas as funções de bridge:
 *       rmbz_notif_on_release_returned()
 *       rmbz_notif_on_release_live()
 *
 *   🐛 BUG 2 — Deploy log nunca registava versão
 *     A função rmbz_predeploy_get_plugin_version() (1.5.148) usava
 *     WP_PLUGIN_DIR . '/1mz-for-artists/...' que assume o nome da pasta.
 *     Se a pasta tiver outro nome (ex: descarregar do GitHub cria pasta
 *     com sufixo "-main"), file_exists devolve false → função devolve ''
 *     → record_current_version sai cedo (empty version) → tabela fica vazia.
 *
 *     IMPACTO: a tabela rmbz_deploy_log ficava vazia, mostrando WARN no
 *     Health Dashboard ("nunca registou versão").
 *
 *     FIX: usar dirname(__FILE__, 3) que resolve dinamicamente a partir
 *     de includes/admin/predeploy-hardening.php → ../../1mz-for-artists.php.
 *     100% portável (mesmo fix da 1.5.150 para o MANUAL.md).
 *
 *   🐛 BUG 3 — Smoke test de cleanup do 1º depósito sempre warn
 *     O smoke test fazia wp_next_scheduled('rmbz_first_deposit_cleanup_extended')
 *     mas isto NÃO é um cron próprio — é uma função hooked ao cron diário
 *     `rmbz_unlock_funds_hook`.
 *     Logo wp_next_scheduled devolvia sempre false → warn permanente.
 *
 *     FIX: smoke test agora verifica:
 *       1) Cron parent (rmbz_unlock_funds_hook) está agendado
 *       2) has_action('rmbz_unlock_funds_hook', 'rmbz_first_deposit_cleanup_extended')
 *
 *   🐛 BUG 4 — Health Dashboard com nomes de hooks errados
 *     Mesma causa raiz: o Health Dashboard listava
 *     `rmbz_first_deposit_cleanup_extended` (não-cron) e
 *     `rmbz_imports_archive_purge` (que não existe — o real é
 *     `rmbz_archive_daily_purge` em import-archive.php:303).
 *
 *     FIX: lista corrigida para conter só hooks de cron REAIS:
 *       - rmbz_unlock_funds_hook (cron-jobs.php:31)
 *       - rmbz_archive_daily_purge (import-archive.php:303)
 *
 *   🐛 BUG 5 — Smoke test de schema com coluna errada
 *     O smoke test verificava 'user_telemovel' em rmbz_distro_releases,
 *     mas como confirmado acima a coluna real é 'artist_telemovel'.
 *
 *     FIX: lista de colunas críticas corrigida.
 *
 *   META-OBSERVAÇÃO:
 *     A primeira execução dos smoke tests apanhou 5 bugs em código
 *     próprio do smoke tests + bridge de notificações + dashboard. Isto
 *     valida exactamente o propósito da feature: pequenos warnings/fails
 *     surfaceiam grandes problemas escondidos.
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/notifications-distro-bridge.php (bug 1)
 *     - includes/admin/predeploy-hardening.php (bugs 2 + 4)
 *     - includes/admin/smoke-tests.php (bugs 3 + 5)
 *
 * == 1.5.153 (2026-05) — 🧪 Smoke Tests Automáticos ==
 *   Suite rápida de testes não-destrutivos que confirma que componentes
 *   essenciais do plugin estão funcionais. Recomendado correr depois de
 *   cada update.
 *
 *   ACESSO: 🛡 Pré-Deploy → secção "🧪 Smoke Tests" (entre Health e Snapshots)
 *
 *   FICHEIRO NOVO: includes/admin/smoke-tests.php (~430 linhas)
 *
 *   CARACTERÍSTICAS:
 *     - NÃO-DESTRUTIVO: zero CREATE/UPDATE/DELETE em dados reais
 *     - IDEMPOTENTE: corre 100 vezes seguidas sem efeitos
 *     - RÁPIDO: alvo <10 segundos para suite completa
 *     - READ-ONLY: SELECT, function_exists, file_exists, wp_next_scheduled
 *     - INDEPENDENTE: cada teste isolado; falha de um não afecta outros
 *     - AGRUPADOS: 6 categorias para legibilidade
 *
 *   20 TESTES EM 6 CATEGORIAS:
 *
 *     SCHEMA (7):
 *       - rmbz_wallets acessível
 *       - rmbz_wallet_transactions com PK transaction_id (bug fix 1.5.147)
 *       - rmbz_distro_releases com colunas críticas
 *       - rmbz_distro_ledger com coluna 'amount' (bridge 1.5.145 depende)
 *       - rmbz_subscriptions presente
 *       - rmbz_dsp_catalog (1.5.144) instalada
 *       - rmbz_deploy_log (1.5.148) com registos
 *
 *     FUNÇÕES (4):
 *       - rmbz_update_wallet_balance() existe (API principal wallet)
 *       - rmbz_secure_transfer_usd_to_mt() existe (USD→MT — invariante crítico)
 *       - rmbz_notify() existe (Centro de Notificações)
 *       - rmbz_distro_dsp_catalog() devolve catálogo válido
 *
 *     CRON (2):
 *       - rmbz_unlock_funds_hook agendado (diário)
 *       - rmbz_first_deposit_cleanup_extended agendado
 *
 *     FILESYSTEM (3):
 *       - /uploads/ writable
 *       - Pasta de snapshots existe + writable + .htaccess presente
 *       - /uploads/rmbz_imports_archive/ acessível
 *
 *     NOTIFICAÇÕES (2):
 *       - Templates registados (≥11 esperados desde 1.5.144)
 *       - rmbz_email_logs acessível
 *
 *     CATÁLOGO (2):
 *       - DSP catálogo activo tem ≥10 entradas
 *       - Tier 1 (essenciais) tem ≥6 DSPs
 *
 *   OUTPUT:
 *     - Sumário no topo: "✅ N/M PASS · X FAIL · Y WARN · Zms total"
 *     - Tabelas agrupadas por categoria
 *     - Cada linha: ✓/⚠/✗ · nome · mensagem detalhada · duration ms
 *     - Persistência via transient (1h) — vê a última execução ao voltar
 *
 *   STATUS POSSÍVEIS:
 *     pass — tudo OK
 *     warn — não bloqueante mas recomendado verificar (ex: tabela vazia)
 *     fail — problema real, precisa de atenção antes de prosseguir
 *
 *   HOOK NOVO em predeploy-hardening.php:
 *     do_action('rmbz_predeploy_after_health')
 *     Permite a outros módulos injectarem painéis após o Health Dashboard.
 *
 *   FICHEIROS:
 *     NOVO:
 *       - includes/admin/smoke-tests.php
 *     EDITADOS:
 *       - 1mz-for-artists.php (require + version + changelog)
 *       - includes/admin/predeploy-hardening.php (hook novo)
 *
 * == 1.5.152 (2026-05) — 🎨 Polimento UX: alinhar páginas novas ao Design System ==
 *   As funcionalidades adicionadas nas 1.5.144–1.5.149 usavam cores
 *   hardcoded (≈100+ ocorrências). O plugin já tem um Design System
 *   maduro (--rmbz-* tokens em assets/css/01-tokens.css) que NÃO estava
 *   a ser usado nas páginas novas. Resultado: cada modal/painel tinha
 *   cores ligeiramente diferentes (#2ecc71 vs #10b981 — ambos verdes,
 *   mas tom diferente), criando inconsistência visual subtil mas perceptível.
 *
 *   ESCOPO (5 páginas refactorizadas):
 *
 *   1. MODAL "ADICIONAR/REMOVER LOJAS" (frontend, 1.5.144)
 *      Páginal mais visível — vista pelos artistas. Refactor completo:
 *        - Backdrop, dialog, header, body, info, notice, tier, grid, cell, summary
 *        - Estados sel/diff-add/diff-remove com tokens de success/danger
 *        - Tipografia: var(--rmbz-font-display) em headers
 *        - Spacing: var(--rmbz-space-*) em padding/margin
 *        - Border-radius: var(--rmbz-radius-*) (xs/sm/md)
 *        - Shadow: var(--rmbz-shadow-xl)
 *        - JS preview live: setColor via CSS vars (com fallback)
 *
 *   2. PEDIDOS DE LOJAS + CATÁLOGO (admin, 1.5.144)
 *      ≈17 hardcodes → 0. Cores: #2ecc71→success, #e74c3c→danger,
 *      #888→text-muted, #a855f7→accent, etc.
 *
 *   3. WALLET CLEANUP — preview live + presets + histórico (admin, 1.5.147)
 *      ≈58 hardcodes → 3 (restantes são casos especiais condicionais).
 *      JS de preview live também usa CSS vars (severity colors).
 *
 *   4. PRÉ-DEPLOY — Health Dashboard, Snapshots, Deploy Log (admin, 1.5.148)
 *      ≈19 hardcodes → 1. Tokens: success/warning/danger no health,
 *      warning-soft no rollback guide.
 *
 *   5. MANUAL (admin, 1.5.149)
 *      ≈28 hardcodes → 17 (deixei greys/whites que combinam melhor com
 *      tema do WP admin que é branco; mudei só semânticas).
 *
 *   ESTRATÉGIA APLICADA:
 *     - SEMPRE com fallback: var(--rmbz-success, #10b981)
 *       (assim funciona mesmo se o DS não carregar)
 *     - Tokens semânticos: success/danger/warning/info (universais)
 *     - Tokens de superfície (bg-surface, bg-elevated) só na modal
 *       frontend (dark theme); admin do WP fica com white background nativo
 *     - Tokens de tipografia (font-display) só em frontend (admin usa fonte do WP)
 *     - JS usa CSS vars via .style.color = 'var(--rmbz-...)'
 *       (browsers modernos resolvem; fallback no DS file)
 *
 *   GANHO REAL:
 *     - Antes: 256 cores únicas no plugin todo
 *     - Modal de Lojas: 100% tokens (era 0%)
 *     - Páginas admin: ≈90% tokens (era 0%)
 *     - Aspect inconsistency entre páginas reduzida significativamente
 *     - Themable: alterar 1 token aplica em todas as páginas (vs editar
 *       100+ sítios um a um)
 *
 *   NÃO TOCADO:
 *     - Páginas existentes (anteriores à 1.5.144) que já têm o seu CSS
 *     - Componentes do DS (assets/css/03-components.css) — não é necessário
 *     - Frontend principal (assets/css/frontend.css, account-page.css) —
 *       já usavam DS antes
 *
 *   FICHEIROS:
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/distribution/distribution-store-requests.php (modal + admin)
 *     - includes/admin/import-diagnostics.php (wallet cleanup UI)
 *     - includes/admin/predeploy-hardening.php (health/snapshots/deploy log)
 *     - includes/admin/manual-page.php (página manual)
 *
 * == 1.5.151 (2026-05) — 📦 MANUAL.md movido para assets/ ==
 *   Reorganização: MANUAL.md agora vive em assets/ (junto a css/ e js/),
 *   o sítio padrão para recursos do plugin. Fica fora da raiz para ela
 *   ficar limpa (só com o ficheiro principal do plugin).
 *
 *   ANTES:
 *     /1mz-for-artists/
 *       ├── 1mz-for-artists.php
 *       ├── MANUAL.md          ← na raiz
 *       └── ...
 *
 *   AGORA:
 *     /1mz-for-artists/
 *       ├── 1mz-for-artists.php
 *       ├── assets/
 *       │   ├── css/
 *       │   ├── js/
 *       │   └── MANUAL.md      ← agrupado com outros recursos
 *       └── ...
 *
 *   Função rmbz_manual_get_path() actualizada:
 *     dirname(__FILE__, 3) . '/assets/MANUAL.md'
 *
 *   Ficheiros tocados: 2
 *     - includes/admin/manual-page.php (path actualizado)
 *     - MANUAL.md (movido para assets/MANUAL.md)
 *
 * == 1.5.150 (2026-05) — 🔧 Fixes na página Manual ==
 *   2 fixes na funcionalidade introduzida na 1.5.149:
 *
 *   FIX 1 — "MANUAL.md não encontrado":
 *     Causa: a função rmbz_manual_get_path() usava
 *       WP_PLUGIN_DIR . '/1mz-for-artists/MANUAL.md'
 *     que ASSUMIA o nome da pasta. Falhava se o plugin estivesse instalado
 *     numa pasta com nome diferente (ex: ZIP descarregado do GitHub muitas
 *     vezes cria pasta com sufixo "-main" ou "-master").
 *
 *     Fix: usar dirname(__FILE__, 3) que sobe 3 níveis a partir de
 *     includes/admin/manual-page.php → raiz do plugin (qualquer que seja
 *     o nome da pasta). 100% portável.
 *
 *   FIX 2 — Posição do menu:
 *     O item "📖 Manual" estava registado com prioridade 60, o que o punha
 *     entre outros itens (não estava agrupado com Configurações).
 *
 *     Fix: prioridade 100 — garante que aparece DEPOIS de todos os outros
 *     submenus registados (incluindo Configurações que está em prioridade
 *     default 10). Resultado: o Manual fica como último item do menu,
 *     logo a seguir a Configurações — agrupamento natural.
 *
 *   Ficheiros tocados: 1
 *     - includes/admin/manual-page.php
 *
 * == 1.5.149 (2026-05) — 📖 Manual do Admin (operacional + técnico) ==
 *   Documenta TUDO o que foi construído ao longo da sessão (1.5.117–1.5.148)
 *   numa única referência. Audiência: admins, suporte, novos colaboradores,
 *   e developers futuros (incluindo nós próprios daqui a 6 meses).
 *
 *   FICHEIROS NOVOS (2):
 *     - MANUAL.md (~930 linhas, 35KB) na raiz do plugin
 *     - includes/admin/manual-page.php (~470 linhas)
 *
 *   ESTRUTURA DO MANUAL:
 *     PARTE I — OPERACIONAL (16 secções)
 *       Visão geral · Estrutura do menu · Operações diárias ·
 *       Aprovar depósitos/levantamentos · Importar royalties ·
 *       Aprovar releases · Pedidos splits/lojas · Centro Notif. ·
 *       Referrals · Gestão de artista · Diagnósticos · Pré-Deploy ·
 *       FAQs operacionais · Troubleshooting comum
 *
 *     PARTE II — TÉCNICO (8 secções)
 *       Arquitectura 2 contas (MT/USD) · Schema BD · Cron jobs ·
 *       Hooks/filtros disponíveis · Markers de auditoria ·
 *       Estrutura do código · Convenções/padrões · Comandos SQL úteis
 *
 *     APÊNDICES: Glossário · Versões importantes
 *
 *   PÁGINA ADMIN "📖 Manual" (Menu 1MZ Manager → 📖 Manual):
 *     - Renderiza MANUAL.md com parser markdown leve (zero dependências
 *       externas — sem Parsedown, sem CommonMark, sem composer)
 *     - Suporta: H1-H6, listas (ul/ol), tabelas, code blocks, code inline,
 *       blockquotes, bold, italic, links, separadores
 *     - TOC sticky lateral com h2/h3 + scroll-spy (highlight da secção activa)
 *     - Anchors automáticas em headings (algoritmo igual ao GitHub)
 *     - Botão ⬇ "Download MANUAL.md" para offline reading (filename inclui versão)
 *     - Responsive: TOC empilha em mobile (<900px)
 *     - Estilos auto-contidos no template (não polui restante admin)
 *
 *   FILOSOFIA:
 *     O MANUAL.md é a fonte de verdade — fica versionado com o plugin.
 *     A página admin é só um renderer cómodo. Em caso de discrepância,
 *     o CÓDIGO é a fonte de verdade (manual pode estar desatualizado).
 *     Para devs futuros: ler MANUAL.md ANTES de editar código.
 *
 *   FICHEIROS:
 *     NOVOS:
 *       - MANUAL.md
 *       - includes/admin/manual-page.php
 *     EDITADOS:
 *       - 1mz-for-artists.php (require + version + changelog)
 *
 * == 1.5.148 (2026-05) — 🛡 Hardening Pré-Produção ==
 *   Adiciona ferramentas de operação para deploys seguros e visibilidade
 *   contínua sobre o estado do sistema. Tudo numa só página admin.
 *
 *   ACESSO: Menu 1MZ Manager → 🛡 Pré-Deploy
 *
 *   FICHEIRO NOVO: includes/admin/predeploy-hardening.php (~770 linhas)
 *
 *   3 COMPONENTES:
 *
 *   1) 💾 SNAPSHOTS DA BASE DE DADOS
 *      Botão "📸 Criar Snapshot Agora" gera dump SQL de TODAS as tabelas
 *      rmbz_* (CREATE TABLE + INSERTs em batches de 500 linhas).
 *      Guardado em /uploads/rmbz_predeploy_snapshots/ com:
 *        - .htaccess "Deny from all" (protecção contra acesso público)
 *        - index.html vazio (defesa secundária)
 *      Filename: snapshot_v{VERSAO}_{YYYYMMDD_HHMMSS}.sql
 *
 *      Lista de snapshots existentes com:
 *        - ⬇ Download (com nonce + validação regex contra path traversal)
 *        - 🗑 Apagar (com nonce + confirmação + validação regex)
 *
 *      RESTAURO: deliberadamente MANUAL (não automático). Admin descarrega
 *      o .sql e importa via phpMyAdmin. Filosofia: rollback de dados é
 *      operação destrutiva, deve sempre passar por revisão humana.
 *
 *   2) 📊 HEALTH DASHBOARD
 *      10 verificações em tempo real, cada uma com estado ok/warn/fail:
 *        - PHP version (>= 7.4)
 *        - Memory limit (>= 256MB recomendado)
 *        - Upload max filesize (>= 32MB recomendado para CSVs)
 *        - Disco livre em /uploads
 *        - Tabelas rmbz_* presentes (quantidade)
 *        - Tabelas críticas presentes (8 tabelas obrigatórias)
 *        - Cron jobs registados (rmbz_unlock_funds_hook, etc.)
 *        - WP_DEBUG_LOG activo
 *        - Erros nas últimas 24h (parsing de wp-content/debug.log)
 *        - Última actividade financeira (last deposit_request)
 *      Resumo: "X OK · Y warn · Z fail" + veredicto pronto/não-pronto.
 *
 *   3) 📜 DEPLOY LOG
 *      Tabela nova: rmbz_deploy_log
 *        id, version, deploy_at, deployed_by, is_stable, snapshot_path, notes
 *      Auto-regista cada vez que o admin entra com versão diferente da
 *      última gravada (idempotente — não duplica). Marca o user_login do
 *      admin que viu primeiro a nova versão.
 *
 *      Botão "⭐ Marcar estável" / "Desmarcar" para identificar versões
 *      conhecidas como funcionais — para sabes a que voltar em caso de
 *      bug numa versão futura.
 *
 *      Campo snapshot_path liga snapshot ao deploy: quando criar snapshot
 *      logo após upgrade, fica registado qual snapshot foi feito para
 *      qual versão.
 *
 *   4) ↩ ROLLBACK GUIDE (estático)
 *      Bloco amarelo no fim da página com instruções passo-a-passo de
 *      rollback: identificar versão estável, snapshot, descarregar SQL,
 *      importar via phpMyAdmin, substituir ZIP. Para que em momento de
 *      crise não tenhas de procurar nada.
 *
 *   SEGURANÇA:
 *     - Todas as acções com check_admin_referer + manage_options capability
 *     - Path traversal protegido por regex no nome do snapshot:
 *       /^snapshot_v[\d.]+_\d{8}_\d{6}\.sql$/
 *     - basename() aplicado a todos os filenames
 *     - Endpoint de download separado de páginas admin (admin-post)
 *     - Snapshots em pasta protegida por .htaccess Deny from all
 *
 *   FICHEIROS:
 *     NOVO:
 *       - includes/admin/predeploy-hardening.php
 *     EDITADO:
 *       - 1mz-for-artists.php (require + version + changelog)
 *
 * == 1.5.147 (2026-05) — 🩺 Wallet MT Cleanup: 4 melhorias críticas ==
 *   Antes desta versão, o admin podia debitar wallets de artistas sem ver
 *   o impacto exacto, sem poder escolher valor parcial, sem ver histórico
 *   de correções já feitas, e a tabela de transações tinha LIMIT 100
 *   hardcoded (em wallets com 1700+ tx, não conseguias ver o resto).
 *   Esta versão resolve os 4 problemas em conjunto.
 *
 *   1) PREVIEW LIVE DO IMPACTO (AJAX)
 *      Painel novo dentro da inspecção mostra:
 *        - Saldo actual da wallet
 *        - Saldo após o débito proposto
 *        - Severidade visual: ✓ SEGURO (verde) | ⚠ ATENÇÃO (amarelo) | 🚨 NEGATIVO (vermelho)
 *        - Recomendação contextual ("Esta correção vai criar saldo NEGATIVO de X MT.
 *          Considera o Débito Seguro (Y MT) que zera a wallet sem ficar negativa.")
 *      Endpoint AJAX `rmbz_wallet_mt_preview` (nonce protegido) — debounce 300ms
 *      no input para não martelar o servidor.
 *
 *   2) DÉBITO PARCIAL com 3 quick-presets:
 *        💯 "Tudo (X MT)"     — debita o net_wrong total
 *        🛡 "Seguro (Y MT)"   — debita o min(net_wrong, saldo_actual) — nunca fica < 0
 *        ½ "Metade (Z MT)"   — debita 50% (compromisso intermédio)
 *      Mais um campo de input livre para qualquer valor entre 0.01 e net_wrong.
 *      Backend (rmbz_wallet_mt_apply_correction) já aceitava valores parciais —
 *      só faltava a UI para os admin escolher.
 *
 *   3) HISTÓRICO DE CORREÇÕES com possibilidade de ANULAR
 *      Nova secção "🧹 Correções já aplicadas" lista todas as transações
 *      com type='mt_fix_correction' deste artista, ordenadas por data.
 *      Cada linha tem botão "↩ Anular" que faz crédito de devolução com
 *      type='mt_fix_undo' e marker [undo:original_tx_id].
 *      Idempotência: se já foi anulada, mostra "✓ Já anulada" e remove botão.
 *      Linhas anuladas aparecem com opacity:0.55 para diferenciar visualmente.
 *      Funções novas:
 *        - rmbz_wallet_mt_get_corrections_history($tel)
 *        - rmbz_wallet_mt_undo_correction($tx_id)
 *
 *   4) PAGINAÇÃO no histórico de transações afectadas
 *      Antes: LIMIT 100 hardcoded — wallets com 1700 tx só mostravam as 100
 *      mais recentes (nem dava para ver o que se estava a debitar).
 *      Agora: paginação por 100, query string ?insp_page=N.
 *      Truncação inteligente "1 ... 5 6 7 ... 17" para muitas páginas.
 *      Header mostra "página X de Y · total Z" para contexto imediato.
 *      Função nova: rmbz_wallet_mt_count_artist_details($tel)
 *      Função estendida: rmbz_wallet_mt_get_artist_details($tel, $page, $per_page)
 *      (assinatura nova com defaults retro-compatíveis).
 *
 *   BUG FIX BÓNUS:
 *      A query antiga em rmbz_wallet_mt_get_artist_details() fazia `SELECT id`
 *      mas a coluna correcta da PK é `transaction_id` (id era alias auto que
 *      ficava NULL). Agora correctamente `SELECT transaction_id` — necessário
 *      para que o botão "Anular" tenha o ID certo.
 *
 *   FLUXO TÍPICO RECOMENDADO:
 *      1. Settings → 1MZ Distribuição → Diagnósticos → Secção 5
 *      2. Lista mostra wallets afectadas — clicar "🔍 Inspeccionar e Debitar"
 *      3. Ver composição (imports vs match vs revert), markers únicos,
 *         distribuição mensal — perceber padrão
 *      4. Ver histórico de transações (paginado) e correções já aplicadas
 *      5. Painel de débito: começar com preset "Seguro" (nunca cria negativo)
 *      6. Confirmar valor no preview live, escrever nota, aplicar
 *      7. Se enganaste, anular individualmente da secção "Correções aplicadas"
 *
 *   Ficheiros tocados: 2
 *     - includes/admin/wallet-mt-cleanup.php (4 funções novas + 1 estendida + 1 fix de coluna + 1 endpoint AJAX)
 *     - includes/admin/import-diagnostics.php (UI estendida com paginação + histórico + painel de débito)
 *
 * == 1.5.146 (2026-05) — 🔧 Fix: backdrop da modal de Lojas não cobria tela toda ==
 *   Reportado: o backdrop escuro da modal "Adicionar / Remover Lojas" só
 *   cobria a parte de cima da página, deixando ver o conteúdo por baixo.
 *
 *   CAUSA RAIZ:
 *     A modal estava renderizada DENTRO do single-view, que por sua vez
 *     vive dentro de wrappers do tema WordPress que têm `transform` /
 *     `filter` / `will-change`. Quando algum ancestral tem essas
 *     propriedades, cria-se um novo "containing block" e o `position:fixed`
 *     deixa de ser relativo à viewport — passa a ser relativo ao
 *     ancestral, fazendo o backdrop e dialog ficarem confinados.
 *
 *   FIX (2 partes):
 *
 *   1) JS no carregamento move o nó da modal para document.body:
 *        if (modalEl.parentNode !== document.body) {
 *            document.body.appendChild(modalEl);
 *        }
 *      Garante que está sempre como filho directo do <body>, escapando
 *      qualquer ancestral problemático.
 *
 *   2) CSS do backdrop e dialog mudou de position:absolute (para o backdrop)
 *      e position:relative (para o dialog) para BOTH position:fixed.
 *      Dialog agora centrado via top:50%; left:50%; transform:translate(-50%,-50%).
 *      Defensa em profundidade — mesmo se algum bug futuro impedir o move
 *      para o body, fixed garante que ambos cobrem a viewport correctamente.
 *
 *   3) BÓNUS — body.classList.add('rmbz-stores-modal-open') quando aberta:
 *      .rmbz-stores-modal-open { overflow:hidden } → bloqueia scroll do
 *      body atrás da modal (UX standard de modais).
 *
 *   4) BÓNUS — .rmbz-stores-fe-actions agora tem padding + border-top
 *      próprios + flex-shrink:0 → barra de acções fica fixa no fundo do
 *      dialog mesmo com conteúdo longo (botões sempre visíveis).
 *
 *   Ficheiros tocados: 1
 *     - includes/distribution/distribution-store-requests.php
 *
 * == 1.5.145 (2026-05) — 🔌 Conectar 4 templates pendentes da 1.5.138 ==
 *   A 1.5.138 introduziu 11 templates editáveis no Centro de Notificações.
 *   7 já estavam ligados aos triggers respectivos. Faltavam 4 que ficaram
 *   "órfãos" — editáveis mas nunca disparavam. Esta versão liga todos.
 *
 *   FICHEIRO NOVO: includes/notifications-distro-bridge.php (~165 linhas)
 *     Bridge entre eventos de distribuição/subscrições e o Centro.
 *     Falha silenciosa se rmbz_notify ausente (nunca quebra nada).
 *
 *   TRIGGER 1: release_returned
 *     - Hook: rmbz_distro_release_returned (já existia desde sempre)
 *     - Listener: rmbz_notif_on_release_returned() em 3 args (id, problems, note)
 *     - Mensagem: "Release X devolvido por motivos Y" — lista problemas
 *       formatados (label + nota) + nota geral do admin se existir
 *
 *   TRIGGER 2: release_live
 *     - Hook: rmbz_distro_status_changed (genérico, dispara em qualquer transição)
 *     - Listener filtra: só dispara se NEW status é live/distribuido E OLD não
 *       (evita re-disparos em casos como migrate, fix manual, etc.)
 *     - Idempotência natural pelo próprio hook (só chama uma vez na transição)
 *
 *   TRIGGER 3: royalties_credited
 *     - Não há hook do WordPress (créditos são INSERTs em massa)
 *     - Função pública: rmbz_notif_dispatch_royalties_credited($credits_by_user, $hash)
 *     - Caller é o handler de approve_import (royalties-approval-handler.php),
 *       que CALCULA os créditos por utilizador via SUM(amount) FROM ledger
 *       agrupado por artist_telemovel WHERE import_hash = X AND reverted_at IS NULL
 *     - Filtra créditos < $0.01 para evitar spam por matches de microcêntimos
 *     - Notifica em USD (a moeda nativa do ledger)
 *
 *   TRIGGER 4: vip_expired
 *     - Cron diário (rmbz_unlock_funds_hook) — verifica subscrições que
 *       expiraram nas últimas 24h
 *     - Janela de 24h evita perder casos se cron falhar num dia
 *     - Idempotência por flag em extra_data['vip_expired_notified'] = end_date
 *       (mesma técnica que vip_expiring na 1.5.138)
 *
 *   COMPORTAMENTO RESILIENTE:
 *     Cada listener verifica se rmbz_notify existe antes de fazer trabalho.
 *     Se Centro de Notificações for desactivado/removido, todo o sistema
 *     continua a funcionar sem erro — apenas não envia notificações.
 *
 *   FICHEIROS:
 *     NOVO:
 *       - includes/notifications-distro-bridge.php
 *     EDITADOS:
 *       - 1mz-for-artists.php (require + version + changelog)
 *       - includes/admin/royalties/royalties-approval-handler.php
 *         (calcula credits_by_user e dispara dispatch após COMMIT)
 *
 * == 1.5.144 (2026-05) — 🏪 Sistema de Pedidos de Lojas + Catálogo Gerido ==
 *   Substitui os botões "alert(em breve)" por sistema completo de gestão
 *   de lojas (DSPs) por release, com workflow de pedido + aprovação +
 *   cobrança automática.
 *
 *   ARQUITECTURA:
 *
 *   2 TABELAS NOVAS (auto-migradas via dbDelta):
 *
 *     rmbz_dsp_catalog
 *       - Catálogo gerido pelo admin (built-in importadas + custom)
 *       - Toggle is_active por loja, mesmo para built-in
 *       - is_builtin flag para proteger contra deletes acidentais
 *       - sort_order para reordenação futura
 *
 *     rmbz_store_change_requests
 *       - Pedidos do artista para alterar lojas duma release
 *       - Snapshot do estado actual + lista proposta + diff calculado
 *       - is_first_change (1ª = grátis), fee_amount, fee_charged
 *       - status: pending | approved | rejected | cancelled
 *
 *   1 FICHEIRO NOVO: includes/distribution/distribution-store-requests.php (~870 linhas)
 *     Tudo modular: schema, queries, helpers, modal frontend, handlers,
 *     páginas admin (catálogo + pedidos).
 *
 *   FLUXO DO ARTISTA:
 *
 *   1. Na página da release LIVE aparece botão "🏪 Adicionar / Remover Lojas"
 *      (só visível se for o owner; não aparece em takedown).
 *   2. Modal abre com TODAS as DSPs activas do catálogo, agrupadas por tier.
 *      DSPs actuais já vêm marcadas. Marcar/desmarcar mostra diff em tempo
 *      real (verde = adicionar, vermelho = remover).
 *   3. Resumo dinâmico mostra:
 *        - "Sem alterações" → submit desactivado
 *        - "X a adicionar · Y a remover" → submit activo
 *        - Custo: 1ª = grátis · só remoções = grátis · com adições = N MT
 *   4. Submit valida ownership e cria registo PENDING. Se já houver pedido
 *      pending para a mesma release, rejeita com mensagem clara.
 *   5. Auto-abre se URL contém #open-stores (link da list-view aproveita).
 *
 *   FLUXO DO ADMIN:
 *
 *   2 TABS NOVOS no menu admin de Distribuição:
 *
 *     🏪 Pedidos Lojas (com badge de pendentes)
 *       - 3 sub-tabs: Pendentes | Aprovados | Rejeitados
 *       - Cada linha: # | data | artista | DIFF VISUAL | custo | acções
 *       - Botão "✓ Aprovar": confirma → debita saldo (se aplicável) → aplica
 *         na rmbz_distro_release_platforms.dsp_list → marca approved
 *       - Botão "✗ Rejeitar": expande textarea para nota → confirma rejeição
 *       - Após decisão: status, decided_at, admin_actor preservados
 *
 *     📚 Catálogo Lojas
 *       - Form para adicionar loja custom (slug, name, tier)
 *       - Settings: toggle global on/off + custo da fee_subsequent
 *       - Tabela com TODAS as lojas (built-in + custom):
 *           Tier | Slug | Nome | Tipo (built-in/custom) | Estado | Acções
 *       - Built-in podem ser DESACTIVADAS mas não apagadas
 *       - Custom podem ser apagadas
 *
 *   REGRAS DE COBRANÇA:
 *
 *     - 1ª alteração APROVADA por release = grátis (status=approved só
 *       conta para o limite, pending/rejected não)
 *     - Subsequentes: SE há adições → cobra rmbz_store_change_fee_subsequent
 *                    SE só remoções → grátis
 *     - Se utilizador não tem saldo na altura da aprovação → erro,
 *       admin precisa de creditar saldo primeiro ou rejeitar pedido
 *
 *   REFACTOR DA FUNÇÃO `rmbz_distro_dsp_catalog()`:
 *     - Renomeada para `rmbz_distro_dsp_catalog_builtin()` (lista interna fixa)
 *     - Wrapper `rmbz_distro_dsp_catalog($only_active=true)` agora delega
 *       para `rmbz_store_get_catalog()` (lê BD com merge built-in + custom)
 *     - Fallback gracioso: se BD ainda não migrada, devolve built-in
 *     - 100% retro-compatível com chamadores existentes
 *
 *   NOTIFICAÇÕES (notifications-center.php):
 *     2 keys novos no Centro de Notificações:
 *       store_change_approved → email + 🔔 com diff e custo
 *       store_change_rejected → email + 🔔 com motivo
 *     Templates editáveis pelo admin via Settings → E-mails 📧.
 *
 *   FIX BÓNUS:
 *     Os botões falsos "+ ADICIONAR LOJAS" e "DIVIDIR ROYALTIES (SPLITS)"
 *     na list-view (com `alert("em breve")`) agora são links reais para
 *     o single-view com âncora #open-stores ou #open-splits que auto-abrem
 *     a modal correspondente. Splits já existia desde 1.5.117; lojas é novo.
 *
 *   HOOK NOVO (distribution-single-view.php):
 *     `do_action('rmbz_distro_after_single_view', $release_id, $user_tel)`
 *     Permite a outros módulos injectarem conteúdo (modais) no fim do
 *     single-view sem editar o ficheiro. Disparado só se utilizador é owner.
 *
 *   ROTAS NOVAS (admin-distribution-controller.php):
 *     case 'store_requests' → rmbz_store_admin_requests_page()
 *     case 'stores_catalog' → rmbz_store_admin_catalog_page()
 *
 *   SETTINGS NOVAS:
 *     rmbz_store_changes_enabled        (bool, default 1)
 *     rmbz_store_change_fee_subsequent  (int MT, default 100)
 *
 *   FICHEIROS:
 *     NOVO:
 *       - includes/distribution/distribution-store-requests.php (~870 linhas)
 *     EDITADOS:
 *       - 1mz-for-artists.php (require + version + changelog)
 *       - includes/notifications-center.php (2 keys novos)
 *       - includes/distribution/distribution-platforms.php (refactor catálogo)
 *       - includes/distribution/distribution-single-view.php (botão + hook)
 *       - includes/distribution/distribution-list-view.php (substituir alerts)
 *       - includes/admin/admin-distribution-controller.php (2 rotas + tabs)
 *
 * == 1.5.143 (2026-05) — ↩ Reverter saldo nos Levantamentos/Depósitos ==
 *   Continua o trabalho da 1.5.141/1.5.142. Decisão "opção B": cada pedido
 *   ganha um botão 'Reverter' que apaga registo + remove transações
 *   relacionadas no Extrato + ajusta saldo de forma coerente.
 *
 *   POR LINHA (cada pedido) agora tem 2 botões:
 *     🔴 EXCLUIR (vermelho)  — apaga só o pedido, saldo e Extrato intactos
 *     🟠 REVERTER (laranja)  — apaga pedido + remove transações no Extrato
 *                              + ajusta saldo
 *
 *   NO HEADER (cada panel) tem 2 botões:
 *     🔴 LIMPAR TUDO         — apaga todos os pedidos só
 *     🟠 REVERTER TUDO       — para cada pedido, faz a reversão completa
 *
 *   COMO IDENTIFICAMOS AS TRANSAÇÕES RELACIONADAS:
 *     Não há marker estável (tipo [withdrawal:ID]) nas transações antigas,
 *     por isso a função helper rmbz_admin_revert_request_impact() usa
 *     heurística por (tipo + valor + janela temporal):
 *
 *     - DEPÓSITO aprovado:
 *         Procura 1× transaction_type='deposit' com amount=+req.amount
 *         na janela ±48h em torno de request_date.
 *         (Pendente/rejeitado: 0 transações criadas → nada a reverter.)
 *
 *     - LEVANTAMENTO (qualquer status):
 *         Procura 1× 'withdrawal_request' com amount=-req.amount
 *         + (se rejeitado) 1× 'withdrawal_rejected' com amount=+req.amount
 *         ambas na janela ±48h.
 *
 *     Para cada transação encontrada: SUBTRAI o seu valor ao saldo
 *     (efectivamente desfaz o efeito original) e apaga a linha.
 *
 *   CASOS COBERTOS (levantamento):
 *     pendente  → 1 trans (-amount), reverte +amount, líquido devolve dinheiro
 *     aprovado  → 1 trans (-amount), reverte +amount, líquido devolve dinheiro
 *     rejeitado → 2 trans (-amount, +amount), líquido 0, ambas apagadas
 *
 *   VALIDAÇÕES:
 *     - Confirm dialog detalhado com impacto antes de executar
 *     - Botão 'Reverter' SÓ aparece em depósitos APROVADOS (rejected/pending
 *       não criaram transações, não há nada a reverter)
 *     - Mensagem de retorno mostra: count revertido, transações removidas,
 *       delta no saldo, saldo final
 *
 *   POTENCIAL DE SALDO NEGATIVO:
 *     Reverter um depósito aprovado debita o saldo. Se o utilizador já
 *     gastou esse dinheiro (compra plano, etc.) o saldo pode ficar negativo.
 *     Admin é avisado pelo confirm dialog. Sistema permite saldo negativo.
 *
 *   HANDLERS NOVOS (admin-user-profile.php):
 *     reverter_levantamento         - Por linha
 *     reverter_deposito             - Por linha (só aprovados)
 *     reverter_todos_levantamentos  - Em massa
 *     reverter_todos_depositos      - Em massa (só os aprovados afectam saldo)
 *
 *   FUNÇÃO HELPER:
 *     rmbz_admin_revert_request_impact($tel, $kind, $amount, $req_dt)
 *       Reutilizada por todos os handlers acima.
 *       Devolve: ['removed_count', 'delta', 'new_balance']
 *
 *   Ficheiros tocados: 1
 *     - includes/admin/admin-user-profile.php
 *
 * == 1.5.142 (2026-05) — 🗑 Limpeza/Anulação do Extrato (parte 2 da 1.5.141) ==
 *   Completa o pedido iniciado na 1.5.141. No header do panel "Extrato da
 *   Carteira" foram adicionados 2 botões lado a lado (decisão "opção C"):
 *
 *   🗑 LIMPAR REGISTOS (vermelho)
 *     - Apaga TODAS as transações do extrato deste utilizador
 *     - NÃO mexe no saldo (limpeza puramente visual)
 *     - Útil para "esconder" histórico antigo sem afectar contabilidade
 *     - Confirm: "Vai apagar N transações. Saldo NÃO será alterado (continua X MT)"
 *
 *   ↩ ANULAR E REVERTER (laranja)
 *     - Apaga TODAS as transações + reverte o saldo (= "Anular" em massa)
 *     - Calcula a soma de todas as transações e subtrai do saldo actual
 *     - Confirm mostra impacto exacto: "Saldo actual X → após anulação Y"
 *     - PODE LEVAR A SALDO NEGATIVO se houve mais créditos que débitos —
 *       admin é avisado pelo confirm com os números antes de continuar
 *
 *   Cores distintas (vermelho vs laranja) para diferenciar destrutivo-leve
 *   de destrutivo-pesado. Tooltip em cada botão explica o efeito.
 *
 *   HANDLERS NOVOS (admin-user-profile.php):
 *     limpar_extrato  - delete em rmbz_wallet_transactions, saldo intacto
 *     anular_extrato  - calcula sum(amount), subtrai do balance, depois delete
 *   Ambos com nonce + filtro user_telemovel.
 *
 *   Mensagem de retorno detalhada com count e (no anular) a transição
 *   de saldo: "5 transações anuladas. Saldo: 1500,00 MT → 0,00 MT
 *   (revertido 1500,00 MT)."
 *
 *   Ficheiros tocados: 1
 *     - includes/admin/admin-user-profile.php
 *
 * == 1.5.141 (2026-05) — 🗑 Limpeza de Levantamentos + Depósitos no perfil admin ==
 *
 *   PARTE 1 (entregue agora):
 *   - "Anular" → "Excluir" no Extrato (botão renomeado para clareza)
 *   - Título do panel: "Extrato da Carteira & Anulações" → "Extrato da Carteira"
 *   - 2 NOVAS TABELAS no perfil do utilizador (admin):
 *
 *     💸 PEDIDOS DE LEVANTAMENTO
 *       Lista todos os pedidos do utilizador (LIMIT 50) com colunas:
 *         Data | Valor | Origem (MT/USD) | Método | Estado | Ação
 *       Cores nos estados (pendente=dourado, aprovado=verde, rejeitado=vermelho).
 *       - Botão "Excluir" por linha
 *       - Botão "🗑 Limpar Tudo" no header (confirma com count)
 *       - NÃO mexe no saldo (movimentos correspondentes ficam no Extrato)
 *
 *     📥 PEDIDOS DE DEPÓSITO
 *       Lista todos os pedidos com colunas:
 *         Data | Valor | Método | Tx ID | Estado | Ação
 *       - Botão "Excluir" por linha
 *       - Botão "🗑 Limpar Tudo" no header
 *       - Apaga TAMBÉM o ficheiro de comprovativo (proof_image) do disco
 *         para libertar espaço — operação destrutiva, confirmação dupla.
 *       - NÃO mexe no saldo (créditos aprovados ficam no Extrato)
 *
 *   PARTE 2 (pendente):
 *   - Botão "Limpar Tudo" no Extrato + comportamento sobre saldo
 *     (aguarda decisão do admin: só apagar OU apagar+reverter OU ambas opções)
 *
 *   HANDLERS NOVOS NO SAVE (admin-user-profile.php):
 *     excluir_levantamento  - Apaga 1 pedido (validado por user_telemovel)
 *     excluir_deposito      - Apaga 1 pedido + ficheiro do disco
 *     limpar_levantamentos  - Apaga TODOS do user
 *     limpar_depositos      - Apaga TODOS + ficheiros
 *
 *   Todos os handlers:
 *   - Verificam nonce (rmbz_user_controls_nonce)
 *   - Filtram por user_telemovel (não conseguem afectar outros utilizadores)
 *   - Devolvem mensagem com count de registos afectados
 *
 *   Ficheiros tocados: 1
 *     - includes/admin/admin-user-profile.php
 *
 * == 1.5.140 (2026-05) — ⚡ Paginação do Extrato sem reload (AJAX) ==
 *   Reportado: cada clique na paginação fazia recarregar a página inteira
 *   — chato e lento.
 *
 *   Solução: paginação 100% AJAX com substituição incremental do tbody.
 *
 *   ENDPOINT (ajax-functions.php):
 *     `rmbz_extrato_paginate` — recebe nonce + page, devolve JSON com:
 *       - rows_html: HTML das <tr> para o tbody
 *       - pagination_html: HTML dos botões da paginação
 *       - counter_html: "A mostrar X-Y de Z transações"
 *       - page: página actual (após clamp se pediu fora do range)
 *     Validações: nonce, login obrigatório, telemovel não vazio.
 *     Apenas devolve dados do utilizador autenticado (security guard).
 *
 *   RENDERER PARTILHADO (ajax-functions.php):
 *     `rmbz_extrato_render_pagination_html($current, $total)` — função
 *     única usada PELO RENDER INICIAL e PELO AJAX. Garante markup
 *     idêntico (truncação 1 ... 5 6 7 ... 20, estilos, classes).
 *
 *   FRONTEND (my-account-page.php):
 *     - Wrapper #rmbz-extrato-wrapper para event delegation
 *     - tbody #rmbz-extrato-tbody para receber novas linhas
 *     - Div #rmbz-extrato-pagination + #rmbz-extrato-counter substituídos
 *     - Overlay #rmbz-extrato-loading com "⏳ A carregar..." durante AJAX
 *     - Botões com class .rmbz-ext-page-btn + data-ext-page="N"
 *
 *   JS (inline):
 *     - Event delegation no wrapper — funciona mesmo após o pagination
 *       ser substituído (handlers não se perdem)
 *     - Debounce/anti-double-click via flag inFlight
 *     - history.pushState para actualizar a URL sem reload
 *     - Fallback automático: se fetch falhar, redirect para
 *       ?ext_page=N (= comportamento antigo, sempre funciona)
 *     - Sem `&&` no script (segue padrão 1.5.119/126)
 *
 *   FALLBACK GRACIOSO:
 *     Os botões continuam a ser <a href="?ext_page=N"> normais. Se JS
 *     falhar, ou estiver desactivado, ou fetch crashar, os links
 *     funcionam como antes. Zero regressão.
 *
 *   Ficheiros tocados: 2
 *     - includes/ajax-functions.php (+endpoint +renderer)
 *     - includes/my-account-page.php (markup com IDs + script)
 *
 * == 1.5.139 (2026-05) — 📑 Paginação no Extrato da Carteira ==
 *   Reportado: o extrato carregava todas as transações de uma vez (LIMIT 30
 *   fixo) — em contas com muitos royalties (match reviews podem gerar
 *   dezenas de linhas só num import) a tabela ficava interminável.
 *
 *   Mudanças:
 *
 *   1) QUERY paginada: 15 transações por página, controlada por
 *      `?ext_page=N` na URL. Chave separada de `?paged=N` (paginação dos
 *      envios) para não interferir.
 *
 *   2) UI da paginação debaixo da tabela:
 *      - Counter "A mostrar 1–15 de 47 transações" (esquerda)
 *      - Botões: ← Anterior · 1 2 3 4 5 · Próxima → (direita)
 *      - Truncação inteligente para muitas páginas:
 *          ≤7 páginas: mostra todas
 *          >7: mostra primeira, ..., (atual±1), ..., última
 *          Ex: "1 ... 5 6 7 ... 20"
 *      - Página actual destacada em dourado.
 *      - Anterior/Próxima escondidos se já estás no extremo.
 *
 *   3) Âncora #extrato adicionada ao título para que ao paginar a página
 *      faça scroll directo para o extrato (scroll-margin-top:80px para
 *      compensar o header sticky).
 *
 *   4) URLs preservam outras query strings com add_query_arg() — se já
 *      estás numa tab específica ou com outros params, ficam intactos.
 *
 *   Ficheiros tocados: 1
 *     - includes/my-account-page.php
 *
 * == 1.5.138 (2026-05) — 📬 Centro de Notificações com 11 novos templates ==
 *   Sistema completo de notificações ao utilizador via email + sino 🔔,
 *   com TODOS os textos editáveis pelo admin. Centraliza o que estava
 *   espalhado em ~25 chamadas wp_mail() directas pelo código.
 *
 *   FICHEIRO NOVO: includes/notifications-center.php (~330 linhas)
 *     Função única `rmbz_notify($user_tel, $key, $vars)` que:
 *       1. Lê template para a key de get_option('rmbz_email_templates')
 *       2. Substitui {variáveis} no subject e no body
 *       3. Envia email (HTML, com identidade do remetente das settings)
 *       4. Grava em rmbz_email_logs (sino 🔔 do utilizador)
 *       5. Loga sucesso/falha para histórico admin
 *
 *     Helper `rmbz_notify_admin($key, $vars)` para alertas internos.
 *
 *     11 KEYS NOVOS suportados (com defaults pré-escritos em PT-PT):
 *       Onboarding:   welcome_pending, deposit_approved, deposit_rejected
 *       Planos VIP:   vip_purchased, vip_expiring, vip_expired
 *       Convites:     referral_pending, referral_completed
 *       Distribuição: royalties_credited, release_returned, release_live
 *
 *     Cada key tem metadados (label, descrição, grupo, vars suportadas)
 *     em rmbz_notifications_keys_meta() — usado pela UI admin.
 *
 *   UI ADMIN ESTENDIDA (email-manager.php)
 *     A página existente "E-mails 📧" agora mostra:
 *       - Os 4 templates legacy (admin_new, artist_received, etc.)
 *       - Os 11 novos templates AGRUPADOS por categoria com cor:
 *         🔵 Onboarding (azul) | 🟡 Planos VIP (dourado)
 *         🟢 Convites (verde) | 🟣 Distribuição (roxo)
 *       Cada bloco mostra: ícone + label + descrição + chips com {variáveis}
 *       disponíveis + input de assunto + textarea do corpo.
 *
 *   HOOKS DISPARADOS (refactor + adições):
 *
 *     1) Registo (form-handler.php:438)
 *        → welcome_pending — utilizador recebe info do depósito pendente
 *
 *     2) Aprovar depósito (admin-handlers.php:407)
 *        ANTES: rmbz_send_user_deposit_status() — só email, hardcoded
 *        AGORA: rmbz_notify('deposit_approved') — editável + sino
 *
 *     3) Rejeitar depósito (admin-handlers.php:442)
 *        ANTES: rmbz_send_user_deposit_status() — só email
 *        AGORA: rmbz_notify('deposit_rejected') — editável + sino
 *
 *     4) Comprar plano VIP (admin-handlers.php:1187)
 *        → vip_purchased — confirmação com plano, dias, data expiração
 *
 *     5) Cron diário (cron-jobs.php) — NOVA tarefa
 *        → vip_expiring — 3 dias antes da subscrição expirar
 *        Idempotente: marca extra_data['vip_expiring_notified'] para
 *        não repetir até nova subscrição ser criada.
 *
 *     6) Referral pending (referral-system.php:120)
 *        → referral_pending — alerta ao referrer assim que alguém o usa
 *
 *     7) Referral completed (referral-system.php:295)
 *        ANTES: rmbz_create_notification() — função que pode não existir
 *        AGORA: rmbz_notify('referral_completed') — comissão + sino
 *
 *   PRIVACIDADE:
 *     Telemóveis no email são parcialmente mascarados (ex: "+258 84 ••• 8807")
 *     para evitar exposição completa em mensagens de referrals.
 *
 *   TEMPLATES TÊM DEFAULTS:
 *     Se admin nunca editou um template, é usado o default em PT-PT
 *     definido em rmbz_notifications_default_templates(). Admin pode
 *     editar a qualquer momento via Settings → E-mails 📧.
 *
 *   FICHEIROS:
 *     NOVO: includes/notifications-center.php
 *     EDITADOS:
 *       - 1mz-for-artists.php (require + version + changelog)
 *       - includes/email-manager.php (UI dinâmica + save handler)
 *       - includes/admin/admin-handlers.php (refactor depósitos + VIP)
 *       - includes/form-handler.php (welcome_pending)
 *       - includes/referral-system.php (pending + completed via Centro)
 *       - includes/cron-jobs.php (vip_expiring)
 *
 * == 1.5.137 (2026-05) — 🔧 Pill MT: espaço entre "MT" e o valor ==
 *   Reportado: a pill "MT 15" estava a sair colada — "MT15".
 *
 *   Causa: o CSS de `.rmbz-stat-pill__label` aplica `margin-left:
 *   var(--rmbz-space-2)` assumindo que o label vem DEPOIS do value.
 *   Como na pill MT inverti a ordem (label PREFIXO, value depois),
 *   a margem ficava do lado errado e ambos elementos ficavam colados.
 *
 *   Fix: estilo inline no label da pill MT — `margin-left:0;
 *   margin-right:var(--rmbz-space-2,8px);` — espaço fica do lado certo.
 *   Outras pills (que mantêm a ordem normal value→label) ficam intactas.
 *
 *   Ficheiros tocados: 1
 *     - includes/my-account-page.php
 *
 * == 1.5.136 (2026-05) — 💱 Pill MT: remover ícone cifrão $, prefixar "MT" ==
 *   Reportado: a pill de recompensa em MT mostrava um SVG que parecia
 *   um cifrão `$`, sugerindo USD. Como a moeda é metical (MT), confunde.
 *
 *   Mudanças:
 *     - Removido `$icon_mt` (SVG do "$" estilizado) — não era usado em
 *       lado nenhum a fazer sentido.
 *     - Pill de MT agora aparece sem ícone — texto "MT" como PREFIXO,
 *       seguido do valor: leitura natural "MT 15". Mais limpo e sem
 *       ambiguidade de moeda.
 *     - Adicionado `$icon_calendar` (rectângulo com header) para a pill
 *       de Dias VIP — antes reusava o `$icon_mt`, era confuso.
 *
 *   Resultado visual:
 *     Antes:  [$ 15 MT]    (ícone cifrão + valor + label)
 *     Agora:  [MT 15]      (label prefixo + valor, sem ícone)
 *     Antes:  [$ 5 dias VIP]
 *     Agora:  [📅 5 dias VIP]  (ícone calendário próprio)
 *
 *   Ficheiros tocados: 1
 *     - includes/my-account-page.php
 *
 * == 1.5.135 (2026-05) — ⚠ Aviso explícito antes de submeter convite inválido ==
 *   Pedidos do utilizador:
 *     1. Deixar visualmente CLARO que o campo "Convidado por" é OPCIONAL
 *        (contorno amarelo, badge destacada).
 *     2. Se preencher um número que não é válido e tentar finalizar o
 *        registo, AVISAR explicitamente que o convidador não vai ganhar
 *        os pontos — em vez de ignorar silenciosamente como antes.
 *
 *   VISUAL — campo embrulhado num container destacado:
 *     - Border amarelo subtil (rgba(240,185,11,0.45))
 *     - Background dourado claro (rgba(240,185,11,0.05))
 *     - Badge "OPCIONAL" no topo-esquerdo do container
 *       (fundo dourado #f0b90b, texto preto, uppercase, 10px)
 *     - Hint reformulado: "Se um amigo te convidou... Se não te convidaram,
 *       deixa em branco." — explícito.
 *
 *   GUARDA NO SUBMIT (JS):
 *     Adicionado hidden input `rmbz-referral-state` que o JS de validação
 *     atualiza com 4 estados possíveis:
 *       - ""           : campo vazio ou não chegou a verificar
 *       - "checking"   : AJAX em curso
 *       - "valid"      : ✓ aprovado
 *       - "invalid"    : ✗ rejeitado pelo servidor
 *
 *     No submit do form, se o valor não está vazio E o estado não é
 *     "valid", aparece confirm() com mensagem específica:
 *
 *       Se "invalid":
 *         "⚠ ATENÇÃO: O número de convite que preencheste NÃO é válido
 *          (ou não está cadastrado na 1MZ). Se continuares, a tua conta
 *          será criada normalmente — MAS quem te convidou NÃO vai
 *          receber recompensa por ti.
 *          • OK = continuar e ignorar o número de convite
 *          • Cancelar = corrigir o número primeiro"
 *
 *       Se "checking" (AJAX ainda a meio):
 *         "Ainda estamos a verificar o número de convite. Tens a certeza
 *          que queres continuar? Se não esperares, o convidador pode não
 *          receber a recompensa."
 *
 *     Se utilizador cancelar → submit é abortado (preventDefault +
 *     stopPropagation), input ganha focus para corrigir.
 *     Se confirmar → submit prossegue normalmente; servidor já lida com
 *     número inválido (form-handler.php delega à create_pending desde
 *     1.5.133, que rejeita silenciosamente se inválido).
 *
 *     Listener registado com capture=true para correr ANTES de outros
 *     submit handlers (ex: do template anti-bot do registo).
 *
 *     Sem `&&` no script (segue padrão 1.5.119/126).
 *
 *   Ficheiros tocados: 1
 *     - includes/my-account-page.php
 *
 * == 1.5.134 (2026-05) — 🎨 Card "Ganhe Saldo Extra" com chips inline ==
 *   Inconsistência visual reportada: o card "Distribuição Digital" tinha
 *   chips elegantes inline ao lado da descrição (`$ 20.14 ganhos`,
 *   `🎵 1 música`, `⚠ 1 atenção`), mas o card "Ganhe Saldo Extra" tinha
 *   um bloco grande separado com fundo verde — destoava na grelha.
 *
 *   Refeito o card de convites para usar EXACTAMENTE o mesmo padrão
 *   visual (.rmbz-stat-pill com variantes --brand/--accent/--warning):
 *
 *     Pill 1 (--accent, verde-azul) : "👥 N amigos" — aparece se count > 0
 *     Pill 2 (--brand, dourado)     : "$ X MT"     — aparece se mt_earned > 0
 *     Pill 3 (--brand, dourado)     : "⏰ N dias VIP" — aparece se days_earned > 0
 *     Pill 4 (--warning, laranja)   : "⏰ N pendentes" — aparece se pending > 0
 *
 *   Cada pill aparece SÓ se o valor é > 0 — card fica limpo se não há
 *   atividade. Tooltip nos pendentes explica o estado ("Aguardam o amigo
 *   fazer o 1º depósito + comprar plano").
 *
 *   Ícones SVG criados no mesmo formato (viewBox 14×14, classe
 *   rmbz-stat-pill__icon) para consistência: users, MT, clock.
 *
 *   Plurais respeitados via _n() — "1 amigo / 2 amigos", "1 dia VIP / 2
 *   dias VIP", "1 pendente / 2 pendentes".
 *
 *   Removido bloco grande verde com fundo `rgba(46,204,113,0.08)` e
 *   inline styles — substituído pelos pills do design system, que já
 *   têm os estilos consistentes.
 *
 *   Ficheiros tocados: 1
 *     - includes/my-account-page.php
 *
 * == 1.5.133 (2026-05) — ✅ Validação ao vivo + lista de proibição de convites ==
 *   Pedido em duas frentes:
 *     1. Quando utilizador escreve número no campo "Convidado por" do
 *        registo, sistema valida em tempo real e mostra "✓ Convite aprovado"
 *        com nome, ou "✗" com motivo (não existe / não autorizado / etc).
 *     2. Admin tem forma prática de banir utilizadores específicos de
 *        receber comissões de convite (anti-fraude).
 *
 *   FRONTEND — VALIDAÇÃO AO VIVO (my-account-page.php)
 *     Campo "Convidado por" agora tem id `rmbz-referral-input` e área de
 *     feedback `rmbz-referral-feedback` por baixo. JS dispara AJAX com
 *     debounce 500ms quando o utilizador escreve ≥9 dígitos. Resposta:
 *       ✓ verde:  "Convite aprovado — {nome} vai receber recompensa"
 *       ✗ vermelho: "Não está registado", "Não autorizado", "Próprio número"...
 *     Sem `&&` em JS (segue padrão da 1.5.119/126 contra escape do WP).
 *     Loading visual "⏳ A verificar..." enquanto AJAX pende.
 *
 *   AJAX REFINADO (ajax-functions.php)
 *     `rmbz_validate_referral_code_ajax` melhorado:
 *       - Aceita `referral_phone` SEM o prefixo +258 (normaliza no servidor)
 *       - `buyer_phone` agora opcional (registo novo, conta ainda não existe)
 *       - Valida formato mínimo (≥11 chars com +258)
 *       - Verifica blacklist global (rmbz_referral_blacklist)
 *       - Verifica is_banned da conta também (defesa em profundidade)
 *       - Mensagens distintas: "não registado" vs "não autorizado"
 *
 *   ADMIN — TOGGLE NO PERFIL DO UTILIZADOR (admin-user-profile.php)
 *     Antes admin tinha de editar o textarea da blacklist em Settings →
 *     Convites — pouco prático para casos pontuais.
 *     Agora: na página "Histórico do Artista" → secção "Bloqueios", há um
 *     novo switch "🚫 Banir de Convites". Quando ON, adiciona automaticamente
 *     o telemóvel à `rmbz_referral_blacklist`. Quando OFF, remove. A setting
 *     em Settings continua a funcionar como antes (bidireccional).
 *
 *   FORM-HANDLER (form-handler.php)
 *     Refactorizado: agora delega TODA a validação a
 *     `rmbz_referral_create_pending()` (que já fazia: existência, auto-ref,
 *     blacklist, duplicados, e agora is_banned). Em vez de duplicar a
 *     verificação, se a função devolve false → não grava `referred_by` →
 *     registo continua silenciosamente sem referrer.
 *
 *   MÓDULO CENTRAL (referral-system.php)
 *     `rmbz_referral_create_pending()` agora também rejeita se
 *     `referrer.is_banned = 1`, mesma lógica do AJAX. Garantia de que
 *     não há caminho que crie comissão para conta banida.
 *
 *   Ficheiros tocados: 5
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/ajax-functions.php (AJAX refinado)
 *     - includes/admin/admin-user-profile.php (toggle Banir Convites)
 *     - includes/form-handler.php (delegação à create_pending)
 *     - includes/my-account-page.php (campo + JS de validação)
 *     - includes/referral-system.php (check is_banned)
 *
 * == 1.5.132 (2026-05) — ✂ Limpeza: bloco "último relatório" só mostra a data ==
 *   Pedido: o card "Último relatório importado pela 1MZ" mostrava o nome
 *   do ficheiro CSV (ex: `analysis_1777316245_xihdg130.csv`) e um parágrafo
 *   explicativo sobre "se a sua música não está incluída...". Tudo isso é
 *   informação técnica/operacional interna que confunde o artista — gera
 *   suporte desnecessário ("o que é este código?") sem trazer valor.
 *
 *   Mantido apenas o sinal claro: "Último relatório importado · 27 de
 *   abril de 2026". É só isso que o utilizador precisa de saber.
 *
 *   Layout também ficou mais compacto (uma linha em vez de três).
 *
 *   Ficheiros tocados: 1
 *     - includes/distribution/distribution-analytics.php
 *
 * == 1.5.131 (2026-05) — 🎁 Sistema de Convites/Referrals operacional ==
 *   Pedido pelo utilizador: implementar a funcionalidade do card "Ganhe
 *   Saldo Extra" que estava só com markup mas sem lógica funcional.
 *
 *   ESTADO ANTERIOR:
 *     - Card mostrava input + botão "Copiar" (já funcional)
 *     - Settings tinha rmbz_referral_rate (3%) e rmbz_referral_blacklist
 *     - Coluna `referred_by` existia em rmbz_wallets mas nunca era escrita
 *     - Função rmbz_log_referral_attempt() existia mas tabela rmbz_referral_log
 *       NÃO estava no schema (gravava em tabela inexistente — falha silenciosa)
 *     - AJAX rmbz_validate_referral existia mas não era usado pelo registo
 *
 *   ESTADO AGORA — sistema completo end-to-end:
 *
 *   1) SCHEMA (functions-setup.php)
 *      Tabela rmbz_referral_log adicionada formalmente, com novos campos
 *      de tracking duplo: first_deposit_approved_at, plan_purchased_at.
 *      Versão da BD: 2.1.5 → 2.1.6.
 *      Tabela também adicionada ao verificador de "tabelas em falta".
 *
 *   2) SETTINGS (admin-settings.php)
 *      Nova subsecção "Tipo de Recompensa" com radio MT vs Dias VIP:
 *        - rmbz_referral_type ('mt' | 'days', default 'mt')
 *        - rmbz_referral_rate (% sobre depósito, usado se tipo=mt)
 *        - rmbz_referral_days (dias VIP fixos, usado se tipo=days)
 *      Descrição da secção explica REGRA DUPLA: 1º depósito + plano VIP.
 *
 *   3) FORM DE REGISTO (my-account-page.php)
 *      Campo novo opcional "Convidado por (telemóvel)" entre PIN e a
 *      secção de Activação VIP. Hint explicativo. Aceita formato livre
 *      (com ou sem +258) — normalizado no servidor.
 *
 *   4) HANDLER DE REGISTO (form-handler.php)
 *      Quando registo é processado:
 *        - Normaliza tel referrer (+258 prefix)
 *        - Valida que existe e não é o próprio utilizador
 *        - Grava em rmbz_wallets.referred_by
 *        - Cria entrada pending em rmbz_referral_log
 *      Falha silenciosamente se inválido (não bloqueia o registo).
 *
 *   5) MÓDULO CENTRAL (includes/referral-system.php — NOVO ~330 linhas)
 *      Funções:
 *        - rmbz_referral_create_pending() — cria entry no registo
 *        - rmbz_referral_on_first_deposit_approved() — handler hook
 *        - rmbz_referral_on_vip_plan_purchased() — handler hook
 *        - rmbz_referral_try_complete() — paga comissão se ambas condições
 *        - rmbz_referral_grant_vip_days() — adiciona dias à subscrição
 *        - rmbz_referral_get_user_stats() — para o card
 *        - rmbz_referral_get_user_log() — para futura modal de detalhes
 *
 *      Hooks ouvidos:
 *        - rmbz_first_deposit_approved (1ª condição)
 *        - rmbz_vip_plan_purchased (2ª condição)
 *      Quando ambas estão marcadas, comissão é creditada e log fica
 *      status=completed. Notificação ao referrer (se sistema disponível).
 *
 *      Anti-fraude:
 *        - Auto-referral bloqueado (referrer === buyer)
 *        - Blacklist respeitada
 *        - Duplicados evitados (1 entry por buyer)
 *
 *   6) HOOKS DISPARADOS (admin-handlers.php)
 *      - Em rmbz_processar_aprovacao_deposito: dispara rmbz_first_deposit_approved
 *        SÓ se for o 1º depósito aprovado (count = 1 após update).
 *      - Em rmbz_process_buy_vip_package: dispara rmbz_vip_plan_purchased
 *        sempre que VIP é activado com sucesso.
 *
 *   7) CARD "GANHE SALDO EXTRA" (my-account-page.php)
 *      Antes: input + botão Copiar.
 *      Agora:
 *        - Texto promocional dinâmico ("Ganha X% em MT" ou "Ganha N dias VIP")
 *          consoante setting rmbz_referral_type
 *        - Stats reais: convidados, MT ganhos, dias ganhos, pendentes
 *          (só aparece o bloco se tem pelo menos 1 convidado/pendente)
 *        - Input + Copiar mantido
 *
 *   FLUXO COMPLETO TESTÁVEL:
 *     1. User A registou-se (já tem conta).
 *     2. User B regista-se com "Convidado por: tel_A"
 *        → entry pending no log
 *     3. Admin aprova 1º depósito de B
 *        → first_deposit_approved_at marcado, mas ainda pending
 *     4. B compra plano VIP (qualquer)
 *        → plan_purchased_at marcado → AMBAS condições cumpridas
 *        → comissão creditada (MT ou dias) → status=completed
 *        → A vê notificação + stats actualizadas no card
 *
 *   FICHEIROS NOVOS: 1
 *     - includes/referral-system.php
 *
 *   FICHEIROS EDITADOS: 6
 *     - 1mz-for-artists.php (require + version + changelog)
 *     - includes/functions-setup.php (schema + verificador)
 *     - includes/admin/admin-settings.php (settings + callbacks)
 *     - includes/admin/admin-handlers.php (2 hooks)
 *     - includes/form-handler.php (gravação de referred_by + create_pending)
 *     - includes/my-account-page.php (campo no form + card melhorado)
 *
 * == 1.5.130 (2026-05) — 🐛 Notice obsoleta após aprovação do 1º depósito ==
 *   Sintoma reportado: depois do admin aprovar o primeiro depósito do
 *   utilizador, ao entrar na plataforma o utilizador via um popup
 *   "✅ SUCESSO — Conta criada com sucesso! O pagamento inicial está em
 *   análise pela nossa equipa." — claramente obsoleto, porque já não
 *   está em análise.
 *
 *   Causa: cadeia de eventos.
 *     1. Utilizador regista-se (form-handler.php:408) → set
 *        $_SESSION['rmbz_withdrawal_notice'] com mensagem "em análise"
 *     2. Redirect → utilizador logado → rmbz_render_dashboard_view() corre
 *     3. Desde a 1.5.128, o gate dispara `return` early antes da linha 1396
 *        (onde o popup é renderizado e a sessão limpa)
 *     4. Notice fica em sessão, sem ser consumida
 *     5. Quando admin aprova → gate deixa de disparar → dashboard chega
 *        à linha 1396 → mostra o popup OBSOLETO
 *
 *   Fix em duas frentes (defesa em profundidade):
 *
 *   1) PROACTIVA (first-deposit-gate.php):
 *      Quando o gate dispara, descarta silenciosamente a notice de sessão
 *      se contiver a substring "pagamento inicial". O gate em si já
 *      comunica o estado claramente — a notice é redundante.
 *      Heurística específica: outras notices (ex: "PIN alterado") ficam
 *      intactas para aparecerem normalmente quando o utilizador sair do gate.
 *
 *   2) RETRIBUTIVA (my-account-page.php):
 *      Antes do display da notice no dashboard, verifica:
 *        - Notice contém "pagamento inicial"?
 *        - Utilizador JÁ tem depósito aprovado OU subscrição activa?
 *      Se ambos verdadeiros → notice é claramente órfã → descarta.
 *      Cobre utilizadores que já tinham notice presa em sessão antes
 *      desta versão.
 *
 *   Ficheiros tocados: 3
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/first-deposit-gate.php (limpeza preventiva)
 *     - includes/my-account-page.php (limpeza retributiva)
 *
 * == 1.5.129 (2026-05) — ⚙ Settings: Inscrição Inicial + Contactos de Suporte ==
 *   Duas mudanças relacionadas pedidas pelo utilizador:
 *
 *   1) CONTACTOS DO FIRST DEPOSIT GATE deixam de ser hardcoded.
 *      Antes: "+258 84 0000000" e admin_email do WP no código.
 *      Agora: 2 settings novas que admin gere em Settings → 1MZ:
 *        - `rmbz_support_whatsapp` (texto livre, ex: "+258 84 1234567")
 *        - `rmbz_support_email` (vazio = usa admin_email do WP)
 *      Se WhatsApp for vazio, o botão "💬 WhatsApp" não é renderizado
 *      no gate (não força admin a ter WhatsApp). Email tem fallback
 *      automático para admin_email.
 *
 *   2) SECÇÃO LEGACY "Configurações de Acesso Artista (Subscrição)"
 *      com Preço Mensal MT + Duração em Dias deixa de aparecer no UI.
 *      Razão: depois da introdução dos Pacotes VIP (com `rmbz_vip_packages`,
 *      cada pacote tem o seu preço/dias — vimos VIP 20 / VIP 30 / VIP 40
 *      no UI), os settings legacy `rmbz_sub_price`/`rmbz_sub_days` são
 *      contraditórios — confunde o admin.
 *
 *      Substituída por nova secção "Inscrição Inicial e Contactos de
 *      Suporte" com 3 campos:
 *        - Valor mínimo do 1º depósito (MT) — `rmbz_first_deposit_min`
 *          O artista deposita este valor para activar a conta. Quando
 *          o admin aprova, o valor fica creditado na carteira e o
 *          artista compra um Pacote VIP à sua escolha.
 *        - WhatsApp do Suporte
 *        - Email do Suporte
 *
 *      Settings legacy `rmbz_sub_price`/`rmbz_sub_days` mantidas
 *      registadas (compatibilidade com fluxos legacy ainda activos
 *      em subscription-functions.php), só não aparecem no UI.
 *
 *   APLICAÇÃO em form-handler.php (criação de conta):
 *      O depósito inicial agora usa `rmbz_first_deposit_min` em vez
 *      de `rmbz_sub_price`. Se a setting nova for vazia (instalações
 *      a fazer upgrade), cai no `rmbz_sub_price` legacy — sem regressão.
 *
 *   APLICAÇÃO em my-account-page.php (login view):
 *      O preço mostrado no formulário de registo lê primeiro
 *      `rmbz_first_deposit_min`. Mesma lógica de fallback.
 *
 *   Ficheiros tocados: 4
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/admin/admin-settings.php (settings novas + secção)
 *     - includes/first-deposit-gate.php (lê settings em vez de hardcoded)
 *     - includes/form-handler.php (registo usa first_deposit_min)
 *     - includes/my-account-page.php (login view mostra first_deposit_min)
 *
 *   PRÓXIMOS PASSOS RECOMENDADOS após instalar:
 *     1. Ir a Settings → 1MZ no admin
 *     2. Preencher os 3 novos campos:
 *        - Valor mínimo do 1º depósito (ex: 500 MT — alinhado ao VIP 20)
 *        - WhatsApp do Suporte (ex: +258 84 XXXXXXX)
 *        - Email do Suporte (deixar vazio se admin_email do WP serve)
 *     3. Guardar — o gate dos novos registos passa a usar os valores correctos
 *
 * == 1.5.128 (2026-05) — 🐛 First Deposit Gate: bloquear o shortcode certo ==
 *   Sintoma reportado: utilizador novo (MetaBoyZ, deposit 1.500MT pendente)
 *   continuou a ver a área completa "Olá, X" com cartões VIP, em vez do
 *   gate dourado introduzido na 1.5.127.
 *
 *   Causa: o gate foi adicionado nos shortcodes `[1mz_artist_area]` e
 *   `[1mz_distribution_dashboard]` em shortcodes.php. Mas a página
 *   principal que os utilizadores usam ("Olá, X" + cartões VIP + cupão +
 *   tabs) é renderizada pelo shortcode `[rmbz_minha_conta]` em
 *   my-account-page.php (função `rmbz_render_dashboard_view`). O gate
 *   nunca chegava a disparar lá.
 *
 *   Fix: adicionar a chamada `rmbz_first_deposit_gate_block_if_needed()`
 *   no início de `rmbz_render_dashboard_view`, antes de qualquer query
 *   ou render. Se dispara, devolve imediatamente sem renderizar nada.
 *
 *   Defesa em profundidade: o gate continua chamado também nos outros
 *   dois shortcodes (1.5.127), por se algum utilizador chegar lá por
 *   link directo.
 *
 *   Ficheiros tocados: 2
 *     - 1mz-for-artists.php (version + changelog)
 *     - includes/my-account-page.php (chamada ao gate em rmbz_render_dashboard_view)
 *
 * == 1.5.127 (2026-05) — 🚪 First Deposit Gate: bloqueio total pre-activação ==
 *   Antes: utilizador novo cria conta + paga (depósito fica pendente). Vê
 *   o paywall normal — confuso, porque parece que perdeu uma subscrição
 *   que nem sequer chegou a ter. Podia tentar usar funcionalidades e
 *   apanhar erros confusos.
 *
 *   Agora: detecção precisa do estado "first deposit pending" e GATE
 *   full-screen com tudo o que o utilizador precisa de saber:
 *
 *   DETECÇÃO (rmbz_user_has_pending_first_deposit):
 *     - Conta existe em rmbz_wallets
 *     - Tem pelo menos 1 deposit pendente em rmbz_deposit_requests
 *     - NUNCA teve deposit aprovado (se já teve, é top-up, não first)
 *     - NUNCA teve subscrição (excluí casos de cupão)
 *     Heurística cumulativa para evitar falsos positivos.
 *
 *   GATE (rmbz_render_first_deposit_gate):
 *     - Full-screen, design dark coerente com paleta da plataforma
 *     - Mostra: valor pago, método, ID transacção, tel emissor
 *     - Countdown 24h ao vivo (atualiza cada minuto via JS)
 *     - Cor do badge muda conforme proximidade do prazo (verde > 6h,
 *       amarelo 1-6h, vermelho < 1h, cinza expirado)
 *     - Instruções claras: "o que está a acontecer", "ainda não pagaste?",
 *       "já pagaste mas não vês alteração?"
 *     - Botões de contacto: WhatsApp (admin phone) + email
 *     - Botão "Sair / Voltar mais tarde" (logout limpo)
 *
 *   APLICAÇÃO:
 *     Gate chamado em DOIS pontos de entrada:
 *       - Shortcode [1mz_artist_area] (página principal do artista)
 *       - Shortcode [1mz_distribution_dashboard] (área de distribuição)
 *     Em ambos, se o gate dispara, devolve o HTML e impede qualquer
 *     outra renderização da página.
 *
 *   CLEANUP AUTOMÁTICO (rmbz_first_deposit_cleanup_extended):
 *     Substitui rmbz_cleanup_expired_deposits do cron-jobs.php (já existia
 *     mas só apagava o depósito; agora também apaga a conta).
 *     Critérios para apagar conta (cumulativos, defesa em profundidade):
 *       - Não tem mais nenhum deposit (já apagámos os pendentes)
 *       - Nunca teve deposit aprovado
 *       - Nunca teve subscrição
 *       - Saldo zero
 *       - Sem histórico de transactions
 *     Se algum critério falha, a conta NÃO é apagada (segura por design).
 *     Hook `rmbz_first_deposit_account_purged($telemovel)` disparado para
 *     outros módulos limparem dados associados.
 *
 *   REJEIÇÃO MANUAL pelo admin:
 *     O handler existente `rmbz_processar_rejeicao_deposito` já fazia
 *     cleanup defensivo (apagava wallet se balance=0 e zero transactions).
 *     Esse handler cobre exactamente o caso first-deposit. Não precisou
 *     de alteração — confirmámos que funciona como esperado.
 *
 *   FICHEIRO NOVO:
 *     - includes/first-deposit-gate.php (~440 linhas)
 *
 *   FICHEIROS EDITADOS:
 *     - 1mz-for-artists.php (require + version)
 *     - includes/shortcodes.php (chamadas ao gate em ambos os shortcodes)
 *
 * == 1.5.126 (2026-05) — 🐛 Hardening pre-prod: && em JS + protecção estrutural ==
 *   Antes do go-live, fizemos auditoria completa do código frontend e
 *   actuámos em duas frentes:
 *
 *   1) FIX CIRÚRGICO em distribution-single-view.php
 *      Identificadas 12 ocorrências de `&&` em JS inline — exactamente o
 *      mesmo padrão que causou o bug crítico da 1.5.119 (WordPress escapa
 *      `&` para `&#038;` no output de shortcode, e o parser JS atira
 *      SyntaxError quebrando toda a execução de scripts seguintes).
 *
 *      Estas ocorrências NÃO estavam visivelmente partidas porque dependem
 *      de fluxos que o utilizador ainda não tinha exercitado a fundo:
 *
 *        - `rmbzGetDistroNonce()` / `rmbzGetAjaxUrl()` (linhas ~970-984)
 *          6× `&&` em type-check + value-check em cadeia. Usadas pelo
 *          fluxo de TAKEDOWN (botão "Pedir Retirada das Lojas").
 *        - Handler precheck do takedown (linha ~1040): 1× `&&`
 *        - Validação do form takedown (`rmbzTdRecheck`): 1× `&&` em
 *          `reason && confirm`. Sem isto, botão "Enviar pedido" nunca
 *          ficava enabled.
 *        - Listener Escape para fechar modal takedown: 1× `&&`
 *        - Handler submit takedown: 3× `&&` em response checks
 *
 *      Todos os 12 reescritos com if aninhados / variáveis intermédias.
 *
 *   2) PROTECÇÃO ESTRUTURAL em shortcodes.php
 *      O bug do `&` escapado é causado por filtros aplicados a
 *      `the_content` (`convert_chars`, `wptexturize`) que processam
 *      texto sem distinguir conteúdo dentro vs fora de `<script>`.
 *      Embora desde WP 4.4 (2015) o core proteja `<script>`, plugins
 *      de cache/optimização de terceiros frequentemente NÃO o fazem.
 *
 *      Solução: durante a renderização dos nossos shortcodes
 *      (`1mz_distribution_dashboard` e `1mz_artist_area`), removemos
 *      temporariamente os 2 filtros nocivos com `remove_filter`, e
 *      re-adicionamos com `add_filter` depois. Filtros adicionados por
 *      terceiros ficam intactos — se algum dia houver outro suspeito
 *      identificado, adiciona-se à lista no mesmo padrão.
 *
 *      Esta protecção estrutural significa que mesmo os ~80 `&&` que
 *      ainda existem em outros ficheiros frontend (wizard, my-account,
 *      etc.) já estão protegidos do escape do core. Se aparecer algum
 *      problema futuro, basta confirmar que o filtro problemático está
 *      na lista de remoção temporária.
 *
 *   Ficheiros tocados: 2
 *     - includes/distribution/distribution-single-view.php
 *     - includes/shortcodes.php
 *
 * == 1.5.125 (2026-05) — 🔗 Fix: link "Ver release completo" levava para tab errada ==
 *   Sintoma reportado: na tab "Serviços Exclusivos" → cards pendentes,
 *   clicar em "Ver release completo →" não abria o detalhe — levava para
 *   um sítio errado.
 *
 *   Causa: o link em admin-exclusive-services.php usava URL legacy:
 *       admin.php?page=rmbz-admin-distribution&tab=dashboard&release=ID
 *   Mas o roteamento canónico de detalhe de release (usado em 7 outros
 *   sítios do código) é:
 *       admin.php?page=rmbz-admin-distribution&tab=releases&view=detail&id=ID
 *
 *   Triplo erro acumulado:
 *     1. tab=dashboard em vez de tab=releases (tab errada)
 *     2. release=ID em vez de id=ID (parâmetro não lido pelo handler)
 *     3. faltava view=detail (sem isto a tab releases mostra a lista, não o detalhe)
 *
 *   Provavelmente legacy de antes do sistema de tabs ser refactorado para
 *   usar view=detail. Verifiquei o resto do código admin com grep — era
 *   o único sítio com este padrão errado.
 *
 *   Ficheiros tocados: 1
 *     - includes/admin/admin-exclusive-services.php
 *
 * == 1.5.124 (2026-05) — 🎨 Admin: redesign moderno do painel Metadata ==
 *   Painel Metadata da release passou de tabela 2-col WP-clássica para
 *   layout grid moderno com hierarquia tipográfica clara.
 *
 *   Princípios de design aplicados:
 *     1. Cada campo é um item com label uppercase pequena (11px,
 *        letter-spacing 0.06em, cinza #646970) + valor maior (14px, #1d2327).
 *        Hierarquia clara entre rótulo e dado, sem necessidade de cor
 *        explícita no valor.
 *     2. Códigos identificadores (UPC, ISRC, telemóvel) em "chips" mono
 *        com fundo cinza subtil (#f0f0f1) e borda — aparência consistente,
 *        fácil de seleccionar/copiar visualmente.
 *     3. Estados visuais consistentes via classes pequenas:
 *         - --pending (vermelho subtil): falta atribuir
 *         - --ok (verde subtil): tudo atribuído
 *         - --locked (laranja subtil): bloqueado por publicação
 *         - --muted (cinza itálico): sem dados
 *     4. Grid CSS responsivo: 2 colunas em ≥600px, 1 coluna abaixo.
 *        Reflow natural em sidebars estreitos.
 *     5. Header do bloco com border-bottom subtil + título em uppercase —
 *        marca visualmente o início da secção sem peso excessivo.
 *     6. Mensagem de rejeição (quando existe) com border-left vermelho,
 *        padrão visual reconhecível para "atenção".
 *     7. Ícones emoji nas labels para reconhecimento rápido (📅 data,
 *        🎵 género, 🌐 idioma, 🆔 UPC, 🏷 ISRC, 📍 local, 📱 tel, 🏢 label).
 *
 *   Lógica de apresentação dos ISRC mantida da 1.5.123 mas reformulada
 *   visualmente com chips e badges de status em vez de texto solto.
 *
 *   CSS scoped em .rmbz-meta-section — não conflita com o resto do admin.
 *   Sem JS adicionado. Sem alteração de dados — só apresentação.
 *
 *   Ficheiros tocados: 1
 *     - includes/admin/admin-distribution-releases.php
 *
 * == 1.5.123 (2026-05) — 🏷 Admin: linha ISRC(s) no painel Metadata ==
 *   No painel admin de detalhe de release, o bloco "📝 Metadata" mostrava
 *   UPC mas não ISRC. ISRC já era visível dentro do bloco "🎶 Faixas"
 *   mais abaixo (em badges por faixa), mas para releases single fazia
 *   sentido ter o ISRC junto ao UPC, já que ambos identificam o lançamento.
 *
 *   Lógica de apresentação adaptativa:
 *     - 1 faixa, 1 ISRC: mostra `<code>MZ0AB2669276</code>` (paralelo ao UPC)
 *     - N faixas todas com ISRC: "N faixas:" + lista compacta
 *     - Mistura (alguns atribuídos, outros pendentes): "X de N atribuídos"
 *       + lista dos existentes + aviso vermelho "⚠ N pendente(s)"
 *     - Sem faixas: "Sem faixas" em itálico cinza
 *
 *   ISRC continua a ser **por faixa** (não por release) — UPC é por release.
 *   Esta linha é só uma vista agregada para o admin não ter de fazer scroll
 *   até ao bloco de faixas para ver o ISRC quando há só 1 faixa.
 *
 *   Ficheiros tocados: 1
 *     - includes/admin/admin-distribution-releases.php
 *
 * == 1.5.122 (2026-05) — 📐 DSP links: layout 100% cards (sidebar-friendly) ==
 *   A 1.5.121 baseou a decisão de mostrar tabela vs cards na **largura da
 *   viewport** (`@media max-width:720px`). Mas em PC com sidebar estreito
 *   a viewport continua 1920px e a regra não disparava — o painel DSP
 *   ficava com tabela de colunas fixas (Loja:180px + Estado:90px) dentro
 *   de um sidebar de ~300px, esmagando o input URL outra vez.
 *
 *   Fix definitivo: abandonar a tabela e usar SEMPRE cards. Vantagens:
 *     - Funciona em sidebar de 280px, mobile de 380px, ou desktop de 1920px
 *     - Input URL sempre com largura 100% do container disponível
 *     - Markup mais simples (sem dual rendering tabela+cards)
 *     - JS de propagação mobile→desktop deixou de ser necessário
 *
 *   Bonus: quando o painel está num container suficientemente largo (≥800px,
 *   detectado via JS com `getBoundingClientRect`), aplica-se modificador
 *   `--wide` que activa grid de 2 colunas na lista. Ou seja:
 *     - Sidebar estreito → 1 coluna (cards verticais empilhados)
 *     - Painel principal full-width → 2 colunas (aproveita espaço horizontal)
 *
 *   Não há mudança de comportamento para o backend: os inputs continuam
 *   com `name="dsp_link[slug]"` exactamente como antes — handler PHP intocado.
 *
 *   Ficheiros tocados: 1
 *     - includes/distribution/distribution-release-workflow.php
 *
 * == 1.5.121 (2026-05) — 📱 Mobile fix: tabela DSP esmagava input URL ==
 *   Sintoma reportado: na admin → release detail → "Links das lojas (DSPs)",
 *   em mobile a coluna URL ficava com largura ~50px (espaço só para
 *   uma letra como "h" do https). Era impossível colar URLs.
 *
 *   Causa: a tabela `widefat striped` tinha colunas com larguras fixas
 *   (Loja:180px, Estado:90px). Em ecrãs estreitos (~380px), sobravam só
 *   ~50-100px para a coluna URL — input ficava estilhaçado.
 *
 *   Fix em distribution-release-workflow.php (função rmbz_release_workflow_render_dsp_links_editor):
 *     - Mantém a tabela em desktop (≥720px) — admin desktop sem regressão.
 *     - Em mobile (<720px) a tabela vira display:none e aparece uma lista
 *       de cards verticais (.rmbz-dsp-cards) com:
 *         - cabeçalho: ícone + nome da loja + badge de estado
 *         - input URL com largura total (100%), padding generoso, font-size 13px
 *     - Inputs mobile usam name="dsp_link_mobile[slug]"; antes do submit,
 *       um pequeno IIFE copia os valores para os inputs desktop "dsp_link[slug]"
 *       (que continuam no DOM mas hidden) e remove os names mobile, para o
 *       handler PHP receber exactamente o mesmo formato de sempre.
 *     - Sem alteração no handler `rmbz_release_save_dsp_links` — backwards
 *       compatible 100%.
 *
 *   Ficheiros tocados: 1
 *     - includes/distribution/distribution-release-workflow.php
 *
 * == 1.5.120 (2026-05) — 🤝 Sistema de fases para edição de splits ==
 *   Splits NÃO são uma preferência de UI — são contratos de distribuição
 *   de receita. Mudar splits depois de royalties chegarem afecta dinheiro
 *   de outras pessoas. Esta versão introduz fricção proporcional ao risco.
 *
 *   3 FASES:
 *
 *   FASE 1 — Pre-live, sem royalties:
 *     - Edição livre.
 *     - Rate limit: máximo 3 alterações por release nos últimos 30 dias.
 *       Conta-se via tabela rmbz_splits_audit (action LIKE 'frontend_save%').
 *       Reset automático fora da janela (não é hard cap eterno).
 *
 *   FASE 2 — Live, ainda sem royalties recebidos:
 *     - Edição com checkbox de confirmação dupla obrigatória no UI.
 *     - Banner amarelo: "Esta release já está nas lojas".
 *
 *   FASE 3 — Royalties já gerados (qualquer holding existe, paga ou não):
 *     - Edição direta BLOQUEADA no AJAX handler (`rmbz_ajax_splits_frontend_save`).
 *     - UI mostra botão "📩 Pedir alteração ao admin" em vez de "Editar".
 *     - Pedido vai para nova tabela `rmbz_split_change_requests` com:
 *         razão obrigatória (≥10 chars), splits propostos, snapshot dos
 *         actuais, fase no momento.
 *     - Admin avalia em nova tab "📩 Pedidos Splits" do menu Distribuição.
 *     - Admin decide caso-a-caso o destino das holdings já alocadas:
 *         (1) MANTER — splits novos só aplicam a futuros royalties
 *         (2) DEVOLVER ao owner — converte USD→MT via rmbz_secure_transfer_usd_to_mt
 *             (respeita arquitectura two-account; nunca escreve USD na wallet MT)
 *         (3) REVERTER — marca holdings como `reverted_at`, não pagam a ninguém
 *     - Notificação do requerente via rmbz_notify_user (se existir).
 *     - Email ao admin no momento de submissão do pedido.
 *
 *   PERMISSÕES:
 *     - Iniciar pedido: só o owner da release.
 *     - Decidir pedido: só admin (manage_options).
 *
 *   FICHEIROS NOVOS:
 *     - includes/distribution/distribution-splits-phases.php (367 linhas)
 *
 *   FICHEIROS EDITADOS:
 *     - 1mz-for-artists.php (require do novo ficheiro + version)
 *     - includes/admin/admin-distribution-controller.php (tab nova + badge)
 *     - includes/distribution/distribution-splits-frontend.php
 *         (banner de fase no UI, render condicional do botão,
 *          textarea de razão para Fase 3, checkbox confirmação Fase 2,
 *          handler JS request-change, audit log no save)
 *
 *   ARQUITECTURA:
 *     - Ponto único de detecção de fase: `rmbz_splits_get_release_phase()`.
 *     - Hook `rmbz_track_splits_saved` continua a ser disparado também
 *       quando admin aprova um pedido (mesma classificação/notificação
 *       de colaboradores que o save normal).
 *     - Audit log dedicado: action 'change_request_approved/rejected'
 *       em rmbz_splits_audit, para o forensic report ver tudo num sítio.
 *
 * == 1.5.119 (2026-05) — 🐛 Fix raiz: WordPress escapa && em JS inline ==
 *   ENCONTRÁMOS A CAUSA DO SyntaxError "'#' not followed by identifier"
 *   reportado desde a 1.5.116. View-source da página confirmou:
 *
 *       if (res &#038;&#038; res.success) { ... }
 *
 *   O nosso JavaScript inline tinha `if (res && res.success)`, mas o
 *   WordPress (provavelmente via convert_chars() ou wptexturize() no
 *   filtro the_content que processa shortcodes) está a escapar o `&`
 *   para a entidade HTML `&#038;`. O parser JS do browser vê o `&`
 *   tenta ler `#038` como private field, vê o `;` e atira SyntaxError.
 *
 *   Resultado: TODO o IIFE da modal abortava nessa linha. O stub global
 *   da 1.5.117 só protegia o `rmbzOpenSplitsModal` (logo a modal abria),
 *   mas todos os listeners definidos abaixo dessa linha — incluindo o
 *   `modal.addEventListener('click', ...)` que fecha a modal no botão X
 *   — nunca eram registados. Daí o utilizador reportar:
 *     "X de fechar do modal da Configurar Splits não funciona"
 *
 *   Fix cirúrgico: substituir todos os `&&` em JS inline por if's
 *   aninhados ou variáveis intermédias. Tecnicamente a refactor mais
 *   limpa seria mover o JS para wp_add_inline_script (onde o WP nunca
 *   escapa entidades), mas isso é uma re-arquitectura grande para uma
 *   próxima iteração.
 *
 *   Locais corrigidos em distribution-splits-frontend.php:
 *     - Listener Escape (era: e.key === 'Escape' && modal...is-open)
 *     - Filter de rows válidas (era: r.name.trim() && parseFloat(pct) > 0)
 *     - Handler de resposta AJAX (era: res && res.success / res && res.data)
 *
 *   Nota: outros ficheiros do plugin podem ter o mesmo problema latente
 *   no frontend (ex: distribution-splits.php, distribution-wizard.php).
 *   Se aparecer sintoma semelhante noutra página, aplicar mesmo padrão.
 *
 * == 1.5.118 (2026-05) — 🎨 UX/UI: modal de splits — overlay + layout ==
 *   Dois fixes visuais reportados após instalação da 1.5.117:
 *
 *   1) NO PC, O OVERLAY ESCURO NÃO COBRIA O ECRÃ TODO.
 *      Causa: o overlay tinha `position:absolute` (relativo ao parent
 *      `.rmbz-splits-fe-modal`). O modal por sua vez é renderizado dentro
 *      do markup do single-view, e algum ancestor (provavelmente
 *      `.distro-single-view` ou um wrapper do tema) tem `transform`,
 *      `filter` ou `contain` aplicado — o que faz colapsar a referência
 *      do `position:fixed` para esse ancestor em vez do viewport. Regra
 *      CSS clássica e contra-intuitiva.
 *      Fix em duas frentes:
 *      a) Overlay agora é `position:fixed` em vez de `absolute`.
 *      b) Ao abrir, o JS faz `document.body.appendChild(modal)` —
 *         move o modal para fora de qualquer container problemático,
 *         garantindo que `position:fixed` referencia o viewport. Os
 *         dois caminhos (stub global em distribution-single-view e
 *         IIFE da modal em distribution-splits-frontend) fazem isto.
 *
 *   2) MARKUP DA TRACK ESTAVA CONFUSO EM MOBILE.
 *      Sintoma: título da faixa, código ISRC e badge "100% para ti"
 *      a sobrepor-se / em layout estranho. O CSS antigo usava `flex
 *      align-items:center` com tudo na mesma linha, sem wrap.
 *      Fix:
 *      - Track-head agora é flex com `flex-wrap:wrap` e `gap:10px`.
 *      - Wrapper `.rmbz-splits-fe-track-title-wrap` envolve título +
 *        ISRC em coluna; o ISRC fica numa linha de baixo, alinhado à
 *        esquerda, em vez de inline confuso.
 *      - Status badge tem `flex-shrink:0; white-space:nowrap` —
 *        não é cortado nem amassado.
 *      - Botão "Adicionar splits" promovido de link discreto para
 *        CTA preenchido (background dourado, hover com sombra).
 *      - Media query @640px reorganiza o editor de splits em 3
 *        linhas (nome / telemovel+role / pct+remove) em vez do
 *        grid de 5 colunas que ficava esmagado.
 *      - Tabela de splits passa a fazer scroll horizontal em ecrãs
 *        estreitos em vez de amassar conteúdo.
 *
 * == 1.5.117 (2026-05) — 🔧 Fix: botão "Configurar Splits" não abria modal ==
 *   Sintoma reportado pelo utilizador: na single-view de uma release, clicar
 *   em "🔧 Configurar Splits" não abria nada (nem modal, nem mensagem de erro).
 *
 *   Causa-raiz mais provável: a função `window.rmbzOpenSplitsModal` era
 *   definida APENAS dentro do IIFE do modal. O IIFE começava com:
 *       var modal = document.getElementById('rmbz-splits-modal');
 *       if (!modal) return;
 *   Logo, em qualquer cenário em que o markup da modal não estivesse no DOM
 *   (por exemplo: utilizador não-owner, erro intermitente de PHP, ordem de
 *   carregamento estranha numa cache, ou erro JS de outro script da página
 *   que abortasse a execução antes de chegar a este IIFE), a função ficava
 *   indefinida e o `onclick="rmbzOpenSplitsModal()"` falhava silenciosamente
 *   com ReferenceError — exactamente o sintoma "não acontece nada".
 *
 *   Correcção implementada em distribution-splits-frontend.php:
 *
 *   1) `rmbzOpenSplitsModal` é agora definida ANTES do IIFE, com lookup do
 *      modal feito no momento do clique. Se não encontrar, mostra alert
 *      claro e log no console em vez de falhar silenciosamente.
 *
 *   2) Nova função PHP `rmbz_splits_frontend_emit_disabled_stub($reason)`
 *      é chamada quando `rmbz_splits_frontend_render_modal()` decide NÃO
 *      renderizar (release inválida ou utilizador não-owner). Emite stub
 *      JS que mostra mensagem amigável.
 *
 *   3) Comentários HTML diagnósticos são emitidos quando a modal não é
 *      renderizada, com a razão. Permite inspeccionar o "view source" e
 *      perceber imediatamente o que se passou.
 *
 *   Sem alteração de comportamento para o caso "happy path" (utilizador
 *   owner, modal renderiza, abre normalmente). Apenas fortalece o erro-
 *   handling e remove pontos de falha silenciosa.
 *
 * == 1.5.70 (2026-04) — 📱 Mobile: tabs viram dropdown + radio-pills empilham ==
 *   Duas melhorias mobile pedidas pelo utilizador:
 *
 *   1) TABS DA MINHA CONTA (Envios, Vídeos, Lançamentos, Finanças)
 *      - Em desktop (>768px): mantém-se igual (tabs horizontais com underline
 *        dourado animado).
 *      - Em mobile (≤768px): vira dropdown — só a tab activa é visível, com
 *        chevron dourado; toque abre lista das outras opções; escolha fecha
 *        sozinha; clique fora ou ESC também fecha.
 *      - As tabs de auth (Entrar/Criar Conta) são EXCLUÍDAS via :not(--auth)
 *        porque são apenas 2 opções que ficam óptimas como segmented control.
 *
 *   2) RADIO-PILLS (.rmbz-radio-group--full) — Pagar/Cupão, ID/Imagem, etc.
 *      - Em desktop (>768px): cápsula horizontal (igual).
 *      - Em 481-768px: lado a lado com flex-wrap automático.
 *      - Em ≤480px: viram cards rectangulares EMPILHADOS (um por linha) com
 *        cantos suaves. O container perde background/borda (o "estádio"
 *        gigante feio). Activo destaca-se em dourado mantendo o tamanho.
 *
 *   FICHEIROS ALTERADOS:
 *      - assets/css/03-components.css (regra das tabs §4 e dos pills §6)
 *      - assets/js/account-page.js (handler do dropdown na secção 4)
 *      - 1mz-for-artists.php (bump de versão para invalidar cache)
 *
 *   SEM ALTERAÇÕES PHP NO MARKUP — funciona com a estrutura HTML existente.
 *
 * == 1.5.69 (2026-04) — 🎨 Status pills dos lançamentos com cores semânticas ==
 *   Bug visual reportado pelo utilizador na lista "Seus lançamentos"
 *   (/distribuição-digital/?distro_action=dashboard): o badge "Returned"
 *   aparecia em CINZENTO neutro, sem chamar atenção. Devia ser amarelo/
 *   laranja para indicar "precisa da tua atenção, mas pode corrigir".
 *
 *   ROOT CAUSE: O if/elseif inline em distribution-main.php:308-318 só
 *   tinha cases para 'live', 'distribuido', 'incomplete', 'pendente'.
 *   O status 'returned' caía no default `$pill_bg = '#999'` (cinzento).
 *   Não havia nem case nem label traduzido para "returned".
 *
 *   FIX em distribution-main.php — substituí o if/elseif por uma TABELA
 *   DE CONFIGURAÇÃO ($status_map) que mapeia cada status para label +
 *   cor + acção do link. Mais fácil de manter e de adicionar novos
 *   status no futuro.
 *
 *   [TABELA NOVA $status_map]
 *
 *     live        / distribuido → "Live"        VERDE     #2ecc71  view
 *     pendente                  → "Em Revisão"  AMARELO   #f0b90b  view
 *     returned                  → "Devolvido"   LARANJA   #e67e22  EDIT
 *     rejected                  → "Rejeitado"   VERMELHO  #e74c3c  view
 *     incomplete                → "Rascunho"    CINZENTO  #94a3b8  EDIT
 *
 *   [JUSTIFICAÇÃO DAS CORES — semântica forte]
 *
 *     • VERDE = sucesso. Música está nas lojas a gerar royalties.
 *     • AMARELO = aguarda. Equipa em revisão, utilizador não pode acelerar.
 *     • LARANJA = ATENÇÃO TUA. Pode ser corrigido, link vai para EDIT.
 *       (Antes: cinzento default — utilizador nem percebia que era acção.)
 *     • VERMELHO = fim do caminho. Rejeitado por motivos de IP/qualidade,
 *       não pode ser editado. Status preparado para o futuro — ainda não
 *       é gerado pelo sistema mas o suporte já está pronto.
 *     • CINZENTO AZULADO = neutro/em construção. Rascunho NÃO É ERRO —
 *       é "ainda em construção". Antes estava VERMELHO o que assustava
 *       o utilizador (parecia rejeitado). Vermelho fica reservado para
 *       rejected definitivo.
 *
 *   [BENEFÍCIOS DO REFACTOR]
 *     • Adicionar novo status no futuro = 1 linha no array
 *     • Status desconhecidos têm fallback gracioso (cinzento neutro,
 *       label capitalizado) em vez de partir
 *     • Sem if/elseif tipo escada — tabela legível
 *     • Link de EDIT em vez de VIEW para 'returned' — coerente com
 *       'incomplete' (ambos são "ainda em construção")
 *
 *   [PRESERVADO]
 *     • Estrutura HTML do release-card inalterada
 *     • CSS .status-pill / .release-card não tocados
 *     • Lógica de filtros (Singles/EPs/Álbuns) não tocada
 *     • Cards Live continuam ↗ view_release
 *     • Cards Rascunho continuam ↗ edit_release
 *     • Adicionado: Devolvidos agora ↗ edit_release (faz sentido — vai
 *       directo corrigir, em vez de só ver)
 *
 *   [ALTERAÇÕES VISUAIS — antes/depois]
 *
 *     Returned (devolvido pela equipa, precisa correcção):
 *       Antes: ⚪ Returned       (cinzento, em inglês, sem destaque)
 *       Agora: 🟠 Devolvido      (laranja, traduzido, atenção visual)
 *
 *     Incomplete (rascunho não terminado):
 *       Antes: 🔴 Rascunho (Não Finalizado)  (vermelho, parece rejeitado)
 *       Agora: ⚪ Rascunho                    (cinzento, neutro/em construção)
 *
 *     Live (publicado):
 *       Antes: 🟢 Live  ← inalterado
 *       Agora: 🟢 Live
 *
 *     Pendente (em revisão pela equipa):
 *       Antes: 🟡 Em Revisão  ← inalterado
 *       Agora: 🟡 Em Revisão
 *
 *     Rejected (futuro):
 *       Antes: ⚪ Rejected  (cinzento default, não havia case)
 *       Agora: 🔴 Rejeitado
 *
 * == 1.5.68 (2026-04) — 🎨 Área do Artista: header + alerta + selector modernizados ==
 *   O utilizador pediu para modernizar o formulário da Área do Artista
 *   (/area-artista/). É um template grande (788 linhas) com lógica
 *   complexa (3 tipos de envio, uploads, paywall). Modernização gradual
 *   começando pelo que é visível antes do form em si.
 *
 *   FILOSOFIA: Atacar primeiro o que se vê primeiro. Esta versão
 *   moderniza o terço superior do template (header, alerta pendente,
 *   selector de tipo). Resto do form (campos, uploads, paywall) fica
 *   para versões futuras — refactor por partes evita risco de
 *   regressão num template grande.
 *
 *   [3 ZONAS MODERNIZADAS em templates/frontend/artist-upload-form.php]
 *
 *   1. HEADER — "Painel de Envio" + Voltar
 *      Antes: <h2> com inline + <a class="rmbz-back-link"> link feio
 *      Agora: .rmbz-artist-dashboard__title (Bricolage display) +
 *             .rmbz-btn--ghost com SVG arrow back
 *
 *   2. ALERTA DE ENVIO PENDENTE
 *      Antes: <style> bloco inline + emoji ⏳ + bloco com border-color
 *             #f0b90b hardcoded
 *      Agora: .rmbz-pending-alert (warning) + SVG relógio circular +
 *             título Bricolage + 2 mensagens (principal + auxiliar)
 *      Border-left destacado warning, ícone em círculo soft, semântica
 *      clara.
 *
 *   3. SELECTOR DE TIPO (3 cards Site/Social/Vídeo)
 *      Antes: <div class="type-option" onclick="..."> com inline styles
 *             para locked, emojis 🎵 🎬 🏆 🔒
 *      Agora: <button class="rmbz-type-card"> com SVGs (nota musical,
 *             câmara, troféu, cadeado), variantes --special (Vídeo)
 *             em laranja e --locked desabilitado
 *      Hover translateY -2px + box-shadow dourado, active state
 *      preservado para o JS antigo.
 *
 *   [COMPONENTES NOVOS em assets/css/03-components.css §16]
 *
 *     .rmbz-artist-dashboard         → container exterior 800px max
 *     .rmbz-artist-dashboard__header → flex space-between, divisor inferior
 *     .rmbz-artist-dashboard__title  → Bricolage 2xl semibold
 *
 *     .rmbz-pending-alert            → cartão warning horizontal
 *     .rmbz-pending-alert__icon      → círculo 48×48 com SVG 28px
 *     .rmbz-pending-alert__title     → display lg
 *     .rmbz-pending-alert__msg       → secondary base
 *     .rmbz-pending-alert__msg-aux   → muted xs (texto secundário)
 *
 *     .rmbz-type-selector            → wrapper section
 *     .rmbz-type-selector__heading   → h3 centrado display lg
 *     .rmbz-type-selector__grid      → grid 3 colunas (PC) / 1 (mobile)
 *     .rmbz-type-card                → card individual, hover/active states
 *     .rmbz-type-card__icon          → círculo 48×48 brand-soft
 *     .rmbz-type-card__title         → semibold sm
 *     .rmbz-type-card__subtitle      → muted xs
 *     .rmbz-type-card.is-special     → variante laranja (Ganhar Vídeo)
 *     .rmbz-type-card--locked        → opaco, cursor not-allowed
 *
 *   [CO-EXISTÊNCIA LEGACY/MODERNO]
 *
 *     Mantive as classes legacy em paralelo no markup:
 *       <div class="rmbz-artist-dashboard onemz-artist-dashboard">
 *       <button class="rmbz-type-card type-option"
 *               data-type="audio_site" onclick="selectType(...)">
 *
 *     Razões:
 *     - JS antigo (selectType) usa .type-option.active — mantido
 *     - <style> legacy no fim do template ainda controla os campos
 *       do form (.rmbz-input, .scenario-grid, .channel-option) que
 *       ainda não foram modernizados — não posso removê-lo todo
 *     - Outras páginas usam .onemz-artist-dashboard como wrapper
 *       (my-account-page.php) — não posso remover do markup global
 *
 *     No <style> legacy do template, removi as regras que ficaram
 *     redundantes (.onemz-artist-dashboard {background:#1e1e1e...},
 *     .rmbz-dashboard-header, .rmbz-back-link, .type-buttons-grid,
 *     .type-option, .type-icon, e respectivas mobile rules).
 *     Mantive .section-title, .rmbz-input, .modern-audio-dropzone,
 *     .scenario-grid, .channel-option, .rmbz-btn-submit, .rmbz-lock-screen,
 *     .price-tag (ainda em uso no form não modernizado).
 *
 *   [DECISÕES TÉCNICAS]
 *     • Por que <button> em vez de <div> nos cards activos?
 *       Acessibilidade — Tab navigation, Enter/Space, screen readers.
 *       Locked continua <div> com aria-disabled (não navegável).
 *     • Por que i18n (esc_html__)? Plugin já está internacionalizado.
 *     • Por que array PHP estruturado para os 3 tipos? Reduz código
 *       repetitivo, fácil de adicionar novos tipos no futuro.
 *     • Por que SVG inline em vez de <img>? Currentcolor herda da
 *       variante (--brand para activos, --warning para special, --muted
 *       para locked). Padrão consistente com resto do plugin.
 *
 *   [PRÓXIMOS PASSOS — refactor gradual do mesmo template]
 *     • Modernizar campos do form (.rmbz-input, labels, radios)
 *     • Modernizar uploads (.modern-audio-dropzone, drop-zone)
 *     • Modernizar selector de cenários (.scenario-card)
 *     • Modernizar paywall (.rmbz-lock-screen + .price-tag)
 *     • Substituir alerts/confirms (file size, format) por rmbz.alert
 *
 *   [PRESERVADO]
 *     • Toda a lógica PHP (permissões, castigos, pendente, subscrição)
 *     • Toda a lógica JS (selectType, addAlbumTrack, uploads AJAX)
 *     • IDs do form (smart-submission-form, fields-site, etc.)
 *     • Hooks WP, nonces, admin-post.php endpoints
 *     • .onemz-formulario-bloqueado (filter blur quando há pendente)
 *
 * == 1.5.67 (2026-04) — 🚪 Gate de login com features (vende o produto) ==
 *   Bug visual reportado pelo utilizador: o gate "Acesso Restrito" criado
 *   em 1.5.66 estava demasiado pequeno (480px) e parecia "perdido" no
 *   meio da página. O utilizador pediu cartão maior em PC + lista de
 *   features sobre o serviço (vende o produto a quem ainda não logou),
 *   e mantém grande/legível em mobile.
 *
 *   FILOSOFIA: Em vez de criar uma classe nova `--featured`, criei um
 *   slot opcional `__features` dentro do .rmbz-gate. CSS via :has()
 *   detecta automaticamente quando o slot existe e expande para 2
 *   colunas. Gates simples (sem features) continuam a funcionar igual.
 *
 *   [LAYOUT 2-COL EM PC (>768px)] em assets/css/03-components.css §15
 *
 *     .rmbz-gate:has(.rmbz-gate__features) {
 *       max-width: 880px;          (era 480px — quase 2× mais largo)
 *       grid-template-columns: 1fr 1fr;
 *       gap: var(--rmbz-space-7);
 *       text-align: left;          (era center)
 *     }
 *
 *     Esquerda: ícone + título + mensagem + CTA (alinhamento à esq)
 *     Direita:  lista de features (4 benefícios concretos)
 *
 *   [MOBILE (<768px) → 1-COL STACK]
 *
 *     .rmbz-gate:has(.rmbz-gate__features) colapsa para 1 coluna
 *     - Ícone + título + msg + CTA centrados
 *     - Features em baixo, separadas por border-top subtil
 *     - Texto centra novamente para parecer cartão "hero"
 *
 *     Adicionalmente, gate normal (sem features) também ganha
 *     max-width: 100% em mobile — antes ficava restrito a 480px e
 *     parecia tímido em ecrãs grandes-mobile (tablets, etc.).
 *
 *   [SUB-COMPONENTES NOVOS no CSS]
 *
 *     .rmbz-gate__features          → wrapper flex column gap-4
 *     .rmbz-gate__features-heading  → "O QUE TEM ACESSO:" small caps muted
 *     .rmbz-gate__feature           → linha individual (ícone + texto)
 *     .rmbz-gate__feature-icon      → quadrado 32×32 brand-soft com SVG 18px
 *     .rmbz-gate__feature-text      → wrapper flex:1
 *     .rmbz-gate__feature-title     → semibold sm primary
 *     .rmbz-gate__feature-desc      → xs secondary line-height 1.5
 *
 *     Em mobile: feature-icon encolhe para 28×28 e svg para 16px.
 *
 *   [4 FEATURES CONFIGURADOS em shortcodes.php]
 *
 *     Os benefícios foram tirados do texto oficial do produto (email
 *     intro-welcome.php) + adicionados 1-2 mais relevantes para o
 *     login wall específico. Cada um tem ícone SVG semântico:
 *
 *     1. 🌐 globe   → "Distribuição em 150+ lojas"
 *                     "Spotify, Apple Music, TikTok, YouTube Music,
 *                      Deezer e dezenas mais."
 *     2. 💵 money   → "Receba royalties em USD"
 *                     "Levante para M-Pesa, eMola ou Mkesh sempre que
 *                      quiser."
 *     3. 📊 chart   → "Analytics em tempo real"
 *                     "Veja streams, ouvintes únicos e países onde a
 *                      sua música toca."
 *     4. 🛡 shield  → "100% dos seus direitos"
 *                     "A sua música é sua. Sem contratos exclusivos,
 *                      sem cláusulas escondidas."
 *
 *     Os ícones são SVG inline (não emojis) para visual consistente em
 *     qualquer browser. Cada feature está num array PHP estruturado —
 *     fácil de editar, traduzir, ou expandir.
 *
 *   [DECISÕES TÉCNICAS]
 *     • Por que slot opcional via :has() e não classe modificadora?
 *       Mais flexível — futuros gates podem optar por features ou
 *       não sem mudar de classe. Comportamento "progressive enhancement".
 *     • Por que 4 features e não 3 ou 5? Em PC, 4 distribuem-se
 *       elegantemente na coluna direita. 3 deixariam vazio em baixo,
 *       5+ seria pesado para um login wall.
 *     • Por que ícones em quadrados arredondados (border-radius:8px)
 *       e não círculos? Quadrados leem-se mais "feature/produto" e
 *       círculos lêem-se "estado/avatar". Distingue do ícone principal
 *       grande (cadeado) que é circular para parecer "selo/identidade".
 *     • Por que não usar este layout em todos os gates? O bloqueio
 *       admin (variant --danger) é uma má notícia — não devemos vender
 *       o produto a alguém que acabou de ser banido. Mantém-se simples.
 *
 *   [PRESERVADO]
 *     • Gate de bloqueio admin em distribution-main.php intacto
 *     • Gate área-artista (rmbz-area-artista-gate) intacto
 *     • Variantes de cor do .rmbz-gate (info/warning/danger/success)
 *     • Border-top colorido por variante
 *     • Backwards compat: gates sem __features ficam exactamente iguais
 *
 * == 1.5.66 (2026-04) — 🚪 Componente .rmbz-gate moderno para acessos restritos ==
 *   Bug visual reportado pelo utilizador: a página /distribuicao-digital/
 *   sem login mostrava um cartão "Acesso Restrito" antigo com inline
 *   styles (background:#1e1e1e, ícone 🔒 emoji, botão azul Bootstrap-ish).
 *   Fora da identidade do design system. O utilizador pediu para
 *   modernizar e otimizar mobile.
 *
 *   FILOSOFIA: Em vez de mudar só este caso, criei um componente
 *   reutilizável `.rmbz-gate` no design system para TODOS os "acessos
 *   restritos / estados vazios". Aproveitei para modernizar 3 instâncias
 *   visíveis na frente. Mais virão à medida que aparecerem.
 *
 *   [COMPONENTE NOVO em assets/css/03-components.css §15]
 *
 *     .rmbz-gate                  → wrapper centrado, max-width 480px
 *     .rmbz-gate--info            → border-top azul + ícone azul (login)
 *     .rmbz-gate--warning         → border-top laranja + ícone laranja
 *     .rmbz-gate--danger          → border-top vermelho + ícone vermelho (bloqueio)
 *     .rmbz-gate--success         → border-top verde + ícone verde (estado positivo)
 *
 *     .rmbz-gate__icon            → círculo 64×64 com SVG 32×32, soft bg
 *     .rmbz-gate__title           → Bricolage Grotesque 2xl semibold
 *     .rmbz-gate__message         → Inter base secondary line-height 1.6
 *     .rmbz-gate__message-aux     → secundária menor (sm muted)
 *     .rmbz-gate__actions         → flex centrado para CTAs
 *
 *   Visual:
 *     • Cartão centrado com max-width 480px (em desktop)
 *     • Border-top de 3px na cor da variante (reforça semântica)
 *     • Ícone grande circular no topo com background-soft
 *     • Título display + mensagem secondary + 1 ou mais CTAs
 *     • Mobile: full-width, padding reduzido, ícone 56px, botões 100%
 *
 *   [APLICADO em 3 sítios]
 *
 *     1. shortcodes.php — dashboard distribuição sem login (.--info)
 *        Antes: <div class="rmbz-login-warning" style="background:#1e1e1e;
 *                  padding:40px; ..."> + emoji 🔒 + botão Bootstrap
 *        Agora: <div class="rmbz-gate rmbz-gate--info"> + SVG cadeado +
 *               .rmbz-btn--primary
 *
 *     2. shortcodes.php — área artista sem login (wrapper diferente)
 *        Caso especial: o gate EMBUTE o shortcode [rmbz_minha_conta]
 *        completo lá dentro. Por isso não usa o .rmbz-gate padrão (que
 *        é para CTAs simples) — criei .rmbz-area-artista-gate específico
 *        com max-width 720px e padding adaptado para acomodar o form.
 *
 *     3. distribution-main.php — bloqueio admin (.--danger)
 *        Antes: <div class="distro-dashboard" style="background:#1a1a1a;
 *                  padding:60px 20px; border:1px solid #e74c3c; ..."> +
 *               emoji 🚫 + cor vermelha hardcoded
 *        Agora: <div class="rmbz-gate rmbz-gate--danger"> + SVG bloqueio
 *               + 2 mensagens (principal + auxiliar)
 *
 *   [DECISÕES TÉCNICAS]
 *     • Por que SVG em vez de emoji? Emojis (🔒 🚫) mudam de aspecto
 *       entre Apple/Google/Windows. SVG é consistente em qualquer
 *       browser. Padrão usado em todo o resto do plugin.
 *     • Por que componente reutilizável e não fix isolado? Há vários
 *       cartões "acesso restrito / estado vazio" no plugin (admin
 *       handlers, perfil, single view de release sem permissão, etc.).
 *       Componente partilhado = consistência visual + menos código.
 *     • Por que 4 variantes (info/warning/danger/success)? Cobre os
 *       casos semânticos universais: login (neutro/azul), aviso suave
 *       (laranja), erro/bloqueio (vermelho), confirmação (verde).
 *     • Por que i18n (esc_html__)? O plugin já está internacionalizado.
 *       Aproveitei para adicionar strings ao text-domain.
 *
 *   [PRESERVADO]
 *     • URLs de redirecionamento intactas (home_url + path)
 *     • Comportamento condicional (if !logged_in / if blocked)
 *     • Fluxo do shortcode [rmbz_minha_conta] embutido na área artista
 *     • Sem alterações a JS, queries, AJAX, hooks
 *
 *   [PRÓXIMOS PASSOS]
 *     Restantes "estados vazios" no plugin que podem usar este componente:
 *       • admin/admin-views.php (vários cartões com inline styles)
 *       • distribution-analytics.php (gate de subscrição)
 *       • distribution-single-view.php (release não encontrado)
 *       • paywall VIP (rmbz_render_distribution_paywall)
 *     Faremos quando aparecerem em capturas/bug reports.
 *
 * == 1.5.65 (2026-04) — 💬 Modais bonitos no fluxo finalize do wizard ==
 *   Bug visual reportado pelo utilizador: o confirm() nativo
 *   "Tem a certeza? Após enviar, não pode editar este lançamento" ainda
 *   aparecia no fluxo de finalizar release. Aproveitei para refactorizar
 *   TODO o handler do finalize de uma vez (4 chamadas a alert/confirm).
 *
 *   FILOSOFIA: Atacar fluxos completos em vez de bugs isolados — quando
 *   um handler tem 4 alerts/confirms, faz sentido modernizar tudo de
 *   uma vez por coerência visual.
 *
 *   [REFACTOR em distribution-wizard.php — handler de finalize-release]
 *
 *     Helpers locais adicionados ao topo do handler:
 *       rAlert(msg, opts)   — chama rmbz.alert OU fallback nativo
 *       rConfirm(msg, opts) — chama rmbz.confirm OU fallback nativo
 *                            (devolve sempre Promise<bool>)
 *
 *     Reduz verbosidade — em vez de 6 linhas com if/else por chamada,
 *     uso 1 linha rAlert(...) que cai gracioso para nativo se preciso.
 *
 *     4 chamadas migradas para modais bonitos:
 *
 *     1. Confirm "Tem a certeza?" → rConfirm warning
 *        Antes: confirm('Tem a certeza? ...')           [síncrono]
 *        Agora: rConfirm('<p>Esta ac\u00e7\u00e3o ...</p>', {
 *                 title: 'Confirmar envio',
 *                 variant: 'warning',
 *                 html: true,
 *                 okLabel: 'Sim, enviar',
 *                 cancelLabel: 'Voltar atr\u00e1s'
 *               }).then(confirmed => { if (confirmed) ... })
 *
 *        TUDO o que vinha depois do confirm foi embrulhado no .then()
 *        porque rmbz.confirm é assíncrono (Promise) — não bloqueia a
 *        thread como o nativo. Mensagem reescrita em 2 parágrafos com
 *        "definitiva" em <strong> para reforçar a gravidade.
 *
 *     2. Alert "release_id em falta" → rAlert danger
 *        Mensagem reescrita: "Recarregue a página e tente de novo"
 *        (acção concreta, não apenas erro técnico).
 *
 *     3. Alert "✅ Lançamento enviado com sucesso!" → rAlert success
 *        Antes: alert simples + redirect imediato
 *        Agora: modal success com mensagem rica:
 *          "O seu lançamento foi recebido pela equipa. Será revisto
 *          e distribuído nas próximas horas."
 *        Redirect só após o utilizador clicar OK (.then(() => redirect))
 *        — mais civilizado, dá-lhe tempo de ler.
 *
 *     4. Alert "❌ Erro: ..." → rAlert danger
 *        Título "Não foi possível enviar" + mensagem do err.message.
 *        Fallback se err.message vazio: "Tente novamente daqui a alguns
 *        segundos" (acção concreta).
 *
 *   [VANTAGENS]
 *     • Visual coerente em todo o fluxo (4 dialogs bonitos seguidos)
 *     • Cores semânticas: warning antes, success ao concluir, danger
 *       em erros — o utilizador percebe pelo cor o que aconteceu
 *     • HTML rico permite formatação (negrito, parágrafos)
 *     • Async/Promise não bloqueia a thread — mais rápido em redes lentas
 *     • Fallback gracioso se design system não carregou (raro mas seguro)
 *
 *   [PRESERVADO]
 *     • Toda a lógica de validação (.is-incomplete check)
 *     • Lógica de fetch sequencial (save → finalize)
 *     • Reset do botão em caso de erro (innerText + disabled = false)
 *     • scrollIntoView na primeira faixa incompleta
 *     • POST contracts (action=onemz_finalize_release, nonce, etc.)
 *
 *   [PRÓXIMOS PASSOS]
 *     14 alerts + 3 confirms restantes no wizard ainda por migrar.
 *     Faremos quando aparecerem em capturas/bug reports.
 *
 * == 1.5.64 (2026-04) — 🐛 Fix múltiplo: ícone achatado, letras coladas, botão preso ==
 *   Bugs visuais reportados pelo utilizador na 1.5.63:
 *     1. Ícone laranja "encolhido"/achatado no topo (não circular perfeito)
 *     2. Letras da mensagem "coladas" (line-height demasiado apertado)
 *     3. Botão "Entendi" colado ao fundo da caixa (padding insuficiente)
 *
 *   ANÁLISE — TODOS os 3 bugs tinham 1 causa comum: conflito com o
 *   .rmbz-modal__panel pai que tem `display: flex; flex-direction: column;
 *   align-items: stretch` (default). Os filhos do dialog ficavam:
 *     • Ícone esticado horizontalmente (perde aspect-ratio quadrado)
 *     • Panel comprimido para 360px (do --rmbz-modal--sm) em vez dos 420px
 *       que tínhamos definido — apertava tudo
 *     • Padding inferior insuficiente quando flex era column
 *
 *   FIXES MULTIPLOS:
 *
 *     [JS] design-system.js:361 — removido `rmbz-modal--sm`
 *       Antes: overlay.className = 'rmbz-modal rmbz-modal--sm rmbz-dialog'
 *       Agora: overlay.className = 'rmbz-modal rmbz-dialog'
 *       O .rmbz-dialog__panel já define max-width: 420px próprio. Adicionar
 *       --sm sobrepunha para 360px (regra mais específica vinha depois).
 *
 *     [CSS] .rmbz-dialog__panel
 *       • align-items: center            (era stretch herdado, esticava ícone)
 *       • padding-bottom: space-7        (era space-6, agora 32px — botão respira)
 *
 *     [CSS] .rmbz-dialog__icon
 *       • aspect-ratio: 1 / 1            (força quadrado mesmo se flex tentar esticar)
 *       • min-width / min-height: 56px   (reset de qualquer min-* do tema)
 *       • svg { display: block }         (evita inline gap subtle)
 *
 *     [CSS] .rmbz-dialog__title
 *       • width: 100%                    (panel é flex center, sem isto o título
 *                                          encolhia ao tamanho do texto)
 *
 *     [CSS] .rmbz-dialog__message
 *       • line-height: 1.6               (era --rmbz-leading-relaxed=1.5, agora
 *                                          mais respiração entre linhas)
 *       • width: 100%                    (mesmo problema do título)
 *       + spacing entre <p> via .rmbz-dialog__message p { margin-bottom: space-3 }
 *
 *     [CSS] .rmbz-dialog__actions
 *       • width: 100%                    (justify-content só funciona se o flex
 *                                          container ocupar largura total)
 *
 *   Resultado esperado:
 *     • Ícone perfeitamente circular (aspect-ratio garante)
 *     • Caixa larga (420px em vez de 360px) — texto cabe melhor
 *     • Letras com respiração entre linhas
 *     • Parágrafos separados com spacing
 *     • Botão centrado e com 32px de respiração até à borda
 *     • Funciona em qualquer tema/CSS reset
 *
 *   PRESERVADO: tudo o resto. API rmbz.alert/confirm não muda. Mobile
 *   continua a empilhar botões verticalmente.
 *
 * == 1.5.63 (2026-04) — 🐛 Fix: layout dos botões do dialog (centrar quando único) ==
 *   Bugs visuais reportados pelo utilizador na 1.5.62:
 *     1. Botão "Entendi" estava colado à borda inferior direita do panel
 *        — sem padding suficiente, parecia ter "escapado" da caixa
 *     2. Botão estava à direita quando devia estar centrado (estilo "OK"
 *        universal para alerts com 1 só botão)
 *
 *   FIX em assets/css/03-components.css §7.5:
 *
 *     [Fix #1 — padding inferior]
 *     .rmbz-dialog__panel { padding-bottom: var(--rmbz-space-6); }
 *     (era space-5/20px, agora space-6/24px — mais respiração)
 *
 *     [Fix #2 — botão único centra automaticamente]
 *     .rmbz-dialog__actions:has(.rmbz-btn:only-child) {
 *       justify-content: center;
 *     }
 *
 *     Lógica do layout:
 *       1 botão (alert)    → centrado    (estilo "OK" universal)
 *       2 botões (confirm) → flex-end    (cancel à esq, ok à dir — convenção PT/EU)
 *
 *     O selector :has(.rmbz-btn:only-child) detecta automaticamente sem
 *     precisar de classe modificadora extra. O JS que cria o dialog não
 *     precisa de saber.
 *
 *     Min-width do botão único subido de 100px para 120px — ao centrar,
 *     um botão um pouco mais largo fica visualmente mais confortável.
 *
 *   COMPATIBILIDADE: o plugin já usa :has() noutros sítios (radio-pill,
 *   dialog-message). Suporte universal em browsers modernos (Safari
 *   15.4+, Chrome 105+, Firefox 121+).
 *
 *   PRESERVADO: tudo o resto inalterado. API rmbz.alert/confirm não
 *   muda. Mobile continua a empilhar botões verticalmente (column-reverse,
 *   ok em cima).
 *
 * == 1.5.62 (2026-04) — 💬 Modal bonito substitui alert()/confirm() nativos ==
 *   Bug visual reportado pelo utilizador: o alert() do browser usado para
 *   "Não pode submeter ainda" (faixas incompletas) era feio, com style do
 *   sistema operativo, fora da identidade do plugin, e bloqueava a thread.
 *   O utilizador pediu para ser modal bonito, e que **todos os sítios**
 *   que usem alert() devem migrar.
 *
 *   FILOSOFIA: Esta versão faz **infraestrutura + 1 caso de uso**. Cria a
 *   API genérica `window.rmbz.alert()` e `window.rmbz.confirm()` (modais
 *   bonitos, animados, acessíveis), e substitui o caso da imagem (faixas
 *   incompletas no Passo 4 do wizard) como prova de conceito. Versões
 *   futuras vão substituir os ~35 outros alert() e ~58 confirm() do plugin
 *   à medida que aparecerem (estratégia incremental, segura).
 *
 *   [API NOVA em assets/js/design-system.js]
 *
 *     window.rmbz.alert(message, opts)
 *       Substituto fire-and-forget do alert() nativo.
 *       Devolve Promise<true> para quem quiser saber quando fechou.
 *
 *     window.rmbz.confirm(message, opts)
 *       Substituto do confirm() nativo. NÃO BLOQUEIA — devolve Promise<bool>.
 *       Use: rmbz.confirm("Apagar?").then(ok => { if (ok) ... })
 *
 *     opts:
 *       title:       string opcional
 *       variant:     'info' (default) | 'warning' | 'danger' | 'success'
 *       html:        bool — opt-in para HTML cru no message (default: escapado)
 *       okLabel:     string (default: 'OK')
 *       cancelLabel: string (só relevante para confirm; default: 'Cancelar')
 *
 *     Exemplos:
 *       rmbz.alert("Operação concluída.")
 *       rmbz.alert("Algo correu mal.", { title: "Erro", variant: "danger" })
 *       rmbz.confirm("Apagar permanentemente?", {
 *         title: "Confirmar acção",
 *         variant: "danger",
 *         okLabel: "Apagar",
 *         cancelLabel: "Voltar"
 *       }).then(ok => { if (ok) doDelete(); });
 *
 *   [VANTAGENS sobre alert()/confirm() nativos]
 *     • Visualmente coerente com o resto do plugin (rmbz-modal animado)
 *     • Não bloqueia a thread (importante para AJAX paralelos)
 *     • Suporta título separado da mensagem
 *     • 4 variantes de cor com ícones SVG semânticos:
 *         info     (azul, círculo com 'i')
 *         warning  (laranja, triângulo)
 *         danger   (vermelho, círculo com X)
 *         success  (verde, check)
 *     • Mensagens podem conter HTML rico (listas, strong, br) com opt-in
 *     • Auto-destruídos ao fechar (não poluem o DOM)
 *     • ESC fecha (= cancel/false)
 *     • Enter activa o botão primário
 *     • Focus trap automático
 *     • role=alertdialog para acessibilidade
 *     • Em mobile: vira sheet vertical com botões full-width
 *     • Fallback gracioso: se algo correr mal, cai para alert()/confirm()
 *       nativo em vez de não fazer nada
 *
 *   [CSS NOVO em assets/css/03-components.css §7.5]
 *
 *     .rmbz-dialog                       wrapper (extends .rmbz-modal)
 *     .rmbz-dialog__panel                container central, centrado
 *     .rmbz-dialog__panel--{variant}     border-top de 3px na cor da variant
 *     .rmbz-dialog__icon                 círculo grande no topo (56×56)
 *     .rmbz-dialog__icon--{variant}      background-soft + border + cor
 *     .rmbz-dialog__title                título Bricolage Grotesque semibold
 *     .rmbz-dialog__message              mensagem em --rmbz-text-secondary
 *                                         (com listas/strong bem formatados)
 *     .rmbz-dialog__actions              botões alinhados à direita
 *
 *   [APLICADO PRIMEIRO CASO em distribution-wizard.php:1985]
 *     Antes (alert nativo):
 *       alert(
 *         '⚠ Não pode submeter ainda.\n\n' +
 *         'As seguintes faixas estão incompletas...\n\n' +
 *         titles.join('\n') +
 *         '\n\nClique em "⚠ Completar"...'
 *       );
 *
 *     Agora (modal bonito):
 *       window.rmbz.alert(msgHtml, {
 *         title: 'Não pode submeter ainda',
 *         variant: 'warning',
 *         html: true,
 *         okLabel: 'Entendi'
 *       });
 *
 *     A mensagem agora é HTML rico: parágrafos + lista <ul><li> com cada
 *     faixa em strong, em vez de "  • Faixa 1: ..." em texto monoespaçado.
 *     Visualmente muito mais legível e profissional.
 *
 *   [SEGURANÇA]
 *     • Por defeito (sem html:true), TODA a mensagem é escapada via
 *       textContent → innerHTML (igual ao escapeHtml() do JS), apenas \n
 *       é convertido para <br>. Imune a XSS.
 *     • Com html:true, a responsabilidade passa para o chamador (devem
 *       escapar dados de utilizador antes de passar). No caso aplicado
 *       (faixas incompletas), os títulos das faixas são escapados
 *       manualmente com .replace para &/<> antes de entrar no HTML.
 *
 *   [PRÓXIMOS PASSOS — versões futuras]
 *     A migração dos restantes alert()/confirm() pode ser feita gradualmente:
 *       • Wizard tem 14 alerts + 3 confirms restantes
 *       • my-account-page.php tem 9 alerts + 6 confirms
 *       • distribution-single-view.php tem 3 alerts
 *       • distribution-list-view.php tem 2 alerts
 *       • distribution-analytics.php tem 2 alerts
 *
 *     Estratégia: substituir à medida que aparecerem em capturas de
 *     ecrã ou bug reports. Não fazer "rewrite massivo" — cada alert
 *     pode ter contexto/comportamento próprio (alguns dependem de
 *     bloqueio síncrono, raro mas existe).
 *
 *   [PRESERVADO]
 *     • Toda a API window.rmbz existente (modal, toast, button)
 *     • Comportamento de scrollIntoView para a primeira faixa incompleta
 *     • Lógica de validação (querySelectorAll + filter) inalterada
 *     • Fallback para alert() nativo se rmbz não estiver carregado
 *
 * == 1.5.61 (2026-04) — 🐛 Fix: ícone do pill demasiado afastado do valor ==
 *   Bug visual reportado pelo utilizador na 1.5.60: o ícone cifrão e o
 *   valor "$25.14" ficavam afastados ("$    25.14    ganhos" em vez de
 *   "$25.14   ganhos"). Sentia-se um buraco entre o símbolo e o número.
 *
 *   CAUSA: o .rmbz-stat-pill usava `gap: var(--rmbz-space-2)` (8px)
 *   uniforme entre os 3 elementos filhos (ícone, valor, label). Mas
 *   ícone+valor formam **uma unidade visual** (símbolo + número, como
 *   "$25.14" ou "♪12") enquanto o label é uma anotação à parte.
 *   Espaço uniforme partia visualmente o número.
 *
 *   FIX em assets/css/03-components.css:
 *     • Removido o `gap` uniforme da .rmbz-stat-pill
 *     • Adicionado margin-right: 4px ao __icon (encosta ao valor)
 *     • Adicionado margin-left: 8px ao __label (separa do valor)
 *
 *   Resultado: agrupamento visual correcto.
 *     Antes: [ $   25.14   ganhos ]
 *     Agora: [ $ 25.14   ganhos ]
 *
 *   MOBILE: gaps internos ainda mais reduzidos (2px e 4px) para pills
 *   compactos em ecrãs pequenos.
 *
 *   PRESERVADO: tudo o resto da 1.5.60 inalterado (3 variantes, query,
 *   pulse animation, backwards compat). Só mudou o spacing interno.
 *
 * == 1.5.60 (2026-04) — 🐛 Fix: cifrão duplicado no pill de ganhos ==
 *   Bug visual reportado pelo utilizador na 1.5.59:
 *   o pill de ganhos mostrava "$ $25.14 ganhos" — dois cifrões.
 *
 *   CAUSA: o ícone SVG cifrão JÁ está visível à esquerda do valor
 *   (introduzido em 1.5.58). Quando passei o número para mono
 *   tabular-nums, deixei o "$" prefix no template PHP em vez de
 *   confiar apenas no ícone. Resultado: dois símbolos seguidos.
 *
 *   FIX: my-account-page.php:1965 — removido o "$" hardcoded do
 *   template. O ícone SVG cifrão fica como único indicador visual
 *   de moeda. O valor agora é só "25.14".
 *
 *     Antes: <span class="rmbz-stat-pill__value">$<?php echo … ?></span>
 *     Agora: <span class="rmbz-stat-pill__value"><?php echo … ?></span>
 *
 *   DECISÃO: manter o ícone SVG e remover o "$" textual (em vez do
 *   contrário) por 3 motivos:
 *     • Ícone SVG é mais elegante visualmente (já estava integrado)
 *     • Funciona como rótulo semântico universal independente da língua
 *     • Para outras moedas no futuro (€, MT, etc.), basta trocar o
 *       ícone — o número fica neutro
 *
 *   Aria-label original "Ganhos: %s dólares" mantido — leitor de ecrã
 *   continua a anunciar "dólares" naturalmente.
 *
 *   PRESERVADO: tudo o resto da 1.5.59 inalterado (3 pills, query
 *   única, animação pulse, mobile, backwards compat).
 *
 * == 1.5.59 (2026-04) — 📊 3 stat-pills no cartão Distribuição (ganhos / músicas / atenções) ==
 *   Continuação da 1.5.58. O utilizador pediu mais 2 pills além do
 *   ganhos: contagem de músicas publicadas e contagem de releases que
 *   requerem atenção. Tudo bem destacado com cores diferentes — stat-
 *   pills em vez de texto inline confuso.
 *
 *   FILOSOFIA: Generalizar o componente .rmbz-earnings-pill (criado em
 *   1.5.58 só para ganhos) para .rmbz-stat-pill com variantes de cor
 *   semânticas. Backwards compat: a classe antiga continua a funcionar
 *   via alias CSS (mesmos selectors).
 *
 *   [QUERY ÚNICA — uma só round-trip à DB]
 *     SELECT
 *       SUM(CASE WHEN status IN ('live','distribuido') THEN 1 ELSE 0 END) AS live_count,
 *       SUM(CASE WHEN status = 'returned' THEN 1 ELSE 0 END) AS attention_count
 *     FROM wp_rmbz_distro_releases
 *     WHERE artist_telemovel = %s
 *
 *     Indexada por artist_telemovel — performance trivial mesmo com
 *     centenas de releases. Adicionada em my-account-page.php logo
 *     depois da query do distro_balance_usd.
 *
 *   [COMPONENTE GENERALIZADO em assets/css/03-components.css]
 *
 *     .rmbz-stat-pills          → wrapper flex com gap + flex-wrap
 *     .rmbz-stat-pill           → base do pill (CSS vars --pill-color, --pill-bg, --pill-glow)
 *     .rmbz-stat-pill--brand    → dourado (ganhos)        — default
 *     .rmbz-stat-pill--accent   → azul    (contadores neutros)
 *     .rmbz-stat-pill--warning  → laranja (alertas) + animação pulse subtil
 *     .rmbz-stat-pill__icon     → mini-ícone SVG 14×14
 *     .rmbz-stat-pill__value    → valor (mono tabular-nums, bold)
 *     .rmbz-stat-pill__label    → label em texto secundário
 *
 *     Backwards compat: .rmbz-earnings-pill e sub-elementos continuam
 *     a funcionar (mesmo CSS via aliases nos selectors).
 *
 *   [VARIANTE --warning — animação pulse subtil]
 *     A pill de "atenção" pulsa de 2.5s em 2.5s — chamar atenção sem ser
 *     agressivo. A animação respeita prefers-reduced-motion (não pulsa
 *     para utilizadores que pediram para reduzir movimento — vestibulares,
 *     epilepsia, etc.). No hover, a animação pausa (utilizador a pensar
 *     no conteúdo, não interromper).
 *
 *   [3 PILLS NO MARKUP — todos condicionais]
 *
 *     1. Pill ganhos ($X.XX):       só se distro_balance_usd > 0
 *     2. Pill músicas (X música/s):  só se distro_live_count > 0
 *     3. Pill atenções (X atenção/ões): só se distro_attention_count > 0
 *
 *     Wrapper .rmbz-stat-pills só renderiza se houver pelo menos 1 pill
 *     (evita div vazio). Plurais correctos via _n() em todos os labels e
 *     aria-labels — gramática portuguesa preservada (música/músicas,
 *     atenção/atenções).
 *
 *   [DECISÕES TÉCNICAS]
 *     • Por que generalizar e não criar 3 componentes separados?
 *       Mesma estrutura visual + comportamento (hover, mobile, layout) —
 *       diferença é só cor. CSS vars permitem reutilizar 80% do código.
 *     • Por que pulse só na variante --warning? "Atenção" é o único caso
 *       que precisa de feedback contínuo (utilizador deve resolver). As
 *       outras 2 são informativas (ganhos e contagem) — pulsar seria
 *       ruído visual.
 *     • Por que ícones SVG diferentes para cada pill? Cifrão (ganhos),
 *       nota musical (músicas), triângulo (alerta). Reforça
 *       semanticamente sem precisar de ler o texto.
 *     • Por que mono tabular-nums em todos os 3 valores? Para os 3
 *       pills ficarem visualmente alinhados quando aparecem em fila —
 *       "$25.14", "12", "1" todos com a mesma família tipográfica.
 *     • Por que SUM(CASE WHEN) e não 2 SELECT COUNT separados?
 *       Round-trip único à DB (1 query em vez de 2). Padrão SQL standard.
 *     • Por que status 'live' E 'distribuido' contam como "publicada"?
 *       Vi no código existente em distribution-main.php que ambos são
 *       tratados como "Live" no UI ("Live" || "distribuido" === "publicada").
 *     • Por que apenas status 'returned' = atenção? Outros estados
 *       ('incomplete' = rascunho, 'pendente' = em revisão pelo admin)
 *       não requerem acção do artista. Só 'returned' (devolvido para
 *       correcção) é "tens trabalho a fazer".
 *
 *   [PRESERVADO]
 *     • Toda a lógica PHP existente intacta — só adicionámos
 *     • Pill .rmbz-earnings-pill (1.5.58) continua a funcionar via alias
 *     • Cartão --accent (azul Spotify) inalterado
 *     • Botão "Abrir Dashboard Digital" inalterado
 *     • Mobile: 3 pills caem em flex-wrap natural (1 ou 2 ou 3 por linha)
 *     • Acessibilidade: aria-labels naturais ("Ganhos: 25 dólares") +
 *       prefers-reduced-motion respeitado para o pulse
 *
 * == 1.5.58 (2026-04) — 💰 Pill de ganhos premium no cartão Distribuição ==
 *   Bug visual reportado pelo utilizador: o "$25.14 ganhos" no cartão
 *   "Distribuição Digital" estava como `<span>` verde inline no
 *   parágrafo do subtítulo. Confundia-se com texto normal e não dava
 *   ênfase à única métrica financeira do cartão (motivo principal
 *   para o utilizador clicar).
 *
 *   SOLUÇÃO: Componente novo no design system — `.rmbz-earnings-pill`.
 *   Pill premium dourado com mini-ícone de cifrão à esquerda, valor em
 *   mono tabular-nums e label "ganhos" em texto secundário.
 *
 *   [COMPONENTE NOVO em assets/css/03-components.css]
 *     .rmbz-earnings-pill          → wrapper inline-flex, pill dourado
 *     .rmbz-earnings-pill__icon    → mini-ícone SVG cifrão (14×14)
 *     .rmbz-earnings-pill__value   → valor monetário (mono, bold, dourado)
 *     .rmbz-earnings-pill__label   → "ganhos" em texto secundário
 *
 *   Visual:
 *     • Background: --rmbz-brand-soft (dourado subtil 8%)
 *     • Border: 1px sólido --rmbz-brand (dourado vivo)
 *     • Border-radius: pill (totalmente arredondado)
 *     • Box-shadow: ring subtil 1px@8% + sombra dourada 8px@12%
 *       (ligeira elevação "premium" sem competir com o botão CTA)
 *     • Hover: levanta 1px (translateY) + sombras crescem
 *     • Mini-ícone cifrão SVG: 14×14, --rmbz-brand, currentColor
 *     • Valor "$25.14": mono tabular-nums, --rmbz-brand, bold
 *     • Label "ganhos": --rmbz-text-muted, font-medium
 *     • Mobile: pill mais compacto (font-size 1 step abaixo)
 *
 *   [APLICAÇÃO em my-account-page.php:~1908 — cartão Distribuição]
 *     Antes:
 *       <p class="rmbz-feature-card__subtitle">
 *         Lance as suas músicas… 150+ lojas.
 *         <?php if (...): ?>
 *           <span style="display:inline-block;margin-left:...;
 *                        color:var(--rmbz-success);font-weight:...;">
 *             · $25.14 ganhos
 *           </span>
 *         <?php endif; ?>
 *       </p>
 *
 *     Agora:
 *       <p class="rmbz-feature-card__subtitle">
 *         Lance as suas músicas… 150+ lojas.
 *       </p>
 *       <?php if (...): ?>
 *         <span class="rmbz-earnings-pill" aria-label="Ganhos: ...">
 *           <svg class="rmbz-earnings-pill__icon">…cifrão…</svg>
 *           <span class="rmbz-earnings-pill__value">$25.14</span>
 *           <span class="rmbz-earnings-pill__label">ganhos</span>
 *         </span>
 *       <?php endif; ?>
 *
 *   Pill agora é elemento próprio FORA do <p> (era inline dentro do
 *   subtítulo) — visualmente separado, com hierarquia clara.
 *
 *   [DECISÕES TÉCNICAS]
 *     • Por que dourado e não verde (que = ganhos)? Coerência com a
 *       identidade do plugin (dourado = financeiro/premium). Verde
 *       conflituaria com o azul do cartão --accent. Além disso, o
 *       símbolo cifrão + número já comunica "dinheiro" sem precisar
 *       de cor verde.
 *     • Por que mono tabular-nums no valor? É um valor financeiro —
 *       merece tratamento de números financeiros (alinhamento perfeito
 *       de dígitos, fonte que destaca números). Padrão usado no resto
 *       do plugin (saldo, países contagem, etc).
 *     • Por que mini-ícone cifrão e não emoji 💰? SVG é consistente em
 *       qualquer browser, escala perfeitamente, e segue o padrão dos
 *       restantes ícones do plugin (todos SVG inline com currentColor).
 *     • Por que `aria-label` com formato falado? Leitores de ecrã leriam
 *       "$25.14" como "cifrão vinte e cinco ponto catorze" — confuso.
 *       Com aria-label "Ganhos: 25.14 dólares" fica natural.
 *     • Por que componente reutilizável e não estilo inline? Provavelmente
 *       no futuro vamos querer mostrar ganhos noutros sítios (página de
 *       referrals, histórico de royalties, etc). Componente fica disponível.
 *
 *   [PRESERVADO]
 *     • Toda a lógica PHP intacta — só mudou o markup visual
 *     • Cartão --accent (azul Spotify) inalterado
 *     • Botão "Abrir Dashboard Digital" inalterado
 *     • Condição `if ( $distro_balance_usd > 0 )` inalterada (só mostra
 *       pill se tiver ganhos)
 *     • Mobile: pill quebra naturalmente para próxima linha (já tem
 *       flex-wrap no parent), com font-size mais compacto
 *     • Acessibilidade: aria-label no pill para leitores de ecrã
 *     • Acessibilidade: ícone com aria-hidden (decorativo)
 *
 * == 1.5.57 (2026-04) — 📱 Header responsivo: stack limpa em mobile ==
 *   Bug visual reportado pelo utilizador: em telemóvel, o header do
 *   dashboard ficava confuso. O nome "Olá, Jaime Metano 👋" quebrava
 *   palavra a palavra em 3 linhas (Olá, / Jaime / Metano), o ID e
 *   Perfil ficavam soltos no meio, o sino e Sair amontoados à direita.
 *
 *   CAUSA: `flex-wrap: wrap` default fazia o trabalho mal. O `__user`
 *   tinha `flex:1` e tentava ocupar máximo espaço, comprimindo
 *   lateralmente até quebrar palavra a palavra. Não havia media query
 *   específica para mobile no header.
 *
 *   FIX em assets/css/03-components.css §10.1 (.rmbz-header):
 *
 *     @media (max-width: 640px) {
 *       .rmbz-header              { flex-direction: column;
 *                                   align-items: stretch;
 *                                   gap: var(--rmbz-space-3);
 *                                   padding: var(--rmbz-space-4); }
 *       .rmbz-header__user        { flex: none; width: 100%; }
 *       .rmbz-header__greeting    { font-size: var(--rmbz-text-lg); }
 *       .rmbz-header__actions     { width: 100%;
 *                                   justify-content: flex-end;
 *                                   gap: var(--rmbz-space-4);
 *                                   padding-top: var(--rmbz-space-3);
 *                                   border-top: 1px solid
 *                                     var(--rmbz-border-subtle); }
 *       .rmbz-header__user .rmbz-avatar  { width: 40px; height: 40px;
 *                                          font-size: var(--rmbz-text-base); }
 *     }
 *
 *   Resultado em mobile:
 *     ┌──────────────────────────────┐
 *     │ [J]  Olá, Jaime Metano 👋    │
 *     │      ID: +258...   ⚙Perfil   │
 *     ├──────────────────────────────┤  ← divisor subtil
 *     │                  🔔   →Sair  │
 *     └──────────────────────────────┘
 *
 *   Decisões:
 *     • Avatar mais pequeno em mobile (44→40px) — não domina o layout
 *     • Greeting --text-lg em vez de --text-xl — cabe sem quebrar
 *     • Sino e Sair com gap-4 (era gap-3 em PC) — espaço para tocar
 *     • Border-top no actions — separa visualmente "quem és" de "o que
 *       fazes" sem precisar de outro componente
 *     • Perfil fica com o ID (mesma row do __user) — semanticamente
 *       é uma acção da identidade, não uma acção de sessão
 *
 *   PRESERVADO: layout em PC inalterado (max-width:640px só dispara em
 *   mobile). Markup PHP intacto — só CSS. Variantes do avatar (--sm,
 *   --lg) inalteradas.
 *
 * == 1.5.56 (2026-04) — ✨ Sombra do avatar reforçada para destacar do fundo ==
 *   Mini-ajuste pedido pelo utilizador — em 1.5.55 tinha o aro dourado
 *   nítido mas o avatar continuava "plano" sobre o fundo do header.
 *   Faltava sentir-se elevado, como botão premium.
 *
 *   FIX em assets/css/03-components.css §10.2 (.rmbz-avatar):
 *
 *     Antes (1.5.55):
 *       border: 2px solid var(--rmbz-brand);                          // aro nítido OK
 *       box-shadow: 0 0 0 3px rgba(240,185,11,0.12);                   // só separador
 *       (sem elevação, sem glow significativo)
 *
 *     Agora (1.5.56):
 *       border: 2px solid var(--rmbz-brand);                          // mantido
 *       box-shadow:
 *         0 0 0 4px rgba(240,185,11,0.22),                            // ring exterior + visível
 *         0 6px 16px rgba(0,0,0,0.5),                                  // sombra de elevação
 *         0 0 24px rgba(240,185,11,0.18);                              // glow dourado largo
 *
 *   3 sombras compostas em sequência:
 *     1. Ring exterior 4px @ 22% — anel dourado mais visível à volta
 *     2. Sombra preta com offset Y=6 e blur 16 — faz flutuar (clássico
 *        material elevation), avatar parece levantado do fundo
 *     3. Glow dourado largo 24px @ 18% — luz quente que se espalha,
 *        sente-se "luminoso/premium" sem ser exagerado
 *
 *   Hover ajustado: avatar levanta 1px (transform translateY) +
 *   sombras todas crescem proporcionalmente — feedback subtil de
 *   elemento interactivo. transform adicionado à transition.
 *
 *   Decisões de calibre (não exagerar):
 *     • Glow dourado a 18% (não 30%+) — não vira "lâmpada acesa"
 *     • Sombra preta a 50% (não 80%) — não fica "ferida no fundo"
 *     • Ring exterior 4px (não 8px) — não rouba espaço aos elementos
 *       à volta (sino, "Sair", etc.)
 *     • Hover translateY -1px (não -3px) — micro-movimento elegante
 *
 *   PRESERVADO: tudo o resto do .rmbz-avatar inalterado. Variantes
 *   --sm e --lg continuam a herdar este box-shadow naturalmente
 *   (escala bem em ambos os tamanhos).
 *
 * == 1.5.55 (2026-04) — ✨ Aro do avatar ajustado para combinar com o cupão ==
 *   Mini-tweak ao halo do avatar do header. Em 1.5.53 deixei o aro
 *   "embrulhado em luz" com glow forte de 14px e opacidade 60%, que
 *   visualmente ficava desbotado e difuso. O utilizador comparou com o
 *   border do cartão "Tem um Cupão Promocional?" e queria igual.
 *
 *   Análise por amostragem de pixéis: o cupão tem border de 1-2px com
 *   #f0b90b PURO (100% opacidade) — uma linha dourada nítida, sem glow.
 *
 *   FIX em assets/css/03-components.css §10.2 (.rmbz-avatar):
 *
 *     Antes (1.5.53):
 *       border: 2px solid rgba(240, 185, 11, 0.6);
 *       box-shadow: 0 0 0 4px rgba(240, 185, 11, 0.18),
 *                   0 0 14px 2px rgba(240, 185, 11, 0.25);
 *
 *     Agora (1.5.55):
 *       border: 2px solid var(--rmbz-brand);                          // 100% sólido
 *       box-shadow: 0 0 0 3px rgba(240, 185, 11, 0.12);                // só separador subtil
 *       (glow de 14px removido)
 *
 *   Mantive 3px de ring exterior subtil (12% opacidade) por questão
 *   técnica: em fundo preto puro, um border 100% sem qualquer respiração
 *   à volta fica "cortado/colado" ao fundo. Os 3px funcionam como
 *   anti-aliasing visual, integrando o aro com o resto sem chamar
 *   atenção. Replica fielmente o efeito que o cupão tem.
 *
 *   Hover ajustado também: como o border já está no máximo (100%), em
 *   vez de o intensificar, intensifica o glow exterior (3px@12% → 4px@25%).
 *   Feedback subtil de elemento "vivo" sem exagero.
 *
 *   PRESERVADO: tudo o resto do .rmbz-avatar inalterado (tamanho,
 *   tipografia, background dourado, transições). Variantes --sm e --lg
 *   continuam a funcionar zero alterações.
 *
 * == 1.5.54 (2026-04) — 💎 Cartão "VIP Activo" com background verde do antigo ==
 *   Continuação do polish iniciado em 1.5.53. O utilizador pediu o
 *   visual "verde escuro VIP" do design antigo de volta no cartão
 *   "Acesso Artista VIP Activo" — recuperado com tokens limpos e
 *   modificador isolado.
 *
 *   FILOSOFIA: Cirurgia. Modificador NOVO --vip que só se aplica ao
 *   cartão VIP Activo. Outros cartões --success (notavelmente o
 *   "Ganhe Saldo Extra" / referrals) continuam exactamente como
 *   estavam. Mesmo padrão da 1.5.53 (--saldo).
 *
 *   [TOKENS NOVOS]
 *     --rmbz-bg-vip-from: #20392b   // canto sup. esq. (verde-escuro vivo)
 *     --rmbz-bg-vip-to:   #1e2320   // canto inf. dir. (preto-esverdeado)
 *     Cores amostradas pixel a pixel da referência visual do utilizador.
 *
 *     Variante HC (high contrast) também adicionada:
 *       --rmbz-bg-vip-from: #142a1c
 *       --rmbz-bg-vip-to:   #0e1411
 *
 *   [MODIFICADOR NOVO .rmbz-feature-card--vip]
 *     background: linear-gradient(135deg, var(--rmbz-bg-vip-from) 0%,
 *                                          var(--rmbz-bg-vip-to) 100%);
 *     border-color: rgba(16, 185, 129, 0.15);     // border verde subtil
 *     :hover { border 0.3, shadow 24px 30% }
 *
 *     Sub-elementos com legibilidade afinada:
 *       __title:                 rgba(255,255,255,0.95)
 *       __subtitle:              rgba(255,255,255,0.65)
 *       __perms (border-top):    rgba(255,255,255,0.08)
 *       __perms-heading:         rgba(255,255,255,0.5)
 *
 *   [LIMPEZA: inline styles → classes utilitárias]
 *     Aproveitei a oportunidade para limpar 2 inline styles longos no
 *     PHP que existiam no bloco "O seu plano inclui" + heading. Antes:
 *
 *       <div style="padding:0 var(--rmbz-space-6) var(--rmbz-space-5);
 *                   border-top:1px dashed var(--rmbz-border-subtle);">
 *           <h5 style="margin:var(--rmbz-space-4) 0 var(--rmbz-space-3) 0;
 *                      font-size:var(--rmbz-text-2xs);
 *                      font-weight:var(--rmbz-weight-semibold);
 *                      color:var(--rmbz-text-muted);
 *                      text-transform:uppercase;
 *                      letter-spacing:var(--rmbz-tracking-wider);">
 *
 *     Agora:
 *
 *       <div class="rmbz-feature-card__perms">
 *           <h5 class="rmbz-feature-card__perms-heading">
 *
 *     2 classes novas ao design system (.rmbz-feature-card__perms e
 *     __perms-heading) que ficam disponíveis para reutilização noutros
 *     cartões de plano. Como bónus, o modificador --vip pode agora
 *     sobrescrever sem precisar de !important nem de selectors frágeis
 *     do tipo [style*="border-top"].
 *
 *   [APLICAÇÃO]
 *     <div class="rmbz-feature-card rmbz-feature-card--success rmbz-feature-card--vip">
 *
 *     Aplicado APENAS no my-account-page.php:1731 (cartão VIP Activo).
 *     O cartão de referrals em my-account-page.php:2036 continua só com
 *     --success — verifiquei manualmente para garantir.
 *
 *   [DECISÕES TÉCNICAS]
 *     • Por que gradiente e não cor sólida? A referência tem gradiente
 *       diagonal claro (canto sup esq verde-vivo, canto inf dir neutro).
 *       Cor sólida ficaria mais plana. O gradiente dá profundidade
 *       "premium" coerente com o espírito VIP.
 *     • Por que `linear-gradient(135deg, ...)`? 135deg dá topo-esquerdo
 *       → fundo-direito, o mesmo movimento da referência. Parecia natural.
 *     • Por que tokens separados (--from / --to) e não 1 token "completo"
 *       com `linear-gradient` dentro? Os browsers não aceitam gradiente
 *       em background-color shorthand. Tokens separados também permitem
 *       reutilizar cada cor isoladamente em hover effects, borders, etc.
 *     • Por que limpar os inline styles agora e não num refactor à parte?
 *       Os selectors `[style*="..."]` que precisaria para sobrescrever os
 *       inline styles são frágeis (dependem do conteúdo exacto). Limpar
 *       já neste passo evita dívida técnica e o modificador fica robusto.
 *
 *   [PRESERVADO]
 *     • Toda a estrutura DOM intacta (markup só substitui inline styles
 *       por classes equivalentes — output visual idêntico fora do --vip)
 *     • Todos os IDs JS-deps inalterados
 *     • rmbz-perm-list e rmbz-perm-list__item (lista de permissões)
 *       continuam a funcionar zero alterações
 *     • Cartão de referrals (2º --success) idêntico
 *     • Acessibilidade: contraste branco-95% sobre #20392b ≈ 14:1 (AAA)
 *
 * == 1.5.53 (2026-04) — ✨ Polish: avatar com halo + saldo com fundo navy ==
 *   Pequenos ajustes estéticos pedidos pelo utilizador depois de ver o
 *   plugin modernizado. Trazem de volta dois detalhes do design antigo
 *   que faziam falta: aro dourado a respirar à volta do avatar, e cor
 *   navy clássica como fundo do cartão Património Total.
 *
 *   FILOSOFIA: Cirurgia. Duas mudanças muito pequenas, isoladas. A do
 *   avatar afecta TODOS os .rmbz-avatar (intencional — visual mais rico
 *   em todos os sítios). A do saldo cria um modificador NOVO (--saldo)
 *   que só se aplica ao cartão Património — outros cartões --brand
 *   (introduzir cupão) continuam exactamente como estavam.
 *
 *   [PARTE 1 — Halo dourado no avatar]
 *     Ficheiro: assets/css/03-components.css §10.2 (.rmbz-avatar)
 *
 *     Antes (1.5.52):
 *       border: 2px solid rgba(240, 185, 11, 0.4);          // ring 40% subtil
 *       box-shadow: 0 0 0 4px rgba(240, 185, 11, 0.08);    // glow 8% quase invisível
 *
 *     Agora (1.5.53):
 *       border: 2px solid rgba(240, 185, 11, 0.6);          // ring 60% mais sólido
 *       box-shadow:
 *         0 0 0 4px rgba(240, 185, 11, 0.18),               // ring exterior 18% visível
 *         0 0 14px 2px rgba(240, 185, 11, 0.25);            // glow 14px 25% que respira
 *       transition: smooth para hover
 *
 *     + adicionado :hover state com halo ainda mais intenso (0.85 ring,
 *       18px glow @ 35%) — feedback subtil de elemento "vivo".
 *
 *     Aplica-se ao avatar do header (letra J no canto superior esquerdo)
 *     e a qualquer outro avatar do plugin (lista de utilizadores admin,
 *     etc) — visual coeso em todos os sítios.
 *
 *   [PARTE 2 — Background navy no cartão Património Total]
 *     Ficheiro: assets/css/01-tokens.css + assets/css/03-components.css §FEATURE-CARD
 *     Markup: includes/my-account-page.php:1930
 *
 *     Token novo:
 *       --rmbz-bg-saldo: #2c3e50;     // "Midnight Blue" do Flat UI
 *                                      // (cor exacta amostrada da
 *                                      // referência visual do utilizador)
 *
 *     Variante HC (high contrast) também adicionada:
 *       --rmbz-bg-saldo: #1f2c39;     // mais escuro, mantém contraste
 *
 *     Modificador novo .rmbz-feature-card--saldo:
 *       background: var(--rmbz-bg-saldo);
 *       border-color: rgba(255, 255, 255, 0.06);     // border subtil que aquece
 *       :hover { border 0.12, shadow 24px 25% }
 *
 *       Sub-elementos com legibilidade afinada para o navy:
 *         __metric-label:     rgba(255,255,255,0.55)  // mais claro que muted
 *         __metric-breakdown: rgba(255,255,255,0.65)
 *         __metric-breakdown strong: rgba(255,255,255,0.92)
 *
 *     Aplicação:
 *       <div class="rmbz-feature-card rmbz-feature-card--brand rmbz-feature-card--saldo">
 *
 *     Importante: o cartão "Introduzir cupão" continua APENAS com --brand
 *     (linha 1676) — preto com barra dourada como antes. Apenas o
 *     Património Total recebe o navy. Decisão deliberada para preservar
 *     hierarquia visual: o saldo é o cartão central da experiência
 *     financeira e merece destaque próprio.
 *
 *   [DECISÕES TÉCNICAS]
 *     • Por que `.rmbz-avatar` directo e não variante --glow? O halo
 *       intensificado é discreto o suficiente para não distrair em
 *       qualquer contexto, e poupa ter de adicionar a classe em todos
 *       os sítios onde o avatar aparece. Se um dia houver um caso onde
 *       precise de halo desligado, criamos --plain.
 *     • Por que modificador --saldo e não mudar o --brand? --brand é
 *       usado em 2 sítios (cupão + Património). Mudar o background do
 *       --brand transformaria o cupão também. Modificador isolado dá
 *       controlo cirúrgico.
 *     • Por que `rgba(255,255,255,X)` e não tokens? O navy é uma cor
 *       fora da escala do design system. Texto sobre navy precisa de
 *       brancos puros com opacidade variada (não os greys do DS que
 *       estão calibrados para fundos pretos). É a única excepção em
 *       todo o componente — documentada e contida no modificador.
 *
 *   [PRESERVADO]
 *     • Toda a estrutura DOM intacta (markup só adiciona classe)
 *     • Todos os IDs JS-deps inalterados
 *     • Outros cartões --brand (cupão) idênticos
 *     • Acessibilidade: contraste do dourado sobre navy = AA WCAG
 *       (foi verificado mentalmente — #f0b90b sobre #2c3e50 dá ~7:1)
 *
 * == 1.5.52 (2026-04) — 🎯 Wizard polish final: header + progresso + steps (Fase C/4) ==
 *   ÚLTIMA versão da Fase C. Tokeniza tudo o que faltava no wizard:
 *   wrapper, header, barra de progresso, container de cada passo,
 *   num-circle, badges, step-foot, botões, locked-msg, upload-overlay.
 *   Com esta versão, o WIZARD INTEIRO está modernizado e visualmente
 *   coeso com o resto do plugin.
 *
 *   FILOSOFIA: Cirurgia. Tokenizar todas as classes restantes (.rmbz-w-*)
 *   sem alterar nomes. JS de toggle de passos (state-* + .expanded),
 *   JS de upload-overlay dinâmico (criado com innerHTML em runtime),
 *   estrutura DOM completa — tudo intacto.
 *
 *   [MARKUP — Header]
 *     • i18n completo: title "Editar: %s" / "Criar Novo Lançamento" /
 *       "Rascunho · ID #%d" / "Novo Projecto" via printf + translators
 *       comments
 *     • Wrapper .rmbz-w-header__title novo (semântico, em vez de <div>
 *       genérico)
 *     • aria-valuemin/max/now no progressbar + role="progressbar" + aria-label
 *     • Contador "1 / 4" via printf (era concatenação directa) — pronto
 *       para tradução de formatos (alguns idiomas usam "1 de 4")
 *
 *   [CSS-TOKENIZADO — Final do wizard]
 *
 *     1. Wrapper (.rmbz-wizard-v2)
 *        • font-family: var(--rmbz-font-body) (era system font hardcoded)
 *        • Comentário sobre max-width 1200px movido para inline (clareza)
 *
 *     2. Header (.rmbz-w-header)
 *        • Gradiente dourado preservado (intencional — identidade visual
 *          de "ainda em rascunho/edição")
 *        • border-left dourado via var(--rmbz-brand)
 *        • Padding e gap via tokens
 *        • h1 em Bricolage Grotesque bold (era system font)
 *        • small em --rmbz-brand com font-weight medium (era 500 hardcoded)
 *        • !important nos colors mantido — alguns temas WP forçam cores
 *          em h1/small herdadas do tema
 *
 *     3. Progresso (.rmbz-w-progress / .rmbz-w-segs / .rmbz-w-seg)
 *        • Segmentos com transição smooth para o estado on
 *        • on usa --rmbz-brand (era #f0b90b hardcoded)
 *        • Contador .rmbz-w-num em mono tabular-nums (alinhamento perfeito
 *          de "1 / 4")
 *
 *     4. Step container (.rmbz-w-step)
 *        • background: --rmbz-bg-elevated (era #141414 — diferente do
 *          --rmbz-bg-elevated, mas o token é a versão correcta)
 *        • Estados: state-done (opacity), state-current (border + shadow
 *          dourado), state-locked (opacity + pointer-events)
 *        • Step-head agora com hover --rmbz-bg-hover (feedback subtil)
 *        • transition mais semântica (border-color e box-shadow apenas)
 *
 *     5. Num-circle (.rmbz-w-num-circle)
 *        • Tamanho aumentado de 28px para 30px (mais legível em mobile)
 *        • Bricolage Grotesque bold (era system font)
 *        • state-done: --rmbz-success com texto preto
 *        • state-current: --rmbz-brand com --rmbz-text-on-brand
 *        • Transition adicionado para mudanças de estado animadas
 *
 *     6. Step-title (.rmbz-w-step-title)
 *        • strong em --rmbz-text-primary semibold
 *        • small em --rmbz-text-muted (era #888 hardcoded)
 *        • state-done .small: --rmbz-success
 *        • state-current .small: --rmbz-text-secondary (era #aaa)
 *
 *     7. Badges (.badge-done / .badge-current / .badge-locked)
 *        • badge-done: --rmbz-success-soft + --rmbz-success
 *        • badge-current: --rmbz-brand-soft + --rmbz-brand
 *        • badge-locked: --rmbz-text-disabled
 *        • Border-radius pill consistente
 *
 *     8. Step body (.rmbz-w-step-body)
 *        • padding via tokens
 *        • display:none/block preservado para JS de toggle
 *
 *     9. Step foot (.rmbz-w-step-foot)
 *        • border-top em --rmbz-border-subtle
 *        • Padding e gap via tokens
 *
 *    10. Status (.rmbz-w-status)
 *        • Cor e tamanho via tokens
 *
 *    11. Botões (.rmbz-w-btn / .primary / .outline-small / .big)
 *        • primary com box-shadow brand-sm + hover translateY(-1px) +
 *          shadow brand-md (consistente com rmbz-btn--primary do design system)
 *        • Disabled state com tokens
 *        • Big (modificador para finalize) com padding e font-size maiores
 *        • outline-small com border-subtle, hover dourado
 *
 *    12. Locked-msg (.rmbz-w-locked-msg)
 *        • Padding e cor via tokens, mantém italic
 *
 *    13. Upload-overlay (#rmbz-upload-overlay) — criado dinamicamente pelo JS
 *        • backdrop-filter blur(4px) novo (consistente com modais)
 *        • Box em --rmbz-bg-elevated com border --rmbz-brand
 *        • Spinner com tokens, mantém animação rmbz-spin existente
 *        • Texto progresso em font mono tabular-nums
 *
 *   [PRESERVADO 100%]
 *     • Todas as classes .rmbz-w-* mantidas (state-done/current/locked,
 *       expanded, on, big, primary, outline-small, badge-done/current/locked)
 *     • Estrutura DOM 100% — JS de toggle (.expanded), JS de upload-overlay
 *       criado em runtime, tudo continua a funcionar
 *     • IDs JS-deps inalterados em todo o wizard
 *     • Cores intencionais preservadas:
 *       - #000 no num-circle done (preto sobre verde — sempre fixo)
 *       - #ffffff em botões danger hover (branco sobre vermelho — sempre fixo)
 *
 *   [FASE C COMPLETA] Com esta versão, o wizard de distribuição digital
 *   está 100% modernizado:
 *     1.5.49 ✓ Passo 1 (Detalhes do lançamento)
 *     1.5.50 ✓ Passo 3 (Faixas de áudio)
 *     1.5.51 ✓ Passo 4 (Capa do álbum) + Danger Zone
 *     1.5.52 ✓ Header, progresso, steps, botões, upload-overlay
 *
 *   [PROJECTO COMPLETO] Com Fases A (1.5.31-45), B (1.5.46-48) e C
 *   (1.5.49-52) concluídas, todo o plugin user-facing está modernizado:
 *     • Login + Recuperação + Registo (1.5.43)
 *     • Dashboard inteiro (1.5.32-45)
 *     • Modais críticos (depósito, levantamento, perfil, OTP, notif, etc)
 *     • Distribuição digital completa (track-editor, return panel,
 *       platforms, wizard 4 passos)
 *
 *   [PENDENTE — FORA DESTE PROJECTO]
 *     • Área de admin (lista de releases, financial review, etc)
 *     • Área de coupons admin
 *     • Páginas de exclusivos (admin views)
 *
 *   Recomendação forte: TESTAR EM PRODUÇÃO antes de prosseguir com
 *   admin. Foram 21 versões em sequência sem teste real (1.5.32→1.5.52).
 *
 * == 1.5.51 (2026-04) — 🖼️ Wizard Passo 4 + Danger Zone modernizados (Fase C/3) ==
 *   Passo da capa do álbum (upload de imagem 1400×1400 com validação
 *   cliente) + zona de perigo (apagar release) que vive no fim do form.
 *
 *   FILOSOFIA: Cirurgia. NÃO TOCAR no JS de validação Image()/onload
 *   (que mede dimensões via canvas) nem no upload de chunks com
 *   rmbz_upload_release_cover. Tokenizar markup e CSS, manter IDs.
 *
 *   [MARKUP — Passo 4]
 *     • Strings i18n: __() / esc_html_e() em ~15 strings (título, status,
 *       4 checks, 4 labels meta, hints, aviso, botões)
 *     • Plurais correctos via printf com translators comments
 *     • Ícone 🔒 dos badges "locked" → SVG cadeado inline
 *     • Ícone 🖼️ grande do placeholder vazio → SVG ícone imagem
 *       (.rmbz-cover-placeholder__icon)
 *     • Ícone 📁 do botão picker → SVG folder inline (preserva texto
 *       "Seleccionar ficheiro" — JS só toca em #rmbz-cover-picker-text)
 *     • Inline style="font-size:48px" do placeholder → classe semântica
 *     • alt text dos <img> via __() para tradução
 *     • Aviso "Atenção..." com printf + strong em variável (era HTML
 *       direto com <strong> embutido na string — agora i18n-friendly)
 *     • Botão finalize: emojis 🚀 e ⟲ MANTIDOS no label porque o JS na
 *       linha 1572 reescreve btn.innerText com o texto original ao
 *       restaurar após erro. Trocar para SVG criaria inconsistência
 *       PHP↔JS após primeira tentativa falhada (documentado no código).
 *
 *   [MARKUP — Danger Zone]
 *     • Ícone 🗑️ → SVG trash inline com aria-hidden
 *     • Mensagem do confirm() via __() + esc_js() — pronto para tradução
 *     • role="button" no <a> (acessibilidade)
 *
 *   [CSS-TOKENIZADO]
 *     • .rmbz-cover-preview: borders e cores via tokens (--rmbz-border-subtle
 *       quando vazio, --rmbz-success quando válida, --rmbz-danger quando erro)
 *     • .rmbz-cover-placeholder: agora com __icon (SVG) sub-elemento
 *     • .rmbz-cover-info h5: Bricolage Grotesque semi-bold (era system)
 *     • .rmbz-cover-checks li.pass/.fail: --rmbz-success / --rmbz-danger
 *     • .rmbz-cover-checks .dot disabled: --rmbz-text-disabled (eram #555/cinza)
 *     • .rmbz-cover-meta strong: agora font mono tabular-nums — alinha
 *       perfeitamente "1456 KB" / "3000 × 3000 px" / "image/jpeg"
 *     • .rmbz-cover-picker: --rmbz-brand-soft / --rmbz-brand (3 estados:
 *       normal, uploading com opacity, success com verde)
 *     • .rmbz-w-warn: --rmbz-warning-soft (era rgba dourado, agora
 *       laranja warning consistente com avisos do resto do plugin)
 *     • .rmbz-w-danger-zone p: uppercase tracking-wider (visual mais
 *       formal, igual aos titles de section do design system)
 *     • .rmbz-w-btn-danger: text-decoration:none com !important (alguns
 *       temas WordPress forçam underline em <a>), agora display:inline-flex
 *       para acomodar SVG + texto bem alinhados
 *
 *   [PRESERVADO 100%]
 *     • IDs JS-deps (10): rmbz-cover-preview, rmbz-cover-img,
 *       rmbz-cover-checks, rmbz-cover-filemeta, cv-name, cv-size,
 *       cv-dims, cv-type, rmbz-cover-picker-label, rmbz-cover-input,
 *       rmbz-cover-picker-text, rmbz-w-status-4, btn-finalize-release
 *     • name="cover_art_file" (servidor depende)
 *     • Atributos data-check em <li> (lidos pelo JS para toggle pass/fail)
 *     • Estrutura <label> envolvendo <input type="file"> (padrão picker)
 *     • Lógica $is_returned para botão "submeter correcções"
 *     • Lógica $btn_disabled (release devolvido sempre activo, novo só
 *       activo quando tem cover_image)
 *     • Endpoint AJAX rmbz_upload_release_cover inalterado
 *     • Endpoint admin-post.php?action=onemz_delete_release inalterado
 *     • Nonce rmbz_delete_release_nonce intacto
 *     • Estado JS .uploading / .success / .has-image / .has-error
 *       continua a funcionar zero alterações
 *
 *   [PRÓXIMO PASSO FASE C] 1.5.52: Header do wizard, barra de progresso,
 *   step container/head/circle, botões. Polish geral final que fecha
 *   a Fase C com o wizard 100% modernizado.
 *
 * == 1.5.50 (2026-04) — 🎶 Wizard Passo 3 modernizado (Fase C/2) ==
 *   Passo das faixas (audio uploads, lista com reorder, edição metadata).
 *   O passo mais complexo do wizard. Tem upload em chunks, drag-and-drop,
 *   integração com track-editor (já modernizado em 1.5.46), validação
 *   de meta com helper, animações de reorder (flash success).
 *
 *   FILOSOFIA: NÃO TOCAR no JS. Cirurgia apenas no markup e CSS.
 *   O JS de upload de chunks (1255-1378) é particularmente sensível
 *   e está debugado vezes sem conta. Mexer sem necessidade seria
 *   pedir regressões no fluxo crítico de carregar áudio.
 *
 *   [MARKUP]
 *     • Strings i18n: __() / esc_html_e() em ~12 strings (título, status,
 *       botões, hints, mensagens dinâmicas)
 *     • Plurais via _n() para "X faixa(s)" / "X colaborador(es)" /
 *       "X faixa(s) prontas" — agora ortograficamente correcto em PT
 *     • Ícone 🔒 dos badges "locked" → SVG cadeado inline
 *     • Ícone 🎵 grande do empty state → SVG nota musical (corrente)
 *     • Setas ↑↓ dos botões reorder → SVGs limpos com aria-label
 *     • Inline styles removidos:
 *       - <div style="font-size:36px"> → .rmbz-w-empty__icon
 *       - <small style="color:#666"> → .rmbz-w-add-more-row__hint
 *       - <small style="color:#e67e22"> → .rmbz-w-add-more-row__warn
 *       - <span style="color:#2ecc71"> no status → .rmbz-text-success
 *       - <div style="opacity:0.7"> → modifier --full
 *     • Tipos do release ("Single"/"EP"/"Álbum") via __() para tradução
 *     • title="..." dos botões reorder agora i18n + aria-label adicional
 *
 *   [CSS-TOKENIZADO] Classes do passo 3 reescritas com tokens:
 *     • .rmbz-w-empty: agora com sub-elementos __icon (SVG) e __text
 *     • .rmbz-w-track: hover suave com border-color (era estático)
 *     • .rmbz-w-track-num: font mono tabular-nums (alinhamento perfeito
 *       em listas com 10+ faixas)
 *     • .rmbz-w-track-info strong: Bricolage Grotesque semi-bold
 *     • .rmbz-w-track.is-incomplete/.is-complete: usa --rmbz-danger /
 *       --rmbz-success em vez de hex hardcoded
 *     • .rmbz-w-track-missing: --rmbz-warning (laranja em vez de hex)
 *     • .rmbz-w-track-ok: --rmbz-success
 *     • .rmbz-w-btn.outline-small.is-warning: --rmbz-danger-soft
 *     • .rmbz-w-reorder-btn: ligeiramente maior (28x20 era 26x18) para
 *       acomodar SVG. Hover mantém comportamento (dourado preenchido).
 *     • Animação flash-success usa --rmbz-success-soft (era rgba inline)
 *     • Variantes novas: __hint (mono cinza), __warn (warning), --full
 *       (centrado quando lim atingido)
 *
 *   [PRESERVADO 100%]
 *     • IDs JS-deps: real_track_upload (input file hidden), tracks-list-container,
 *       btn-confirm-step-3, rmbz-w-status-3
 *     • Atributos data-track-id, data-direction, data-release-id (usados
 *       pelo JS para query selectors e endpoints AJAX)
 *     • Classes funcionais .is-incomplete/.is-complete (lidas pelo JS de
 *       validação antes de gastar trip ao server na linha 1365)
 *     • Classes de animação .is-moving / .flash-success
 *     • onclick="document.getElementById('real_track_upload').click()"
 *       em 4 sítios (4 botões diferentes que abrem o file picker)
 *     • Estrutura DOM 100% — JS de upload, reorder, edit modal continua
 *       a funcionar zero alterações
 *     • [META-CHECK-2] helper rmbz_track_validate_metadata() inalterado
 *     • [REORDER-2] lógica de is_first/is_last preservada
 *     • [WIZ-RT8] comentário sobre badge antigo movido para Passo 1
 *
 *   [PRÓXIMO PASSO FASE C] 1.5.51: Passo 4 (Capa) — upload de imagem
 *   1400x1400 com validação cliente. Depois 1.5.52: header, progresso,
 *   danger zone, polish geral.
 *
 * == 1.5.49 (2026-04) — 📝 Wizard Passo 1 modernizado (Fase C/1) ==
 *   Início da Fase C — modernização do wizard em si. Atacamos o passo
 *   mais simples primeiro (Detalhes do lançamento) para validar o
 *   padrão antes dos passos mais complexos.
 *
 *   FILOSOFIA: Cirurgia, não rewrite. As classes .rmbz-w-row,
 *   .rmbz-w-field, .rmbz-w-locked-input, .rmbz-w-type-cards,
 *   .rmbz-w-type-card, .rmbz-w-type-hint, .rmbz-w-advanced são
 *   tokenizadas mas mantêm os mesmos nomes — o JS continua a
 *   funcionar zero alterações.
 *
 *   [MARKUP]
 *     • Strings i18n: __() / esc_html_e() / esc_attr_e() em todos os
 *       textos visíveis (8 strings: título, labels, options, hints, botão)
 *     • Plurais via sprintf com translators comments para mensagens
 *       dinâmicas ("Já tem X faixa(s)")
 *     • Cadeado emoji 🔒 do campo "Artista principal" → SVG inline
 *       (.rmbz-w-locked-icon, currentColor)
 *     • aria-hidden="true" nos emojis decorativos (🎵 💿 📀)
 *     • aria-readonly="true" no input bloqueado
 *     • aria-label no <span> do cadeado para leitores de ecrã
 *     • aria-label no radiogroup
 *     • <label for="..."> emparelhado com inputs/selects (acessibilidade)
 *     • IDs descritivos em campos sem id JS-dep (rmbz-w-language,
 *       rmbz-w-genre, rmbz-w-release-date, etc) — facilita debug
 *     • maxlength em campos de texto (100 título, 80 label/local)
 *     • Aviso "tem X faixas" antes era <span style="color:#e67e22"> →
 *       agora classe .rmbz-w-type-hint--warn (--rmbz-warning)
 *
 *   [CSS-TOKENIZADO] Classes do passo 1 reescritas com tokens. Mudanças
 *   visuais (mantendo classes intactas):
 *     • .rmbz-w-field input/select: focus dourado com box-shadow ring
 *       (consistente com .rmbz-input do design system)
 *     • .rmbz-w-locked-input: cadeado SVG via currentColor + classe
 *       semântica .rmbz-w-locked-icon. Backwards-compat com markup
 *       antigo (<span> directo) via :not() selector.
 *     • .rmbz-w-type-card: hover com --rmbz-bg-hover, selected com
 *       --rmbz-brand-soft + --rmbz-brand border + glow shadow
 *     • .rmbz-w-type-card strong: Bricolage Grotesque bold (era
 *       system font)
 *     • .rmbz-w-type-card.selected strong: cor dourada (estado activo
 *       claramente sinalizado)
 *     • .rmbz-w-advanced summary: agora com seta ▸ animada que roda
 *       90° quando aberto (consistente com .rmbz-s2-region do passo 2)
 *
 *   [PRESERVADO 100%]
 *     • IDs JS-deps: release_title, btn-save-step-1, rmbz-w-status-1,
 *       rmbz-type-hint
 *     • Names POST: release_title, main_artist, language, primary_genre,
 *       release_type, release_date, label_name, recording_location,
 *       upc_code (UPC continua hidden — só admin/DSPs vêem)
 *     • Lógica de tracks_count vs release_type (validações dinâmicas)
 *     • [BUG #3] current_time() para min de release_date (timezone WP)
 *     • [WIZ-RT2] Comentários sobre limites alinhados com Spotify
 *     • Lista de 9 géneros intacta
 *     • Estado do botão (texto muda entre "Atualizar" / "Guardar e Continuar")
 *
 *   [PRÓXIMO PASSO FASE C] 1.5.50: Passo 3 (Faixas) — o mais complexo,
 *   tem upload de áudio com chunks, reorder, edição via track-editor
 *   (já modernizado em 1.5.46).
 *
 * == 1.5.48 (2026-04) — 🌍 Módulo de Plataformas modernizado (Fase B/3) ==
 *   Passo 2 do wizard onde o artista escolhe (a) territórios para
 *   distribuição (worldwide / except / selected), (b) DSPs lojas e
 *   plataformas em 3 tiers (Spotify, Apple, etc.), (c) serviços
 *   exclusivos opcionais (YouTube Content ID, Facebook, TikTok).
 *
 *   Refeito com tokens do design system. Sem JS rewrite — só CSS e
 *   pequenas limpezas de inline styles. Risco médio.
 *
 *   FILOSOFIA: Cirurgia, não rewrite. Função tem 527 linhas com 224
 *   linhas de JS (regions toggle, search, save AJAX) bem testadas.
 *   Tocar no JS sem necessidade seria pedir regressões. Em vez disso,
 *   tokenizamos os 80 linhas de CSS e limpamos 5 blocos pequenos com
 *   inline styles que se tornaram dívida ao longo do tempo.
 *
 *   [PHP — pequenas limpezas, mais limpas e i18n-ready]
 *     • $status_badge closure: HTML inline com cores hardcoded → classes
 *       .rmbz-s2-status-badge--{approved,rejected,requested,none}.
 *       Remove 4 cópias de "padding:3px 10px; border-radius:10px..." inline
 *     • Mensagem "Salve o passo 1 primeiro" → .rmbz-s2-locked-msg + i18n
 *     • Header de "Lojas e plataformas" com div inline 4-property →
 *       .rmbz-s2-section-head (semântico, reutilizável)
 *     • <strong style="color:#f0b90b"> em descrições → .rmbz-s2-strong-brand
 *     • <strong style="color:#f0b90b"> + <small style="color:#777"> no
 *       opt-in dos serviços exclusivos → .rmbz-s2-optin-strong + .rmbz-s2-optin-hint
 *     • <div style="flex:1; min-width:200px"> e <div style="display:flex...">
 *       no excl-head → .rmbz-s2-excl-meta + .rmbz-s2-excl-titlerow
 *     • Várias strings ("Todas", "Nenhuma", "Só essenciais", "Ler aviso"...)
 *       agora via __() / esc_html__() / esc_html_e() — pronto para tradução
 *
 *   [CSS-TOKENIZADO] 80 linhas reescritas com tokens. Mudanças visuais
 *   (mantendo classes intactas):
 *     • .rmbz-s2-cards (territórios): hover dourado subtil em todos
 *       os cartões (não só no seleccionado)
 *     • .rmbz-s2-countries-mode-hint: usa --rmbz-brand-soft + border-left
 *       (mantém impacto de "atenção, modo diferente")
 *     • .rmbz-s2-countries-search input: focus dourado com box-shadow ring
 *       (consistente com .rmbz-input do DS)
 *     • .tiny-chip: pill border-radius com transição suave de cores
 *     • .rmbz-s2-region: padrão <details> nativo (preservado), summary
 *       colapsável com seta animada
 *     • .rmbz-s2-region .region-count: font mono com tabular-nums
 *       ("12/45" alinhado perfeitamente)
 *     • .country-item .country-code: font mono small (códigos ISO
 *       legíveis em qualquer densidade)
 *     • .rmbz-s2-chips .chip.active: usa --rmbz-brand + --rmbz-text-on-brand
 *       (preto sobre dourado, igual aos botões primary)
 *     • .rmbz-s2-tier-h: usa --tc (var inline) com fallback para --rmbz-text-muted
 *     • .rmbz-s2-excl: --rmbz-danger-soft background (era rgba hardcoded)
 *     • .rmbz-s2-notice: --rmbz-leading-loose para texto longo legal
 *     • .rmbz-s2-status-badge: 4 variantes consistentes com badges do DS
 *     • .rmbz-s2-decision: usa --rmbz-success-soft / --rmbz-danger-soft
 *
 *   [PRESERVADO 100%]
 *     • Funções globais JS: rmbzDspToggleAll, rmbzDspSelectTier,
 *       rmbzRegionToggle, rmbzRegionToggleAll — onclick inline mantido
 *     • IDs JS-deps: rmbz-dsp-count-num, rmbz-countries-wrap,
 *       rmbz-countries-hint, rmbz-countries-search, btn-save-step-2,
 *       rmbz-w-status-2
 *     • Endpoint AJAX rmbz_save_release_platforms inalterado
 *     • Nonce rmbz_distro_nonce inalterado
 *     • Estrutura DOM 100% preservada — JS continua a funcionar zero
 *       alterações necessárias
 *     • Lógica de tier colors (--tc inline) preservada — Tier 1 dourado,
 *       Tier 2 azul, Tier 3 roxo (mantém identidade visual da hierarquia)
 *     • Lista de territórios, regiões, países — código de geo intacto
 *
 *   [FASE B COMPLETA] Com esta versão, todas as 3 peças relacionadas ao
 *   wizard estão modernizadas:
 *     1.5.46 ✓ Track Editor (modal de metadata por faixa)
 *     1.5.47 ✓ Painel de Devolução (alerta vermelho top do wizard)
 *     1.5.48 ✓ Módulo de Plataformas (passo 2 do wizard)
 *
 *   PRÓXIMO: Fase C — wizard em si (passos 1, 3, 4 + polish geral)
 *   em 4 mini-versões (1.5.49 → 1.5.52).
 *
 * == 1.5.47 (2026-04) — ⟲ Painel de Devolução modernizado (Fase B/2) ==
 *   Painel grande de alerta vermelho que aparece no topo do wizard quando
 *   um lançamento foi devolvido pelo admin para correcção. É a primeira
 *   coisa que o artista vê — tem de gritar "atenção" mas profissionalmente.
 *
 *   Refeito com tokens do design system. Sem JS (puro display) — risco
 *   muito baixo. Estrutura HTML 100% preservada (todas as classes
 *   .rmbz-return-*).
 *
 *   FILOSOFIA: Manter o impacto visual de "alerta vermelho" mas trocar
 *   cores hardcoded por tokens, emojis por SVG, e tipografia por
 *   Bricolage Grotesque + Geist. Coerência visual com o resto.
 *
 *   [VISUAL]
 *     • .rmbz-return-panel: gradient vermelho subtil + barra vermelha
 *       sólida à esquerda (4px) — identidade clara de "alerta sem ler"
 *     • .rmbz-return-icon: substituído emoji ⟲ por SVG inline (seta de
 *       voltar circular), círculo com glow shadow danger
 *     • .rmbz-return-title h2: Bricolage Grotesque semi-bold em
 *       --rmbz-danger
 *     • .rmbz-return-deadline: 3 estados tokenizados:
 *         - normal: warning soft + warning border (amarelo subtil)
 *         - urgent: warning forte + warning border
 *         - overdue: danger soft + danger border + danger text
 *       Ícone relógio SVG inline em vez do emoji ⏰
 *     • .rmbz-return-group-title: uppercase tracking-wider em --rmbz-brand
 *       (padrão consistente com section titles do resto)
 *     • .rmbz-return-note: caixa azul info subtil (--rmbz-info-soft +
 *       --rmbz-info border), com ícone chat SVG e título uppercase
 *       tracking-wider. Substituído emoji 💬.
 *     • .rmbz-return-actions: instruções finais com strong dourado
 *
 *   [I18N]
 *     • Todas as strings agora via __() / esc_html__() / esc_html_e()
 *     • Plurais via _n() ("X dia" vs "X dias")
 *     • printf com translators comments para tradutores
 *
 *   [PRESERVADO]
 *     • Todas as 9 classes .rmbz-return-* mantidas
 *     • Lógica de cálculo de days_left, overdue, by_group intacta
 *     • Função rmbz_distro_get_return_data() inalterada (continua a
 *       devolver problems/note/issued_by/issued_at)
 *     • Não há JS para preservar — só CSS + markup
 *
 *   [PRÓXIMO PASSO FASE B] 1.5.48: Módulo de plataformas (passo 2 do wizard)
 *
 * == 1.5.46 (2026-04) — 🎵 Track Editor modal modernizado (Fase B/1) ==
 *   Modal de edição de metadata de faixa (#rmbz-track-modal). Aberto pelo
 *   wizard ao clicar em "editar" numa faixa. Tinha CSS próprio com cores
 *   hardcoded (#141414, #f0b90b, #888...) que destoava do resto do plugin
 *   modernizado. Refeito com tokens do design system, preservando 100%
 *   da estrutura HTML e do JS.
 *
 *   FILOSOFIA: Cirurgia, não rewrite. Este modal tem 4 tabs (Básico,
 *   Créditos, Splits, Avançado), pie chart de splits, listas dinâmicas
 *   de colaboradores/songwriters/splits, validação cliente, AJAX load/save,
 *   manipulação por IDs (~25 elementos com IDs JS-dependentes), 3 funções
 *   globais (rtmAddCollab/Songwriter/Split). Reescrever isto seria um
 *   pesadelo de regressão. Em vez disso, mantemos a estrutura intacta e
 *   só tokenizamos o CSS para herdar o visual do design system.
 *
 *   [CSS-TOKENIZADO] 98 linhas de CSS (linhas 405-503 do ficheiro)
 *   reescritas com tokens. Mudanças de visual (mantendo classes intactas):
 *
 *     • .rmbz-tm-overlay: backdrop-filter blur(4px) (era opaco), animação
 *       fade-in suave. background rgba(0,0,0,.85) → .75 (mais subtil)
 *     • .rmbz-tm-dialog: barra dourada no topo (igual aos auth-card e
 *       feature-card — identidade visual coerente), animação spring pop
 *       em vez de aparecer abrupto, shadow do design system
 *     • .rmbz-tm-head h2: Bricolage Grotesque semi-bold (era system font)
 *     • .rmbz-tm-tabs: visual igual às tabs do dashboard (1.5.41) —
 *       sublinhado animado com transform:scaleX + ease-spring (em vez do
 *       border-bottom direct), hover responsivo
 *     • .rmbz-tm-pane: animação fade-in suave ao trocar de tab
 *     • Inputs/selects/textarea: focus dourado com box-shadow ring
 *       (era apenas border colorido), tokens em todas as cores
 *     • .rmbz-tm-duration: fonte mono dourada com tabular-nums
 *       (alinhamento perfeito de "3:24")
 *     • .rmbz-tm-pie: usa --rmbz-success/--rmbz-danger em vez de
 *       #2ecc71/#e74c3c hardcoded, font mono no número
 *     • .rmbz-tm-chip: padrão pill consistente com .rmbz-radio-pill
 *     • .rmbz-tm-foot: fundo subtil var(--rmbz-bg-input) para separar
 *       claramente do body, border-radius arredondado nos cantos inferiores
 *     • .rmbz-tm-btn.primary: shadow-brand-sm + hover translateY(-1px)
 *       (consistente com rmbz-btn--primary do design system)
 *
 *   [PRESERVADO 100%]
 *     • TODAS as 25+ classes .rmbz-tm-* mantidas com mesmos nomes
 *     • TODOS os IDs preservados (rtm-title, rtm-isrc, rtm-explicit,
 *       rtm-instrumental, rtm-language, rtm-duration-display, rtm-collab-list,
 *       rtm-sw-list, rtm-has-lyrics, rtm-lyrics-wrap, rtm-lyrics,
 *       rtm-prev-released, rtm-prev-wrap, rtm-original-isrc, rtm-is-cover,
 *       rtm-cover-wrap, rtm-cover-title, rtm-cover-composer, rtm-splits-pie,
 *       rtm-splits-warn, rtm-splits-list, rtm-preview-start, rtm-moods,
 *       rtm-status, rtm-save, rtm-track-id, rtm-release-id)
 *     • Funções globais (rtmAddCollab, rtmAddSongwriter, rtmAddSplit)
 *       continuam a funcionar — onclick inline preservado
 *     • JS de open/close/load/save/populate intacto (331 linhas)
 *     • Endpoints AJAX (rmbz_track_editor_load, rmbz_track_editor_save)
 *       e nonce rmbz_distro_nonce inalterados
 *     • Estrutura DOM 100% preservada — JS continua a funcionar zero
 *       alterações necessárias
 *
 *   [PRÓXIMOS PASSOS DA FASE B]
 *     1.5.47: Painel de devolução (distribution-return-workflow.php parte UI)
 *     1.5.48: Módulo de plataformas (passo 2 do wizard)
 *     Depois Fase C: wizard em si (passos 1, 3, 4 + polish)
 *
 * == 1.5.45 (2026-04) — 👤 Modal de Perfil refeito (último elemento antigo) ==
 *   O modal #profile-modal em user-perfil.php era o último elemento do
 *   fluxo principal do utilizador a usar markup pré-DS. Aberto pelo
 *   botão "Perfil" do header (modernizado em 1.5.42), mas o conteúdo
 *   ainda destoava — borda #333, inputs com 8 propriedades inline,
 *   botão dourado hardcoded. Refeito com design system.
 *
 *   FILOSOFIA: Manter os 2 estados do modal (normal + OTP de verificação
 *   de email) com semântica idêntica ao que existia, mas usando
 *   componentes do DS para visual consistente com o resto do plugin.
 *
 *   [2 MODAIS]
 *
 *   1. profile-modal (caminho normal)
 *      • Form com 3 campos: nome (readonly), email, província
 *      • Email + província só editáveis se tiver subscrição activa
 *      • Sem subscrição → rmbz_alert variant warning + botão variant locked
 *      • Botão "Salvar e verificar e-mail" primary com ícone save SVG
 *      • Loading-on-submit automático
 *
 *   2. profile-otp-modal (apenas se $is_pending_otp)
 *      • Aparece quando utilizador acabou de submeter alteração de email
 *        ($_SESSION['rmbz_verify_email_pending'])
 *      • Ícone email SVG centrado
 *      • Input do código 6 dígitos: mono dourado central + letter-spacing
 *        0.5em + autocomplete="one-time-code" (iOS sugere automaticamente
 *        ao receber a SMS)
 *      • Botão primary "Validar E-mail" com ícone check
 *      • Form secundário "Cancelar Alteração" (envia cancel_otp=1)
 *      • Abre AUTOMATICAMENTE no DOMContentLoaded via window.rmbz.modal.open()
 *
 *   [REFACTOR]
 *      ANTES: 80 linhas com inline styles, !important, gradientes hardcoded
 *      DEPOIS: usando rmbz_modal/rmbz_field/rmbz_btn/rmbz_alert + tokens
 *      Resultado mais maintível e visualmente coeso com o resto do plugin
 *
 *   [CONTRATOS PRESERVADOS]
 *      • Forms: rmbz_update_profile_request, rmbz_verify_profile_otp
 *      • Nonces: rmbz_update_profile_nonce, rmbz_verify_profile_otp_nonce
 *      • Campos POST: user_email, user_provincia, email_otp, cancel_otp
 *      • IDs: profile-modal, profile-otp-modal (botão Perfil do header
 *        continua a abrir correctamente via data-rmbz-modal-open)
 *
 *   [LÓGICA ESPECIAL PRESERVADA]
 *      • Email "@1mz.local" (placeholder do registo) → mostra vazio para
 *        utilizador inserir o seu email real
 *      • Província lê de extra_data.provincia OU fallback user_provincia
 *      • Nome artístico SEMPRE readonly + hint "imutável após registo"
 *      • Lista de 12 províncias intacta (mesma do registo)
 *      • Sem subscrição → email/província bloqueados com aviso warning
 *
 *   [ACCESSIBILITY]
 *      • Inputs com inputmode="numeric" para teclado mobile
 *      • autocomplete="email" e autocomplete="one-time-code"
 *      • aria-label nos botões bloqueados
 *      • Pattern \d{6} validado client-side no OTP
 *
 *   [DASHBOARD-COMPLETO] Com esta versão, TODA a área do utilizador
 *      (login, dashboard, modais, perfil) usa o design system. Único
 *      restante: wizard de distribuição digital (área isolada).
 *
 * == 1.5.44 (2026-04) — 🧹 Dashboard 100%: 2 modais antigos refeitos ==
 *   Os 2 últimos modais do dashboard que ainda usavam markup pré-DS:
 *   "Concluir Depósito Pendente" e "Motivo da Rejeição". Ambos com o
 *   padrão frágil style.display='flex' que causou o bug histórico do
 *   × não fechar (1.5.32). Refeitos com rmbz_modal() para fechar o
 *   dashboard 100% consistente.
 *
 *   [PENDING-DEPOSIT-MODAL]
 *     ANTES: <div class="tickets-modal-overlay"> com 4 inline styles,
 *            close via getElementById().style.display='none', conteúdo
 *            estruturado em <p> aninhados com cores hardcoded.
 *     DEPOIS: rmbz_modal() com body em 3 secções:
 *       1. Ícone hourglass SVG + texto introdutório
 *       2. Cartão de detalhes do pedido (3 metas: valor/método/ref)
 *          com layout justify-between, dividers tracejados subtis
 *       3. Cartão destacado "Número Oficial 1MZ" com font mono em verde
 *          (success), letter-spacing 0.05em, font-variant-numeric
 *          tabular-nums (alinhamento perfeito ao mostrar números)
 *       + footer com botão ghost "Fechar e tentar mais tarde"
 *     IDs internos preservados: p-dep-amount, p-dep-method, p-dep-trans,
 *       p-dep-number — JS continua a injectar valores via .innerText.
 *     JS opener actualizado: window.rmbz.modal.open() em vez de
 *       depModal.style.display='flex' (evita o bug do × não fechar).
 *     Fallback gracioso se DS não carregar.
 *
 *   [REJECTION-MODAL]
 *     ANTES: <div class="tickets-modal-overlay"> com inline styles,
 *            <p id="rejection-reason-text"> com cor #ccc + background
 *            #111 hardcoded, close inline.
 *     DEPOIS: rmbz_modal() com body em 2 partes:
 *       1. Ícone alerta circular SVG (∅ vermelho)
 *       2. <p id="rejection-reason-text"> com tokens (--rmbz-bg-input,
 *          --rmbz-text-secondary, --rmbz-leading-relaxed) — JS continua
 *          a injectar texto da rejeição via .innerText
 *       + footer com botão ghost "Fechar"
 *     ID interno #rejection-reason-text preservado.
 *     JS opener actualizado: window.rmbz.modal.open() em vez de
 *       reasonModal.style.display='flex'.
 *
 *   [TRIGGERS] Botões disparadores .rmbz-view-deposit-btn (com data-amount,
 *     data-method, data-trans) e .rmbz-view-reason-btn (com data-reason)
 *     INALTERADOS — só os handlers JS foram modernizados.
 *
 *   [DASHBOARD-COMPLETO] Com esta versão, TODO o dashboard do artista
 *     usa o design system: header (1.5.42), tabs (1.5.41), 4 cartões
 *     principais (1.5.39), CTAs Depositar/Levantar (1.5.37+38), modais
 *     de Depósito/Levantamento (1.5.32+36), VIP/Pacotes (1.5.40), e
 *     agora estes 2 modais auxiliares. Zero markup antigo no dashboard.
 *
 *   PENDENTE NOUTRAS ÁREAS: profile-modal (em user-perfil.php) e o
 *     wizard de distribuição digital (área-artista). Login + Recuperação
 *     já feitos na 1.5.43.
 *
 * == 1.5.43 (2026-04) — 🔐 Página de Login + Recuperação de PIN refeita ==
 *   A página de entrada do sistema (rmbz_render_login_view) era a maior
 *   relíquia do tema antigo: 24 propriedades !important só nos inputs,
 *   12 properties no botão .onemz-btn-auth, 4 forms colados sem
 *   estrutura clara, popup de mensagem com style.display='flex' inline,
 *   validação de PIN sem mono font, picker de operadora como <select>
 *   feio. Refeita inteira com design system.
 *
 *   FILOSOFIA: Esta página é a primeira impressão do sistema — quem
 *   nunca viu o 1MZ chega aqui. Tem de transmitir confiança fintech
 *   imediatamente. Bricolage Grotesque para o título, mono font para
 *   PINs/códigos (alinhamento perfeito ao digitar), barra dourada no
 *   topo do cartão para identidade, animações smooth, validações
 *   client-side ricas.
 *
 *   [CSS-NEW] 03-components.css §11 AUTH CARD:
 *     • .rmbz-auth-wrapper      — full-height centrado, padding responsivo
 *     • .rmbz-auth-card         — cartão com borda + barra dourada topo
 *     • .rmbz-auth-card__icon   — ícone SVG num quadrado dourado soft
 *     • .rmbz-auth-card__title  — Bricolage Grotesque bold, leading-tight
 *     • .rmbz-auth-card__body   — padding consistente
 *     • .rmbz-tabs--auth        — variante das tabs full-width compactas
 *     • .rmbz-auth-section      — sub-cartão dentro do auth-card (Activação VIP)
 *     • Estado dos forms: .is-active aparece com slide animation
 *     • Mobile: cartão compacto sem borda dura
 *
 *   [PHP-REFACTOR] rmbz_render_login_view() reescrita inteira:
 *     ANTES: 337 linhas com 60+ usos de !important
 *     DEPOIS: 280 linhas usando rmbz_field/rmbz_btn/rmbz_alert/rmbz_modal
 *     Resultado: -57 linhas, zero !important novos, todo o styling via tokens.
 *
 *   [4-FORMS] Cada form usa o design system mas mantém o contrato exacto:
 *
 *     1. LOGIN (action=rmbz_pin_login)
 *        • Telemóvel via rmbz_field tel + pattern + inputmode
 *        • PIN com font mono central + letter-spacing 0.5em (estilo OTP)
 *        • Botão "Entrar na Conta" primary com ícone seta + loading-on-submit
 *        • Link discreto para recover
 *
 *     2. RECOVER STEP 1 (action=rmbz_verify_recovery)
 *        • Nome + telemóvel — mesmo padrão dos campos do login
 *        • Botão primary com ícone lupa
 *        • Link "← Voltar ao Login"
 *
 *     3. RECOVER STEP 2 (action=rmbz_execute_reset)
 *        • Header verde "✓ Conta Encontrada!"
 *        • Botão WhatsApp #25D366 com SVG inline (cor oficial preservada)
 *        • Chave 6 dígitos: mono dourado central com letter-spacing 0.5em,
 *          autocomplete="one-time-code" (iOS sugere automaticamente)
 *        • Novo PIN: mono central
 *        • Botão "Guardar Novo PIN" primary com ícone chave
 *
 *     4. REGISTO (action=onemz_process_registration)
 *        • Nome + telemóvel + província + cidade + PIN — todos via rmbz_field
 *        • Sub-secção "Activação VIP" em .rmbz-auth-section:
 *            - Picker pagar/cupão via .rmbz-radio-pill (era 2 radios feios)
 *            - Se pagar: <select> de operadora (mantido por compat com onchange
 *              que actualiza número da empresa) + sender phone + radio-pill
 *              comprovativo (ID/Imagem) + inputs respectivos
 *            - Se cupão: input central com letter-spacing 0.15em
 *            - JS toggleRegMethod() preservado intacto (lógica required dinâmico)
 *        • Sub-secção "Anti-spam" em .rmbz-auth-section:
 *            - Pergunta matemática em Bricolage Grotesque grande
 *            - Hash secret '1mz_bot_secret' preservado
 *        • Botão "Confirmar Registo" primary com ícone check
 *
 *   [POPUP-MESSAGE] $_SESSION['rmbz_withdrawal_notice'] (mensagem flash):
 *     ANTES: <div> inline com style.display='flex' + close inline JS
 *     DEPOIS: rmbz_modal() com SVG icon central + variant success/danger
 *             aberto automaticamente via window.rmbz.modal.open()
 *
 *   [LOGIN-ERROR] $_SESSION['rmbz_login_error'] (banner de erro):
 *     ANTES: <div> com background:rgba(231,76,60,0.1) + border + padding
 *     DEPOIS: rmbz_alert() variant danger com title + body
 *
 *   [JS-PRESERVED] APIs globais switchAuthTab() e toggleRegMethod()
 *     mantidas com a mesma assinatura para não partir nada externo que
 *     possa chamar (raro mas seguro). switchAuthTab agora também
 *     sincroniza aria-selected nas tabs.
 *
 *   [ACCESSIBILITY]
 *     • role="tablist" + role="tab" + aria-selected nas tabs auth
 *     • role="radiogroup" + aria-label nos pickers
 *     • autocomplete="current-password" / "new-password" / "one-time-code"
 *       — browsers e password managers funcionam correctamente
 *     • inputmode="numeric" em todos os campos numéricos (mobile keyboard)
 *     • aria-label nos elementos sem label visível
 *
 *   [SECURITY-PRESERVED]
 *     • 4 nonces todos preservados (rmbz_pin_login_nonce, rmbz_recovery_nonce,
 *       rmbz_execute_reset_nonce, onemz_registration_nonce)
 *     • Anti-bot: $num1+$num2+md5+secret '1mz_bot_secret' intacto
 *     • Recovery step state via $_GET['recovery_step'] preservado
 *     • Pattern validation client-side em todos os campos numéricos
 *     • Esc_attr/esc_html/esc_url em todos os outputs dinâmicos
 *
 *   [MOBILE]
 *     • Cartão sem borda dura em viewports pequenos
 *     • Padding ajustado em <480px
 *     • Inputs com inputmode adequado para keyboard nativo
 *     • Botões full-width
 *
 * == 1.5.42 (2026-04) — 👤 Header do dashboard refeito (avatar, sino, sair) ==
 *   O header era a primeira coisa que o utilizador via mas tinha herança
 *   directa do tema antigo: gradiente customizado #1a1a1a→#222, avatar
 *   com gradient dourado próprio, botão "Perfil" com 13 propriedades
 *   !important para vencer cascade, sino com glow vermelho hardcoded,
 *   dropdown e modais de notificação inline com style="display:flex" no
 *   JS. ~100 linhas de markup espalhado.
 *
 *   FILOSOFIA: Header é o "cartão de visita" do dashboard — primeira
 *   impressão. Construir como peça do design system permite reuso no
 *   futuro (admin panel, outras áreas) e elimina o ruído visual de
 *   componentes a competir entre si.
 *
 *   [CSS-NEW] 03-components.css §10 — 6 sub-componentes:
 *     §10.1 .rmbz-header           — wrapper com layout flex responsivo
 *     §10.2 .rmbz-avatar           — círculo dourado com inicial (sm/md/lg)
 *     §10.3 .rmbz-id-pill          — pílula mono com label + valor
 *     §10.4 .rmbz-bell             — sino circular icon-button + badge
 *     §10.5 .rmbz-notif-dropdown   — popover com header + list + empty
 *     §10.6 .rmbz-notif-item       — item de notificação com indicador
 *
 *   [HEADER-COMPONENTS]
 *     • Avatar = .rmbz-avatar dourado com border ring + glow subtil
 *     • Saudação em Bricolage Grotesque semi-bold
 *     • ID em pílula com mono font + label "ID:" em uppercase
 *     • Botão "Perfil" via rmbz_btn() variant ghost-sm + ícone settings
 *     • Sino = .rmbz-bell com SVG inline + badge vermelho (.rmbz-bell__badge)
 *       com glow danger e font-variant tabular-nums (números alinhados)
 *     • Botão "Sair" via rmbz_btn() variant ghost-sm + ícone logout SVG
 *
 *   [DROPDOWN-NOTIFS]
 *     • .rmbz-notif-dropdown com pop animation (scale + translateY)
 *     • Header com título + botão "Limpar Tudo" (vermelho subtil)
 *     • Items via .rmbz-notif-item:
 *       - is-unread → fundo dourado soft + indicator visível
 *       - is-read → fundo transparente + indicator escondido
 *       - Snippet com line-clamp:2 (não overflow vertical)
 *       - Date em tabular-nums (alinhamento perfeito)
 *     • Empty state com ícone SVG inbox + texto "Caixa limpa"
 *     • Mobile: dropdown vira fixed centrado (calc 100vw - paddings)
 *
 *   [MODAIS-NOTIFS]
 *     • Reescritos com rmbz_modal() — eliminam ~15 linhas inline por modal
 *     • body em var(--rmbz-bg-input) com scroll interno se conteúdo grande
 *     • footer com rmbz_btn primary "Entendido" + data-rmbz-action="close"
 *     • Conteúdo HTML rico continua a passar por wp_kses_post()
 *     • IDs preservados (notif-modal-{id}) — JS continua a abrir por ID
 *
 *   [JS-MODERNIZADO]
 *     • toggleNotifications: usa hidden attribute + aria-expanded sync
 *     • Click outside e Escape fecham o dropdown (eram dois handlers,
 *       agora um delegated correto)
 *     • openNotification: chama window.rmbz.modal.open() em vez de
 *       style.display='flex' (com fallback se DS não carregou)
 *     • closeNotification: chama window.rmbz.modal.close() (compat)
 *     • Botão "Perfil" usa data-rmbz-modal-open="profile-modal" (padrão
 *       do design system) + handler global delegated — substitui o
 *       onclick inline com getElementById...style.display='flex'
 *
 *   [PRESERVADO] Todas as APIs JS globais (toggleNotifications,
 *     openNotification, closeNotification, clearAllNotifications)
 *     mantidas para compat. IDs JS-dependentes (notif-bell-container,
 *     notif-dropdown, notif-badge, notif-item-{id}, notif-dot-{id},
 *     notif-modal-{id}, profile-modal) intactos. Endpoints AJAX
 *     (rmbz_mark_notif_read, rmbz_clear_notifs) e nonces inalterados.
 *     URL de logout com nonce rmbz_logout_nonce preservado.
 *
 *   [REMOVIDO] CSS antigo .onemz-notif-box (substituído por
 *     .rmbz-notif-dropdown). 13 properties !important do botão Perfil
 *     (substituído por rmbz_btn ghost-sm). Bloco <style> com overrides
 *     de a / .onemz-btn (já não necessários — rmbz_btn não tem
 *     decoration por defeito).
 *
 * == 1.5.41 (2026-04) — 🗂️ Tabs do dashboard modernizadas (.rmbz-tabs) ==
 *   As 4 tabs principais (Envios, Vídeos, Lançamentos, Finanças) eram
 *   underline tabs simples mas com CSS espalhado por 3 sítios:
 *   frontend.css (base), inline em my-account-page.php (overrides com
 *   !important), e mobile em frontend.css. Os emojis 🏆 🚀 estavam
 *   misturados no texto. Modernizado para uniformidade e polish.
 *
 *   FILOSOFIA: Manter padrão underline (Apple, GitHub, Linear) mas
 *   adicionar polish moderno: animação smooth do sublinhado via
 *   transform:scaleX (não pisca), hover sublinhado parcial, ícones
 *   SVG inline (não emoji), scroll horizontal em mobile (em vez de
 *   wrap em chips), suporte a navegação por teclado.
 *
 *   [CSS-NEW] 03-components.css §4.5 .rmbz-tabs:
 *     • Sublinhado animado com transform:scaleX + ease-spring (slide
 *       suave em vez do flick brusco do antigo)
 *     • Hover mostra sublinhado parcial (40%) cinza — feedback subtil
 *     • Active sublinhado dourado completo + cor + peso semi-bold
 *     • Scrollbar escondida (Firefox + WebKit) para visual limpo em
 *       overflow horizontal
 *     • Focus-visible com outline dourado para teclado
 *     • Estrutura: .rmbz-tab > .rmbz-tab__icon + .rmbz-tab__label +
 *       .rmbz-tab__badge (opcional, futuro)
 *     • Conteúdo das tabs com fade-in animation (translateY + opacity)
 *
 *   [RETRO-COMPAT] As classes antigas .account-tabs / .tab-link /
 *     .account-tab-content são alias do design system — ambos os
 *     selectors funcionam. JS de switching não precisa de mudanças
 *     estruturais (apenas adições de aria-selected e teclado).
 *
 *   [MARKUP] my-account-page.php tabs reescritas:
 *     • Ícones SVG inline (inbox, video, music, finance) — herdam
 *       currentColor para tingirem com o estado da tab
 *     • Estrutura semântica: role="tablist", role="tab",
 *       aria-selected, aria-controls — leitor de ecrã anuncia
 *       correctamente a tab seleccionada
 *     • Labels via __() para i18n
 *     • Removidos emojis 🏆 🚀 do texto (substituídos por SVG no
 *       __icon — separação semântica entre conteúdo e visual)
 *     • aria-label no tablist com "Secções da minha conta"
 *
 *   [JS] Handler de switching modernizado:
 *     • Sincroniza aria-selected (true/false) automaticamente
 *     • Função activateTab() reutilizável (DRY)
 *     • Navegação por teclado: ← → para mover entre tabs, Home / End
 *       para primeira/última. Padrão WAI-ARIA Tabs Pattern.
 *
 *   [CSS-LEGACY] CSS antigo em frontend.css preservado mas reescrito
 *     com :not(.rmbz-tabs) — só aplica em markup que não usa o DS.
 *     Garante fallback se o DS não carregar, sem competir quando carrega.
 *     Mesmo padrão para .tab-link e .account-tab-content.
 *
 *   [CSS-LEGACY-INLINE] my-account-page.php tinha overrides com
 *     !important (.onemz-artist-dashboard .tab-link.active...) que
 *     sobrepunham qualquer DS. Removidos — comentário deixado para
 *     histórico.
 *
 * == 1.5.40 (2026-04) — 👑 VIP/Subscrição refeito (Activo, Bloqueado, Pacotes) ==
 *   Os 3 estados da área de subscrição VIP eram herança directa do tema
 *   antigo — gradientes diagonais customizados, paletas dissonantes
 *   (verde-Slack + vermelho-alarme + dourado puro), 8 blocos if/else
 *   duplicados para 4 permissões, emojis ✔️/❌ coloridos, chips
 *   visualmente gritantes. Agora unificados com o design system.
 *
 *   FILOSOFIA: Manter a semântica (verde=activo, vermelho=problema,
 *   dourado=destaque) mas usar os tokens do DS e os componentes já
 *   estabelecidos na 1.5.39 (.rmbz-feature-card). Novo pricing grid
 *   reutilizável. Permissões ausentes em cinza-riscado em vez de
 *   vermelho-alarme (padrão Stripe/Wise/Notion).
 *
 *   [CSS-NEW] 03-components.css ganha 3 secções:
 *     §3.5  .rmbz-feature-card--danger — variante vermelha (par dos
 *           outros modifiers existentes --brand/--accent/--success/--neutral)
 *
 *     §3.6  .rmbz-price-card — pricing card reutilizável com:
 *           • __header  (nome + preço hero + badge de período)
 *           • __body    (lista de features)
 *           • __footer  (CTA full-width)
 *           • Modifier --popular (borda dourada + badge "Mais Popular"
 *             gerado via data-badge e ::before — admin pode no futuro
 *             marcar um pacote como destacado sem mexer em CSS)
 *           + .rmbz-price-grid (grid responsivo auto-fit 260px)
 *
 *     §3.7  .rmbz-perm-list — lista de permissões com 2 layouts:
 *           • Vertical (default) — itens empilhados com círculo-check
 *             pseudo-elements ::before/::after (sem ícones externos)
 *           • --inline — chips horizontais para dentro de feature-card
 *           Itens ausentes: cinza + text-decoration:line-through + opacity 0.55
 *           Itens activos: cor normal + círculo verde com check verde
 *           Mensagem visual: "ausente" é INFORMAÇÃO, não erro.
 *
 *   [PHP] my-account-page.php refactor em 3 partes:
 *
 *     1. VIP Activo → .rmbz-feature-card--success
 *        • Título "Acesso Artista VIP Activo" (capitalização corrigida)
 *        • Subtítulo com contagem de dias usando _n() para pluralização
 *          correcta ("1 dia" vs "2 dias")
 *        • CTA "Novo Lançamento" via rmbz_btn() variant success
 *        • Lista de permissões agora usa array unificado
 *          $perm_labels em vez de 8 blocos if/else duplicados.
 *          Resultado: 16 linhas de PHP limpo em vez de 24 linhas
 *          inline de markup.
 *        • Ícone SVG diamante (não emoji 💎)
 *
 *     2. Acesso Bloqueado → .rmbz-feature-card--danger
 *        • Ícone SVG cadeado
 *        • Texto reescrito: "Não tem subscrição activa. Active um dos
 *           pacotes abaixo..." — mais directo, menos corporativo
 *
 *     3. Grid de Pacotes → .rmbz-price-grid com .rmbz-price-card × N
 *        • Nome do plano em mono-uppercase dourado
 *        • Preço hero em Bricolage Grotesque tabular-nums
 *        • Badge verde "Válido por N dias" (pluralização correcta via _n)
 *        • Lista vertical de permissões (clara: presente/ausente via
 *           classe .is-active em vez de if/else com markup duplicado)
 *        • Botão "Comprar Plano" via rmbz_btn() variant outline
 *           (primary se popular=true no futuro)
 *        • Hover: lift 2px + sombra + borda mais forte
 *
 *   [CONTRATO-PRESERVADO] Form de compra de pacote intacto:
 *     action=rmbz_buy_vip_package, package_id hidden, nonce
 *     rmbz_buy_package_nonce. Handler do servidor não tocado. Confirm
 *     dialog via JS inline também preservado (melhorado com _() e
 *     esc_js para i18n/segurança).
 *
 *   [ACCESSIBILITY] Itens ausentes têm title="Não incluído no seu plano"
 *     para screen readers. Pluralização via _n(). Labels via __().
 *
 * == 1.5.39 (2026-04) — 🃏 4 cartões do dashboard unificados (.rmbz-feature-card) ==
 *   Os cartões principais do dashboard do artista (Cupão, Distribuição,
 *   Património, Referral) eram cada um um snowflake — gradientes
 *   inconsistentes, paletas misturadas (azul-petróleo aqui, verde Slack
 *   ali, dourado puro acolá), tamanhos de tipografia diferentes. Agora
 *   partilham uma anatomia única: .rmbz-feature-card.
 *
 *   FILOSOFIA DE DESIGN: padrão visto em Notion, Linear, Stripe — cartões
 *   visualmente IDÊNTICOS na estrutura, com pequena barra colorida no topo
 *   que dá identidade sem partir uniformidade. Cria a sensação de "produto
 *   coeso" em vez de "página de fragmentos".
 *
 *   [DS-NEW] Componente .rmbz-feature-card em 03-components.css §3.5:
 *            • Anatomia: __body com __head (icon + title + subtitle) +
 *              __action (botão/form)
 *            • Variante __metric para cartões com valor em destaque
 *              (Património: hero number tabular-nums)
 *            • 4 modifiers de cor para a barra topo + tinte do ícone:
 *              --brand (dourado), --accent (azul), --success (verde),
 *              --neutral (cinza)
 *            • Responsivo: stack vertical em mobile (≤640px), com botões
 *              em full-width
 *
 *   [CARDS-REFEITOS] Os 4 cartões reescritos em rmbz_render_dashboard_view():
 *
 *     1. CUPÃO PROMOCIONAL (rmbz-feature-card--brand, dourado)
 *        Antes: gradiente diagonal #1e1e1e→#2a2a2a, borda dourada,
 *               input "feio" mistura de bold + letter-spacing 2px, botão
 *               sólido dourado com class genérica .onemz-btn.btn-gold.
 *        Depois: cartão padrão, ícone SVG ticket, input com classe
 *               .rmbz-input herdando o design system, botão via rmbz_btn().
 *
 *     2. DISTRIBUIÇÃO DIGITAL (rmbz-feature-card--accent, azul)
 *        Antes: caixa cinza #181818, gradiente azul claro nos botões
 *               (#3498db→#2980b9 — paleta antiga), texto verde inline
 *               para "Ganhos Acumulados".
 *        Depois: ícone SVG globo, ganhos integrados no subtitle (só
 *               aparecem se >0), CTA com variant accent + ícone seta.
 *
 *     3. PATRIMÓNIO TOTAL (rmbz-feature-card--brand, dourado)
 *        Antes: o famoso azul-petróleo #2c3e50 que destoava de tudo —
 *               último vestígio do tema antigo no dashboard. Texto
 *               #aec6cf e #8baec2 (azuis pastel) sem ligação à paleta
 *               do plugin.
 *        Depois: cartão dourado consistente, valor 2.548,89 MT em
 *               tipografia Bricolage Grotesque dourada, breakdown
 *               carteira/royalties com font-mono e tabular-nums (números
 *               alinhados verticalmente). Botões Depositar/Levantar
 *               (já modernos da 1.5.37-38) integrados naturalmente.
 *
 *     4. GANHE SALDO EXTRA / Referral (rmbz-feature-card--success, verde)
 *        Antes: borda tracejada #444 (parecia placeholder), input pequeno
 *               sem styling do design system, botão "Copiar" cinza
 *               genérico.
 *        Depois: ícone SVG presente, input com .rmbz-input + font-mono,
 *               botão Copiar com variant outline + ícone SVG de cópia.
 *
 *   [JS-COMPAT] Handler do botão "Copiar" actualizado para preservar a
 *               estrutura interna do rmbz_btn() (ícone + label) ao
 *               alternar texto. Antes fazia copyBtn.innerText='Copiado!'
 *               que apagava o ícone. Agora muda apenas o <span> de label.
 *               Mantém fallback para markup antigo se o botão não usar DS.
 *
 *   PRESERVADO: lógica de bloqueio admin, IDs dos botões dependentes do
 *               JS (rmbz-show-deposit-btn, rmbz-show-withdrawal-btn,
 *               copy-referral-code-btn, referral-code-input,
 *               coupon_code form), nonces, contratos POST.
 *
 *   NÃO TOCADO (fora do scope): cartão VIP Activo (verde), cartão Acesso
 *   Bloqueado (vermelho), grid de Pacotes VIP. São uma sub-feature
 *   complexa (subscrição + planos) que merece a sua própria iteração.
 *
 * == 1.5.38 (2026-04) — 🔵 Botão Levantar em azul moderno (.rmbz-btn--accent) ==
 *   Refinamento visual: o botão Levantar passou de outline (borda neutra
 *   com toque dourado) para uma cor própria — azul moderno — para criar
 *   hierarquia clara contra o Depositar dourado, sem ambos competirem
 *   pela mesma cor.
 *
 *   FILOSOFIA: Padrão clássico de fintech (Wise, Revolut). Quando há duas
 *   acções financeiras lado-a-lado, usar duas cores distintas em vez de
 *   "uma forte + uma neutra" comunica melhor: ambas são acções primárias,
 *   apenas em direcções diferentes (entrada vs saída).
 *
 *   [TOKENS-NEW] 4 cores novas em 01-tokens.css:
 *                • --rmbz-accent          (#3b82f6 — azul Tailwind blue-500)
 *                • --rmbz-accent-soft     (versão 14% para backgrounds)
 *                • --rmbz-accent-glow     (versão 30% para halos)
 *                • --rmbz-accent-strong   (#2563eb — versão hover)
 *                + 2 sombras: --rmbz-shadow-accent-sm e -md (par directo das brand-*)
 *
 *                Mesma família cromática do --rmbz-info mas semanticamente
 *                separado: info=alerts, accent=CTAs secundárias premium.
 *                Permite mudar uma sem afectar a outra no futuro.
 *
 *   [BTN-NEW] Variante .rmbz-btn--accent em 03-components.css:
 *             • Background azul sólido + texto branco
 *             • Sombra com tinte azul (mesma intensidade da dourada da
 *               variante primary)
 *             • Hover: vai para tom mais escuro + sombra mais intensa
 *
 *   [DASHBOARD] my-account-page.php — botão Levantar troca de
 *               variant='outline' para variant='accent'. Tudo o resto
 *               mantém-se: mesmo SVG, mesmo ID (rmbz-show-withdrawal-btn),
 *               mesma lógica de bloqueio.
 *
 *   PRESERVADO: variante outline melhorada na 1.5.37 continua disponível
 *               para outros sítios que precisem dela.
 *
 * == 1.5.37 (2026-04) — 🎯 CTAs do Património (Depositar / Levantar) modernizados ==
 *   Os 2 botões principais do bloco "Património Total" eram herança do
 *   tema antigo: cinzentos, genéricos, sem hierarquia clara entre eles e
 *   sem ligação ao design system dourado dos modais novos. Agora foram
 *   refeitos com filosofia de fintech moderna.
 *
 *   [BTN-1] Depositar = .rmbz-btn--primary (dourado, CTA principal)
 *           Convite visível ao utilizador para adicionar saldo. Ícone
 *           SVG de "seta para cima e linha" (depositar = entrar). Hover
 *           tem brilho dourado mais intenso.
 *
 *   [BTN-2] Levantar = .rmbz-btn--outline (secundário, com toque dourado)
 *           Borda neutra por defeito + ícone SVG dourado para dar carácter.
 *           Hover faz a borda inteira ganhar a marca + fundo soft dourado.
 *           A variante outline foi melhorada globalmente neste sentido —
 *           beneficia também outros sítios que a usem.
 *
 *   [BTN-3] Estado bloqueado = .rmbz-btn--locked (NOVA variante)
 *           Em vez de cinzento genérico, agora é transparente com borda
 *           subtil e cadeado SVG. Comunica "está aqui mas não podes" sem
 *           o aspecto de disabled bruto. Cursor not-allowed, title com
 *           explicação. Aplica-se quando admin bloqueia depósitos ou
 *           levantamentos via extra_data.
 *
 *   [SVG] Ícones agora são SVGs inline (não emoji 🔒/💰/etc):
 *         • Visual idêntico em qualquer browser (emoji muda entre
 *           Apple/Google/Windows)
 *         • currentColor herda a cor do botão automaticamente
 *         • Tamanho proporcional ao font-size via width/height: 1em
 *
 *   [DS] Adicionada regra base .rmbz-btn__icon — garante alinhamento
 *        vertical perfeito de SVGs/emoji/texto em qualquer botão do
 *        plugin. Beneficia todos os botões do design system.
 *
 *   PRESERVADO: IDs (rmbz-show-deposit-btn, rmbz-show-withdrawal-btn)
 *               para o JS opener continuar a encontrá-los. Lógica de
 *               bloqueio (extra_data) intacta. Limite RMBZ_MIN_WITHDRAWAL
 *               continua a esconder o botão Levantar quando saldo é
 *               insuficiente.
 *
 *   NOTA: o cartão pai (#2c3e50 azul-petróleo) ainda é do tema antigo.
 *         Refactor desse cartão fica para próximo passo se o utilizador
 *         pedir — não foi pedido nesta iteração.
 *
 * == 1.5.36 (2026-04) — 💳 Modal de Depósito refeito com Design System ==
 *   Para fazer par com o levantamento (1.5.32), o depósito é agora também
 *   reconstruído com o design system. Mesmo visual premium, mesmo padrão
 *   de componentes, mesma robustez.
 *
 *   [DEP-NEW] Função rmbz_render_modals() — parte do depósito totalmente
 *             nova:
 *             • Markup via rmbz_modal() + rmbz_field() + rmbz_btn() +
 *               rmbz_alert() — zero style="" inline, zero onclick=""
 *             • Operadora via radio-pills nativos (.rmbz-radio-pill) —
 *               browser garante o submit sem JS frágil
 *             • Tipo de comprovativo (ID vs Screenshot) também via
 *               radio-pills com show/hide controlado por CSS :has()
 *             • Número da empresa para destinatário aparece imediatamente
 *               ao escolher operadora — também via :has(), zero JS
 *             • Input de file estilizado: borda tracejada + botão dourado
 *               nativo do browser via ::file-selector-button
 *             • Validação cliente: prefixo telefone vs operadora antes do
 *               submit, com toast em caso de erro
 *             • Validação cliente: ID/imagem obrigatório conforme
 *               proof_type, com toast claro
 *             • Botão de submit com loading state automático
 *             • Em mobile, vira "sheet" igual ao levantamento
 *
 *   [DEP-FIX] Limite max corrigido de 25 000 para 50 000 MT (UI estava
 *             desincronizada do servidor — handler aceita até 50 000).
 *
 *   [DEP-OPENER] Botão #rmbz-show-deposit-btn agora usa rmbz.modal.open()
 *                em vez de style.display='flex'. Mesmo motivo do levantamento:
 *                evita o bug do × não fechar.
 *
 *   [DEP-CONTRACT] Contrato com servidor MANTIDO — zero risco de regressão:
 *                  • action=rmbz_processar_deposito
 *                  • _rmbz_deposit_nonce
 *                  • deposit_amount (float MT)
 *                  • payment_method (M-Pesa|E-Mola)
 *                  • sender_phone (telefone do utilizador)
 *                  • proof_type (id|image)
 *                  • transaction_id ou proof_image
 *                  Handler rmbz_processar_deposito() não foi tocado.
 *
 *   PRÓXIMOS CANDIDATOS: página de login (rmbz_render_login_view),
 *   dashboard do artista (rmbz_render_dashboard_view), modal "pending
 *   deposit" (separado deste).
 *
 * == 1.5.35 (2026-04) — 💵 Reversão correcta de levantamentos de Royalties + preview USD→MT ==
 *   Dois bugs reportados pelo utilizador depois de testar a 1.5.34:
 *
 *   [BUG-1: Reembolso vai sempre para a carteira em MT] Quando o admin
 *           rejeitava um pedido de levantamento que tinha vindo dos
 *           Royalties (USD), o reembolso ia para a Carteira em MT em vez
 *           de voltar para os Royalties em USD. Resultado: o utilizador
 *           perdia a fonte original do dinheiro e o saldo de Royalties
 *           não recuperava.
 *
 *           CAUSA: O handler de rejeição (admin-handlers.php) chamava
 *           sempre rmbz_update_wallet_balance() — função que só sabe
 *           creditar MT na carteira. A informação da fonte original
 *           ('wallet' ou 'royalties') já existia na BD via coluna
 *           `source`, mas nunca era consultada. E o câmbio usado na
 *           altura do pedido não estava gravado em lado nenhum, por
 *           isso seria impossível reverter exactamente.
 *
 *           FIX em 4 partes:
 *             1. functions-setup.php — adicionada coluna
 *                exchange_rate decimal(8,4) na tabela
 *                rmbz_withdrawal_requests. Versão BD bumped 2.1.4 → 2.1.5
 *                para disparar dbDelta automaticamente em sites existentes
 *                (sem perda de dados — dbDelta apenas adiciona a coluna).
 *
 *             2. my-account-page.php — handler de criação de pedido
 *                (rmbz_handle_withdrawal_request, rota royalties) agora
 *                grava o $exchange_rate usado na conversão USD→MT junto
 *                com o pedido. Pedidos antigos têm exchange_rate=NULL.
 *
 *             3. distribution-financial.php — nova função
 *                rmbz_secure_revert_mt_to_usd() faz a operação inversa
 *                de forma atómica (transação SQL): incrementa
 *                total_earnings em USD nos royalties, regista no ledger
 *                USD com tipo 'withdrawal_rejected_revert', tudo num
 *                COMMIT/ROLLBACK só. Usa o câmbio gravado no pedido —
 *                NUNCA o câmbio actual — para que o utilizador fique
 *                exactamente como estava antes do pedido (sem ganhar
 *                nem perder por flutuações cambiais).
 *
 *             4. admin-handlers.php (rmbz_processar_rejeicao_levantamento)
 *                agora decide a rota:
 *                • source='royalties' + exchange_rate>0 → usa a função
 *                  nova, devolve USD aos royalties, NÃO toca na carteira
 *                  (porque a carteira já estava no estado original — foi
 *                  creditada na conversão e debitada no pedido, líquido 0).
 *                • source='wallet' OU pedido antigo sem câmbio → mantém
 *                  o comportamento clássico (credita MT na carteira).
 *                Se a reversão atómica falhar, o estado é revertido
 *                para 'pendente' para não deixar dinheiro perdido.
 *                Audit log do Guarda-Costas regista a rota usada.
 *
 *   [BUG-2: Preview USD→MT em tempo real desapareceu] Ao introduzir o
 *           valor em USD no modal de levantamento, o cartão "Vai receber
 *           aproximadamente X MT" nunca aparecia. CAUSA: rmbz_field()
 *           tinha um bug onde, se o caller passasse 'id' dentro de
 *           'attrs', o <input> saía com DOIS atributos id="" — um do
 *           default automático (rmbz-rmbz_withdrawal_amount_usd), outro
 *           do valor pedido (wd-usd-amount). Browsers ignoram o segundo,
 *           portanto document.getElementById('wd-usd-amount') devolvia
 *           null e o handler de input nunca registava.
 *           FIX: design-system.php — rmbz_field() agora detecta se 'id'
 *           foi passado em 'attrs' e dá-lhe prioridade (remove do attrs,
 *           usa como id principal). Único id no HTML final.
 *
 *   IMPACTO:
 *     • Pedidos NOVOS (criados a partir desta versão): rejeição correcta
 *       devolve aos Royalties em USD, à taxa exacta do dia do pedido.
 *     • Pedidos ANTIGOS (criados antes desta versão, sem câmbio gravado):
 *       continuam a ser reembolsados na Carteira em MT. Para os converter
 *       de volta para Royalties manualmente, o admin pode usar a
 *       ferramenta de gestão de saldos.
 *     • Preview USD→MT volta a funcionar imediatamente.
 *
 * == 1.5.34 (2026-04) — 🔧 Picker de operadora reescrito com radios nativos ==
 *   Hotfix do bug reportado pelo utilizador na 1.5.33: ao clicar em
 *   "M-Pesa" ou "E-Mola", o picker visualmente parecia não seleccionar e
 *   o submit do form falhava com "Indique o método e os dados de
 *   pagamento" do servidor.
 *
 *   ANÁLISE: O picker usava <button class="rmbz-pill" data-op="..."> + um
 *   <input type="hidden" name="rmbz_payment_method"> que era preenchido
 *   por JS ao clicar. O JS testou-se isoladamente e funciona — mas no
 *   contexto real, falhava silenciosamente. Hipótese mais provável: o
 *   handler do listener (registado com addEventListener no document)
 *   estava a competir com outro handler global noutro plugin/tema que
 *   chama stopImmediatePropagation. Já vimos isto na 1.5.27.
 *
 *   FIX: Substituído o picker JS-dependente por <label class="rmbz-radio-pill">
 *   contendo <input type="radio">. O browser garante NATIVAMENTE que o
 *   value do radio seleccionado é incluído no submit do form. Zero JS
 *   envolvido na sincronização. Impossível falhar.
 *
 *   ALTERAÇÕES:
 *     • my-account-page.php (2 sítios) — picker reescrito como radios
 *     • 03-components.css — nova secção §6.5 com .rmbz-radio-pill
 *       (visualmente idêntico ao .rmbz-pill, mas semântica diferente)
 *     • my-account-page.php — JS simplificado: removido o handler do
 *       picker (já não necessário). Validação de submit agora lê o
 *       radio checked em vez do input hidden.
 *
 *   USO DOS COMPONENTES (regra a partir de agora):
 *     • .rmbz-pill-group → para tabs/toggles puramente visuais (mostrar/
 *       esconder painéis no cliente). Ex: Carteira ↔ Royalties (não
 *       precisa enviar valor para o servidor).
 *     • .rmbz-radio-group → para selecções que SÃO submetidas em forms.
 *       Ex: Operadora M-Pesa/E-Mola.
 *
 * == 1.5.33 (2026-04) — 🔧 Bugfixes do modal de levantamento (1.5.32) ==
 *   Hotfix de 4 problemas reais reportados pelo utilizador depois de
 *   instalar a 1.5.32. Causa raiz de cada um identificada e corrigida na
 *   origem, não com workarounds.
 *
 *   [BUG-1: Pills não funcionam] As pills "Carteira ↔ Royalties" não
 *           alternavam — clicar não fazia nada e ambos os painéis ficavam
 *           visíveis ao mesmo tempo. CAUSA: o JS faz
 *           `pill.closest('[data-rmbz-pill-group]')` para encontrar o
 *           container, mas o helper rmbz_pill_group() emitia o container
 *           apenas com a CLASSE .rmbz-pill-group — o data-attribute
 *           obrigatório estava em falta. Resultado: closest() devolvia
 *           null e activatePill() abortava na primeira linha.
 *           FIX: design-system.php → adicionado `data-rmbz-pill-group`
 *           ao <div> container das pills.
 *
 *   [BUG-2: Painel inactivo aparece sempre] O painel de Royalties tinha
 *           atributo `hidden` correctamente emitido pelo PHP, mas
 *           continuava visível na página. CAUSA: `.rmbz-stack-lg` tem
 *           `display: flex` (specificity 0,1,0) e vence o atributo
 *           `[hidden]` do browser (specificity 0,0,1). Sem !important,
 *           qualquer utilitário com display fica sempre visível mesmo
 *           com hidden.
 *           FIX: 02-base.css → regra explícita `[hidden] { display:none
 *           !important }` aplicada a .rmbz-modal, .rmbz-stack* e
 *           descendentes de modais. Sem namespace porque modais vivem
 *           fora de .rmbz-app.
 *
 *   [BUG-3: × não fecha o modal] Clicar no × ou no overlay não fechava o
 *           modal. CAUSA: o opener antigo no dashboard fazia
 *           `modalWith.style.display = 'flex'` directamente. Mesmo com o
 *           DS depois a fazer setAttribute('hidden',''), o style inline
 *           anterior continuava lá e a regra `[hidden] { display:none }`
 *           era vencida pelo `style="display:flex"` (inline > CSS
 *           externo, mesmo com !important).
 *           FIX duplo:
 *             1) my-account-page.php → opener simplificado para chamar
 *                APENAS rmbz.modal.open(), sem fallback poluente.
 *             2) design-system.js → modal.open()/close() agora chamam
 *                `el.style.removeProperty('display')` antes de qualquer
 *                outra coisa, limpando lixo de qualquer código antigo
 *                que tenha mexido no style inline.
 *             3) 03-components.css → `.rmbz-modal[hidden]` ganhou
 *                !important como defesa profunda.
 *
 *   [BUG-4: × visualmente fraco] O × era texto ('×' Unicode) e ficava
 *           pequeno se o tema do utilizador tivesse override em <button>.
 *           FIX: design-system.php → × substituído por SVG inline 18×18
 *           com stroke=2 — visual idêntico em qualquer ambiente.
 *           03-components.css → adicionado .rmbz-modal__close para
 *           centrar o SVG.
 *
 *   PRESERVADO: tudo o resto da 1.5.32. Contrato POST inalterado.
 *
 * == 1.5.32 (2026-04) — 🎯 Modal de Levantamento refeito com Design System ==
 *   FINALMENTE: o modal que foi reescrito 9 vezes em 3 semanas (1.5.21 →
 *   1.5.30) é agora reconstruído com base no Design System v1 introduzido
 *   na 1.5.31. Esta é a primeira área visivelmente refeita do plugin.
 *
 *   [WD-NEW] rmbz_render_modals() — parte do levantamento totalmente nova:
 *            • Markup via rmbz_modal() + rmbz_pill_group() + rmbz_field()
 *              + rmbz_stat() + rmbz_alert() + rmbz_btn() — ZERO HTML
 *              copiado/colado, ZERO style="..." inline, ZERO onclick=""
 *            • Tipografia premium: Bricolage Grotesque (display) + Geist
 *              (corpo) + JetBrains Mono (montantes com tabular-nums)
 *            • Toggle Carteira ↔ Royalties via .rmbz-pill-group com
 *              data-target — gestão automática pelo design-system.js
 *            • Inputs monetários com .rmbz-input--money (mono, alinhado
 *              à direita, prefix MT/USD)
 *            • Validação cliente: prefixos M-Pesa (84/85) / E-Mola (86/87)
 *              ANTES do submit, com toast em caso de erro
 *            • Botão de submit com loading state automático
 *              (data-rmbz-loading-on-submit)
 *            • Em mobile, o modal vira "sheet" (slide-up de baixo)
 *            • Suporta Escape, click no overlay, focus trap automático
 *            • Caso "saldo insuficiente em ambas as fontes" agora mostra
 *              alert claro em vez de texto vermelho perdido no fundo
 *
 *   [WD-OPENER] Botão #rmbz-show-withdrawal-btn agora usa rmbz.modal.open()
 *               quando disponível, com fallback gracioso para o método
 *               antigo (style.display='flex'). Não parte se o DS falhar.
 *
 *   [WD-CONTRACT] Contrato com servidor MANTIDO — zero risco de regressão:
 *                 • action=rmbz_request_withdrawal
 *                 • _wpnonce de rmbz_withdrawal_nonce
 *                 • rmbz_withdrawal_source (wallet|royalties)
 *                 • rmbz_payment_method (M-Pesa|E-Mola, com hífen)
 *                 • rmbz_payment_details (telefone normalizado)
 *                 • rmbz_withdrawal_amount (float MT) ou
 *                   rmbz_withdrawal_amount_usd (float USD)
 *                 O handler rmbz_handle_withdrawal_request() não foi tocado.
 *
 *   [WD-PRESERVED] Da versão 1.5.30:
 *                  • Sistema Guarda-Costas continua activo
 *                  • Helpers rmbz_normalize_phone_mz / rmbz_phone_operator_mz
 *                  • Lógica de saved_payouts (será reintroduzida na próxima
 *                    iteração como chips de contactos guardados)
 *                  • Lock pessimista por utilizador (BUG-WD3)
 *                  • Validação de prefixos no backend
 *
 *   [WD-DEPOSIT] Modal de Depósito MANTIDO igual à 1.5.30 (refactor próximo).
 *                Conviver lado-a-lado mostra a diferença visual.
 *
 *   MÉTRICAS: função rmbz_render_modals() na 1.5.30 tinha 307 linhas com
 *             ~50 atributos style="" inline + 8 onclick="" + 3 blocos
 *             <script>. Agora tem 509 linhas (mais comentários e mais
 *             funcionalidade — alerts visuais melhores, validação cliente)
 *             com ZERO style inline novo no levantamento e UM <script>
 *             isolado de ~50 linhas (de 200+ antes).
 *
 *   PRÓXIMOS CANDIDATOS: modal de depósito (mesmo padrão), página de login
 *   (rmbz_render_login_view), dashboard do artista (rmbz_render_dashboard_view).
 *
 * == 1.5.31 (2026-04) — 🎨 Design System v1 introduzido (não-disruptivo) ==
 *   Esta versão introduz a fundação visual moderna para o plugin sem
 *   alterar o aspecto de NADA do que já existe. O design system convive
 *   com o código antigo durante a migração — refactor incremental, área
 *   a área, sem big-bang.
 *
 *   [DS-NEW] Novos ficheiros:
 *            • assets/css/01-tokens.css     — variáveis CSS (cores,
 *              tipografia, espaçamento, animação) — single source of truth
 *            • assets/css/02-base.css       — reset + utilitários,
 *              namespaced em .rmbz-app (não toca no resto do tema)
 *            • assets/css/03-components.css — 11 componentes:
 *              button, input, card, stat, badge, pill toggle, modal,
 *              toast, empty state, skeleton, alert
 *            • assets/js/design-system.js   — UM delegator central com
 *              API window.rmbz.{modal,toast,button}. Zero onclick, zero
 *              IIFEs por modal. Funciona com CSP estrito.
 *            • includes/design-system.php   — helpers PHP:
 *              rmbz_btn(), rmbz_field(), rmbz_modal(), rmbz_stat(),
 *              rmbz_badge(), rmbz_alert(), rmbz_pill_group(),
 *              rmbz_empty_state(), rmbz_app_open/close()
 *            • templates/components/example-withdrawal-modal-v2.php
 *              — exemplo: modal de levantamento refeito em 80 linhas
 *              (vs ~280 actuais), pronto para substituir gradualmente
 *
 *   [DS-INTEGRATION] Integração mínima:
 *            • design-system.php é carregado em 1mz-for-artists.php
 *              imediatamente após functions-setup.php (carregamento cedo
 *              para estar disponível em todos os outros módulos).
 *            • includes/enqueue.php agora chama rmbz_enqueue_design_system()
 *              antes do CSS/JS antigo, em todas as páginas onde o plugin
 *              renderize algo (frontend e admin).
 *            • Google Fonts (Bricolage Grotesque + Geist + JetBrains Mono)
 *              carregadas via CDN com display=swap.
 *
 *   [DS-DESIGN-DECISIONS] Tipografia premium para tema musical/luxo:
 *            • Display: Bricolage Grotesque (variável, com caráter editorial)
 *            • Body:    Geist (refinado, leitura óptima)
 *            • Mono:    JetBrains Mono (montantes com tabular-nums)
 *
 *   [DS-NON-BREAKING] Garantias de compatibilidade:
 *            • CSS antigo (frontend.css, account-page.css, distribution.css)
 *              continua a ser carregado e a funcionar exactamente como
 *              antes. O design system só se activa em código que use
 *              .rmbz-app + as classes .rmbz-* novas.
 *            • JS antigo (account-page.js, distribution.js) continua
 *              activo. design-system.js usa namespace window.rmbz e
 *              guard __rmbzDsInit anti-duplo-load — não conflita.
 *            • Helpers PHP têm prefixo rmbz_ — não colide com nada
 *              existente.
 *            • Suporta prefers-reduced-motion (acessibilidade).
 *            • Tema [data-rmbz-theme="hc"] preparado para high contrast
 *              futuro.
 *
 *   PRÓXIMO PASSO RECOMENDADO: substituir o modal de levantamento actual
 *   (linhas ~2293-2600 de my-account-page.php) pela função demonstrada
 *   em templates/components/example-withdrawal-modal-v2.php. Resolve
 *   definitivamente o ciclo de 9 reescritas (1.5.21 → 1.5.30).
 *
 * == 1.5.30 (2026-04) — Reverter modal para 1.5.21 (mantendo Guarda-Costas) ==
 *   Utilizador pediu: "antes do Guarda-Costas funcionava, mas deixa o
 *   Guarda-Costas". Interpretação: manter Guarda-Costas + hotfixes PHP 8.1,
 *   mas reverter o modal de levantamento ao estado 1.5.21 (antes dos
 *   redesigns CSS e refactors JS das versões 1.5.25-1.5.29 que estragaram
 *   os botões).
 *
 *   [REVERT] Modal de levantamento restaurado ao design 1.5.21:
 *            • Estrutura em 2 etapas (escolher fonte → preencher dados)
 *            • Dois botões grandes (Carteira/Royalties) no ecrã inicial
 *            • Formulários simples com select M-Pesa/E-Mola + input número
 *            • Preview da conversão USD→MT em tempo real
 *            • Botão Fechar (×) e Fechar (texto) com event delegation
 *              (data-rmbz-close) — o fix que funcionou em 1.5.21
 *            • Escape e clique no overlay também fecham
 *            • Design simples e estável, sem gradientes problemáticos
 *
 *   PRESERVADO de versões anteriores:
 *            • Helpers rmbz_normalize_phone_mz / rmbz_phone_operator_mz
 *            • rmbz_get_saved_payouts / rmbz_save_payout_contact (auto-save
 *              de contactos após sucesso continua activo; simplesmente não
 *              é apresentado no modal por chips — volta a ser um input livre)
 *            • Validação de prefixos 84/85 (M-Pesa) e 86/87 (E-Mola) no
 *              backend (continua a bloquear números errados)
 *            • Sistema Guarda-Costas completo (1.5.22): audit log, checklist,
 *              sinais de risco, vista forense, bloqueio de aprovação
 *              sem checklist completa
 *            • Hotfixes PHP 8.1 do add_submenu_page (1.5.24)
 *
 *   DESCARTADO das versões 1.5.25-1.5.29:
 *            • Redesign com gradientes, variantes por cor (roxo Royalties)
 *            • Toggle pílula Carteira ↔ Royalties num só ecrã
 *            • Picker de contactos em chips clicáveis
 *            • Emergency script + logs de debug
 *            • CSS com !important em input
 *
 * == 1.5.29 (2026-04) — 🎯 CAUSA RAIZ: <script> literal em comentário HTML ==
 *   DESCOBERTA (depois de 4 versões a tentar adivinhar): o erro
 *   "Uncaught SyntaxError: '#' not followed by identifier" era porque
 *   um COMENTÁRIO HTML no modal continha a palavra <script> literal:
 *
 *     <!-- ... o <script> agora carrega SEMPRE ... -->
 *
 *   O parser HTML5 em alguns contextos interpreta a sequência "<script>"
 *   dentro de um comentário como ABERTURA REAL da tag script. Isto
 *   desbalanceia o parser: o comentário "termina" cedo, o verdadeiro
 *   <script> subsequente é tratado como conteúdo do script anterior, e
 *   o CSS do <style> (que começa com ".rmbz-wd-chip {") é eventualmente
 *   parseado como JavaScript — por isso o "#f0b90b" (cor hex CSS) dá
 *   erro "'#' not followed by identifier" no JS parser do browser.
 *
 *   Uma vez o parser desincronizado, NENHUM dos scripts seguintes corre:
 *   • Fechar → morto
 *   • Toggle Carteira/Royalties → morto
 *   • M-Pesa/E-Mola → morto
 *
 *   Exatamente o comportamento que utilizador reportou.
 *
 *   [FIX-CRITICAL] No comentário HTML do modal, "<script>" → "bloco de JS".
 *                  Validado com node --check em todos os scripts do output
 *                  renderizado — agora 0 erros de sintaxe em qualquer script.
 *
 * == 1.5.28 (2026-04) — Script de EMERGÊNCIA para os botões do modal ==
 *   Utilizador reportou: 1.5.26 e 1.5.27 continuam a mostrar botões que
 *   não funcionam + erros vermelhos no Console. Isto indica que o script
 *   principal está a falhar por alguma razão (possivelmente erro JS de
 *   outro plugin a correr antes, ou algum edge case no IIFE).
 *
 *   [EMERGENCY-JS] Adicionado segundo script INDEPENDENTE antes do principal.
 *                  É uma versão MÍNIMA (sem helpers, sem dependências,
 *                  sem features avançadas) que garante:
 *                    • Fechar modal
 *                    • Toggle Carteira ↔ Royalties
 *                    • Selecionar M-Pesa/E-Mola (com classe .active)
 *                    • Botão MAX
 *                    • Fechar no overlay
 *                    • Fechar com Escape
 *                  Se o script principal falhar, o emergency toma conta.
 *                  Se ambos correrem, o guard __rmbzWdEmergency evita
 *                  duplo-processamento (primeiro a bind vence).
 *
 *   Com 2 scripts independentes, é virtualmente impossível os botões
 *   ficarem todos parados. Se ambos falharem, é problema do Console JS
 *   noutra parte da página (outro plugin) que o utilizador precisará
 *   de reportar para investigar.
 *
 * == 1.5.27 (2026-04) — Logs de debug para diagnosticar botões parados ==
 *   Utilizador reportou em 1.5.26: nem Fechar, nem toggle, nem M-Pesa/E-Mola
 *   funcionavam. Simulei o output do PHP e confirmei que o script está a
 *   ser gerado correctamente (1 <script> válido, JS sintaxe OK). O problema
 *   DEVE ser em runtime: cache do browser, ou outro JS a interceptar
 *   cliques com stopImmediatePropagation.
 *
 *   [DEBUG] Adicionados console.log() em cada handler:
 *           • "[WD] Script inicializado..."
 *           • "[WD] Listener click registado com sucesso."
 *           • "[WD] Click → close/overlay/toggle source/set max/..."
 *
 *   Assim o utilizador pode abrir F12 → Console e reportar:
 *   • Se vê "Script inicializado" = script corre
 *   • Se vê "Click → X" ao clicar = listener funciona (o problema está noutro sitio)
 *   • Se NÃO vê nada = outro JS na página está a impedir
 *
 * == 1.5.26 (2026-04) — Refactor total do JS do modal + CSS de input fixado ==
 *   Utilizador continuava a reportar toggle + M-Pesa/E-Mola não
 *   funcionarem, e input de valor com aspeto diferente dos outros
 *   elementos. Raiz do problema: o script grande no fim do modal
 *   dependia de várias coisas (IDs existirem, variáveis PHP interpoladas,
 *   nenhum erro anterior ter parado a execução) — qualquer uma falhar
 *   e tudo parava.
 *
 *   [JS-REWRITE] Script completamente reescrito:
 *                • Um ÚNICO script robusto no topo do modal
 *                • Toda a lógica via event delegation (listeners a nível
 *                  document em vez de por-elemento)
 *                • Dados (câmbio, saldos, mínimos) vêm de data-* attrs
 *                  no form em vez de serem interpolados em PHP dentro do JS
 *                • Todas as funções usam $(id) helper com null checks
 *                • Zero dependência de IDs existirem no page-load
 *                • Guard __rmbzWdInit evita duplo-bind
 *                • Compat: window.rmbzCloseWithdrawal / rmbzToggleSource /
 *                  rmbzSelectOp / rmbzSetMax / rmbzPickContact continuam
 *                  definidos como alias
 *
 *   [JS-DATA-ATTRS] Todos os botões agora usam data-* em vez de onclick=:
 *                   • data-rmbz-close="withdrawal-modal"
 *                   • data-rmbz-toggle-source
 *                   • data-rmbz-set-max
 *                   • data-rmbz-select-op="M-Pesa"|"E-Mola"
 *                   • data-rmbz-pick-contact
 *                   Isto torna o modal à prova de CSP estrito e de erros
 *                   JS noutras partes da página.
 *
 *   [CSS-INPUT-FIX] Input de valor (MT 0,00) tinha bordas pretas e altura
 *                   diferente por causa de CSS global do tema para
 *                   input[type=number]. Reforçado com !important em todas
 *                   as propriedades críticas (background, border, padding,
 *                   height, width, border-radius). Agora fica idêntico ao
 *                   wrap — parece um só elemento contínuo.
 *
 *   [JS-CLEANUP] Removidas ~210 linhas do script antigo no fim do modal.
 *                Código mais simples, menor, mais testável.
 *
 * == 1.5.25 (2026-04) — Modal levantamento: fix toggle + redesign polido ==
 *   Utilizador reportou: (a) botão "Carteira ⇅" não alternava para Royalties
 *   (ficava sempre preso na Carteira); (b) design podia ficar mais moderno.
 *
 *   [UI-FIX-1] Toggle de fonte via event delegation em vez de onclick=.
 *              Mesma abordagem do fix do botão Fechar em 1.5.21 (onclick=
 *              inline pode falhar silenciosamente em certos ambientes com
 *              CSP estrito ou erros JS noutra parte da página). Agora o
 *              botão tem data-rmbz-toggle-source e um listener delegado
 *              no script robusto que carrega cedo apanha o clique.
 *              Proteção defensiva extra: se sourceInput for null por
 *              qualquer razão, tenta recuperar em vez de crashar.
 *
 *   [UI-POLISH-1] Redesign visual completo do modal:
 *                 • Gradiente subtil no card (azul-escuro a dark)
 *                 • Saldo com efeito gradient-text (prateado)
 *                 • Botão × animado (rotaciona 90° no hover)
 *                 • Botão de fonte com variantes por cor:
 *                   - Carteira: amarelo dourado (atual)
 *                   - Royalties: roxo (muda automaticamente ao alternar)
 *                 • Botão "Pedir Levantamento" com gradient amarelo
 *                   e sombra dinâmica (subtle lift no hover)
 *                 • Input com glow amarelo no focus
 *                 • M-Pesa/E-Mola com elevação no hover
 *                 • Cantos mais arredondados (24px no card)
 *                 • Paleta refinada com opacidades em vez de cores
 *                   sólidas (mais moderno)
 *                 • Respeita prefers-reduced-motion implicitamente
 *                   (transições suaves mas não exageradas)
 *
 * == 1.5.24 (2026-04) — Hotfix #2 PHP 8.1 (página escondida com parent real) ==
 *   [BUGFIX] O fix de 1.5.23 (parent_slug='' em vez de null) eliminou os
 *            2 deprecated de strpos/str_replace MAS criou um novo:
 *            strip_tags() em admin-header.php:41 receber null no
 *            $admin_title — porque a página existia sem parent registado
 *            e o WP não conseguia formar o título.
 *
 *   Solução definitiva (padrão usado pelo WooCommerce e plugins maduros):
 *   registar a página como sub-item de 'options.php' (sempre existe → o
 *   admin_title funciona) e depois remover do menu visível com
 *   remove_submenu_page() no hook 'admin_head'. Resultado:
 *     • Página acessível por URL (?page=rmbz-withdrawal-guard)
 *     • Não aparece no menu lateral
 *     • Sem warnings de strpos/str_replace (parent_slug é uma string real)
 *     • Sem warning de strip_tags (admin_title pode ser determinado)
 *
 * == 1.5.23 (2026-04) — Hotfix PHP 8.1 deprecation no Guarda-Costas ==
 *   [BUGFIX] Após instalar 1.5.22, ao desativar/reactivar o plugin
 *            apareciam warnings: "Cannot modify header information —
 *            headers already sent" + 2 deprecated em wp-includes/functions.php.
 *
 *   Causa raiz: o submenu escondido do Guarda-Costas usava
 *   `add_submenu_page(null, ...)` que é um padrão deprecated em PHP 8.1+.
 *   O `null` é passado internamente ao plugin_basename → wp_normalize_path
 *   → strpos/str_replace, gerando 2 warnings. Esses warnings, com
 *   display_errors=1 no servidor, fazem output ANTES dos headers serem
 *   enviados, causando o cascade de "Cannot modify header information"
 *   em vários sítios do WordPress.
 *
 *   Documentação oficial WP recomenda usar string vazia '' em vez de null
 *   para esconder páginas do menu. Mesmo comportamento, sem o warning.
 *
 *   [PHP-FIX-1] add_submenu_page(null,...) → add_submenu_page('',...)
 *   [PHP-FIX-2] str_replace([' ','-'], '', $payment_method) protegido
 *               com (string) cast para evitar warning se o campo for null
 *               em pedidos antigos.
 *
 * == 1.5.22 (2026-04) — 🛡️ Guarda-Costas: Auditoria Forense de Levantamentos ==
 *   Antes a página "Pedidos de Levantamento" mostrava só info básica
 *   (data, artista, valor, número, ações). O admin tinha de aprovar
 *   pagamentos sem ver de onde vinha o dinheiro nem detectar fraude.
 *
 *   Esta release adiciona um sistema completo de KYC/AML inspirado em
 *   distribuidoras profissionais (FUGA, Symphonic, DistroKid).
 *
 *   [GUARD-DB-1] 2 tabelas novas:
 *                • rmbz_withdrawal_audit_log: registo IMUTÁVEL de cada
 *                  ação sobre um pedido (criação, vistas, checklist,
 *                  notas, decisões). Inclui actor + IP + timestamp.
 *                • rmbz_withdrawal_review: estado mutável da revisão
 *                  (checklist JSON, notas internas, score de risco).
 *                Schema bump 2.1.3 → 2.1.4 (auto-migração).
 *
 *   [GUARD-VIEW-1] Nova página dedicada por pedido com 8 secções:
 *                  📋 O Pedido — info básica
 *                  👤 Identidade — nome, telemóvel, sub, histórico
 *                  💰 Origem — para Royalties: conversão USD→MT,
 *                     créditos contributivos, top 10 faixas com ISRC,
 *                     último import CSV; para Carteira: 20 últimas
 *                     transacções da carteira
 *                  ⚠️ Sinais de Risco — análise automática de 8 sinais
 *                  ☑️ Checklist obrigatória (6 items)
 *                  📝 Notas internas
 *                  🎯 Decisão final
 *                  📜 Audit log completo
 *
 *   [GUARD-RISK-1] Detecção automática de 8 sinais de risco:
 *                  • Conta banida (CRÍTICO)
 *                  • Prefixo do número errado (84/85 vs 86/87) (HIGH)
 *                  • Método ≠ operador do número (HIGH)
 *                  • Número usado por outras contas (HIGH — possível mula)
 *                  • Royalties sem CSV importado (HIGH)
 *                  • Número ≠ telemóvel da conta (MEDIUM)
 *                  • Primeiro levantamento (MEDIUM)
 *                  • Histórico de rejeições (MEDIUM)
 *                  • Valor 3x acima da média (MEDIUM)
 *                  • Sem subscrição ativa (LOW)
 *                  Cada sinal soma ao score 0-100. Cor da badge muda
 *                  conforme severidade (verde/amarelo/laranja/vermelho).
 *
 *   [GUARD-CHK-1] Checklist com 6 items obrigatórios:
 *                  identidade · saldo legítimo · número validado ·
 *                  sem red flags · royalties auditados · transferência feita.
 *                  BLOQUEIA aprovação até todas marcadas. Tentativa de
 *                  aprovar sem checklist completa redireciona para a
 *                  vista de detalhe com aviso.
 *
 *   [GUARD-LIST-1] Página de listagem agora mostra:
 *                  • Coluna "Fonte" (Carteira / Royalties) com badges
 *                  • Coluna "Risco" com score+label colorido + estado
 *                    da checklist
 *                  • Botão "🔍 Investigar" abre vista forense
 *                  • Botão "Pagar & Aprovar" desativado se checklist
 *                    incompleta (com aviso ao clicar)
 *
 *   [GUARD-AUDIT-1] Audit log automático em todas as ações:
 *                   created, first_view, viewed, checklist_updated,
 *                   note_updated, approve_blocked, approved, rejected.
 *                   Cada entry guarda: actor (login WP), IP, timestamp,
 *                   detalhes. Imutável — só insere, nunca apaga.
 *
 * == 1.5.21 (2026-04) — Bug fechar modal: solução robusta ==
 *   Apesar do fix em 1.5.20 ter movido a função fechar para fora do else,
 *   o utilizador continuou a reportar que os botões Fechar e × não
 *   funcionavam quando havia pendente. Causa provável: o script grande
 *   no fim do modal pode falhar silenciosamente em algum ambiente (CSP,
 *   plugin de segurança, cache de JS antigo) e a função window.rmbzCloseWithdrawal
 *   nunca chega a ser definida.
 *
 *   [WD-FIX-3] Solução autossuficiente — script de fechar agora carrega
 *              IMEDIATAMENTE, dentro do <div #withdrawal-modal> mas ANTES
 *              de qualquer condicional. Tem 4 mecanismos paralelos:
 *                1. Event delegation: qualquer botão com
 *                   data-rmbz-close="withdrawal-modal" fecha
 *                2. Clique no overlay escuro também fecha
 *                3. Tecla Escape também fecha
 *                4. window.rmbzCloseWithdrawal mantida como alias para compat
 *              Usa style.setProperty('display','none','important') para
 *              vencer qualquer CSS conflituoso.
 *              Guard __rmbzWdCloseInit para evitar duplo-bind se a função
 *              for chamada várias vezes.
 *
 *   [WD-FIX-4] Botões × e Fechar passam a usar data-rmbz-close em vez
 *              de onclick="..." — funciona mesmo em ambientes com CSP
 *              estrito que bloqueiam atributos onclick inline.
 *
 * == 1.5.20 (2026-04) — Modal de levantamento redesenhado + validações ==
 *   Reforma completa do modal de levantamento, inspirado em apps de
 *   pagamentos modernas (PayPal, Stripe). Mais fix crítico de bug.
 *
 *   [WD-FIX-1] Bug: ao tentar levantar com pendente, o "×" e "Fechar"
 *              não funcionavam — modal ficava preso. Causa: a função
 *              window.rmbzCloseWithdrawal() estava DENTRO do <?php else: ?>,
 *              por isso não era definida quando havia pendente. Agora o
 *              <script> está sempre carregado e a função existe sempre.
 *
 *   [WD-UX-1]  Design minimalista de 1 ecrã (em vez de 2 etapas):
 *                • Saldo em destaque grande no topo
 *                • Botão de fonte como pílula clicável (toggle entre
 *                  💼 Carteira e 🎵 Royalties — actualiza saldo + moeda)
 *                • Input central com prefixo da moeda + botão MAX
 *                • Preview de conversão USD→MT em tempo real (Royalties)
 *                • Selector de operador (M-Pesa / E-Mola) com prefixos
 *                • Picker de contactos guardados (chips)
 *                • Botão final desativado até tudo estar válido
 *
 *   [WD-VAL-1] Validação rigorosa de prefixos no front E backend:
 *                • M-Pesa só aceita números 84/85 (Vodacom)
 *                • E-Mola só aceita números 86/87 (Movitel)
 *                • Tmcel (82/83) rejeitado para já (não suportado)
 *                • Auto-deteção do operador quando se digita o número
 *                • Normalização: aceita 9 dígitos, +258, 00258, ou 0
 *                  inicial, mas guarda sempre o formato 9 dígitos
 *                • Helpers reutilizáveis: rmbz_normalize_phone_mz() e
 *                  rmbz_phone_operator_mz()
 *
 *   [WD-MEM-1] Sistema de contactos guardados:
 *                • Após cada levantamento bem-sucedido, o número usado
 *                  é guardado em wallets.extra_data → 'saved_payouts'
 *                • Idempotente: não duplica se já existe
 *                • Mantém os 5 mais recentes
 *                • No próximo levantamento, aparecem como chips clicáveis
 *                  no modal — 1 clique e ficam preenchidos
 *
 * == 1.5.19 (2026-04) — "by 1MZ Artist" hard-coded na lista de lançamentos ==
 *   [BUGFIX] Em distribution-list-view.php (linha 138) o nome do artista
 *            estava literalmente "1MZ Artist" hard-coded em vez de mostrar
 *            o nome real do artista. Agora, antes do loop, fazemos uma
 *            única query a rmbz_wallets para buscar o user_nome do
 *            $user_telemovel (o mesmo padrão já em uso na single-view).
 *            Fallback para "Artista 1MZ" se a wallet não tiver nome
 *            preenchido — consistente com o resto do código.
 *
 * == 1.5.18 (2026-04) — Extrato da Carteira sai fora no mobile ==
 *   Após o levantamento de Royalties funcionar (1.5.17), o artista viu
 *   um novo problema: na Minha Conta → Histórico, a tabela "Extrato da
 *   Carteira" ficava com cards cortados à direita no mobile. Textos
 *   longos do tipo "Levantamento de Royalties (M-Pesa: 849392636) —
 *   $4.00 USD convertidos..." empurravam o td para fora do viewport.
 *
 *   Causa raiz (2 problemas combinados):
 *   • Os <td> não tinham o atributo `data-label` que o CSS responsive
 *     esperava (via `content: attr(data-label)`). Sem ele, o ::before
 *     ficava vazio mas continuava a ocupar espaço.
 *   • Em flex containers, o filho tem min-width:auto por default, o que
 *     impede o td de encolher quando tem conteúdo longo sem espaços.
 *     Com flex + texto longo + min-width:auto = overflow horizontal.
 *
 *   [UI-FIX-1] CSS responsive reforçado em account-page.css e
 *              frontend.css (ambos tinham regras mobile):
 *                • min-width:0 e max-width:100% no td
 *                • overflow-wrap:anywhere + word-break:break-word para
 *                  quebrar textos longos (URLs, IDs, frases grandes)
 *                • flex-wrap:wrap para quando label+valor não cabem na
 *                  mesma linha, descer a valor para linha seguinte
 *                • table-layout:fixed para a tabela em si nunca alargar
 *                • ::before escondido se o td não tem data-label, para
 *                  não deixar espaço em branco esquisito
 *
 *   [UI-FIX-2] Adicionado data-label="..." a todos os <td> das 3 tabelas
 *              da Minha Conta: Pedidos de Depósito, Pedidos de
 *              Levantamento, Extrato da Carteira. Agora no mobile cada
 *              linha mostra "Movimento: descrição" claramente.
 *
 *   [UI-FIX-3] Container .account-tab-content e .onemz-artist-dashboard
 *              ganharam overflow-x:hidden + max-width + box-sizing como
 *              safety net final: mesmo se algum filho escapar no futuro,
 *              o utilizador nunca mais tem scroll horizontal na página.
 *
 * == 1.5.17 (2026-04) — Hotfix conversão USD→MT estava quebrada ==
 *   [WD-FIX-1] A função rmbz_secure_transfer_usd_to_mt() nunca tinha
 *              funcionado em produção por causa de dois bugs de schema
 *              na tabela rmbz_wallet_transactions:
 *              (a) Insert usava a coluna 'rifa_id' que NÃO existe no
 *                  schema. A coluna correta é 'reference_id' (para linkar
 *                  ao ledger USD) ou 'project_id' (videoclip). Escolhido
 *                  reference_id por ser semanticamente correto aqui.
 *              (b) Update de rmbz_distro_stats usava 'updated_at' que só
 *                  existe em instalações que correram distribution-schema-patch.
 *                  Em instalações fresh a coluna só é criada como
 *                  'last_updated' (via functions-setup). Agora usamos
 *                  'last_updated' que existe em ambos os schemas.
 *              Sintoma: ao tentar levantar em USD (fonte Royalties),
 *              o artista recebia "Erro na conversão: Transação cancelada:
 *              Erro ao registar histórico da carteira." O rollback
 *              cancelava tudo, portanto não havia perda de dinheiro, mas
 *              a feature estava 100% inutilizável desde que foi criada.
 *   [WD-FIX-2] Adicionado error_log a todos os throws da função com
 *              $wpdb->last_error — assim futuros erros de BD ficam
 *              registados em /wp-content/debug.log para diagnose rápida.
 *
 * == 1.5.16 (2026-04) — Tendências & Análises com empty state + transparência ==
 *   Problema: artistas sem dados viam a página com zeros e nenhuma
 *   explicação do porquê. Especialmente frustrante porque os DSPs
 *   (Spotify, Apple Music, etc.) reportam com 45-90 dias de atraso e a
 *   maioria dos artistas não sabe que isto é normal na indústria.
 *
 *   [AN-FEAT-1] Empty state educativo: quando um artista abre Tendências
 *               e ainda não tem dados, mostramos em vez do dashboard uma
 *               página explicativa:
 *                 • Mensagem adaptada ao estado real do artista:
 *                   — 0 releases? "Crie o seu primeiro lançamento"
 *                   — Release enviado mas não live? "Aguarda distribuição"
 *                   — Live há <60 dias? "Muito cedo, 2-3 meses normais"
 *                   — Live há 60-120 dias? "Estamos perto"
 *                   — Live há >120 dias? "Algo não está certo, contacte-nos"
 *                 • Timeline visual em 5 passos do ciclo completo:
 *                   lançamento → live → DSP compila → 1MZ importa → artista vê
 *                 • Info sobre último import global da 1MZ (se existir)
 *                 • Botão de ação apropriado (criar lançamento / voltar)
 *
 *   [AN-FEAT-2] Banner de transparência quando o artista TEM dados:
 *               mostra a data do último relatório e há quantos dias foi.
 *               Explica o ciclo 45-90 dias. Útil para o artista entender
 *               que se lançou uma música há 1 mês, é normal ela não
 *               aparecer ainda.
 *
 *   [AN-DB-1]   Nova query em daily_stats para obter MAX/MIN/COUNT por
 *               artista numa única chamada. Usada para decidir se mostra
 *               empty state ou dashboard normal. Performance: a tabela já
 *               tem índice idx_artist_date, então é O(log n).
 *
 * == 1.5.15 (2026-04) — Levantar de Carteira OU Royalties ==
 *   Antes o botão "LEVANTAR" tratava os 2 saldos como um só, apesar de
 *   serem totalmente diferentes (Carteira em MT + Royalties em USD). Se
 *   o artista pedisse mais do que tinha em MT, o pedido era rejeitado
 *   mesmo tendo royalties suficientes. Agora o artista escolhe a fonte.
 *
 *   [WD-FEAT-1] Modal de levantamento passa a ter 2 etapas:
 *               1) Escolher fonte — mostra saldo de cada uma e desativa
 *                  opções sem saldo mínimo (Carteira <50 MT, Royalties
 *                  <equivalente mínimo em USD à taxa actual).
 *               2) Formulário específico da fonte:
 *                  • Carteira: input em MT, fluxo clássico (debita MT).
 *                  • Royalties: input em USD com preview em tempo real
 *                    da conversão para MT. Ao submeter, reusa a função
 *                    atómica rmbz_secure_transfer_usd_to_mt() (transação
 *                    SQL com rollback) e depois cria o withdrawal request
 *                    marcado com source='royalties'.
 *               Botão "← Alterar fonte" para voltar atrás sem fechar.
 *
 *   [WD-DB-1]   Coluna nova `source` (varchar 20, default 'wallet') em
 *               rmbz_withdrawal_requests + índice. Schema version 2.1.2
 *               → 2.1.3 para forçar dbDelta. Registos antigos ficam
 *               automaticamente como 'wallet' (default), consistente.
 *
 *   [WD-UX-1]   Histórico de "Pedidos de Levantamento" na página Minha
 *               Conta passa a ter coluna "Fonte" a mostrar 💼 Carteira
 *               ou 🎵 Royalties para cada pedido — transparência total.
 *
 *   [WD-LOCK-1] Bloqueio "1 pedido pendente de cada vez" mantido (como
 *               pedido). Aplica-se a ambas as fontes: enquanto houver
 *               qualquer pedido pendente, nenhum novo pode ser criado.
 *
 *   [WD-SAFE-1] Se a conversão USD→MT correr mas o débito seguinte
 *               falhar (cenário raro), o artista recebe aviso explícito
 *               de que os MT foram creditados na carteira e pode refazer
 *               o pedido dali. Nunca se perde dinheiro.
 *
 * == 1.5.14 (2026-04) — Serviços Exclusivos com detecção de conflitos ==
 *   Reforma completa do workflow de revisão de pedidos de serviços exclusivos
 *   (YT Content ID, FB Rights Manager, TikTok Monetization) com base em
 *   pesquisa sobre como distribuidores profissionais (FUGA, TuneCore, Symphonic,
 *   DistroKid) fazem esta verificação.
 *
 *   [EXC-DB-1] Schema: 6 novas colunas em rmbz_distro_release_platforms
 *               ({yt,fb,tt}_{conflict_url,conflict_source}) + schema version
 *               bump 1.1 → 1.2 para forçar migração automática.
 *
 *   [EXC-ADMIN-1] Leitor de áudio embebido por card — admin pode ouvir a
 *                 faixa principal (e todas as outras se for EP/álbum) sem
 *                 descarregar. Funciona em todas as tabs (Pendentes /
 *                 Aprovados / Conflitos / Rejeitados / Todos).
 *
 *   [EXC-ADMIN-2] 9 botões de pesquisa externa por card (YouTube, YouTube
 *                 Music, YT Shorts, Spotify, TikTok, Facebook, Instagram,
 *                 Google exacto em youtube.com, e busca por ISRC quando
 *                 existe). URLs pré-preenchidas com título + artista.
 *                 Cada botão abre em nova tab.
 *
 *   [EXC-ADMIN-3] Novo estado "conflict" além de approved/rejected. Quando
 *                 marca conflito, admin preenche: URL da evidência,
 *                 distribuidor/titular suspeito e descrição (obrigatória).
 *                 Workflow: Aprovar / Marcar conflito / Rejeitar (3 botões).
 *                 Decisões já tomadas podem ser revertidas.
 *
 *   [EXC-ADMIN-4] Nova tab "⚠️ Conflitos" com contador próprio. Badge do
 *                 menu principal agora agrega requested+conflict (ambos
 *                 requerem atenção admin).
 *
 *   [EXC-ARTIST-1] Na single-view do release, novo bloco "Serviços
 *                  Exclusivos" mostra ao artista o estado de cada pedido:
 *                    ✅ Aprovado — descrição do que isso significa
 *                    ⏳ Em análise — tempo médio de resposta
 *                    ❌ Rejeitado — motivo completo do admin
 *                    ⚠️ Conflito — descrição + distribuidor suspeito + URL
 *                       da evidência + guia do que fazer (contactar equipa,
 *                       pedir takedown, provar titularidade).
 *                  Só aparece se o artista pediu pelo menos um serviço.
 *
 *   [EXC-NOTIF-1] Handler de decisão em distribution-platforms.php agora
 *                 aceita decision=conflict, valida campos, e envia
 *                 notificação ao artista com mensagem adaptada ao tipo
 *                 de decisão (aprovado/rejeitado/conflito com fonte).
 *
 * == 1.5.13 (2026-04) — Hotfix "headers already sent" ao limpar órfãos ==
 *   [HDR-FIX-1] Na 1.5.12, o botão "🧹 Limpar órfãos" fazia um redirect
 *               com wp_safe_redirect() DENTRO da função de render
 *               (rmbz_render_exclusive_services_page). Problema: quando
 *               essa função corre, o WordPress já enviou o header HTML
 *               do admin (barra preta, menu lateral, etc.), portanto os
 *               headers HTTP já foram para o browser. wp_safe_redirect
 *               tenta enviar novos headers e falha com dois warnings:
 *                 Warning: Cannot modify header information - headers
 *                 already sent by .../template.php:2663 in pluggable.php:1531
 *               A limpeza funcionava (a BD era saneada), mas o utilizador
 *               via 2 avisos PHP antes da página recarregar.
 *   [HDR-FIX-2] Correção: handler movido para um add_action('admin_init'),
 *               que corre ANTES do WordPress renderizar qualquer HTML admin.
 *               Verifica $_GET['page'], nonce e capability, faz o DELETE e
 *               wp_safe_redirect sem problemas. A função de render apenas
 *               lê $orphan_count para mostrar o banner de aviso.
 *
 * == 1.5.12 (2026-04) — Contadores de Serviços Exclusivos deixam de mentir ==
 *   [DB-FIX-1] Bug: na página "Serviços Exclusivos" (admin), a tab
 *              "Pendentes" mostrava "(6)" mas ao clicar a tabela dizia
 *              "Sem pedidos neste filtro". Causa: a query de contagem
 *              fazia SELECT sobre $table_p directamente, enquanto a query
 *              de listagem fazia INNER JOIN com $table_r (releases). Se um
 *              release fosse apagado mas ficassem registos órfãos em
 *              rmbz_distro_release_platforms (a tabela não tem ON DELETE
 *              CASCADE), esses órfãos contavam na badge mas nunca apareciam
 *              na listagem — inflando contador sem razão. A mesma mentira
 *              acontecia na badge "🔒 Serviços Exclusivos" do menu principal
 *              (admin-distribution-controller.php:46).
 *   [DB-FIX-2] Ambas as queries de contagem agora fazem JOIN com releases.
 *              Os números batem sempre com a listagem.
 *   [DB-FIX-3] Bónus: banner de aviso no topo da página quando detecta
 *              órfãos, com botão "🧹 Limpar órfãos" (com nonce CSRF e
 *              confirmação JS) para o admin sanear a BD.
 *
 * == 1.5.11 (2026-04) — Fim do efeito "esticar" no mobile da Distribuição ==
 *   [CSS-FIX-4] Feedback do utilizador: na página Minha Conta o layout
 *               mobile é natural, mas na Distribuição notava-se um efeito
 *               de "esticar" (container arrasta para os lados no carregamento).
 *               Causa raiz: distribution.css e distribution-fullbleed.css
 *               aplicavam full-bleed (margin-left: calc(50% - 50vw)) como
 *               DEFAULT, e depois usavam @media (max-width: 768px) para
 *               desactivar no mobile. Só que o browser renderiza o CSS base
 *               primeiro, depois avalia media queries — havia um instante
 *               em que o container esticava 100vw antes de voltar ao
 *               layout mobile. No mesmo contexto, a página Minha Conta
 *               (frontend.css) nunca teve isso porque usa max-width:900px
 *               + margin:auto, sem full-bleed.
 *   [CSS-FIX-5] Solução: inverter a lógica. Agora o full-bleed só é
 *               aplicado em @media (min-width: 769px) — ou seja, só em
 *               desktop. No mobile, o container nasce já com width:auto
 *               + margin:auto (idêntico à Minha Conta), sem transições
 *               visuais. Aplicado em distribution.css e distribution-fullbleed.css.
 *
 * == 1.5.10 (2026-04) — Taxa de câmbio inválida deixa de zerar saldos em MT ==
 *   [FIN-FIX-1] Bug: se o admin guardasse o campo "Taxa de Câmbio" vazio
 *               ou com valor inválido, rmbz_sanitize_float_setting() devolvia
 *               0.0 e guardava 0.0 na BD. Depois get_option('rmbz_exchange_rate',
 *               63.50) devolvia 0 — o default 63.50 SÓ se aplica quando a
 *               opção NÃO existe, não quando existe como 0. Resultado: o
 *               saldo estimado em MT aparecia como "0,00 MT" no dashboard
 *               de distribuição e na página Minha Conta.
 *   [FIN-FIX-2] O distribution-financial.php já tinha guard manual
 *               (`if ($rate <= 0) $rate = 63.50;`) mas os outros 2 sítios
 *               que lêem a taxa (my-account-page.php, distribution-main.php)
 *               não tinham. Fix inconsistente.
 *   [FIN-FIX-3] Solução: novo helper rmbz_get_exchange_rate() em admin-settings.php
 *               que centraliza o fallback. Todos os 3 call sites agora usam
 *               a função com fallback defensivo (function_exists check).
 *
 * == 1.5.9 (2026-04) — Hotfix preto cobria o site inteiro no mobile ==
 *   [CSS-FIX] No distribution.css (dentro do @media max-width:768px),
 *             quatro regras aplicavam background:#121212 a elementos do
 *             tema WordPress (html/body, #page/.site/main/.entry-content/
 *             .elementor*, .entry-title e .rmbz-distro-mobile-parent),
 *             pintando todo o site de preto e escondendo header/menu/
 *             footer no telemóvel. Removidos apenas esses 4 backgrounds;
 *             o resto do layout mobile (largura, margin, padding, etc.)
 *             fica exactamente como estava — o container do plugin
 *             continua a ocupar a tela toda com o seu próprio fundo
 *             escuro, o tema do site continua a aparecer normalmente.
 *
 * == 1.5.8 (2026-04) — Hotfix overlay do modal "Detalhes da faixa" no PC ==
 *   [MODAL-FIX-1] O modal era renderizado dentro de .rmbz-wizard-v2. Quando
 *                 algum container pai tem transform/filter/will-change, o
 *                 `position: fixed` torna-se relativo a esse container em
 *                 vez do viewport — overlay aparecia partido (só meio escuro).
 *                 Agora o modal é movido para <body> na primeira abertura.
 *                 Também bloqueia/restaura scroll do body.
 *   [MODAL-FIX-2] CSS reforçado: top/right/bottom/left explícitos +
 *                 width:100vw/height:100vh em vez de só `inset:0` (compat).
 *
 * == 1.5.7 (2026-04) — UX exclusivos: feedback visual ao marcar checkbox ==
 *   [OPTIN-FB-1] Quando o artista marca a checkbox de "Li o aviso e aceito"
 *                de um serviço exclusivo (FB monetization, TikTok, etc.), o
 *                badge no topo do card muda imediatamente para "★ marcado —
 *                guardar para enviar". Antes ficava em "não solicitado" até
 *                o user clicar em CONFIRMAR E CONTINUAR no fundo, gerando
 *                confusão. Aviso laranja na barra de status reforça que tem
 *                de guardar.
 *   [OPTIN-FB-2] Backend devolve agora a lista de exclusivos pedidos para
 *                feedback claro pós-save ("3 plataformas guardadas · 2
 *                exclusivo(s) pedido(s)").
 *
 * == 1.5.6 (2026-04) — Hotfix colunas return-workflow em falta ==
 *   [BUG-SCHEMA3] Colunas returned_at, return_deadline, return_problems,
 *                 return_count em rmbz_distro_releases não existiam em
 *                 instalações onde o ALTER inicial falhou silenciosamente.
 *                 O guard de versão (version_compare 1.0) impedia nova
 *                 tentativa. Corrigido em 3 frentes:
 *                 1. functions-setup.php agora declara TODAS as colunas
 *                    no CREATE TABLE (incluindo rejection_*, takedown_*,
 *                    live_at, submitted_at, e as 4 de return).
 *                 2. distribution-return-workflow.php agora só grava a
 *                    versão se confirmar que TODAS as colunas existem
 *                    após o ALTER. Versão bumpada 1.0 → 1.1.
 *                 3. Acessos a $current->return_count etc. agora usam ??
 *                    para evitar PHP 8 warning se a coluna ainda em falta.
 *                 4. rmbz_db_version bumpada 2.1.1 → 2.1.2 para forçar
 *                    dbDelta a re-correr.
 *
 * == 1.5.5 (2026-04) — Validação de metadata por faixa + estado wizard ==
 *   [STATE-FIX] $step3_done agora respeita o mínimo de faixas conforme tipo
 *               declarado (Single 1+, EP 4+, Álbum 7+). Antes o wizard
 *               saltava para Capa após 1 só faixa mesmo num EP.
 *   [META-CHECK-1] Novo helper rmbz_track_validate_metadata() que valida
 *                  songwriter obrigatório, lyrics se has_lyrics, dados de
 *                  cover se is_cover, ISRC original se previously_released.
 *   [META-CHECK-2] Cada faixa no Passo 3 mostra ✓ verde (pronta) ou ⚠
 *                  laranja com lista do que falta. Botão "Completar" fica
 *                  vermelho se incompleto.
 *   [META-CHECK-3] CSS: borda esquerda verde/vermelha conforme estado.
 *   [META-CHECK-4] Backend rmbz_safe_finalize_release valida metadata por
 *                  faixa antes de submeter. Mensagem específica do que falta
 *                  em cada faixa.
 *   [META-CHECK-5] Frontend pre-check antes de FINALIZAR: avisa quais
 *                  faixas estão incompletas e dá scroll para a primeira.
 *
 * == 1.5.4 (2026-04) — Reordenação de faixas com setas ↑↓ ==
 *   [REORDER-1] Novo handler AJAX onemz_reorder_distro_track:
 *               nonce + auth + ownership + estado-do-release + transação.
 *   [REORDER-2] Botões ↑↓ ao lado de cada faixa no Passo 3 (primeira faixa
 *               sem ↑, última sem ↓, escondidas se só houver 1 faixa).
 *   [REORDER-3] CSS com efeito hover + estado loading + animação subtil
 *               quando se tenta mover além do limite.
 *   [REORDER-4] JS com listener delegado + recarrega página em sucesso
 *               para garantir números 1,2,3 sempre coerentes com a BD.
 *
 * == 1.5.3 (2026-04) — Hotfix UX cards + botão adicionar faixas ==
 *   [CARD-FOCUS] Focus do teclado já não aparece como halo só à volta do
 *                ícone (parecia bug). Agora destaca o card inteiro.
 *   [TRACK-ADD] Botão "+ Adicionar faixa" reaparece após carregar a 1ª
 *                faixa (antes só aparecia com $s3==='current', que ficava
 *                false após 1 faixa). Agora aparece enquanto houver
 *                capacidade no tipo declarado (Single<3, EP<6, Álbum<50).
 *   [TRACK-ADD] Adicionado também 2º botão dentro do body do passo 3,
 *                com indicador "X de Y permitidas".
 *
 * == Changelog 1.5.2 (2026-04) — Hotfix cards de tipo de lançamento ==
 *   [HOTFIX-RT1] Cards Single/EP/Álbum não respondiam ao clique. Causa: o
 *                listener estava no <label> exterior, mas o browser dispara
 *                o evento 2x (uma do label, outra do <input> propagado),
 *                deixando o estado inconsistente. Agora ouvimos 'change' no
 *                radio — dispara 1x e funciona com teclado.
 *   [HOTFIX-RT2] Removido pointer-events:none do radio que podia bloquear
 *                eventos noutros browsers. Substituído por hide acessível.
 *
 * == Changelog 1.5.1 (2026-04) — Wizard distribuição + Bugs schema ==
 *
 * NOVAS FUNCIONALIDADES — Wizard de distribuição
 *   [WIZ-RT1..9] Selector visual Single/EP/Álbum no Passo 1 (cards) com
 *                limites Spotify (Single 1-3, EP 4-6, Álbum 7+) validados
 *                em 3 camadas: cliente, ao adicionar faixa, e ao finalizar.
 *                Removeu o badge duplicado do Passo 3.
 *
 * BUGS PRÉ-EXISTENTES — Schema da BD
 *   [BUG-SCHEMA1] A coluna `extra_data` em `rmbz_distro_releases` nunca
 *                 foi criada no schema original. Todo o código que escrevia
 *                 nela falhava silenciosamente. Adicionada ao CREATE TABLE
 *                 e bumpada versão para forçar dbDelta.
 *   [BUG-SCHEMA2] Conflito de versão: `functions-setup.php` e
 *                 `distribution-schema-patch.php` partilhavam a option
 *                 `rmbz_db_version` mas com escalas incompatíveis (2.1.x
 *                 vs 1.x). O patch da distribuição NUNCA corria após o
 *                 functions-setup ter posto 2.1.0. Agora usa chave isolada
 *                 `rmbz_distro_schema_version`.
 *
 * == Changelog 1.5.0 (2026-04) — Auditoria geral de segurança ==
 * (Ver CHANGELOG-SECURITY-1.5.0.md para detalhe completo de cada correção)
 *
 * SEGURANÇA — Vulnerabilidades de autorização / CSRF
 *   [SEC-A1..A7] ajax-upload.php — handler aberto a anónimos, MIME validation
 *                fraca (OR em vez de AND), sem rate limit, sem renomeação.
 *   [SEC-MA1..5] my-account-page.php — handlers AJAX de notificações sem
 *                nonce/auth/ownership; aceitavam telemóvel/email arbitrários.
 *                CSRF logout. Rate limit anti brute-force PIN.
 *   [SEC-FH1..6] form-handler.php — auth+ownership ausentes em
 *                onemz_artist_choose_budget; nonces ausentes em vários
 *                admin_post handlers; upload de álbum sem MIME real.
 *   [SEC-AUTH1..3] auth-functions.php / login — recovery sem rate limit,
 *                  enumeração de contas, mt_rand para chave de reset (agora
 *                  CSPRNG), sem time-safe compare, sem limite de tentativas.
 *   [SEC-UP1] user-perfil.php — verify_profile_otp sem nonce.
 *   [SEC-SP1] distribution-splits.php — admin handler validava só referer.
 *   [SEC-SC1] submission-corrections.php — uploads sem MIME restrito.
 *
 * BUGS — Lógicos / Race conditions
 *   [BUG-WB1] wallet-functions.php — TOCTOU no update_wallet_balance.
 *   [BUG-WD1..3] handlers de saque/depósito — falta de idempotência permitia
 *                duplo reembolso e duplo pedido pendente.
 *
 * BOAS PRÁTICAS
 *   [SQL-1] Várias queries com interpolação substituídas por $wpdb->prepare.
 *   Sanitização padronizada com wp_unslash em todos os $_POST/$_GET.
 *   session_start defensivo antes de cada escrita em $_SESSION.
 */

// Segurança: Bloquear acesso direto ao ficheiro.
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * --------------------------------------------------------------------------
 * Constantes do Plugin
 * --------------------------------------------------------------------------
 */
define( 'RMBZ_PLUGIN_PATH', plugin_dir_path( __FILE__ ) );
define( 'RMBZ_PLUGIN_URL', plugin_dir_url( __FILE__ ) );

/**
 * [1.5.191] Versão do plugin lida DINAMICAMENTE do header.
 *
 * Antes: hard-coded `define( 'RMBZ_PLUGIN_VERSION', '1.5.116' )` ficava
 * desalinhada do header (que estava em 1.5.190 — 74 versões à frente).
 * Resultado: scripts e CSS faziam cache-busting com versão antiga,
 * potencialmente servindo assets cached em vez dos novos.
 *
 * Agora: leitura via `get_file_data()` (função core do WordPress) com
 * static cache para evitar I/O em cada page load. A constante fica
 * SEMPRE em sync com o header — quando alteras o `Version: x.y.z` no
 * topo do ficheiro, o cache-busting actualiza automaticamente.
 *
 * Esta abordagem é o padrão recomendado pelos próprios docs do WordPress
 * para plugins que precisam de aceder à sua própria versão.
 */
function rmbz_get_plugin_version() {
	static $cached = null;
	if ( $cached !== null ) {
		return $cached;
	}

	// `get_file_data` é parte do core do WordPress (wp-includes/functions.php).
	// Lê só o cabeçalho do ficheiro, é leve.
	if ( ! function_exists( 'get_file_data' ) ) {
		// Fallback paranoico se carregado antes do WP. Não deve acontecer
		// porque este ficheiro é o entry point do plugin, mas garante.
		return $cached = '1.0';
	}

	$data    = get_file_data( __FILE__, [ 'Version' => 'Version' ] );
	$cached  = ! empty( $data['Version'] ) ? $data['Version'] : '1.0';

	return $cached;
}

define( 'RMBZ_PLUGIN_VERSION', rmbz_get_plugin_version() );
define( 'RMBZ_DB_VERSION', '1.5' );

/**
 * --------------------------------------------------------------------------
 * Carregamento de Módulos
 * --------------------------------------------------------------------------
 */

function rmbz_carregar_ficheiro( $caminho_relativo ) {
    $caminho_completo = RMBZ_PLUGIN_PATH . $caminho_relativo;
    if ( file_exists( $caminho_completo ) ) {
        require_once $caminho_completo;
    } else {
        error_log( '1MZ For Artists - Erro: Ficheiro não encontrado: ' . $caminho_completo );
    }
}

// 1. Core e Configuração
rmbz_carregar_ficheiro( 'includes/functions-setup.php' ); 
// [DS 1.5.31] Design System — helpers PHP (rmbz_btn, rmbz_modal, rmbz_field, etc)
// Carregado cedo para estar disponível em todos os outros módulos.
rmbz_carregar_ficheiro( 'includes/design-system.php' );
rmbz_carregar_ficheiro( 'includes/auth-functions.php' ); 
// Health-check de tabelas — carregar cedo para detectar e reparar automaticamente
rmbz_carregar_ficheiro( 'includes/schema-health-check.php' );
rmbz_carregar_ficheiro( 'includes/wallet-functions.php' ); 
rmbz_carregar_ficheiro( 'includes/subscription-functions.php' );
rmbz_carregar_ficheiro( 'includes/email-manager.php' ); 
rmbz_carregar_ficheiro( 'includes/user-perfil.php' ); 
rmbz_carregar_ficheiro( 'includes/intro-welcome.php' ); 
rmbz_carregar_ficheiro( 'includes/ajax-upload.php' ); 
// Catálogo, reivindicações, impulsionamento e monetização passam a usar a
// mesma sessão telemóvel + PIN e a mesma subscrição do 1MZ For Artists.
rmbz_carregar_ficheiro( 'includes/art-tracks-native.php' );
rmbz_carregar_ficheiro( 'includes/artists-integration.php' );
rmbz_carregar_ficheiro( 'includes/art-tracks-account.php' );
rmbz_carregar_ficheiro( 'includes/site-publication-workflow.php' );
rmbz_carregar_ficheiro( 'includes/partnership-program.php' );
rmbz_carregar_ficheiro( 'includes/download-monetization.php' );

// 2. Frontend e Shortcodes
rmbz_carregar_ficheiro( 'includes/meta-boxes.php' );
rmbz_carregar_ficheiro( 'includes/shortcodes.php' );
rmbz_carregar_ficheiro( 'includes/showcase-shortcode.php' ); // [1mz_showcase] — apresentação oficial

// 3. Processamento de Dados
rmbz_carregar_ficheiro( 'includes/form-handler.php' ); 
rmbz_carregar_ficheiro( 'includes/ajax-functions.php' ); 

// 4. Páginas de Utilizador
rmbz_carregar_ficheiro( 'includes/my-account-page.php' );
// [1.5.414] Painel VIP Desktop (Material 3, OLED/claro) — opt-in só para
// artistas VIP; injeta-se dentro de rmbz_exibir_my_account_page(). Carregar
// A SEGUIR ao my-account-page.php (usa rmbz_safe_redirect e o handler de
// levantamento rmbz_request_withdrawal definidos lá).
rmbz_carregar_ficheiro( 'includes/my-account-vip.php' );
// [1.5.412] Portal da Equipa de Produção — mesma página minha-conta, login
// com telefone do perfil de "Equipas & Pessoas" + PIN (núcleo em project-team.php)
rmbz_carregar_ficheiro( 'includes/team-portal-page.php' );
// [1.5.413] Carteira da equipa + levantamentos eMola/M-Pesa (tabela
// rmbz_team_payouts, página admin "Equipa 💼 Carteira", handlers do portal)
rmbz_carregar_ficheiro( 'includes/team-portal-withdrawals.php' );

// 5. Painel de Administração
rmbz_carregar_ficheiro( 'includes/admin/admin-menus.php' ); 
rmbz_carregar_ficheiro( 'includes/admin/admin-wd-styles.php' );
rmbz_carregar_ficheiro( 'includes/admin/admin-views.php' ); 
// [1.5.193] Página dedicada Projectos & Chat (v2 full-screen + live chat).
// Define rmbz_pjv2_render_page() chamada por onemz_mostrar_pagina_projetos()
// em admin-views.php. Sem este include, a página mostra
// "Erro: módulo projects-admin-page.php não carregado.".
rmbz_carregar_ficheiro( 'includes/admin/projects-admin-page.php' );
// [1.6.367] FIX: project-team.php (funções rmbz_team_*, schema e handlers de
// "Equipas & Pessoas") nunca era carregado → Fatal error rmbz_team_departments()
// indefinida ao abrir a aba de equipa. Tem de vir ANTES do render do projeto.
rmbz_carregar_ficheiro( 'includes/admin/project-team.php' );
rmbz_carregar_ficheiro( 'includes/admin/admin-handlers.php' );
rmbz_carregar_ficheiro( 'includes/admin/admin-settings.php' ); 
rmbz_carregar_ficheiro( 'includes/admin/admin-notices.php' ); 
rmbz_carregar_ficheiro( 'includes/admin/admin-actions.php' ); 
// [FEAT 1.5.22] Guarda-Costas — auditoria forense de levantamentos
rmbz_carregar_ficheiro( 'includes/admin/admin-withdrawal-guard.php' );
// [1.5.212] Controlo de versão da app Android (force/soft update + emergência)
rmbz_carregar_ficheiro( 'includes/admin/app-version-control.php' );
// [1.5.213] Telemetria da app — online now + distribuição de versões
rmbz_carregar_ficheiro( 'includes/admin/app-telemetry.php' );
// [1.5.215] Invalidação imediata de access tokens (revogação real)
rmbz_carregar_ficheiro( 'includes/admin/app-token-revoke.php' );
// [1.5.219] Pop-up de recomendação + página de download do APK
rmbz_carregar_ficheiro( 'includes/admin/app-download-promo.php' );

// 6. Módulo de Distribuição
rmbz_carregar_ficheiro( 'includes/admin/admin-distribution-controller.php' ); 
rmbz_carregar_ficheiro( 'includes/admin/admin-distribution-releases.php' );
rmbz_carregar_ficheiro( 'includes/admin/admin-distribution-advanced.php' );
rmbz_carregar_ficheiro( 'includes/admin/admin-distribution-settings.php' );
// [1.5.211] Detecção de plágio (fingerprinting acústico via Chromaprint + AcoustID)
rmbz_carregar_ficheiro( 'includes/admin/fingerprint-detection.php' );
rmbz_carregar_ficheiro( 'includes/admin/import-track-matcher.php' );
rmbz_carregar_ficheiro( 'includes/admin/admin-match-review.php' );
rmbz_carregar_ficheiro( 'includes/admin/import-profiles.php' ); 
rmbz_carregar_ficheiro( 'includes/admin/import-processor.php' ); 
// [DS 1.5.90] Mapeamentos manuais de ISRC + exportar relatório/erros
rmbz_carregar_ficheiro( 'includes/admin/import-isrc-mapping.php' );
rmbz_carregar_ficheiro( 'includes/admin/import-export-report.php' );
// [DS 1.5.104] Diagnóstico de integridade de imports (detecção de dados estragados)
rmbz_carregar_ficheiro( 'includes/admin/import-diagnostics.php' );
// [DS 1.5.108] Arquivo de CSVs + desfazer anulação
rmbz_carregar_ficheiro( 'includes/admin/import-archive.php' );
rmbz_carregar_ficheiro( 'includes/admin/import-unrevert.php' );
// [DS 1.5.115] Re-importação adicional (apanhar linhas desmarcadas)
rmbz_carregar_ficheiro( 'includes/admin/import-additional.php' );
// [FEAT 1.6.11] Validação proactiva do CSV (encoding, separador, colunas inconsistentes)
rmbz_carregar_ficheiro( 'includes/admin/import-csv-validator.php' );
// [DS 1.5.110] Detecção e correção de wallets MT contaminadas com USD
rmbz_carregar_ficheiro( 'includes/admin/wallet-mt-cleanup.php' );
// [1.5.148] Hardening pré-produção: snapshots BD + health dashboard + deploy log
rmbz_carregar_ficheiro( 'includes/admin/predeploy-hardening.php' );
// [1.5.153] Smoke tests automáticos — confirmar essencial após cada deploy
rmbz_carregar_ficheiro( 'includes/admin/smoke-tests.php' );
// [1.5.149] Manual do plugin renderizado dentro do admin
rmbz_carregar_ficheiro( 'includes/admin/manual-page.php' );
rmbz_carregar_ficheiro( 'includes/admin/royalties/royalties-approval-helpers.php' );
rmbz_carregar_ficheiro( 'includes/admin/royalties/royalties-approval-handler.php' );
rmbz_carregar_ficheiro( 'includes/admin/royalties/royalties-controller.php' );
rmbz_carregar_ficheiro( 'includes/admin/royalties/royalties-dashboard.php' );
rmbz_carregar_ficheiro( 'includes/admin/royalties/royalties-imports.php' );
rmbz_carregar_ficheiro( 'includes/admin/royalties/royalties-ledger.php' );
rmbz_carregar_ficheiro( 'includes/admin/royalties/royalties-payouts.php' );
rmbz_carregar_ficheiro( 'includes/admin/royalties/royalties-commissions-reports.php' );
rmbz_carregar_ficheiro( 'includes/admin/royalties/royalties-silent-tracks.php' );
rmbz_carregar_ficheiro( 'includes/admin/royalties/royalties-silent-tracks-retrofix.php' );
// [FEAT 1.6.24] Resumo financeiro mensal por artista (página imprimível)
rmbz_carregar_ficheiro( 'includes/admin/royalties/royalties-artist-summary.php' );
// [FEAT 1.6.26] Visão por DSP (loja)
rmbz_carregar_ficheiro( 'includes/admin/royalties/royalties-by-dsp.php' );
// [FEAT 1.6.27] Saúde do sistema
rmbz_carregar_ficheiro( 'includes/admin/royalties/royalties-health.php' );
// [FEAT 1.6.28] Log de auditoria centralizado
rmbz_carregar_ficheiro( 'includes/admin/royalties/royalties-audit.php' );
// [FEAT 1.6.116] Avisos globais no app — schema + página admin
rmbz_carregar_ficheiro( 'includes/announcements/announcements-schema.php' );
rmbz_carregar_ficheiro( 'includes/announcements/announcements-admin.php' );
// [FEAT 1.6.117] Pedidos de remoção Content ID YouTube
rmbz_carregar_ficheiro( 'includes/contentid/contentid-schema.php' );
rmbz_carregar_ficheiro( 'includes/contentid/contentid-helpers.php' );
rmbz_carregar_ficheiro( 'includes/contentid/contentid-admin.php' );
rmbz_carregar_ficheiro( 'includes/contentid/contentid-frontend.php' );
rmbz_carregar_ficheiro( 'includes/admin/admin-exclusive-services.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-schema-patch.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-security.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-platforms.php' );
// [1.5.162] SVGs oficiais de YouTube/Meta/TikTok para Serviços Exclusivos
rmbz_carregar_ficheiro( 'includes/distribution/distribution-service-logos.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-lifecycle.php' );
// [1.5.147] Date picker isolado — componente reutilizável (modal moderno).
// Carregar ANTES do wizard porque o wizard usa rmbz_render_date_picker().
rmbz_carregar_ficheiro( 'includes/distribution/date-picker.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-wizard.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-audio-validator.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-return-workflow.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-release-tracker.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-release-workflow.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-intelligence.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-splits.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-splits-frontend.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-splits-phases.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-store-requests.php' );
// [1.5.156] Cleanup automático de releases abandonadas (incomplete + returned expirado)
rmbz_carregar_ficheiro( 'includes/distribution/distribution-release-cleanup.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-release-type.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-artist-profiles.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-track-editor.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-licensing.php' );
rmbz_carregar_ficheiro( 'includes/distribution/distribution-analytics.php' );
// [1.5.227] Secções avançadas de estatísticas no site (igualar a app).
rmbz_carregar_ficheiro( 'includes/distribution/distribution-analytics-reports.php' );
// [1.5.232] FASE 2 — Conquistas + Histórico de atividade no site.
rmbz_carregar_ficheiro( 'includes/distribution/distribution-achievements.php' );
// [1.5.235] FASE 3 — Notificações (centro + preferências) no site.
rmbz_carregar_ficheiro( 'includes/distribution/distribution-notifications.php' );
// [1.5.207] BUGFIX — sistema de Conquistas (milestones) nunca foi carregado.
// A função rmbz_milestones_catalog() ficava undefined, fazendo o endpoint
// GET /me/milestones devolver items=[] e a app mostrar "Sem marcos disponíveis".
// Tem de ser carregado antes do milestones-controller (que está em api/controllers).
rmbz_carregar_ficheiro( 'includes/distribution/distribution-milestones.php' );

// 7. Sistema e Scripts
rmbz_carregar_ficheiro( 'includes/cron-jobs.php' ); 
rmbz_carregar_ficheiro( 'includes/filming-cron.php' );
rmbz_carregar_ficheiro( 'includes/first-deposit-gate.php' );
rmbz_carregar_ficheiro( 'includes/first-deposit-expiry.php' );
rmbz_carregar_ficheiro( 'includes/project-fine.php' ); // [1.6.332] Multa por atraso (vídeo em curso sem subscrição)
rmbz_carregar_ficheiro( 'includes/project-transfer.php' ); // [1.5.398] Transferência de projeto entre artistas
rmbz_carregar_ficheiro( 'includes/project-giveup.php' ); // [1.5.439] Desistir do projeto (forçado ou com decisão do artista + conselho IA)
rmbz_carregar_ficheiro( 'includes/contract-pdf.php' ); // [1.5.399] Gerador de contrato em PDF (pré-preenchido)
rmbz_carregar_ficheiro( 'includes/feat-participation.php' ); // [1.5.401] Participação (feat) com consentimento do participante
rmbz_carregar_ficheiro( 'includes/filming-sessions.php' ); // [1.5.404] Sessões de gravação ao vivo (Iniciar Gravação + cronómetro)
rmbz_carregar_ficheiro( 'includes/s3-storage.php' ); // [1.5.406] Armazenamento S3 (uploads do 1MZ no bucket, SigV4 puro)
rmbz_carregar_ficheiro( 'includes/user-blocks.php' );  // [1.6.336] Bloqueios temporários/permanentes (expiração automática)
rmbz_carregar_ficheiro( 'includes/media-cleanup.php' ); // [1.6.364] Limpeza de média órfã (comprovativos/imagens ao apagar)
rmbz_carregar_ficheiro( 'includes/notifications-center.php' );
rmbz_carregar_ficheiro( 'includes/notifications-distro-bridge.php' );
// [1.5.182] Sistema NOVO de notificações inline (banners no topo da página).
// Coexiste com notifications-center.php (que faz emails + sino bell counter) —
// este novo sistema mostra banners VISUAIS proeminentes no topo, em vez de
// só counter no sino. Diferentes propósitos: o antigo notifica via email/bell,
// este avisa visualmente quando o user já está dentro da plataforma.
rmbz_carregar_ficheiro( 'includes/notifications/notifications-core.php' );
// [1.5.183] UI admin para criar e gerir notificações (tab "Comunicação"
// na Distribuição).
rmbz_carregar_ficheiro( 'includes/notifications/notifications-admin.php' );
// [1.5.184] Listeners de eventos — geram notificações automaticamente
// quando algo importante acontece (release returned, SOMAS approved,
// withdrawal approved, etc.). Idempotente via dedup_key.
rmbz_carregar_ficheiro( 'includes/notifications/notifications-events.php' );
rmbz_carregar_ficheiro( 'includes/referral-system.php' );
rmbz_carregar_ficheiro( 'includes/enqueue.php' ); 

// 8.5 Mobile API REST — para o app Android/iOS
// [1.6.0] Endpoints REST em /wp-json/1mz/v1/* (auth, profile, wallet)
rmbz_carregar_ficheiro( 'includes/api/api-bootstrap.php' );

// 8.6 API PRÓPRIA do leitor 1MZ Music Player (separada do app de artista)
// Endpoints REST em /wp-json/1mzmusic/v1/* (config/permissões + contas por e-mail)
rmbz_carregar_ficheiro( 'includes/api/musicplayer/mp-bootstrap.php' );

// 8. Diagnóstico temporário (REMOVER quando terminar — ver Ferramentas → 1MZ Diagnóstico Stats)
rmbz_carregar_ficheiro( 'includes/rmbz-diagnostico-stats.php' );


/**
 * ==========================================================================
 * SELO DE AUTENTICIDADE VIP & SLIDER DE RECOMENDAÇÕES INTELIGENTE
 * ==========================================================================
 */

add_action('add_meta_boxes', function() {
    add_meta_box(
        'rmbz_badge_meta', 
        __('Selo 1MZ For Artists', '1mz-for-artists'), 
        'rmbz_badge_meta_html', 
        'post', 
        'side', 
        'default'
    );
});

// A Caixa no Painel de Administração
function rmbz_badge_meta_html($post) {
    wp_nonce_field('salvar_selo_1mz', 'selo_1mz_nonce');
    $value = get_post_meta($post->ID, '_rmbz_is_artist_submission', true);
    ?>
    <div style="background: #111; border: 1px solid #f0b90b; padding: 20px; border-radius: 8px; color: #fff; box-shadow: inset 0 0 20px rgba(240, 185, 11, 0.05);">
        <h4 style="color: #f0b90b; margin: 0 0 15px 0; font-size: 16px; display: flex; align-items: center; gap: 8px; font-weight: bold;">
            <span style="font-size: 20px;">🛡️</span> <?php esc_html_e('Selo de Verificação VIP', '1mz-for-artists'); ?>
        </h4>
        
        <label style="cursor: pointer; display: flex; align-items: center; gap: 12px; background: #1a1a1a; padding: 15px; border-radius: 6px; border: 1px solid #333; transition: 0.3s;">
            <input type="checkbox" name="rmbz_is_artist_submission" value="1" <?php checked($value, '1'); ?> style="width: 20px; height: 20px; accent-color: #f0b90b;">
            <strong style="font-size: 14px; color: #fff;"><?php esc_html_e('Ativar Selo "1MZ For Artists"', '1mz-for-artists'); ?></strong>
        </label>
        
        <p style="color: #888; font-size: 12px; margin: 15px 0 0 0; line-height: 1.4;">
            <?php esc_html_e('Ao marcar esta opção, o artigo irá exibir publicamente o emblema dourado de autenticidade.', '1mz-for-artists'); ?>
        </p>
    </div>
    <?php
}

add_action('save_post', function($post_id) {
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return;
    if ( ! isset( $_POST['selo_1mz_nonce'] ) || ! wp_verify_nonce( $_POST['selo_1mz_nonce'], 'salvar_selo_1mz' ) ) return;
    if ( ! current_user_can( 'edit_post', $post_id ) ) return;
    if ( wp_is_post_revision( $post_id ) ) return;

    if ( isset( $_POST['rmbz_is_artist_submission'] ) ) {
        update_post_meta($post_id, '_rmbz_is_artist_submission', '1');
    } else {
        delete_post_meta($post_id, '_rmbz_is_artist_submission');
    }
});

// Parte 1 — Filtro no Conteúdo do Post
add_filter('the_content', function($content) {
    if ( ! is_singular('post') || ! in_the_loop() || ! is_main_query() ) {
        return $content;
    }

    $post_id      = get_the_ID();
    $tem_selo_vip = get_post_meta( $post_id, '_rmbz_is_artist_submission', true ) === '1';

    // Injeta o slider inteligente no fim do post
    $slider_html = rmbz_render_musicas_recomendadas( $post_id );
    $content    .= $slider_html;

    // Injeta o Selo VIP Público apenas se o meta estiver ativo
    if ( $tem_selo_vip ) {
        $badge = '
        <div style="background: linear-gradient(135deg, #181818 0%, #0a0a0a 100%) !important; border: 1px solid #333 !important; border-left: 5px solid #f0b90b !important; padding: 20px !important; border-radius: 8px !important; margin: 40px 0 !important; display: flex !important; align-items: center !important; gap: 20px !important; box-shadow: 0 10px 25px rgba(0,0,0,0.5) !important; text-align: left !important; flex-wrap: wrap !important;">
            <div style="position: relative !important; width: 60px !important; height: 60px !important; background: #111 !important; border-radius: 50% !important; border: 2px solid #f0b90b !important; display: flex !important; justify-content: center !important; align-items: center !important; flex-shrink: 0 !important; box-shadow: 0 0 15px rgba(240, 185, 11, 0.2) !important;">
                <span style="font-size: 24px !important; line-height: 1 !important;">🏆</span>
                <div style="position: absolute !important; bottom: -8px !important; background: #f0b90b !important; color: #000 !important; font-size: 9px !important; font-weight: 900 !important; padding: 2px 8px !important; border-radius: 10px !important; text-transform: uppercase !important; letter-spacing: 1px !important; line-height: 1.2 !important; white-space: nowrap !important;">VIP</div>
            </div>
            <div style="flex: 1 !important; min-width: 200px !important;">
                <h4 style="margin: 0 0 5px 0 !important; color: #fff !important; font-size: 18px !important; font-weight: 900 !important; text-transform: uppercase !important; letter-spacing: 0.5px !important; border: none !important; padding: 0 !important; background: transparent !important; display: block !important;">Lan&ccedil;amento Verificado</h4>
                <p style="margin: 0 !important; color: #aaa !important; font-size: 14px !important; line-height: 1.5 !important; display: block !important;">Esta obra foi submetida e validada atrav&eacute;s da plataforma oficial <strong style="color: #f0b90b !important;">1MZ For Artists</strong>. Autenticidade e qualidade garantidas.</p>
            </div>
        </div>';

        $content .= $badge;
    }

    return $content;
});

// Parte 2 — Lógica Inteligente do Slider
function rmbz_render_musicas_recomendadas( $post_id_atual ) {
    
    // Obter categorias do post atual para procurar o mesmo género
    $categorias_post = wp_get_post_categories( $post_id_atual );
    
    // Arrays para guardar os resultados finais
    $posts_vip = [];
    $posts_normal = [];
    $ids_usados = [ $post_id_atual ];

    // ==========================================
    // 1. BUSCAR VIPs (Objetivo: 3 Posts)
    // ==========================================
    if ( ! empty( $categorias_post ) ) {
        // Tenta buscar do MESMO GÉNERO
        $query_vip_cat = new WP_Query([
            'post_type'      => 'post',
            'posts_per_page' => 3,
            'post__not_in'   => $ids_usados,
            'category__in'   => $categorias_post,
            'orderby'        => 'rand', // Aleatório
            'meta_query'     => [
                [ 'key' => '_rmbz_is_artist_submission', 'value' => '1' ],
            ],
        ]);
        $posts_vip = $query_vip_cat->posts;
        foreach($posts_vip as $p) { $ids_usados[] = $p->ID; }
    }

    // FALLBACK: Se faltarem VIPs, procura em QUALQUER GÉNERO
    if ( count( $posts_vip ) < 3 ) {
        $faltam_vips = 3 - count( $posts_vip );
        $query_vip_rand = new WP_Query([
            'post_type'      => 'post',
            'posts_per_page' => $faltam_vips,
            'post__not_in'   => $ids_usados,
            'orderby'        => 'rand', // Totalmente aleatório na base de dados
            'meta_query'     => [
                [ 'key' => '_rmbz_is_artist_submission', 'value' => '1' ],
            ],
        ]);
        $posts_vip = array_merge( $posts_vip, $query_vip_rand->posts );
        foreach($query_vip_rand->posts as $p) { $ids_usados[] = $p->ID; }
    }

    // ==========================================
    // 2. BUSCAR NORMAIS (Objetivo: 2 Posts sem selo VIP)
    // ==========================================
    if ( ! empty( $categorias_post ) ) {
        // Tenta buscar do MESMO GÉNERO
        $query_normal_cat = new WP_Query([
            'post_type'      => 'post',
            'posts_per_page' => 2,
            'post__not_in'   => $ids_usados,
            'category__in'   => $categorias_post,
            'orderby'        => 'rand',
            'meta_query'     => [
                'relation' => 'OR',
                [ 'key' => '_rmbz_is_artist_submission', 'compare' => 'NOT EXISTS' ],
                [ 'key' => '_rmbz_is_artist_submission', 'value' => '1', 'compare' => '!=' ],
            ],
        ]);
        $posts_normal = $query_normal_cat->posts;
        foreach($posts_normal as $p) { $ids_usados[] = $p->ID; }
    }

    // FALLBACK: Se faltarem Normais, procura em QUALQUER GÉNERO
    if ( count( $posts_normal ) < 2 ) {
        $faltam_normais = 2 - count( $posts_normal );
        $query_normal_rand = new WP_Query([
            'post_type'      => 'post',
            'posts_per_page' => $faltam_normais,
            'post__not_in'   => $ids_usados,
            'orderby'        => 'rand', // Aleatório na base de dados inteira
            'meta_query'     => [
                'relation' => 'OR',
                [ 'key' => '_rmbz_is_artist_submission', 'compare' => 'NOT EXISTS' ],
                [ 'key' => '_rmbz_is_artist_submission', 'value' => '1', 'compare' => '!=' ],
            ],
        ]);
        $posts_normal = array_merge( $posts_normal, $query_normal_rand->posts );
    }

    // Juntar as duas listas (3 VIP + 2 Normais) e baralhar para ficarem misturados aleatoriamente no ecrã!
    $todos_posts = array_merge( $posts_vip, $posts_normal );
    shuffle( $todos_posts );

    if ( empty( $todos_posts ) ) {
        return '';
    }

    global $post;
    $cards_html = '';
    
    // Gerar o HTML para cada cartão
    foreach ( $todos_posts as $post ) {
        setup_postdata( $post );

        $titulo  = get_the_title();
        $link    = get_permalink();
        
        $is_vip = get_post_meta( get_the_ID(), '_rmbz_is_artist_submission', true ) === '1';

        $artista = get_post_meta( get_the_ID(), '_rmbz_artist_name', true );
        if ( ! $artista ) {
            $artista = get_the_author_meta('display_name');
        }

        if ( has_post_thumbnail() ) {
            $img_url = get_the_post_thumbnail_url( get_the_ID(), 'medium' );
        } else {
            $img_url = '';
        }

        $titulo_safe  = esc_html( wp_strip_all_tags( $titulo ) );
        $artista_safe = esc_html( wp_strip_all_tags( $artista ) );
        $inicial_safe = esc_html( mb_strtoupper( mb_substr( wp_strip_all_tags( $titulo ), 0, 1 ) ) );

        $img_html = $img_url
            ? '<img src="' . esc_url( $img_url ) . '" alt="' . esc_attr( $titulo_safe ) . '" style="width:100% !important; height:160px !important; object-fit:cover !important; display:block !important; border-radius:0 !important; margin:0 !important;">'
            : '<div style="width:100% !important; height:160px !important; background:linear-gradient(135deg,#1a1a1a,#2a2a2a) !important; display:flex !important; align-items:center !important; justify-content:center !important; font-size:48px !important; font-weight:900 !important; color:#f0b90b !important;">' . $inicial_safe . '</div>';

        $vip_badge_html  = '';
        $vip_footer_html = '';
        
        // Se for VIP, adiciona o badge dourado e a marca no rodapé
        if ( $is_vip ) {
            $vip_badge_html = '<div style="position: absolute !important; top: 12px !important; right: 0 !important; background: linear-gradient(90deg, #f0b90b, #d4a000) !important; color: #000 !important; font-size: 10px !important; font-weight: 900 !important; padding: 4px 10px !important; border-radius: 15px 0 0 15px !important; text-transform: uppercase !important; letter-spacing: 0.5px !important; box-shadow: -2px 2px 10px rgba(0,0,0,0.5) !important;">⭐ VIP 1MZ</div>';
            
            $vip_footer_html = '
            <div style="margin-top: 12px !important; padding-top: 12px !important; border-top: 1px dashed #333 !important; display: flex !important; align-items: center !important; gap: 8px !important;">
                <div style="width: 18px !important; height: 18px !important; background: #111 !important; border: 1px solid #f0b90b !important; border-radius: 50% !important; display: flex !important; justify-content: center !important; align-items: center !important; font-size: 9px !important; flex-shrink: 0 !important;">🏆</div>
                <span style="font-size: 10px !important; color: #aaa !important; line-height: 1.2 !important; text-transform: uppercase !important;">Lançado via <strong style="color: #f0b90b !important; display:block !important;">1MZ For Artists</strong></span>
            </div>';
        }

        $cards_html .= '
        <a href="' . esc_url( $link ) . '" class="rmbz-rec-card" style="
            flex: 0 0 180px !important;
            width: 180px !important;
            background: #141414 !important;
            border: 1px solid #2a2a2a !important;
            border-radius: 12px !important;
            overflow: hidden !important;
            text-decoration: none !important;
            transition: transform 0.3s, border-color 0.3s, box-shadow 0.3s !important;
            scroll-snap-align: start !important;
            display: flex !important;
            flex-direction: column !important;
            position: relative !important;
        " onmouseover="this.style.transform=\'translateY(-5px)\';this.style.borderColor=\'#f0b90b\';this.style.boxShadow=\'0 10px 25px rgba(240, 185, 11, 0.15)\';" onmouseout="this.style.transform=\'translateY(0)\';this.style.borderColor=\'#2a2a2a\';this.style.boxShadow=\'none\';">
            
            <div style="position: relative !important; height: 160px !important;">
                ' . $img_html . '
                ' . $vip_badge_html . '
            </div>
            
            <div style="padding: 15px !important; flex: 1 !important; display: flex !important; flex-direction: column !important; justify-content: space-between !important;">
                <div>
                    <p style="margin: 0 0 4px 0 !important; font-size: 14px !important; font-weight: 800 !important; color: #ffffff !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; line-height: 1.3 !important;">' . $titulo_safe . '</p>
                    <p style="margin: 0 !important; font-size: 12px !important; color: #888 !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important;">👤 ' . $artista_safe . '</p>
                </div>
                ' . $vip_footer_html . '
            </div>
        </a>';
    }
    wp_reset_postdata();

    $slider_id = 'rmbz-slider-' . $post_id_atual;

    return '
    <div style="margin: 40px 0 !important; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', sans-serif !important;">
        
        <div style="display: flex !important; align-items: center !important; justify-content: space-between !important; margin-bottom: 20px !important; background: #111 !important; padding: 15px 20px !important; border-radius: 8px !important; border-left: 5px solid #f0b90b !important; box-shadow: 0 4px 15px rgba(0,0,0,0.1) !important;">
            <div style="display: flex !important; align-items: center !important; gap: 12px !important;">
                <div style="background: rgba(240, 185, 11, 0.15) !important; width: 40px !important; height: 40px !important; border-radius: 50% !important; display: flex !important; justify-content: center !important; align-items: center !important; border: 1px solid rgba(240, 185, 11, 0.3) !important;">
                    <span style="font-size: 20px !important;">🎧</span>
                </div>
                <div>
                    <h4 style="margin: 0 0 2px 0 !important; font-size: 18px !important; font-weight: 900 !important; color: #f0b90b !important; text-transform: uppercase !important; letter-spacing: 0.5px !important; border: none !important; padding: 0 !important; background: transparent !important;">Recomendadas para si</h4>
                    <p style="margin: 0 !important; font-size: 11px !important; color: #aaa !important; text-transform: uppercase !important; letter-spacing: 1px !important;">Artistas em Destaque</p>
                </div>
            </div>
            <div style="display: flex !important; gap: 8px !important;">
                <button onclick="rmbzSliderScroll(\'' . esc_js( $slider_id ) . '\', -1)" aria-label="Anterior" style="background: #222 !important; border: 1px solid #444 !important; color: #fff !important; width: 36px !important; height: 36px !important; border-radius: 50% !important; cursor: pointer !important; font-size: 16px !important; display: flex !important; align-items: center !important; justify-content: center !important; padding: 0 !important; transition:0.2s !important;" onmouseover="this.style.background=\'#333\'" onmouseout="this.style.background=\'#222\'">&#8592;</button>
                <button onclick="rmbzSliderScroll(\'' . esc_js( $slider_id ) . '\', 1)" aria-label="Pr&oacute;ximo" style="background: #222 !important; border: 1px solid #444 !important; color: #fff !important; width: 36px !important; height: 36px !important; border-radius: 50% !important; cursor: pointer !important; font-size: 16px !important; display: flex !important; align-items: center !important; justify-content: center !important; padding: 0 !important; transition:0.2s !important;" onmouseover="this.style.background=\'#333\'" onmouseout="this.style.background=\'#222\'">&#8594;</button>
            </div>
        </div>

        <div id="' . esc_attr( $slider_id ) . '" style="display: flex !important; gap: 15px !important; overflow-x: auto !important; scroll-snap-type: x mandatory !important; -webkit-overflow-scrolling: touch !important; scrollbar-width: none !important; -ms-overflow-style: none !important; padding-bottom: 10px !important;">
            ' . $cards_html . '
        </div>
    </div>
    <style>#' . esc_attr( $slider_id ) . '::-webkit-scrollbar { display: none; }</style>
    <script>
    function rmbzSliderScroll(id, dir) {
        var el = document.getElementById(id);
        if (!el) return;
        el.scrollBy({ left: dir * 195, behavior: \'smooth\' });
    }
    </script>';
}

/**
 * --------------------------------------------------------------------------
 * Hook de Ativação Seguro
 * --------------------------------------------------------------------------
 */
function rmbz_safe_activation() {
    if ( function_exists( 'rmbz_ativar_plugin' ) ) {
        rmbz_ativar_plugin();
    }
    if ( function_exists( 'rmbz_artists_install_schema' ) ) {
        rmbz_artists_install_schema();
    }
}
register_activation_hook( __FILE__, 'rmbz_safe_activation' );
