freemium F1: frontend do enforcement (toast + botao Upgrade PRO)
- utils/planLimit.js: parsePlanLimitError + maybeShowPlanLimitToast (traduz PLAN_LIMIT_REACHED em toast amigavel com CTA via grupo system-alerts) - AppTopbar: botao "Upgrade PRO" quando plano ativo e gratuito (reusa resolveActiveSubscriptionContext; plan_key nos selects) - ligado nos 3 pontos de criacao de paciente: PatientsCadastroPage, CadastrosRecebidos (intake), ComponentCadastroRapido (quick-create) - build OK Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,7 @@ import { applyThemeEngine } from '@/theme/theme.options';
|
||||
|
||||
import { fetchAllNotices } from '@/features/notices/noticeService';
|
||||
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore';
|
||||
import { buildUpgradeUrl } from '@/utils/upgradeContext';
|
||||
|
||||
const toast = useToast();
|
||||
const entitlementsStore = useEntitlementsStore();
|
||||
@@ -198,6 +199,30 @@ const showPlanDevMenu = computed(() => {
|
||||
return canSee('settings.view') && enablePlanToggle.value;
|
||||
});
|
||||
|
||||
/* ----------------------------
|
||||
Upgrade PRO — botão user-facing quando o plano ativo é gratuito.
|
||||
Reusa resolveActiveSubscriptionContext() (clínica via tenant_id,
|
||||
pessoal via user_id) e expõe só plan_key pra decidir o badge.
|
||||
----------------------------- */
|
||||
const activePlanKey = ref(null);
|
||||
const isFreePlan = computed(() => {
|
||||
const k = String(activePlanKey.value || '').toLowerCase();
|
||||
return k.endsWith('_free') || k === 'free';
|
||||
});
|
||||
|
||||
async function loadPlanBadge() {
|
||||
try {
|
||||
const { sub } = await resolveActiveSubscriptionContext();
|
||||
activePlanKey.value = sub?.plan_key ?? null;
|
||||
} catch {
|
||||
activePlanKey.value = null; // ausência do badge nunca pode quebrar a topbar
|
||||
}
|
||||
}
|
||||
|
||||
function goUpgrade() {
|
||||
router.push(buildUpgradeUrl({ redirectTo: route.fullPath }));
|
||||
}
|
||||
|
||||
const ctxMenu = ref();
|
||||
const ctxMenuModel = computed(() =>
|
||||
ctxItems.value.length
|
||||
@@ -229,7 +254,7 @@ async function getMyUserId() {
|
||||
async function getActiveTherapistSubscription() {
|
||||
const uid = await getMyUserId();
|
||||
|
||||
const { data, error } = await supabase.from('subscriptions').select('id, tenant_id, user_id, plan_id, status, updated_at').eq('user_id', uid).order('updated_at', { ascending: false }).limit(10);
|
||||
const { data, error } = await supabase.from('subscriptions').select('id, tenant_id, user_id, plan_id, plan_key, status, updated_at').eq('user_id', uid).order('updated_at', { ascending: false }).limit(10);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
@@ -259,7 +284,7 @@ async function getActiveClinicSubscription() {
|
||||
const tid = tenantId.value;
|
||||
if (!tid) return null;
|
||||
|
||||
const { data, error } = await supabase.from('subscriptions').select('id, tenant_id, user_id, plan_id, status, updated_at').eq('tenant_id', tid).eq('status', 'active').order('updated_at', { ascending: false }).limit(1).maybeSingle();
|
||||
const { data, error } = await supabase.from('subscriptions').select('id, tenant_id, user_id, plan_id, plan_key, status, updated_at').eq('tenant_id', tid).eq('status', 'active').order('updated_at', { ascending: false }).limit(1).maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
return data || null;
|
||||
@@ -556,7 +581,16 @@ onMounted(async () => {
|
||||
await loadAndApplyUserSettings();
|
||||
await loadSessionIdentity();
|
||||
await bootstrapEntitlements();
|
||||
loadPlanBadge();
|
||||
});
|
||||
|
||||
// recarrega o badge de plano só ao trocar de tenant ou de contexto (clínica vs
|
||||
// pessoal) — não a cada navegação, pra evitar queries desnecessárias.
|
||||
const isClinicArea = computed(() => {
|
||||
const p = route.path || '';
|
||||
return p.startsWith('/admin') || p.startsWith('/supervisor');
|
||||
});
|
||||
watch([tenantId, isClinicArea], () => { loadPlanBadge(); });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -602,6 +636,9 @@ onMounted(async () => {
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="rail-topbar__actions">
|
||||
<!-- Upgrade PRO — só quando o plano ativo é gratuito -->
|
||||
<Button v-if="isFreePlan" label="Upgrade PRO" icon="pi pi-star-fill" size="small" class="rail-topbar__upgrade-btn" @click="goUpgrade" />
|
||||
|
||||
<!-- Plan Dev Button -->
|
||||
<Button v-if="showPlanDevMenu" ref="planBtn" outlined :loading="planMenuLoading || trocandoPlano" :disabled="planMenuLoading || trocandoPlano" @click="openPlanMenu" class="rail-topbar__btn">
|
||||
<i class="pi pi-sliders-h" />
|
||||
@@ -724,6 +761,19 @@ onMounted(async () => {
|
||||
background: var(--surface-ground);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* ── Botão Upgrade PRO (plano gratuito) ──────────────────── */
|
||||
.rail-topbar__upgrade-btn {
|
||||
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 1px 3px rgba(217, 119, 6, 0.35);
|
||||
}
|
||||
.rail-topbar__upgrade-btn:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
.config-panel {
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user