Files
agenciapsilmno/src/layout/melissa/MelissaAgendador.vue
T
Leonardo f3f0d831d2 Melissa: preview teleport 3-way no Agendador/LinkExterno + chrome 6 paginas
PADRAO PREVIEW 3-WAY (mobile/sidebar/floating)
- Replica o pattern do MelissaNegocio em MelissaAgendador e MelissaLinkExterno.
- Mobile: preview teleporta pro topo do main, acima de tudo (diferente do
  Negocio que vai pro drawer).
- Mid-desktop (1024-1339): teleporta pro fim da sidebar inline.
- Wide-desktop (>=1340): painel flutuante glass fora do fake dialog,
  ancorado a +14px do right edge da .X-page com width 320px.

MELISSAAGENDADOR (.mag-page)
- Importa AgendadorPreview (componente legacy do ConfiguracoesAgendadorPage).
- isWideDesktop ref + matchMedia('(min-width: 1340px)') + previewTarget computed.
- 3 placeholders + Teleport com card mag-w--side mag-w--preview.
- Adiciona right: max(6px, min(50%, calc(100% - 1006px))) em .mag-page no
  @media >=1024px (necessario pra abrir espaco pro floating).

MELISSALINKEXTERNO (.ml-page)
- Restruturacao: sidebar (Como funciona / Boas praticas) movida da DIREITA
  pra ESQUERDA + mobile drawer pattern (botao Menu, Teleport, transitions,
  backdrop) espelhando MelissaAgendador.
- 3-way teleport do preview com placeholders nos 3 alvos.
- ml-side ganha width 320px + scroll proprio.
- Right rule + floating preview CSS.

