first commit
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user