From a979bdf1de07025410a9b191615d640c7c808ae3 Mon Sep 17 00:00:00 2001 From: Leonardo Date: Sat, 13 Jun 2026 18:05:28 -0300 Subject: [PATCH] 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) --- src/components/ComponentCadastroRapido.vue | 15 +++-- .../cadastro/PatientsCadastroPage.vue | 5 +- .../recebidos/CadastrosRecebidosPage.vue | 5 +- src/layout/AppTopbar.vue | 54 +++++++++++++++++- src/utils/planLimit.js | 57 +++++++++++++++++++ 5 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 src/utils/planLimit.js diff --git a/src/components/ComponentCadastroRapido.vue b/src/components/ComponentCadastroRapido.vue index 02fa936..5a8eb89 100644 --- a/src/components/ComponentCadastroRapido.vue +++ b/src/components/ComponentCadastroRapido.vue @@ -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; - toast.add({ - severity: 'error', - summary: 'Erro ao salvar', - detail: msg, - life: 4500 - }); + 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 { diff --git a/src/features/patients/cadastro/PatientsCadastroPage.vue b/src/features/patients/cadastro/PatientsCadastroPage.vue index b8fcffc..8aea699 100644 --- a/src/features/patients/cadastro/PatientsCadastroPage.vue +++ b/src/features/patients/cadastro/PatientsCadastroPage.vue @@ -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) - toast.add({ severity:'error', summary:'Erro', detail:e?.message||'Falha ao salvar.', life:4000 }) + if (!maybeShowPlanLimitToast(toast, e, route.fullPath)) { + toast.add({ severity:'error', summary:'Erro', detail:e?.message||'Falha ao salvar.', life:4000 }) + } } finally { saving.value=false } } diff --git a/src/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue b/src/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue index e9af0b4..c09ba74 100644 --- a/src/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue +++ b/src/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue @@ -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); - toast.add({ severity: 'error', summary: 'Falha ao converter', detail: err?.message || 'Não foi possível converter o cadastro.', life: 4500 }); + 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; } diff --git a/src/layout/AppTopbar.vue b/src/layout/AppTopbar.vue index b5ef568..1f5292f 100644 --- a/src/layout/AppTopbar.vue +++ b/src/layout/AppTopbar.vue @@ -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(); });