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
@@ -21,6 +21,7 @@ import { useRoleGuard } from '@/composables/useRoleGuard';
import { isValidEmail, isValidPhone, sanitizeDigits } from '@/utils/validators';
import { useToast } from 'primevue/usetoast';
import { maybeShowPlanLimitToast } from '@/utils/planLimit';
import InputMask from 'primevue/inputmask';
import Message from 'primevue/message';
@@ -269,12 +270,14 @@ async function submit(mode = 'only') {
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.';
errorMsg.value = msg;
if (!maybeShowPlanLimitToast(toast, err, route.fullPath)) {
toast.add({
severity: 'error',
summary: 'Erro ao salvar',
detail: msg,
life: 4500
});
}
console.error('[ComponentCadastroRapido] insert error:', err);
} finally {
@@ -68,6 +68,7 @@ import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore'
import { logError } from '@/support/supportLogger'
import { digitsOnly, fmtCPF, fmtRG, fmtPhone, toISODate, generateCPF } from '@/utils/validators'
import { maybeShowPlanLimitToast } from '@/utils/planLimit'
import CadastroRapidoConvenio from '@/components/CadastroRapidoConvenio.vue'
import CadastroRapidoMedico from '@/components/CadastroRapidoMedico.vue'
import ContactPhonesEditor from '@/components/ui/ContactPhonesEditor.vue'
@@ -680,7 +681,9 @@ async function onSubmit () {
await openPanel(0)
} catch (e) {
logError('patients.cadastro', 'save falhou', e)
if (!maybeShowPlanLimitToast(toast, e, route.fullPath)) {
toast.add({ severity:'error', summary:'Erro', detail:e?.message||'Falha ao salvar.', life:4000 })
}
} finally { saving.value=false }
}
@@ -21,6 +21,7 @@ import { useTenantStore } from '@/stores/tenantStore';
// extraídos pro repository pra remover duplicação.
import { createPatient, markIntakeConverted } from '@/features/patients/services/patientsRepository';
import { logError } from '@/support/supportLogger';
import { maybeShowPlanLimitToast } from '@/utils/planLimit';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
@@ -417,7 +418,9 @@ async function convertToPatient() {
await fetchIntakes();
} catch (err) {
logError('patients.recebidos', 'converter paciente falhou', err);
if (!maybeShowPlanLimitToast(toast, err, '/admin/pacientes/recebidos')) {
toast.add({ severity: 'error', summary: 'Falha ao converter', detail: err?.message || 'Não foi possível converter o cadastro.', life: 4500 });
}
} finally {
converting.value = false;
}
+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;
}
+57
View File
@@ -0,0 +1,57 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/utils/planLimit.js
|
| Traduz o erro de enforcement de limite de plano vindo do banco
| (RAISE 'PLAN_LIMIT_REACHED|<recurso>|<limite>') num toast amigável com
| CTA de upgrade, reusando o grupo de toast 'system-alerts' do AppLayout
| (que já renderiza um botão de ação a partir de data.deeplink).
|--------------------------------------------------------------------------
*/
import { buildUpgradeUrl } from '@/utils/upgradeContext';
const RESOURCE_LABELS = {
patients: 'pacientes'
};
/**
* Extrai {resource, limit} de um erro de limite de plano. Retorna null se o
* erro não for um PLAN_LIMIT_REACHED (deixa o caller tratar genericamente).
*/
export function parsePlanLimitError(err) {
const msg = err?.message || err?.error_description || err?.details || '';
const m = /PLAN_LIMIT_REACHED\|([a-z_]+)\|(\d+)/i.exec(String(msg));
if (!m) return null;
return { resource: m[1], limit: Number(m[2]) };
}
/**
* Se `err` for um erro de limite de plano, mostra o toast amigável com CTA e
* retorna true (o caller deve então parar — não mostrar o erro genérico).
* Caso contrário retorna false.
*
* @param {object} toast instância do PrimeVue useToast()
* @param {Error} err erro capturado
* @param {string|null} redirectTo rota pra voltar após o upgrade (ex: route.fullPath)
*/
export function maybeShowPlanLimitToast(toast, err, redirectTo = null) {
const parsed = parsePlanLimitError(err);
if (!parsed) return false;
const label = RESOURCE_LABELS[parsed.resource] || parsed.resource;
toast?.add?.({
group: 'system-alerts',
severity: 'warn',
summary: 'Limite do plano gratuito',
detail: `Você atingiu o limite de ${parsed.limit} ${label} do plano gratuito. Faça upgrade para adicionar mais.`,
life: 8000,
data: {
deeplink: buildUpgradeUrl({ redirectTo }),
actionLabel: 'Fazer upgrade'
}
});
return true;
}