fix: enforce strict plan validation frontend and backend

This commit is contained in:
Marcio Bevervanso 2026-02-17 19:07:10 -03:00
parent 858be2d032
commit 62a509d8a6
4 changed files with 125 additions and 45 deletions

View file

@ -7,9 +7,12 @@ interface DashboardCoachProps {
setCoachPlan: (plan: any) => void; setCoachPlan: (plan: any) => void;
coachHistory?: any[]; // Array of coach_analyses records coachHistory?: any[]; // Array of coach_analyses records
setIsCoachWizardOpen: (open: boolean) => void; setIsCoachWizardOpen: (open: boolean) => void;
userPlan: 'free' | 'pro' | 'trial';
} }
const DashboardCoach: React.FC<DashboardCoachProps> = ({ coachPlan, setCoachPlan, coachHistory = [], setIsCoachWizardOpen }) => { const DashboardCoach: React.FC<DashboardCoachProps> = ({ coachPlan, setCoachPlan, coachHistory = [], setIsCoachWizardOpen, userPlan }) => {
const isPaid = userPlan === 'pro' || userPlan === 'trial';
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// STATE 1: NO HISTORY (HERO / ONBOARDING) // STATE 1: NO HISTORY (HERO / ONBOARDING)
@ -43,6 +46,7 @@ const DashboardCoach: React.FC<DashboardCoachProps> = ({ coachPlan, setCoachPlan
</p> </p>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
{isPaid ? (
<button <button
onClick={() => setIsCoachWizardOpen(true)} onClick={() => setIsCoachWizardOpen(true)}
className="px-8 py-3.5 bg-brand-600 hover:bg-brand-500 text-white rounded-xl font-bold flex items-center gap-3 transition-all shadow-lg shadow-brand-900/50 hover:scale-105 active:scale-95" className="px-8 py-3.5 bg-brand-600 hover:bg-brand-500 text-white rounded-xl font-bold flex items-center gap-3 transition-all shadow-lg shadow-brand-900/50 hover:scale-105 active:scale-95"
@ -50,6 +54,15 @@ const DashboardCoach: React.FC<DashboardCoachProps> = ({ coachPlan, setCoachPlan
<Zap size={20} fill="currentColor" /> <Zap size={20} fill="currentColor" />
Gerar Novo Protocolo Gerar Novo Protocolo
</button> </button>
) : (
<button
disabled
className="px-8 py-3.5 bg-gray-700 text-gray-400 rounded-xl font-bold flex items-center gap-3 cursor-not-allowed opacity-75"
>
<Zap size={20} />
Disponível no Plano PRO
</button>
)}
</div> </div>
</div> </div>
@ -108,7 +121,7 @@ const DashboardCoach: React.FC<DashboardCoachProps> = ({ coachPlan, setCoachPlan
].map((stat, i) => ( ].map((stat, i) => (
<div key={i} className="text-center p-6 bg-white rounded-3xl border border-gray-200 shadow-sm hover:shadow-md transition-shadow"> <div key={i} className="text-center p-6 bg-white rounded-3xl border border-gray-200 shadow-sm hover:shadow-md transition-shadow">
<p className="text-3xl font-black text-gray-900 mb-1">{stat.value}</p> <p className="text-3xl font-black text-gray-900 mb-1">{stat.value}</p>
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">{stat.label}</p> <p className="text-xs font-bold text-gray-400 uppercase tracking-wider}>{stat.label}</p>
</div> </div>
))} ))}
</div> </div>
@ -120,25 +133,67 @@ const DashboardCoach: React.FC<DashboardCoachProps> = ({ coachPlan, setCoachPlan
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// STATE 2: COACH RESULT (CONTENT ONLY, HISTORY IS IN MAIN SIDEBAR) // STATE 2: COACH RESULT (CONTENT ONLY, HISTORY IS IN MAIN SIDEBAR)
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// 🔒 FREE PLAN LOCK
if (!isPaid) {
return (
<div className="w-full animate-in fade-in duration-500">
<div className="bg-gray-900 rounded-3xl border border-gray-800 p-12 text-center h-[500px] flex flex-col items-center justify-center relative overflow-hidden group">
<div className="absolute inset-0 bg-brand-900/10"></div>
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-brand-900/20 rounded-full blur-[120px] translate-x-1/2 -translate-y-1/2"></div>
<div className="relative z-10 flex flex-col items-center">
<div className="w-20 h-20 bg-gray-800/50 rounded-full flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300 border border-white/10">
<ShieldAlert size={40} className="text-brand-500" />
</div>
<h3 className="text-2xl font-bold text-white mb-3">Funcionalidade PRO</h3>
<p className="text-gray-400 max-w-md mx-auto mb-8 text-lg">
O Personal IA está disponível apenas para membros PRO. Desbloqueie todo o potencial do seu corpo agora.
</p>
<button
onClick={() => {
// Reduz o active tab para subscription se possível, mas aqui estamos isolados.
// Idealmente chamaria uma função do pai ou link direto pro checkout.
// Vamos despachar um evento ou alertar por enquanto, ou usar href para dashboard?
// Melhor: window.location.href com query param ou apenas instruir.
const subTab = document.querySelector('[data-tab="subscription"]') as HTMLElement;
if (subTab) subTab.click();
else window.location.reload(); // Simplificação
}}
className="px-8 py-3.5 bg-brand-600 hover:bg-brand-500 text-white rounded-xl font-bold flex items-center gap-2 transition-all shadow-lg hover:shadow-brand-500/20"
>
<Zap size={20} fill="currentColor" />
Fazer Upgrade Agora
</button>
</div>
</div>
</div>
);
}
return ( return (
<div className="w-full animate-in fade-in duration-500"> <div className="w-full animate-in fade-in duration-500">
{coachPlan ? ( {coachPlan ? (
<CoachResult data={coachPlan} onReset={() => setCoachPlan(null)} /> <CoachResult data={coachPlan} onReset={() => setCoachPlan(null)} />
) : ( ) : (
<div className="bg-white rounded-3xl border border-gray-100 p-12 text-center h-[500px] flex flex-col items-center justify-center"> <div className="bg-white rounded-3xl border border-gray-100 p-12 text-center h-[500px] flex flex-col items-center justify-center relative overflow-hidden group">
<div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mb-4">
<Activity size={32} className="text-gray-300" /> <div className="w-16 h-16 bg-brand-50 rounded-full flex items-center justify-center mb-4 group-hover:scale-110 transition-transform duration-300">
<Activity size={32} className="text-brand-500" />
</div> </div>
<h3 className="text-lg font-bold text-gray-900 mb-2">Selecione uma análise</h3> <h3 className="text-lg font-bold text-gray-900 mb-2">Selecione uma análise</h3>
<p className="text-gray-500 max-w-sm mx-auto"> <p className="text-gray-500 max-w-sm mx-auto mb-8">
Escolha um protocolo no menu lateral ("Coach AI → Histórico") ou gere um novo. Escolha um protocolo no menu lateral ("Coach AI → Histórico") ou gere um novo para transformar seus resultados.
</p> </p>
<button <button
onClick={() => setIsCoachWizardOpen(true)} onClick={() => setIsCoachWizardOpen(true)}
className="mt-6 px-6 py-2 bg-brand-600 hover:bg-brand-500 text-white rounded-xl font-bold flex items-center gap-2 transition-all" className="px-8 py-3 bg-brand-600 hover:bg-brand-500 text-white rounded-xl font-bold flex items-center gap-2 transition-all shadow-lg shadow-brand-200 hover:shadow-brand-300 transform hover:-translate-y-0.5"
> >
<Plus size={18} /> <Plus size={20} />
Nova Análise Nova Análise com IA
</button> </button>
</div> </div>
)} )}
@ -146,4 +201,4 @@ const DashboardCoach: React.FC<DashboardCoachProps> = ({ coachPlan, setCoachPlan
); );
}; };
export default DashboardCoach; export default DashboardCoach;

View file

@ -60,17 +60,20 @@ const DashboardSubscription: React.FC<DashboardSubscriptionProps> = ({ user, pla
<div className="p-8 border-b border-gray-100 relative z-10"> <div className="p-8 border-b border-gray-100 relative z-10">
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
<div className="flex items-center gap-3 mb-2"> <div className="flex flex-col">
<span className="text-xs text-gray-500 uppercase font-bold tracking-wider">{t.dashboard.currentPlan}</span> <h3 className="text-sm font-bold text-gray-500 uppercase tracking-widest mb-1">{t.dashboard.currentPlan}</h3>
{(user.plan === 'pro' || user.plan === 'trial') && ( <div className="flex items-center gap-3">
<span className="text-[10px] bg-brand-100 text-brand-700 px-2 py-0.5 rounded-full border border-brand-200 font-bold uppercase tracking-wider"> <span className={`text-2xl font-bold ${planName === 'PRO' ? 'text-brand-600' : 'text-gray-900'}`}>
Ativo {planName === 'PRO' ? 'Professional' : planName}
</span> </span>
</div>
{/* Check if user has a valid date and is NOT on free plan (unless free plan has an expiry which is rare but possible for trials) */}
{user.plan !== 'free' && user.plan_valid_until && (
<p className="text-xs text-gray-400 mt-1">
Válido até {new Date(user.plan_valid_until).toLocaleDateString('pt-BR')}
</p>
)} )}
</div> </div>
<h2 className="text-4xl font-bold text-gray-900 mb-4 capitalize">
{planName}
</h2>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-gray-600 bg-gray-50 px-3 py-1.5 rounded-lg border border-gray-100"> <div className="flex items-center gap-2 text-gray-600 bg-gray-50 px-3 py-1.5 rounded-lg border border-gray-100">
<Calendar size={16} className="text-brand-500" /> <Calendar size={16} className="text-brand-500" />

View file

@ -213,6 +213,7 @@ const Dashboard: React.FC<DashboardProps> = ({ user, onLogout, onOpenAdmin, onOp
setCoachPlan={setCoachPlan} setCoachPlan={setCoachPlan}
coachHistory={coachHistory} coachHistory={coachHistory}
setIsCoachWizardOpen={setIsCoachWizardOpen} setIsCoachWizardOpen={setIsCoachWizardOpen}
userPlan={user.plan}
/> />
)} )}

View file

@ -545,6 +545,27 @@ serve(async (req) => {
textMessage && textMessage &&
/coach|treino|avalia[çc][aã]o/i.test(textMessage) /coach|treino|avalia[çc][aã]o/i.test(textMessage)
) { ) {
// [STRICT VALIDATION] Check for active PAID plan
const { data: entitlement } = await supabase
.from("user_entitlements")
.select("is_active, valid_until, entitlement_code")
.eq("user_id", userId)
.match({ is_active: true }) // Ensure active
.maybeSingle();
const isPaid = entitlement &&
['pro', 'mensal', 'trimestral', 'anual', 'trial', 'paid'].includes(entitlement.entitlement_code) &&
(!entitlement.valid_until || new Date(entitlement.valid_until) > new Date());
if (!isPaid) {
await sendWhatsAppMessage(
remoteJid,
"🔒 *Funcionalidade Exclusiva PRO*\n\nO *Personal Coach IA* está disponível apenas para assinantes PRO.\n\nCom o plano PRO você tem:\n✅ Treinos personalizados\n✅ Dieta sob medida\n✅ Ajustes mensais\n\nFaça o upgrade agora em: https://foodsnap.com.br"
);
return new Response("Coach Blocked (Free)", { status: 200 });
}
// [LOGIC START] Verificar última avaliação (Limite de 7 dias)
// [LOGIC START] Verificar última avaliação (Limite de 7 dias) // [LOGIC START] Verificar última avaliação (Limite de 7 dias)
const { data: lastAnalysis } = await supabase const { data: lastAnalysis } = await supabase
.from("coach_analyses") .from("coach_analyses")
@ -807,16 +828,16 @@ serve(async (req) => {
if (state === "IDLE") { if (state === "IDLE") {
console.log(`[WH] Entering Food Scan flow. isImage=${isImage}`); console.log(`[WH] Entering Food Scan flow. isImage=${isImage}`);
// 6a. Verificar plano e quota // 6a. Verificar plano e quota
// 6a. Verificar plano e quota
const { data: entitlement } = await supabase const { data: entitlement } = await supabase
.from("user_entitlements") .from("user_entitlements")
.select("is_active, valid_until, entitlement_code") .select("is_active, valid_until, entitlement_code")
.eq("user_id", userId) .eq("user_id", userId)
.eq("is_active", true) .match({ is_active: true })
.order("valid_until", { ascending: false, nullsFirst: false })
.maybeSingle(); .maybeSingle();
const isPaid = const isPaid = entitlement &&
entitlement?.is_active && ['pro', 'mensal', 'trimestral', 'anual', 'trial', 'paid'].includes(entitlement.entitlement_code) &&
(!entitlement.valid_until || new Date(entitlement.valid_until) > new Date()); (!entitlement.valid_until || new Date(entitlement.valid_until) > new Date());
if (!isPaid) { if (!isPaid) {