7c0c1b3528
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>
1104 lines
37 KiB
Vue
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>
|