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:
Leonardo
2026-06-13 18:05:28 -03:00
parent a73b82fa86
commit a979bdf1de
5 changed files with 126 additions and 10 deletions
+52 -2
View File
@@ -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 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;
}