ZERADO
This commit is contained in:
537
src/views/pages/billing/TherapistMeuPlanoPage.vue
Normal file
537
src/views/pages/billing/TherapistMeuPlanoPage.vue
Normal file
@@ -0,0 +1,537 @@
|
||||
<!-- src/views/pages/billing/TherapistMeuPlanoPage.vue -->
|
||||
<script setup>
|
||||
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const loading = 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([])
|
||||
|
||||
// ── Hero sticky ────────────────────────────────────────────
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
// ── 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 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 `${fmtDate(s.current_period_start)} → ${fmtDate(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 }
|
||||
})
|
||||
})
|
||||
|
||||
// ── Histórico ───────────────────────────────────────────────
|
||||
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() {
|
||||
router.push('/therapist/upgrade?redirectTo=/therapist/meu-plano')
|
||||
}
|
||||
|
||||
// ── Fetch ───────────────────────────────────────────────────
|
||||
async function fetchMeuPlanoTherapist() {
|
||||
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) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
fetchMeuPlanoTherapist()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="mplan-sentinel" />
|
||||
|
||||
<!-- Hero sticky -->
|
||||
<div ref="headerEl" class="mplan-hero mx-3 md:mx-5 mb-4" :class="{ 'mplan-hero--stuck': headerStuck }">
|
||||
<div class="mplan-hero__blobs" aria-hidden="true">
|
||||
<div class="mplan-hero__blob mplan-hero__blob--1" />
|
||||
<div class="mplan-hero__blob mplan-hero__blob--2" />
|
||||
</div>
|
||||
|
||||
<!-- Row 1 -->
|
||||
<div class="mplan-hero__row1">
|
||||
<div class="mplan-hero__brand">
|
||||
<div class="mplan-hero__icon"><i class="pi pi-credit-card text-lg" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<div class="mplan-hero__title">Meu Plano</div>
|
||||
<Tag v-if="subscription" :value="statusLabel(subscription.status)" :severity="statusSeverity(subscription.status)" />
|
||||
</div>
|
||||
<div class="mplan-hero__sub">Plano pessoal do terapeuta — gerencie sua assinatura</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop (≥1200px) -->
|
||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchMeuPlanoTherapist" />
|
||||
<Button label="Alterar plano" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgrade" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile (<1200px) -->
|
||||
<div class="flex xl:hidden items-center gap-2 shrink-0">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchMeuPlanoTherapist" />
|
||||
<Button label="Alterar plano" icon="pi pi-arrow-up-right" size="small" class="rounded-full" @click="goUpgrade" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<Divider class="mplan-hero__divider my-2" />
|
||||
|
||||
<!-- Row 2: resumo rápido (oculto no mobile) -->
|
||||
<div class="mplan-hero__row2">
|
||||
<div v-if="loading" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-spin pi-spinner text-xs" /> Carregando…
|
||||
</div>
|
||||
<template v-else-if="subscription">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="font-semibold text-sm text-[var(--text-color)]">{{ planName }}</span>
|
||||
<span v-if="priceLabel" class="text-sm text-[var(--text-color-secondary)]">{{ priceLabel }}</span>
|
||||
<span class="text-xs text-[var(--text-color-secondary)] border border-[var(--surface-border)] rounded-full px-3 py-1">
|
||||
Período: {{ periodLabel }}
|
||||
</span>
|
||||
<Tag v-if="subscription.cancel_at_period_end" severity="warning" value="Cancelamento agendado" />
|
||||
<Tag v-else severity="success" value="Renovação automática" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="text-sm text-[var(--text-color-secondary)]">Nenhuma assinatura ativa.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="px-3 md:px-5 mb-5 flex flex-col gap-4">
|
||||
|
||||
<!-- Sem assinatura -->
|
||||
<div v-if="!loading && !subscription" class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-credit-card text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhuma assinatura encontrada</div>
|
||||
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">Escolha um plano para começar a usar todos os recursos.</div>
|
||||
<div class="mt-4">
|
||||
<Button label="Ver planos" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgrade" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
|
||||
<!-- Seu plano inclui: features compactas -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-[var(--surface-border)] flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-semibold text-[var(--text-color)]">Seu plano inclui</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">Recursos disponíveis na sua assinatura atual</div>
|
||||
</div>
|
||||
<Tag v-if="features.length" :value="`${features.length}`" severity="secondary" />
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<div v-if="!subscription" class="text-sm text-[var(--text-color-secondary)]">Sem assinatura.</div>
|
||||
<div v-else-if="!features.length" class="text-sm text-[var(--text-color-secondary)]">Nenhuma feature vinculada a este plano.</div>
|
||||
|
||||
<div v-else class="space-y-5">
|
||||
<div v-for="g in groupedFeatures" :key="g.module">
|
||||
<!-- Cabeçalho do módulo -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-50">
|
||||
{{ moduleLabel(g.module) }}
|
||||
</span>
|
||||
<div class="flex-1 h-px bg-[var(--surface-border)]" />
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">{{ g.items.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Grid compacto de features -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
|
||||
<div
|
||||
v-for="f in g.items"
|
||||
:key="f.key"
|
||||
class="flex items-start gap-2 py-1 px-2 rounded-lg hover:bg-[var(--surface-ground)] transition-colors"
|
||||
:title="f.description || f.key"
|
||||
>
|
||||
<i class="pi pi-check-circle text-emerald-500 text-sm mt-0.5 shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium truncate text-[var(--text-color)]">{{ f.key }}</div>
|
||||
<div v-if="f.description" class="text-xs text-[var(--text-color-secondary)] leading-snug truncate">{{ f.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Histórico -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-[var(--surface-border)] flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="font-semibold text-[var(--text-color)]">Histórico</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">Últimos 50 eventos da assinatura</div>
|
||||
</div>
|
||||
<Tag v-if="events.length" :value="`${events.length}`" severity="secondary" />
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<div v-if="!subscription" class="text-sm text-[var(--text-color-secondary)]">Sem histórico (não há assinatura).</div>
|
||||
<div v-else-if="!events.length" class="py-8 text-center">
|
||||
<i class="pi pi-history text-2xl text-[var(--text-color-secondary)] opacity-30 mb-2 block" />
|
||||
<div class="text-sm text-[var(--text-color-secondary)]">Sem eventos registrados.</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="ev in events"
|
||||
:key="ev.id"
|
||||
class="rounded-xl border border-[var(--surface-border)] p-3 hover:bg-[var(--surface-ground)] transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<Tag :value="eventLabel(ev.event_type)" :severity="eventSeverity(ev.event_type)" />
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">
|
||||
por <b>{{ displayUser(ev.created_by) }}</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ev.old_plan_id || ev.new_plan_id" class="mt-1.5 text-xs text-[var(--text-color-secondary)] flex items-center gap-1.5 flex-wrap">
|
||||
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.old_plan_id) }}</span>
|
||||
<i class="pi pi-arrow-right text-xs opacity-50" />
|
||||
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.new_plan_id) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ev.reason" class="mt-1 text-xs text-[var(--text-color-secondary)] opacity-70">{{ ev.reason }}</div>
|
||||
|
||||
<div v-if="ev.metadata" class="mt-1.5">
|
||||
<pre class="m-0 text-xs text-[var(--text-color-secondary)] whitespace-pre-wrap break-words opacity-60">{{ prettyMeta(ev.metadata) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-[var(--text-color-secondary)] shrink-0">{{ fmtDate(ev.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Rodapé: subscription ID -->
|
||||
<div v-if="subscription" class="text-xs text-[var(--text-color-secondary)] flex items-center gap-2 flex-wrap">
|
||||
<span>ID da assinatura:</span>
|
||||
<code class="font-mono select-all">{{ subscription.id }}</code>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mplan-sentinel { height: 1px; }
|
||||
|
||||
.mplan-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.mplan-hero--stuck {
|
||||
margin-left: 0; margin-right: 0;
|
||||
border-top-left-radius: 0; border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.mplan-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.mplan-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.mplan-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.10); }
|
||||
.mplan-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(16,185,129,0.08); }
|
||||
|
||||
.mplan-hero__row1 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
.mplan-hero__brand {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.mplan-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.mplan-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.mplan-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
.mplan-hero__row2 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mplan-hero__divider,
|
||||
.mplan-hero__row2 { display: none; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user