diff --git a/src/main/java/com/seconectai/condocontab/tenant/http/ClienteHandlers.java b/src/main/java/com/seconectai/condocontab/tenant/http/ClienteHandlers.java index 6298906..96001ce 100644 --- a/src/main/java/com/seconectai/condocontab/tenant/http/ClienteHandlers.java +++ b/src/main/java/com/seconectai/condocontab/tenant/http/ClienteHandlers.java @@ -460,10 +460,66 @@ public final class ClienteHandlers implements HttpHandler { private void conectarEvolution(HttpExchange ex,long empresaId)throws Exception{Mapb=body(ex);String url=b.getOrDefault("instance_url","");String numero=EvolutionApiClient.onlyDigits(req(b,"numero_whatsapp"));if(numero.length()<10){HttpResponses.json(ex,400,Map.of("erro","numero_whatsapp_invalido","mensagem","Informe o número com DDD e código do país."));return;}try(Connection m=masterDs.getMasterConnection()){liberarCadastrosEvolutionOrfaos(m,numero);Map existente=one(m,"SELECT id,empresa_id,status FROM evolution_instancias WHERE numero_whatsapp=? ORDER BY id DESC LIMIT 1",Arrays.asList(numero));if(existente!=null){HttpResponses.json(ex,409,Map.of("erro","numero_whatsapp_ja_cadastrado","mensagem","Este número de WhatsApp já está cadastrado em uma conexão."));return;}}String instance="empresa"+empresaId+"_"+System.currentTimeMillis();String token=UUID.randomUUID().toString().replace("-","");var criada=evolutionApiClient.criarInstancia(url,instance,token,numero);try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("INSERT INTO evolution_instancias (empresa_id,instance_url,instance_token_enc,numero_whatsapp,status) VALUES (?,?,?,?, 'pendente') RETURNING id")){ps.setLong(1,empresaId);ps.setString(2,url);ps.setString(3,criada.instanceName()+":"+criada.token());ps.setString(4,numero);try(ResultSet rs=ps.executeQuery()){rs.next();Map resp=new LinkedHashMap<>();resp.put("id",rs.getLong(1));resp.put("instance_name",criada.instanceName());resp.put("status","pendente");resp.put("qrcode_base64",criada.qrcodeBase64());resp.put("mock",criada.mock());HttpResponses.json(ex,201,resp);}}} private void liberarCadastrosEvolutionOrfaos(Connection m,String numero)throws Exception{List> existentes=rows(m,"SELECT id,instance_url,instance_token_enc FROM evolution_instancias WHERE numero_whatsapp=? ORDER BY id",Arrays.asList(numero));for(Map inst:existentes){String token=str(inst.get("instance_token_enc"));String instanceName=token.split(":",2)[0];if(instanceName.isBlank())continue;try{evolutionApiClient.estado(str(inst.get("instance_url")),instanceName);}catch(Exception err){if(!evolutionNaoEncontrada(err))throw err;try(PreparedStatement ps=m.prepareStatement("DELETE FROM evolution_instancias WHERE id=?")){ps.setLong(1,((Number)inst.get("id")).longValue());ps.executeUpdate();}}}} private boolean evolutionNaoEncontrada(Exception err){String msg=err.getMessage()==null?"":err.getMessage().toLowerCase(Locale.ROOT);return msg.contains("404")||msg.contains("not found")||msg.contains("instance not found")||msg.contains("nao encontrada")||msg.contains("não encontrada");} - private void statusEvolution(HttpExchange ex,long empresaId)throws Exception{Map inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,200,Map.of("status","nao_configurada"));return;}Map resp=new LinkedHashMap<>();resp.put("instancia",inst);try{String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);resp.put("evolution",evolutionApiClient.estado(str(inst.get("instance_url")),parts[0]));}catch(Exception ignored){}HttpResponses.json(ex,200,resp);} - private void qrcodeEvolution(HttpExchange ex,long empresaId)throws Exception{Map inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,404,Map.of("erro","instancia_nao_configurada"));return;}String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);Map qr=evolutionApiClient.conectar(str(inst.get("instance_url")),parts[0],str(inst.get("numero_whatsapp")));HttpResponses.json(ex,200,qr);} - private void desconectarEvolution(HttpExchange ex,long empresaId)throws Exception{Map inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,404,Map.of("erro","instancia_nao_configurada"));return;}String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);HttpResponses.json(ex,200,evolutionApiClient.logout(str(inst.get("instance_url")),parts[0]));} - private void reconectarEvolution(HttpExchange ex,long empresaId)throws Exception{Map inst=evolutionInstancia(empresaId);if(inst==null){HttpResponses.json(ex,404,Map.of("erro","instancia_nao_configurada"));return;}String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2);HttpResponses.json(ex,200,evolutionApiClient.reiniciar(str(inst.get("instance_url")),parts[0]));} + private void statusEvolution(HttpExchange ex,long empresaId)throws Exception{ + List> insts; + try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,empresa_id,instance_url,instance_token_enc,numero_whatsapp,status,solicitado_em,aprovado_em FROM evolution_instancias WHERE empresa_id=? ORDER BY solicitado_em DESC,id DESC")){ + ps.setLong(1,empresaId); + insts=new ArrayList<>(); + try(ResultSet rs=ps.executeQuery()){while(rs.next())insts.add(resultMap(rs));} + } + if(insts.isEmpty()){ + HttpResponses.json(ex,200,Map.of("status","nao_configurada","instancias",List.of())); + return; + } + for(Map i:insts){ + String token=str(i.get("instance_token_enc")); + if(token!=null&&!token.isBlank()){ + String instanceName=token.split(":",2)[0]; + i.put("instance_name",instanceName); + try{ + i.put("evolution",evolutionApiClient.estado(str(i.get("instance_url")),instanceName)); + }catch(Exception ignored){ + i.put("evolution",Map.of("state","desconhecido")); + } + } + } + Map resp=new LinkedHashMap<>(); + resp.put("instancia",insts.get(0)); + resp.put("instancias",insts); + resp.put("evolution",insts.get(0).get("evolution")); + HttpResponses.json(ex,200,resp); + } + private void qrcodeEvolution(HttpExchange ex,long empresaId)throws Exception{ + Map b=body(ex); + Long id=optLong(b.get("id")); + Map inst=id!=null?evolutionInstanciaPorId(empresaId,id):evolutionInstancia(empresaId); + if(inst==null){HttpResponses.json(ex,404,Map.of("erro","instancia_nao_configurada"));return;} + String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2); + Map qr=evolutionApiClient.conectar(str(inst.get("instance_url")),parts[0],str(inst.get("numero_whatsapp"))); + HttpResponses.json(ex,200,qr); + } + private void desconectarEvolution(HttpExchange ex,long empresaId)throws Exception{ + Map b=body(ex); + Long id=optLong(b.get("id")); + Map inst=id!=null?evolutionInstanciaPorId(empresaId,id):evolutionInstancia(empresaId); + if(inst==null){HttpResponses.json(ex,404,Map.of("erro","instancia_nao_configurada"));return;} + String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2); + HttpResponses.json(ex,200,evolutionApiClient.logout(str(inst.get("instance_url")),parts[0])); + } + private void reconectarEvolution(HttpExchange ex,long empresaId)throws Exception{ + Map b=body(ex); + Long id=optLong(b.get("id")); + Map inst=id!=null?evolutionInstanciaPorId(empresaId,id):evolutionInstancia(empresaId); + if(inst==null){HttpResponses.json(ex,404,Map.of("erro","instancia_nao_configurada"));return;} + String[] parts=String.valueOf(inst.get("instance_token_enc")).split(":",2); + HttpResponses.json(ex,200,evolutionApiClient.reiniciar(str(inst.get("instance_url")),parts[0])); + } + private Map evolutionInstanciaPorId(long empresaId,long id)throws Exception{ + try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,empresa_id,instance_url,instance_token_enc,numero_whatsapp,status,solicitado_em,aprovado_em FROM evolution_instancias WHERE id=? AND empresa_id=?")){ + ps.setLong(1,id);ps.setLong(2,empresaId); + try(ResultSet rs=ps.executeQuery()){return rs.next()?resultMap(rs):null;} + } + } private Map evolutionInstancia(long empresaId)throws Exception{try(Connection m=masterDs.getMasterConnection();PreparedStatement ps=m.prepareStatement("SELECT id,empresa_id,instance_url,instance_token_enc,numero_whatsapp,status,solicitado_em,aprovado_em FROM evolution_instancias WHERE empresa_id=? ORDER BY solicitado_em DESC,id DESC LIMIT 1")){ps.setLong(1,empresaId);try(ResultSet rs=ps.executeQuery()){return rs.next()?resultMap(rs):null;}}} private void listarAssembleiasCalendario(HttpExchange ex,Connection c,long emp,String cargo,long fid)throws Exception{ diff --git a/src/main/webapp/cliente/configuracoes.html b/src/main/webapp/cliente/configuracoes.html index bb65ed5..64a02ad 100644 --- a/src/main/webapp/cliente/configuracoes.html +++ b/src/main/webapp/cliente/configuracoes.html @@ -1 +1,139 @@ -WhatsApp

WhatsApp

WhatsApp

Status

\ No newline at end of file + + + + + + WhatsApp + + + + + + + +
+
+

WhatsApp

+
+ + +
+

Cadastrar Novo WhatsApp

+
+
+ + +
+ +
+
+ + +
+
+

Minhas Conexões WhatsApp

+ +
+ +
+ + + + + + + + + +
+ + + + \ No newline at end of file diff --git a/src/main/webapp/cliente/configuracoes.js b/src/main/webapp/cliente/configuracoes.js index ed82353..3405dc9 100644 --- a/src/main/webapp/cliente/configuracoes.js +++ b/src/main/webapp/cliente/configuracoes.js @@ -1 +1,323 @@ -const form=document.querySelector('#wa-form'),statusEl=document.querySelector('#status'),qr=document.querySelector('#qr'),qrBox=document.querySelector('#qr-box'),pairing=document.querySelector('#pairing'),btnQr=document.querySelector('#btn-qr'),btnReconnect=document.querySelector('#btn-reconnect'),btnDisconnect=document.querySelector('#btn-disconnect');async function load(){try{const d=await Api.get('/api/config/evolution-instancia/status');const i=d.instancia;const evo=d.evolution||{};statusEl.innerHTML=i?`

Status de aprovação: ${Util.badge(i.status)}

Número: ${i.numero_whatsapp||'-'}

Conexão: ${Util.badge(evo.state||'-')}

`:`

Status: ${Util.badge(d.status)}

`;const has=i!=null;btnQr.disabled=!has;btnReconnect.disabled=!has;btnDisconnect.disabled=!has}catch(e){Util.toast(e.message,true)}}function showQr(d){qrBox.classList.remove('hidden');const base64=d.qrcode_base64||d.qr_code_base64;if(base64){qr.src=base64.startsWith('data:image/')?base64:'data:image/png;base64,'+base64;qr.classList.remove('hidden')}else{qr.classList.add('hidden');Util.toast('Evolution não retornou QR Code nesta tentativa',true)}pairing.value=d.pairing_code||''}form.onsubmit=async e=>{e.preventDefault();try{const d=await Api.post('/api/config/evolution-instancia/conectar',Util.formData(form));statusEl.innerHTML=`

WhatsApp cadastrado com sucesso. Aguarde a liberação no painel Master.

Status de aprovação: ${Util.badge(d.status)}

Instância: ${d.instance_name}

`;showQr(d);Util.toast('WhatsApp cadastrado com sucesso');setTimeout(load,1600)}catch(err){Util.toast(err.message,true)}};btnQr.onclick=async()=>{try{const d=await Api.post('/api/config/evolution-instancia/qrcode',{});showQr(d);Util.toast('QR Code atualizado')}catch(e){Util.toast(e.message,true)}};btnReconnect.onclick=async()=>{try{await Api.post('/api/config/evolution-instancia/reconectar',{});Util.toast('Reconexão solicitada');setTimeout(load,1200)}catch(e){Util.toast(e.message,true)}};btnDisconnect.onclick=async()=>{if(!await Util.confirm('Desconectar esta sessão do WhatsApp?','Desconectar WhatsApp'))return;try{await Api.post('/api/config/evolution-instancia/desconectar',{});Util.toast('Sessão desconectada');setTimeout(load,1200)}catch(e){Util.toast(e.message,true)}};load(); +const form = document.querySelector('#wa-form'); +const cardsContainer = document.querySelector('#cards-container'); + +// QR Modal Elements +const qrModal = document.querySelector('#qr-modal'); +const qrModalTitle = document.querySelector('#qr-modal-title'); +const qrModalImg = document.querySelector('#qr-modal-img'); +const qrLoading = document.querySelector('#qr-loading'); +const qrPairingBox = document.querySelector('#qr-pairing-box'); +const qrModalPairing = document.querySelector('#qr-modal-pairing'); + +// Reconnect Modal Elements +const reconectarModal = document.querySelector('#reconectar-modal'); +const reconectarStepConfirm = document.querySelector('#reconectar-step-confirm'); +const reconectarStepLoading = document.querySelector('#reconectar-step-loading'); + +// Disconnect Modal Elements +const desconectarModal = document.querySelector('#desconectar-modal'); +const desconectarStepConfirm = document.querySelector('#desconectar-step-confirm'); +const desconectarStepLoading = document.querySelector('#desconectar-step-loading'); + +let conexoes = []; +let selectedInstanciaId = null; + +const Icons = { + qrCode: '', + restart: '', + power: '', + whatsapp: '' +}; + +function escapeHtml(str) { + return String(str || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function formatPhonePlain(raw) { + if (!raw) return ''; + const d = String(raw).replace(/\D/g, ''); + if (d.length === 13 && d.startsWith('55')) { + return `+55 (${d.slice(2, 4)}) ${d.slice(4, 9)}-${d.slice(9)}`; + } + if (d.length === 12 && d.startsWith('55')) { + return `+55 (${d.slice(2, 4)}) ${d.slice(4, 8)}-${d.slice(8)}`; + } + if (d.length === 11) { + return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`; + } + if (d.length === 10) { + return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`; + } + return d; +} + +function formatPhone(raw) { + const formatted = formatPhonePlain(raw); + return formatted || 'Não informado'; +} + +function connectionBadge(state) { + const s = String(state || '').toLowerCase(); + if (s === 'open' || s === 'conectado') { + return 'Conectado (open)'; + } + if (s === 'close' || s === 'desconectado') { + return 'Desconectado (close)'; + } + if (s === 'connecting' || s === 'conectando') { + return 'Conectando...'; + } + return `${escapeHtml(state || 'Desconhecido')}`; +} + +function renderCards(list) { + if (!list || list.length === 0) { + cardsContainer.innerHTML = ` + `; + return; + } + + cardsContainer.innerHTML = list.map(inst => { + const evo = inst.evolution || {}; + const evoState = evo.state || 'close'; + const instName = inst.instance_name || ('instancia_' + inst.id); + const phoneFormatted = formatPhone(inst.numero_whatsapp); + + return ` + + `; + }).join(''); +} + +async function load() { + try { + const d = await Api.get('/api/config/evolution-instancia/status'); + if (Array.isArray(d.instancias)) { + conexoes = d.instancias; + } else if (d.instancia) { + const inst = d.instancia; + inst.evolution = d.evolution || {}; + conexoes = [inst]; + } else { + conexoes = []; + } + renderCards(conexoes); + } catch (e) { + cardsContainer.innerHTML = ``; + Util.toast(e.message, true); + } +} + +function exibirQrCode(d) { + const base64 = d.qrcode_base64 || d.qr_code_base64; + qrLoading.classList.add('hidden'); + if (base64) { + qrModalImg.src = base64.startsWith('data:image/') ? base64 : 'data:image/png;base64,' + base64; + qrModalImg.classList.remove('hidden'); + } else { + qrModalImg.classList.add('hidden'); + Util.toast('Evolution não retornou imagem de QR Code nesta tentativa', true); + } + if (d.pairing_code) { + qrModalPairing.value = d.pairing_code; + qrPairingBox.classList.remove('hidden'); + } else { + qrPairingBox.classList.add('hidden'); + qrModalPairing.value = ''; + } +} + +async function requisitarQrCode() { + qrLoading.classList.remove('hidden'); + qrModalImg.classList.add('hidden'); + qrPairingBox.classList.add('hidden'); + try { + const payload = selectedInstanciaId ? { id: selectedInstanciaId } : {}; + const d = await Api.post('/api/config/evolution-instancia/qrcode', payload); + exibirQrCode(d); + } catch (e) { + qrLoading.classList.add('hidden'); + Util.toast(e.message || 'Erro ao obter QR Code', true); + } +} + +function abrirModalQr(id, initialData = null) { + selectedInstanciaId = id; + const inst = conexoes.find(c => c.id === id); + if (inst && inst.numero_whatsapp) { + qrModalTitle.textContent = `Conectar ${formatPhonePlain(inst.numero_whatsapp)}`; + } else { + qrModalTitle.textContent = 'Conectar WhatsApp'; + } + qrModal.classList.remove('hidden'); + + if (initialData && (initialData.qrcode_base64 || initialData.qr_code_base64)) { + exibirQrCode(initialData); + } else { + requisitarQrCode(); + } +} + +function fecharModalQr() { + qrModal.classList.add('hidden'); +} + +function abrirModalReconectar(id) { + selectedInstanciaId = id; + reconectarStepConfirm.classList.remove('hidden'); + reconectarStepLoading.classList.add('hidden'); + reconectarModal.classList.remove('hidden'); +} + +function fecharModalReconectar() { + reconectarModal.classList.add('hidden'); + reconectarStepLoading.classList.add('hidden'); + reconectarStepConfirm.classList.remove('hidden'); +} + +async function executarReconectar() { + reconectarStepConfirm.classList.add('hidden'); + reconectarStepLoading.classList.remove('hidden'); + try { + const payload = selectedInstanciaId ? { id: selectedInstanciaId } : {}; + await Api.post('/api/config/evolution-instancia/reconectar', payload); + Util.toast('Reconexão solicitada com sucesso!'); + setTimeout(() => { + fecharModalReconectar(); + load(); + }, 1200); + } catch (e) { + reconectarStepLoading.classList.add('hidden'); + reconectarStepConfirm.classList.remove('hidden'); + Util.toast(e.message, true); + } +} + +function abrirModalDesconectar(id) { + selectedInstanciaId = id; + desconectarStepConfirm.classList.remove('hidden'); + desconectarStepLoading.classList.add('hidden'); + desconectarModal.classList.remove('hidden'); +} + +function fecharModalDesconectar() { + desconectarModal.classList.add('hidden'); + desconectarStepLoading.classList.add('hidden'); + desconectarStepConfirm.classList.remove('hidden'); +} + +async function executarDesconectar() { + desconectarStepConfirm.classList.add('hidden'); + desconectarStepLoading.classList.remove('hidden'); + try { + const payload = selectedInstanciaId ? { id: selectedInstanciaId } : {}; + await Api.post('/api/config/evolution-instancia/desconectar', payload); + Util.toast('Sessão desconectada com sucesso!'); + setTimeout(() => { + fecharModalDesconectar(); + load(); + }, 1200); + } catch (e) { + desconectarStepLoading.classList.add('hidden'); + desconectarStepConfirm.classList.remove('hidden'); + Util.toast(e.message, true); + } +} + +form.onsubmit = async (e) => { + e.preventDefault(); + const submitBtn = form.querySelector('button[type="submit"]'); + try { + if (submitBtn) submitBtn.disabled = true; + const body = Util.formData(form); + const d = await Api.post('/api/config/evolution-instancia/conectar', body); + form.reset(); + Util.toast('WhatsApp cadastrado com sucesso!'); + await load(); + if (d.id) { + abrirModalQr(d.id, d); + } + } catch (err) { + Util.toast(err.message, true); + } finally { + if (submitBtn) submitBtn.disabled = false; + } +}; + +window.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + fecharModalQr(); + fecharModalReconectar(); + fecharModalDesconectar(); + } +}); + +// Expor funções globais para handlers inline +window.load = load; +window.abrirModalQr = abrirModalQr; +window.fecharModalQr = fecharModalQr; +window.requisitarQrCode = requisitarQrCode; +window.abrirModalReconectar = abrirModalReconectar; +window.fecharModalReconectar = fecharModalReconectar; +window.executarReconectar = executarReconectar; +window.abrirModalDesconectar = abrirModalDesconectar; +window.fecharModalDesconectar = fecharModalDesconectar; +window.executarDesconectar = executarDesconectar; + +load(); diff --git a/src/main/webapp/css/app.css b/src/main/webapp/css/app.css index 9a99392..79ab161 100644 --- a/src/main/webapp/css/app.css +++ b/src/main/webapp/css/app.css @@ -1415,3 +1415,195 @@ th { width: 100%; } } + +/* WhatsApp Cards & Animated Popups */ +.wa-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 20px; + margin-top: 16px; +} + +.wa-card { + background: #ffffff; + border: 1px solid var(--line, #e2e8f0); + border-radius: var(--radius-lg, 16px); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05); + display: flex; + flex-direction: column; + transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease; + overflow: hidden; + position: relative; +} + +.wa-card:hover { + transform: translateY(-2px); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.05); + border-color: var(--brand-light, #cbd5e1); +} + +.wa-card-header { + padding: 18px 20px; + border-bottom: 1px solid var(--line, #e2e8f0); + background: #fbfcfd; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.wa-card-title-group { + display: flex; + align-items: center; + gap: 12px; +} + +.wa-icon-bubble { + width: 42px; + height: 42px; + border-radius: 12px; + background: var(--ok-soft, #e6f7ec); + color: var(--ok, #10b981); + display: grid; + place-items: center; + flex-shrink: 0; +} + +.wa-card-number { + font-size: 16px; + font-weight: 800; + color: var(--text, #1e293b); + margin: 0; + line-height: 1.2; +} + +.wa-card-inst { + font-size: 12px; + color: var(--muted-strong, #64748b); + font-family: monospace; + margin-top: 2px; + display: block; +} + +.wa-card-badges { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.wa-card-body { + padding: 18px 20px; + flex: 1; + display: flex; + flex-direction: column; + gap: 12px; +} + +.wa-info-row { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 13px; +} + +.wa-info-label { + color: var(--muted-strong, #64748b); + font-weight: 500; +} + +.wa-info-value { + font-weight: 700; + color: var(--text, #1e293b); +} + +.wa-card-footer { + padding: 14px 20px; + border-top: 1px solid var(--line, #e2e8f0); + background: #fbfcfd; + display: flex; + align-items: center; + justify-content: space-between; +} + +.wa-card-footer .table-actions { + margin-left: auto; +} + +.wa-empty-card { + padding: 36px 20px; + text-align: center; + background: #ffffff; + border: 1px dashed var(--line, #e2e8f0); + border-radius: var(--radius-lg, 16px); + color: var(--muted-strong, #64748b); + grid-column: 1 / -1; +} + +/* Animations for Popups */ +@keyframes modalPopIn { + 0% { + opacity: 0; + transform: scale(0.92) translateY(12px); + } + 100% { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes spinnerRotate { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +@keyframes pulseIcon { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.1); opacity: 0.8; } +} + +.animated-modal .modal-panel { + animation: modalPopIn 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +.action-loading-spinner { + width: 48px; + height: 48px; + border: 4px solid var(--line, #e2e8f0); + border-top-color: var(--brand, #0f766e); + border-radius: 50%; + animation: spinnerRotate 0.8s linear infinite; + margin: 0 auto 16px; +} + +.action-icon-circle { + width: 56px; + height: 56px; + border-radius: 50%; + display: grid; + place-items: center; + margin: 0 auto 16px; +} + +.action-icon-circle.danger { + background: var(--danger-soft, #fee2e2); + color: var(--danger, #ef4444); + animation: pulseIcon 1.5s ease-in-out infinite; +} + +.action-icon-circle.brand { + background: var(--brand-soft, #ccfbf1); + color: var(--brand, #0f766e); + animation: pulseIcon 1.5s ease-in-out infinite; +} + +.action-icon-circle.ok { + background: var(--ok-soft, #d1fae5); + color: var(--ok, #10b981); +} + +.action-icon-circle svg { + width: 28px; + height: 28px; + stroke-width: 2.5; +}