Files
agenciapsilmno/src/layout/melissa/MelissaPlano.vue
T
Leonardo 7c0c1b3528 MelissaPlano/AlterarPlano desktop: cards min-h 300 + body scroll
Aplica o mesmo fix do MelissaPerfil/Negocio:
- align-items: start no grid (cells nao stretch p/ row height)
- Cards min-height 300px + max-height 100% (do container)
- .mpl-w__body / .map-w__body com flex: 1 + min-height: 0 +
  overflow-y: auto (scrollbar fina)

MelissaPlano: vale pros 2 cards do main (Recursos / Historico) e
pros 2 cards da sidebar (Plano atual / Resumo) — quando o user
tem features longas ou muitos eventos, scroll interno engata.

MelissaAlterarPlano: aplicado SO na sidebar (Plano atual / Filtros).
Os plan cards do main (.map-plan) ficam fora — sao product cards
com layout proprio (preco grande + 3 CTAs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 15:51:47 -03:00

1380 lines
47 KiB
Vue

<script setup>
/*
* MelissaPlano — Pagina nativa Melissa pra "Meu Plano".
*
* Substitui o embed cfg-plano que vivia dentro do MelissaConfiguracoes.
* Layout 2-col (espelha MelissaPerfil):
* - COL 1 (sidebar) — Card "Plano atual" (nome + status + valor +
* periodo + ID) + Card "Resumo" (mini-stats: recursos, eventos,
* proxima renovacao) + Footer com CTAs (Alterar plano + Atualizar)
* - COL 2 (main) — Card "Seu plano inclui" (features agrupadas por
* modulo) + Card "Historico" (subscription_events com badges).
*
* Logica de fetch espelhada de TherapistMeuPlanoPage.vue (subscriptions
* + plans + plan_prices + plan_features + features + subscription_events
* + profiles).
*/
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// Tag/Skeleton: auto via PrimeVueResolver
const emit = defineEmits(['close']);
const router = useRouter();
const toast = useToast();
const tenantStore = useTenantStore();
// ── Breakpoints + drawer ───────────────────────────────────
const drawerOpen = ref(false);
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// ── Estado ─────────────────────────────────────────────────
const loading = ref(false);
const hasLoaded = ref(false);
const subscription = ref(null);
const plan = ref(null);
const price = ref(null);
const features = ref([]);
const events = ref([]);
const plans = ref([]);
const profiles = ref([]);
// ── Formatters ─────────────────────────────────────────────
function money(currency, amountCents) {
if (amountCents == null) return null;
const value = Number(amountCents) / 100;
try {
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: currency || 'BRL'
}).format(value);
} catch (_) {
return `${value.toFixed(2)} ${currency || ''}`.trim();
}
}
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleString('pt-BR');
}
function fmtDateShort(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleDateString('pt-BR');
}
function prettyMeta(meta) {
if (!meta) return null;
try {
if (typeof meta === 'string') return meta;
return JSON.stringify(meta, null, 2);
} catch (_) {
return String(meta);
}
}
function statusSeverity(st) {
const s = String(st || '').toLowerCase();
if (s === 'active') return 'success';
if (s === 'trialing') return 'info';
if (s === 'past_due') return 'warning';
if (s === 'canceled' || s === 'cancelled') return 'danger';
return 'secondary';
}
function statusLabel(st) {
const s = String(st || '').toLowerCase();
if (s === 'active') return 'Ativo';
if (s === 'trialing') return 'Trial';
if (s === 'past_due') return 'Atrasado';
if (s === 'canceled' || s === 'cancelled') return 'Cancelado';
return st || '—';
}
function eventSeverity(t) {
const k = String(t || '').toLowerCase();
if (k === 'plan_changed') return 'info';
if (k === 'canceled') return 'danger';
if (k === 'reactivated') return 'success';
return 'secondary';
}
function eventLabel(t) {
const k = String(t || '').toLowerCase();
if (k === 'plan_changed') return 'Plano alterado';
if (k === 'canceled') return 'Cancelada';
if (k === 'reactivated') return 'Reativada';
return t || '—';
}
// ── Computed ───────────────────────────────────────────────
const planName = computed(() => plan.value?.name || subscription.value?.plan_key || '—');
const intervalLabel = computed(() => {
const i = subscription.value?.interval;
if (i === 'month') return 'mês';
if (i === 'year') return 'ano';
return i || '—';
});
const priceLabel = computed(() => {
if (!price.value) return null;
return `${money(price.value.currency, price.value.amount_cents)} / ${intervalLabel.value}`;
});
const periodLabel = computed(() => {
const s = subscription.value;
if (!s?.current_period_start || !s?.current_period_end) return '—';
return `${fmtDateShort(s.current_period_start)}${fmtDateShort(s.current_period_end)}`;
});
const renewLabel = computed(() => {
const s = subscription.value;
if (!s?.current_period_end) return '—';
return fmtDateShort(s.current_period_end);
});
// ── Features agrupadas ─────────────────────────────────────
function moduleFromKey(key) {
const k = String(key || '').trim();
if (!k) return 'Outros';
if (k.includes('.')) return k.split('.')[0] || 'Outros';
if (k.includes('_')) return k.split('_')[0] || 'Outros';
return 'Outros';
}
function moduleLabel(m) {
const s = String(m || '').trim();
if (!s) return 'Outros';
return s.charAt(0).toUpperCase() + s.slice(1);
}
const groupedFeatures = computed(() => {
const list = features.value || [];
const map = new Map();
for (const f of list) {
const mod = moduleFromKey(f.key);
if (!map.has(mod)) map.set(mod, []);
map.get(mod).push(f);
}
const modules = Array.from(map.keys()).sort((a, b) => {
if (a === 'Outros') return 1;
if (b === 'Outros') return -1;
return a.localeCompare(b);
});
return modules.map((mod) => {
const items = map.get(mod) || [];
items.sort((a, b) => String(a.key || '').localeCompare(String(b.key || '')));
return { module: mod, items };
});
});
// ── Historico ──────────────────────────────────────────────
const planById = computed(() => {
const m = new Map();
for (const p of plans.value || []) m.set(String(p.id), p);
return m;
});
function planKeyOrName(planId) {
if (!planId) return '—';
const p = planById.value.get(String(planId));
return p?.key || p?.name || String(planId);
}
const profileById = computed(() => {
const m = new Map();
for (const p of profiles.value || []) m.set(String(p.id), p);
return m;
});
function displayUser(userId) {
if (!userId) return '—';
const p = profileById.value.get(String(userId));
if (!p) return String(userId);
const name = p.nome || p.name || p.full_name || p.display_name || null;
const email = p.email || p.email_principal || null;
if (name && email) return `${name} <${email}>`;
return name || email || String(userId);
}
// ── Actions ────────────────────────────────────────────────
function goUpgrade() {
// Vai pra pagina nativa Melissa de alterar plano (substitui o
// /therapist/upgrade que virava redirect e perdia o overlay Melissa).
router.push({ name: 'Melissa', params: { secao: 'alterar-plano' } });
}
// ── Fetch ──────────────────────────────────────────────────
async function fetchMeuPlano() {
loading.value = true;
try {
const { data: authData, error: authError } = await supabase.auth.getUser();
if (authError) throw authError;
const uid = authData?.user?.id;
if (!uid) throw new Error('Sessão não encontrada.');
const sRes = await supabase
.from('subscriptions')
.select('*')
.eq('user_id', uid)
.order('created_at', { ascending: false })
.limit(10);
if (sRes.error) throw sRes.error;
const subList = sRes.data || [];
const priority = (st) => {
const s = String(st || '').toLowerCase();
if (s === 'active') return 1;
if (s === 'trialing') return 2;
if (s === 'past_due') return 3;
if (s === 'unpaid') return 4;
if (s === 'incomplete') return 5;
if (s === 'canceled' || s === 'cancelled') return 9;
return 8;
};
subscription.value = subList.length
? subList.slice().sort((a, b) => {
const pa = priority(a?.status);
const pb = priority(b?.status);
if (pa !== pb) return pa - pb;
return new Date(b?.created_at || 0) - new Date(a?.created_at || 0);
})[0]
: null;
if (!subscription.value) {
plan.value = null;
price.value = null;
features.value = [];
events.value = [];
plans.value = [];
profiles.value = [];
return;
}
const pRes = await supabase
.from('plans')
.select('id, key, name, description')
.eq('id', subscription.value.plan_id)
.maybeSingle();
if (pRes.error) throw pRes.error;
plan.value = pRes.data || null;
const nowIso = new Date().toISOString();
const ppRes = await supabase
.from('plan_prices')
.select('currency, interval, amount_cents, is_active, active_from, active_to')
.eq('plan_id', subscription.value.plan_id)
.eq('interval', subscription.value.interval)
.eq('is_active', true)
.lte('active_from', nowIso)
.or(`active_to.is.null,active_to.gte.${nowIso}`)
.order('active_from', { ascending: false })
.limit(1)
.maybeSingle();
if (ppRes.error) throw ppRes.error;
price.value = ppRes.data || null;
const pfRes = await supabase
.from('plan_features')
.select('feature_id')
.eq('plan_id', subscription.value.plan_id);
if (pfRes.error) throw pfRes.error;
const featureIds = (pfRes.data || []).map((r) => r.feature_id).filter(Boolean);
if (featureIds.length) {
const fRes = await supabase
.from('features')
.select('id, key, description, descricao')
.in('id', featureIds)
.order('key', { ascending: true });
if (fRes.error) throw fRes.error;
features.value = (fRes.data || []).map((f) => ({
key: f.key,
description: (f.descricao || f.description || '').trim()
}));
} else {
features.value = [];
}
const eRes = await supabase
.from('subscription_events')
.select('*')
.eq('subscription_id', subscription.value.id)
.order('created_at', { ascending: false })
.limit(50);
if (eRes.error) throw eRes.error;
events.value = eRes.data || [];
const planIds = new Set();
if (subscription.value?.plan_id) planIds.add(String(subscription.value.plan_id));
for (const ev of events.value) {
if (ev?.old_plan_id) planIds.add(String(ev.old_plan_id));
if (ev?.new_plan_id) planIds.add(String(ev.new_plan_id));
}
if (planIds.size) {
const { data: pAll, error: epAll } = await supabase
.from('plans')
.select('id,key,name')
.in('id', Array.from(planIds));
plans.value = epAll ? [] : pAll || [];
} else {
plans.value = [];
}
const userIds = new Set();
for (const ev of events.value) {
const by = String(ev.created_by || '').trim();
if (by) userIds.add(by);
}
if (userIds.size) {
const { data: pr, error: epr } = await supabase
.from('profiles')
.select('*')
.in('id', Array.from(userIds));
profiles.value = epr ? [] : pr || [];
} else {
profiles.value = [];
}
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 });
} finally {
loading.value = false;
hasLoaded.value = true;
}
}
// ── 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); }
}
await tenantStore.ensureLoaded();
await fetchMeuPlano();
});
</script>
<template>
<Transition name="mpl-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mpl-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
>
<div id="mpl-mobile-drawer-target" class="mpl-mobile-drawer__scroll" />
</div>
</Transition>
<Transition name="mpl-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="mpl-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="mpl-page">
<header class="mpl-page__head">
<button
class="mpl-menu-btn mpl-menu-btn--mobile-only"
v-tooltip.bottom="'Plano & Acoes'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu Plano</span>
</button>
<div class="mpl-page__title">
<i class="pi pi-credit-card mpl-page__title-icon" />
<span>Meu Plano</span>
<Tag
v-if="subscription"
:value="statusLabel(subscription.status)"
:severity="statusSeverity(subscription.status)"
/>
</div>
<div class="mpl-page__actions">
<button
class="mpl-act-btn"
v-tooltip.bottom="'Atualizar'"
:disabled="loading"
@click="fetchMeuPlano"
>
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
</button>
<button class="mpl-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
<i class="pi pi-times" />
</button>
</div>
</header>
<div class="mpl-subheader">
<i class="pi pi-info-circle mpl-subheader__icon" />
<span class="mpl-subheader__text">
Sua assinatura, recursos inclusos e histórico de mudanças. Pra trocar
de plano, clique em <strong>Alterar plano</strong> na lateral.
</span>
</div>
<div class="mpl-body">
<!-- COL 1: Sidebar -->
<Teleport to="#mpl-mobile-drawer-target" :disabled="!isMobile">
<aside class="mpl-side">
<div class="mpl-side__scroll">
<!-- Card: Plano atual -->
<div class="mpl-w mpl-w--side">
<div class="mpl-w__head">
<div class="mpl-w__icon"><i class="pi pi-credit-card" /></div>
<div class="mpl-w__title">
<div class="mpl-w__title-text">Plano atual</div>
<div class="mpl-w__sub">Assinatura ativa e ciclo de cobrança</div>
</div>
</div>
<div class="mpl-w__body">
<template v-if="loading && !hasLoaded">
<Skeleton width="60%" height="22px" class="mb-2" />
<Skeleton width="100%" height="14px" class="mb-1" />
<Skeleton width="80%" height="14px" />
</template>
<template v-else-if="!subscription">
<div class="mpl-empty">
<i class="pi pi-credit-card mpl-empty__icon" />
<div class="mpl-empty__title">Nenhuma assinatura</div>
<div class="mpl-empty__hint">
Escolha um plano pra começar.
</div>
</div>
</template>
<template v-else>
<div class="mpl-plan">
<div class="mpl-plan__name">{{ planName }}</div>
<div class="mpl-plan__price">
{{ priceLabel || 'Preço não disponível' }}
</div>
</div>
<div class="mpl-info">
<div class="mpl-info__row">
<span class="mpl-info__label">Status</span>
<Tag
:value="statusLabel(subscription.status)"
:severity="statusSeverity(subscription.status)"
/>
</div>
<div class="mpl-info__row">
<span class="mpl-info__label">Ciclo atual</span>
<span class="mpl-info__value">{{ periodLabel }}</span>
</div>
<div class="mpl-info__row">
<span class="mpl-info__label">
{{ subscription.cancel_at_period_end ? 'Encerra em' : 'Renova em' }}
</span>
<span class="mpl-info__value">{{ renewLabel }}</span>
</div>
<div class="mpl-info__row">
<span class="mpl-info__label">Renovação</span>
<Tag
v-if="subscription.cancel_at_period_end"
value="Cancelamento agendado"
severity="warning"
/>
<Tag
v-else
value="Automática"
severity="success"
/>
</div>
<div v-if="plan?.description" class="mpl-info__row mpl-info__row--full">
<span class="mpl-info__label">Descrição</span>
<span class="mpl-info__desc">{{ plan.description }}</span>
</div>
</div>
</template>
</div>
</div>
<!-- Card: Resumo (mini stats) -->
<div v-if="subscription" class="mpl-w mpl-w--side">
<div class="mpl-w__head">
<div class="mpl-w__icon"><i class="pi pi-chart-pie" /></div>
<div class="mpl-w__title">
<div class="mpl-w__title-text">Resumo</div>
<div class="mpl-w__sub">Indicadores rápidos</div>
</div>
</div>
<div class="mpl-w__body">
<div class="mpl-stats">
<div class="mpl-stat">
<div class="mpl-stat__val">{{ features.length }}</div>
<div class="mpl-stat__lbl">Recursos</div>
</div>
<div class="mpl-stat">
<div class="mpl-stat__val">{{ events.length }}</div>
<div class="mpl-stat__lbl">Eventos</div>
</div>
<div class="mpl-stat">
<div class="mpl-stat__val mpl-stat__val--small">{{ renewLabel }}</div>
<div class="mpl-stat__lbl">{{ subscription.cancel_at_period_end ? 'Encerra' : 'Renova' }}</div>
</div>
</div>
<div class="mpl-id-row">
<span class="mpl-id-row__label">ID da assinatura</span>
<code class="mpl-id-row__code">{{ subscription.id }}</code>
</div>
</div>
</div>
</div>
<div class="mpl-side__footer">
<button class="mpl-btn mpl-btn--primary mpl-btn--full" @click="goUpgrade">
<i class="pi pi-arrow-up-right" />
<span>{{ subscription ? 'Alterar plano' : 'Ver planos' }}</span>
</button>
</div>
</aside>
</Teleport>
<!-- COL 2: Main -->
<div class="mpl-main">
<!-- Empty state -->
<div v-if="!loading && !subscription" class="mpl-w">
<div class="mpl-w__head">
<div class="mpl-w__icon"><i class="pi pi-info-circle" /></div>
<div class="mpl-w__title">
<div class="mpl-w__title-text">Sem assinatura ativa</div>
<div class="mpl-w__sub">Você ainda não tem um plano contratado</div>
</div>
</div>
<div class="mpl-w__body">
<div class="mpl-empty mpl-empty--big">
<i class="pi pi-credit-card mpl-empty__icon" />
<div class="mpl-empty__title">Escolha um plano pra começar</div>
<div class="mpl-empty__hint">
Ative todos os recursos do Agência PSI.
</div>
<button class="mpl-btn mpl-btn--primary" @click="goUpgrade">
<i class="pi pi-arrow-up-right" />
<span>Ver planos</span>
</button>
</div>
</div>
</div>
<!-- Loading skeleton -->
<template v-if="loading && !hasLoaded">
<div class="mpl-w" v-for="n in 2" :key="`sk-${n}`">
<div class="mpl-w__body">
<Skeleton width="40%" height="20px" class="mb-3" />
<Skeleton v-for="m in 4" :key="`sk-${n}-${m}`" width="100%" height="32px" class="mb-2" />
</div>
</div>
</template>
<template v-else-if="subscription">
<!-- Recursos do plano -->
<div class="mpl-w">
<div class="mpl-w__head">
<div class="mpl-w__icon"><i class="pi pi-check-circle" /></div>
<div class="mpl-w__title">
<div class="mpl-w__title-text">Seu plano inclui</div>
<div class="mpl-w__sub">Recursos liberados pelo plano contratado</div>
</div>
<span v-if="features.length" class="mpl-w__count">{{ features.length }}</span>
</div>
<div class="mpl-w__body">
<div v-if="!features.length" class="mpl-empty mpl-empty--small">
<i class="pi pi-info-circle" />
Nenhum recurso vinculado.
</div>
<div v-else class="mpl-features">
<div
v-for="g in groupedFeatures"
:key="g.module"
class="mpl-feat-group"
>
<div class="mpl-feat-group__head">
<span class="mpl-feat-group__label">{{ moduleLabel(g.module) }}</span>
<div class="mpl-feat-group__line" />
<span class="mpl-feat-group__count">{{ g.items.length }}</span>
</div>
<div class="mpl-feat-list">
<div
v-for="f in g.items"
:key="f.key"
class="mpl-feat"
:title="f.description || f.key"
>
<i class="pi pi-check-circle mpl-feat__check" />
<div class="mpl-feat__text">
<div class="mpl-feat__key">{{ f.key }}</div>
<div v-if="f.description" class="mpl-feat__desc">
{{ f.description }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Historico -->
<div class="mpl-w">
<div class="mpl-w__head">
<div class="mpl-w__icon"><i class="pi pi-history" /></div>
<div class="mpl-w__title">
<div class="mpl-w__title-text">Histórico</div>
<div class="mpl-w__sub">Mudanças, cancelamentos e reativações</div>
</div>
<span v-if="events.length" class="mpl-w__count">{{ events.length }}</span>
</div>
<div class="mpl-w__body">
<div v-if="!events.length" class="mpl-empty mpl-empty--small">
<i class="pi pi-history" />
Sem eventos registrados.
</div>
<div v-else class="mpl-events">
<div v-for="ev in events" :key="ev.id" class="mpl-event">
<div class="mpl-event__head">
<Tag
:value="eventLabel(ev.event_type)"
:severity="eventSeverity(ev.event_type)"
/>
<span class="mpl-event__date">{{ fmtDate(ev.created_at) }}</span>
</div>
<div v-if="ev.old_plan_id || ev.new_plan_id" class="mpl-event__diff">
<span class="mpl-event__plan">{{ planKeyOrName(ev.old_plan_id) }}</span>
<i class="pi pi-arrow-right" />
<span class="mpl-event__plan">{{ planKeyOrName(ev.new_plan_id) }}</span>
</div>
<div class="mpl-event__by">
por <b>{{ displayUser(ev.created_by) }}</b>
</div>
<div v-if="ev.reason" class="mpl-event__reason">{{ ev.reason }}</div>
<pre v-if="ev.metadata" class="mpl-event__meta">{{ prettyMeta(ev.metadata) }}</pre>
</div>
<div class="mpl-events__hint">Mostrando até 50 eventos (mais recentes).</div>
</div>
</div>
</div>
</template>
</div>
</div>
</section>
</template>
<style scoped>
/* ═══════ Page chrome (mesmo pattern do MelissaPerfil) ═══════ */
.mpl-page {
position: absolute;
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) 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: mpl-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes mpl-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
.mpl-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;
}
.mpl-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 700;
color: var(--m-text);
}
.mpl-page__title-icon {
color: var(--p-primary-color);
font-size: 1.05rem;
}
.mpl-page__actions {
display: flex;
align-items: center;
gap: 6px;
}
.mpl-act-btn {
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;
}
.mpl-act-btn:hover {
background: var(--m-bg-soft-hover);
color: var(--m-text);
}
.mpl-act-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.mpl-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;
}
.mpl-close:hover {
background: var(--m-bg-soft-hover);
color: var(--m-text);
}
.mpl-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;
}
.mpl-menu-btn:hover { background: var(--m-bg-soft-hover); }
.mpl-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;
}
.mpl-subheader__icon {
color: var(--p-primary-color);
font-size: 0.85rem;
margin-top: 2px;
flex-shrink: 0;
}
/* ═══════ Body 2-col ═══════ */
.mpl-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.mpl-side {
width: 320px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--m-bg-soft);
border-right: 1px solid var(--m-border);
overflow: hidden;
}
.mpl-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;
}
.mpl-side__scroll::-webkit-scrollbar { width: 5px; }
.mpl-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mpl-side__footer {
flex-shrink: 0;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
}
.mpl-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;
}
.mpl-main::-webkit-scrollbar { width: 5px; }
.mpl-main::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Desktop (>=1024): main em grid 2-col, cards min-h 300 + max-h 100%
+ body com overflow-y: auto. Sidebar (Plano atual + Resumo) ganha
o mesmo cap pra nao crescer demais quando tem features/eventos longos. */
@media (min-width: 1024px) {
.mpl-main {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
align-items: start;
align-content: start;
}
.mpl-main > .mpl-w {
min-height: 300px;
max-height: 100%;
display: flex;
flex-direction: column;
}
.mpl-side > .mpl-side__scroll > .mpl-w--side {
min-height: 300px;
max-height: 100%;
display: flex;
flex-direction: column;
}
.mpl-main > .mpl-w > .mpl-w__body,
.mpl-side .mpl-w--side > .mpl-w__body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mpl-main > .mpl-w > .mpl-w__body::-webkit-scrollbar,
.mpl-side .mpl-w--side > .mpl-w__body::-webkit-scrollbar { width: 5px; }
.mpl-main > .mpl-w > .mpl-w__body::-webkit-scrollbar-thumb,
.mpl-side .mpl-w--side > .mpl-w__body::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
}
/* ═══════ Card-base (estilo MelissaFinanceiro/MelissaPerfil) ═══════ */
.mpl-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;
}
.mpl-w--side {
background: var(--m-bg-medium);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16);
}
.mpl-w__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
}
.mpl-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);
}
.mpl-w__icon > i { font-size: 0.95rem; }
.mpl-w__title {
flex: 1;
min-width: 0;
}
.mpl-w__title-text {
font-size: 0.92rem;
font-weight: 700;
color: var(--m-text);
line-height: 1.2;
}
.mpl-w__sub {
font-size: 0.74rem;
color: var(--m-text-muted);
margin-top: 2px;
line-height: 1.3;
}
.mpl-w__count {
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-color) 18%, transparent);
color: var(--p-primary-color);
border-radius: 999px;
padding: 3px 10px;
font-size: 0.74rem;
font-weight: 700;
}
.mpl-w__body {
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* ═══════ Buttons ═══════ */
.mpl-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;
}
.mpl-btn:hover { background: var(--m-bg-soft-hover); }
.mpl-btn--primary {
background: var(--p-primary-color);
border-color: var(--p-primary-color);
color: var(--p-primary-contrast-color, #fff);
}
.mpl-btn--primary:hover {
background: color-mix(in srgb, var(--p-primary-color) 88%, black);
}
.mpl-btn--full { width: 100%; }
/* ═══════ Sidebar: Plano atual ═══════ */
.mpl-plan {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
border-radius: 9px;
background: color-mix(in srgb, var(--p-primary-color) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--p-primary-color) 25%, transparent);
}
.mpl-plan__name {
font-size: 1.1rem;
font-weight: 800;
color: var(--p-primary-color);
line-height: 1.1;
}
.mpl-plan__price {
font-size: 0.78rem;
color: var(--m-text);
font-weight: 600;
}
.mpl-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.mpl-info__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mpl-info__row--full {
flex-direction: column;
align-items: flex-start;
gap: 3px;
}
.mpl-info__label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--m-text-muted);
flex-shrink: 0;
}
.mpl-info__value {
font-size: 0.78rem;
font-weight: 600;
color: var(--m-text);
text-align: right;
word-break: break-word;
}
.mpl-info__desc {
font-size: 0.78rem;
color: var(--m-text);
line-height: 1.4;
}
/* ═══════ Sidebar: Resumo (mini stats) ═══════ */
.mpl-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.mpl-stat {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 10px 6px;
border-radius: 9px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
text-align: center;
}
.mpl-stat__val {
font-size: 1.1rem;
font-weight: 800;
color: var(--m-text);
line-height: 1.1;
}
.mpl-stat__val--small {
font-size: 0.8rem;
}
.mpl-stat__lbl {
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--m-text-muted);
}
.mpl-id-row {
display: flex;
flex-direction: column;
gap: 3px;
padding-top: 4px;
border-top: 1px solid var(--m-border);
}
.mpl-id-row__label {
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--m-text-muted);
}
.mpl-id-row__code {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.7rem;
color: var(--m-text-muted);
word-break: break-all;
user-select: all;
}
/* ═══════ Empty states ═══════ */
.mpl-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 8px;
text-align: center;
}
.mpl-empty--small {
flex-direction: row;
padding: 12px;
color: var(--m-text-muted);
font-size: 0.85rem;
}
.mpl-empty--small > i { color: var(--p-primary-color); }
.mpl-empty--big {
padding: 36px 24px;
gap: 10px;
}
.mpl-empty__icon {
font-size: 2rem;
color: var(--m-text-faint);
opacity: 0.6;
}
.mpl-empty--big .mpl-empty__icon { font-size: 2.5rem; }
.mpl-empty__title {
font-size: 0.92rem;
font-weight: 700;
color: var(--m-text);
}
.mpl-empty__hint {
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.4;
}
/* ═══════ Recursos do plano (features grouped) ═══════ */
.mpl-features {
display: flex;
flex-direction: column;
gap: 14px;
}
.mpl-feat-group__head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.mpl-feat-group__label {
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--p-primary-color);
}
.mpl-feat-group__line {
flex: 1;
height: 1px;
background: var(--m-border);
}
.mpl-feat-group__count {
font-size: 0.66rem;
font-weight: 700;
color: var(--m-text-muted);
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 999px;
padding: 1px 7px;
}
.mpl-feat-list {
display: grid;
grid-template-columns: 1fr;
gap: 4px;
}
@media (min-width: 600px) {
.mpl-feat-list { grid-template-columns: 1fr 1fr; }
}
.mpl-feat {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
transition: background-color 120ms ease;
}
.mpl-feat:hover { background: var(--m-bg-medium); }
.mpl-feat__check {
color: rgb(22, 163, 74);
font-size: 0.85rem;
margin-top: 2px;
flex-shrink: 0;
}
.mpl-feat__text { min-width: 0; flex: 1; }
.mpl-feat__key {
font-size: 0.82rem;
font-weight: 600;
color: var(--m-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mpl-feat__desc {
font-size: 0.7rem;
color: var(--m-text-muted);
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ═══════ Historico (events) ═══════ */
.mpl-events {
display: flex;
flex-direction: column;
gap: 8px;
}
.mpl-event {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 9px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
}
.mpl-event__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.mpl-event__date {
font-size: 0.7rem;
color: var(--m-text-muted);
}
.mpl-event__diff {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
color: var(--m-text-muted);
flex-wrap: wrap;
}
.mpl-event__diff > i { font-size: 0.68rem; opacity: 0.6; }
.mpl-event__plan {
color: var(--m-text);
font-weight: 600;
}
.mpl-event__by {
font-size: 0.74rem;
color: var(--m-text-muted);
}
.mpl-event__by > b { color: var(--m-text); font-weight: 600; }
.mpl-event__reason {
font-size: 0.74rem;
color: var(--m-text-muted);
font-style: italic;
}
.mpl-event__meta {
margin: 4px 0 0;
padding: 6px 8px;
border-radius: 6px;
background: var(--m-bg-soft);
font-size: 0.68rem;
color: var(--m-text-muted);
font-family: 'JetBrains Mono', ui-monospace, monospace;
white-space: pre-wrap;
word-break: break-word;
max-height: 120px;
overflow-y: auto;
}
.mpl-events__hint {
font-size: 0.7rem;
color: var(--m-text-muted);
text-align: center;
padding-top: 4px;
}
/* ═══════ Mobile drawer ═══════ */
.mpl-mobile-drawer {
position: fixed;
top: 0; left: 0;
height: 100vh;
height: 100dvh;
width: min(360px, 88vw);
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;
}
.mpl-mobile-drawer.is-open { transform: translateX(0); }
.mpl-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mpl-mobile-drawer__scroll .mpl-side {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mpl-mobile-drawer__scroll .mpl-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;
}
.mpl-mobile-drawer__scroll .mpl-side__scroll::-webkit-scrollbar { width: 5px; }
.mpl-mobile-drawer__scroll .mpl-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mpl-mobile-drawer__scroll .mpl-w--side {
margin: 0;
flex-shrink: 0;
}
.mpl-mobile-drawer__scroll .mpl-side__footer {
flex-shrink: 0;
margin: 0;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
}
.mpl-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;
}
.mpl-drawer-fade-enter-active,
.mpl-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mpl-drawer-fade-enter-from,
.mpl-drawer-fade-leave-to { opacity: 0; }
/* ═══════ Mobile (<1024px) ═══════ */
@media (max-width: 1023px) {
.mpl-body {
flex-direction: column;
padding: 0;
}
.mpl-side { display: none; }
.mpl-main {
width: 100%;
padding: 8px;
}
.mpl-main .mpl-w {
height: auto;
flex: 0 0 auto;
align-self: stretch;
}
.mpl-page__title > span:first-of-type { display: none; }
.mpl-page__title-icon { display: none; }
.mpl-menu-btn--mobile-only { display: inline-flex; }
}
</style>