first commit

This commit is contained in:
Leonardo
2026-02-18 22:36:45 -03:00
parent ec6b6ef53a
commit 676042268b
122 changed files with 26354 additions and 1615 deletions
+396
View File
@@ -0,0 +1,396 @@
<!-- src/views/pages/upgrade/UpgradePage.vue (ajuste o caminho conforme seu projeto) -->
<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'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
const toast = useToast()
const route = useRoute()
const router = useRouter()
const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore()
// 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'
}
const requestedFeatureLabel = computed(() => {
if (!requestedFeature.value) return null
return featureLabels[requestedFeature.value] || requestedFeature.value
})
// estado
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 subscription = ref(null) // subscription ativa do tenant
// ✅ Modelo B: plano é do TENANT
const tenantId = computed(() => tenantStore.activeTenantId || null)
const planById = computed(() => {
const m = new Map()
for (const p of plans.value) m.set(p.id, p)
return m
})
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()
set.add(row.feature_id)
m.set(row.plan_id, set)
}
return m
})
const currentPlanId = computed(() => subscription.value?.plan_id || null)
function planKeyById(id) {
return planById.value.get(id)?.key || null
}
const currentPlanKey = computed(() => planKeyById(currentPlanId.value))
function friendlyFeatureLabel(featureKey) {
return featureLabels[featureKey] || featureKey
}
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))
})
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
}
function goBack() {
router.back()
}
function goBilling() {
router.push('/admin/billing')
}
function contactSupport() {
router.push('/admin/billing')
}
async function fetchAll() {
loading.value = true
try {
const tid = tenantId.value
if (!tid) throw new Error('Tenant ativo não encontrado.')
const [pRes, fRes, pfRes, sRes] = await Promise.all([
supabase.from('plans').select('*').order('key', { ascending: true }),
supabase.from('features').select('*').order('key', { ascending: true }),
supabase.from('plan_features').select('plan_id, feature_id'),
supabase
.from('subscriptions')
.select('id, tenant_id, plan_id, plan_key, interval, status, created_at, updated_at')
.eq('tenant_id', tid)
.eq('status', 'active')
.order('updated_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] sem subscription ativa (ok):', sRes.error)
}
plans.value = pRes.data || []
features.value = fRes.data || []
planFeatures.value = pfRes.data || []
subscription.value = sRes.data || null
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
loading.value = false
}
}
async function changePlan(targetPlanId) {
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
})
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.')
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
// ✅ recarrega entitlements (sem reload)
entitlementsStore.clear?.()
await entitlementsStore.fetch(tid, { force: true })
toast.add({
severity: 'success',
summary: 'Plano atualizado',
detail: `Agora você está no plano ${planKeyById(subscription.value.plan_id) || ''}`.trim(),
life: 3000
})
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
upgrading.value = false
}
}
onMounted(fetchAll)
// se trocar tenant ativo, recarrega
watch(
() => tenantId.value,
() => {
if (tenantId.value) 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)]">
<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" />
<div class="absolute -bottom-20 right-32 h-80 w-80 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div class="relative flex flex-col gap-4">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<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>
<span class="mx-2 opacity-50"></span>
Você está no plano:
<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>
</div>
<!-- BLOCO: 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>
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">
Esse recurso depende do plano que inclui a 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>
</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.
</div>
</div>
</div>
</div>
<!-- PLANOS (DINÂMICOS) -->
<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'
? 'relative overflow-hidden rounded-[1.75rem] border border-primary/40 bg-[var(--surface-card)]'
: ''"
>
<div v-if="p.key === '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'">
<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'" />
<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" />
</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>
</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>
</li>
</ul>
<Divider class="my-4" />
<div class="flex flex-col gap-3">
<Button
v-if="currentPlanId !== p.id"
:label="`Mudar para ${String(p.key || '').toUpperCase()}`"
icon="pi pi-arrow-up"
size="large"
class="w-full"
:loading="upgrading"
:disabled="upgrading || loading"
@click="changePlan(p.id)"
/>
<Button
v-else
label="Você já está neste plano"
icon="pi pi-check"
severity="secondary"
outlined
class="w-full"
disabled
/>
<Button
v-if="p.key !== 'free'"
label="Falar com suporte"
icon="pi pi-comments"
severity="secondary"
outlined
class="w-full"
@click="contactSupport"
/>
<div class="text-center text-xs text-[var(--text-color-secondary)]">
Cancele quando quiser. Sem burocracia.
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</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).
</div>
</div>
</template>