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:
@@ -21,6 +21,7 @@ import { useRoleGuard } from '@/composables/useRoleGuard';
|
|||||||
import { isValidEmail, isValidPhone, sanitizeDigits } from '@/utils/validators';
|
import { isValidEmail, isValidPhone, sanitizeDigits } from '@/utils/validators';
|
||||||
|
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
|
import { maybeShowPlanLimitToast } from '@/utils/planLimit';
|
||||||
|
|
||||||
import InputMask from 'primevue/inputmask';
|
import InputMask from 'primevue/inputmask';
|
||||||
import Message from 'primevue/message';
|
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.';
|
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.';
|
||||||
errorMsg.value = msg;
|
errorMsg.value = msg;
|
||||||
|
|
||||||
|
if (!maybeShowPlanLimitToast(toast, err, route.fullPath)) {
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: 'Erro ao salvar',
|
summary: 'Erro ao salvar',
|
||||||
detail: msg,
|
detail: msg,
|
||||||
life: 4500
|
life: 4500
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.error('[ComponentCadastroRapido] insert error:', err);
|
console.error('[ComponentCadastroRapido] insert error:', err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import { tenantDb } from '@/lib/supabase/tenantClient';
|
|||||||
import { useTenantStore } from '@/stores/tenantStore'
|
import { useTenantStore } from '@/stores/tenantStore'
|
||||||
import { logError } from '@/support/supportLogger'
|
import { logError } from '@/support/supportLogger'
|
||||||
import { digitsOnly, fmtCPF, fmtRG, fmtPhone, toISODate, generateCPF } from '@/utils/validators'
|
import { digitsOnly, fmtCPF, fmtRG, fmtPhone, toISODate, generateCPF } from '@/utils/validators'
|
||||||
|
import { maybeShowPlanLimitToast } from '@/utils/planLimit'
|
||||||
import CadastroRapidoConvenio from '@/components/CadastroRapidoConvenio.vue'
|
import CadastroRapidoConvenio from '@/components/CadastroRapidoConvenio.vue'
|
||||||
import CadastroRapidoMedico from '@/components/CadastroRapidoMedico.vue'
|
import CadastroRapidoMedico from '@/components/CadastroRapidoMedico.vue'
|
||||||
import ContactPhonesEditor from '@/components/ui/ContactPhonesEditor.vue'
|
import ContactPhonesEditor from '@/components/ui/ContactPhonesEditor.vue'
|
||||||
@@ -680,7 +681,9 @@ async function onSubmit () {
|
|||||||
await openPanel(0)
|
await openPanel(0)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logError('patients.cadastro', 'save falhou', 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 })
|
toast.add({ severity:'error', summary:'Erro', detail:e?.message||'Falha ao salvar.', life:4000 })
|
||||||
|
}
|
||||||
} finally { saving.value=false }
|
} finally { saving.value=false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useTenantStore } from '@/stores/tenantStore';
|
|||||||
// extraídos pro repository pra remover duplicação.
|
// extraídos pro repository pra remover duplicação.
|
||||||
import { createPatient, markIntakeConverted } from '@/features/patients/services/patientsRepository';
|
import { createPatient, markIntakeConverted } from '@/features/patients/services/patientsRepository';
|
||||||
import { logError } from '@/support/supportLogger';
|
import { logError } from '@/support/supportLogger';
|
||||||
|
import { maybeShowPlanLimitToast } from '@/utils/planLimit';
|
||||||
import { useConfirm } from 'primevue/useconfirm';
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
import { useToast } from 'primevue/usetoast';
|
import { useToast } from 'primevue/usetoast';
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
@@ -417,7 +418,9 @@ async function convertToPatient() {
|
|||||||
await fetchIntakes();
|
await fetchIntakes();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logError('patients.recebidos', 'converter paciente falhou', 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 });
|
toast.add({ severity: 'error', summary: 'Falha ao converter', detail: err?.message || 'Não foi possível converter o cadastro.', life: 4500 });
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
converting.value = false;
|
converting.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { applyThemeEngine } from '@/theme/theme.options';
|
|||||||
|
|
||||||
import { fetchAllNotices } from '@/features/notices/noticeService';
|
import { fetchAllNotices } from '@/features/notices/noticeService';
|
||||||
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore';
|
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore';
|
||||||
|
import { buildUpgradeUrl } from '@/utils/upgradeContext';
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const entitlementsStore = useEntitlementsStore();
|
const entitlementsStore = useEntitlementsStore();
|
||||||
@@ -198,6 +199,30 @@ const showPlanDevMenu = computed(() => {
|
|||||||
return canSee('settings.view') && enablePlanToggle.value;
|
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 ctxMenu = ref();
|
||||||
const ctxMenuModel = computed(() =>
|
const ctxMenuModel = computed(() =>
|
||||||
ctxItems.value.length
|
ctxItems.value.length
|
||||||
@@ -229,7 +254,7 @@ async function getMyUserId() {
|
|||||||
async function getActiveTherapistSubscription() {
|
async function getActiveTherapistSubscription() {
|
||||||
const uid = await getMyUserId();
|
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;
|
if (error) throw error;
|
||||||
|
|
||||||
@@ -259,7 +284,7 @@ async function getActiveClinicSubscription() {
|
|||||||
const tid = tenantId.value;
|
const tid = tenantId.value;
|
||||||
if (!tid) return null;
|
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;
|
if (error) throw error;
|
||||||
return data || null;
|
return data || null;
|
||||||
@@ -556,7 +581,16 @@ onMounted(async () => {
|
|||||||
await loadAndApplyUserSettings();
|
await loadAndApplyUserSettings();
|
||||||
await loadSessionIdentity();
|
await loadSessionIdentity();
|
||||||
await bootstrapEntitlements();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -602,6 +636,9 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<!-- Ações -->
|
<!-- Ações -->
|
||||||
<div class="rail-topbar__actions">
|
<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 -->
|
<!-- Plan Dev Button -->
|
||||||
<Button v-if="showPlanDevMenu" ref="planBtn" outlined :loading="planMenuLoading || trocandoPlano" :disabled="planMenuLoading || trocandoPlano" @click="openPlanMenu" class="rail-topbar__btn">
|
<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" />
|
<i class="pi pi-sliders-h" />
|
||||||
@@ -724,6 +761,19 @@ onMounted(async () => {
|
|||||||
background: var(--surface-ground);
|
background: var(--surface-ground);
|
||||||
color: var(--primary-color);
|
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 {
|
.config-panel {
|
||||||
z-index: 200;
|
z-index: 200;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user