Files
agenciapsilmno/src/layout/melissa/MelissaAlterarPlano.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

1104 lines
37 KiB
Vue

<script setup>
/*
* MelissaAlterarPlano — Pagina nativa Melissa pra escolha/upgrade de plano.
*
* Substitui o redirect pra /therapist/upgrade que vinha do MelissaPlano.
* Layout 2-col (espelha MelissaPerfil/Plano):
* - COL 1 (sidebar) — Card "Plano atual" (nome + valor + status do
* subscription corrente) + Card "Filtros" (busca + interval toggle
* mensal/anual) + Footer "Voltar pro Meu Plano"
* - COL 2 (main) — Grid responsivo de plan cards (Mensal/Anual,
* descricao, preco, 3 CTAs: Escolher do interval atual + Mensal + Anual)
*
* Logica espelhada do TherapistUpgradePage.vue (subscriptions + plans
* target=therapist + plan_prices + RPC change_subscription_plan).
*/
import { ref, computed, onMounted } from 'vue';
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', 'goto']);
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(true);
const saving = ref(false);
const isFetching = ref(false);
const uid = ref(null);
const currentSub = ref(null);
const plans = ref([]);
const prices = ref([]);
const q = ref('');
const billingInterval = ref('month'); // 'month' | 'year'
const intervalOptions = [
{ label: 'Mensal', value: 'month' },
{ label: 'Anual', value: 'year' }
];
// ── Helpers ────────────────────────────────────────────────
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 intervalLabel(i) {
if (i === 'month') return 'mês';
if (i === 'year') return 'ano';
return i || '—';
}
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 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 priceFor(planId, interval) {
return (prices.value || []).find(
(p) => String(p.plan_id) === String(planId) && String(p.interval) === String(interval)
) || null;
}
function priceLabelFor(planRow, interval) {
const pp = priceFor(planRow?.id, interval);
if (!pp) return null;
return `${money(pp.currency, pp.amount_cents)} / ${intervalLabel(interval)}`;
}
function priceLabelForCard(planRow) {
return priceLabelFor(planRow, billingInterval.value) || '—';
}
const currentPlanName = computed(() => {
if (!currentSub.value) return null;
const p = plans.value.find((x) => String(x.id) === String(currentSub.value.plan_id));
return p?.name || currentSub.value.plan_key || '—';
});
const currentPriceLabel = computed(() => {
if (!currentSub.value) return null;
const p = plans.value.find((x) => String(x.id) === String(currentSub.value.plan_id));
if (!p) return null;
return priceLabelFor(p, currentSub.value.interval);
});
const filteredPlans = computed(() => {
const term = String(q.value || '').trim().toLowerCase();
if (!term) return plans.value || [];
return (plans.value || []).filter((p) => {
const a = String(p.key || '').toLowerCase();
const b = String(p.name || '').toLowerCase();
const c = String(p.description || '').toLowerCase();
return a.includes(term) || b.includes(term) || c.includes(term);
});
});
// ── Fetch ──────────────────────────────────────────────────
async function loadData() {
if (isFetching.value) return;
isFetching.value = true;
loading.value = true;
try {
const { data: authData, error: authError } = await supabase.auth.getUser();
if (authError) throw authError;
uid.value = authData?.user?.id;
if (!uid.value) throw new Error('Sessão não encontrada.');
const sRes = await supabase
.from('subscriptions')
.select('*')
.eq('user_id', uid.value)
.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;
};
currentSub.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;
const pRes = await supabase
.from('plans')
.select('id, key, name, description, target, is_active')
.eq('target', 'therapist')
.order('created_at', { ascending: true });
if (pRes.error) throw pRes.error;
plans.value = (pRes.data || []).filter((p) => p?.is_active !== false);
const nowIso = new Date().toISOString();
const ppRes = await supabase
.from('plan_prices')
.select('plan_id, currency, interval, amount_cents, is_active, active_from, active_to')
.eq('is_active', true)
.lte('active_from', nowIso)
.or(`active_to.is.null,active_to.gte.${nowIso}`)
.order('active_from', { ascending: false });
if (ppRes.error) throw ppRes.error;
prices.value = ppRes.data || [];
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 });
} finally {
loading.value = false;
isFetching.value = false;
}
}
// ── Preflight + escolha ────────────────────────────────────
function preflight(planRow, interval) {
if (!uid.value) return { ok: false, msg: 'Sessão não encontrada.' };
if (!planRow?.id) return { ok: false, msg: 'Plano inválido.' };
if (!['month', 'year'].includes(String(interval))) {
return { ok: false, msg: 'Intervalo inválido.' };
}
const pp = priceFor(planRow.id, interval);
if (!pp) {
return {
ok: false,
msg: `Este plano não tem preço ativo para ${intervalLabel(interval)}.`
};
}
if (
currentSub.value?.plan_id === planRow.id &&
String(currentSub.value?.interval) === String(interval)
) {
return { ok: false, msg: 'Você já está nesse plano/intervalo.' };
}
return { ok: true, msg: '' };
}
async function choosePlan(planRow, interval) {
const pf = preflight(planRow, interval);
if (!pf.ok) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: pf.msg, life: 4200 });
return;
}
saving.value = true;
try {
const nowIso = new Date().toISOString();
if (currentSub.value?.id) {
const { error: e1 } = await supabase.rpc('change_subscription_plan', {
p_subscription_id: currentSub.value.id,
p_new_plan_id: planRow.id
});
if (e1) throw e1;
const { error: e2 } = await supabase
.from('subscriptions')
.update({
interval,
updated_at: nowIso,
cancel_at_period_end: false,
status: 'active'
})
.eq('id', currentSub.value.id);
if (e2) throw e2;
} else {
const { data: ins, error: eIns } = await supabase
.from('subscriptions')
.insert({
user_id: uid.value,
tenant_id: null,
plan_id: planRow.id,
plan_key: planRow.key,
interval,
status: 'active',
cancel_at_period_end: false,
provider: 'manual',
source: 'therapist_upgrade',
started_at: nowIso,
current_period_start: nowIso
})
.select('*')
.maybeSingle();
if (eIns) throw eIns;
currentSub.value = ins || null;
}
toast.add({
severity: 'success',
summary: 'Plano atualizado',
detail: 'Seu plano foi atualizado com sucesso.',
life: 3200
});
await loadData();
// Volta pro MelissaPlano pra ver o estado novo
emit('goto', 'plano');
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 });
} finally {
saving.value = false;
}
}
// ── 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 loadData();
});
</script>
<template>
<Transition name="map-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="map-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
>
<div id="map-mobile-drawer-target" class="map-mobile-drawer__scroll" />
</div>
</Transition>
<Transition name="map-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="map-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="map-page">
<header class="map-page__head">
<button
class="map-menu-btn map-menu-btn--mobile-only"
v-tooltip.bottom="'Filtros & Plano atual'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu</span>
</button>
<div class="map-page__title">
<i class="pi pi-arrow-up-right map-page__title-icon" />
<span>Alterar Plano</span>
<Tag
v-if="currentSub"
:value="`Atual: ${currentSub.plan_key}`"
:severity="statusSeverity(currentSub.status)"
/>
<Tag v-else value="Sem plano" severity="warning" />
</div>
<div class="map-page__actions">
<button
class="map-act-btn"
v-tooltip.bottom="'Atualizar'"
:disabled="loading || saving"
@click="loadData"
>
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
</button>
<button class="map-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
<i class="pi pi-times" />
</button>
</div>
</header>
<div class="map-subheader">
<i class="pi pi-info-circle map-subheader__icon" />
<span class="map-subheader__text">
Escolha seu plano pessoal. Use o filtro <strong>Mensal/Anual</strong>
no aside pra comparar preços.
</span>
</div>
<div class="map-body">
<Teleport to="#map-mobile-drawer-target" :disabled="!isMobile">
<aside class="map-side">
<div class="map-side__scroll">
<!-- Card: Plano atual -->
<div class="map-w map-w--side">
<div class="map-w__head">
<div class="map-w__icon"><i class="pi pi-credit-card" /></div>
<div class="map-w__title">
<div class="map-w__title-text">Plano atual</div>
<div class="map-w__sub">Sua assinatura corrente</div>
</div>
</div>
<div class="map-w__body">
<template v-if="loading && !plans.length">
<Skeleton width="60%" height="22px" class="mb-2" />
<Skeleton width="80%" height="14px" />
</template>
<template v-else-if="!currentSub">
<div class="map-empty">
<i class="pi pi-info-circle" />
<span>Você ainda não tem um plano pessoal.</span>
</div>
</template>
<template v-else>
<div class="map-curr">
<div class="map-curr__name">{{ currentPlanName }}</div>
<div class="map-curr__key">{{ currentSub.plan_key }}</div>
<div class="map-curr__price">
{{ currentPriceLabel || 'Preço não disponível' }}
</div>
<Tag
:value="statusLabel(currentSub.status)"
:severity="statusSeverity(currentSub.status)"
/>
</div>
</template>
</div>
</div>
<!-- Card: Filtros -->
<div class="map-w map-w--side">
<div class="map-w__head">
<div class="map-w__icon"><i class="pi pi-filter" /></div>
<div class="map-w__title">
<div class="map-w__title-text">Filtros</div>
<div class="map-w__sub">Busque planos e ajuste o intervalo</div>
</div>
</div>
<div class="map-w__body">
<FloatLabel variant="on">
<InputText
id="map_q"
v-model="q"
class="w-full"
:disabled="loading || saving"
/>
<label for="map_q">Buscar plano</label>
</FloatLabel>
<div class="map-interval">
<span class="map-interval__label">Preço:</span>
<button
v-for="opt in intervalOptions"
:key="opt.value"
type="button"
class="map-interval__btn"
:class="{ 'is-active': billingInterval === opt.value }"
:disabled="loading || saving"
@click="billingInterval = opt.value"
>{{ opt.label }}</button>
</div>
</div>
</div>
</div>
<div class="map-side__footer">
<button class="map-btn map-btn--full" @click="emit('goto', 'plano')">
<i class="pi pi-arrow-left" />
<span>Voltar pro Meu Plano</span>
</button>
</div>
</aside>
</Teleport>
<div class="map-main">
<!-- Loading -->
<template v-if="loading">
<div class="map-cards">
<div v-for="n in 3" :key="`sk-${n}`" class="map-w">
<div class="map-w__body">
<Skeleton width="60%" height="20px" class="mb-2" />
<Skeleton width="100%" height="14px" class="mb-3" />
<Skeleton width="40%" height="36px" class="mb-3" />
<Skeleton width="100%" height="36px" />
</div>
</div>
</div>
</template>
<!-- Empty (filtro vazio) -->
<div v-else-if="!filteredPlans.length" class="map-w">
<div class="map-w__body">
<div class="map-empty map-empty--big">
<i class="pi pi-box map-empty__icon" />
<div class="map-empty__title">Nenhum plano encontrado</div>
<div class="map-empty__hint">Tente limpar o filtro de busca.</div>
<button class="map-btn" @click="q = ''">
<i class="pi pi-filter-slash" />
<span>Limpar busca</span>
</button>
</div>
</div>
</div>
<!-- Grid de planos -->
<div v-else class="map-cards">
<div
v-for="p in filteredPlans"
:key="p.id"
class="map-plan"
:class="{ 'is-current': currentSub?.plan_id === p.id }"
>
<div class="map-plan__head">
<div class="map-plan__title">
<div class="map-plan__name">{{ p.name || p.key }}</div>
<div class="map-plan__key">{{ p.key }}</div>
</div>
<Tag
v-if="currentSub?.plan_id === p.id"
value="Atual"
severity="success"
/>
</div>
<div class="map-plan__body">
<div v-if="p.description" class="map-plan__desc">{{ p.description }}</div>
<div class="map-plan__price">
<div class="map-plan__price-val">{{ priceLabelForCard(p) }}</div>
<div class="map-plan__price-hint">
{{ priceFor(p.id, billingInterval)
? `Preço ativo para ${intervalLabel(billingInterval)}.`
: `Sem preço ativo para ${intervalLabel(billingInterval)}.` }}
</div>
</div>
<div class="map-plan__actions">
<button
class="map-btn map-btn--primary map-btn--full"
:disabled="loading || saving || !priceFor(p.id, billingInterval)"
@click="choosePlan(p, billingInterval)"
>
<i :class="saving ? 'pi pi-spin pi-spinner' : 'pi pi-check'" />
<span>{{ billingInterval === 'month' ? 'Escolher mensal' : 'Escolher anual' }}</span>
</button>
<div class="map-plan__actions-row">
<button
class="map-btn"
:disabled="loading || saving || !priceFor(p.id, 'month')"
@click="choosePlan(p, 'month')"
>
<span>Mensal</span>
<span class="map-plan__sub-price" v-if="priceFor(p.id, 'month')">
{{ money(priceFor(p.id, 'month').currency, priceFor(p.id, 'month').amount_cents) }}
</span>
</button>
<button
class="map-btn"
:disabled="loading || saving || !priceFor(p.id, 'year')"
@click="choosePlan(p, 'year')"
>
<span>Anual</span>
<span class="map-plan__sub-price" v-if="priceFor(p.id, 'year')">
{{ money(priceFor(p.id, 'year').currency, priceFor(p.id, 'year').amount_cents) }}
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
/* ═══════ Page chrome ═══════ */
.map-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: map-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes map-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
.map-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;
}
.map-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 700;
color: var(--m-text);
}
.map-page__title-icon { color: var(--p-primary-color); font-size: 1.05rem; }
.map-page__actions { display: flex; align-items: center; gap: 6px; }
.map-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;
}
.map-act-btn:hover { background: var(--m-bg-soft-hover); color: var(--m-text); }
.map-act-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.map-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;
}
.map-close:hover { background: var(--m-bg-soft-hover); color: var(--m-text); }
.map-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;
}
.map-menu-btn:hover { background: var(--m-bg-soft-hover); }
.map-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;
}
.map-subheader__icon {
color: var(--p-primary-color);
font-size: 0.85rem;
margin-top: 2px;
flex-shrink: 0;
}
/* ═══════ Body 2-col ═══════ */
.map-body {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
.map-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;
}
.map-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;
}
.map-side__scroll::-webkit-scrollbar { width: 5px; }
.map-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.map-side__footer {
flex-shrink: 0;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
}
.map-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;
}
.map-main::-webkit-scrollbar { width: 5px; }
.map-main::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* ═══════ Card-base ═══════ */
.map-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;
}
.map-w--side {
background: var(--m-bg-medium);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16);
}
.map-w__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
}
.map-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);
}
.map-w__icon > i { font-size: 0.95rem; }
.map-w__title { flex: 1; min-width: 0; }
.map-w__title-text {
font-size: 0.92rem;
font-weight: 700;
color: var(--m-text);
line-height: 1.2;
}
.map-w__sub {
font-size: 0.74rem;
color: var(--m-text-muted);
margin-top: 2px;
line-height: 1.3;
}
.map-w__body {
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
/* ═══════ Sidebar: Plano atual ═══════ */
.map-curr {
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);
}
.map-curr__name {
font-size: 1.05rem;
font-weight: 800;
color: var(--p-primary-color);
line-height: 1.1;
}
.map-curr__key {
font-size: 0.7rem;
color: var(--m-text-muted);
font-family: 'JetBrains Mono', ui-monospace, monospace;
}
.map-curr__price {
font-size: 0.85rem;
color: var(--m-text);
font-weight: 600;
margin-top: 4px;
}
/* ═══════ Sidebar: Filtros ═══════ */
.map-interval {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.map-interval__label {
font-size: 0.74rem;
color: var(--m-text-muted);
font-weight: 600;
}
.map-interval__btn {
flex: 1;
padding: 7px 12px;
border-radius: 999px;
border: 1.5px 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 140ms ease;
}
.map-interval__btn:hover { background: var(--m-bg-soft-hover); }
.map-interval__btn.is-active {
background: var(--p-primary-color);
border-color: var(--p-primary-color);
color: var(--p-primary-contrast-color, #fff);
}
.map-interval__btn:disabled { opacity: 0.45; cursor: not-allowed; }
/* ═══════ Buttons ═══════ */
.map-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;
}
.map-btn:hover { background: var(--m-bg-soft-hover); }
.map-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.map-btn--primary {
background: var(--p-primary-color);
border-color: var(--p-primary-color);
color: var(--p-primary-contrast-color, #fff);
}
.map-btn--primary:hover {
background: color-mix(in srgb, var(--p-primary-color) 88%, black);
}
.map-btn--full { width: 100%; }
/* ═══════ Plan cards (main) ═══════ */
.map-cards {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
align-content: start;
}
@media (min-width: 768px) {
.map-cards { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1280px) {
.map-cards { grid-template-columns: repeat(3, 1fr); }
}
.map-plan {
background: var(--m-bg-soft);
border: 1.5px 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;
transition: border-color 140ms ease, box-shadow 140ms ease;
}
.map-plan:hover {
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.14);
}
.map-plan.is-current {
border-color: color-mix(in srgb, var(--p-primary-color) 55%, transparent);
box-shadow: 0 4px 18px color-mix(in srgb, var(--p-primary-color) 12%, transparent);
}
.map-plan__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
}
.map-plan__title { min-width: 0; flex: 1; }
.map-plan__name {
font-size: 0.96rem;
font-weight: 700;
color: var(--m-text);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.map-plan__key {
font-size: 0.7rem;
color: var(--m-text-muted);
font-family: 'JetBrains Mono', ui-monospace, monospace;
margin-top: 2px;
}
.map-plan__body {
padding: 14px;
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
.map-plan__desc {
font-size: 0.82rem;
color: var(--m-text-muted);
line-height: 1.4;
}
.map-plan__price {
display: flex;
flex-direction: column;
gap: 4px;
}
.map-plan__price-val {
font-size: 1.6rem;
font-weight: 800;
color: var(--m-text);
line-height: 1.1;
}
.map-plan__price-hint {
font-size: 0.7rem;
color: var(--m-text-muted);
}
.map-plan__actions {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: auto;
}
.map-plan__actions-row {
display: flex;
gap: 6px;
}
.map-plan__actions-row .map-btn {
flex: 1;
flex-direction: column;
gap: 2px;
padding: 8px 6px;
font-size: 0.74rem;
}
.map-plan__sub-price {
font-size: 0.7rem;
font-weight: 600;
color: var(--m-text-muted);
}
/* ═══════ Empty states ═══════ */
.map-empty {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-radius: 9px;
background: var(--m-bg-soft);
border: 1px dashed var(--m-border);
color: var(--m-text-muted);
font-size: 0.82rem;
}
.map-empty > i { color: var(--p-primary-color); font-size: 0.92rem; }
.map-empty--big {
flex-direction: column;
text-align: center;
padding: 36px 24px;
gap: 10px;
}
.map-empty--big > i { font-size: 2rem; opacity: 0.6; }
.map-empty__icon {
font-size: 2rem;
color: var(--m-text-faint);
opacity: 0.6;
}
.map-empty__title {
font-size: 0.92rem;
font-weight: 700;
color: var(--m-text);
}
.map-empty__hint {
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.4;
}
/* ═══════ Mobile drawer ═══════ */
.map-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;
}
.map-mobile-drawer.is-open { transform: translateX(0); }
.map-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.map-mobile-drawer__scroll .map-side {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.map-mobile-drawer__scroll .map-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;
}
.map-mobile-drawer__scroll .map-side__scroll::-webkit-scrollbar { width: 5px; }
.map-mobile-drawer__scroll .map-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.map-mobile-drawer__scroll .map-w--side {
margin: 0;
flex-shrink: 0;
}
.map-mobile-drawer__scroll .map-side__footer {
flex-shrink: 0;
margin: 0;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
}
.map-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;
}
.map-drawer-fade-enter-active,
.map-drawer-fade-leave-active { transition: opacity 200ms ease; }
.map-drawer-fade-enter-from,
.map-drawer-fade-leave-to { opacity: 0; }
/* Desktop (>=1024): cards da sidebar (Plano atual + Filtros) ganham
min-h 300 + max-h 100% + body com overflow-y: auto pra nao
crescerem demais quando tem texto longo. Plan cards do main
(.map-plan) ficam fora — sao product cards com layout proprio. */
@media (min-width: 1024px) {
.map-side > .map-side__scroll > .map-w--side {
min-height: 300px;
max-height: 100%;
display: flex;
flex-direction: column;
}
.map-side .map-w--side > .map-w__body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.map-side .map-w--side > .map-w__body::-webkit-scrollbar { width: 5px; }
.map-side .map-w--side > .map-w__body::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
}
/* ═══════ Mobile (<1024px) ═══════ */
@media (max-width: 1023px) {
.map-body { flex-direction: column; padding: 0; }
.map-side { display: none; }
.map-main { width: 100%; padding: 8px; }
.map-page__title > span:first-of-type { display: none; }
.map-page__title-icon { display: none; }
.map-menu-btn--mobile-only { display: inline-flex; }
}
</style>