COMPONENTE NOVO: src/components/cadastro/CadastroExternoPreview.vue (~350L)
- Phone-frame 260px estilo AgendadorPreview replicando o CadastroPacienteExterno
  publico: nav (logo Psi + chip verificado), hero (avatar 38px + nome split
  firstName/lastName em accent + work_description label + clinic name),
  stepper 4 dots (1 active), card etapa 1 (numero decorativo + tag "Etapa
  1 de 4" + title "Sobre voce" + 3 input bars + CTA "Continuar"), powered by.
- Recebe :token e busca info via mesma edge function que o publico
  (get-intake-invite-info), watch refetcha quando token rotaciona.
- Sem token ou sem dados, fallback gracioso pra placeholders ("Profissional"
  + iniciais).

CHROME EM 6 PAGINAS TABULARES (sem preview)
- Apenas o right: max(6px, min(50%, calc(100% - 1006px))) no @media >=1024px,
  fazendo a janela ficar do mesmo tamanho do MelissaAgendador.
- MelissaCadastrosRecebidos (.mcr), MelissaRecorrencias (.mr), MelissaGrupos
  (.mg), MelissaTags (.mt), MelissaCompromissos (.mc), MelissaMedicos (.mm).
- +9 a 12 linhas por arquivo. Cada um nao tinha @media >=1024px ainda.

ESLint: 0 errors da minha mudanca. 2 errors pre-existentes em
MelissaRecorrencias.vue (totalDone unused L235, v-for/v-bind:key L584) -
nao toquei aquelas linhas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:08:56 -03:00

2355 lines
94 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
/*
* MelissaAgendador — Pagina nativa Melissa pra "Agendador Online".
*
* Substitui o embed cfg-agendador que vivia dentro do MelissaConfiguracoes.
* Layout 2-col:
* - COL 1 (sidebar) — Card "Status" (toggle ativo + tag PRO + link
* publico com copy/open + slug personalizado) + Card "Resumos"
* (6 atalhos clicaveis pras secoes do main)
* - COL 2 (main) — 6 cards: Identidade Visual / Perfil Publico /
* Fluxo de Agendamento / Pagamento / Triagem & LGPD / Textos da
* Jornada. Cada um com seu botao "Salvar" proprio (saveCard).
*
* Logica espelhada do ConfiguracoesAgendadorPage (tabela
* agendador_configuracoes + bucket agendador + payment_settings).
*/
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import MelissaConfigList from './MelissaConfigList.vue';
import JoditTextEditor from '@/components/ui/JoditTextEditor.vue';
import AgendadorPreview from '@/components/agendador/AgendadorPreview.vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useEntitlementsStore } from '@/stores/entitlementsStore';
// InputText/Select/SelectButton/Textarea/RadioButton/Checkbox/ColorPicker/
// ToggleSwitch/Tag/Skeleton: auto via PrimeVueResolver
// (PrimeVue <Editor> trocado por JoditTextEditor — Quill colapsava em
// flex layouts com 3 editores empilhados, Jodit faz layout imperativo
// via JS sem depender de medicao do flex pai.)
const emit = defineEmits(['close']);
const toast = useToast();
const tenantStore = useTenantStore();
const entitlements = useEntitlementsStore();
const hasAgendador = computed(() => entitlements.can('agendador.online'));
const hasLinkPersonalizado = computed(() => entitlements.can('agendador.link_personalizado'));
const AGENDADOR_BUCKET = 'agendador';
// ── Breakpoints + drawer ───────────────────────────────────
const drawerOpen = ref(false);
const isMobile = ref(false);
const isWideDesktop = ref(false); // >= 1340px — preview vira painel flutuante fora do fake dialog
let _mqMobile = null;
let _mqWide = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function _onMqWideChange(e) { isWideDesktop.value = e.matches; }
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// Onde o preview do celular e renderizado:
// - mobile: dentro do main, no topo do conteudo (acima de tudo)
// - mid-desktop (1024-1339): dentro da sidebar inline (apos Status/Resumos)
// - wide-desktop (>=1340): painel flutuante fora do fake dialog
const previewTarget = computed(() => {
if (isMobile.value) return '#mag-main-preview-target';
if (isWideDesktop.value) return '#mag-floating-preview-target';
return '#mag-sidebar-preview-target';
});
// Toggle entre cards (default) e lista de configs (alterna inline na sidebar)
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
function fecharCfg() { cfgOpen.value = false; }
// ── Estado ─────────────────────────────────────────────────
const loading = ref(true);
const ownerId = ref(null);
const savingCard = ref(null);
const uploadingField = ref(null);
const fileInputLogo = ref(null);
const fileInputHeader = ref(null);
const fileInputFundo = ref(null);
const linkCopied = ref(false);
let _copyTimer = null;
const DEFAULT_CFG = {
ativo: false,
link_slug: '',
imagem_fundo_url: '',
imagem_header_url: '',
logomarca_url: '',
cor_primaria: '#4b6bff',
nome_exibicao: '',
endereco: '',
botao_como_chegar_ativo: true,
maps_url: '',
modo_aprovacao: 'aprovacao',
modalidade: 'presencial',
tipos_habilitados: ['primeira', 'retorno'],
duracao_sessao_min: 50,
antecedencia_minima_horas: 24,
prazo_resposta_horas: 2,
reserva_horas: 2,
pagamento_obrigatorio: false,
pagamento_modo: 'sem_pagamento',
pagamento_metodos_visiveis: [],
pix_chave: '',
pix_countdown_minutos: 20,
triagem_motivo: true,
triagem_como_conheceu: false,
verificacao_email: false,
exigir_aceite_lgpd: true,
mensagem_boas_vindas: '',
texto_como_se_preparar: '',
texto_termos_lgpd: ''
};
const cfg = ref({ ...DEFAULT_CFG });
const paymentSettings = ref({});
// ── Opções ─────────────────────────────────────────────────
const tiposOptions = [
{ label: 'Primeira Entrevista', value: 'primeira' },
{ label: 'Retorno', value: 'retorno' },
{ label: 'Reagendar', value: 'reagendar' }
];
const modalidadeOptions = [
{ label: 'Presencial', value: 'presencial' },
{ label: 'Online (vídeo)', value: 'online' },
{ label: 'Ambos', value: 'ambos' }
];
const modoOptions = [
{ label: 'Aprovação manual', value: 'aprovacao' },
{ label: 'Automático', value: 'automatico' }
];
const duracoesOptions = [
{ label: '30 min', value: 30 },
{ label: '40 min', value: 40 },
{ label: '45 min', value: 45 },
{ label: '50 min', value: 50 },
{ label: '55 min', value: 55 },
{ label: '1 hora', value: 60 },
{ label: '1h 30', value: 90 },
{ label: '2 horas', value: 120 }
];
const antecedenciaOptions = [
{ label: 'Sem limite', value: 0 },
{ label: '1 hora', value: 1 },
{ label: '2 horas', value: 2 },
{ label: '6 horas', value: 6 },
{ label: '12 horas', value: 12 },
{ label: '24 horas', value: 24 },
{ label: '48 horas', value: 48 },
{ label: '72 horas', value: 72 }
];
const reservaOptions = [
{ label: '1 hora', value: 1 },
{ label: '2 horas', value: 2 },
{ label: '4 horas', value: 4 },
{ label: '6 horas', value: 6 },
{ label: '12 horas', value: 12 },
{ label: '24 horas', value: 24 },
{ label: '48 horas', value: 48 }
];
const pixCountdownOptions = [
{ label: '5 min', value: 5 },
{ label: '10 min', value: 10 },
{ label: '15 min', value: 15 },
{ label: '20 min', value: 20 },
{ label: '30 min', value: 30 },
{ label: '60 min', value: 60 }
];
const prazoRespostaOptions = [
{ label: '1 hora', value: 1 },
{ label: '2 horas', value: 2 },
{ label: '4 horas', value: 4 },
{ label: '6 horas', value: 6 },
{ label: '12 horas', value: 12 },
{ label: '24 horas', value: 24 },
{ label: '48 horas', value: 48 },
{ label: '72 horas', value: 72 }
];
const modosPagamento = [
{
value: 'sem_pagamento',
label: 'Sem cobrança antecipada',
desc: 'O horário fica reservado sem exigir pagamento.',
icon: 'pi-calendar-clock'
},
{
value: 'pagar_na_hora',
label: 'Pagar na hora da sessão',
desc: 'O paciente vê as formas aceitas e paga no dia.',
icon: 'pi-wallet'
},
{
value: 'pix_antecipado',
label: 'Pix antecipado obrigatório',
desc: 'O paciente paga via Pix antes do agendamento.',
icon: 'pi-qrcode'
}
];
// METODO_LABEL removido — nao e mais usado nos resumos da sidebar
// (resumoPagamento agora mostra contagem de metodos em vez de labels).
// ── Link público ───────────────────────────────────────────
const linkPublico = computed(() => {
if (!cfg.value.link_slug) return '';
return `${window.location.origin}/agendar/${cfg.value.link_slug}`;
});
async function copyLink() {
try {
await navigator.clipboard.writeText(linkPublico.value);
linkCopied.value = true;
clearTimeout(_copyTimer);
_copyTimer = setTimeout(() => { linkCopied.value = false; }, 2000);
} catch {
toast.add({
severity: 'warn',
summary: 'Copie manualmente',
detail: linkPublico.value,
life: 5000
});
}
}
function abrirLinkPublico() {
if (!linkPublico.value) return;
window.open(linkPublico.value, '_blank', 'noopener');
}
// ── Resumos pra sidebar ────────────────────────────────────
const resumoIdentidade = computed(() => {
const parts = [];
if (cfg.value.nome_exibicao) parts.push(cfg.value.nome_exibicao);
if (cfg.value.cor_primaria) parts.push(cfg.value.cor_primaria);
return parts.join(' · ') || 'Não configurado';
});
const resumoPerfil = computed(() => {
const parts = [];
if (cfg.value.endereco) parts.push(cfg.value.endereco.slice(0, 40) + (cfg.value.endereco.length > 40 ? '…' : ''));
if (cfg.value.botao_como_chegar_ativo) parts.push('Como chegar ativo');
return parts.join(' · ') || 'Não configurado';
});
const resumoFluxo = computed(() => {
const modo = cfg.value.modo_aprovacao === 'aprovacao' ? 'Aprovação manual' : 'Automático';
const tipos = (cfg.value.tipos_habilitados || []).length;
return `${modo} · ${tipos} tipo${tipos !== 1 ? 's' : ''} · ${cfg.value.duracao_sessao_min}min`;
});
const resumoPagamento = computed(() => {
const modo = cfg.value.pagamento_modo;
if (modo === 'pix_antecipado') return `Pix antecipado · ${cfg.value.pix_countdown_minutos}min`;
if (modo === 'pagar_na_hora') {
const ativos = cfg.value.pagamento_metodos_visiveis || [];
return ativos.length
? `Pagar na hora · ${ativos.length} método${ativos.length !== 1 ? 's' : ''}`
: 'Pagar na hora';
}
return 'Sem cobrança antecipada';
});
const resumoTriagem = computed(() => {
const campos = [];
if (cfg.value.triagem_motivo) campos.push('Motivo');
if (cfg.value.triagem_como_conheceu) campos.push('Origem');
if (cfg.value.verificacao_email) campos.push('E-mail');
if (cfg.value.exigir_aceite_lgpd) campos.push('LGPD');
return campos.join(' · ') || 'Sem triagem extra';
});
const resumoTextos = computed(() => {
const bv = cfg.value.mensagem_boas_vindas?.trim();
const cp = cfg.value.texto_como_se_preparar?.trim();
if (bv || cp) return 'Textos configurados';
return 'Nenhum texto configurado';
});
// Métodos pra "pagar na hora"
const metodosDisponiveis = computed(() => {
const ps = paymentSettings.value;
return [
{ key: 'pix', label: 'Pix', icon: 'pi-qrcode', ativo: ps.pix_ativo },
{ key: 'deposito', label: 'Depósito/TED', icon: 'pi-building-columns', ativo: ps.deposito_ativo },
{ key: 'dinheiro', label: 'Dinheiro', icon: 'pi-wallet', ativo: ps.dinheiro_ativo },
{ key: 'cartao', label: 'Cartão', icon: 'pi-credit-card', ativo: ps.cartao_ativo },
{ key: 'convenio', label: 'Convênio', icon: 'pi-heart', ativo: ps.convenio_ativo }
];
});
const algumMetodoConfigurado = computed(() => metodosDisponiveis.value.some((m) => m.ativo));
function toggleMetodoVisivel(key) {
const arr = [...(cfg.value.pagamento_metodos_visiveis || [])];
const idx = arr.indexOf(key);
if (idx === -1) arr.push(key);
else arr.splice(idx, 1);
cfg.value.pagamento_metodos_visiveis = arr;
}
function isMetodoVisivel(key) {
return (cfg.value.pagamento_metodos_visiveis || []).includes(key);
}
function toggleTipo(value) {
const list = [...(cfg.value.tipos_habilitados || [])];
const idx = list.indexOf(value);
if (idx >= 0) list.splice(idx, 1);
else list.push(value);
cfg.value.tipos_habilitados = list;
}
// ── Auth / tenant ──────────────────────────────────────────
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
return data?.user?.id;
}
async function getActiveTenantId(uid) {
const fromStore = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
if (fromStore) return fromStore;
const { data } = await supabase
.from('tenant_members')
.select('tenant_id')
.eq('user_id', uid)
.eq('status', 'active')
.limit(1)
.maybeSingle();
return data?.tenant_id || null;
}
// ── Load ───────────────────────────────────────────────────
async function loadPaymentSettings(uid) {
try {
const { data } = await supabase
.from('payment_settings')
.select('pix_ativo, pix_chave, pix_tipo, deposito_ativo, dinheiro_ativo, cartao_ativo, convenio_ativo')
.eq('owner_id', uid)
.maybeSingle();
paymentSettings.value = data || {};
} catch {
paymentSettings.value = {};
}
}
async function load() {
loading.value = true;
try {
const uid = await getOwnerId();
ownerId.value = uid;
const [{ data, error }] = await Promise.all([
supabase.from('agendador_configuracoes').select('*').eq('owner_id', uid).maybeSingle(),
loadPaymentSettings(uid)
]);
if (error) throw error;
if (data) {
const loaded = {
...DEFAULT_CFG,
...data,
tipos_habilitados: Array.isArray(data.tipos_habilitados)
? data.tipos_habilitados
: DEFAULT_CFG.tipos_habilitados,
pagamento_metodos_visiveis: Array.isArray(data.pagamento_metodos_visiveis)
? data.pagamento_metodos_visiveis
: []
};
if (!data.pagamento_modo) {
loaded.pagamento_modo = data.pagamento_obrigatorio ? 'pix_antecipado' : 'sem_pagamento';
}
cfg.value = loaded;
} else {
cfg.value = { ...DEFAULT_CFG };
if (tenantStore.tenant?.name) cfg.value.nome_exibicao = tenantStore.tenant.name;
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message, life: 4000 });
} finally {
loading.value = false;
}
}
// ── Toggle ativo ───────────────────────────────────────────
async function toggleAtivo() {
const uid = ownerId.value;
if (!uid) return;
const novoAtivo = !cfg.value.ativo;
cfg.value.ativo = novoAtivo;
try {
const tenantId = await getActiveTenantId(uid);
const { error } = await supabase
.from('agendador_configuracoes')
.upsert(
{ owner_id: uid, tenant_id: tenantId, ativo: novoAtivo, updated_at: new Date().toISOString() },
{ onConflict: 'owner_id' }
);
if (error) throw error;
toast.add({
severity: novoAtivo ? 'success' : 'info',
summary: novoAtivo ? 'Agendador ativado' : 'Agendador desativado',
life: 3000
});
if (novoAtivo) await load();
} catch (e) {
cfg.value.ativo = !novoAtivo;
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
}
}
// ── Save card ──────────────────────────────────────────────
function buildPayload(cardKey) {
const c = cfg.value;
if (cardKey === 'identidade') {
return {
imagem_fundo_url: c.imagem_fundo_url,
imagem_header_url: c.imagem_header_url,
logomarca_url: c.logomarca_url,
cor_primaria: c.cor_primaria
};
}
if (cardKey === 'perfil') {
return {
nome_exibicao: c.nome_exibicao,
endereco: c.endereco,
botao_como_chegar_ativo: c.botao_como_chegar_ativo,
maps_url: c.maps_url
};
}
if (cardKey === 'fluxo') {
return {
modo_aprovacao: c.modo_aprovacao,
modalidade: c.modalidade,
tipos_habilitados: c.tipos_habilitados,
duracao_sessao_min: c.duracao_sessao_min,
antecedencia_minima_horas: c.antecedencia_minima_horas,
prazo_resposta_horas: c.prazo_resposta_horas
};
}
if (cardKey === 'pagamento') {
const modo = c.pagamento_modo || 'sem_pagamento';
return {
pagamento_modo: modo,
pagamento_obrigatorio: modo === 'pix_antecipado',
pagamento_metodos_visiveis: c.pagamento_metodos_visiveis || [],
pix_chave: c.pix_chave?.trim() ?? '',
pix_countdown_minutos: c.pix_countdown_minutos,
reserva_horas: c.reserva_horas
};
}
if (cardKey === 'triagem') {
return {
triagem_motivo: c.triagem_motivo,
triagem_como_conheceu: c.triagem_como_conheceu,
verificacao_email: c.verificacao_email,
exigir_aceite_lgpd: c.exigir_aceite_lgpd
};
}
if (cardKey === 'textos') {
return {
mensagem_boas_vindas: c.mensagem_boas_vindas,
texto_como_se_preparar: c.texto_como_se_preparar,
texto_termos_lgpd: c.texto_termos_lgpd
};
}
if (cardKey === 'slug') {
return {
link_slug:
c.link_slug
?.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-') || null
};
}
return {};
}
async function saveCard(cardKey) {
savingCard.value = cardKey;
try {
const uid = ownerId.value;
const tenantId = await getActiveTenantId(uid);
const payload = buildPayload(cardKey);
const { error } = await supabase
.from('agendador_configuracoes')
.upsert(
{ owner_id: uid, tenant_id: tenantId, ...payload, updated_at: new Date().toISOString() },
{ onConflict: 'owner_id' }
);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Salvo', life: 2500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 });
} finally {
savingCard.value = null;
}
}
// ── Upload imagens ─────────────────────────────────────────
async function uploadImagem(file, field) {
if (!file || !ownerId.value) return null;
uploadingField.value = field;
try {
const ext = file.name.split('.').pop() || 'jpg';
const path = `${ownerId.value}/${field}-${Date.now()}.${ext}`;
const { error: upErr } = await supabase.storage
.from(AGENDADOR_BUCKET)
.upload(path, file, { upsert: true, contentType: file.type });
if (upErr) throw upErr;
const { data } = supabase.storage.from(AGENDADOR_BUCKET).getPublicUrl(path);
return data?.publicUrl || null;
} finally {
uploadingField.value = null;
}
}
async function onFileSelected(event, field) {
const file = event.target.files?.[0];
if (!file) return;
try {
const url = await uploadImagem(file, field);
if (url) {
if (field === 'logomarca') cfg.value.logomarca_url = url;
if (field === 'header') cfg.value.imagem_header_url = url;
if (field === 'fundo') cfg.value.imagem_fundo_url = url;
const uid = ownerId.value;
const tenantId = await getActiveTenantId(uid);
await supabase
.from('agendador_configuracoes')
.upsert(
{ owner_id: uid, tenant_id: tenantId, ...buildPayload('identidade'), updated_at: new Date().toISOString() },
{ onConflict: 'owner_id' }
);
toast.add({ severity: 'success', summary: 'Imagem salva', life: 2000 });
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro no upload', detail: e.message, life: 4000 });
}
}
// ── Ancora pra sessao ──────────────────────────────────────
function scrollToCard(cardKey) {
if (isMobile.value) drawerOpen.value = false;
nextTick(() => {
const el = document.getElementById('mag-sec-' + cardKey);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
// ── Lifecycle ──────────────────────────────────────────────
onMounted(async () => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
catch { _mqMobile.addListener(_onMqMobileChange); }
_mqWide = window.matchMedia('(min-width: 1340px)');
isWideDesktop.value = _mqWide.matches;
try { _mqWide.addEventListener('change', _onMqWideChange); }
catch { _mqWide.addListener(_onMqWideChange); }
}
await tenantStore.ensureLoaded();
await load();
});
onBeforeUnmount(() => {
if (_mqMobile) {
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
catch { _mqMobile.removeListener(_onMqMobileChange); }
}
if (_mqWide) {
try { _mqWide.removeEventListener('change', _onMqWideChange); }
catch { _mqWide.removeListener(_onMqWideChange); }
}
clearTimeout(_copyTimer);
});
const summaryItems = computed(() => [
{ key: 'identidade', label: 'Identidade Visual', icon: 'pi pi-palette', color: '#a855f7', resumo: resumoIdentidade.value },
{ key: 'perfil', label: 'Perfil Público', icon: 'pi pi-map-marker', color: '#3b82f6', resumo: resumoPerfil.value },
{ key: 'fluxo', label: 'Fluxo', icon: 'pi pi-sitemap', color: '#06b6d4', resumo: resumoFluxo.value },
{ key: 'pagamento', label: 'Pagamento', icon: 'pi pi-credit-card', color: '#10b981', resumo: resumoPagamento.value },
{ key: 'triagem', label: 'Triagem & LGPD', icon: 'pi pi-shield', color: '#f97316', resumo: resumoTriagem.value },
{ key: 'textos', label: 'Textos', icon: 'pi pi-file-edit', color: '#ec4899', resumo: resumoTextos.value }
]);
</script>
<template>
<Transition name="mag-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mag-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
>
<div id="mag-mobile-drawer-target" class="mag-mobile-drawer__scroll" />
</div>
</Transition>
<Transition name="mag-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mag-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<!-- Painel flutuante do Preview (wide-desktop >=1340px). Vive FORA do
fake dialog, ancorado a sua right edge + 14px gap. Em mobile o
preview teleporta pro #mag-main-preview-target (topo do main); em
mid-desktop (1024-1339) teleporta pro #mag-sidebar-preview-target. -->
<aside id="mag-floating-preview-target" class="mag-floating-preview" aria-label="Pré-visualização do agendador"></aside>
<section class="mag-page">
<header class="mag-page__head">
<button
class="mag-menu-btn mag-menu-btn--mobile-only"
v-tooltip.bottom="'Status & Resumos'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu</span>
</button>
<div class="mag-page__title">
<i class="pi pi-calendar-clock mag-page__title-icon" />
<span>Agendador Online</span>
<Tag value="PRO" severity="contrast" class="mag-page__pro" />
<Tag
v-if="cfg.ativo"
value="Ativo"
severity="success"
/>
<Tag
v-else
value="Inativo"
severity="secondary"
/>
</div>
<div class="mag-page__actions">
<button class="mag-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
<i class="pi pi-times" />
</button>
</div>
</header>
<div class="mag-subheader">
<i class="pi pi-info-circle mag-subheader__icon" />
<span class="mag-subheader__text">
Configure o link público que seus pacientes usarão para
solicitar horários online. Cada card abaixo tem seu próprio
botão de salvar.
</span>
</div>
<div class="mag-body">
<Teleport to="#mag-mobile-drawer-target" :disabled="!isMobile">
<aside class="mag-side">
<button class="mag-cfg-btn" :class="{ 'is-open': cfgOpen }" @click="toggleCfg">
<i :class="cfgOpen ? 'pi pi-arrow-left' : 'pi pi-cog'" />
<span>{{ cfgOpen ? 'Voltar' : 'Configurações' }}</span>
<i v-if="!cfgOpen" class="pi pi-chevron-down mag-cfg-btn__chev" />
</button>
<div v-if="cfgOpen" class="mag-side__scroll mag-side__scroll--cfg">
<MelissaConfigList @select="fecharCfg" />
</div>
<div v-else class="mag-side__scroll">
<!-- Card: Status -->
<div class="mag-w mag-w--side">
<div class="mag-w__head">
<div class="mag-w__icon"><i class="pi pi-calendar-clock" /></div>
<div class="mag-w__title">
<div class="mag-w__title-text">Status</div>
<div class="mag-w__sub">Ativar e link público</div>
</div>
</div>
<div class="mag-w__body">
<!-- Toggle ativar -->
<div class="mag-toggle-row">
<div class="mag-toggle-row__text">
<div class="mag-toggle-row__label">
{{ cfg.ativo ? 'Agendador ativo' : 'Agendador inativo' }}
</div>
<div class="mag-toggle-row__hint">
<template v-if="hasAgendador">
{{ cfg.ativo
? 'Pacientes podem solicitar horários.'
: 'Ative pra liberar o link público.' }}
</template>
<template v-else>
Seu plano não inclui o Agendador Online.
</template>
</div>
</div>
<ToggleSwitch
v-if="hasAgendador"
:modelValue="cfg.ativo"
@update:modelValue="toggleAtivo"
/>
<ToggleSwitch
v-else
:modelValue="false"
disabled
v-tooltip.left="'Seu plano não inclui o Agendador'"
/>
</div>
<!-- Link público -->
<template v-if="cfg.ativo">
<div v-if="!cfg.link_slug" class="mag-link-loading">
<i class="pi pi-spin pi-spinner" />
<span>Gerando link</span>
</div>
<template v-else>
<div class="mag-link-label">Link público</div>
<div class="mag-link-row">
<input
:value="linkPublico"
readonly
class="mag-link-input"
@click="$event.target.select()"
/>
<button
class="mag-icon-btn"
v-tooltip.top="linkCopied ? 'Copiado!' : 'Copiar'"
@click="copyLink"
>
<i :class="linkCopied ? 'pi pi-check' : 'pi pi-copy'" />
</button>
<button
class="mag-icon-btn"
v-tooltip.top="'Abrir'"
@click="abrirLinkPublico"
>
<i class="pi pi-external-link" />
</button>
</div>
<!-- Slug personalizado -->
<div v-if="hasLinkPersonalizado" class="mag-slug">
<div class="mag-slug__label">Apelido personalizado</div>
<div class="mag-slug__row">
<span class="mag-slug__prefix">/agendar/</span>
<input
v-model="cfg.link_slug"
placeholder="dra-ana-silva"
class="mag-slug__input"
/>
</div>
<button
class="mag-btn mag-btn--primary mag-btn--full mag-btn--sm"
:disabled="savingCard === 'slug'"
@click="saveCard('slug')"
>
<i :class="savingCard === 'slug' ? 'pi pi-spin pi-spinner' : 'pi pi-check'" />
<span>Salvar apelido</span>
</button>
</div>
<div v-else class="mag-upgrade">
<i class="pi pi-lock" />
<div>
<strong>Apelido personalizado</strong> disponível em planos superiores.
</div>
</div>
</template>
</template>
<div v-else class="mag-empty-link">
Ative o agendador acima pra gerar o link público.
</div>
</div>
</div>
<!-- Card: Resumos -->
<div class="mag-w mag-w--side">
<div class="mag-w__head">
<div class="mag-w__icon"><i class="pi pi-list" /></div>
<div class="mag-w__title">
<div class="mag-w__title-text">Configurações</div>
<div class="mag-w__sub">Click pra ir à seção</div>
</div>
</div>
<div class="mag-w__body">
<button
v-for="s in summaryItems"
:key="s.key"
type="button"
class="mag-summary-item"
@click="scrollToCard(s.key)"
>
<div
class="mag-summary-item__icon"
:style="{
background: s.color + '22',
color: s.color
}"
>
<i :class="s.icon" />
</div>
<div class="mag-summary-item__text">
<div class="mag-summary-item__label">{{ s.label }}</div>
<div class="mag-summary-item__resumo">{{ s.resumo }}</div>
</div>
<i class="pi pi-chevron-right mag-summary-item__chev" />
</button>
</div>
</div>
<!-- Target do Teleport do Preview pra modo sidebar (mid-desktop 1024-1339).
Em mobile o preview teleporta pro #mag-main-preview-target (topo do main);
em wide-desktop (>=1340px) teleporta pro #mag-floating-preview-target. -->
<div id="mag-sidebar-preview-target" class="mag-sidebar-preview-target" />
</div>
</aside>
</Teleport>
<div class="mag-main">
<!-- Target do Teleport do Preview pra modo mobile (acima de tudo no main).
Em desktop fica oculto via CSS e o preview teleporta pra sidebar/floating. -->
<div id="mag-main-preview-target" class="mag-main-preview-target" />
<!-- Loading -->
<template v-if="loading">
<div class="mag-w" v-for="n in 3" :key="`sk-${n}`">
<div class="mag-w__body">
<Skeleton width="40%" height="20px" class="mb-3" />
<Skeleton v-for="m in 4" :key="`sk-${n}-${m}`" width="100%" height="36px" class="mb-2" />
</div>
</div>
</template>
<template v-else>
<!-- Identidade Visual -->
<div id="mag-sec-identidade" class="mag-w">
<div class="mag-w__head">
<div class="mag-w__icon mag-w__icon--purple"><i class="pi pi-palette" /></div>
<div class="mag-w__title">
<div class="mag-w__title-text">Identidade Visual</div>
<div class="mag-w__sub">Logomarca, cor e imagens</div>
</div>
</div>
<div class="mag-w__body">
<div class="mag-grid-2">
<div class="mag-field">
<label class="mag-label">Nome de exibição</label>
<InputText
v-model="cfg.nome_exibicao"
placeholder="Ex: Dra. Ana Silva — Psicóloga"
class="w-full"
/>
<span class="mag-hint">Aparece no topo do agendador.</span>
</div>
<div class="mag-field">
<label class="mag-label">Cor principal</label>
<div class="mag-color">
<ColorPicker v-model="cfg.cor_primaria" format="hex" />
<InputText
v-model="cfg.cor_primaria"
placeholder="#4b6bff"
class="mag-color__input"
maxlength="7"
/>
<div
class="mag-color__swatch"
:style="{ background: cfg.cor_primaria }"
/>
</div>
<span class="mag-hint">Botões e destaques.</span>
</div>
</div>
<div class="mag-divider" />
<div class="mag-uploads">
<!-- Logomarca -->
<div class="mag-upload">
<div class="mag-upload__head">
<i class="pi pi-user-circle" />
<span>Logomarca</span>
</div>
<div class="mag-upload__body">
<div class="mag-upload__hint">Avatar circular 300×300</div>
<div v-if="cfg.logomarca_url" class="mag-upload__preview">
<img :src="cfg.logomarca_url" alt="Logomarca" class="mag-upload__img mag-upload__img--circle" />
</div>
<button
type="button"
class="mag-upload__zone"
@click="fileInputLogo?.click()"
>
<i :class="uploadingField === 'logomarca' ? 'pi pi-spin pi-spinner' : 'pi pi-upload'" />
<span>{{ uploadingField === 'logomarca' ? 'Enviando…' : 'Clique para enviar' }}</span>
</button>
<input
ref="fileInputLogo"
type="file"
accept="image/*"
hidden
@change="(e) => onFileSelected(e, 'logomarca')"
/>
<InputText
v-model="cfg.logomarca_url"
placeholder="ou cole uma URL…"
class="w-full"
/>
</div>
</div>
<!-- Header -->
<div class="mag-upload">
<div class="mag-upload__head">
<i class="pi pi-image" />
<span>Header</span>
</div>
<div class="mag-upload__body">
<div class="mag-upload__hint">Faixa superior 1400×300</div>
<div v-if="cfg.imagem_header_url" class="mag-upload__preview">
<img :src="cfg.imagem_header_url" alt="Header" class="mag-upload__img" />
</div>
<button
type="button"
class="mag-upload__zone"
@click="fileInputHeader?.click()"
>
<i :class="uploadingField === 'header' ? 'pi pi-spin pi-spinner' : 'pi pi-upload'" />
<span>{{ uploadingField === 'header' ? 'Enviando…' : 'Clique para enviar' }}</span>
</button>
<input
ref="fileInputHeader"
type="file"
accept="image/*"
hidden
@change="(e) => onFileSelected(e, 'header')"
/>
<InputText
v-model="cfg.imagem_header_url"
placeholder="ou cole uma URL…"
class="w-full"
/>
</div>
</div>
<!-- Fundo -->
<div class="mag-upload">
<div class="mag-upload__head">
<i class="pi pi-stop" />
<span>Fundo</span>
</div>
<div class="mag-upload__body">
<div class="mag-upload__hint">Fundo da página 1920×1080</div>
<div v-if="cfg.imagem_fundo_url" class="mag-upload__preview">
<img :src="cfg.imagem_fundo_url" alt="Fundo" class="mag-upload__img" />
</div>
<button
type="button"
class="mag-upload__zone"
@click="fileInputFundo?.click()"
>
<i :class="uploadingField === 'fundo' ? 'pi pi-spin pi-spinner' : 'pi pi-upload'" />
<span>{{ uploadingField === 'fundo' ? 'Enviando…' : 'Clique para enviar' }}</span>
</button>
<input
ref="fileInputFundo"
type="file"
accept="image/*"
hidden
@change="(e) => onFileSelected(e, 'fundo')"
/>
<InputText
v-model="cfg.imagem_fundo_url"
placeholder="ou cole uma URL…"
class="w-full"
/>
</div>
</div>
</div>
<div class="mag-card-actions">
<button
class="mag-btn mag-btn--primary"
:disabled="savingCard === 'identidade'"
@click="saveCard('identidade')"
>
<i :class="savingCard === 'identidade' ? 'pi pi-spin pi-spinner' : 'pi pi-check'" />
<span>Salvar identidade</span>
</button>
</div>
</div>
</div>
<!-- Perfil Público -->
<div id="mag-sec-perfil" class="mag-w">
<div class="mag-w__head">
<div class="mag-w__icon mag-w__icon--blue"><i class="pi pi-map-marker" /></div>
<div class="mag-w__title">
<div class="mag-w__title-text">Perfil Público</div>
<div class="mag-w__sub">Endereço e botão "Como chegar"</div>
</div>
</div>
<div class="mag-w__body">
<div class="mag-field">
<label class="mag-label">Endereço</label>
<InputText
v-model="cfg.endereco"
placeholder="Rua das Flores, 123, Centro — São Paulo, SP"
class="w-full"
/>
</div>
<div class="mag-toggle-row">
<div class="mag-toggle-row__text">
<div class="mag-toggle-row__label">Botão "Como chegar"</div>
<div class="mag-toggle-row__hint">Abre o mapa pro paciente.</div>
</div>
<ToggleSwitch v-model="cfg.botao_como_chegar_ativo" />
</div>
<div v-if="cfg.botao_como_chegar_ativo" class="mag-field">
<label class="mag-label">URL do Google Maps <span class="mag-label__opt">(opcional)</span></label>
<InputText
v-model="cfg.maps_url"
placeholder="https://maps.google.com/..."
class="w-full"
/>
<span class="mag-hint">Vazio: abre uma busca pelo endereço acima.</span>
</div>
<div class="mag-card-actions">
<button
class="mag-btn mag-btn--primary"
:disabled="savingCard === 'perfil'"
@click="saveCard('perfil')"
>
<i :class="savingCard === 'perfil' ? 'pi pi-spin pi-spinner' : 'pi pi-check'" />
<span>Salvar perfil</span>
</button>
</div>
</div>
</div>
<!-- Fluxo de Agendamento -->
<div id="mag-sec-fluxo" class="mag-w">
<div class="mag-w__head">
<div class="mag-w__icon mag-w__icon--cyan"><i class="pi pi-sitemap" /></div>
<div class="mag-w__title">
<div class="mag-w__title-text">Fluxo de Agendamento</div>
<div class="mag-w__sub">Aprovação, modalidade, tipos e duração</div>
</div>
</div>
<div class="mag-w__body">
<div class="mag-group-title">Aprovação</div>
<div class="mag-radio-list">
<button
v-for="opt in modoOptions"
:key="opt.value"
type="button"
class="mag-radio"
:class="{ 'is-active': cfg.modo_aprovacao === opt.value }"
@click="cfg.modo_aprovacao = opt.value"
>
<RadioButton :modelValue="cfg.modo_aprovacao" :value="opt.value" />
<div>
<div class="mag-radio__label">{{ opt.label }}</div>
<div class="mag-radio__desc">
<template v-if="opt.value === 'aprovacao'">
Você analisa cada solicitação. Horário fica reservado.
</template>
<template v-else>
Confirmados automaticamente. Sem revisão.
</template>
</div>
</div>
</button>
</div>
<div v-if="cfg.modo_aprovacao === 'aprovacao'" class="mag-field">
<label class="mag-label">Prazo para responder</label>
<Select
v-model="cfg.prazo_resposta_horas"
:options="prazoRespostaOptions"
optionLabel="label"
optionValue="value"
class="w-full"
/>
<span class="mag-hint">Sem resposta: paciente notificado e horário liberado.</span>
</div>
<div class="mag-divider" />
<div class="mag-group-title">Atendimento</div>
<div class="mag-field">
<label class="mag-label">Modalidade</label>
<SelectButton
v-model="cfg.modalidade"
:options="modalidadeOptions"
optionLabel="label"
optionValue="value"
/>
</div>
<div class="mag-field">
<label class="mag-label">Tipos de agendamento</label>
<div class="mag-chips">
<button
v-for="opt in tiposOptions"
:key="opt.value"
type="button"
class="mag-chip"
:class="{ 'is-active': cfg.tipos_habilitados?.includes(opt.value) }"
@click="toggleTipo(opt.value)"
>
<i v-if="cfg.tipos_habilitados?.includes(opt.value)" class="pi pi-check" />
<span>{{ opt.label }}</span>
</button>
</div>
</div>
<div class="mag-divider" />
<div class="mag-group-title">Horários</div>
<div class="mag-grid-2">
<div class="mag-field">
<label class="mag-label">Duração da sessão</label>
<Select
v-model="cfg.duracao_sessao_min"
:options="duracoesOptions"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
<div class="mag-field">
<label class="mag-label">Antecedência mínima</label>
<Select
v-model="cfg.antecedencia_minima_horas"
:options="antecedenciaOptions"
optionLabel="label"
optionValue="value"
class="w-full"
/>
</div>
</div>
<div class="mag-card-actions">
<button
class="mag-btn mag-btn--primary"
:disabled="savingCard === 'fluxo'"
@click="saveCard('fluxo')"
>
<i :class="savingCard === 'fluxo' ? 'pi pi-spin pi-spinner' : 'pi pi-check'" />
<span>Salvar fluxo</span>
</button>
</div>
</div>
</div>
<!-- Pagamento -->
<div id="mag-sec-pagamento" class="mag-w">
<div class="mag-w__head">
<div class="mag-w__icon mag-w__icon--green"><i class="pi pi-credit-card" /></div>
<div class="mag-w__title">
<div class="mag-w__title-text">Pagamento</div>
<div class="mag-w__sub">Como o paciente vai pagar</div>
</div>
</div>
<div class="mag-w__body">
<div class="mag-radio-list">
<button
v-for="modo in modosPagamento"
:key="modo.value"
type="button"
class="mag-radio mag-radio--mode"
:class="{ 'is-active': cfg.pagamento_modo === modo.value }"
@click="cfg.pagamento_modo = modo.value"
>
<div
class="mag-radio__icon"
:class="{ 'is-active': cfg.pagamento_modo === modo.value }"
>
<i :class="['pi', modo.icon]" />
</div>
<div class="mag-radio__text">
<div class="mag-radio__label">{{ modo.label }}</div>
<div class="mag-radio__desc">{{ modo.desc }}</div>
</div>
<i v-if="cfg.pagamento_modo === modo.value" class="pi pi-check-circle mag-radio__check" />
</button>
</div>
<!-- Pagar na hora -->
<template v-if="cfg.pagamento_modo === 'pagar_na_hora'">
<div class="mag-divider" />
<div class="mag-group-title">Formas aceitas</div>
<div v-if="!algumMetodoConfigurado" class="mag-warn">
<i class="pi pi-exclamation-triangle" />
<span>Nenhuma forma de pagamento configurada. em Configurações &rsaquo; Pagamento.</span>
</div>
<div class="mag-methods">
<label
v-for="m in metodosDisponiveis"
:key="m.key"
class="mag-method"
:class="{
'is-disabled': !m.ativo,
'is-active': m.ativo && isMetodoVisivel(m.key)
}"
>
<Checkbox
:modelValue="isMetodoVisivel(m.key)"
:disabled="!m.ativo"
binary
@change="m.ativo && toggleMetodoVisivel(m.key)"
/>
<i :class="['pi', m.icon]" />
<span class="mag-method__label">{{ m.label }}</span>
<Tag v-if="!m.ativo" value="Não configurado" severity="secondary" class="mag-method__tag" />
</label>
</div>
</template>
<!-- Pix antecipado -->
<template v-if="cfg.pagamento_modo === 'pix_antecipado'">
<div class="mag-divider" />
<div class="mag-group-title">Configurações do Pix</div>
<div class="mag-grid-2">
<div class="mag-field">
<label class="mag-label">Chave Pix</label>
<InputText
v-model="cfg.pix_chave"
:placeholder="paymentSettings.pix_chave ? `Usando: ${paymentSettings.pix_chave}` : 'CPF, e-mail, telefone…'"
class="w-full"
/>
<span v-if="!cfg.pix_chave && paymentSettings.pix_chave" class="mag-hint">
Vazio: usa a chave de Pagamento ({{ paymentSettings.pix_chave }}).
</span>
</div>
<div class="mag-field">
<label class="mag-label">Tempo para pagar</label>
<Select
v-model="cfg.pix_countdown_minutos"
:options="pixCountdownOptions"
optionLabel="label"
optionValue="value"
class="w-full"
/>
<span class="mag-hint">Sem pagamento no prazo: horário liberado.</span>
</div>
</div>
</template>
<div class="mag-divider" />
<div class="mag-group-title">Reserva do horário</div>
<div class="mag-field">
<label class="mag-label">Tempo de reserva</label>
<Select
v-model="cfg.reserva_horas"
:options="reservaOptions"
optionLabel="label"
optionValue="value"
class="w-full"
/>
<span class="mag-hint">
<template v-if="cfg.pagamento_modo === 'pix_antecipado'">
Horário bloqueado aguardando o pagamento Pix.
</template>
<template v-else>
Horário bloqueado enquanto a solicitação está pendente.
</template>
</span>
</div>
<div class="mag-card-actions">
<button
class="mag-btn mag-btn--primary"
:disabled="savingCard === 'pagamento'"
@click="saveCard('pagamento')"
>
<i :class="savingCard === 'pagamento' ? 'pi pi-spin pi-spinner' : 'pi pi-check'" />
<span>Salvar pagamento</span>
</button>
</div>
</div>
</div>
<!-- Triagem & LGPD -->
<div id="mag-sec-triagem" class="mag-w">
<div class="mag-w__head">
<div class="mag-w__icon mag-w__icon--orange"><i class="pi pi-shield" /></div>
<div class="mag-w__title">
<div class="mag-w__title-text">Triagem & LGPD</div>
<div class="mag-w__sub">Campos extras e termos</div>
</div>
</div>
<div class="mag-w__body">
<div class="mag-group-title">Campos extras no formulário</div>
<div class="mag-toggle-row">
<div class="mag-toggle-row__text">
<div class="mag-toggle-row__label">Motivo da consulta</div>
<div class="mag-toggle-row__hint">Texto livre opcional pro paciente.</div>
</div>
<ToggleSwitch v-model="cfg.triagem_motivo" />
</div>
<div class="mag-toggle-row">
<div class="mag-toggle-row__text">
<div class="mag-toggle-row__label">Como nos conheceu?</div>
<div class="mag-toggle-row__hint">Origem (indicação, redes sociais, busca).</div>
</div>
<ToggleSwitch v-model="cfg.triagem_como_conheceu" />
</div>
<div class="mag-divider" />
<div class="mag-group-title">Segurança & LGPD</div>
<div class="mag-toggle-row">
<div class="mag-toggle-row__text">
<div class="mag-toggle-row__label">Verificação de e-mail</div>
<div class="mag-toggle-row__hint">Paciente confirma o e-mail antes de finalizar.</div>
</div>
<ToggleSwitch v-model="cfg.verificacao_email" />
</div>
<div class="mag-toggle-row">
<div class="mag-toggle-row__text">
<div class="mag-toggle-row__label">Aceite obrigatório de termos</div>
<div class="mag-toggle-row__hint">Checkbox de LGPD antes de finalizar.</div>
</div>
<ToggleSwitch v-model="cfg.exigir_aceite_lgpd" />
</div>
<div class="mag-card-actions">
<button
class="mag-btn mag-btn--primary"
:disabled="savingCard === 'triagem'"
@click="saveCard('triagem')"
>
<i :class="savingCard === 'triagem' ? 'pi pi-spin pi-spinner' : 'pi pi-check'" />
<span>Salvar triagem</span>
</button>
</div>
</div>
</div>
<!-- Textos da Jornada -->
<div id="mag-sec-textos" class="mag-w mag-w--full">
<div class="mag-w__head">
<div class="mag-w__icon mag-w__icon--pink"><i class="pi pi-file-edit" /></div>
<div class="mag-w__title">
<div class="mag-w__title-text">Textos da Jornada</div>
<div class="mag-w__sub">Mensagens exibidas ao paciente</div>
</div>
</div>
<div class="mag-w__body">
<div class="mag-field">
<label class="mag-label">Mensagem de boas-vindas</label>
<span class="mag-hint">Aparece após o paciente informar o nome.</span>
<JoditTextEditor
v-model="cfg.mensagem_boas_vindas"
:min-height="100"
/>
</div>
<div class="mag-divider" />
<div class="mag-field">
<label class="mag-label">Como se preparar para a sessão</label>
<span class="mag-hint">Aparece na confirmação. Dicas, instruções, etc.</span>
<JoditTextEditor
v-model="cfg.texto_como_se_preparar"
:min-height="140"
/>
</div>
<template v-if="cfg.exigir_aceite_lgpd">
<div class="mag-divider" />
<div class="mag-field">
<label class="mag-label">Termos de uso / Política de privacidade</label>
<span class="mag-hint">Aparece no checkbox de aceite.</span>
<JoditTextEditor
v-model="cfg.texto_termos_lgpd"
:min-height="140"
/>
</div>
</template>
<div class="mag-card-actions">
<button
class="mag-btn mag-btn--primary"
:disabled="savingCard === 'textos'"
@click="saveCard('textos')"
>
<i :class="savingCard === 'textos' ? 'pi pi-spin pi-spinner' : 'pi pi-check'" />
<span>Salvar textos</span>
</button>
</div>
</div>
</div>
</template>
</div>
</div>
</section>
<!-- Card de Pré-visualização (Teleport). O target alterna conforme o
viewport: topo do main (mobile) / sidebar (mid-desktop) / painel
flutuante fora do fake dialog (wide-desktop). -->
<Teleport :to="previewTarget">
<div class="mag-w mag-w--side mag-w--preview">
<div class="mag-w__head">
<div class="mag-w__icon"><i class="pi pi-mobile" /></div>
<div class="mag-w__title">
<div class="mag-w__title-text">Pré-visualização</div>
<div class="mag-w__sub">Como aparece no celular</div>
</div>
</div>
<div class="mag-w__body">
<AgendadorPreview :cfg="cfg" />
</div>
</div>
</Teleport>
</template>
<style scoped>
/* ═══════ Page chrome ═══════ */
.mag-page {
position: absolute;
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) var(--m-config-aside-left, 6px);
z-index: 40;
display: flex;
flex-direction: column;
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border);
border-radius: 18px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
color: var(--m-text);
animation: mag-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes mag-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
.mag-page__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
gap: 10px;
}
.mag-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 700;
color: var(--m-text);
flex-wrap: wrap;
}
.mag-page__title-icon { color: var(--p-primary-color); font-size: 1.05rem; }
.mag-page__pro { font-size: 0.66rem !important; }
.mag-page__actions { display: flex; align-items: center; gap: 6px; }
.mag-close {
width: 32px;
height: 32px;
display: grid;
place-items: center;
background: transparent;
border: 1px solid var(--m-border);
color: var(--m-text-muted);
border-radius: 8px;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
}
.mag-close:hover { background: var(--m-bg-soft-hover); color: var(--m-text); }
.mag-menu-btn {
display: none;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 8px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
flex-shrink: 0;
}
.mag-menu-btn:hover { background: var(--m-bg-soft-hover); }
.mag-subheader {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 18px;
background: var(--m-bg-soft);
border-bottom: 1px solid var(--m-border);
color: var(--m-text-muted);
font-size: 0.78rem;
flex-shrink: 0;
}
.mag-subheader__icon {
color: var(--p-primary-color);
font-size: 0.85rem;
margin-top: 2px;
flex-shrink: 0;
}
/* ═══════ Body 2-col ═══════ */
.mag-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.mag-side {
width: 360px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--m-bg-soft);
border-right: 1px solid var(--m-border);
overflow: hidden;
}
.mag-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mag-side__scroll::-webkit-scrollbar { width: 5px; }
.mag-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Botao "Configuracoes" no topo da .mag-side. Click alterna entre
cards (default) e lista de configs (estado is-open: vira "Voltar"). */
.mag-cfg-btn {
display: flex;
align-items: center;
gap: 8px;
width: calc(100% - 24px);
margin: 12px 12px 0;
padding: 10px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 9px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.84rem;
font-weight: 600;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
flex-shrink: 0;
}
.mag-cfg-btn:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mag-cfg-btn.is-open {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
color: var(--p-primary-color);
}
.mag-cfg-btn > i:first-child {
color: var(--p-primary-color);
font-size: 0.92rem;
}
.mag-cfg-btn > span { flex: 1; }
.mag-cfg-btn__chev {
color: var(--m-text-muted);
font-size: 0.7rem;
}
.mag-side__scroll--cfg {
padding: 8px;
gap: 0;
}
.mag-main {
flex: 1;
min-width: 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mag-main::-webkit-scrollbar { width: 5px; }
.mag-main::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Desktop (>=1024): cards empilhados verticalmente (1-col), cada um
com altura propria do conteudo. O grid 2-col anterior forcava cells
na mesma row a igualarem altura — com align-self: start cards menores
ficavam no topo do cell e o espaco vazio embaixo dava aparencia de
"encavalado". 1-col com max-width centralizado eh mais legivel em
telas largas e respeita "100% do conteudo interno" pedido pelo user.
.mag-main ja eh display:flex flex-direction:column gap:12px no base —
so adiciona limites de largura aqui. */
@media (min-width: 1024px) {
/* Fake dialog: largura adaptativa, alinhada a esquerda (apos o
config-aside global). Espelha o pattern de MelissaNegocio:
- 10241012px : full-width (right: 6px) — overlap minimo
- 10122012px : width = 1000px fixo (right cresce com viewport)
- >= 2012px : width = ~50% do viewport (right: 50%)
Necessario pra ter espaco a direita pro painel flutuante do preview. */
.mag-page {
right: max(6px, min(50%, calc(100% - 1006px)));
}
.mag-main {
max-width: 1100px;
margin: 0 auto;
width: 100%;
}
.mag-main > .mag-w {
height: auto;
flex-shrink: 0;
}
}
/* ═══════ Card-base ═══════ */
.mag-w {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
flex-direction: column;
}
.mag-w--side {
background: var(--m-bg-medium);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16);
}
.mag-w__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
}
.mag-w__icon {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 9px;
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-color) 15%, transparent);
color: var(--p-primary-color);
}
.mag-w__icon > i { font-size: 0.95rem; }
.mag-w__icon--purple { background: color-mix(in srgb, #a855f7 15%, transparent); color: #a855f7; }
.mag-w__icon--blue { background: color-mix(in srgb, #3b82f6 15%, transparent); color: #3b82f6; }
.mag-w__icon--cyan { background: color-mix(in srgb, #06b6d4 15%, transparent); color: #06b6d4; }
.mag-w__icon--green { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; }
.mag-w__icon--orange { background: color-mix(in srgb, #f97316 15%, transparent); color: #f97316; }
.mag-w__icon--pink { background: color-mix(in srgb, #ec4899 15%, transparent); color: #ec4899; }
.mag-w__title { flex: 1; min-width: 0; }
.mag-w__title-text {
font-size: 0.92rem;
font-weight: 700;
color: var(--m-text);
line-height: 1.2;
}
.mag-w__sub {
font-size: 0.74rem;
color: var(--m-text-muted);
margin-top: 2px;
line-height: 1.3;
}
.mag-w__body {
padding: 14px;
display: flex;
flex-direction: column;
gap: 14px;
}
/* ═══════ Sidebar: Status ═══════ */
.mag-toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-radius: 9px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
}
.mag-toggle-row__text { flex: 1; min-width: 0; }
.mag-toggle-row__label {
font-size: 0.82rem;
font-weight: 700;
color: var(--m-text);
}
.mag-toggle-row__hint {
font-size: 0.7rem;
color: var(--m-text-muted);
margin-top: 2px;
line-height: 1.3;
}
.mag-link-loading {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--m-text-muted);
font-size: 0.78rem;
}
.mag-link-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--m-text-muted);
}
.mag-link-row {
display: flex;
gap: 4px;
align-items: stretch;
}
.mag-link-input {
flex: 1;
min-width: 0;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
padding: 7px 10px;
border-radius: 7px;
font-size: 0.72rem;
font-family: 'JetBrains Mono', ui-monospace, monospace;
outline: none;
}
.mag-link-input:focus {
border-color: var(--p-primary-color);
}
.mag-slug {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 8px;
border-top: 1px solid var(--m-border);
}
.mag-slug__label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--m-text-muted);
}
.mag-slug__row {
display: flex;
align-items: stretch;
border: 1px solid var(--m-border);
border-radius: 7px;
overflow: hidden;
background: var(--m-bg-soft);
}
.mag-slug__prefix {
padding: 7px 10px;
background: var(--m-bg-medium);
color: var(--m-text-muted);
font-size: 0.72rem;
font-family: 'JetBrains Mono', ui-monospace, monospace;
border-right: 1px solid var(--m-border);
}
.mag-slug__input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
color: var(--m-text);
padding: 7px 10px;
font-size: 0.78rem;
font-family: 'JetBrains Mono', ui-monospace, monospace;
outline: none;
}
.mag-upgrade {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 9px;
background: rgba(245, 158, 11, 0.10);
border: 1px dashed rgba(245, 158, 11, 0.40);
color: var(--m-text-muted);
font-size: 0.74rem;
line-height: 1.4;
}
.mag-upgrade > i {
color: rgb(245, 158, 11);
font-size: 0.95rem;
margin-top: 2px;
}
.mag-empty-link {
padding: 10px 12px;
border-radius: 9px;
background: var(--m-bg-soft);
border: 1px dashed var(--m-border);
color: var(--m-text-muted);
font-size: 0.78rem;
text-align: center;
}
/* ═══════ Sidebar: Resumos ═══════ */
.mag-summary-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
cursor: pointer;
font-family: inherit;
text-align: left;
transition: background-color 120ms ease, border-color 120ms ease;
}
.mag-summary-item:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mag-summary-item__icon {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 8px;
flex-shrink: 0;
}
.mag-summary-item__icon > i { font-size: 0.85rem; }
.mag-summary-item__text { flex: 1; min-width: 0; }
.mag-summary-item__label {
font-size: 0.82rem;
font-weight: 700;
color: var(--m-text);
}
.mag-summary-item__resumo {
font-size: 0.7rem;
color: var(--m-text-muted);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mag-summary-item__chev {
color: var(--m-text-muted);
font-size: 0.7rem;
flex-shrink: 0;
opacity: 0.5;
}
/* ═══════ Buttons ═══════ */
.mag-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-size: 0.82rem;
font-weight: 600;
font-family: inherit;
transition: background-color 120ms ease, opacity 120ms ease;
}
.mag-btn:hover { background: var(--m-bg-soft-hover); }
.mag-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.mag-btn--primary {
background: var(--p-primary-color);
border-color: var(--p-primary-color);
color: var(--p-primary-contrast-color, #fff);
}
.mag-btn--primary:hover {
background: color-mix(in srgb, var(--p-primary-color) 88%, black);
}
.mag-btn--full { width: 100%; }
.mag-btn--sm { padding: 6px 10px; font-size: 0.78rem; }
.mag-icon-btn {
width: 36px;
display: grid;
place-items: center;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 7px;
color: var(--m-text-muted);
cursor: pointer;
flex-shrink: 0;
transition: background-color 120ms ease, color 120ms ease;
}
.mag-icon-btn:hover {
background: var(--m-bg-soft-hover);
color: var(--m-text);
}
.mag-card-actions {
display: flex;
justify-content: flex-end;
padding-top: 4px;
border-top: 1px solid var(--m-border);
margin-top: 4px;
}
/* ═══════ Form ═══════ */
.mag-field {
display: flex;
flex-direction: column;
gap: 6px;
/* flex-shrink: 0 — em parent flex-col (.mag-w__body), garante que
fields nao colapsem. Originalmente adicionado pro PrimeVue Editor
(Quill) que media altura via JS e shrunk quando flex pai nao tinha
altura definida. Mantido apos troca pra Jodit por consistencia
(form fields nunca devem shrink em layout vertical). */
flex-shrink: 0;
}
.mag-label {
font-size: 0.78rem;
font-weight: 700;
color: var(--m-text);
}
.mag-label__opt {
font-weight: 400;
color: var(--m-text-muted);
opacity: 0.7;
}
.mag-hint {
font-size: 0.7rem;
color: var(--m-text-muted);
line-height: 1.3;
}
.mag-grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
}
.mag-divider {
height: 1px;
background: var(--m-border);
margin: 4px 0;
}
.mag-group-title {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--m-text-muted);
}
/* Color picker */
.mag-color {
display: flex;
align-items: center;
gap: 8px;
}
.mag-color__input {
flex: 1;
min-width: 0;
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.mag-color__swatch {
width: 36px;
height: 36px;
border-radius: 7px;
border: 1px solid var(--m-border);
flex-shrink: 0;
}
/* ═══════ Uploads ═══════ */
.mag-uploads {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
@media (min-width: 700px) {
.mag-uploads { grid-template-columns: repeat(3, 1fr); }
}
.mag-upload {
border: 1px solid var(--m-border);
border-radius: 9px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mag-upload__head {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
background: var(--m-bg-medium);
border-bottom: 1px solid var(--m-border);
font-size: 0.78rem;
font-weight: 700;
color: var(--m-text);
}
.mag-upload__head > i { font-size: 0.78rem; opacity: 0.6; }
.mag-upload__body {
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
}
.mag-upload__hint {
font-size: 0.68rem;
color: var(--m-text-muted);
opacity: 0.7;
}
.mag-upload__preview {
display: flex;
justify-content: center;
}
.mag-upload__img {
max-width: 100%;
height: 56px;
border-radius: 6px;
border: 1px solid var(--m-border);
object-fit: cover;
}
.mag-upload__img--circle {
width: 56px;
height: 56px;
border-radius: 50%;
}
.mag-upload__zone {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px;
border: 1.5px dashed var(--m-border);
border-radius: 7px;
background: var(--m-bg-soft);
color: var(--m-text-muted);
cursor: pointer;
font-family: inherit;
font-size: 0.74rem;
font-weight: 600;
transition: border-color 120ms ease, background-color 120ms ease;
}
.mag-upload__zone:hover {
border-color: var(--p-primary-color);
background: color-mix(in srgb, var(--p-primary-color) 6%, transparent);
}
/* ═══════ Radio cards ═══════ */
.mag-radio-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.mag-radio {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 9px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
text-align: left;
font-family: inherit;
transition: border-color 120ms ease, background-color 120ms ease;
}
.mag-radio:hover { border-color: var(--m-border-strong); }
.mag-radio.is-active {
border-color: var(--p-primary-color);
background: color-mix(in srgb, var(--p-primary-color) 6%, transparent);
}
.mag-radio__label {
font-size: 0.85rem;
font-weight: 700;
color: var(--m-text);
}
.mag-radio__desc {
font-size: 0.72rem;
color: var(--m-text-muted);
margin-top: 2px;
line-height: 1.3;
}
.mag-radio--mode { gap: 12px; }
.mag-radio__icon {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 9px;
background: var(--m-bg-medium);
color: var(--m-text-muted);
flex-shrink: 0;
}
.mag-radio__icon.is-active {
background: color-mix(in srgb, var(--p-primary-color) 18%, transparent);
color: var(--p-primary-color);
}
.mag-radio__icon > i { font-size: 1rem; }
.mag-radio__text { flex: 1; min-width: 0; }
.mag-radio__check {
color: var(--p-primary-color);
flex-shrink: 0;
}
/* ═══════ Chips (tipos de agendamento) ═══════ */
.mag-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.mag-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 12px;
border-radius: 999px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: all 120ms ease;
}
.mag-chip:hover { border-color: var(--m-border-strong); }
.mag-chip.is-active {
background: var(--p-primary-color);
border-color: var(--p-primary-color);
color: var(--p-primary-contrast-color, #fff);
}
.mag-chip > i { font-size: 0.7rem; }
/* ═══════ Methods (pagar na hora) ═══════ */
.mag-methods {
display: flex;
flex-direction: column;
gap: 6px;
}
.mag-method {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
cursor: pointer;
font-size: 0.85rem;
transition: background-color 120ms ease, border-color 120ms ease;
}
.mag-method:hover { background: var(--m-bg-soft-hover); }
.mag-method.is-active {
border-color: var(--p-primary-color);
background: color-mix(in srgb, var(--p-primary-color) 6%, transparent);
}
.mag-method.is-disabled {
opacity: 0.45;
cursor: not-allowed;
}
.mag-method > i {
color: var(--m-text-muted);
font-size: 0.92rem;
}
.mag-method__label { flex: 1; font-weight: 600; }
.mag-method__tag { font-size: 0.66rem !important; }
.mag-warn {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: 9px;
background: rgba(245, 158, 11, 0.10);
border: 1px solid rgba(245, 158, 11, 0.30);
color: rgb(180, 83, 9);
font-size: 0.78rem;
line-height: 1.4;
}
.mag-warn > i { font-size: 0.92rem; margin-top: 2px; flex-shrink: 0; }
/* PrimeVue Editor (Quill) trocado por JoditTextEditor — Jodit traz
CSS proprio (jodit/es2021/jodit.min.css importado no componente)
e suporta dark/light via theme prop. Sem override scoped aqui. */
/* ═══════ Mobile drawer ═══════ */
.mag-mobile-drawer {
position: fixed;
top: 0; left: 0;
height: 100vh;
height: 100dvh;
width: min(380px, 90vw);
z-index: 80;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
}
.mag-mobile-drawer.is-open { transform: translateX(0); }
.mag-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mag-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mag-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* No mobile a .mag-side e teleportada pra dentro do drawer scroll. */
.mag-mobile-drawer__scroll .mag-side {
width: 100%;
border-right: none;
}
.mag-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.mag-drawer-fade-enter-active,
.mag-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mag-drawer-fade-enter-from,
.mag-drawer-fade-leave-to { opacity: 0; }
/* ═══════ Mobile (<1024px) ═══════ */
@media (max-width: 1023px) {
.mag-body { flex-direction: column; padding: 0; }
.mag-body > .mag-side { display: none; }
.mag-main { width: 100%; padding: 8px; }
.mag-main .mag-w {
height: auto;
flex: 0 0 auto;
align-self: stretch;
}
.mag-grid-2 { grid-template-columns: 1fr; }
.mag-page__title > span:first-of-type { display: none; }
.mag-page__title-icon { display: none; }
.mag-menu-btn--mobile-only { display: inline-flex; }
}
/* ═══════ Painel flutuante do Preview (wide-desktop >= 1340px) ═══════
Vive FORA do fake dialog, ancorado a sua right edge + 14px gap.
Largura fica em 320px pra acomodar o phone-frame (260px + bordas + padding).
Glass igual ao fake dialog: fundo, blur, borda, radius, sombra. */
.mag-floating-preview {
display: none; /* default: oculto. Wide-desktop ativa via @media abaixo. */
position: absolute;
top: 6px;
/* height segue o conteudo (sem bottom). max-height limita ao mesmo
espaco do fake dialog pra forcar scroll se ficar muito alto. */
max-height: calc(100% - var(--m-dock-h, 76px) - 12px);
width: 320px;
z-index: 39; /* abaixo do mag-page (40) — nao concorre por foco */
overflow-y: auto;
overflow-x: hidden;
/* Sem padding aqui: o card .mag-w--preview interno controla o espaco
e seu __head fica flush com o topo, alinhando com o head do fake dialog. */
padding: 0;
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border);
border-radius: 18px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mag-floating-preview::-webkit-scrollbar { width: 5px; }
.mag-floating-preview::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
/* Placeholders do preview na sidebar/main: nao introduzem wrapper visivel.
Os filhos teleportados se posicionam como flex items diretos do parent
(mag-side scroll ou mag-main), herdando o mesmo gap dos outros cards. */
.mag-sidebar-preview-target,
.mag-main-preview-target { display: contents; }
/* Esconde target da sidebar em mobile (preview vai pro main) e em
wide-desktop (vai pro floating) */
@media (max-width: 1023px) {
.mag-sidebar-preview-target { display: none; }
}
/* Esconde target do main em desktop (>=1024px) — preview vai pra
sidebar/floating */
@media (min-width: 1024px) {
.mag-main-preview-target { display: none; }
}
/* Dentro do painel flutuante, o card de preview perde o "card-em-card":
sem fundo/borda/sombra propria (o glass do painel ja faz esse papel). */
.mag-floating-preview > .mag-w--preview {
background: transparent;
border: none;
box-shadow: none;
border-radius: 0;
}
.mag-floating-preview > .mag-w--preview > .mag-w__head {
border-bottom: 1px solid var(--m-border);
padding: 14px 18px;
}
.mag-floating-preview > .mag-w--preview > .mag-w__body {
padding: 14px 18px;
}
/* Wide-desktop: floating ativo, ancorado a right edge do .mag-page + 14px gap.
.mag-page tem `right: max(6px, min(50%, calc(100% - 1006px)))`, entao seu
right edge esta a `100% - max(...)` do parent-left. O preview comeca 14px
apos isso. 1340px e o piso onde page (1006) + gap (14) + preview (320) +
margem (caso) cabem confortavelmente. */
@media (min-width: 1340px) {
.mag-floating-preview {
display: block;
left: calc(100% - max(6px, min(50%, calc(100% - 1006px))) + 14px);
}
/* Placeholder da sidebar some — preview foi pro painel flutuante */
.mag-sidebar-preview-target { display: none; }
}
</style>