This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions
@@ -0,0 +1,636 @@
<!-- src/views/pages/billing/ClinicMeuPlanoPage.vue -->
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
const router = useRouter()
const route = useRoute()
const toast = useToast()
const tenantStore = useTenantStore()
const loading = ref(false)
const subscription = ref(null)
const plan = ref(null)
const price = ref(null)
const features = ref([]) // [{ key, description }]
const events = ref([]) // subscription_events
// ✅ para histórico auditável
const plans = ref([]) // [{id,key,name}]
const profiles = ref([]) // profiles de created_by
const tenantId = computed(() =>
tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.currentTenantId || null
)
// -------------------------
// helpers (format)
// -------------------------
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 goUpgradeClinic () {
// ✅ mantém caminho de retorno consistente
const redirectTo = route?.fullPath || '/admin/meu-plano'
router.push(`/upgrade?redirectTo=${encodeURIComponent(redirectTo)}`)
}
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'
if (s === 'incomplete' || s === 'incomplete_expired' || s === 'unpaid') return 'warning'
return 'secondary'
}
function statusLabelPretty (st) {
const s = String(st || '').toLowerCase()
if (s === 'active') return 'Ativa'
if (s === 'trialing') return 'Trial'
if (s === 'past_due') return 'Pagamento pendente'
if (s === 'canceled' || s === 'cancelled') return 'Cancelada'
if (s === 'unpaid') return 'Não paga'
if (s === 'incomplete') return 'Incompleta'
if (s === 'incomplete_expired') return 'Incompleta (expirada)'
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'
if (k === 'created') return 'secondary'
if (k === 'status_changed') return 'warning'
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'
if (k === 'created') return 'Criada'
if (k === 'status_changed') return 'Status alterado'
return t || '-'
}
// -------------------------
// helpers (plans / profiles)
// -------------------------
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 || p.username || null
const email = p.email || p.email_principal || p.user_email || null
if (name && email) return `${name} <${email}>`
if (name) return name
if (email) return email
return String(userId)
}
// -------------------------
// computed (header info)
// -------------------------
const planName = computed(() => plan.value?.name || subscription.value?.plan_key || '-')
const statusLabel = computed(() => subscription.value?.status || '-')
const statusLabelPrettyComputed = computed(() => statusLabelPretty(subscription.value?.status))
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)}`
})
const cancelHint = computed(() => {
const s = subscription.value
if (!s) return null
if (s.cancel_at_period_end) {
const end = s.current_period_end ? fmtDate(s.current_period_end) : null
return end ? `Cancelamento no fim do período (${end}).` : 'Cancelamento no fim do período.'
}
if (s.canceled_at) return `Cancelada em ${fmtDate(s.canceled_at)}.`
return null
})
// -------------------------
// ✅ agrupamento de features por módulo (prefixo)
// -------------------------
function moduleFromKey (key) {
const k = String(key || '').trim()
if (!k) return 'Outros'
// tenta por "."
if (k.includes('.')) {
const head = k.split('.')[0]
return head || 'Outros'
}
// tenta por "_"
if (k.includes('_')) {
const head = k.split('_')[0]
return head || '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)
}
// ordena módulos e itens
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 }
})
})
// -------------------------
// fetch
// -------------------------
async function fetchMeuPlanoClinic () {
loading.value = true
try {
const tid = tenantId.value
if (!tid) throw new Error('Tenant ativo não encontrado.')
// 1) assinatura do tenant (prioriza status "ativo" e afins; cai pro mais recente)
// ✅ depois das mudanças: não assume só "active" (pode estar trialing/past_due etc.)
const sRes = await supabase
.from('subscriptions')
.select('*')
.eq('tenant_id', tid)
.order('created_at', { ascending: false })
.limit(10)
if (sRes.error) throw sRes.error
const list = 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 = (list.slice().sort((a, b) => {
const pa = priority(a?.status)
const pb = priority(b?.status)
if (pa !== pb) return pa - pb
// empate: mais recente
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
}
// 2) plano (atual)
if (subscription.value.plan_id) {
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
} else {
plan.value = null
}
// 3) preço vigente (intervalo atual)
// ✅ robustez: tenta preço vigente por janela; se não achar, pega o último ativo do intervalo
price.value = null
if (subscription.value.plan_id && subscription.value.interval) {
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
if (!price.value) {
const ppFallback = 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)
.order('active_from', { ascending: false })
.limit(1)
.maybeSingle()
if (ppFallback.error) throw ppFallback.error
price.value = ppFallback.data || null
}
}
// 4) features do plano
features.value = []
if (subscription.value.plan_id) {
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()
}))
}
}
// 5) histórico (50) — se existir subscription_id
events.value = []
if (subscription.value?.id) {
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 || []
}
// ✅ 6) pré-carrega planos citados em (old/new) + plano atual
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 = []
}
// ✅ 7) perfis (created_by)
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(fetchMeuPlanoClinic)
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<!-- Topbar padrão -->
<div class="mb-4 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col">
<div class="text-2xl font-semibold leading-none">Meu plano</div>
<small class="text-color-secondary mt-1">
Plano da clínica (tenant) e recursos habilitados.
</small>
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
<Button
label="Alterar plano"
icon="pi pi-arrow-up-right"
:loading="loading"
@click="goUpgradeClinic"
/>
<Button
label="Atualizar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
@click="fetchMeuPlanoClinic"
/>
</div>
</div>
</div>
<!-- Card resumo -->
<Card class="rounded-[2rem] overflow-hidden">
<template #content>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<div class="text-xl md:text-2xl font-semibold leading-tight">
{{ planName }}
</div>
<div class="text-sm text-color-secondary mt-1">
<span v-if="priceLabel">{{ priceLabel }}</span>
<span v-else>Preço não encontrado para este intervalo.</span>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<Tag :value="statusLabelPrettyComputed" :severity="statusSeverity(subscription?.status)" />
<Tag
v-if="subscription?.cancel_at_period_end"
severity="warning"
value="Cancelamento agendado"
rounded
/>
<Tag
v-else-if="subscription"
severity="success"
value="Renovação automática"
rounded
/>
</div>
<div class="mt-3 text-sm text-color-secondary">
<b>Período:</b> {{ periodLabel }}
</div>
<div v-if="cancelHint" class="mt-2 text-sm text-color-secondary">
{{ cancelHint }}
</div>
<div v-if="plan?.description" class="mt-3 text-sm opacity-80 max-w-3xl">
{{ plan.description }}
</div>
</div>
<div v-if="subscription" class="flex flex-col items-end gap-2">
<small class="text-color-secondary">subscription_id</small>
<code class="text-xs opacity-80 break-all">
{{ subscription.id }}
</code>
</div>
</div>
<div v-if="!subscription" class="mt-4 rounded-2xl border border-[var(--surface-border)] p-4 text-sm text-color-secondary">
Nenhuma assinatura encontrada para este tenant.
</div>
</template>
</Card>
<Divider class="my-6" />
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Features agrupadas -->
<Card class="rounded-[2rem] overflow-hidden">
<template #title>Seu plano inclui</template>
<template #content>
<div v-if="!subscription" class="text-color-secondary">
Sem assinatura.
</div>
<div v-else-if="!features.length" class="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"
class="rounded-2xl border border-[var(--surface-border)] overflow-hidden"
>
<div class="px-4 py-3 bg-[var(--surface-50)] border-b border-[var(--surface-border)] flex items-center justify-between">
<div class="font-semibold">
{{ moduleLabel(g.module) }}
</div>
<Tag :value="`${g.items.length}`" severity="secondary" rounded />
</div>
<div class="p-4">
<ul class="m-0 p-0 list-none space-y-3">
<li
v-for="f in g.items"
:key="f.key"
class="rounded-2xl border border-[var(--surface-border)] p-3"
>
<div class="flex items-start gap-3">
<i class="pi pi-check mt-1 text-sm text-color-secondary"></i>
<div class="min-w-0">
<div class="font-medium break-words">{{ f.key }}</div>
<div class="text-sm text-color-secondary mt-1" v-if="f.description">
{{ f.description }}
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
<div class="text-xs text-color-secondary">
Agrupamento automático por prefixo da key (ex.: <b>agenda.*</b>, <b>patients.*</b>, etc.).
</div>
</div>
</template>
</Card>
<!-- Histórico auditável -->
<Card class="rounded-[2rem] overflow-hidden">
<template #title>Histórico</template>
<template #content>
<div v-if="!subscription" class="text-color-secondary">
Sem histórico (não assinatura).
</div>
<div v-else-if="!events.length" class="text-color-secondary">
Sem eventos registrados.
</div>
<div v-else class="space-y-3">
<div
v-for="ev in events"
:key="ev.id"
class="rounded-2xl border border-[var(--surface-border)] p-3"
>
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<Tag
:value="eventLabel(ev.event_type)"
:severity="eventSeverity(ev.event_type)"
rounded
/>
<span class="text-sm text-color-secondary">
por <b>{{ displayUser(ev.created_by) }}</b>
</span>
</div>
<!-- De Para (quando existir) -->
<div v-if="ev.old_plan_id || ev.new_plan_id" class="mt-2 text-sm">
<span class="text-color-secondary">Plano:</span>
<span class="font-medium ml-2">{{ planKeyOrName(ev.old_plan_id) }}</span>
<i class="pi pi-arrow-right text-color-secondary mx-2" />
<span class="font-medium">{{ planKeyOrName(ev.new_plan_id) }}</span>
</div>
<div v-if="ev.reason" class="mt-2 text-sm opacity-80">
{{ ev.reason }}
</div>
</div>
<div class="text-sm text-color-secondary">
{{ fmtDate(ev.created_at) }}
</div>
</div>
<div class="mt-2 text-xs text-color-secondary" v-if="ev.metadata">
<pre class="m-0 whitespace-pre-wrap break-words">{{ prettyMeta(ev.metadata) }}</pre>
</div>
</div>
</div>
<div class="mt-4 text-xs text-color-secondary">
Mostrando até 50 eventos (mais recentes).
</div>
</template>
</Card>
</div>
</div>
</template>
<style scoped>
/* (intencionalmente vazio) */
</style>
@@ -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 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>
@@ -0,0 +1,422 @@
<!-- src/views/pages/billing/TherapistUpgradePage.vue -->
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
const toast = useToast()
const route = useRoute()
const router = useRouter()
const loading = ref(false)
const saving = ref(false)
const isFetching = ref(false)
const uid = ref(null)
const currentSub = ref(null)
const plans = ref([]) // plans (therapist)
const prices = ref([]) // plan_prices ativos do momento
const q = ref('')
const billingInterval = ref('month') // 'month' | 'year'
const intervalOptions = [
{ label: 'Mensal', value: 'month' },
{ label: 'Anual', value: 'year' }
]
const redirectTo = computed(() => route.query.redirectTo || '/therapist/meu-plano')
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 priceFor (planId, interval) {
return (prices.value || []).find(p => String(p.plan_id) === String(planId) && String(p.interval) === String(interval)) || null
}
function priceLabelForCard (planRow) {
const pp = priceFor(planRow?.id, billingInterval.value)
if (!pp) return '—'
return `${money(pp.currency, pp.amount_cents)} / ${intervalLabel(billingInterval.value)}`
}
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)
})
})
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.')
// assinatura pessoal atual (Modelo A): busca por user_id, prioriza status ativo
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 subPriority = (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 = subPriority(a?.status)
const pb = subPriority(b?.status)
if (pa !== pb) return pa - pb
return new Date(b?.created_at || 0) - new Date(a?.created_at || 0)
})[0]
: null
// planos do terapeuta (target = therapist)
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)
// preços ativos (janela de vigência)
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) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
} finally {
loading.value = false
isFetching.value = false
}
}
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)}.` }
}
// se já estiver nesse plano+intervalo, evita ação
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: '' }
}
/**
* ✅ Fluxo seguro:
* - se já existe subscription => chama RPC change_subscription_plan (audita + triggers)
* - se não existe => cria subscription mínima e depois (opcional) roda rebuild_owner_entitlements
*
* Observação:
* - aqui eu assumo que você tem `change_subscription_plan(p_subscription_id, p_new_plan_id)`
* e que interval pode ser alterado por update simples ou outro RPC.
* - se você tiver um RPC específico para intervalo, só troca abaixo.
*/
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) {
// 1) troca plano via RPC (auditoria)
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
// 2) atualiza intervalo (se o seu RPC já faz isso, pode remover)
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 {
// cria subscription pessoal
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
}
// (opcional) se sua regra de entitlements depende disso, você pode rebuildar aqui:
// await supabase.rpc('rebuild_owner_entitlements', { p_owner_id: uid.value })
toast.add({
severity: 'success',
summary: 'Plano atualizado',
detail: 'Seu plano foi atualizado com sucesso.',
life: 3200
})
// ✅ garante refletir estado real
await loadData()
// redirect
await router.push(String(redirectTo.value))
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || String(e), life: 5000 })
} finally {
saving.value = false
}
}
function goBack () {
router.push(String(redirectTo.value))
}
onMounted(loadData)
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<!-- Topbar padrão -->
<div class="mb-4 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col">
<div class="text-2xl font-semibold leading-none">Upgrade do terapeuta</div>
<small class="text-color-secondary mt-1">
Escolha seu plano pessoal (Modelo A).
</small>
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
<Button
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
:disabled="saving"
@click="goBack"
/>
<Button
label="Atualizar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
:disabled="saving"
@click="loadData"
/>
</div>
</div>
<div class="mt-3 flex flex-wrap items-center gap-3">
<Tag
v-if="currentSub"
:value="`Plano atual: ${currentSub.plan_key} • ${intervalLabel(currentSub.interval)} • ${currentSub.status}`"
severity="success"
rounded
/>
<Tag
v-else
value="Você ainda não tem um plano pessoal."
severity="warning"
rounded
/>
<div class="flex items-center gap-2 ml-auto">
<small class="text-color-secondary">Exibição de preço</small>
<SelectButton
v-model="billingInterval"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
:disabled="loading || saving"
/>
</div>
</div>
</div>
<!-- Busca padrão FloatLabel -->
<Card class="rounded-[2rem] overflow-hidden mb-4">
<template #content>
<div class="flex flex-wrap items-center gap-3 justify-between">
<div class="flex flex-col">
<div class="font-semibold">Planos disponíveis</div>
<small class="text-color-secondary">
Filtre por nome/key/descrição e selecione.
</small>
</div>
<div class="w-full md:w-[420px]">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="therapist_upgrade_search" class="w-full pr-10" variant="filled" />
</IconField>
<label for="therapist_upgrade_search">Buscar plano...</label>
</FloatLabel>
</div>
</div>
</template>
</Card>
<Divider />
<!-- Cards estilo vitrine -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mt-4">
<Card
v-for="p in filteredPlans"
:key="p.id"
:class="[
'rounded-[2rem] overflow-hidden border border-[var(--surface-border)]',
currentSub?.plan_id === p.id ? 'ring-1 ring-emerald-500/25 md:-translate-y-1 md:scale-[1.01]' : ''
]"
>
<template #title>
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold truncate">{{ p.name || p.key }}</div>
<small class="text-color-secondary">{{ p.key }}</small>
</div>
<Tag v-if="currentSub?.plan_id === p.id" severity="success" value="Atual" rounded />
</div>
</template>
<template #content>
<div class="text-sm text-color-secondary" v-if="p.description">
{{ p.description }}
</div>
<div class="mt-4">
<div class="text-4xl font-semibold leading-none">
{{ priceLabelForCard(p) }}
</div>
<div class="text-xs text-color-secondary mt-1">
Alternar mensal/anual no topo para comparar.
</div>
</div>
<div class="mt-5 flex gap-2 flex-wrap">
<Button
:label="billingInterval === 'month' ? 'Escolher mensal' : 'Escolher anual'"
icon="pi pi-check"
:loading="saving"
:disabled="loading || saving"
@click="choosePlan(p, billingInterval)"
/>
<Button
label="Mensal"
severity="secondary"
outlined
:disabled="loading || saving"
@click="choosePlan(p, 'month')"
/>
<Button
label="Anual"
severity="secondary"
outlined
:disabled="loading || saving"
@click="choosePlan(p, 'year')"
/>
</div>
<div class="mt-3 text-xs text-color-secondary">
<span v-if="priceFor(p.id, billingInterval)">
Preço ativo encontrado para {{ intervalLabel(billingInterval) }}.
</span>
<span v-else>
Sem preço ativo para {{ intervalLabel(billingInterval) }}.
</span>
</div>
</template>
</Card>
</div>
<div v-if="!filteredPlans.length && !loading" class="mt-4 text-sm text-color-secondary">
Nenhum plano encontrado.
</div>
</div>
</template>
+234 -223
View File
@@ -1,13 +1,8 @@
<!-- src/views/pages/upgrade/UpgradePage.vue (ajuste o caminho conforme seu projeto) -->
<!-- src/views/pages/upgrade/UpgradePage.vue -->
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Tag from 'primevue/tag'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
@@ -21,35 +16,54 @@ const router = useRouter()
const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore()
// ======================================================
// ✅ Detecta contexto: terapeuta (personal) ou clínica (tenant)
// O guard passa ?role=therapist ou ?role=clinic_admin na URL,
// mas também fazemos fallback pelo activeRole do tenantStore.
// ======================================================
const activeRole = computed(() => {
const fromQuery = route.query.role || ''
const fromStore = tenantStore.activeRole || ''
const r = String(fromQuery || fromStore).trim()
if (r === 'therapist') return 'therapist'
if (r === 'clinic_admin' || r === 'tenant_admin') return 'clinic_admin'
return 'clinic_admin' // fallback seguro
})
const isTherapist = computed(() => activeRole.value === 'therapist')
const planTarget = computed(() => isTherapist.value ? 'therapist' : 'clinic')
// Feature que motivou o redirecionamento
const requestedFeature = computed(() => route.query.feature || null)
// nomes amigáveis (fallback se não achar)
const featureLabels = {
'online_scheduling.manage': 'Agendamento Online',
'online_scheduling.public': 'Página pública de agendamento',
'advanced_reports': 'Relatórios avançados',
'sms_reminder': 'Lembretes por SMS',
'intakes_pro': 'Formulários PRO'
// nome vem do banco (features.name), sem hardcode
const featureNameMap = computed(() => {
const m = new Map()
for (const f of features.value) m.set(f.key, f.name || f.key)
return m
})
function friendlyFeatureLabel (key) {
return featureNameMap.value.get(key) || key
}
const requestedFeatureLabel = computed(() => {
if (!requestedFeature.value) return null
return featureLabels[requestedFeature.value] || requestedFeature.value
return featureNameMap.value.get(requestedFeature.value) || requestedFeature.value
})
// estado
const loading = ref(false)
const loading = ref(false)
const upgrading = ref(false)
const plans = ref([]) // plans reais
const features = ref([]) // features reais
const planFeatures = ref([]) // links reais plan_features
const plans = ref([])
const features = ref([])
const planFeatures = ref([])
const prices = ref([])
const subscription = ref(null)
const subscription = ref(null) // subscription ativa do tenant
// ✅ Modelo B: plano é do TENANT
const tenantId = computed(() => tenantStore.activeTenantId || null)
// IDs de contexto — dependem do role
const tenantId = computed(() => isTherapist.value ? null : (tenantStore.activeTenantId || null))
const userId = computed(() => isTherapist.value ? (tenantStore.user?.id || null) : null)
const planById = computed(() => {
const m = new Map()
@@ -58,7 +72,6 @@ const planById = computed(() => {
})
const enabledFeatureIdsByPlanId = computed(() => {
// Map planId -> Set(featureId)
const m = new Map()
for (const row of planFeatures.value) {
const set = m.get(row.plan_id) || new Set()
@@ -68,145 +81,161 @@ const enabledFeatureIdsByPlanId = computed(() => {
return m
})
const currentPlanId = computed(() => subscription.value?.plan_id || null)
const currentPlanId = computed(() => subscription.value?.plan_id || null)
const currentPlanKey = computed(() => planById.value.get(currentPlanId.value)?.key || subscription.value?.plan_key || null)
function planKeyById (id) {
return planById.value.get(id)?.key || null
}
const currentPlanKey = computed(() => {
// ✅ fallback: se não carregou plans ainda, usa o plan_key da subscription
return planKeyById(currentPlanId.value) || subscription.value?.plan_key || null
})
function friendlyFeatureLabel (featureKey) {
return featureLabels[featureKey] || featureKey
const billingInterval = ref('month')
const intervalOptions = [
{ label: 'Mensal', value: 'month' },
{ label: 'Anual', value: 'year' }
]
const q = ref('')
function intervalLabel (i) {
return i === 'month' ? 'mês' : i === 'year' ? 'ano' : (i || '-')
}
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 priceFor (planId, interval) {
return (prices.value || []).find(p =>
String(p.plan_id) === String(planId) && String(p.interval) === String(interval)
) || null
}
function priceLabelForPlan (planId) {
const pp = priceFor(planId, billingInterval.value)
if (!pp) return '—'
return `${money(pp.currency, pp.amount_cents)} / ${intervalLabel(billingInterval.value)}`
}
const sortedPlans = computed(() => {
// ordena free primeiro, pro segundo, resto por key
const arr = [...plans.value]
const rank = (k) => (k === 'free' ? 0 : k === 'pro' ? 1 : 10)
arr.sort((a, b) => {
const ra = rank(a.key), rb = rank(b.key)
if (ra !== rb) return ra - rb
return String(a.key).localeCompare(String(b.key))
})
const term = String(q.value || '').trim().toLowerCase()
let arr = [...plans.value]
if (term) {
arr = arr.filter(p =>
[p.key, p.name, p.description].some(s => String(s || '').toLowerCase().includes(term))
)
}
const rank = k => String(k).toLowerCase() === 'pro' ? 0 : String(k).toLowerCase() === 'free' ? 5 : 10
arr.sort((a, b) => rank(a.key) - rank(b.key) || String(a.key).localeCompare(String(b.key)))
return arr
})
function planBenefits (planId) {
const set = enabledFeatureIdsByPlanId.value.get(planId) || new Set()
const list = features.value.map((f) => ({
ok: set.has(f.id),
key: f.key,
text: friendlyFeatureLabel(f.key)
}))
// coloca as “ok” em cima
list.sort((a, b) => Number(b.ok) - Number(a.ok) || a.text.localeCompare(b.text))
return list
return features.value
.map(f => ({ ok: set.has(f.id), key: f.key, text: friendlyFeatureLabel(f.key) }))
.sort((a, b) => Number(b.ok) - Number(a.ok) || a.text.localeCompare(b.text))
}
function goBack () {
router.back()
}
function goBack () { router.back() }
function goBilling () { router.push(isTherapist.value ? '/therapist/meu-plano' : '/admin/meu-plano') }
function contactSupport () { goBilling() }
function goBilling () {
router.push('/admin/billing')
}
function contactSupport () {
router.push('/admin/billing')
}
// ✅ revalida a rota atual para o guard reavaliar features após troca de plano
async function revalidateCurrentRoute () {
// tenta respeitar um redirectTo (quando usuário veio por recurso bloqueado)
const redirectTo = route.query.redirectTo ? String(route.query.redirectTo) : null
// se existe redirectTo, tente ir para ele (guard decide se entra ou volta ao upgrade)
if (redirectTo) {
try {
await router.replace(redirectTo)
return
} catch (_) {
// se falhar, cai no refresh da rota atual
}
try { await router.replace(redirectTo); return } catch {}
}
// força o vue-router a reprocessar a rota (dispara beforeEach)
try {
await router.replace(router.currentRoute.value.fullPath)
} catch (_) {}
try { await router.replace(router.currentRoute.value.fullPath) } catch {}
}
async function fetchAll () {
loading.value = true
try {
const tid = tenantId.value
if (!tid) throw new Error('Tenant ativo não encontrado.')
if (!tenantStore.loaded) await tenantStore.loadSessionAndTenant()
const [pRes, fRes, pfRes, sRes] = await Promise.all([
supabase.from('plans').select('*').eq('is_active', true).order('key', { ascending: true }),
supabase.from('features').select('*').order('key', { ascending: true }),
supabase.from('plan_features').select('plan_id, feature_id'),
supabase
const nowIso = new Date().toISOString()
// ✅ busca planos do target correto (therapist ou clinic)
const pQuery = supabase
.from('plans')
.select('*')
.eq('is_active', true)
.eq('target', planTarget.value)
.order('key', { ascending: true })
// ✅ busca subscription do contexto correto
let sQuery = supabase
.from('subscriptions')
.select(`id, tenant_id, user_id, plan_id, plan_key, interval, status,
provider, source, started_at, current_period_start,
current_period_end, created_at, updated_at`)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (isTherapist.value) {
const uid = userId.value
if (!uid) throw new Error('Usuário não identificado.')
sQuery = supabase
.from('subscriptions')
// ✅ pega mais campos úteis e faz join do plano (ajuda a exibir e debugar)
.select(`
id,
tenant_id,
user_id,
plan_id,
plan_key,
"interval",
status,
provider,
source,
started_at,
current_period_start,
current_period_end,
created_at,
updated_at,
plan:plan_id (
id,
key,
name,
description,
price_cents,
currency,
billing_interval,
is_active
)
`)
.eq('tenant_id', tid)
.select(`id, tenant_id, user_id, plan_id, plan_key, interval, status,
provider, source, started_at, current_period_start,
current_period_end, created_at, updated_at`)
.eq('user_id', uid)
.is('tenant_id', null)
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
} else {
const tid = tenantId.value
if (!tid) throw new Error('Tenant ativo não encontrado.')
sQuery = supabase
.from('subscriptions')
.select(`id, tenant_id, user_id, plan_id, plan_key, interval, status,
provider, source, started_at, current_period_start,
current_period_end, created_at, updated_at`)
.eq('tenant_id', tid)
.eq('status', 'active')
// ✅ created_at é mais confiável que updated_at em assinaturas manuais
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
])
if (pRes.error) throw pRes.error
if (fRes.error) throw fRes.error
if (pfRes.error) throw pfRes.error
// ✅ subscription pode ser null sem quebrar a página
if (sRes.error) {
console.warn('[Upgrade] erro ao buscar subscription:', sRes.error)
}
plans.value = pRes.data || []
features.value = fRes.data || []
planFeatures.value = pfRes.data || []
subscription.value = sRes.data || null
const [pRes, fRes, pfRes, sRes, ppRes] = await Promise.all([
pQuery,
supabase.from('features').select('id, key, name, descricao').order('key', { ascending: true }),
supabase.from('plan_features').select('plan_id, feature_id'),
sQuery,
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 })
])
// pode remover esses logs depois
console.groupCollapsed('[Upgrade] fetchAll')
console.log('tenantId:', tid)
console.log('subscription:', subscription.value)
console.log('currentPlanKey:', currentPlanKey.value)
console.groupEnd()
if (pRes.error) throw pRes.error
if (fRes.error) throw fRes.error
if (pfRes.error) throw pfRes.error
if (ppRes.error) throw ppRes.error
if (sRes.error) console.warn('[Upgrade] erro ao buscar subscription:', sRes.error)
plans.value = pRes.data || []
features.value = fRes.data || []
planFeatures.value = pfRes.data || []
subscription.value = sRes.data || null
prices.value = ppRes.data || []
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
@@ -216,46 +245,42 @@ async function fetchAll () {
}
async function changePlan (targetPlanId) {
if (!targetPlanId || upgrading.value) return
if (!subscription.value?.id) {
toast.add({
severity: 'warn',
summary: 'Sem assinatura ativa',
detail: 'Não encontrei uma assinatura ativa para este tenant. Ative via pagamento manual primeiro.',
life: 4500
detail: 'Não encontrei uma assinatura ativa. Vá em "Assinatura" para ativar/criar um plano.',
life: 5200
})
return
}
if (!targetPlanId) return
if (upgrading.value) return
upgrading.value = true
try {
const tid = tenantId.value
if (!tid) throw new Error('Tenant ativo não encontrado.')
if (String(subscription.value.plan_id) === String(targetPlanId)) return
const current = subscription.value.plan_id
if (current === targetPlanId) return
// ✅ usa o mesmo RPC do seu painel SaaS (transação + histórico)
const { data, error } = await supabase.rpc('change_subscription_plan', {
p_subscription_id: subscription.value.id,
p_new_plan_id: targetPlanId
})
if (error) throw error
// atualiza estado local
subscription.value.plan_id = data?.plan_id || targetPlanId
subscription.value.plan_id = data?.plan_id || targetPlanId
subscription.value.plan_key = data?.plan_key || planKeyById(subscription.value.plan_id) || subscription.value.plan_key
// ✅ recarrega entitlements (sem reload)
// (importante pra refletir o plano imediatamente)
entitlementsStore.clear?.()
// seu store tem loadForTenant no guard; se existir aqui também, use primeiro
if (typeof entitlementsStore.loadForTenant === 'function') {
await entitlementsStore.loadForTenant(tid, { force: true })
} else if (typeof entitlementsStore.fetch === 'function') {
await entitlementsStore.fetch(tid, { force: true })
// ✅ recarrega entitlements do contexto correto
if (isTherapist.value) {
const uid = userId.value
if (uid && typeof entitlementsStore.loadForUser === 'function') {
await entitlementsStore.loadForUser(uid, { force: true })
}
} else {
const tid = tenantId.value
if (tid && typeof entitlementsStore.loadForTenant === 'function') {
await entitlementsStore.loadForTenant(tid, { force: true })
}
}
toast.add({
@@ -265,11 +290,7 @@ async function changePlan (targetPlanId) {
life: 3000
})
// ✅ garante consistência (principalmente se RPC mexer em mais campos)
await fetchAll()
// ✅ dispara o guard novamente: se o usuário perdeu acesso a uma rota PRO,
// ele deve ser redirecionado automaticamente.
await revalidateCurrentRoute()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
@@ -279,28 +300,21 @@ async function changePlan (targetPlanId) {
}
onMounted(async () => {
// ✅ garante que o tenant já foi carregado antes de buscar planos
if (!tenantStore.loaded) await tenantStore.loadSessionAndTenant()
await fetchAll()
})
// se trocar tenant ativo, recarrega
watch(
() => tenantId.value,
() => {
if (tenantId.value) fetchAll()
}
)
watch(() => tenantStore.activeTenantId, () => { fetchAll() })
watch(() => tenantStore.user?.id, () => { fetchAll() })
</script>
<template>
<Toast />
<div class="p-4 md:p-6 lg:p-8">
<!-- HERO CONCEITUAL -->
<div class="mb-6 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
<!-- HERO -->
<div class="mb-5 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5 md:p-7">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-20 -right-24 h-80 w-80 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-12 -left-24 h-96 w-96 rounded-full bg-emerald-400/10 blur-3xl" />
@@ -308,116 +322,112 @@ watch(
</div>
<div class="relative flex flex-col gap-4">
<div class="flex items-start justify-between gap-3">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-2xl md:text-3xl font-semibold leading-tight">
Atualize seu plano
</div>
<div class="text-2xl md:text-3xl font-semibold leading-tight">Atualize seu plano</div>
<div class="mt-1 text-sm md:text-base text-[var(--text-color-secondary)]">
Tenant ativo:
<b>{{ tenantId || '—' }}</b>
Contexto: <b>{{ isTherapist ? 'Terapeuta' : 'Clínica' }}</b>
<span class="mx-2 opacity-50"></span>
Você está no plano:
<b>{{ currentPlanKey || '—' }}</b>
Plano atual: <b>{{ currentPlanKey || '—' }}</b>
</div>
</div>
<div class="flex items-center gap-2">
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined @click="goBack" />
<Button label="Assinatura" icon="pi pi-credit-card" severity="secondary" outlined @click="goBilling" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" @click="fetchAll" />
<div class="flex items-center gap-2 flex-wrap">
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined :disabled="upgrading" @click="goBack" />
<Button label="Assinatura" icon="pi pi-credit-card" severity="secondary" outlined :disabled="upgrading" @click="goBilling" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" :disabled="upgrading" @click="fetchAll" />
</div>
</div>
<!-- BLOCO: RECURSO BLOQUEADO -->
<!-- recurso bloqueado -->
<div
v-if="requestedFeatureLabel"
class="relative overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4"
>
<div class="absolute inset-0 opacity-60 pointer-events-none">
<div class="absolute -top-10 -right-12 h-40 w-40 rounded-full bg-amber-400/10 blur-2xl" />
<div class="absolute -bottom-10 left-16 h-40 w-40 rounded-full bg-rose-400/10 blur-2xl" />
</div>
<div class="relative flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<Tag severity="warning" value="Recurso bloqueado" />
<div class="font-semibold truncate">
{{ requestedFeatureLabel }}
</div>
<div class="font-semibold truncate">{{ requestedFeatureLabel }}</div>
</div>
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">
Esse recurso depende do plano que inclui a feature <b>{{ requestedFeature }}</b>.
Esse recurso depende da feature <b>{{ requestedFeature }}</b>.
</div>
</div>
<div class="flex items-center gap-2">
<Button
label="Ver planos"
icon="pi pi-arrow-down"
severity="secondary"
outlined
@click="() => document.getElementById('plans-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
/>
</div>
<Button
label="Ver planos"
icon="pi pi-arrow-down"
severity="secondary"
outlined
@click="() => document.getElementById('plans-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
/>
</div>
</div>
<div class="text-xs md:text-sm text-[var(--text-color-secondary)]">
A diferença entre ter uma agenda e ter um sistema mora nos detalhes.
<!-- busca + intervalo -->
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-3">
<div class="w-full md:w-[420px]">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="upgrade_search" class="w-full pr-10" variant="filled" :disabled="loading || upgrading" />
</IconField>
<label for="upgrade_search">Buscar plano...</label>
</FloatLabel>
</div>
<div class="flex flex-col items-start md:items-end gap-2">
<small class="text-[var(--text-color-secondary)]">Exibição de preço</small>
<SelectButton v-model="billingInterval" :options="intervalOptions" optionLabel="label" optionValue="value" :disabled="loading || upgrading" />
</div>
</div>
</div>
</div>
</div>
<!-- PLANOS (DINÂMICOS) -->
<!-- PLANOS -->
<div id="plans-grid" class="grid grid-cols-12 gap-4 md:gap-6">
<div v-for="p in sortedPlans" :key="p.id" class="col-span-12 lg:col-span-6">
<!-- card destaque pro PRO -->
<div
:id="p.key === 'pro' ? 'plan-pro' : null"
:class="p.key === 'pro'
:class="String(p.key).toLowerCase() === 'pro'
? 'relative overflow-hidden rounded-[1.75rem] border border-primary/40 bg-[var(--surface-card)]'
: ''"
: 'relative overflow-hidden rounded-[1.75rem] border border-[var(--surface-border)] bg-[var(--surface-card)]'"
>
<div v-if="p.key === 'pro'" class="pointer-events-none absolute inset-0 opacity-80">
<div v-if="String(p.key).toLowerCase() === 'pro'" class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-24 -right-28 h-96 w-96 rounded-full bg-primary/10 blur-3xl" />
<div class="absolute -bottom-28 left-12 h-96 w-96 rounded-full bg-emerald-400/10 blur-3xl" />
</div>
<Card :class="p.key === 'pro' ? 'relative border-0' : 'overflow-hidden'">
<Card class="relative border-0">
<template #title>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<i :class="p.key === 'pro' ? 'pi pi-sparkles opacity-80' : 'pi pi-leaf opacity-70'" />
<i :class="String(p.key).toLowerCase() === 'pro' ? 'pi pi-sparkles opacity-80' : 'pi pi-leaf opacity-70'" />
<span class="text-xl font-semibold">Plano {{ String(p.key || '').toUpperCase() }}</span>
</div>
<div class="flex items-center gap-2">
<Tag v-if="currentPlanId === p.id" value="Atual" severity="secondary" />
<Tag v-else-if="p.key === 'pro'" value="Recomendado" severity="success" />
<Tag v-else-if="String(p.key).toLowerCase() === 'pro'" value="Recomendado" severity="success" />
</div>
</div>
</template>
<template #subtitle>
<span v-if="p.key === 'free'">O essencial para começar, sem travar seu fluxo.</span>
<span v-else-if="p.key === 'pro'">Para quem quer automatizar, reduzir ruído e ganhar previsibilidade.</span>
<span v-else>Plano personalizado: {{ p.key }}</span>
<div class="flex items-center justify-between gap-3 flex-wrap">
<span class="text-[var(--text-color-secondary)]">
<template v-if="String(p.key).toLowerCase() === 'free'">O essencial para começar, sem travar seu fluxo.</template>
<template v-else-if="String(p.key).toLowerCase() === 'pro'">Para automatizar, reduzir ruído e ganhar previsibilidade.</template>
<template v-else>Plano: {{ p.key }}</template>
</span>
<span class="text-sm font-semibold">{{ priceLabelForPlan(p.id) }}</span>
</div>
</template>
<template #content>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<ul class="list-none p-0 m-0 flex flex-col gap-3">
<li v-for="(b, i) in planBenefits(p.id)" :key="i" class="flex items-start gap-2">
<i
:class="b.ok ? 'pi pi-check-circle text-emerald-500' : 'pi pi-times-circle opacity-50'"
class="mt-0.5"
/>
<span :class="b.ok ? '' : 'text-[var(--text-color-secondary)]'">
{{ b.text }}
</span>
<i :class="b.ok ? 'pi pi-check-circle text-emerald-500' : 'pi pi-times-circle opacity-50'" class="mt-0.5" />
<span :class="b.ok ? '' : 'text-[var(--text-color-secondary)]'">{{ b.text }}</span>
</li>
</ul>
@@ -434,7 +444,6 @@ watch(
:disabled="upgrading || loading"
@click="changePlan(p.id)"
/>
<Button
v-else
label="Você já está neste plano"
@@ -444,20 +453,22 @@ watch(
class="w-full"
disabled
/>
<Button
v-if="p.key !== 'free'"
v-if="String(p.key).toLowerCase() !== 'free'"
label="Falar com suporte"
icon="pi pi-comments"
severity="secondary"
outlined
class="w-full"
:disabled="upgrading"
@click="contactSupport"
/>
<div class="text-center text-xs text-[var(--text-color-secondary)]">
Cancele quando quiser. Sem burocracia.
</div>
<div v-if="!subscription?.id" class="text-center text-xs text-amber-500">
Sem assinatura ativa clique em <b>Assinatura</b> para ativar/criar.
</div>
</div>
</div>
</template>
@@ -467,7 +478,7 @@ watch(
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
Observação: alguns recursos PRO podem depender de configuração inicial (ex.: SMS exige provedor).
Alguns recursos PRO podem depender de configuração inicial (ex.: SMS exige provedor).
</div>
</div>
</template>