feat: complete dashboard redesign, payment history, and backend limits

This commit is contained in:
Marcio Bevervanso 2026-02-17 17:49:42 -03:00
commit 0741b4e03c
87 changed files with 18241 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

20
README.md Normal file
View file

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1ieKyFsxWOnR3ACt_oJ5Hpidgm9Wny9At
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

BIN
foodsnap-main.rar Normal file

Binary file not shown.

BIN
foodsnap.rar Normal file

Binary file not shown.

143
index.html Normal file
View file

@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="pt-BR" class="scroll-smooth">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FoodSnap - Nutritional Intelligence</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
50: '#ecfdf5',
100: '#d1fae5',
200: '#a7f3d0',
300: '#6ee7b7',
400: '#34d399',
500: '#10b981',
600: '#059669',
700: '#047857',
800: '#065f46',
900: '#064e3b',
950: '#022c22',
}
},
fontFamily: {
sans: ['"Plus Jakarta Sans"', 'Inter', 'sans-serif'],
},
boxShadow: {
'premium': '0 20px 40px -6px rgba(0, 0, 0, 0.1)',
'glow': '0 0 20px rgba(5, 150, 105, 0.3)',
'card-hover': '0 10px 30px -5px rgba(0, 0, 0, 0.08)',
},
backgroundImage: {
'noise': "url('https://grainy-gradients.vercel.app/noise.svg')",
}
}
}
}
</script>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet">
<!-- Configurações de Tema e Tratamento de Erros -->
<script>
// Define ambiente de produção para otimizar React
window.process = { env: { NODE_ENV: 'production' } };
// Configuração Tailwind
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['"Plus Jakarta Sans"', 'Inter', 'sans-serif'], // More modern font stack
},
colors: {
gray: {
25: '#fcfcfd', // Lighter background
50: '#f9fafb',
100: '#f3f4f6',
200: '#e5e7eb',
300: '#d1d5db',
400: '#9ca3af',
500: '#6b7280',
600: '#4b5563',
700: '#374151',
800: '#1f2937',
850: '#111827',
900: '#030712',
},
brand: {
50: '#ecfdf5',
100: '#d1fae5',
200: '#a7f3d0',
300: '#6ee7b7',
400: '#34d399',
500: '#10b981',
600: '#059669',
700: '#047857',
800: '#065f46',
900: '#064e3b',
950: '#022c22',
},
},
boxShadow: {
'sm': '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
'DEFAULT': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
'md': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
'lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1)',
'xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',
'2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
'glow': '0 0 40px rgba(16, 185, 129, 0.2)',
'premium': '0 20px 40px -5px rgba(0, 0, 0, 0.1), 0 10px 20px -5px rgba(0, 0, 0, 0.04)', // New premium shadow
'card': '0 0 0 1px rgba(0,0,0,0.03), 0 2px 8px rgba(0,0,0,0.04), 0 8px 32px rgba(0,0,0,0.04)', // Enhanced card shadow
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'shimmer': 'linear-gradient(45deg, rgba(255,255,255,0) 40%, rgba(255,255,255,0.5) 50%, rgba(255,255,255,0) 60%)',
}
}
}
}
window.onerror = function (msg, url, line, col, error) {
console.error("Critical Error:", msg, error);
};
</script>
<!-- Import Map OTIMIZADO -->
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.3.1",
"react/": "https://esm.sh/react@18.3.1/",
"react-dom": "https://esm.sh/react-dom@18.3.1",
"react-dom/": "https://esm.sh/react-dom@18.3.1/",
"react-dom/client": "https://esm.sh/react-dom@18.3.1/client",
"react/jsx-runtime": "https://esm.sh/react@18.3.1/jsx-runtime",
"lucide-react": "https://esm.sh/lucide-react@0.344.0?deps=react@18.3.1,react-dom@18.3.1",
"framer-motion": "https://esm.sh/framer-motion@11.0.8?deps=react@18.3.1,react-dom@18.3.1",
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.39.7",
"@google/genai": "https://esm.sh/@google/genai@^1.33.0"
}
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5
metadata.json Normal file
View file

@ -0,0 +1,5 @@
{
"name": "FoodSnap",
"description": "Instant nutritional analysis from a simple photo.",
"requestFramePermissions": []
}

3286
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "foodsnap",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@google/genai": "^1.33.0",
"@supabase/supabase-js": "2.39.7",
"framer-motion": "11.0.8",
"html2pdf.js": "^0.12.1",
"lucide-react": "0.344.0",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

327
src/App.tsx Normal file
View file

@ -0,0 +1,327 @@
import React, { useState, useEffect } from 'react';
import Header from './components/landing/Header';
import Hero from './components/landing/Hero';
import CoachHighlight from './components/landing/CoachHighlight';
import HowItWorks from './components/landing/HowItWorks';
import Features from './components/landing/Features';
import Testimonials from './components/landing/Testimonials';
import Pricing from './components/landing/Pricing';
import FAQ from './components/landing/FAQ';
import Footer from './components/landing/Footer';
import RegistrationModal from './components/modals/RegistrationModal';
import CalculatorsModal from './components/modals/CalculatorsModal';
import Dashboard from './pages/Dashboard';
import AdminPanel from './pages/AdminPanel';
import ProfessionalDashboard from './pages/ProfessionalDashboard';
import FAQPage from './pages/FAQPage';
import { LanguageProvider } from './contexts/LanguageContext';
import { supabase } from './lib/supabase';
import { Loader2 } from 'lucide-react';
import { User } from './types';
// removed User interface definition
const AppContent: React.FC = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isToolsOpen, setIsToolsOpen] = useState(false);
const [authMode, setAuthMode] = useState<'login' | 'register'>('register');
const [selectedPlan, setSelectedPlan] = useState('starter');
const [currentView, setCurrentView] = useState<'home' | 'faq'>('home'); // Estado de navegação
const [isCompletingProfile, setIsCompletingProfile] = useState(false); // Novo estado para controle de perfil incompleto
const [user, setUser] = useState<User | null>(null);
const [isAdminView, setIsAdminView] = useState(false);
const [isProfessionalView, setIsProfessionalView] = useState(false);
const [isLoadingSession, setIsLoadingSession] = useState(true);
// Check active session on load
// Check active session on load
useEffect(() => {
let mounted = true;
const initializeAuth = async () => {
try {
// Obter sessão inicial sem race conditions complexas
const { data: { session }, error } = await supabase.auth.getSession();
if (error) {
console.error("Erro ao obter sessão inicial:", error);
if (mounted) setIsLoadingSession(false);
return;
}
if (session?.user) {
console.log("App: Sessão encontrada, carregando perfil...");
if (mounted) {
await fetchUserProfile(session.user.id, session.user.email);
}
} else {
console.log("App: Nenhuma sessão ativa.");
if (mounted) setIsLoadingSession(false);
}
} catch (err) {
console.error("Erro inesperado na autenticação:", err);
if (mounted) setIsLoadingSession(false);
}
};
initializeAuth();
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
console.log(`Auth event: ${event}`);
if (!mounted) return;
if (event === 'SIGNED_OUT') {
setUser(null);
setIsAdminView(false);
setIsProfessionalView(false);
setIsLoadingSession(false);
setCurrentView('home');
setIsCompletingProfile(false);
} else if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
if (session?.user) {
// Apenas recarrega se o usuário ainda não estiver setado ou se mudou
// Mas para garantir atualização de claims/perfil, recarregamos.
await fetchUserProfile(session.user.id, session.user.email);
}
}
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, []);
const fetchUserProfile = async (userId: string, email?: string) => {
try {
let profile = null;
// Tentativa única de buscar perfil. O cliente Supabase já trata retries de rede.
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.maybeSingle();
if (error) throw error;
profile = data;
// Se não tem perfil ou falta telefone, solicitamos completar cadastro
if (!profile || !profile.phone_e164) {
console.warn("Perfil incompleto. Solicitando dados.");
// Não fazemos signOut, apenas abrimos o modal para completar
setIsCompletingProfile(true);
setAuthMode('register'); // Visualmente irrelevante pois isCompletingProfile domina
setIsModalOpen(true);
// User fica null, então cai na Landing Page com Modal aberto. Perfeito.
return;
}
// Se perfil ok, garante que flag de completar está false
setIsCompletingProfile(false);
const { data: entitlement } = await supabase
.from('user_entitlements')
.select('*')
.eq('user_id', userId)
.maybeSingle();
let plan: 'free' | 'pro' | 'trial' = 'free';
if (entitlement) {
if (entitlement.entitlement_code === 'pro' && entitlement.is_active) plan = 'pro';
else if (entitlement.entitlement_code === 'trial' && entitlement.is_active) plan = 'trial';
}
setUser({
id: userId,
name: profile.full_name || 'Usuário',
email: email || profile.email || '',
phone: profile.phone_e164,
public_id: profile.public_id,
avatar: undefined, // Column does not exist in DB, using undefined to trigger UI fallback
is_admin: profile.is_admin,
is_professional: profile.is_professional,
plan: plan,
plan_valid_until: entitlement?.valid_until
});
// Auto-switch logic:
// If user is professional, default to Professional View.
// If user is Admin, they usually see Admin View but we might default to user dashboard for them unless they toggle.
// Logic requested: "System verifies and logs him in correct panel".
const loginIntent = localStorage.getItem('login_intent');
if (profile.is_professional) {
setIsProfessionalView(true);
} else {
setIsProfessionalView(false);
}
// Override if explicit intent was set (though we removed the button, old intents might linger, safe to ignore or keep)
if (loginIntent === 'user') {
// If they explicitly wanted user view but are pro, maybe respect it?
// For now, let's stick to the requested "System verifies" rule above.
}
} catch (error) {
console.error('Error fetching profile:', error);
setUser(null);
} finally {
setIsLoadingSession(false);
}
};
const handleOpenRegister = (plan: string = 'starter') => {
setSelectedPlan(plan);
setAuthMode('register');
setIsModalOpen(true);
setIsCompletingProfile(false);
};
const handleOpenLogin = (context?: 'user' | 'professional') => {
// If context is professional, we can store this intent to redirect after login
// For now, we'll use a simple localStorage flag or state
if (context === 'professional') {
localStorage.setItem('login_intent', 'professional');
} else {
localStorage.setItem('login_intent', 'user');
}
setAuthMode('login');
setIsModalOpen(true);
setIsCompletingProfile(false);
};
const handleAuthSuccess = async () => {
setIsModalOpen(false);
setIsCompletingProfile(false);
// Force refresh profile to ensure we have latest data
const { data: { session } } = await supabase.auth.getSession();
if (session?.user) {
await fetchUserProfile(session.user.id, session.user.email);
// Check intent
const intent = localStorage.getItem('login_intent');
if (intent === 'professional') {
// We can't know for sure if they are pro yet inside this function scope easily unless we use the user state which might be stale
// But fetchUserProfile updates 'user'.
// Ideally we wait for user state to update.
// For simplicity, let's rely on the useEffect that watches 'user' or just check 'isProfessionalView' toggle inside fetchUserProfile?
// Lets keep it simple: We just set the view if the profile allows it.
// Actually, fetchUserProfile runs, updates User.
// We can check localstorage IN fetchUserProfile.
}
localStorage.removeItem('login_intent');
}
};
const handleLogout = async () => {
await supabase.auth.signOut();
setUser(null);
setIsAdminView(false);
};
const toggleAdminView = () => {
if (user?.is_admin) {
setIsAdminView(!isAdminView);
}
};
// Helper function for navigating
const handleNavigate = (view: 'home' | 'faq') => {
setCurrentView(view);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
if (isLoadingSession) {
return (
<div className="min-h-screen flex items-center justify-center bg-white">
<Loader2 className="w-8 h-8 animate-spin text-brand-600" />
</div>
);
}
// Rota Admin
if (user && isAdminView && user.is_admin) {
return <AdminPanel user={user} onExitAdmin={toggleAdminView} onLogout={handleLogout} />;
}
// Rota Profissional
if (user && isProfessionalView) {
return <ProfessionalDashboard user={user} onExit={() => setIsProfessionalView(false)} onLogout={handleLogout} />;
}
// Rota Dashboard Usuário
if (user) {
return (
<Dashboard
user={user}
onLogout={handleLogout}
onOpenAdmin={user.is_admin ? toggleAdminView : undefined}
onOpenPro={() => setIsProfessionalView(true)}
/>
);
}
// Rota Pública (Landing Page ou FAQ Page)
return (
<div className="min-h-screen bg-white text-gray-900 font-sans selection:bg-brand-100 selection:text-brand-900">
<Header
onRegister={() => handleOpenRegister('starter')}
onLogin={handleOpenLogin}
onOpenTools={() => setIsToolsOpen(true)}
onNavigate={handleNavigate} // Passa navegação
/>
<main>
{currentView === 'home' ? (
<>
<Hero onRegister={() => handleOpenRegister('starter')} />
<CoachHighlight onRegister={() => handleOpenRegister('starter')} />
<HowItWorks />
<Features />
<Testimonials />
<Pricing onRegister={handleOpenRegister} />
<FAQ />
</>
) : (
<FAQPage onBack={() => handleNavigate('home')} />
)}
</main>
<Footer
onRegister={() => handleOpenRegister('starter')}
onNavigate={handleNavigate} // Passa navegação
/>
<RegistrationModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
plan={selectedPlan}
mode={authMode}
isCompletingProfile={isCompletingProfile} // Passa o estado de completar perfil
onSuccess={handleAuthSuccess}
/>
<CalculatorsModal
isOpen={isToolsOpen}
onClose={() => setIsToolsOpen(false)}
/>
</div>
);
};
const App: React.FC = () => {
return (
<LanguageProvider>
<AppContent />
</LanguageProvider>
);
};
export default App;

View file

@ -0,0 +1,59 @@
import React from 'react';
import { Activity, Trophy, CheckCircle2, AlertCircle } from 'lucide-react';
import { Card, Badge } from './Shared';
interface AnalysisSectionProps {
analysis: any;
}
const AnalysisSection: React.FC<AnalysisSectionProps> = ({ analysis }) => {
return (
<section className="grid md:grid-cols-2 gap-8">
<Card title="Análise Corporal" icon={<Activity className="text-brand-500" />}>
<div className="grid grid-cols-2 gap-6 mb-6">
<div className="bg-gray-50 p-4 rounded-2xl">
<p className="text-gray-400 text-xs font-bold uppercase mb-1">Gordura Estimada</p>
<p className="text-3xl font-extrabold text-gray-900">{analysis?.body_fat_percentage}%</p>
</div>
<div className="bg-gray-50 p-4 rounded-2xl">
<p className="text-gray-400 text-xs font-bold uppercase mb-1">Massa Muscular</p>
<p className="text-3xl font-extrabold text-gray-900">{analysis?.muscle_mass_level}</p>
</div>
</div>
<div>
<h4 className="font-bold text-gray-900 mb-2">Avaliação Postural</h4>
<p className="text-gray-600 leading-relaxed bg-blue-50/50 p-4 rounded-xl border border-blue-100 text-sm">
{analysis?.posture_analysis || "Nenhum desvio significativo detectado."}
</p>
</div>
</Card>
<Card title="Pontos Chave" icon={<Trophy className="text-yellow-500" />}>
<div className="space-y-6">
<div>
<h4 className="text-sm font-bold text-green-700 uppercase tracking-wider mb-3 flex items-center gap-2">
<CheckCircle2 size={16} /> Pontos Fortes
</h4>
<div className="flex flex-wrap gap-2">
{analysis?.strengths?.map((s: string, i: number) => (
<Badge key={i} text={s} color="green" />
))}
</div>
</div>
<div>
<h4 className="text-sm font-bold text-orange-700 uppercase tracking-wider mb-3 flex items-center gap-2">
<AlertCircle size={16} /> Foco Total
</h4>
<div className="flex flex-wrap gap-2">
{analysis?.weaknesses?.map((s: string, i: number) => (
<Badge key={i} text={s} color="orange" />
))}
</div>
</div>
</div>
</Card>
</section>
);
};
export default AnalysisSection;

View file

@ -0,0 +1,255 @@
import React, { useState } from 'react';
import { Dumbbell, Utensils, Activity, Loader2 } from 'lucide-react';
import { motion } from 'framer-motion';
import { KPI, Tab } from './Shared';
import AnalysisSection from './AnalysisSection';
import DietSection from './DietSection';
import WorkoutSection from './WorkoutSection';
// PDF pages
import { PdfAnalysisCompact } from './pdf/PdfAnalysisCompact';
import { PdfDietCompact } from './pdf/PdfDietCompact';
import { PdfWorkoutCompact } from './pdf/PdfWorkoutCompact';
// @ts-ignore
import { renderToStaticMarkup } from 'react-dom/server';
interface CoachResultProps {
data: any;
onReset: () => void;
}
const N8N_WEBHOOK_URL = 'https://n8n.seureview.com.br/webhook/pdf-coach';
const CoachResult: React.FC<CoachResultProps> = ({ data, onReset }) => {
const [activeTab, setActiveTab] = useState<'analysis' | 'diet' | 'workout'>('analysis');
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
if (!data) return null;
const { analysis, diet, workout, motivation_quote } = data;
const handleSavePDF = async () => {
setIsGeneratingPdf(true);
try {
// 1) Render 2 pages (Diet & Workout only - requested by user)
const pdfPages = (
<div className="pdf-root">
{/* REMOVED ANALYSIS PAGE AS REQUESTED */}
<div className="pdf-page">
<PdfDietCompact diet={data.diet} />
</div>
<div className="pdf-page">
<PdfWorkoutCompact workout={data.workout} quote={data.motivation_quote} />
</div>
</div>
);
const pagesHtml = renderToStaticMarkup(pdfPages);
// 2) Full HTML + print-lock CSS (Optimized for Gotenberg)
const fullHtml = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
50: '#f0fdfa', 100: '#ccfbf1', 200: '#99f6e4', 300: '#5eead4',
400: '#2dd4bf', 500: '#14b8a6', 600: '#0d9488', 700: '#0f766e',
800: '#115e59', 900: '#134e4a', 950: '#042f2e',
}
}
}
}
}
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700;900&display=swap');
/* ----- PRINT LOCK (A4) ----- */
@page { size: A4; margin: 0; }
html, body { margin: 0; padding: 0; }
body {
font-family: 'Inter', sans-serif;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
background: #ffffff;
}
* { box-sizing: border-box; }
/* One A4 per page - STRICT dimensions */
.pdf-page {
width: 210mm;
height: 297mm;
padding: 12mm;
overflow: hidden;
page-break-after: always;
break-after: page;
background: #fff;
display: flex;
flex-direction: column;
position: relative;
}
.pdf-page:last-child {
page-break-after: auto;
break-after: auto;
}
/* Safety for weird blocks */
.avoid-break {
break-inside: avoid;
page-break-inside: avoid;
}
</style>
</head>
<body>
${pagesHtml}
</body>
</html>`;
// 3) Send to n8n
const fileName = `FoodSnap_Titan_${new Date().toISOString().split('T')[0]}`;
const response = await fetch(N8N_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html: fullHtml, file_name: fileName }),
});
if (!response.ok) throw new Error(`Erro n8n: ${response.status} ${response.statusText}`);
// 4) Download PDF
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${fileName}.pdf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (err) {
console.error('PDF Generation Server Error:', err);
alert('Erro ao gerar PDF no servidor. Verifique se o Webhook do n8n está configurado.');
} finally {
setIsGeneratingPdf(false);
}
};
return (
<div className="max-w-5xl mx-auto animate-in fade-in slide-in-from-bottom-8 duration-700 pb-12">
{/* Premium Header */}
<div className="bg-white rounded-[2rem] p-6 md:p-8 mb-8 shadow-xl relative overflow-hidden text-gray-900 border border-gray-100">
<div className="absolute top-0 right-0 w-96 h-96 bg-brand-50 rounded-full blur-[100px] opacity-60 -translate-y-1/2 translate-x-1/3"></div>
<div className="absolute bottom-0 left-0 w-80 h-80 bg-blue-50 rounded-full blur-[80px] opacity-60 translate-y-1/3 -translate-x-1/3"></div>
<div className="relative z-10 flex flex-col md:flex-row justify-between items-start gap-6">
<div>
<div className="flex items-center gap-3 mb-3">
<span className="px-2.5 py-0.5 bg-brand-50 border border-brand-100 rounded-full text-[10px] font-bold uppercase tracking-widest text-brand-700">
Protocolo Titan
</span>
<span className="text-gray-400 text-xs font-mono">{new Date().toLocaleDateString()}</span>
</div>
<h1 className="text-3xl md:text-4xl font-extrabold tracking-tight mb-3 leading-tight">
Seu Blueprint <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-600 to-emerald-600">
De Transformação
</span>
</h1>
<p className="text-gray-500 text-base max-w-xl italic font-light border-l-2 border-brand-200 pl-4">
"{motivation_quote || 'Disciplina é a ponte entre metas e conquistas.'}"
</p>
</div>
<div className="flex flex-col gap-2 min-w-[180px]">
<button
onClick={onReset}
className="px-5 py-2.5 bg-gray-50 hover:bg-gray-100 border border-gray-200 rounded-xl text-xs font-bold text-gray-700 transition-all hover:scale-105 active:scale-95 text-center"
>
Gerar Novo
</button>
<button
onClick={handleSavePDF}
disabled={isGeneratingPdf}
className="px-5 py-2.5 bg-brand-600 hover:bg-brand-500 text-white rounded-xl text-xs font-bold shadow-lg shadow-brand-500/30 transition-all hover:scale-105 active:scale-95 text-center flex items-center justify-center gap-2 disabled:opacity-70"
>
{isGeneratingPdf ? <Loader2 size={16} className="animate-spin" /> : 'Baixar PDF (3 páginas)'}
</button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-10">
<KPI label="Biótipo" value={analysis?.somatotype} />
<KPI label="Objetivo" value={workout?.focus} />
<KPI label="Calorias" value={`${Math.round(diet?.total_calories || 0)} kcal`} />
<KPI label="Estrutura" value={workout?.split} />
</div>
</div>
{/* Tabs */}
<div className="flex justify-center mb-10 sticky top-4 z-40">
<div className="bg-white/80 backdrop-blur-md p-1.5 rounded-2xl shadow-lg border border-gray-100 flex gap-1 overflow-x-auto max-w-full">
<Tab
active={activeTab === 'analysis'}
onClick={() => setActiveTab('analysis')}
icon={<Activity size={18} />}
label="Diagnóstico"
/>
<Tab
active={activeTab === 'diet'}
onClick={() => setActiveTab('diet')}
icon={<Utensils size={18} />}
label="Nutrição"
/>
<Tab
active={activeTab === 'workout'}
onClick={() => setActiveTab('workout')}
icon={<Dumbbell size={18} />}
label="Treinamento"
/>
</div>
</div>
{/* Rich UI */}
<div className="min-h-[600px]">
{activeTab === 'analysis' && (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="space-y-8">
<AnalysisSection analysis={analysis} />
</motion.div>
)}
{activeTab === 'diet' && (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="space-y-8">
<DietSection diet={diet} />
</motion.div>
)}
{activeTab === 'workout' && (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="space-y-8">
<WorkoutSection workout={workout} />
</motion.div>
)}
</div>
</div>
);
};
export default CoachResult;

View file

@ -0,0 +1,405 @@
import React, { useState, useRef, useEffect } from 'react';
import { Camera, Upload, X, ChevronRight, Check, AlertCircle, Loader2, Dumbbell, Apple, Activity, Image as ImageIcon } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { supabase } from '@/lib/supabase';
import { useLanguage } from '@/contexts/LanguageContext';
interface CoachWizardProps {
isOpen: boolean;
onClose: () => void;
onComplete: (data: any) => void;
}
type Step = 'photos' | 'goal' | 'processing';
const CoachWizard: React.FC<CoachWizardProps> = ({ isOpen, onClose, onComplete }) => {
const { t } = useLanguage();
const [step, setStep] = useState<Step>('photos');
const [photos, setPhotos] = useState<{ front?: string, side?: string, back?: string }>({});
const [goal, setGoal] = useState<string>('');
const [loading, setLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [loadingMsgIndex, setLoadingMsgIndex] = useState(0);
const loadingMessages = t.coach.processing.steps;
useEffect(() => {
let interval: any;
if (step === 'processing' && !errorMessage) {
interval = setInterval(() => {
setLoadingMsgIndex(prev => (prev + 1) % loadingMessages.length);
}, 3000);
}
return () => clearInterval(interval);
}, [step, errorMessage]);
// Refs for different input types
const fileInputRef = useRef<HTMLInputElement>(null);
const cameraInputRef = useRef<HTMLInputElement>(null);
const [activePhotoField, setActivePhotoField] = useState<'front' | 'side' | 'back' | null>(null);
if (!isOpen) return null;
// --- Image Processing Helper (Resize & Compress) ---
const processImage = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
const MAX_WIDTH = 1024; // Resize to max 1024px width for AI/Backend limit
const scaleSize = MAX_WIDTH / img.width;
const width = (scaleSize < 1) ? MAX_WIDTH : img.width;
const height = (scaleSize < 1) ? img.height * scaleSize : img.height;
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
// Compress to JPEG 0.7 quality
const compressedDataUrl = canvas.toDataURL('image/jpeg', 0.7);
resolve(compressedDataUrl);
};
img.onerror = (err) => reject(err);
};
reader.onerror = (err) => reject(err);
});
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0] && activePhotoField) {
const file = e.target.files[0];
try {
setLoading(true); // Show momentary loading for compression
const compressedImage = await processImage(file);
setPhotos(prev => ({ ...prev, [activePhotoField]: compressedImage }));
setActivePhotoField(null);
} catch (error) {
console.error("Error processing image:", error);
alert("Erro ao processar a imagem. Tente outra.");
} finally {
setLoading(false);
// Reset inputs to allow selecting same file again if needed
if (fileInputRef.current) fileInputRef.current.value = "";
if (cameraInputRef.current) cameraInputRef.current.value = "";
}
}
};
const triggerUpload = (field: 'front' | 'side' | 'back', source: 'gallery' | 'camera') => {
setActivePhotoField(field);
if (source === 'gallery') {
setTimeout(() => fileInputRef.current?.click(), 0);
} else {
setTimeout(() => cameraInputRef.current?.click(), 0);
}
};
const handleNext = () => {
if (step === 'photos') {
if (photos.front && photos.side && photos.back) setStep('goal');
else alert("Por favor, adicione as 3 fotos (Frente, Perfil, Costas) para garantir a precisão da análise.");
} else if (step === 'goal') {
if (goal) startProcessing();
}
};
const startProcessing = async () => {
setStep('processing');
setLoading(true);
setErrorMessage(null);
try {
// Create a timeout promise that rejects after 55 seconds
const timeoutPromise = new Promise((_, reject) => {
const id = setTimeout(() => {
clearTimeout(id);
reject(new Error(t.coach.processing.wait));
}, 55000); // 55s strict timeout
});
// Race between the API call and the timeout
const response: any = await Promise.race([
supabase.functions.invoke('coach-generator', {
body: { photos, goal, intent: 'coach' }
}),
timeoutPromise
]);
// If we get here, it means the API responded before timeout
const { data, error } = response;
if (error) {
console.error("Supabase Invoke Error:", error);
// Tenta extrair a mensagem de erro real do backend se existir
let errorMsg = "Falha na comunicação com a IA.";
if (error && typeof error === 'object') {
// Supabase functions usually return { context: ..., error: { message: "..." } } or just error
if ('message' in error) errorMsg = (error as any).message;
else errorMsg = JSON.stringify(error);
}
throw new Error(errorMsg);
}
if (!data) {
throw new Error("Nenhuma resposta recebida da IA.");
}
console.log("Coach Result:", data);
// Validate essential data presence
if (!data.analysis || !data.diet || !data.workout) {
throw new Error("A resposta da IA veio incompleta. Tente com fotos mais claras.");
}
onComplete(data);
onClose();
} catch (err: any) {
console.error("Coach Logic Error:", err);
let message = "Erro ao gerar protocolo. Verifique sua conexão e tente novamente.";
if (err.name === 'AbortError') message = "O servidor demorou muito para responder. Tente fotos menores.";
if (err.message) message = err.message;
setErrorMessage(message);
// Don't auto-close, let user see error and retry
} finally {
setLoading(false);
// If error, stay on processing step or go back?
// Better to show error on processing screen with a "Retry" button or "Back"
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-md">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="bg-white rounded-3xl w-full max-w-2xl overflow-hidden shadow-2xl flex flex-col max-h-[90vh] border border-white/20 ring-1 ring-black/5"
>
{/* Header */}
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50">
<div>
<h2 className="text-xl font-bold text-gray-900 flex items-center gap-2">
<Activity className="text-brand-600" />
{t.coach.title}
</h2>
<p className="text-sm text-gray-500">{t.coach.subtitle}</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
<X size={20} className="text-gray-500" />
</button>
</div>
{/* Content */}
<div className="p-8 overflow-y-auto flex-1">
<AnimatePresence mode="wait">
{step === 'photos' && (
<motion.div
key="photos"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
<div className="bg-blue-50/50 text-blue-800 p-4 rounded-2xl text-sm flex gap-3 items-start border border-blue-100/50">
<AlertCircle className="shrink-0 mt-0.5" size={18} />
<p>
<strong>{t.coach.photosStep.alert.split(':')[0]}:</strong> {t.coach.photosStep.alert.split(':')[1]}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{['front', 'side', 'back'].map((side) => (
<div key={side} className="flex flex-col gap-2">
<p className="font-bold text-gray-700 capitalize text-center text-sm tracking-wide">
{side === 'front' ? t.coach.photosStep.front : side === 'side' ? t.coach.photosStep.side : t.coach.photosStep.back}
</p>
<div className={`aspect-[3/4] rounded-2xl border-2 border-dashed flex flex-col items-center justify-center relative overflow-hidden group transition-all duration-300
${photos[side as keyof typeof photos]
? 'border-brand-500 bg-gray-50'
: 'border-gray-200 hover:border-brand-300 hover:bg-gray-50 hover:shadow-lg'
}
`}>
{photos[side as keyof typeof photos] ? (
<>
<img src={photos[side as keyof typeof photos]} className="absolute inset-0 w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px] opacity-0 group-hover:opacity-100 transition-all flex flex-col items-center justify-center gap-3 p-4">
<button
onClick={() => triggerUpload(side as any, 'camera')}
className="bg-white text-gray-900 text-xs font-bold py-2.5 px-4 rounded-xl flex items-center gap-2 w-full justify-center hover:bg-brand-50 transition-colors shadow-lg"
>
<Camera size={14} /> {t.coach.photosStep.camera}
</button>
<button
onClick={() => triggerUpload(side as any, 'gallery')}
className="bg-gray-900 text-white text-xs font-bold py-2.5 px-4 rounded-xl flex items-center gap-2 w-full justify-center hover:bg-black transition-colors shadow-lg"
>
<ImageIcon size={14} /> {t.coach.photosStep.gallery}
</button>
</div>
<div className="absolute top-2 right-2 bg-green-500 text-white p-1.5 rounded-full shadow-lg animate-in zoom-in">
<Check size={12} strokeWidth={4} />
</div>
</>
) : (
<div className="flex flex-col items-center gap-4 w-full px-4 text-center">
<span className="text-gray-300 group-hover:text-brand-300 transition-colors"><Camera size={32} /></span>
<div className="flex flex-col gap-2 w-full translate-y-0 transition-all duration-300">
<button
onClick={() => triggerUpload(side as any, 'camera')}
className="bg-brand-600 text-white text-xs font-bold py-2 rounded-lg flex items-center justify-center gap-2 hover:bg-brand-700 w-full shadow-sm"
>
{t.coach.photosStep.camera}
</button>
<button
onClick={() => triggerUpload(side as any, 'gallery')}
className="bg-white border border-gray-200 text-gray-700 text-xs font-bold py-2 rounded-lg flex items-center justify-center gap-2 hover:bg-gray-50 w-full"
>
{t.coach.photosStep.gallery}
</button>
</div>
{/* Helper text removed as buttons are visible */}
</div>
)}
</div>
</div>
))}
</div>
</motion.div>
)}
{step === 'goal' && (
<motion.div
key="goal"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
<h3 className="text-lg font-bold text-gray-900 text-center mb-6">{t.coach.goalStep.title}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[
{ id: 'hypertrophy', icon: <Dumbbell />, title: t.coach.goalStep.hypertrophy.title, desc: t.coach.goalStep.hypertrophy.desc },
{ id: 'definition', icon: <Activity />, title: t.coach.goalStep.definition.title, desc: t.coach.goalStep.definition.desc },
{ id: 'maintenance', icon: <Apple />, title: t.coach.goalStep.maintenance.title, desc: t.coach.goalStep.maintenance.desc },
{ id: 'strength', icon: <Dumbbell />, title: t.coach.goalStep.strength.title, desc: t.coach.goalStep.strength.desc }
].map((opt) => (
<button
key={opt.id}
onClick={() => setGoal(opt.id)}
className={`p-6 rounded-xl border-2 text-left transition-all ${goal === opt.id
? 'border-brand-500 bg-brand-50 shadow-md ring-1 ring-brand-500'
: 'border-gray-200 hover:border-brand-200 hover:bg-gray-50'
}`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center mb-3 ${goal === opt.id ? 'bg-brand-500 text-white' : 'bg-white border border-gray-200 text-gray-600'}`}>
{opt.icon}
</div>
<h4 className="font-bold text-gray-900">{opt.title}</h4>
<p className="text-sm text-gray-500 mt-1">{opt.desc}</p>
</button>
))}
</div>
</motion.div>
)}
{step === 'processing' && (
<motion.div
key="processing"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center justify-center py-12 text-center"
>
{errorMessage ? (
<div className="flex flex-col items-center animate-in fade-in zoom-in duration-300">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mb-4 text-red-600">
<AlertCircle size={40} />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">{t.coach.processing.errorTitle}</h3>
<p className="text-gray-500 max-w-sm mb-6">{errorMessage}</p>
<button
onClick={() => setStep('goal')}
className="bg-gray-900 text-white px-6 py-2 rounded-xl font-bold hover:bg-black transition-colors"
>
{t.coach.processing.retry}
</button>
</div>
) : (
<>
<div className="relative mb-8">
<div className="w-24 h-24 border-4 border-gray-100 rounded-full"></div>
<div className="w-24 h-24 border-4 border-brand-600 rounded-full border-t-transparent animate-spin absolute top-0 left-0"></div>
<Activity className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-brand-600" size={32} />
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-2 min-h-[40px] flex items-center justify-center">
{loadingMessages[loadingMsgIndex]}
</h3>
<p className="text-gray-500 max-w-md animate-pulse">
{t.coach.processing.wait}
</p>
</>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Footer */}
{step !== 'processing' && (
<div className="p-6 border-t border-gray-100 bg-gray-50 flex justify-end gap-3">
{step === 'goal' && (
<button onClick={() => setStep('photos')} className="px-6 py-2.5 text-gray-600 font-medium hover:bg-gray-200 rounded-xl transition-colors">
{t.coach.buttons.back}
</button>
)}
<button
onClick={handleNext}
className={`px-8 py-2.5 rounded-xl font-bold flex items-center gap-2 transition-all shadow-lg shadow-brand-500/20
${(step === 'photos' && (!photos.front || !photos.side || !photos.back)) || (step === 'goal' && !goal)
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-brand-600 text-white hover:bg-brand-700 hover:-translate-y-0.5'
}
`}
disabled={(step === 'photos' && (!photos.front || !photos.side || !photos.back)) || (step === 'goal' && !goal)}
>
{step === 'goal' ? t.coach.buttons.generate : t.coach.buttons.next}
{step !== 'goal' && <ChevronRight size={18} />}
</button>
</div>
)}
</motion.div>
{/* Hidden Inputs */}
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileChange}
/>
<input
type="file"
ref={cameraInputRef}
className="hidden"
accept="image/*"
capture="environment"
onChange={handleFileChange}
/>
</div>
);
};
export default CoachWizard;

View file

@ -0,0 +1,115 @@
import React from 'react';
import { Plus, Trash2, Edit2, CheckCircle2, AlertCircle, Droplets, Apple, Clock, Pill } from 'lucide-react';
import { MacroCard } from './Shared';
interface DietSectionProps {
diet: any;
}
const DietSection: React.FC<DietSectionProps> = ({ diet }) => {
return (
<div className="space-y-8">
{/* Macros & Hydration */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<MacroCard label="Proteína" value={`${diet?.macros?.protein_g} g`} color="brand" />
<MacroCard label="Carboidratos" value={`${diet?.macros?.carbs_g} g`} color="blue" />
<MacroCard label="Gorduras" value={`${diet?.macros?.fats_g} g`} color="yellow" />
<div className="bg-white p-6 rounded-3xl border border-gray-100 shadow-sm flex flex-col items-center justify-center relative overflow-hidden">
<div className="absolute inset-0 bg-blue-500/5 z-0"></div>
<Droplets className="text-blue-500 mb-2 relative z-10" />
<span className="text-2xl font-black text-blue-900 relative z-10">{diet?.hydration_liters}L</span>
<span className="text-xs font-bold uppercase text-blue-400 relative z-10">Água/Dia</span>
</div>
</div>
<div className="grid md:grid-cols-3 gap-8">
{/* Meal Plan List */}
<div className="md:col-span-2 space-y-6">
<h3 className="text-2xl font-bold text-gray-900">Plano Alimentar</h3>
<div className="space-y-4">
{diet?.meal_plan_example?.map((meal: any, i: number) => (
<div key={i} className="bg-white p-6 rounded-3xl border border-gray-100 shadow-sm hover:shadow-md transition-shadow group break-inside-avoid">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center font-bold text-gray-500 group-hover:bg-brand-100 group-hover:text-brand-600 transition-colors flex-shrink-0">
{i + 1}
</div>
<div>
<h4 className="font-bold text-lg text-gray-900 leading-tight">{meal.name}</h4>
{meal.time_range && (
<p className="text-sm text-brand-500 font-medium flex items-center gap-1 mt-0.5">
<Clock size={12} /> {meal.time_range}
</p>
)}
</div>
</div>
<div className="pl-16 space-y-4">
{/* New Format: Multiple Options */}
{meal.options && Array.isArray(meal.options) ? (
<div className="space-y-2">
{meal.options.map((opt: string, idx: number) => (
<div key={idx} className="p-3 bg-gray-50 rounded-xl border border-gray-100">
<p className="text-gray-800 font-medium text-sm">{opt}</p>
</div>
))}
</div>
) : (
// Legacy Fallback
<div className="p-3 bg-gray-50 rounded-xl border border-gray-100">
<p className="text-gray-800 font-medium">
{meal.main_option || (meal.options && meal.options[0]) || "Opção Padrão"}
</p>
</div>
)}
{/* Substitution Suggestion */}
{(meal.substitution_suggestion || meal.substitution) && (
<div className="p-3 bg-green-50/50 border border-green-100 rounded-xl flex gap-3 items-start">
<div className="mt-0.5 text-green-600 bg-white rounded-full p-0.5 shadow-sm">
<CheckCircle2 size={12} strokeWidth={3} />
</div>
<div>
<p className="text-xs font-bold text-green-700 uppercase mb-0.5">Dica de Substituição</p>
<p className="text-gray-600 text-xs leading-relaxed">
{meal.substitution_suggestion || meal.substitution}
</p>
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
{/* Supplements */}
<div className="space-y-6">
<h3 className="text-2xl font-bold text-gray-900">Suplementação</h3>
<div className="bg-gray-900 text-white p-6 rounded-3xl shadow-xl relative overflow-hidden break-inside-avoid">
<div className="absolute top-0 right-0 w-32 h-32 bg-brand-500 rounded-full blur-[60px] opacity-20"></div>
<div className="relative z-10 space-y-6">
{diet?.supplements?.map((sup: any, i: number) => {
// Handle complex object or simple string
const name = typeof sup === 'string' ? sup : sup.name;
const dosage = typeof sup === 'string' ? '' : sup.dosage;
const reason = typeof sup === 'string' ? '' : sup.reason;
return (
<div key={i} className="border-l-2 border-brand-500 pl-4 py-1">
<h5 className="font-bold text-lg mb-1 flex items-center gap-2">
<Pill size={16} className="text-brand-400" /> {name}
</h5>
{dosage && <p className="text-sm text-gray-300 mb-0.5">{dosage}</p>}
{reason && <p className="text-xs text-gray-500 italic">{reason}</p>}
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
);
};
export default DietSection;

View file

@ -0,0 +1,62 @@
import React from 'react';
export const KPI = ({ label, value }: any) => (
<div className="bg-gray-50 border border-gray-100 p-4 rounded-2xl">
<p className="text-gray-500 text-[10px] font-bold uppercase tracking-wider mb-1">{label}</p>
<p className="text-xl font-bold truncate text-gray-900">{value || '-'}</p>
</div>
);
export const Tab = ({ active, onClick, icon, label }: any) => (
<button
onClick={onClick}
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl font-bold text-sm transition-all whitespace-nowrap
${active
? 'bg-gray-900 text-white shadow-md'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-900'
}
`}
>
{icon}
{label}
</button>
);
export const Card = ({ title, icon, children, className = "" }: any) => (
<div className={`bg-white p-6 md:p-8 rounded-[2.5rem] border border-gray-100 shadow-sm h-full ${className}`}>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-gray-50 flex items-center justify-center">
{icon}
</div>
<h3 className="text-xl font-bold text-gray-900">{title}</h3>
</div>
{children}
</div>
);
export const Badge = ({ text, color }: any) => {
const styles = color === 'green'
? 'bg-emerald-50 text-emerald-700 border-emerald-100'
: 'bg-orange-50 text-orange-700 border-orange-100';
return (
<span className={`px-3 py-1.5 rounded-xl text-xs font-bold border ${styles}`}>
{text}
</span>
);
};
export const MacroCard = ({ label, value, color }: any) => {
const colors: any = {
brand: 'bg-brand-50 text-brand-900 border-brand-100',
blue: 'bg-blue-50 text-blue-900 border-blue-100',
yellow: 'bg-yellow-50 text-yellow-900 border-yellow-100'
};
return (
<div className={`p-6 rounded-3xl border ${colors[color]} flex flex-col items-center justify-center text-center shadow-sm`}>
<span className="text-xs font-bold uppercase opacity-60 mb-1">{label}</span>
<span className="text-3xl font-black">{value}</span>
</div>
);
};

View file

@ -0,0 +1,100 @@
import React, { useState } from 'react';
import { Calendar, Activity, ChevronUp, ChevronDown, Dumbbell } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
interface WorkoutSectionProps {
workout: any;
}
const WorkoutSection: React.FC<WorkoutSectionProps> = ({ workout }) => {
const [openInjury, setOpenInjury] = useState(false);
return (
<div className="space-y-8">
{/* Workout Header */}
<div className="flex flex-col md:flex-row gap-6 mb-8">
<div className="flex-1 bg-white p-8 rounded-[2rem] border border-gray-100 shadow-sm flex items-center justify-between">
<div>
<h3 className="text-gray-500 font-bold uppercase text-xs tracking-wider mb-2">Estrutura de Treino</h3>
<p className="text-4xl font-black text-gray-900">{workout?.split}</p>
<p className="text-brand-600 font-medium">{workout?.frequency_days} dias na semana</p>
</div>
<div className="w-16 h-16 bg-gray-50 rounded-2xl flex items-center justify-center">
<Calendar className="text-gray-900" size={32} />
</div>
</div>
{/* Injury Adaptations Accordion - Only show if data exists */}
{workout?.injury_adaptations && (
<div className="flex-1 bg-red-50 p-6 rounded-[2rem] border border-red-100 cursor-pointer hover:bg-red-100/80 transition-colors" onClick={() => setOpenInjury(!openInjury)}>
<div className="flex justify-between items-center mb-2">
<h4 className="font-bold text-red-900 flex items-center gap-2">
<Activity size={20} /> Adaptações para Dores?
</h4>
{openInjury ? <ChevronUp className="text-red-700" /> : <ChevronDown className="text-red-700" />}
</div>
<p className="text-red-700/70 text-sm mb-4">Clique para ver exercícios alternativos.</p>
<AnimatePresence>
{openInjury && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
<div className="space-y-3 pt-2 border-t border-red-200">
{Object.entries(workout.injury_adaptations).map(([key, val]: any) => (
<div key={key}>
<span className="text-xs font-bold uppercase text-red-800 block mb-1">
{key === 'knee_pain' ? 'Dor no Joelho' : key === 'shoulder_pain' ? 'Dor no Ombro' : 'Dor nas Costas'}
</span>
<p className="text-sm text-red-900 font-medium bg-white/50 p-2 rounded-lg">{val}</p>
</div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
</div>
{/* Routine Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{workout?.routine?.map((day: any, i: number) => (
<div key={i} className="bg-white rounded-[2rem] p-6 border border-gray-100 shadow-sm hover:shadow-lg transition-all group break-inside-avoid">
<div className="flex justify-between items-start mb-6">
<div>
<span className="inline-block px-3 py-1 bg-gray-100 text-gray-600 rounded-lg text-xs font-bold uppercase tracking-wider mb-2">
{day.day}
</span>
<h4 className="text-xl font-bold text-gray-900">{day.muscle_group}</h4>
</div>
<div className="w-10 h-10 rounded-full border border-gray-100 flex items-center justify-center">
<Dumbbell size={18} className="text-gray-400 group-hover:text-brand-500 transition-colors" />
</div>
</div>
<div className="space-y-3">
{day.exercises?.map((ex: any, idx: number) => (
<div key={idx} className="p-3 rounded-xl bg-gray-50 hover:bg-brand-50/50 transition-colors border border-transparent hover:border-brand-100">
<div className="flex justify-between items-center mb-1">
<p className="font-bold text-gray-900 text-sm">{ex.name}</p>
<div className="flex gap-2 text-xs font-mono text-gray-500">
<span className="font-bold text-brand-700">{ex.sets}x</span>
<span>{ex.reps}</span>
</div>
</div>
{ex.technique && <p className="text-xs text-gray-400 line-clamp-1">{ex.technique}</p>}
</div>
))}
</div>
</div>
))}
</div>
</div>
);
};
export default WorkoutSection;

View file

@ -0,0 +1,102 @@
import React from 'react';
import { Activity } from 'lucide-react';
import { PdfHeaderRow, safeStr, asArray } from './PdfShared';
export const PdfAnalysisCompact: React.FC<{ data: any }> = ({ data }) => {
const a = data?.analysis || {};
const d = data?.diet || {};
const w = data?.workout || {};
const bullets = asArray(
a?.improvements ||
a?.what_to_improve ||
a?.improve ||
a?.recommendations ||
a?.tips ||
a?.notes ||
a?.observations ||
[]
)
.map((x: any) => (typeof x === 'string' ? x : safeStr(x?.text, '')))
.filter(Boolean)
.slice(0, 8);
const positives = asArray(a?.strengths || a?.positives || a?.good_points || a?.pontos_fortes || [])
.map((x: any) => (typeof x === 'string' ? x : safeStr(x?.text, '')))
.filter(Boolean)
.slice(0, 6);
return (
<div className="h-full">
<PdfHeaderRow
index="01"
title="Resumo & Diagnóstico"
subtitle="Resumo das fotos, pontos fortes e o que melhorar (compacto)"
icon={<Activity size={42} />}
/>
<div className="grid grid-cols-4 gap-2 mb-3">
<div className="rounded-xl border border-gray-200 p-2">
<div className="text-[10px] uppercase tracking-widest text-gray-400 font-semibold">Biótipo</div>
<div className="text-[12px] font-bold text-gray-900">{safeStr(a?.somatotype)}</div>
</div>
<div className="rounded-xl border border-gray-200 p-2">
<div className="text-[10px] uppercase tracking-widest text-gray-400 font-semibold">Objetivo</div>
<div className="text-[12px] font-bold text-gray-900">{safeStr(w?.focus)}</div>
</div>
<div className="rounded-xl border border-gray-200 p-2">
<div className="text-[10px] uppercase tracking-widest text-gray-400 font-semibold">Calorias</div>
<div className="text-[12px] font-bold text-gray-900">
{Math.round(d?.total_calories || 0)} kcal
</div>
</div>
<div className="rounded-xl border border-gray-200 p-2">
<div className="text-[10px] uppercase tracking-widest text-gray-400 font-semibold">Estrutura</div>
<div className="text-[12px] font-bold text-gray-900">{safeStr(w?.split)}</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-gray-200 p-3">
<div className="text-[11px] font-black text-gray-900 mb-2">Pontos fortes</div>
{positives.length ? (
<ul className="list-disc pl-4 space-y-1 text-[11px] leading-snug text-gray-700">
{positives.map((t: string, i: number) => (
<li key={i}>{t}</li>
))}
</ul>
) : (
<p className="text-[11px] text-gray-600">
{safeStr(a?.summary || a?.overview || a?.diagnosis || a?.diagnostico, 'Sem detalhes extras.')}
</p>
)}
</div>
<div className="rounded-2xl border border-gray-200 p-3">
<div className="text-[11px] font-black text-gray-900 mb-2">O que melhorar</div>
{bullets.length ? (
<ul className="list-disc pl-4 space-y-1 text-[11px] leading-snug text-gray-700">
{bullets.map((t: string, i: number) => (
<li key={i}>{t}</li>
))}
</ul>
) : (
<p className="text-[11px] text-gray-600">
{safeStr(a?.improvement_summary || a?.next_steps || a?.proximos_passos, 'Sem detalhes extras.')}
</p>
)}
</div>
</div>
<div className="mt-3 rounded-2xl border border-gray-200 p-3">
<div className="text-[11px] font-black text-gray-900 mb-1">Notas rápidas</div>
<p className="text-[11px] leading-snug text-gray-700">
{safeStr(
a?.final_note || a?.note || a?.observacao_final || a?.closing,
'Consistência diária > perfeição. Foque em execução e acompanhamento.'
)}
</p>
</div>
</div>
);
};

View file

@ -0,0 +1,181 @@
import React, { useMemo } from 'react';
import { Utensils, Droplets, Pill } from 'lucide-react';
import { PdfHeaderRow, safeStr, asArray } from './PdfShared';
function truncate(text: string, max = 140) {
const t = (text || '').trim();
if (!t) return '-';
return t.length > max ? t.slice(0, max - 1) + '…' : t;
}
function pickMeals(diet: any): any[] {
// ✅ match do frontend
if (Array.isArray(diet?.meal_plan_example) && diet.meal_plan_example.length) return diet.meal_plan_example;
// fallback antigos
const candidates = [
diet?.meals,
diet?.meal_plan,
diet?.plan,
diet?.daily_plan,
diet?.diet_plan,
diet?.meals_plan,
diet?.mealsPlan,
diet?.refeicoes,
diet?.refeicoes_plano,
];
for (const c of candidates) if (Array.isArray(c) && c.length) return c;
return [];
}
export const PdfDietCompact: React.FC<{ diet: any }> = ({ diet }) => {
const meals = useMemo(() => pickMeals(diet).slice(0, 6), [diet]); // 6 max pra caber 1 página
const supplements = asArray(diet?.supplements).slice(0, 6);
const protein = diet?.macros?.protein_g ?? diet?.protein_g ?? diet?.protein ?? diet?.protein_grams;
const carbs = diet?.macros?.carbs_g ?? diet?.carbs_g ?? diet?.carbs ?? diet?.carb_grams;
const fats = diet?.macros?.fats_g ?? diet?.fat_g ?? diet?.fat ?? diet?.fat_grams;
const water = diet?.hydration_liters ?? diet?.water_liters ?? diet?.hydration;
return (
<div className="h-full flex flex-col">
<PdfHeaderRow
index="02"
title="Dieta"
subtitle="Plano alimentar + suplementação (compacto em 1 página)"
icon={<Utensils size={42} />}
/>
{/* Summary row */}
<div className="rounded-2xl border border-gray-200 p-2 mb-2 avoid-break">
<div className="grid grid-cols-5 gap-2">
<div className="col-span-2">
<div className="text-[10px] uppercase tracking-widest text-gray-400 font-semibold">Calorias/dia</div>
<div className="text-[12px] font-bold">{Math.round(diet?.total_calories || 0)} kcal</div>
</div>
<div>
<div className="text-[10px] uppercase tracking-widest text-gray-400 font-semibold">Proteína</div>
<div className="text-[12px] font-bold">{safeStr(protein)}</div>
</div>
<div>
<div className="text-[10px] uppercase tracking-widest text-gray-400 font-semibold">Carbo</div>
<div className="text-[12px] font-bold">{safeStr(carbs)}</div>
</div>
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-[10px] uppercase tracking-widest text-gray-400 font-semibold">Gordura</div>
<div className="text-[12px] font-bold">{safeStr(fats)}</div>
</div>
<div className="flex items-center gap-1 text-gray-700">
<Droplets size={14} className="text-blue-500" />
<div className="text-[11px] font-bold text-blue-900">{safeStr(water, '-')}{String(water || '').includes('L') ? '' : 'L'}</div>
</div>
</div>
</div>
</div>
{/* Main grid: meals + supplements */}
<div className="grid grid-cols-3 gap-3 flex-1 min-h-0">
{/* Meals (2 cols) */}
<div className="col-span-2 space-y-2 min-h-0">
<div className="text-[11px] font-black text-gray-900">Plano Alimentar</div>
<div className="space-y-2">
{meals.length ? (
meals.map((meal: any, i: number) => {
const options = Array.isArray(meal?.options) ? meal.options : [];
const opt1 = options?.[0] || meal?.main_option || '';
const opt2 = options?.[1] || '';
return (
<div key={i} className="rounded-2xl border border-gray-200 p-2 avoid-break">
<div className="flex items-start justify-between gap-2">
<div>
<div className="text-[11px] font-extrabold text-gray-900 leading-snug">
{safeStr(meal?.name, `Refeição ${i + 1}`)}
</div>
{meal?.time_range && (
<div className="text-[10px] text-brand-700 font-semibold">
{safeStr(meal.time_range)}
</div>
)}
</div>
<div className="text-[10px] text-gray-400 font-bold">#{i + 1}</div>
</div>
<div className="mt-1 space-y-1">
{opt1 ? (
<div className="text-[10px] leading-snug text-gray-800 bg-gray-50 border border-gray-100 rounded-xl p-2">
<span className="font-bold text-gray-700">Opção 1: </span>
{truncate(String(opt1), 160)}
</div>
) : null}
{opt2 ? (
<div className="text-[10px] leading-snug text-gray-800 bg-gray-50 border border-gray-100 rounded-xl p-2">
<span className="font-bold text-gray-700">Opção 2: </span>
{truncate(String(opt2), 160)}
</div>
) : null}
{(meal?.substitution_suggestion || meal?.substitution) ? (
<div className="text-[10px] leading-snug text-green-900 bg-green-50/70 border border-green-100 rounded-xl p-2">
<span className="font-bold uppercase text-[9px] text-green-800">Dica de substituição:</span>{' '}
{truncate(String(meal?.substitution_suggestion || meal?.substitution), 180)}
</div>
) : null}
</div>
</div>
);
})
) : (
<div className="rounded-2xl border border-gray-200 p-3 text-[11px] text-gray-700">
Não achei <code>diet.meal_plan_example</code>. Se teu JSON mudou, me manda 1 exemplo do <code>diet</code>.
</div>
)}
</div>
</div>
{/* Supplements (1 col) */}
<div className="col-span-1 min-h-0 flex flex-col">
<div className="text-[11px] font-black text-gray-900 mb-2">Suplementação</div>
<div className="bg-gray-900 text-white rounded-3xl p-3 flex-1 min-h-0 overflow-hidden avoid-break">
<div className="space-y-3">
{supplements.length ? (
supplements.map((sup: any, i: number) => {
const name = typeof sup === 'string' ? sup : sup?.name;
const dosage = typeof sup === 'string' ? '' : sup?.dosage;
const reason = typeof sup === 'string' ? '' : sup?.reason;
return (
<div key={i} className="border-l-2 border-brand-500 pl-3">
<div className="flex items-center gap-2">
<Pill size={14} className="text-brand-300" />
<div className="text-[11px] font-bold leading-snug">{truncate(String(name || 'Suplemento'), 40)}</div>
</div>
{dosage ? <div className="text-[10px] text-gray-200">{truncate(String(dosage), 60)}</div> : null}
{reason ? <div className="text-[9px] text-gray-400 italic">{truncate(String(reason), 80)}</div> : null}
</div>
);
})
) : (
<div className="text-[10px] text-gray-300">
Sem suplementos informados.
</div>
)}
</div>
</div>
<div className="mt-2 text-[10px] text-gray-500 leading-snug">
Dica: água + consistência diária. Ajustes finos semanais.
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,34 @@
import React from 'react';
export function safeStr(v: any, fallback = '-'): string {
if (v === null || v === undefined) return fallback;
if (typeof v === 'string') return v.trim() || fallback;
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : fallback;
return fallback;
}
export function asArray(x: any): any[] {
return Array.isArray(x) ? x : [];
}
export const PdfHeaderRow: React.FC<{
index: string;
title: string;
subtitle: string;
icon: React.ReactNode;
}> = ({ index, title, subtitle, icon }) => {
return (
<div className="flex items-end justify-between border-b border-gray-200 pb-3 mb-3">
<div>
<div className="text-[10px] uppercase tracking-[0.22em] text-gray-400 font-semibold">
Protocolo Titan FoodSnap Coach
</div>
<h2 className="text-2xl font-black text-gray-900 leading-tight">
{index}. {title}
</h2>
<p className="text-[12px] text-gray-500">{subtitle}</p>
</div>
<div className="text-gray-300">{icon}</div>
</div>
);
};

View file

@ -0,0 +1,119 @@
import React, { useMemo } from 'react';
import { Dumbbell, Quote } from 'lucide-react';
import { PdfHeaderRow, safeStr, asArray } from './PdfShared';
function pickRoutine(workout: any) {
// ✅ SHAPE REAL DO FRONTEND (WorkoutSection usa workout.routine)
const r = workout?.routine ?? workout?.days ?? workout?.plan ?? [];
return Array.isArray(r) ? r : [];
}
function pickExercises(day: any) {
const ex = day?.exercises ?? day?.items ?? day?.workout ?? [];
return Array.isArray(ex) ? ex : [];
}
function exLine(ex: any) {
if (typeof ex === 'string') return ex;
const name = safeStr(ex?.name || ex?.exercise || ex?.movimento, '');
const sets = ex?.sets ?? ex?.series;
const reps = ex?.reps ?? ex?.repetitions;
const technique = safeStr(ex?.technique || ex?.notes || ex?.cue, '');
const sr: string[] = [];
if (sets !== undefined && sets !== null && String(sets).trim() !== '') sr.push(`${sets}x`);
if (reps !== undefined && reps !== null && String(reps).trim() !== '') sr.push(`${reps}`);
const left = [name, sr.length ? sr.join(' ') : ''].filter(Boolean).join(' — ');
return [left, technique].filter(Boolean).join(' • ') || '-';
}
export const PdfWorkoutCompact: React.FC<{ workout: any; quote?: string }> = ({ workout, quote }) => {
const days = useMemo(() => pickRoutine(workout).slice(0, 5), [workout]);
return (
<div className="h-full flex flex-col">
<PdfHeaderRow
index="03"
title="Treino"
subtitle="Rotina (resumo de execução + foco por dia)"
icon={<Dumbbell size={42} />}
/>
{/* Top summary */}
<div className="rounded-2xl border border-gray-200 p-2 mb-3">
<div className="grid grid-cols-4 gap-2">
<div>
<div className="text-[10px] uppercase tracking-widest text-gray-400 font-semibold">Split</div>
<div className="text-[12px] font-bold text-gray-900">{safeStr(workout?.split)}</div>
</div>
<div>
<div className="text-[10px] uppercase tracking-widest text-gray-400 font-semibold">Frequência</div>
<div className="text-[12px] font-bold text-gray-900">{safeStr(workout?.frequency_days, '-')} dias</div>
</div>
<div>
<div className="text-[10px] uppercase tracking-widest text-gray-400 font-semibold">Objetivo</div>
<div className="text-[12px] font-bold text-gray-900">{safeStr(workout?.focus)}</div>
</div>
<div>
<div className="text-[10px] uppercase tracking-widest text-gray-400 font-semibold">Duração</div>
<div className="text-[12px] font-bold text-gray-900">{safeStr(workout?.duration || '48 semanas')}</div>
</div>
</div>
</div>
{/* Day cards (muito mais bonito que tabela) */}
<div className="flex-1 min-h-0 grid grid-cols-2 gap-3 overflow-hidden">
{days.length ? (
days.map((day: any, idx: number) => {
const exs = pickExercises(day).slice(0, 5);
const dayName = safeStr(day?.day || day?.name || day?.title || `Dia ${idx + 1}`, `Dia ${idx + 1}`);
const muscle = safeStr(day?.muscle_group || day?.focus || day?.grupo, '');
return (
<div key={idx} className="rounded-2xl border border-gray-200 p-3 overflow-hidden">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-[11px] font-black text-gray-900 leading-tight truncate">{dayName}</div>
<div className="text-[10px] text-gray-500 leading-tight">{muscle}</div>
</div>
<div className="text-[10px] text-gray-400 font-mono whitespace-nowrap">{safeStr(workout?.split, '')}</div>
</div>
<div className="mt-2 space-y-1">
{exs.length ? (
<ul className="list-disc pl-4 space-y-1">
{exs.map((ex: any, i: number) => (
<li key={i} className="text-[10px] text-gray-700 leading-snug break-words">
{exLine(ex)}
</li>
))}
</ul>
) : (
<div className="text-[10px] text-gray-600">Treino do dia não detalhado.</div>
)}
</div>
{day?.technique_focus ? (
<div className="mt-2 text-[10px] text-gray-500 leading-snug">
<span className="font-bold text-gray-600">Técnica:</span> {safeStr(day?.technique_focus, '-')}
</div>
) : null}
</div>
);
})
) : (
<div className="text-[11px] text-gray-600 leading-snug col-span-2">
Rotina não detalhada neste relatório.
</div>
)}
</div>
<div className="mt-2 pt-2 border-t border-gray-200 flex items-center justify-center gap-2 text-gray-500">
<Quote size={14} />
<span className="text-[10px] italic">"{quote || 'Disciplina é a ponte entre metas e conquistas.'}"</span>
</div>
</div>
);
};

View file

@ -0,0 +1,67 @@
import React from 'react';
import MacroBadge from './MacroBadge';
interface HistoryCardProps {
item: {
id: string;
img: string;
category: string;
details?: string;
cals: number;
score: number;
date: string;
protein: string;
carbs: string;
fat: string;
};
fallback: string;
}
const HistoryCard: React.FC<HistoryCardProps> = ({ item, fallback }) => (
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden shadow-sm hover:shadow-premium hover:-translate-y-1 transition-all duration-300 group cursor-pointer h-full flex flex-col">
<div className="h-36 overflow-hidden relative bg-gray-100">
<img
src={item.img}
alt={item.category}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
onError={(e) => {
const target = e.currentTarget;
// Proteção contra Loop Infinito de Erros
if (target.src !== fallback) {
target.src = fallback;
}
}}
/>
<div className="absolute top-0 inset-x-0 h-16 bg-gradient-to-b from-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<div className="absolute top-2 right-2 bg-black/60 backdrop-blur-md text-white text-[10px] font-bold px-2 py-1 rounded-full border border-white/10">
{item.cals} kcal
</div>
{item.score > 0 && (
<div className={`absolute bottom-2 left-2 text-[10px] font-bold px-2 py-0.5 rounded-full shadow-lg text-white border border-white/20 ${item.score >= 80 ? 'bg-green-500' : 'bg-yellow-500'}`}>
Score {item.score}
</div>
)}
</div>
<div className="p-4 flex-1 flex flex-col">
<h5 className="font-bold text-gray-900 text-base mb-1 truncate group-hover:text-brand-600 transition-colors">{item.category}</h5>
{item.details && <p className="text-xs text-gray-500 mb-3 line-clamp-1">{item.details}</p>}
<div className="mt-auto pt-3 border-t border-gray-50 flex justify-between items-center text-[10px] text-gray-400">
<span>{item.date}</span>
</div>
<div className="flex gap-2 mt-3">
<div className="flex-1 bg-gray-50 rounded-lg px-2 py-1.5 text-center group-hover:bg-brand-50/50 transition-colors">
<span className="block text-[8px] text-gray-400 font-bold uppercase">Prot</span>
<span className="text-xs font-bold text-gray-700">{item.protein}</span>
</div>
<div className="flex-1 bg-gray-50 rounded-lg px-2 py-1.5 text-center group-hover:bg-blue-50/50 transition-colors">
<span className="block text-[8px] text-gray-400 font-bold uppercase">Carb</span>
<span className="text-xs font-bold text-gray-700">{item.carbs}</span>
</div>
</div>
</div>
</div>
);
export default HistoryCard;

View file

@ -0,0 +1,16 @@
import React from 'react';
interface MacroBadgeProps {
label: string;
value: string | number;
color: string;
}
const MacroBadge: React.FC<MacroBadgeProps> = ({ label, value, color }) => (
<div className={`px-3 py-1 rounded-lg text-xs font-medium ${color}`}>
<span className="opacity-70 mr-1">{label}:</span>
<span className="font-bold">{value}</span>
</div>
);
export default MacroBadge;

View file

@ -0,0 +1,27 @@
import React, { ReactNode } from 'react';
interface StatCardProps {
title: string;
value: string;
sub: string;
icon: ReactNode;
highlight?: boolean;
}
const StatCard: React.FC<StatCardProps> = ({ title, value, sub, icon, highlight }) => (
<div className={`p-6 rounded-2xl ${highlight ? 'bg-gradient-to-br from-brand-50 to-white border border-brand-100' : 'bg-white border border-gray-100'} shadow-sm hover:shadow-premium transition-all duration-300 group`}>
<div className="flex items-center justify-between mb-4">
<div className={`p-3 rounded-2xl ${highlight ? 'bg-brand-100 text-brand-600' : 'bg-gray-50 text-gray-500'} group-hover:scale-110 transition-transform duration-300`}>
{icon}
</div>
{highlight && <span className="flex h-2 w-2 rounded-full bg-brand-500"></span>}
</div>
<div>
<h4 className="text-3xl font-extrabold text-gray-900 tracking-tight">{value}</h4>
<p className="text-sm text-gray-500 font-medium mt-1">{title}</p>
<p className="text-xs text-gray-400 mt-0.5">{sub}</p>
</div>
</div>
);
export default StatCard;

View file

@ -0,0 +1,149 @@
import React from 'react';
import { Sparkles, Zap, ScanLine, ScanEye, BrainCircuit, TrendingUp, History, Plus, Activity } from 'lucide-react';
import CoachResult from '@/components/coach/CoachResult';
interface DashboardCoachProps {
coachPlan: any;
setCoachPlan: (plan: any) => void;
coachHistory?: any[]; // Array of coach_analyses records
setIsCoachWizardOpen: (open: boolean) => void;
}
const DashboardCoach: React.FC<DashboardCoachProps> = ({ coachPlan, setCoachPlan, coachHistory = [], setIsCoachWizardOpen }) => {
// ─────────────────────────────────────────────────────────────────────────────
// STATE 1: NO HISTORY (HERO / ONBOARDING)
// ─────────────────────────────────────────────────────────────────────────────
if (!coachHistory || coachHistory.length === 0) {
return (
<div className="max-w-6xl mx-auto animate-in fade-in slide-in-from-bottom-8 duration-700">
{/* Sleek Modern Header */}
<div className="bg-gray-900 rounded-[2rem] p-8 md:p-12 mb-8 relative overflow-hidden text-white shadow-2xl">
{/* Abstract Premium Background */}
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-brand-600 rounded-full blur-[120px] opacity-20 translate-x-1/3 -translate-y-1/2"></div>
<div className="absolute bottom-0 left-0 w-[400px] h-[400px] bg-blue-600 rounded-full blur-[100px] opacity-20 -translate-x-1/3 translate-y-1/3"></div>
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-10">
<div className="max-w-xl">
<div className="inline-flex items-center gap-2 px-3 py-1 bg-white/10 backdrop-blur-md rounded-full border border-white/10 w-fit mb-6">
<Sparkles size={12} className="text-brand-400" />
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-200">AI Personal Trainer</span>
</div>
<h1 className="text-3xl md:text-5xl font-bold mb-4 tracking-tight leading-tight">
Seu Corpo, <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-400 to-emerald-400">
Sua Melhor Versão.
</span>
</h1>
<p className="text-gray-400 text-lg mb-8 leading-relaxed font-light">
Chega de treinos genéricos. Nossa IA analisa seu biótipo e cria um protocolo 100% científico e adaptado para você.
</p>
<div className="flex flex-wrap gap-4">
<button
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"
>
<Zap size={20} fill="currentColor" />
Gerar Novo Protocolo
</button>
</div>
</div>
{/* Visual Stats / Tech Feel */}
<div className="hidden md:block relative">
<div className="w-64 h-80 bg-gray-800/50 backdrop-blur-sm rounded-2xl border border-white/5 p-4 flex flex-col gap-4 rotate-3 hover:rotate-0 transition-transform duration-500">
<div className="h-40 bg-gray-700/50 rounded-xl overflow-hidden relative">
<div className="absolute inset-0 flex items-center justify-center">
<ScanLine size={48} className="text-brand-500/50 animate-pulse" />
</div>
{/* Fake Data Lines */}
<div className="absolute bottom-2 left-2 right-2 flex justify-between">
<span className="h-1 w-8 bg-brand-500 rounded-full"></span>
<span className="h-1 w-12 bg-gray-600 rounded-full"></span>
</div>
</div>
<div className="flex-1 flex flex-col justify-between">
<div className="space-y-2">
<div className="h-2 w-3/4 bg-gray-700 rounded-full"></div>
<div className="h-2 w-1/2 bg-gray-700 rounded-full"></div>
</div>
<div className="flex justify-between items-center text-xs text-gray-400 font-mono">
<span>ACCURACY</span>
<span className="text-brand-400">98.5%</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Features Grid - Darker/Cleaner */}
<div className="grid md:grid-cols-3 gap-6">
{[
{ title: 'Visão Computacional', desc: 'Identifica gordura e desvios posturais.', icon: <ScanEye size={24} className="text-blue-400" /> },
{ title: 'Hiper-Personalização', desc: 'Cada grama de carbo calculada para VOCÊ.', icon: <BrainCircuit size={24} className="text-brand-400" /> },
{ title: 'Evolução Constante', desc: 'Refaça a análise a cada 30 dias.', icon: <TrendingUp size={24} className="text-emerald-400" /> },
].map((feat, i) => (
<div key={i} className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm hover:shadow-md transition-shadow">
<div className="w-12 h-12 bg-gray-50 rounded-xl flex items-center justify-center mb-4 text-gray-900 border border-gray-100">
{feat.icon}
</div>
<h3 className="font-bold text-gray-900 mb-2">{feat.title}</h3>
<p className="text-sm text-gray-500 leading-relaxed">{feat.desc}</p>
</div>
))}
</div>
{/* Social Proof / Trust Strip */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-12 mt-12">
{[
{ label: "Protocolos Gerados", value: "10k+" },
{ label: "Precisão da IA", value: "98%" },
{ label: "Tempo Médio", value: "30 seg" },
{ label: "Avaliação", value: "4.9/5" },
].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">
<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>
</div>
))}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// STATE 2: COACH RESULT (CONTENT ONLY, HISTORY IS IN MAIN SIDEBAR)
// ─────────────────────────────────────────────────────────────────────────────
return (
<div className="w-full animate-in fade-in duration-500">
{coachPlan ? (
<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="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mb-4">
<Activity size={32} className="text-gray-300" />
</div>
<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">
Escolha um protocolo no menu lateral ("Coach AI → Histórico") ou gere um novo.
</p>
<button
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"
>
<Plus size={18} />
Nova Análise
</button>
</div>
)}
</div>
);
};
export default DashboardCoach;

View file

@ -0,0 +1,81 @@
import React from 'react';
import { Search, Loader2 } from 'lucide-react';
import HistoryCard from '@/components/common/HistoryCard';
import MacroBadge from '@/components/common/MacroBadge';
interface DashboardHistoryProps {
history: any[];
loadingHistory: boolean;
t: any;
fallbackImage: string;
}
const DashboardHistory: React.FC<DashboardHistoryProps> = ({ history, loadingHistory, t, fallbackImage }) => {
return (
<div className="max-w-5xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-500">
<header className="mb-8">
<h1 className="text-2xl md:text-3xl font-bold text-gray-900">{t.dashboard.historyTitle}</h1>
<p className="text-gray-500">{t.dashboard.historySubtitle}</p>
</header>
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200 mb-6 flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input type="text" placeholder={t.dashboard.searchPlaceholder} className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500/50" />
</div>
</div>
{loadingHistory ? (
<div className="flex justify-center p-12"><Loader2 className="animate-spin text-gray-400" size={32} /></div>
) : history.length === 0 ? (
<div className="text-center py-12 bg-white rounded-xl border border-gray-200">
<p className="text-gray-500">{t.dashboard.emptyHistory}</p>
</div>
) : (
<div className="space-y-4">
{history.map(item => (
<div key={item.id} className="bg-white p-4 rounded-xl border border-gray-100 shadow-sm flex flex-col sm:flex-row items-start sm:items-center gap-4 hover:border-brand-200 transition-colors">
<div className="shrink-0 relative w-full sm:w-28 h-28 rounded-lg overflow-hidden bg-gray-100">
<img
src={item.img}
alt={item.category}
onError={(e) => {
const target = e.currentTarget;
if (target.src !== fallbackImage) {
target.src = fallbackImage;
}
}}
className="w-full h-full object-cover"
/>
{item.score > 0 && (
<div className={`absolute top-1 right-1 text-[10px] font-bold px-1.5 py-0.5 rounded shadow-sm text-white ${item.score >= 80 ? 'bg-green-500' : (item.score >= 50 ? 'bg-yellow-500' : 'bg-red-500')}`}>
{item.score}
</div>
)}
</div>
<div className="flex-1 w-full">
<div className="flex justify-between items-start mb-1">
<div>
<h4 className="font-bold text-gray-900 text-lg">{item.category}</h4>
{item.details && <p className="text-xs text-gray-500 line-clamp-1">{item.details}</p>}
</div>
<span className="text-xs text-gray-400 font-mono">{item.date}</span>
</div>
<div className="flex flex-wrap gap-2 mt-3">
<MacroBadge label="Kcal" value={item.cals} color="bg-gray-100 text-gray-800" />
<MacroBadge label="Prot" value={item.protein} color="bg-brand-50 text-brand-700" />
<MacroBadge label="Carb" value={item.carbs} color="bg-blue-50 text-blue-700" />
<MacroBadge label="Gord" value={item.fat} color="bg-yellow-50 text-yellow-700" />
</div>
</div>
</div>
))}
</div>
)}
</div>
);
};
export default DashboardHistory;

View file

@ -0,0 +1,189 @@
import React from 'react';
import { Search, Zap, CreditCard, MessageCircle, Smartphone, QrCode, ChevronRight, Loader2, CheckCircle2, TrendingUp, Calendar, ArrowRight } from 'lucide-react';
import HistoryCard from '@/components/common/HistoryCard';
interface DashboardOverviewProps {
user: {
name: string;
public_id: string;
plan: string;
plan_valid_until?: string;
};
stats: {
totalCount: number;
avgCals: number;
};
loadingStats: boolean;
history: any[];
loadingHistory: boolean;
planName: string;
t: any;
whatsappUrl: string;
qrCodeUrl: string;
whatsappNumber: string;
setActiveTab: (tab: string) => void;
fallbackImage: string;
}
const DashboardOverview: React.FC<DashboardOverviewProps> = ({
user,
stats,
loadingStats,
history,
loadingHistory,
planName,
t,
whatsappUrl,
qrCodeUrl,
whatsappNumber,
setActiveTab,
fallbackImage
}) => {
return (
<div className="max-w-7xl mx-auto animate-in fade-in duration-700 space-y-8">
{/* 1. Hero Section (Glassmorphism / Dark Mode Concept) */}
<div className="relative rounded-[2.5rem] bg-gray-900 p-8 md:p-12 overflow-hidden shadow-2xl text-white">
{/* Background Blobs */}
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-brand-600 rounded-full blur-[140px] opacity-20 translate-x-1/3 -translate-y-1/2"></div>
<div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-blue-600 rounded-full blur-[120px] opacity-20 -translate-x-1/4 translate-y-1/3"></div>
<div className="relative z-10 flex flex-col md:flex-row items-start justify-between gap-8">
<div>
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-white/10 backdrop-blur-md border border-white/10 text-xs font-bold uppercase tracking-widest mb-4 text-brand-300">
<CheckCircle2 size={12} /> {planName} Member
</div>
<h1 className="text-4xl md:text-5xl font-bold mb-4 tracking-tight leading-tight">
Olá, {user.name.split(' ')[0]} 👋
</h1>
<p className="text-gray-400 text-lg max-w-lg mb-8 leading-relaxed">
Vamos transformar sua saúde hoje? Acompanhe seu progresso e mantenha o foco nas suas metas.
</p>
<div className="flex flex-wrap gap-3">
<button
onClick={() => setActiveTab('coach')}
className="group bg-white text-gray-900 px-6 py-3 rounded-xl font-bold hover:bg-brand-50 transition-all flex items-center gap-2 shadow-lg hover:shadow-xl hover:-translate-y-0.5"
>
<Zap size={18} className="text-brand-600 group-hover:text-brand-700" fill="currentColor" />
Ver Meu Plano
</button>
<button
onClick={() => window.open(whatsappUrl, '_blank')}
className="px-6 py-3 rounded-xl font-bold border border-white/20 hover:bg-white/10 transition-all flex items-center gap-2 text-white backdrop-blur-sm"
>
<MessageCircle size={18} />
Novo Registro
</button>
</div>
</div>
{/* Quick Stats in Hero */}
<div className="grid grid-cols-2 gap-4 w-full md:w-auto">
<div className="bg-white/5 backdrop-blur-md border border-white/10 p-5 rounded-2xl md:min-w-[160px]">
<p className="text-gray-400 text-xs font-bold uppercase tracking-wider mb-1">{t.dashboard.statDishes}</p>
<p className="text-3xl font-black text-white">{loadingStats ? '...' : stats.totalCount}</p>
<div className="mt-2 text-[10px] text-brand-300 bg-brand-900/50 px-2 py-1 rounded inline-block">
+12% vs mês
</div>
</div>
<div className="bg-white/5 backdrop-blur-md border border-white/10 p-5 rounded-2xl md:min-w-[160px]">
<p className="text-gray-400 text-xs font-bold uppercase tracking-wider mb-1">{t.dashboard.statCals}</p>
<p className="text-3xl font-black text-white">{loadingStats ? '...' : Math.round(stats.avgCals)}</p>
<div className="mt-2 text-[10px] text-blue-300 bg-blue-900/50 px-2 py-1 rounded inline-block">
Média Diária
</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 2. Recent History (Main Column) */}
<div className="lg:col-span-2 space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900 flex items-center gap-2">
<TrendingUp className="text-brand-500" size={24} />
{t.dashboard.recentTitle}
</h2>
<button
onClick={() => setActiveTab('history')}
className="text-sm font-semibold text-gray-500 hover:text-brand-600 flex items-center gap-1 transition-colors"
>
Ver Todos <ArrowRight size={16} />
</button>
</div>
{loadingHistory ? (
<div className="flex justify-center p-12"><Loader2 className="animate-spin text-brand-500" size={32} /></div>
) : history.length === 0 ? (
<div className="text-center p-12 bg-white rounded-3xl border border-dashed border-gray-200">
<div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mx-auto mb-4">
<Search className="text-gray-300" size={32} />
</div>
<p className="text-gray-500 font-medium">{t.dashboard.emptyRecent}</p>
<button
onClick={() => window.open(whatsappUrl, '_blank')}
className="mt-4 text-brand-600 font-bold hover:underline"
>
Começar Agora
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{history.slice(0, 4).map(item => (
<HistoryCard key={item.id} item={item} fallback={fallbackImage} />
))}
</div>
)}
</div>
{/* 3. Side Widget (WhatsApp Connect) */}
<div className="space-y-6">
<div className="bg-white rounded-3xl p-6 border border-gray-100 shadow-sm relative overflow-hidden group hover:shadow-md transition-all duration-500">
<div className="absolute top-0 right-0 w-32 h-32 bg-green-50 rounded-full blur-[60px] -translate-y-1/2 translate-x-1/2 group-hover:bg-green-100 transition-colors"></div>
<div className="relative z-10 flex flex-col items-center text-center">
<div className="bg-white p-2 rounded-2xl shadow-lg border border-gray-100 mb-4 transform group-hover:scale-105 transition-transform duration-300">
<img
src={qrCodeUrl}
alt="QR Code"
className="w-32 h-32 mix-blend-multiply"
/>
</div>
<h3 className="text-lg font-bold text-gray-900 mb-1">Conectar WhatsApp</h3>
<p className="text-sm text-gray-500 mb-4 px-4">Escaneie para enviar fotos e receber análises instantâneas.</p>
<button
onClick={() => window.open(whatsappUrl, '_blank')}
className="w-full bg-green-600 hover:bg-green-700 text-white font-bold py-3 rounded-xl flex items-center justify-center gap-2 transition-colors shadow-lg shadow-green-500/20"
>
<MessageCircle size={18} />
Abrir WhatsApp Web
</button>
</div>
</div>
{/* Mini Plan Status */}
<div className="bg-white rounded-3xl p-6 border border-gray-100 shadow-sm flex items-center justify-between">
<div>
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider mb-1">Status do Plano</p>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${user.plan === 'pro' ? 'bg-brand-500' : 'bg-gray-400'}`}></div>
<span className="font-bold text-gray-900 text-lg capitalize">{planName}</span>
</div>
{user.plan_valid_until && (
<p className="text-xs text-gray-500 mt-1">Válido até {new Date(user.plan_valid_until).toLocaleDateString('pt-BR')}</p>
)}
</div>
<div className="w-12 h-12 bg-gray-50 rounded-2xl flex items-center justify-center text-gray-400">
<CreditCard size={24} />
</div>
</div>
</div>
</div>
</div>
);
};
export default DashboardOverview;

View file

@ -0,0 +1,181 @@
import React, { useEffect, useState } from 'react';
import { CreditCard, ExternalLink, Calendar, CheckCircle2, History, AlertCircle, Loader2 } from 'lucide-react';
import { User } from '@/types';
import { supabase } from '@/lib/supabase';
interface DashboardSubscriptionProps {
user: User;
planName: string;
t: any;
handleStripePortal: () => void;
}
const DashboardSubscription: React.FC<DashboardSubscriptionProps> = ({ user, planName, t, handleStripePortal }) => {
const [payments, setPayments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchPayments = async () => {
try {
const { data, error } = await supabase
.from('payments')
.select('*')
.order('created_at', { ascending: false });
if (data) setPayments(data);
} catch (error) {
console.error("Error fetching payments:", error);
} finally {
setLoading(false);
}
};
if (user.id) {
fetchPayments();
}
}, [user.id]);
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'bg-green-100 text-green-700';
case 'pending': return 'bg-yellow-100 text-yellow-700';
case 'failed': return 'bg-red-100 text-red-700';
default: return 'bg-gray-100 text-gray-700';
}
};
return (
<div className="max-w-4xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-500 space-y-8">
<header className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">{t.dashboard.subTitle}</h1>
<p className="text-gray-500 max-w-2xl">{t.dashboard.subDesc}</p>
</header>
{/* Current Plan Card */}
<div className="bg-white rounded-3xl shadow-sm border border-gray-200 overflow-hidden relative">
<div className="absolute top-0 right-0 p-8 opacity-5">
<CreditCard size={120} />
</div>
<div className="p-8 border-b border-gray-100 relative z-10">
<div className="flex justify-between items-start">
<div>
<div className="flex items-center gap-3 mb-2">
<span className="text-xs text-gray-500 uppercase font-bold tracking-wider">{t.dashboard.currentPlan}</span>
{(user.plan === 'pro' || user.plan === 'trial') && (
<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">
Ativo
</span>
)}
</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-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" />
<span className="text-sm font-medium">
{user.plan_valid_until
? `${t.dashboard.validUntil} ${new Date(user.plan_valid_until).toLocaleDateString('pt-BR')}`
: t.dashboard.limitedAccess}
</span>
</div>
</div>
</div>
</div>
</div>
<div className="bg-gray-50/50 p-6 flex flex-col sm:flex-row items-center justify-between gap-4">
<p className="text-sm text-gray-500 max-w-md">
Gerencie sua assinatura e métodos de pagamento através do portal seguro.
</p>
<button
onClick={handleStripePortal}
className="bg-white border border-gray-200 text-gray-900 font-bold px-5 py-2.5 rounded-xl hover:bg-gray-50 hover:border-gray-300 transition-all flex items-center gap-2 text-sm shadow-sm"
>
<ExternalLink size={16} />
{t.dashboard.btnPortal}
</button>
</div>
</div>
{/* Payment History */}
<div className="bg-white rounded-3xl shadow-sm border border-gray-200 overflow-hidden">
<div className="p-6 border-b border-gray-100 flex items-center justify-between">
<h3 className="font-bold text-lg text-gray-900 flex items-center gap-2">
<History size={20} className="text-gray-400" />
Histórico de Pagamentos
</h3>
</div>
{loading ? (
<div className="p-12 flex justify-center text-gray-400">
<Loader2 className="animate-spin" size={24} />
</div>
) : payments.length === 0 ? (
<div className="p-12 text-center text-gray-400">
<AlertCircle size={32} className="mx-auto mb-3 opacity-20" />
<p>Nenhum pagamento registrado ainda.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead className="bg-gray-50/50 text-gray-500 text-xs uppercase font-bold tracking-wider">
<tr>
<th className="px-6 py-4">Data</th>
<th className="px-6 py-4">Valor</th>
<th className="px-6 py-4">Plano</th>
<th className="px-6 py-4 text-right">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-sm">
{payments.map((payment) => (
<tr key={payment.id} className="hover:bg-gray-50/50 transition-colors">
<td className="px-6 py-4 font-medium text-gray-900">
{new Date(payment.created_at).toLocaleDateString('pt-BR')}
</td>
<td className="px-6 py-4 text-gray-600">
R$ {payment.amount.toFixed(2)}
</td>
<td className="px-6 py-4 capitalize text-gray-600">
{payment.plan_type}
</td>
<td className="px-6 py-4 text-right">
<span className={`inline-flex px-2 py-1 rounded-full text-xs font-bold uppercase tracking-wider ${getStatusColor(payment.status)}`}>
{payment.status === 'completed' ? 'Pago' : payment.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Upgrade Banner (Conditional) */}
{user.plan === 'free' && (
<div className="bg-gradient-to-r from-gray-900 to-gray-800 rounded-3xl p-8 text-white relative overflow-hidden shadow-xl">
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6">
<div>
<h3 className="text-xl font-bold mb-2 flex items-center gap-2">
<CheckCircle2 className="text-green-400" />
Desbloqueie todo o potencial
</h3>
<p className="text-gray-300 max-w-lg text-sm leading-relaxed">
Obtenha análises ilimitadas, histórico completo e acesso às funcionalidades profissionais.
</p>
</div>
<button className="bg-white hover:bg-gray-100 text-gray-900 font-bold px-8 py-3 rounded-xl transition-all shadow-lg hover:shadow-xl hover:-translate-y-0.5 whitespace-nowrap">
Fazer Upgrade Agora
</button>
</div>
{/* Decorative BG */}
<div className="absolute top-0 right-0 w-64 h-64 bg-white rounded-full blur-[100px] opacity-10 translate-x-1/2 -translate-y-1/2"></div>
</div>
)}
</div>
);
};
export default DashboardSubscription;

View file

@ -0,0 +1,110 @@
import React from 'react';
import { motion } from 'framer-motion';
import { ScanEye, Dumbbell, Utensils, CheckCircle2 } from 'lucide-react';
interface CoachHighlightProps {
onRegister: () => void;
}
const CoachHighlight: React.FC<CoachHighlightProps> = ({ onRegister }) => {
return (
<section className="py-24 bg-gray-900 relative overflow-hidden">
{/* Background Effects */}
<div className="absolute top-0 left-0 w-full h-full overflow-hidden">
<div className="absolute top-0 right-0 w-[800px] h-[800px] bg-brand-600 rounded-full blur-[150px] opacity-20 -translate-y-1/2 translate-x-1/3"></div>
<div className="absolute bottom-0 left-0 w-[600px] h-[600px] bg-blue-600 rounded-full blur-[150px] opacity-10 translate-y-1/3 -translate-x-1/3"></div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div className="grid lg:grid-cols-2 gap-16 items-center">
{/* Left: Text Content */}
<div className="text-left">
<div className="inline-flex items-center gap-2 px-3 py-1 bg-brand-500/10 border border-brand-500/30 rounded-full text-brand-400 text-xs font-bold uppercase tracking-widest mb-6">
<span className="w-2 h-2 rounded-full bg-brand-500 animate-pulse"></span>
Nova Tecnologia
</div>
<h2 className="text-4xl md:text-5xl font-black text-white leading-[1.1] mb-6 tracking-tight">
Seu corpo analisado <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-400 to-brand-200">
pela Inteligência Artificial.
</span>
</h2>
<p className="text-lg text-gray-400 mb-8 leading-relaxed max-w-xl">
Esqueça planilhas genéricas. Nossa IA escaneia seu biótipo através de fotos e cria, em segundos, o protocolo exato de treino e dieta para sua estrutura.
</p>
<div className="space-y-4 mb-10">
<FeatureRow icon={<ScanEye className="text-brand-400" />} title="Visão Computacional" desc="Identifica massa muscular, gordura e postura." />
<FeatureRow icon={<Utensils className="text-brand-400" />} title="Dieta Milimétrica" desc="Macros calculados para o seu metabolismo basal." />
<FeatureRow icon={<Dumbbell className="text-brand-400" />} title="Treino Adaptativo" desc="Periodização baseada no seu nível e objetivo." />
</div>
<button
onClick={onRegister}
className="bg-brand-600 hover:bg-brand-500 text-white px-8 py-4 rounded-2xl font-bold text-lg transition-all shadow-lg shadow-brand-600/20 hover:scale-105 active:scale-95"
>
Quero minha análise agora
</button>
</div>
{/* Right: Visual Demo (Mockup) */}
<div className="relative">
<div className="relative z-10 bg-gray-800 rounded-3xl border border-gray-700 p-2 shadow-2xl transform rotate-3 hover:rotate-0 transition-all duration-500">
<img
src="https://images.unsplash.com/photo-1571019614242-c5c5dee9f50b?ixlib=rb-4.0.3&auto=format&fit=crop&w=1000&q=80"
alt="Trainer reviewing data"
className="rounded-2xl w-full h-auto opacity-70"
/>
{/* Floating Elements duplicating the 'Scanner' feel */}
<div className="absolute top-[20%] left-[10%] bg-black/80 backdrop-blur-md border border-brand-500/50 p-4 rounded-xl flex items-center gap-4 shadow-xl animate-bounce delay-700">
<div className="w-10 h-10 rounded-full bg-brand-500/20 flex items-center justify-center text-brand-400">
<ScanEye size={20} />
</div>
<div>
<div className="text-xs text-brand-400 font-bold uppercase tracking-wider">Scanning...</div>
<div className="text-white font-bold text-sm">Ectomorfo Identificado</div>
</div>
</div>
<div className="absolute bottom-[20%] right-[-20px] bg-white text-gray-900 p-4 rounded-xl shadow-xl max-w-[200px] animate-in fade-in slide-in-from-bottom-8 duration-700 delay-300">
<div className="flex items-center gap-2 mb-2">
<CheckCircle2 size={16} className="text-green-500" />
<span className="font-bold text-xs uppercase text-gray-500">Protocolo Gerado</span>
</div>
<div className="space-y-1">
<div className="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-brand-500 w-[80%]"></div>
</div>
<div className="h-1.5 w-full bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 w-[60%]"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
);
};
const FeatureRow = ({ icon, title, desc }: any) => (
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-gray-800 flex items-center justify-center shrink-0 border border-gray-700">
{icon}
</div>
<div>
<h4 className="text-white font-bold text-lg mb-1">{title}</h4>
<p className="text-gray-400 text-sm leading-snug">{desc}</p>
</div>
</div>
);
export default CoachHighlight;

View file

@ -0,0 +1,58 @@
import React, { useState } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
const FAQ: React.FC = () => {
const { t } = useLanguage();
const faqs = [
{ question: t.faq.q1, answer: t.faq.a1 },
{ question: t.faq.q2, answer: t.faq.a2 },
{ question: t.faq.q3, answer: t.faq.a3 },
{ question: t.faq.q4, answer: t.faq.a4 }
];
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<section id="faq" className="py-20 bg-gray-50">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-gray-900 mb-4">{t.faq.title}</h2>
</div>
<div className="space-y-4">
{faqs.map((faq, index) => (
<div
key={index}
className="bg-white rounded-xl border border-gray-200 overflow-hidden transition-all duration-200 hover:border-brand-200"
>
<button
className="w-full px-6 py-4 text-left flex justify-between items-center focus:outline-none"
onClick={() => setOpenIndex(openIndex === index ? null : index)}
>
<span className="font-semibold text-gray-900">{faq.question}</span>
{openIndex === index ? (
<ChevronUp className="text-brand-500" size={20} />
) : (
<ChevronDown className="text-gray-400" size={20} />
)}
</button>
<div
className={`px-6 overflow-hidden transition-all duration-300 ease-in-out ${openIndex === index ? 'max-h-40 pb-6 opacity-100' : 'max-h-0 opacity-0'
}`}
>
<p className="text-gray-600 leading-relaxed text-sm">
{faq.answer}
</p>
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default FAQ;

View file

@ -0,0 +1,213 @@
import React from 'react';
import { Flame, Scale, MessageSquare, Sparkles, ArrowLeftRight, UtensilsCrossed, CheckCircle2, Dumbbell } from 'lucide-react';
import { motion } from 'framer-motion';
import { useLanguage } from '@/contexts/LanguageContext';
const Features: React.FC = () => {
const { t } = useLanguage();
const features = [
{
icon: <Dumbbell className="w-6 h-6 text-emerald-600" strokeWidth={1.5} />,
bg: "bg-emerald-50 border-emerald-100 ring-2 ring-emerald-500/10 shadow-lg shadow-emerald-500/10",
title: "Coach AI",
description: "NOVO! Seu personal trainer e nutricionista via IA. Envie fotos, descubra seu biótipo e receba treinos e dietas 100% adaptados.",
novelty: true
},
{
icon: <Flame className="w-6 h-6 text-red-600" strokeWidth={1.5} />,
bg: "bg-red-50 border-red-100",
title: "Startup para Profissionais",
description: "Personal Trainer ou Nutricionista? Tenha seu próprio app/dashboard para gerenciar alunos, vender planos e acompanhar a evolução deles.",
novelty: true
},
{
icon: <UtensilsCrossed className="w-6 h-6 text-orange-600" strokeWidth={1.5} />,
bg: "bg-orange-50 border-orange-100",
title: t.features.f1Title,
description: t.features.f1Desc
},
{
icon: <Sparkles className="w-6 h-6 text-brand-600" strokeWidth={1.5} />,
bg: "bg-brand-50 border-brand-100",
title: t.features.f2Title,
description: t.features.f2Desc
},
{
icon: <ArrowLeftRight className="w-6 h-6 text-blue-600" strokeWidth={1.5} />,
bg: "bg-blue-50 border-blue-100",
title: t.features.f3Title,
description: t.features.f3Desc
},
{
icon: <Scale className="w-6 h-6 text-indigo-600" strokeWidth={1.5} />,
bg: "bg-indigo-50 border-indigo-100",
title: t.features.f4Title,
description: t.features.f4Desc
},
{
icon: <MessageSquare className="w-6 h-6 text-purple-600" strokeWidth={1.5} />,
bg: "bg-purple-50 border-purple-100",
title: t.features.f5Title,
description: t.features.f5Desc
}
];
return (
<section id="features" className="py-24 bg-white relative scroll-mt-24 overflow-hidden">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col lg:flex-row gap-20 items-start">
<div className="lg:w-1/2 lg:sticky lg:top-24 order-2 lg:order-1 pt-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.6 }}
>
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-brand-50 border border-brand-100 text-brand-700 text-xs font-bold uppercase tracking-wider mb-6">
<Sparkles size={12} />
{t.features.guruTitle}
</span>
<h2 className="text-3xl font-bold text-gray-900 sm:text-4xl mb-6 tracking-tight leading-[1.2]">
{t.features.mainTitle}
</h2>
<p className="text-lg text-gray-600 mb-10 leading-relaxed font-light">
{t.features.subtitle}
</p>
</motion.div>
<div className="grid sm:grid-cols-1 gap-6">
{features.map((feature, index) => (
<motion.div
key={index}
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.5, delay: index * 0.1 }}
className="group flex gap-5 items-start"
>
<div className={`shrink-0 p-3.5 rounded-2xl border shadow-sm group-hover:shadow-md transition-all duration-300 ${feature.bg}`}>
{feature.icon}
</div>
<div>
<h3 className="font-bold text-gray-900 text-lg mb-1 flex items-center gap-2">
{feature.title}
{/* @ts-ignore */}
{feature.novelty && (
<span className="px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 text-[10px] uppercase font-bold tracking-wide border border-emerald-200">Novo</span>
)}
</h3>
<p className="text-gray-500 text-sm leading-relaxed">{feature.description}</p>
</div>
</motion.div>
))}
</div>
</div>
<motion.div
initial={{ opacity: 0, scale: 0.95, x: 20 }}
whileInView={{ opacity: 1, scale: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.8 }}
className="lg:w-1/2 w-full order-1 lg:order-2 flex justify-center"
>
<div className="relative w-full max-w-lg aspect-[4/5] sm:aspect-square">
{/* Abstract Background Blob */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[120%] h-[120%] bg-gradient-to-br from-brand-100/50 via-blue-50/50 to-purple-50/50 rounded-full blur-3xl -z-10"></div>
{/* Main Card Image Container */}
<div className="relative h-full w-full rounded-[2.5rem] overflow-hidden shadow-2xl border border-white/50 bg-white ring-1 ring-black/5 transform rotate-[-2deg] hover:rotate-0 transition-transform duration-500">
<img
src="https://images.unsplash.com/photo-1512621776951-a57141f2eefd?ixlib=rb-4.0.3&auto=format&fit=crop&w=1000&q=80"
alt="Healthy Bowl"
className="w-full h-full object-cover"
/>
{/* Gradient Overlay for Text Readability */}
<div className="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent pointer-events-none"></div>
</div>
{/* Floating UI Elements */}
{/* Top Right: Score Badge */}
<div className="absolute -top-6 -right-4 z-20 animate-bounce delay-1000 duration-[3000ms]">
<div className="bg-white p-2 rounded-2xl shadow-xl border border-gray-100 flex items-center gap-3 pr-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-brand-400 to-brand-600 flex flex-col items-center justify-center text-white shadow-lg shadow-brand-500/30">
<span className="font-black text-lg leading-none">94</span>
<span className="text-[9px] opacity-90 font-bold">SCORE</span>
</div>
<div>
<p className="text-xs text-gray-400 font-bold uppercase">Qualidade</p>
<div className="flex text-yellow-400">
<CheckCircle2 size={16} className="text-brand-500 fill-brand-100" />
<span className="text-sm font-bold text-gray-800 ml-1">Excelente</span>
</div>
</div>
</div>
</div>
{/* Bottom Left: Visual Tip (Chat Bubble style) */}
<div className="absolute top-12 -left-8 max-w-[240px] z-30">
<div className="bg-white/90 backdrop-blur-md shadow-xl p-4 rounded-2xl rounded-tr-none border border-gray-100 relative transform transition-transform hover:scale-105 duration-300">
<div className="flex gap-3 items-start">
<div className="bg-gradient-to-br from-blue-500 to-indigo-600 p-2 rounded-full text-white shrink-0 shadow-lg shadow-blue-500/30">
<Sparkles size={16} fill="currentColor" />
</div>
<div>
<span className="font-bold block text-gray-900 text-xs mb-1 uppercase tracking-wide">{t.features.visualTipTitle}</span>
<p className="text-xs text-gray-600 font-medium leading-relaxed">
{t.features.visualTipDesc}
</p>
</div>
</div>
</div>
</div>
{/* Bottom Center: Macro Analysis Card (Simulating App UI) */}
<div className="absolute bottom-8 inset-x-8 z-20">
<div className="bg-white/95 backdrop-blur-xl border border-gray-200 p-5 rounded-2xl shadow-2xl shadow-gray-900/10">
<div className="flex items-center justify-between mb-4">
<div>
<h4 className="text-gray-900 font-bold text-base">Salada Caesar & Frango</h4>
<p className="text-xs text-gray-400 font-medium mt-0.5">Análise em tempo real 12:42</p>
</div>
<div className="bg-gray-100 px-2 py-1 rounded text-xs font-bold text-gray-500">
340 kcal
</div>
</div>
<div className="space-y-3">
<div>
<div className="flex justify-between text-[11px] mb-1 font-bold">
<span className="text-gray-500 uppercase">Proteína</span>
<span className="text-gray-900">28g</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-brand-500 w-[65%] rounded-full"></div>
</div>
</div>
<div>
<div className="flex justify-between text-[11px] mb-1 font-bold">
<span className="text-gray-500 uppercase">Carboidratos</span>
<span className="text-gray-900">12g</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 w-[25%] rounded-full"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</motion.div>
</div>
</div>
</section>
);
};
export default Features;

View file

@ -0,0 +1,117 @@
import React from 'react';
import { Scan, Zap, MessageCircle, Instagram, Twitter, Linkedin } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
interface FooterProps {
onRegister: () => void;
onNavigate?: (view: 'home' | 'faq') => void; // Optional prop to support navigation
}
const Footer: React.FC<FooterProps> = ({ onRegister, onNavigate }) => {
const { t } = useLanguage();
const handleFaqClick = (e: React.MouseEvent) => {
if (onNavigate) {
e.preventDefault();
onNavigate('faq');
}
};
const handleHomeClick = (e: React.MouseEvent, id?: string) => {
if (onNavigate) {
// Se tiver navegação, garante que estamos na home primeiro
if (!id) {
e.preventDefault();
onNavigate('home');
}
}
};
return (
<footer className="bg-gray-950 text-gray-400 border-t border-gray-900">
{/* Final CTA */}
<div className="relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-brand-900/40 via-gray-950 to-gray-950"></div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-20 text-center relative z-10">
<h2 className="text-3xl md:text-4xl font-bold text-white mb-6 tracking-tight">
{t.footer.ctaTitle}
</h2>
<p className="text-gray-400 text-lg mb-8 max-w-2xl mx-auto font-light">
{t.footer.ctaDesc}
</p>
<button
onClick={onRegister}
className="inline-flex items-center gap-2 bg-brand-600 text-white hover:bg-brand-500 px-8 py-4 rounded-full text-lg font-bold transition-all hover:scale-105 shadow-xl shadow-brand-900/50 hover:shadow-brand-500/20"
>
<MessageCircle size={20} />
{t.footer.ctaBtn}
</button>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-16 pb-8 border-t border-gray-900">
<div className="grid grid-cols-1 md:grid-cols-4 gap-12 mb-12">
<div className="col-span-1 md:col-span-1">
<div className="flex items-center gap-3 mb-6 cursor-pointer" onClick={(e) => handleHomeClick(e)}>
<div className="relative flex items-center justify-center w-10 h-10 bg-gray-900 rounded-xl border border-gray-800">
<Scan size={20} className="text-brand-500" strokeWidth={1.5} />
<Zap size={10} className="absolute text-yellow-500 fill-yellow-500" />
</div>
<span className="text-xl font-bold tracking-tight text-white">FoodSnap.ai</span>
</div>
<p className="text-sm text-gray-500 leading-relaxed">
{t.footer.desc}
</p>
</div>
<div>
<h3 className="text-white font-semibold mb-6 text-sm uppercase tracking-wider">{t.footer.platform}</h3>
<ul className="space-y-3 text-sm">
<li><a href="#how-it-works" onClick={(e) => handleHomeClick(e, 'how-it-works')} className="hover:text-brand-400 transition-colors">{t.header.howItWorks}</a></li>
<li><a href="#features" onClick={(e) => handleHomeClick(e, 'features')} className="hover:text-brand-400 transition-colors">{t.header.features}</a></li>
<li><a href="#pricing" onClick={(e) => handleHomeClick(e, 'pricing')} className="hover:text-brand-400 transition-colors">{t.header.pricing}</a></li>
<li>
<button onClick={handleFaqClick} className="hover:text-brand-400 transition-colors text-left">
FAQ / Ajuda
</button>
</li>
</ul>
</div>
<div>
<h3 className="text-white font-semibold mb-6 text-sm uppercase tracking-wider">{t.footer.legal}</h3>
<ul className="space-y-3 text-sm">
<li><a href="#" className="hover:text-brand-400 transition-colors">Termos de Uso</a></li>
<li><a href="#" className="hover:text-brand-400 transition-colors">Privacidade</a></li>
<li><a href="#" className="hover:text-brand-400 transition-colors">Disclaimer</a></li>
</ul>
</div>
<div>
<h3 className="text-white font-semibold mb-6 text-sm uppercase tracking-wider">{t.footer.connect}</h3>
<div className="flex gap-4">
<a href="#" className="w-10 h-10 flex items-center justify-center rounded-full bg-gray-900 border border-gray-800 hover:border-brand-600 hover:bg-brand-600 hover:text-white transition-all duration-300 text-gray-400">
<Instagram size={18} />
</a>
<a href="#" className="w-10 h-10 flex items-center justify-center rounded-full bg-gray-900 border border-gray-800 hover:border-brand-600 hover:bg-brand-600 hover:text-white transition-all duration-300 text-gray-400">
<Twitter size={18} />
</a>
<a href="#" className="w-10 h-10 flex items-center justify-center rounded-full bg-gray-900 border border-gray-800 hover:border-brand-600 hover:bg-brand-600 hover:text-white transition-all duration-300 text-gray-400">
<Linkedin size={18} />
</a>
</div>
</div>
</div>
<div className="border-t border-gray-900 pt-8 flex flex-col md:flex-row justify-between items-center gap-4 text-xs text-gray-600">
<p>&copy; {new Date().getFullYear()} FoodSnap.ai. {t.footer.rights}</p>
<div className="flex gap-6">
<span className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-green-500"></div> System Operational</span>
</div>
</div>
</div>
</footer>
);
};
export default Footer;

View file

@ -0,0 +1,218 @@
import React, { useState, useEffect } from 'react';
import { Scan, Menu, X, Zap, ArrowRight, Globe, Calculator } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
interface HeaderProps {
onRegister: () => void;
onLogin: (context?: 'user' | 'professional') => void;
onOpenTools: () => void;
onNavigate?: (view: 'home' | 'faq') => void;
}
const Header: React.FC<HeaderProps> = ({ onRegister, onLogin, onOpenTools, onNavigate }) => {
const [isScrolled, setIsScrolled] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [langMenuOpen, setLangMenuOpen] = useState(false);
const { language, setLanguage, t } = useLanguage();
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const navLinks = [
{ name: t.header.howItWorks, id: 'how-it-works' },
{ name: t.header.features, id: 'features' },
{ name: t.header.pricing, id: 'pricing' },
];
const toggleLang = (lang: 'pt' | 'en' | 'es') => {
setLanguage(lang);
setLangMenuOpen(false);
};
const handleScrollTo = (id: string) => {
// Se a função de navegação for fornecida, garante que vamos para a home primeiro
if (onNavigate) {
onNavigate('home');
// Pequeno delay para permitir a renderização da home antes de scrollar
setTimeout(() => {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}, 100);
} else {
const element = document.getElementById(id);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
}
setMobileMenuOpen(false);
};
const handleLogoClick = (e: React.MouseEvent) => {
e.preventDefault();
if (onNavigate) onNavigate('home');
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
<header
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 border-b ${isScrolled
? 'bg-white/80 backdrop-blur-md border-gray-200/50 py-3 shadow-sm'
: 'bg-transparent border-transparent py-6'
}`}
>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between">
{/* Logo Professional */}
<a href="#" onClick={handleLogoClick} className="flex items-center gap-3 group">
<div className="relative flex items-center justify-center w-11 h-11 bg-brand-950 rounded-xl border border-brand-800 shadow-lg shadow-brand-900/20 group-hover:scale-105 transition-all duration-300 group-hover:shadow-brand-600/30">
<Scan size={24} className="text-brand-400 opacity-90 group-hover:opacity-100 transition-opacity" strokeWidth={1.25} />
<Zap size={14} className="absolute text-yellow-500 fill-yellow-500 -rotate-12 translate-y-0.5 translate-x-0.5 drop-shadow-sm" strokeWidth={1.5} />
</div>
<div className="flex flex-col justify-center h-full">
<span className="text-2xl font-bold tracking-tight text-gray-900 leading-none group-hover:text-brand-900 transition-colors flex items-baseline gap-0.5">
FoodSnap<span className="text-brand-600 text-xl">.ai</span>
</span>
<span className="text-xs font-medium tracking-wide text-gray-500 mt-0.5 group-hover:text-brand-600/80 transition-colors">
{t.header.slogan}
</span>
</div>
</a>
{/* Desktop Nav */}
<nav className="hidden md:flex items-center gap-8">
{navLinks.map((link) => (
<button
key={link.name}
onClick={() => handleScrollTo(link.id)}
className="text-sm font-medium text-gray-600 hover:text-brand-600 transition-colors relative group whitespace-nowrap"
>
{link.name}
<span className="absolute -bottom-1 left-0 w-0 h-0.5 bg-brand-500 transition-all group-hover:w-full"></span>
</button>
))}
{/* Tools Button - New Featured Item */}
<button
onClick={onOpenTools}
className="flex items-center gap-1.5 text-sm font-semibold text-brand-700 bg-brand-50 hover:bg-brand-100 px-3 py-1.5 rounded-lg border border-brand-200 transition-all hover:shadow-sm"
>
<Calculator size={14} className="text-brand-600" />
{t.header.tools}
</button>
<div className="h-6 w-px bg-gray-200 mx-1"></div>
{/* Language Selector */}
<div className="relative">
<button
onClick={() => setLangMenuOpen(!langMenuOpen)}
className="flex items-center gap-1 text-sm font-medium text-gray-600 hover:text-brand-600 transition-colors p-1"
>
<Globe size={16} />
<span className="uppercase">{language}</span>
</button>
{langMenuOpen && (
<div className="absolute top-full right-0 mt-2 w-32 bg-white rounded-xl shadow-xl border border-gray-100 overflow-hidden py-1 animate-in fade-in zoom-in-95 duration-200">
{[
{ code: 'pt', label: 'Português' },
{ code: 'en', label: 'English' },
{ code: 'es', label: 'Español' }
].map((l) => (
<button
key={l.code}
onClick={() => toggleLang(l.code as any)}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-50 flex items-center justify-between ${language === l.code ? 'text-brand-600 font-bold bg-brand-50' : 'text-gray-600'}`}
>
{l.label}
{language === l.code && <div className="w-1.5 h-1.5 rounded-full bg-brand-500" />}
</button>
))}
</div>
)}
</div>
<div className="flex items-center gap-4">
<button
onClick={() => onLogin()}
className="group bg-brand-600 hover:bg-brand-700 text-white px-5 py-2 rounded-lg text-sm font-bold transition-all shadow-md shadow-brand-500/20 flex items-center gap-2 hover:-translate-y-0.5 cursor-pointer whitespace-nowrap"
>
Acessar
<ArrowRight size={16} className="group-hover:translate-x-1 transition-transform" />
</button>
</div>
</nav>
{/* Mobile Menu Toggle */}
<button
className="md:hidden p-2 text-gray-600 hover:text-brand-600 transition-colors"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? <X /> : <Menu />}
</button>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="absolute top-full left-0 right-0 bg-white border-t border-gray-100 shadow-xl md:hidden p-4 flex flex-col gap-4 animate-in slide-in-from-top-5 duration-200 h-[calc(100vh-80px)] overflow-y-auto">
{navLinks.map((link) => (
<button
key={link.name}
onClick={() => handleScrollTo(link.id)}
className="text-base font-medium text-gray-700 py-3 border-b border-gray-50 last:border-0 hover:text-brand-600 text-left"
>
{link.name}
</button>
))}
<button
onClick={() => {
setMobileMenuOpen(false);
onOpenTools();
}}
className="text-base font-bold text-brand-700 py-3 border-b border-gray-50 flex items-center gap-2"
>
<Calculator size={18} />
{t.header.tools}
</button>
<div className="flex gap-2 py-2">
<button onClick={() => toggleLang('pt')} className={`flex-1 py-2 rounded-lg text-sm border ${language === 'pt' ? 'border-brand-500 bg-brand-50 text-brand-700' : 'border-gray-200'}`}>PT</button>
<button onClick={() => toggleLang('en')} className={`flex-1 py-2 rounded-lg text-sm border ${language === 'en' ? 'border-brand-500 bg-brand-50 text-brand-700' : 'border-gray-200'}`}>EN</button>
<button onClick={() => toggleLang('es')} className={`flex-1 py-2 rounded-lg text-sm border ${language === 'es' ? 'border-brand-500 bg-brand-50 text-brand-700' : 'border-gray-200'}`}>ES</button>
</div>
<div className="flex flex-col gap-3 mt-2">
<button
onClick={() => {
setMobileMenuOpen(false);
onLogin();
}}
className="text-gray-600 font-semibold py-2"
>
{t.header.login}
</button>
<button
onClick={() => {
setMobileMenuOpen(false);
onRegister();
}}
className="bg-brand-600 text-white text-center py-3.5 rounded-xl font-semibold shadow-md w-full"
>
{t.header.cta}
</button>
</div>
</div>
)}
</header>
);
};
export default Header;

View file

@ -0,0 +1,360 @@
import React, { useState, useRef } from 'react';
import { ArrowRight, MessageCircle, Scan, Zap, Camera, Lightbulb, Sparkles, Upload, X } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useLanguage } from '@/contexts/LanguageContext';
interface HeroProps {
onRegister: () => void;
}
const Hero: React.FC<HeroProps> = ({ onRegister }) => {
const [demoState, setDemoState] = useState<'initial' | 'analyzing' | 'result'>('initial');
const [userImage, setUserImage] = useState<string | null>(null);
const [showDemoInstruction, setShowDemoInstruction] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const { t } = useLanguage();
const handleDemoClick = () => {
setShowDemoInstruction(true);
};
const handleTriggerUpload = () => {
fileInputRef.current?.click();
};
const scrollToPricing = (e: React.MouseEvent) => {
e.preventDefault();
const element = document.getElementById('pricing');
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
const imageUrl = URL.createObjectURL(file);
setUserImage(imageUrl);
setShowDemoInstruction(false); // Fecha o modal
setDemoState('analyzing');
// Simulate network delay and processing
setTimeout(() => {
setDemoState('result');
}, 3500); // Um pouco mais de tempo para ver o "robô pensando"
}
};
return (
<section className="relative pt-24 pb-12 lg:pt-32 lg:pb-20 overflow-hidden bg-gray-50/30">
{/* Hidden Input for Demo */}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
accept="image/*"
/>
{/* Modern Background */}
<div className="absolute top-0 inset-x-0 h-[600px] bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-brand-50 via-white to-white -z-10" />
<div className="absolute -top-40 right-0 w-[600px] h-[600px] bg-brand-200/20 rounded-full blur-[120px] mix-blend-multiply -z-10" />
<div className="absolute top-40 left-[-100px] w-[500px] h-[500px] bg-accent-200/20 rounded-full blur-[100px] mix-blend-multiply -z-10" />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col lg:flex-row items-center gap-12 lg:gap-20">
{/* Text Content */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="flex-1 text-center lg:text-left"
>
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-brand-50 border border-brand-100 shadow-sm text-brand-700 text-xs font-bold uppercase tracking-wider mb-8 ring-1 ring-brand-500/20">
<span className="flex h-2 w-2 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-brand-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-brand-500"></span>
</span>
NOVO: Coach AI 2.0 - Treino & Dieta
</div>
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-extrabold text-gray-900 leading-[1.1] mb-6 tracking-tight">
{t.hero.titleStart} <br />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-600 via-brand-500 to-emerald-400">
{t.hero.titleHighlight}
</span>
</h1>
<p className="text-lg text-gray-600 mb-8 max-w-2xl mx-auto lg:mx-0 leading-relaxed font-light">
{t.hero.subtitle}
</p>
<div className="flex flex-col sm:flex-row items-center gap-4 justify-center lg:justify-start">
<button
onClick={handleDemoClick}
className="w-full sm:w-auto flex items-center justify-center gap-3 bg-gray-900 hover:bg-black text-white px-8 py-4 rounded-2xl text-lg font-bold shadow-lg shadow-gray-900/20 transition-all hover:-translate-y-1 hover:shadow-xl"
>
{demoState === 'initial' ? (
<>
<Upload size={20} className="text-brand-300" />
{t.hero.ctaUpload}
</>
) : (
<>
<Camera size={20} />
Testar outra foto
</>
)}
</button>
<button
onClick={scrollToPricing}
className="w-full sm:w-auto flex items-center justify-center gap-2 bg-white border border-gray-200 hover:border-brand-300 hover:bg-brand-50/30 text-gray-700 px-8 py-4 rounded-xl text-lg font-medium transition-all shadow-sm cursor-pointer"
>
{t.hero.ctaPlans}
<ArrowRight size={18} />
</button>
</div>
<div className="mt-10 flex items-center justify-center lg:justify-start gap-4 text-sm text-gray-500 border-t border-gray-100 pt-6">
<div className="flex items-center gap-2">
<div className="flex -space-x-3">
{[1, 2, 3].map((i) => (
<div key={i} className="w-8 h-8 rounded-full border-2 border-white bg-gray-200 overflow-hidden shadow-sm">
<img src={`https://picsum.photos/100/100?random=${i + 20}`} alt="user" className="w-full h-full object-cover" />
</div>
))}
</div>
<span className="font-medium text-gray-700">{t.hero.stats}</span>
</div>
<span className="h-4 w-px bg-gray-300 mx-2"></span>
<div className="flex items-center gap-1 text-brand-700 font-semibold">
<Zap size={14} fill="currentColor" />
<span>{t.hero.analysis}</span>
</div>
</div>
</motion.div>
{/* Visual Element - Modern Mockup Interactive */}
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.7, delay: 0.2 }}
className="flex-1 relative w-full max-w-md lg:max-w-full flex justify-center lg:justify-end"
>
<div className="relative z-10 w-[340px] bg-gray-950 rounded-[45px] border-[8px] border-gray-950 shadow-2xl overflow-hidden ring-1 ring-white/10 transform transition-transform duration-500">
{/* Notch */}
<div className="absolute top-0 left-1/2 -translate-x-1/2 h-7 w-36 bg-gray-950 rounded-b-2xl z-20"></div>
{/* Screen */}
<div className="w-full h-[680px] bg-gray-50 flex flex-col font-sans">
{/* Header Mockup */}
<div className="bg-white/90 backdrop-blur-md p-4 pt-12 border-b border-gray-200 flex items-center justify-between sticky top-0 z-10">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-brand-950 text-brand-500 flex items-center justify-center shadow-lg shadow-brand-900/20 border border-brand-900/10">
<Scan size={18} />
</div>
<div>
<p className="font-bold text-sm text-gray-900 leading-tight">FoodSnap</p>
<div className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"></span>
<p className="text-[10px] text-gray-500 font-medium">Online</p>
</div>
</div>
</div>
</div>
{/* Chat Area */}
<div className="flex-1 p-4 flex flex-col gap-5 overflow-hidden bg-[#f1f5f9] relative">
{/* User Message (Image) */}
<AnimatePresence>
<motion.div
key={userImage || 'default-img'}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="self-end max-w-[85%] flex flex-col items-end"
>
<div className="bg-brand-600 p-1 rounded-2xl rounded-tr-sm shadow-md mb-1 ring-1 ring-brand-700/10">
<img
src={userImage || "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?auto=format&fit=crop&w=600&q=80"}
className="rounded-xl w-full h-32 object-cover"
alt="Meal"
/>
</div>
<p className="text-[10px] text-gray-400 font-medium mr-1">12:30</p>
</motion.div>
</AnimatePresence>
{/* Loading State / Robot Message */}
{demoState === 'analyzing' && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="self-start max-w-[85%]"
>
<div className="bg-white px-4 py-3 rounded-2xl rounded-tl-sm shadow-sm border border-gray-200 flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce [animation-delay:-0.3s]"></span>
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce [animation-delay:-0.15s]"></span>
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full animate-bounce"></span>
</div>
<span className="text-xs font-semibold text-gray-500">{t.hero.demoProcessing}</span>
</div>
</div>
</motion.div>
)}
{/* AI Response */}
{(demoState === 'initial' || demoState === 'result') && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", stiffness: 200, damping: 20 }}
className="self-start max-w-[95%]"
>
<div className="bg-white p-4 rounded-2xl rounded-tl-sm shadow-md border border-gray-200">
{/* Header Analysis */}
<div className="flex items-center justify-between mb-4 border-b border-gray-100 pb-3">
<div className="flex items-center gap-2">
<div className="bg-brand-100 p-1.5 rounded-lg text-brand-700">
<Zap size={14} fill="currentColor" />
</div>
<span className="text-xs font-bold text-gray-900 uppercase tracking-wide">{t.hero.analysis}</span>
</div>
<span className="bg-green-100 text-green-800 text-[10px] font-bold px-2 py-0.5 rounded-full border border-green-200">
Score A
</span>
</div>
<div className="space-y-4">
{/* Macros */}
<div>
<div className="flex justify-between items-baseline mb-2">
<span className="text-2xl font-extrabold text-gray-900 tracking-tight">
{demoState === 'initial' ? '485' : '520'} <span className="text-xs font-normal text-gray-400">kcal</span>
</span>
<span className="text-[10px] text-gray-400">
{demoState === 'initial' ? 'High Protein' : 'Balanced'}
</span>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="bg-gray-50 p-2 rounded-lg text-center border border-gray-100">
<p className="text-[9px] text-gray-400 font-bold uppercase mb-0.5">Prot</p>
<p className="font-bold text-gray-900 text-sm">{demoState === 'initial' ? '32g' : '28g'}</p>
</div>
<div className="bg-gray-50 p-2 rounded-lg text-center border border-gray-100">
<p className="text-[9px] text-gray-400 font-bold uppercase mb-0.5">Carb</p>
<p className="font-bold text-gray-900 text-sm">{demoState === 'initial' ? '45g' : '55g'}</p>
</div>
<div className="bg-gray-50 p-2 rounded-lg text-center border border-gray-100">
<p className="text-[9px] text-gray-400 font-bold uppercase mb-0.5">Gord</p>
<p className="font-bold text-gray-900 text-sm">{demoState === 'initial' ? '12g' : '18g'}</p>
</div>
</div>
</div>
{/* Insights */}
<div className="bg-brand-50/50 rounded-xl p-3 border border-brand-100 space-y-3">
<div className="flex gap-2.5 items-start">
<Lightbulb size={14} className="text-yellow-500 shrink-0 mt-0.5" fill="currentColor" />
<p className="text-[11px] text-gray-600 leading-relaxed">
<span className="font-bold text-gray-800">{t.hero.demoAdvice}</span> {t.hero.demoAdviceText}
</p>
</div>
</div>
</div>
</div>
<div className="flex items-center gap-1 mt-1 ml-1">
<Sparkles size={10} className="text-brand-500" />
<p className="text-[9px] text-gray-400">Powered by FoodSnap</p>
</div>
</motion.div>
)}
</div>
{/* Input Area (Visual Only) */}
<div className="bg-white p-3 border-t border-gray-200 flex items-center gap-3">
<div
className="w-9 h-9 rounded-full bg-gray-100 flex items-center justify-center text-gray-400 hover:bg-gray-200 transition-colors cursor-pointer"
onClick={handleDemoClick}
>
<Camera size={18} />
</div>
<div className="h-10 flex-1 bg-gray-100 rounded-full px-4 flex items-center text-xs text-gray-400 border border-transparent">
...
</div>
</div>
</div>
</div>
{/* Floating Elements */}
{demoState === 'initial' && (
<div className="absolute top-[20%] -left-4 lg:-left-12 bg-white p-3 rounded-xl shadow-xl border border-gray-100 animate-bounce delay-1000 hidden md:block z-20">
<div className="flex items-center gap-3">
<div className="bg-brand-50 p-1.5 rounded-lg text-brand-700 border border-brand-100">
<Scan size={16} />
</div>
<div>
<p className="text-[10px] text-gray-400 font-bold uppercase">Detected</p>
<p className="text-xs font-bold text-gray-800">Salmon Bowl</p>
</div>
</div>
</div>
)}
</motion.div>
</div>
</div>
{/* Demo Instruction Modal */}
<AnimatePresence>
{showDemoInstruction && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowDemoInstruction(false)}
className="absolute inset-0 bg-gray-950/70 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative bg-white w-full max-w-md rounded-2xl shadow-2xl overflow-hidden p-8 text-center"
>
<button
onClick={() => setShowDemoInstruction(false)}
className="absolute top-4 right-4 p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-full transition-colors"
>
<X size={20} />
</button>
<div className="w-16 h-16 bg-brand-50 rounded-2xl flex items-center justify-center mx-auto mb-6">
<Camera size={32} className="text-brand-600" />
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-3">{t.hero.demoModalTitle}</h3>
<p className="text-gray-600 mb-8 leading-relaxed">
{t.hero.demoModalDesc}
</p>
<button
onClick={handleTriggerUpload}
className="w-full bg-brand-600 hover:bg-brand-700 text-white font-bold py-4 rounded-xl shadow-lg shadow-brand-500/30 transition-all hover:scale-[1.02] flex items-center justify-center gap-2"
>
<Upload size={20} />
{t.hero.demoModalBtn}
</button>
</motion.div>
</div>
)}
</AnimatePresence>
</section>
);
};
export default Hero;

View file

@ -0,0 +1,64 @@
import React from 'react';
import { Camera, Send, Activity, ChevronRight } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
const HowItWorks: React.FC = () => {
const { t } = useLanguage();
const steps = [
{
icon: <Camera className="w-6 h-6 text-white" />,
title: t.howItWorks.step1Title,
description: t.howItWorks.step1Desc
},
{
icon: <Send className="w-6 h-6 text-white" />,
title: t.howItWorks.step2Title,
description: t.howItWorks.step2Desc
},
{
icon: <Activity className="w-6 h-6 text-white" />,
title: t.howItWorks.step3Title,
description: t.howItWorks.step3Desc
}
];
return (
<section id="how-it-works" className="py-16 bg-gray-900 text-white relative overflow-hidden scroll-mt-24">
{/* Background Pattern */}
<div className="absolute inset-0 opacity-10" style={{ backgroundImage: 'radial-gradient(#ffffff 1px, transparent 1px)', backgroundSize: '30px 30px' }}></div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div className="text-center mb-20">
<h2 className="text-3xl font-bold sm:text-4xl mb-4 tracking-tight">{t.howItWorks.title}</h2>
<p className="text-lg text-gray-400 max-w-2xl mx-auto font-light">
{t.howItWorks.subtitle}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-12 relative">
{steps.map((step, index) => (
<div key={index} className="relative flex flex-col items-center text-center group">
<div className="w-16 h-16 bg-brand-600 rounded-2xl shadow-lg shadow-brand-900/50 flex items-center justify-center mb-8 group-hover:scale-110 group-hover:bg-brand-500 transition-all duration-300">
{step.icon}
</div>
{/* Connector */}
{index < steps.length - 1 && (
<div className="hidden md:block absolute top-8 left-[60%] w-[80%] h-px bg-gradient-to-r from-brand-900 to-transparent z-0">
<ChevronRight className="absolute right-0 -top-3 text-brand-900" />
</div>
)}
<h3 className="text-xl font-bold mb-3 text-white">{step.title}</h3>
<p className="text-gray-400 leading-relaxed text-sm px-4">{step.description}</p>
</div>
))}
</div>
</div>
</section>
);
};
export default HowItWorks;

View file

@ -0,0 +1,184 @@
import React from 'react';
import { Check, ShieldCheck, Sparkles, Star, Gift } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
interface PricingProps {
onRegister: (plan: string) => void;
}
const Pricing: React.FC<PricingProps> = ({ onRegister }) => {
const { t } = useLanguage();
return (
<section id="pricing" className="py-24 bg-white relative overflow-hidden scroll-mt-24">
{/* Background Decor */}
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-brand-50 rounded-full blur-[100px] -translate-y-1/2 translate-x-1/2 -z-10"></div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-gray-900 sm:text-4xl mb-4 tracking-tight">{t.pricing.title}</h2>
<p className="text-lg text-gray-600">{t.pricing.subtitle}</p>
</div>
{/* Free Plan Banner */}
<div className="max-w-xl mx-auto mb-24 animate-in fade-in slide-in-from-bottom-4 duration-700 relative z-20">
<div className="bg-brand-50 border border-brand-100 rounded-2xl p-6 flex flex-col sm:flex-row items-center justify-between gap-6 text-center sm:text-left shadow-sm">
<div className="flex items-center gap-4">
<div className="bg-brand-100 p-3 rounded-xl text-brand-600">
<Gift size={24} strokeWidth={1.5} />
</div>
<div>
<h3 className="font-bold text-gray-900">{t.pricing.freeTierTitle}</h3>
<p className="text-sm text-gray-600">{t.pricing.freeTierDesc}</p>
</div>
</div>
<button
onClick={() => onRegister('starter')}
className="whitespace-nowrap bg-white text-gray-900 border border-gray-200 hover:border-brand-500 hover:text-brand-600 font-bold py-2.5 px-6 rounded-xl text-sm transition-all shadow-sm"
>
{t.header.cta}
</button>
</div>
</div>
<div className="grid lg:grid-cols-3 gap-8 lg:gap-14 max-w-7xl mx-auto items-center relative z-10">
{/* Plan: Monthly (Was Starter in structure, now Monthly) */}
<div className="bg-white rounded-3xl border border-gray-200 p-8 flex flex-col hover:border-gray-300 transition-colors lg:mt-12 hover:shadow-lg">
<h3 className="text-lg font-bold text-gray-900">{t.pricing.plans.monthly.title}</h3>
<div className="mt-4 mb-2 flex items-baseline gap-1">
<span className="text-4xl font-extrabold text-gray-900">{t.pricing.plans.monthly.price}</span>
<span className="text-gray-500 text-sm">{t.pricing.plans.monthly.period}</span>
</div>
<p className="text-gray-500 mb-6 text-sm">{t.pricing.plans.monthly.description}</p>
<p className="text-xs font-semibold text-gray-400 mb-6 uppercase tracking-wide">{t.pricing.plans.monthly.billingInfo}</p>
<ul className="space-y-4 mb-8 flex-grow">
{t.pricing.plans.monthly.features.map((item, i) => (
<li key={i} className="flex items-start gap-3 text-gray-600 text-sm">
<Check className="w-5 h-5 text-gray-400 shrink-0" strokeWidth={2} />
<span>{item}</span>
</li>
))}
</ul>
<button
onClick={() => onRegister('monthly')}
className="block w-full text-center bg-white border border-gray-200 hover:bg-gray-50 hover:border-gray-300 text-gray-900 font-semibold py-3.5 rounded-xl transition-all text-sm shadow-sm"
>
{t.pricing.plans.monthly.btnText}
</button>
</div>
{/* Plan: Annual (Highlighted) */}
<div className="relative z-20 transform lg:scale-105">
<div className="bg-brand-950 rounded-3xl border border-brand-800 p-8 flex flex-col relative shadow-2xl shadow-brand-900/20 ring-1 ring-brand-700/50 h-full">
<div className="flex justify-between items-start mt-2">
<div>
<h3 className="text-lg font-bold text-white flex items-center gap-2">
{t.pricing.plans.annual.title}
<span className="bg-white/10 text-white text-[10px] px-2 py-0.5 rounded-full border border-white/10">{t.pricing.plans.annual.savings}</span>
</h3>
<p className="text-brand-200 text-sm mt-1 opacity-90">{t.pricing.plans.annual.description}</p>
</div>
<div className="p-2 bg-white/5 rounded-lg border border-white/10">
<Sparkles className="text-accent-400" size={20} />
</div>
</div>
<div className="mt-6 mb-2 flex items-baseline gap-1">
<span className="text-5xl font-extrabold text-white tracking-tight">{t.pricing.plans.annual.price}</span>
<span className="text-brand-200 text-sm font-medium">{t.pricing.plans.annual.period}</span>
</div>
<p className="text-xs font-medium text-brand-300 mb-8 uppercase tracking-wide">{t.pricing.plans.annual.billingInfo}</p>
<div className="h-px bg-gradient-to-r from-transparent via-brand-800 to-transparent mb-8"></div>
<ul className="space-y-4 mb-8 flex-grow">
{t.pricing.plans.annual.features.map((item, i) => (
<li key={i} className="flex items-start gap-3 text-gray-100 text-sm">
<div className="bg-brand-600 rounded-full p-0.5 text-white shadow-sm mt-0.5">
<Check className="w-3 h-3" strokeWidth={3} />
</div>
<span className="leading-snug">{item}</span>
</li>
))}
</ul>
<button
onClick={() => onRegister('annual')}
className="block w-full text-center bg-gradient-to-r from-brand-500 to-brand-600 hover:from-brand-400 hover:to-brand-500 text-white font-bold py-4 rounded-xl transition-all shadow-lg shadow-brand-500/20 text-sm hover:-translate-y-0.5 relative overflow-hidden group"
>
<div className="absolute inset-0 bg-white/20 translate-y-full group-hover:translate-y-0 transition-transform duration-300"></div>
<span className="relative">{t.pricing.plans.annual.btnText}</span>
</button>
</div>
</div>
{/* Plan: Quarterly */}
<div className="bg-white rounded-3xl border border-gray-200 p-8 flex flex-col hover:border-brand-300 hover:shadow-lg transition-all lg:mt-12 group">
<h3 className="text-lg font-bold text-gray-900 group-hover:text-brand-600 transition-colors">{t.pricing.plans.quarterly.title}</h3>
<div className="mt-4 mb-2 flex items-baseline gap-1">
<span className="text-4xl font-extrabold text-gray-900">{t.pricing.plans.quarterly.price}</span>
<span className="text-gray-500 text-sm">{t.pricing.plans.quarterly.period}</span>
</div>
<p className="text-gray-500 mb-6 text-sm">{t.pricing.plans.quarterly.description}</p>
<p className="text-xs font-semibold text-gray-400 mb-6 uppercase tracking-wide">{t.pricing.plans.quarterly.billingInfo}</p>
<ul className="space-y-4 mb-8 flex-grow">
{t.pricing.plans.quarterly.features.map((item, i) => (
<li key={i} className="flex items-start gap-3 text-gray-600 text-sm">
<Check className="w-5 h-5 text-brand-500 shrink-0" strokeWidth={2} />
<span>{item}</span>
</li>
))}
</ul>
<button
onClick={() => onRegister('quarterly')}
className="block w-full text-center bg-white border-2 border-gray-100 hover:border-brand-500 hover:text-brand-600 text-gray-700 font-bold py-3.5 rounded-xl transition-all text-sm"
>
{t.pricing.plans.quarterly.btnText}
</button>
</div>
</div>
{/* --- PROFESSIONAL PLAN SECTION --- */}
<div className="max-w-2xl mx-auto mt-16 animate-in fade-in slide-in-from-bottom-6 duration-700">
<div className="relative bg-gray-900 rounded-2xl p-6 md:p-8 overflow-hidden shadow-xl border border-gray-800">
{/* Abstract Shapes */}
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-600/10 rounded-full blur-[60px] -translate-y-1/2 translate-x-1/3"></div>
<div className="relative z-10 flex flex-col sm:flex-row items-center gap-6">
<div className="flex-1 text-center sm:text-left">
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[10px] font-bold uppercase tracking-wider mb-3">
<Sparkles size={12} /> Para Nutris e Personais
</div>
<h3 className="text-xl font-bold text-white mb-2">Área Profissional</h3>
<p className="text-gray-400 text-sm mb-0">
Sistema completo para gestão de alunos, treinos e dietas.
</p>
</div>
<div className="bg-gray-800/50 backdrop-blur-md border border-gray-700 p-4 rounded-xl min-w-[200px] text-center">
<span className="inline-block px-3 py-1 mb-2 rounded-full bg-gray-700 text-gray-300 text-[10px] font-bold uppercase tracking-wider">
Em Breve
</span>
<button
disabled
className="w-full bg-white/10 text-white/50 font-bold py-2 rounded-lg text-sm cursor-not-allowed hover:bg-white/10"
>
Aguarde
</button>
</div>
</div>
</div>
</div>
</div>
</section>
);
};
export default Pricing;

View file

@ -0,0 +1,67 @@
import React from 'react';
import { Star, Quote } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
const Testimonials: React.FC = () => {
const { t } = useLanguage();
const reviews = [
{
name: "Rafael Silva",
role: t.testimonials.r1Role,
image: "https://picsum.photos/100/100?random=10",
content: t.testimonials.r1Content
},
{
name: "Dra. Mariana Costa",
role: t.testimonials.r2Role,
image: "https://picsum.photos/100/100?random=11",
content: t.testimonials.r2Content
},
{
name: "Lucas Mendes",
role: t.testimonials.r3Role,
image: "https://picsum.photos/100/100?random=12",
content: t.testimonials.r3Content
}
];
return (
<section className="py-24 bg-gray-50 border-y border-gray-100">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-gray-900 sm:text-4xl mb-4 tracking-tight">{t.testimonials.title}</h2>
<p className="text-lg text-gray-600">{t.testimonials.subtitle}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{reviews.map((review, index) => (
<div key={index} className="bg-white p-8 rounded-2xl shadow-sm hover:shadow-xl hover:scale-[1.02] hover:bg-brand-50/20 transition-all duration-300 border border-gray-100 flex flex-col relative group">
<Quote className="absolute top-8 right-8 text-gray-100 group-hover:text-brand-100 transition-colors" size={40} />
<div className="flex gap-1 mb-6 text-yellow-400">
{[...Array(5)].map((_, i) => <Star key={i} size={16} fill="currentColor" />)}
</div>
<p className="text-gray-600 leading-relaxed mb-8 flex-grow relative z-10">"{review.content}"</p>
<div className="flex items-center gap-4 mt-auto border-t border-gray-50 pt-6 group-hover:border-brand-100/50 transition-colors">
<img
src={review.image}
alt={review.name}
className="w-10 h-10 rounded-full object-cover ring-2 ring-gray-100 group-hover:ring-brand-100 transition-all"
/>
<div>
<h4 className="font-bold text-gray-900 text-sm">{review.name}</h4>
<p className="text-xs text-brand-600 font-medium">{review.role}</p>
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default Testimonials;

View file

@ -0,0 +1,45 @@
import React from 'react';
import { LayoutDashboard, History, CreditCard, Dumbbell } from 'lucide-react';
interface MobileNavProps {
activeTab: string;
setActiveTab: (tab: any) => void;
t: any;
}
const MobileNav: React.FC<MobileNavProps> = ({ activeTab, setActiveTab, t }) => {
return (
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-50 flex justify-around p-2 pb-safe shadow-lg">
<button
onClick={() => setActiveTab('overview')}
className={`flex flex-col items-center gap-1 p-2 rounded-lg ${activeTab === 'overview' ? 'text-brand-600' : 'text-gray-400'}`}
>
<LayoutDashboard size={20} />
<span className="text-[10px] font-medium">{t.dashboard.menuOverview}</span>
</button>
<button
onClick={() => setActiveTab('history')}
className={`flex flex-col items-center gap-1 p-2 rounded-lg ${activeTab === 'history' ? 'text-brand-600' : 'text-gray-400'}`}
>
<History size={20} />
<span className="text-[10px] font-medium">{t.dashboard.menuHistory}</span>
</button>
<button
onClick={() => setActiveTab('coach')}
className={`flex flex-col items-center gap-1 p-2 rounded-lg ${activeTab === 'coach' ? 'text-brand-600' : 'text-gray-400'}`}
>
<Dumbbell size={20} />
<span className="text-[10px] font-medium">Coach AI</span>
</button>
<button
onClick={() => setActiveTab('subscription')}
className={`flex flex-col items-center gap-1 p-2 rounded-lg ${activeTab === 'subscription' ? 'text-brand-600' : 'text-gray-400'}`}
>
<CreditCard size={20} />
<span className="text-[10px] font-medium">{t.dashboard.menuSubscription}</span>
</button>
</div>
);
};
export default MobileNav;

View file

@ -0,0 +1,172 @@
import React, { useState } from 'react';
import { LayoutDashboard, History, CreditCard, Dumbbell, ShieldAlert, BrainCircuit, LogOut, Zap, ChevronDown, ChevronRight, Calendar } from 'lucide-react';
import { User } from '@/types';
interface SidebarProps {
user: User;
activeTab: string;
setActiveTab: (tab: string) => void;
onLogout: () => void;
onOpenAdmin?: () => void;
onOpenPro?: () => void;
t: any; // Translation object
coachHistory?: any[]; // Array of coach_analyses records
onSelectCoachPlan?: (plan: any) => void;
}
const Sidebar: React.FC<SidebarProps> = ({ user, activeTab, setActiveTab, onLogout, onOpenAdmin, onOpenPro, t, coachHistory, onSelectCoachPlan }) => {
const [isCoachExpanded, setIsCoachExpanded] = useState(false);
const handleCoachClick = () => {
// If has history, toggle submenu
if (coachHistory && coachHistory.length > 0) {
setIsCoachExpanded(!isCoachExpanded);
}
setActiveTab('coach');
};
return (
<aside className="w-64 bg-white border-r border-gray-200 fixed h-full z-20 hidden md:flex flex-col">
<div className="p-6 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-brand-900 rounded-lg flex items-center justify-center text-brand-400">
<Zap size={18} fill="currentColor" />
</div>
<span className="font-bold text-xl tracking-tight">FoodSnap</span>
</div>
</div>
<nav className="flex-1 p-4 space-y-1 overflow-y-auto custom-scrollbar">
<SidebarItem
icon={<LayoutDashboard size={20} />}
label={t.dashboard.menuOverview}
active={activeTab === 'overview'}
onClick={() => setActiveTab('overview')}
/>
{/* Coach AI with Submenu */}
<div className="space-y-1">
<SidebarItem
icon={<Dumbbell size={20} />}
label="Coach AI"
active={activeTab === 'coach'}
onClick={handleCoachClick}
hasSubmenu={!!(coachHistory && coachHistory.length > 0)}
isExpanded={isCoachExpanded}
/>
{/* Submenu Items */}
{isCoachExpanded && coachHistory && (
<div className="pl-11 space-y-1 animate-in slide-in-from-top-2 duration-200">
{coachHistory.map((item) => (
<button
key={item.id}
onClick={() => {
const plan = typeof item.ai_structured === 'string'
? JSON.parse(item.ai_structured)
: item.ai_structured;
if (onSelectCoachPlan) onSelectCoachPlan(plan);
setActiveTab('coach');
}}
className="w-full text-left px-3 py-2 text-xs font-medium text-gray-500 hover:text-brand-600 hover:bg-brand-50 rounded-lg flex items-center gap-2 transition-colors truncate"
>
<Calendar size={12} />
<span>{new Date(item.created_at).toLocaleDateString('pt-BR')}</span>
<span className="text-[10px] text-gray-400 ml-auto truncate max-w-[60px]">{item.goal_suggestion || "Personalizado"}</span>
</button>
))}
</div>
)}
</div>
<SidebarItem
icon={<History size={20} />}
label={t.dashboard.menuHistory} // Refers to Food History
active={activeTab === 'history'}
onClick={() => setActiveTab('history')}
/>
<SidebarItem
icon={<CreditCard size={20} />}
label={t.dashboard.menuSubscription}
active={activeTab === 'subscription'}
onClick={() => setActiveTab('subscription')}
/>
{/* Admin Link if capable */}
{onOpenAdmin && (
<div className="mt-4 pt-4 border-t border-gray-100">
<button
onClick={onOpenAdmin}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all text-gray-400 hover:bg-gray-50 hover:text-red-500"
>
<ShieldAlert size={20} />
<span className="text-sm font-medium">Admin Panel</span>
</button>
</div>
)}
{/* Professional Link */}
{user.is_professional && onOpenPro && (
<div className="mt-2">
<button
onClick={onOpenPro}
className="w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all bg-gray-900 text-white hover:bg-black shadow-lg shadow-gray-200"
>
<BrainCircuit size={20} className="text-brand-400" />
<span className="text-sm font-bold">Área Profissional</span>
</button>
</div>
)}
</nav>
<div className="p-4 border-t border-gray-100 bg-gray-50/50">
<div className="flex items-center gap-3 mb-4 px-2">
<img src={user.avatar || `https://ui-avatars.com/api/?name=${user.name}&background=random`} alt="User" className="w-9 h-9 rounded-full bg-gray-200 border-2 border-white shadow-sm" />
<div className="overflow-hidden">
<p className="text-sm font-bold text-gray-900 truncate">{user.name}</p>
<p className="text-[10px] text-gray-500 truncate uppercase font-bold tracking-wider">{user.plan === 'pro' ? 'PRO PLAN' : 'FREE PLAN'}</p>
</div>
</div>
<button
onClick={onLogout}
className="w-full flex items-center justify-center gap-2 text-gray-500 hover:text-red-600 hover:bg-red-50 p-2 rounded-lg transition-colors text-xs font-bold uppercase tracking-wider"
>
<LogOut size={16} />
{t.dashboard.logout}
</button>
</div>
</aside>
);
};
interface SidebarItemProps {
icon: React.ReactNode;
label: string;
active: boolean;
onClick: () => void;
hasSubmenu?: boolean;
isExpanded?: boolean;
}
const SidebarItem = ({ icon, label, active, onClick, hasSubmenu, isExpanded }: SidebarItemProps) => (
<button
onClick={onClick}
className={`w-full flex items-center justify-between px-4 py-3 rounded-xl transition-all group ${active
? 'bg-brand-50 text-brand-700 font-semibold shadow-sm'
: 'text-gray-500 hover:bg-gray-50 hover:text-gray-900'
}`}
>
<div className="flex items-center gap-3">
{icon}
<span className="text-sm">{label}</span>
</div>
{hasSubmenu && (
<div className={`transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}>
<ChevronDown size={14} className={active ? 'text-brand-500' : 'text-gray-400 group-hover:text-gray-600'} />
</div>
)}
</button>
);
export default Sidebar;

View file

@ -0,0 +1,659 @@
import React, { useState } from 'react';
import { X, Calculator, Droplets, Activity, Scale, ChevronRight, ArrowRight, Check, Dumbbell, Flame, Heart, Percent } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useLanguage } from '@/contexts/LanguageContext';
interface CalculatorsModalProps {
isOpen: boolean;
onClose: () => void;
}
type ToolType = 'bmi' | 'water' | 'bmr' | 'tdee' | 'orm' | 'bodyfat' | 'hr';
const CalculatorsModal: React.FC<CalculatorsModalProps> = ({ isOpen, onClose }) => {
const [activeTool, setActiveTool] = useState<ToolType>('bmi');
const { t } = useLanguage();
if (!isOpen) return null;
return (
<AnimatePresence>
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6">
{/* Backdrop Overlay */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-gray-950/70 backdrop-blur-md"
/>
{/* Modal Container */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="relative bg-white w-full max-w-6xl h-[600px] md:h-[750px] max-h-[95vh] rounded-3xl shadow-2xl overflow-hidden flex flex-col md:flex-row ring-1 ring-gray-200"
>
{/* Close Button (Mobile) */}
<button
onClick={onClose}
className="md:hidden absolute top-4 right-4 z-20 p-2 bg-gray-100 rounded-full text-gray-500 hover:text-gray-900"
>
<X size={20} />
</button>
{/* Sidebar Navigation */}
<aside className="w-full md:w-80 bg-gray-50 border-b md:border-b-0 md:border-r border-gray-200 flex flex-col shrink-0">
<div className="p-4 md:p-6 border-b border-gray-100/50">
<div className="flex items-center gap-2">
<div className="bg-brand-600 p-2 rounded-lg text-white shadow-lg shadow-brand-500/20">
<Calculator size={20} />
</div>
<span className="font-bold text-lg text-gray-900 tracking-tight">FoodSnap Tools</span>
</div>
</div>
<nav className="flex-1 overflow-y-auto p-4 md:p-4 space-y-1 scrollbar-thin scrollbar-thumb-gray-200 flex flex-row md:flex-col gap-2 md:gap-1 overflow-x-auto md:overflow-x-visible pb-4 md:pb-0">
<NavButton
active={activeTool === 'bmi'}
onClick={() => setActiveTool('bmi')}
icon={<Scale size={18} />}
label={t.tools.bmi.title}
desc="Índice de Massa Corporal"
/>
<NavButton
active={activeTool === 'tdee'}
onClick={() => setActiveTool('tdee')}
icon={<Flame size={18} />}
label={t.tools.tdee.title}
desc="Gasto Total Diário"
/>
<NavButton
active={activeTool === 'water'}
onClick={() => setActiveTool('water')}
icon={<Droplets size={18} />}
label={t.tools.water.title}
desc="Hidratação Diária"
/>
<NavButton
active={activeTool === 'bmr'}
onClick={() => setActiveTool('bmr')}
icon={<Activity size={18} />}
label={t.tools.bmr.title}
desc="Taxa Metabólica Basal"
/>
<div className="hidden md:block h-px bg-gray-200 my-2 mx-4"></div>
<NavButton
active={activeTool === 'orm'}
onClick={() => setActiveTool('orm')}
icon={<Dumbbell size={18} />}
label={t.tools.orm.title}
desc="Força Máxima (1RM)"
/>
<NavButton
active={activeTool === 'bodyfat'}
onClick={() => setActiveTool('bodyfat')}
icon={<Percent size={18} />}
label={t.tools.bodyfat.title}
desc="Gordura Corporal"
/>
<NavButton
active={activeTool === 'hr'}
onClick={() => setActiveTool('hr')}
icon={<Heart size={18} />}
label={t.tools.hr.title}
desc="Zonas Cardíacas"
/>
</nav>
</aside>
{/* Main Content Area */}
<main className="flex-1 relative overflow-y-auto bg-white">
{/* Close Button (Desktop) */}
<button
onClick={onClose}
className="hidden md:block absolute top-6 right-6 p-2 text-gray-400 hover:text-gray-900 transition-colors bg-white hover:bg-gray-100 rounded-full z-10"
>
<X size={24} />
</button>
<div className="p-6 md:p-10 max-w-3xl mx-auto h-full flex flex-col justify-center min-h-[500px]">
<AnimatePresence mode="wait">
<motion.div
key={activeTool}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
className="w-full"
>
{activeTool === 'bmi' && <BMICalculator t={t} />}
{activeTool === 'water' && <WaterCalculator t={t} />}
{activeTool === 'bmr' && <BMRCalculator t={t} />}
{activeTool === 'tdee' && <TDEECalculator t={t} />}
{activeTool === 'orm' && <ORMCalculator t={t} />}
{activeTool === 'bodyfat' && <BodyFatCalculator t={t} />}
{activeTool === 'hr' && <HeartRateCalculator t={t} />}
</motion.div>
</AnimatePresence>
</div>
</main>
</motion.div>
</div>
</AnimatePresence>
);
};
// --- Components de Navegação ---
const NavButton = ({ active, onClick, icon, label, desc }: any) => (
<button
onClick={onClick}
className={`flex items-center gap-3 p-3 md:p-3.5 rounded-xl transition-all duration-200 w-full text-left min-w-[200px] md:min-w-0 md:mb-1 ${active
? 'bg-white shadow-md shadow-gray-200 ring-1 ring-gray-200'
: 'hover:bg-gray-100 text-gray-500 hover:text-gray-900'
}`}
>
<div className={`p-2 rounded-lg transition-colors shrink-0 ${active ? 'bg-brand-50 text-brand-600' : 'bg-gray-200/50 text-gray-500'}`}>
{icon}
</div>
<div className="min-w-0">
<span className={`block text-sm font-bold truncate ${active ? 'text-gray-900' : 'text-gray-600'}`}>{label}</span>
<span className="hidden md:block text-[10px] text-gray-400 font-medium leading-tight truncate">{desc}</span>
</div>
{active && <div className="hidden md:block ml-auto w-1.5 h-1.5 rounded-full bg-brand-500 shrink-0"></div>}
</button>
);
// --- Calculadoras (Existentes + Novas) ---
const BMICalculator = ({ t }: any) => {
const [weight, setWeight] = useState('');
const [height, setHeight] = useState('');
const [bmi, setBmi] = useState<number | null>(null);
const calculate = () => {
if (weight && height) {
const h = parseFloat(height) / 100;
const val = parseFloat(weight) / (h * h);
setBmi(parseFloat(val.toFixed(1)));
}
};
const getStatus = (val: number) => {
if (val < 18.5) return { label: 'Abaixo do peso', color: 'text-blue-500', bg: 'bg-blue-500', range: 0 };
if (val < 25) return { label: 'Peso ideal', color: 'text-green-500', bg: 'bg-green-500', range: 33 };
if (val < 30) return { label: 'Sobrepeso', color: 'text-yellow-500', bg: 'bg-yellow-500', range: 66 };
return { label: 'Obesidade', color: 'text-red-500', bg: 'bg-red-500', range: 100 };
};
const status = bmi ? getStatus(bmi) : null;
return (
<div className="space-y-8">
<div className="space-y-2">
<h2 className="text-2xl md:text-3xl font-bold text-gray-900">{t.tools.bmi.title}</h2>
<p className="text-gray-500">{t.tools.bmi.desc}</p>
</div>
<div className="grid grid-cols-2 gap-6">
<BigInput label={t.tools.bmi.labelWeight} value={weight} onChange={setWeight} placeholder="70" unit="kg" />
<BigInput label={t.tools.bmi.labelHeight} value={height} onChange={setHeight} placeholder="175" unit="cm" />
</div>
<button
onClick={calculate}
disabled={!weight || !height}
className="w-full bg-gray-900 hover:bg-black text-white font-bold py-4 rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{t.tools.calculate} <ArrowRight size={18} />
</button>
{bmi && status && (
<div className="bg-gray-50 rounded-2xl p-6 border border-gray-100 animate-in fade-in slide-in-from-bottom-4">
<div className="text-center mb-6">
<p className="text-sm text-gray-500 font-medium uppercase tracking-wide mb-1">Seu Resultado</p>
<div className="text-5xl font-extrabold text-gray-900 mb-2">{bmi}</div>
<span className={`inline-block px-3 py-1 rounded-full text-sm font-bold bg-white shadow-sm border border-gray-100 ${status.color}`}>
{status.label}
</span>
</div>
{/* Visual Bar */}
<div className="relative h-4 bg-gray-200 rounded-full overflow-hidden mb-2">
<div className="absolute inset-y-0 left-0 w-1/4 bg-blue-400"></div>
<div className="absolute inset-y-0 left-1/4 w-1/4 bg-green-400"></div>
<div className="absolute inset-y-0 left-2/4 w-1/4 bg-yellow-400"></div>
<div className="absolute inset-y-0 left-3/4 w-1/4 bg-red-400"></div>
</div>
<div className="relative h-4 w-full">
<div
className="absolute top-0 -translate-x-1/2 transition-all duration-500"
style={{ left: `${Math.min(Math.max((bmi / 40) * 100, 5), 95)}%` }}
>
<div className="w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-b-[8px] border-b-gray-800 mx-auto"></div>
</div>
</div>
<div className="flex justify-between text-[10px] text-gray-400 font-medium mt-1">
<span>18.5</span>
<span>25.0</span>
<span>30.0</span>
</div>
</div>
)}
</div>
);
};
const WaterCalculator = ({ t }: any) => {
const [weight, setWeight] = useState('');
const [liters, setLiters] = useState<number | null>(null);
const calculate = () => {
if (weight) {
const val = parseFloat(weight) * 0.035;
setLiters(parseFloat(val.toFixed(1)));
}
};
return (
<div className="space-y-8">
<div className="space-y-2">
<h2 className="text-2xl md:text-3xl font-bold text-gray-900 text-cyan-900">{t.tools.water.title}</h2>
<p className="text-gray-500">{t.tools.water.desc}</p>
</div>
<div className="flex items-center gap-8">
<div className="flex-1 space-y-6">
<BigInput label={t.tools.bmi.labelWeight} value={weight} onChange={setWeight} placeholder="70" unit="kg" />
<button
onClick={calculate}
disabled={!weight}
className="w-full bg-cyan-500 hover:bg-cyan-600 text-white font-bold py-4 rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{t.tools.calculate} <ArrowRight size={18} />
</button>
</div>
{/* Visual Bottle */}
<div className="hidden md:flex w-32 h-64 bg-gray-100 rounded-[2rem] border-4 border-gray-200 relative overflow-hidden items-end justify-center shrink-0">
<div
className="absolute bottom-0 left-0 right-0 bg-cyan-400 transition-all duration-700 ease-out opacity-80"
style={{ height: liters ? '70%' : '10%' }}
>
<div className="w-full absolute -top-4 left-0 h-8 bg-cyan-400 rounded-[100%]"></div>
</div>
<div className="relative z-10 mb-8 text-center">
<Droplets className="text-white drop-shadow-md mx-auto mb-2" size={24} />
{liters && <span className="text-white font-bold text-xl drop-shadow-md">{liters}L</span>}
</div>
</div>
</div>
{liters && (
<div className="md:hidden bg-cyan-50 border border-cyan-100 p-6 rounded-2xl text-center">
<p className="text-sm text-cyan-800 font-medium uppercase tracking-wide mb-1">Meta Diária</p>
<p className="text-5xl font-extrabold text-cyan-600">{liters} L</p>
</div>
)}
</div>
);
};
const BMRCalculator = ({ t }: any) => {
const [gender, setGender] = useState<'male' | 'female'>('male');
const [weight, setWeight] = useState('');
const [height, setHeight] = useState('');
const [age, setAge] = useState('');
const [bmr, setBmr] = useState<number | null>(null);
const calculate = () => {
if (weight && height && age) {
let val = (10 * parseFloat(weight)) + (6.25 * parseFloat(height)) - (5 * parseFloat(age));
val = gender === 'male' ? val + 5 : val - 161;
setBmr(Math.round(val));
}
};
return (
<div className="space-y-6">
<div className="space-y-2">
<h2 className="text-2xl md:text-3xl font-bold text-gray-900">{t.tools.bmr.title}</h2>
<p className="text-gray-500">{t.tools.bmr.desc}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<button onClick={() => setGender('male')} className={`p-4 rounded-xl border-2 transition-all flex flex-col items-center gap-2 ${gender === 'male' ? 'border-brand-500 bg-brand-50 text-brand-700' : 'border-gray-200 bg-white text-gray-400 hover:border-gray-300'}`}>
<div className="font-bold">{t.tools.bmr.male}</div>
{gender === 'male' && <div className="p-1 bg-brand-500 rounded-full text-white"><Check size={12} /></div>}
</button>
<button onClick={() => setGender('female')} className={`p-4 rounded-xl border-2 transition-all flex flex-col items-center gap-2 ${gender === 'female' ? 'border-brand-500 bg-brand-50 text-brand-700' : 'border-gray-200 bg-white text-gray-400 hover:border-gray-300'}`}>
<div className="font-bold">{t.tools.bmr.female}</div>
{gender === 'female' && <div className="p-1 bg-brand-500 rounded-full text-white"><Check size={12} /></div>}
</button>
</div>
<div className="grid grid-cols-3 gap-4">
<BigInput label={t.tools.bmi.labelWeight} value={weight} onChange={setWeight} placeholder="70" unit="kg" />
<BigInput label={t.tools.bmi.labelHeight} value={height} onChange={setHeight} placeholder="175" unit="cm" />
<BigInput label={t.tools.bmr.labelAge} value={age} onChange={setAge} placeholder="30" unit="anos" />
</div>
<button
onClick={calculate}
disabled={!weight || !height || !age}
className="w-full bg-gray-900 hover:bg-black text-white font-bold py-4 rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{t.tools.calculate} <ArrowRight size={18} />
</button>
{bmr && (
<div className="bg-orange-50 border border-orange-100 p-6 rounded-2xl flex items-center justify-between animate-in fade-in zoom-in">
<div>
<p className="text-sm text-orange-800 font-medium uppercase tracking-wide mb-1">Gasto em Repouso</p>
<p className="text-xs text-orange-600/70 max-w-[200px]">Calorias que você queima parado.</p>
</div>
<div className="text-right">
<p className="text-4xl font-extrabold text-orange-600">{bmr}</p>
<p className="text-xs font-bold text-orange-400 uppercase">kcal / dia</p>
</div>
</div>
)}
</div>
);
};
const TDEECalculator = ({ t }: any) => {
const [gender, setGender] = useState<'male' | 'female'>('male');
const [weight, setWeight] = useState('');
const [height, setHeight] = useState('');
const [age, setAge] = useState('');
const [activity, setActivity] = useState<number>(1.2);
const [tdee, setTdee] = useState<number | null>(null);
const calculate = () => {
if (weight && height && age) {
let bmr = (10 * parseFloat(weight)) + (6.25 * parseFloat(height)) - (5 * parseFloat(age));
bmr = gender === 'male' ? bmr + 5 : bmr - 161;
setTdee(Math.round(bmr * activity));
}
};
const activityLevels = [
{ val: 1.2, label: t.tools.tdee.sedentary },
{ val: 1.375, label: t.tools.tdee.light },
{ val: 1.55, label: t.tools.tdee.moderate },
{ val: 1.725, label: t.tools.tdee.active },
{ val: 1.9, label: t.tools.tdee.veryActive },
];
return (
<div className="space-y-6">
<div className="space-y-2">
<h2 className="text-2xl md:text-3xl font-bold text-gray-900">{t.tools.tdee.title}</h2>
<p className="text-gray-500">{t.tools.tdee.desc}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<button onClick={() => setGender('male')} className={`p-3 rounded-xl border-2 transition-all flex justify-center items-center gap-2 ${gender === 'male' ? 'border-brand-500 bg-brand-50 text-brand-700' : 'border-gray-200 bg-white text-gray-400'}`}>{t.tools.bmr.male}</button>
<button onClick={() => setGender('female')} className={`p-3 rounded-xl border-2 transition-all flex justify-center items-center gap-2 ${gender === 'female' ? 'border-brand-500 bg-brand-50 text-brand-700' : 'border-gray-200 bg-white text-gray-400'}`}>{t.tools.bmr.female}</button>
</div>
<div className="grid grid-cols-3 gap-4">
<BigInput label={t.tools.bmi.labelWeight} value={weight} onChange={setWeight} placeholder="70" unit="kg" />
<BigInput label={t.tools.bmi.labelHeight} value={height} onChange={setHeight} placeholder="175" unit="cm" />
<BigInput label={t.tools.bmr.labelAge} value={age} onChange={setAge} placeholder="30" unit="anos" />
</div>
<div>
<label className="block text-xs font-bold text-gray-500 mb-2 uppercase tracking-wide">{t.tools.tdee.activity}</label>
<div className="grid grid-cols-1 gap-2">
{activityLevels.map((lvl) => (
<button
key={lvl.val}
onClick={() => setActivity(lvl.val)}
className={`text-left px-4 py-3 rounded-xl border transition-all flex justify-between items-center ${activity === lvl.val ? 'border-brand-500 bg-brand-50 text-brand-900 ring-1 ring-brand-500' : 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'}`}
>
<span className="font-medium text-sm">{lvl.label}</span>
{activity === lvl.val && <div className="w-2 h-2 rounded-full bg-brand-500"></div>}
</button>
))}
</div>
</div>
<button
onClick={calculate}
disabled={!weight || !height || !age}
className="w-full bg-gray-900 hover:bg-black text-white font-bold py-4 rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50"
>
{t.tools.calculate}
</button>
{tdee && (
<div className="bg-brand-900 text-white p-6 rounded-2xl flex items-center justify-between animate-in fade-in zoom-in shadow-xl">
<div>
<p className="text-sm text-brand-300 font-bold uppercase tracking-wide mb-1">Gasto Calórico Total</p>
<p className="text-xs text-brand-200 opacity-80 max-w-[200px]">Energia necessária para manter seu peso atual.</p>
</div>
<div className="text-right">
<p className="text-4xl font-extrabold text-white">{tdee}</p>
<p className="text-xs font-bold text-brand-400 uppercase">kcal / dia</p>
</div>
</div>
)}
</div>
);
};
const ORMCalculator = ({ t }: any) => {
const [lift, setLift] = useState('');
const [reps, setReps] = useState('');
const [orm, setOrm] = useState<number | null>(null);
const calculate = () => {
if (lift && reps) {
// Epley Formula
const w = parseFloat(lift);
const r = parseFloat(reps);
if (r === 1) {
setOrm(w);
} else {
const val = w * (1 + r / 30);
setOrm(Math.round(val));
}
}
};
return (
<div className="space-y-8">
<div className="space-y-2">
<h2 className="text-2xl md:text-3xl font-bold text-gray-900">{t.tools.orm.title}</h2>
<p className="text-gray-500">{t.tools.orm.desc}</p>
</div>
<div className="grid grid-cols-2 gap-6">
<BigInput label={t.tools.orm.lift} value={lift} onChange={setLift} placeholder="100" unit="kg" />
<BigInput label={t.tools.orm.reps} value={reps} onChange={setReps} placeholder="5" unit="reps" />
</div>
<button
onClick={calculate}
disabled={!lift || !reps}
className="w-full bg-gray-900 hover:bg-black text-white font-bold py-4 rounded-xl transition-all shadow-lg hover:shadow-xl disabled:opacity-50"
>
{t.tools.calculate} <ArrowRight size={18} className="inline ml-2" />
</button>
{orm && (
<div className="bg-gray-100 p-6 rounded-2xl animate-in fade-in slide-in-from-bottom-2">
<div className="flex justify-between items-center mb-4">
<span className="text-gray-500 font-bold uppercase text-xs">Sua Força Máxima Estimada</span>
<Dumbbell className="text-gray-400" size={20} />
</div>
<div className="text-center py-4">
<span className="text-6xl font-black text-gray-900 tracking-tighter">{orm}</span>
<span className="text-2xl text-gray-400 font-bold ml-2">kg</span>
</div>
<div className="grid grid-cols-3 gap-2 mt-4 text-center">
<div className="bg-white p-2 rounded-lg shadow-sm">
<span className="block text-xs text-gray-400 font-bold">90%</span>
<span className="block font-bold text-gray-800">{Math.round(orm * 0.9)}kg</span>
</div>
<div className="bg-white p-2 rounded-lg shadow-sm">
<span className="block text-xs text-gray-400 font-bold">70% (Hipertrofia)</span>
<span className="block font-bold text-gray-800">{Math.round(orm * 0.7)}kg</span>
</div>
<div className="bg-white p-2 rounded-lg shadow-sm">
<span className="block text-xs text-gray-400 font-bold">50%</span>
<span className="block font-bold text-gray-800">{Math.round(orm * 0.5)}kg</span>
</div>
</div>
</div>
)}
</div>
);
};
const BodyFatCalculator = ({ t }: any) => {
const [gender, setGender] = useState<'male' | 'female'>('male');
const [waist, setWaist] = useState('');
const [neck, setNeck] = useState('');
const [hip, setHip] = useState('');
const [height, setHeight] = useState('');
const [bf, setBf] = useState<number | null>(null);
const calculate = () => {
// US Navy Method
const h = parseFloat(height);
const w = parseFloat(waist);
const n = parseFloat(neck);
if (gender === 'male' && h && w && n) {
const res = 495 / (1.0324 - 0.19077 * Math.log10(w - n) + 0.15456 * Math.log10(h)) - 450;
setBf(parseFloat(res.toFixed(1)));
} else if (gender === 'female' && h && w && n && hip) {
const hi = parseFloat(hip);
const res = 495 / (1.29579 - 0.35004 * Math.log10(w + hi - n) + 0.22100 * Math.log10(h)) - 450;
setBf(parseFloat(res.toFixed(1)));
}
};
return (
<div className="space-y-6">
<div className="space-y-2">
<h2 className="text-2xl md:text-3xl font-bold text-gray-900">{t.tools.bodyfat.title}</h2>
<p className="text-gray-500">{t.tools.bodyfat.desc}</p>
</div>
<div className="flex gap-4 mb-4">
<button onClick={() => setGender('male')} className={`flex-1 p-3 rounded-xl border-2 transition-all font-bold ${gender === 'male' ? 'border-brand-500 bg-brand-50 text-brand-700' : 'border-gray-200'}`}>{t.tools.bmr.male}</button>
<button onClick={() => setGender('female')} className={`flex-1 p-3 rounded-xl border-2 transition-all font-bold ${gender === 'female' ? 'border-brand-500 bg-brand-50 text-brand-700' : 'border-gray-200'}`}>{t.tools.bmr.female}</button>
</div>
<div className="grid grid-cols-2 gap-4">
<BigInput label={t.tools.bmi.labelHeight} value={height} onChange={setHeight} placeholder="175" unit="cm" />
<BigInput label={t.tools.bodyfat.neck} value={neck} onChange={setNeck} placeholder="40" unit="cm" />
<BigInput label={t.tools.bodyfat.waist} value={waist} onChange={setWaist} placeholder="90" unit="cm" />
{gender === 'female' && (
<BigInput label={t.tools.bodyfat.hip} value={hip} onChange={setHip} placeholder="100" unit="cm" />
)}
</div>
<button
onClick={calculate}
disabled={!height || !neck || !waist || (gender === 'female' && !hip)}
className="w-full bg-brand-600 hover:bg-brand-700 text-white font-bold py-4 rounded-xl transition-all shadow-lg"
>
{t.tools.calculate}
</button>
{bf && (
<div className="bg-brand-50 border border-brand-100 p-8 rounded-2xl text-center animate-in fade-in zoom-in">
<p className="text-sm text-brand-800 font-bold uppercase tracking-wide mb-2">Gordura Corporal Estimada</p>
<p className="text-6xl font-black text-brand-600">{bf}<span className="text-3xl">%</span></p>
<div className="mt-4 text-xs text-brand-500 font-medium bg-brand-100 inline-block px-3 py-1 rounded-full">
Método US Navy
</div>
</div>
)}
</div>
);
};
const HeartRateCalculator = ({ t }: any) => {
const [age, setAge] = useState('');
const [maxHr, setMaxHr] = useState<number | null>(null);
const calculate = () => {
if (age) {
const val = 220 - parseFloat(age);
setMaxHr(val);
}
};
return (
<div className="space-y-8">
<div className="space-y-2">
<h2 className="text-2xl md:text-3xl font-bold text-gray-900">{t.tools.hr.title}</h2>
<p className="text-gray-500">{t.tools.hr.desc}</p>
</div>
<div className="max-w-xs">
<BigInput label={t.tools.bmr.labelAge} value={age} onChange={setAge} placeholder="30" unit="anos" />
</div>
<button onClick={calculate} disabled={!age} className="w-full bg-red-600 hover:bg-red-700 text-white font-bold py-4 rounded-xl transition-all shadow-lg shadow-red-500/30">
{t.tools.calculate}
</button>
{maxHr && (
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-2">
<div className="text-center mb-6">
<span className="text-sm font-bold text-gray-400 uppercase">Frequência Máxima Teórica</span>
<div className="text-5xl font-black text-gray-900">{maxHr} <span className="text-xl font-medium text-gray-400">bpm</span></div>
</div>
<div className="space-y-2">
<ZoneBar zone="5" color="bg-red-500" range="90-100%" val={`${Math.round(maxHr * 0.9)} - ${maxHr}`} label="Performance Máxima" />
<ZoneBar zone="4" color="bg-orange-500" range="80-90%" val={`${Math.round(maxHr * 0.8)} - ${Math.round(maxHr * 0.9)}`} label="Cardio Intenso" />
<ZoneBar zone="3" color="bg-green-500" range="70-80%" val={`${Math.round(maxHr * 0.7)} - ${Math.round(maxHr * 0.8)}`} label="Aeróbico (Queima)" />
<ZoneBar zone="2" color="bg-blue-500" range="60-70%" val={`${Math.round(maxHr * 0.6)} - ${Math.round(maxHr * 0.7)}`} label="Queima de Gordura / Aquecimento" />
</div>
</div>
)}
</div>
);
};
const ZoneBar = ({ zone, color, range, val, label }: any) => (
<div className="bg-gray-50 p-3 rounded-xl border border-gray-100 flex items-center gap-4">
<div className={`w-10 h-10 rounded-lg ${color} flex items-center justify-center text-white font-bold shrink-0`}>Z{zone}</div>
<div className="flex-1">
<div className="flex justify-between items-center mb-1">
<span className="font-bold text-gray-800 text-sm">{label}</span>
<span className="text-xs font-bold text-gray-400">{range}</span>
</div>
<div className="text-sm font-medium text-gray-600">{val} bpm</div>
</div>
</div>
);
const BigInput = ({ label, value, onChange, placeholder, unit }: any) => (
<div className="w-full">
<label className="block text-xs font-bold text-gray-500 mb-2 uppercase tracking-wide">{label}</label>
<div className="relative">
<input
type="number"
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-4 py-4 rounded-xl bg-gray-50 border border-gray-200 text-gray-900 font-bold text-lg focus:border-brand-500 focus:ring-2 focus:ring-brand-200 outline-none transition-all placeholder-gray-300"
placeholder={placeholder}
/>
{unit && <span className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 font-medium text-sm">{unit}</span>}
</div>
</div>
);
export default CalculatorsModal;

View file

@ -0,0 +1,409 @@
import React, { useState, useEffect } from 'react';
import { X, ArrowRight, Loader2, Lock, Mail, User as UserIcon, Eye, EyeOff, Phone, CheckCircle, AlertCircle } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { supabase } from '@/lib/supabase';
import { useLanguage } from '@/contexts/LanguageContext';
interface RegistrationModalProps {
isOpen: boolean;
onClose: () => void;
plan: string;
mode: 'login' | 'register';
isCompletingProfile?: boolean;
onSuccess: () => void;
}
const onlyDigits = (s: string) => (s || '').replace(/\D/g, '');
const GoogleIcon = () => (
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
);
const RegistrationModal: React.FC<RegistrationModalProps> = ({
isOpen,
onClose,
plan,
mode,
isCompletingProfile = false,
onSuccess
}) => {
const { t } = useLanguage();
const [activeMode, setActiveMode] = useState<'login' | 'register'>(mode);
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// Feedback states
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
password: ''
});
useEffect(() => {
if (isOpen) {
if (isCompletingProfile) {
// Se estiver completando perfil, busca dados da sessão atual
supabase.auth.getUser().then(({ data }) => {
if (data.user) {
setFormData(prev => ({
...prev,
email: data.user?.email || '',
name: data.user?.user_metadata?.full_name || data.user?.user_metadata?.name || '',
}));
}
});
} else {
setActiveMode(mode);
}
setLoading(false);
setErrorMsg(null);
setSuccessMsg(null);
setShowPassword(false);
if (!isCompletingProfile) setFormData({ name: '', email: '', phone: '', password: '' });
}
}, [isOpen, mode, isCompletingProfile]);
const friendlyAuthError = (msg: string) => {
const m = (msg || '').toLowerCase();
// Simplified error mapping, could be extended to dictionary if strict multi-lang errors needed
if (m.includes('database error')) return 'Server Error.';
if (m.includes('already registered') || m.includes('user already registered')) return 'Email already registered.';
if (m.includes('invalid login credentials')) return 'Invalid credentials.';
if (m.includes('password should be at least')) return 'Password too short (min 6 chars).';
if (m.includes('email not confirmed')) return 'Please confirm your email.';
if (m.includes('duplicate key') || m.includes('already exists')) return 'Phone or Email already in use.';
return msg || 'An error occurred.';
};
const handleGoogleLogin = async () => {
setLoading(true);
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: window.location.origin
}
});
if (error) {
setErrorMsg(friendlyAuthError(error.message));
setLoading(false);
}
// Se der certo, o usuário sai da página, então não precisamos setar loading false
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setErrorMsg(null);
setSuccessMsg(null);
try {
// --- MODO: COMPLETAR PERFIL (Vindo do Google) ---
if (isCompletingProfile) {
const phoneDigits = onlyDigits(formData.phone);
const fullName = formData.name.trim();
if (!fullName) throw new Error(t.auth.errorRequired);
if (!phoneDigits || phoneDigits.length < 10) throw new Error(t.auth.errorPhone);
// RPC para salvar profile
const { error: rpcError } = await supabase.rpc('register_user_profile', {
p_full_name: fullName,
p_phone: phoneDigits,
p_email: formData.email // Email já vem do Google/Sessão
});
if (rpcError) {
console.error(rpcError);
throw new Error('Error saving profile. Try again.');
}
setSuccessMsg(t.auth.successLogin);
setTimeout(() => onSuccess(), 1500);
return;
}
// --- MODO: REGISTRO NORMAL ---
if (activeMode === 'register') {
const email = (formData.email || '').trim().toLowerCase();
const fullName = (formData.name || '').trim();
const phoneDigits = onlyDigits(formData.phone);
if (!fullName) throw new Error(t.auth.errorRequired);
if (!email) throw new Error(t.auth.errorRequired);
if (!phoneDigits) throw new Error(t.auth.errorRequired);
if (phoneDigits.length < 10) throw new Error(t.auth.errorPhone);
const { data: authData, error: authError } = await supabase.auth.signUp({
email,
password: formData.password,
options: { emailRedirectTo: window.location.origin }
});
if (authError) throw authError;
if (!authData.user) {
setSuccessMsg(t.auth.successRegister);
setTimeout(() => onSuccess(), 2000);
return;
}
const { error: rpcError } = await supabase.rpc('register_user_profile', {
p_full_name: fullName,
p_phone: phoneDigits,
p_email: email
});
if (rpcError) throw new Error('Phone/Email already in use.');
setSuccessMsg(t.auth.successRegister);
setTimeout(() => onSuccess(), 1500);
return;
}
// --- MODO: LOGIN NORMAL ---
const { error: loginError } = await supabase.auth.signInWithPassword({
email: (formData.email || '').trim().toLowerCase(),
password: formData.password
});
if (loginError) throw loginError;
setSuccessMsg(t.auth.successLogin);
setTimeout(() => onSuccess(), 1500);
} catch (error: any) {
console.error('Auth Error:', error);
setLoading(false);
const rawMsg = error?.message || error?.error_description || 'Error';
setErrorMsg(friendlyAuthError(rawMsg));
}
};
const title = isCompletingProfile ? t.auth.completeProfile : (activeMode === 'login' ? t.auth.welcomeBack : t.auth.createAccount);
const subtitle = isCompletingProfile ? t.auth.confirmPhone : (activeMode === 'login' ? t.auth.accessPanel : t.auth.fillToAccess);
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[60] flex items-center justify-center px-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={isCompletingProfile ? undefined : onClose}
className="absolute inset-0 bg-gray-900/60 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="relative bg-white w-full max-w-md rounded-2xl shadow-2xl overflow-hidden"
>
<div className="p-8">
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="text-2xl font-bold text-gray-900">{title}</h3>
<p className="text-gray-500 text-sm mt-1">{subtitle}</p>
</div>
{!isCompletingProfile && (
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 p-1 rounded-full hover:bg-gray-100 transition-colors">
<X size={20} />
</button>
)}
</div>
{errorMsg && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="mb-4 p-3 bg-red-50 border border-red-100 text-red-600 text-sm rounded-lg flex items-start gap-2">
<AlertCircle size={18} className="shrink-0 mt-0.5" />
<span>{errorMsg}</span>
</motion.div>
)}
{successMsg && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="mb-4 p-3 bg-green-50 border border-green-100 text-green-700 text-sm rounded-lg flex items-center gap-2">
<CheckCircle size={18} className="shrink-0" />
<span className="font-medium">{successMsg}</span>
</motion.div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Campos para Registro ou Completar Perfil */}
{(activeMode === 'register' || isCompletingProfile) && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t.auth.nameLabel}</label>
<div className="relative">
<UserIcon className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
disabled={!!successMsg}
className="w-full bg-white text-gray-900 pl-10 pr-4 py-3 rounded-xl border border-gray-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-200 outline-none transition-all placeholder-gray-400 disabled:bg-gray-50"
placeholder="John Doe"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
/>
</div>
</div>
)}
{(activeMode === 'register' || isCompletingProfile) && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t.auth.phoneLabel} <span className="text-red-500">*</span></label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="tel"
required
disabled={!!successMsg}
className="w-full bg-white text-gray-900 pl-10 pr-4 py-3 rounded-xl border border-gray-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-200 outline-none transition-all placeholder-gray-400 disabled:bg-gray-50"
placeholder={t.auth.phonePlaceholder}
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
/>
</div>
<p className="text-[11px] text-gray-500 mt-1 ml-1">{t.auth.phoneHelper}</p>
</div>
)}
{!isCompletingProfile && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t.auth.emailLabel}</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="email"
required
disabled={!!successMsg}
className="w-full bg-white text-gray-900 pl-10 pr-4 py-3 rounded-xl border border-gray-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-200 outline-none transition-all placeholder-gray-400 disabled:bg-gray-50"
placeholder="user@example.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t.auth.passwordLabel}</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type={showPassword ? 'text' : 'password'}
required
disabled={!!successMsg}
className="w-full bg-white text-gray-900 pl-10 pr-10 py-3 rounded-xl border border-gray-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-200 outline-none transition-all placeholder-gray-400 disabled:bg-gray-50"
placeholder="******"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
<button
type="button"
disabled={!!successMsg}
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 disabled:opacity-50"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
</>
)}
<div className="pt-2">
<button
type="submit"
disabled={loading || !!successMsg}
className={`w-full font-bold py-3.5 rounded-xl shadow-lg flex items-center justify-center gap-2 transition-all disabled:opacity-80 disabled:cursor-not-allowed ${successMsg
? 'bg-green-600 text-white shadow-green-500/25'
: 'bg-brand-600 hover:bg-brand-700 text-white shadow-brand-500/25'
}`}
>
{loading ? (
<Loader2 className="animate-spin" size={20} />
) : successMsg ? (
<>
<CheckCircle size={20} />
{t.auth.btnSuccess}
</>
) : (
<>
{isCompletingProfile ? t.auth.btnSave : (activeMode === 'login' ? t.auth.btnLogin : t.auth.btnRegister)}
<ArrowRight size={20} />
</>
)}
</button>
</div>
</form>
{/* Google Button & Toggle Mode */}
{!isCompletingProfile && (
<div className="mt-6">
<div className="relative mb-6">
<div className="absolute inset-0 flex items-center"><div className="w-full border-t border-gray-200"></div></div>
<div className="relative flex justify-center text-sm"><span className="px-2 bg-white text-gray-500">{t.auth.or}</span></div>
</div>
<button
type="button"
onClick={handleGoogleLogin}
disabled={loading || !!successMsg}
className="w-full bg-white text-gray-700 font-semibold py-3 rounded-xl border border-gray-200 hover:bg-gray-50 hover:border-gray-300 transition-all flex items-center justify-center gap-3 shadow-sm"
>
<GoogleIcon />
{t.auth.googleBtn}
</button>
<div className="mt-6 text-center text-sm">
<p className="text-gray-500">
{activeMode === 'login' ? t.auth.noAccount : t.auth.hasAccount}
<button
onClick={() => {
if (!loading && !successMsg) setActiveMode(activeMode === 'login' ? 'register' : 'login');
}}
disabled={loading || !!successMsg}
className="ml-1 font-semibold text-brand-600 hover:text-brand-700 hover:underline disabled:opacity-50 disabled:no-underline"
>
{activeMode === 'login' ? t.auth.registerLink : t.auth.loginLink}
</button>
</p>
</div>
</div>
)}
</div>
<div className="bg-gray-50 px-8 py-4 border-t border-gray-100 flex items-center justify-center gap-2 text-xs text-gray-400">
<Lock size={12} />
{t.auth.security}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
};
export default RegistrationModal;

View file

@ -0,0 +1,195 @@
import React, { useState } from 'react';
import {
Briefcase,
Users,
DollarSign,
Settings,
Plus,
Edit2,
Trash2,
ChevronRight,
Award,
CheckCircle2,
Calendar,
MessageSquare
} from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
const ProfessionalModule: React.FC = () => {
// Mock Data for MVP
const [services, setServices] = useState([
{ id: 1, title: 'Consultoria Online Mensal', price: 15000, active: true, clients: 12 },
{ id: 2, title: 'Treino Hipertrofia Individual', price: 8990, active: true, clients: 5 },
{ id: 3, title: 'Avaliação Física Presencial', price: 12000, active: false, clients: 0 }
]);
const [clients] = useState([
{ id: 1, name: 'João Silva', plan: 'Consultoria Online', status: 'active', lastCheckin: 'Hoje' },
{ id: 2, name: 'Maria Oliveira', plan: 'Treino Hipertrofia', status: 'active', lastCheckin: 'Ontem' },
{ id: 3, name: 'Carlos Santos', plan: 'Consultoria Online', status: 'pending', lastCheckin: '3 dias atrás' }
]);
const { t } = useLanguage(); // Assuming we might add translations later, but sticking to PT for hardcoded MVP parts
const formatCurrency = (val: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(val / 100);
};
return (
<div className="max-w-6xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Header Section */}
<header className="mb-8 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl md:text-3xl font-bold text-gray-900 flex items-center gap-2">
Área Profissional <span className="bg-brand-100 text-brand-700 text-xs px-2 py-1 rounded-full border border-brand-200">BETA</span>
</h1>
<p className="text-gray-500 mt-1">Gerencie seus serviços, alunos e faturamento em um lugar.</p>
</div>
<div className="flex gap-3">
<button className="bg-brand-600 text-white px-5 py-2.5 rounded-xl font-bold hover:bg-brand-700 transition-colors shadow-lg shadow-brand-500/20 flex items-center gap-2">
<Plus size={18} /> Novo Serviço
</button>
<button className="bg-white border border-gray-200 text-gray-700 px-4 py-2.5 rounded-xl font-semibold hover:bg-gray-50 transition-colors flex items-center gap-2">
<Settings size={18} /> Configurar Perfil
</button>
</div>
</header>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center">
<Users size={24} />
</div>
<div>
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider mb-0.5">Alunos Ativos</p>
<h4 className="text-2xl font-black text-gray-900">17</h4>
</div>
</div>
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-green-50 text-green-600 flex items-center justify-center">
<DollarSign size={24} />
</div>
<div>
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider mb-0.5">Faturamento (Mês)</p>
<h4 className="text-2xl font-black text-gray-900">R$ 2.450,00</h4>
</div>
</div>
<div className="bg-white p-6 rounded-2xl border border-gray-100 shadow-sm flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-purple-50 text-purple-600 flex items-center justify-center">
<Award size={24} />
</div>
<div>
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider mb-0.5">Serviços Ativos</p>
<h4 className="text-2xl font-black text-gray-900">3</h4>
</div>
</div>
</div>
<div className="grid lg:grid-cols-3 gap-8">
{/* Meus Serviços */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden">
<div className="p-6 border-b border-gray-100 flex justify-between items-center">
<h3 className="font-bold text-lg text-gray-900 flex items-center gap-2">
<Briefcase size={20} className="text-brand-600" /> Meus Serviços
</h3>
</div>
<div className="divide-y divide-gray-100">
{services.map(service => (
<div key={service.id} className="p-5 hover:bg-gray-50 transition-colors flex items-center justify-between group">
<div>
<div className="flex items-center gap-3 mb-1">
<h4 className="font-bold text-gray-900">{service.title}</h4>
{service.active ? (
<span className="text-[10px] font-bold bg-green-100 text-green-700 px-2 py-0.5 rounded-full uppercase tracking-wide">Ativo</span>
) : (
<span className="text-[10px] font-bold bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full uppercase tracking-wide">Inativo</span>
)}
</div>
<p className="text-sm text-gray-500 font-medium">
{formatCurrency(service.price)} {service.clients} alunos inscritos
</p>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" title="Editar">
<Edit2 size={16} />
</button>
<button className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Excluir">
<Trash2 size={16} />
</button>
</div>
</div>
))}
</div>
<div className="p-4 bg-gray-50 border-t border-gray-100 text-center">
<button className="text-sm font-bold text-brand-600 hover:text-brand-700 hover:underline">
Ver todos os serviços
</button>
</div>
</div>
{/* Quick Tips / Upsell */}
<div className="bg-gradient-to-r from-gray-900 to-gray-800 rounded-2xl p-8 text-white relative overflow-hidden">
<div className="relative z-10">
<h3 className="text-xl font-bold mb-2">Aumente suas vendas</h3>
<p className="text-gray-300 mb-6 max-w-lg text-sm leading-relaxed">
Profissionais que detalham bem seus serviços e usam fotos profissionais vendem 3x mais.
Configure seu perfil público agora mesmo.
</p>
<button className="bg-white text-gray-900 font-bold px-5 py-2.5 rounded-xl text-sm hover:bg-gray-100 transition-colors">
Editar Perfil Público
</button>
</div>
<div className="absolute right-0 top-0 w-64 h-64 bg-brand-500 rounded-full blur-[100px] opacity-20 translate-x-1/3 -translate-y-1/3"></div>
</div>
</div>
{/* Meus Alunos (Sidebar) */}
<div className="space-y-6">
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden h-full">
<div className="p-6 border-b border-gray-100 flex justify-between items-center">
<h3 className="font-bold text-lg text-gray-900 flex items-center gap-2">
<Users size={20} className="text-blue-600" /> Alunos Recentes
</h3>
</div>
<div className="divide-y divide-gray-100">
{clients.map(client => (
<div key={client.id} className="p-4 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center text-xs font-bold text-gray-600">
{client.name.substring(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<h4 className="font-bold text-gray-900 text-sm truncate">{client.name}</h4>
<p className="text-xs text-gray-500 truncate">{client.plan}</p>
</div>
{client.status === 'active' ? (
<CheckCircle2 size={14} className="text-green-500" />
) : (
<div className="w-2 h-2 rounded-full bg-yellow-400"></div>
)}
</div>
<div className="flex items-center justify-between text-xs text-gray-400 pl-11">
<span className="flex items-center gap-1"><Calendar size={10} /> {client.lastCheckin}</span>
<button className="text-brand-600 font-bold hover:underline flex items-center gap-1">
Ver <ChevronRight size={10} />
</button>
</div>
</div>
))}
</div>
<div className="p-4 bg-gray-50 border-t border-gray-100 text-center">
<button className="text-sm font-bold text-blue-600 hover:text-blue-700 hover:underline flex items-center justify-center gap-2">
<MessageSquare size={14} /> Mensagens
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default ProfessionalModule;

View file

@ -0,0 +1,14 @@
import React from 'react';
export const PlaceholderModule = ({ title, desc, icon }: any) => (
<div className="flex flex-col items-center justify-center h-[60vh] text-center max-w-md mx-auto animate-in zoom-in-95 duration-500">
<div className="w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center text-gray-400 mb-6">
{icon}
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">{title}</h2>
<p className="text-gray-500 mb-8 leading-relaxed">{desc}</p>
<button className="bg-gray-900 text-white px-6 py-3 rounded-xl font-bold hover:bg-black transition-transform hover:scale-105 active:scale-95">
Em breve
</button>
</div>
);

View file

@ -0,0 +1,9 @@
import React from 'react';
export const StatsCard = ({ label, value, trend, alert }: any) => (
<div className="bg-white p-6 rounded-2xl border border-gray-200 shadow-sm transition-all hover:shadow-md">
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider mb-2">{label}</p>
<h3 className="text-3xl font-black text-gray-900 mb-2">{value}</h3>
<p className={`text-xs font-bold ${alert ? 'text-red-500' : 'text-green-500'}`}>{trend}</p>
</div>
);

View file

@ -0,0 +1,27 @@
import React from 'react';
import { StatsCard } from '../common/StatsCard';
export const OverviewMock = () => (
<div className="max-w-6xl mx-auto space-y-8 animate-in fade-in duration-500">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<StatsCard label="Alunos Ativos" value="24" trend="+3 esse mês" />
<StatsCard label="Planos Vencendo" value="5" trend="Próx. 7 dias" alert />
<StatsCard label="Receita Mensal" value="R$ 4.250" trend="+12% vs. anterior" />
</div>
<div className="bg-white rounded-2xl border border-gray-200 p-6 shadow-sm">
<h3 className="font-bold text-gray-900 mb-4">Atividade Recente</h3>
<div className="space-y-4">
{[1, 2, 3].map(i => (
<div key={i} className="flex items-center gap-4 py-2 border-b border-gray-50 last:border-0">
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center font-bold text-gray-500 text-xs">US</div>
<div>
<p className="text-sm font-bold text-gray-900">João Silva finalizou o treino "Hipertrofia A"</p>
<p className="text-xs text-gray-500"> 2 horas Duração: 45min</p>
</div>
</div>
))}
</div>
</div>
</div>
);

View file

@ -0,0 +1,231 @@
import React, { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabase';
import { User } from '@/types';
import { Search, PlusCircle, Users, X, Calendar } from 'lucide-react';
interface StudentsListProps {
user: User;
}
export const StudentsList: React.FC<StudentsListProps> = ({ user }) => {
const [students, setStudents] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
// New Student Form State
const [newName, setNewName] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newPhone, setNewPhone] = useState('');
useEffect(() => {
fetchStudents();
}, [user.id]);
const fetchStudents = async () => {
try {
setLoading(true);
// First, ensure the professional profile exists (Auto-create logic if missing)
const { data: proProfile } = await supabase
.from('professionals')
.select('id')
.eq('id', user.id)
.maybeSingle();
if (!proProfile) {
// Auto-create professional profile if it doesn't exist (First Login)
await supabase.from('professionals').insert({
id: user.id,
business_name: user.name,
primary_color: '#059669'
});
}
const { data, error } = await supabase
.from('pro_students')
.select('*')
.eq('professional_id', user.id)
.order('created_at', { ascending: false });
if (error) throw error;
setStudents(data || []);
} catch (error) {
console.error('Error fetching students:', error);
} finally {
setLoading(false);
}
};
const handleCreateStudent = async (e: React.FormEvent) => {
e.preventDefault();
try {
const { error } = await supabase.from('pro_students').insert({
professional_id: user.id,
name: newName,
email: newEmail,
phone: newPhone,
status: 'active'
});
if (error) throw error;
setIsCreateOpen(false);
setNewName('');
setNewEmail('');
setNewPhone('');
fetchStudents(); // Refresh list
} catch (error) {
console.error('Error creating student:', error);
}
};
const filteredStudents = students.filter(s =>
s.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(s.email && s.email.toLowerCase().includes(searchTerm.toLowerCase()))
);
return (
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Header / Actions */}
<div className="p-6 border-b border-gray-100 flex flex-col sm:flex-row justify-between gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
placeholder="Buscar aluno por nome ou email..."
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500/50"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-2">
<button
onClick={() => setIsCreateOpen(true)}
className="px-4 py-2 bg-brand-600 hover:bg-brand-700 text-white rounded-lg text-sm font-bold flex items-center gap-2 transition-colors"
>
<PlusCircle size={16} />
Novo Aluno
</button>
</div>
</div>
{/* List */}
{loading ? (
<div className="p-12 text-center text-gray-400">Carregando alunos...</div>
) : filteredStudents.length === 0 ? (
<div className="p-12 text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4 text-gray-400">
<Users size={32} />
</div>
<h3 className="font-bold text-gray-900 mb-1">Nenhum aluno encontrado</h3>
<p className="text-gray-500 text-sm mb-4">Comece adicionando seu primeiro aluno.</p>
<button
onClick={() => setIsCreateOpen(true)}
className="text-brand-600 font-bold text-sm hover:underline"
>
Adicionar Aluno
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 text-gray-500 font-bold uppercase text-xs">
<tr>
<th className="px-6 py-3">Aluno</th>
<th className="px-6 py-3">Status</th>
<th className="px-6 py-3">Contato</th>
<th className="px-6 py-3">Entrou em</th>
<th className="px-6 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredStudents.map(student => (
<tr key={student.id} className="hover:bg-gray-50 transition-colors group">
<td className="px-6 py-4 font-medium text-gray-900 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-brand-100 text-brand-700 flex items-center justify-center font-bold text-xs uppercase flex-shrink-0">
{student.name.substring(0, 2)}
</div>
<span className="truncate max-w-[150px] sm:max-w-none">{student.name}</span>
</td>
<td className="px-6 py-4">
<span className={`px-2 py-1 rounded-full text-xs font-bold capitalize ${student.status === 'active' ? 'bg-green-100 text-green-700' :
student.status === 'pending' ? 'bg-yellow-100 text-yellow-700' : 'bg-gray-100 text-gray-500'
}`}>
{student.status === 'active' ? 'Ativo' : student.status === 'pending' ? 'Pendente' : 'Inativo'}
</span>
</td>
<td className="px-6 py-4 text-gray-500">
<div className="flex flex-col">
<span>{student.email}</span>
<span className="text-xs">{student.phone}</span>
</div>
</td>
<td className="px-6 py-4 text-gray-500 whitespace-nowrap">{new Date(student.created_at).toLocaleDateString('pt-BR')}</td>
<td className="px-6 py-4 text-right opacity-0 group-hover:opacity-100 transition-opacity">
<button className="text-brand-600 font-bold hover:underline">Gerenciar</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Create Modal */}
{isCreateOpen && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl p-6 w-full max-w-md animate-in zoom-in-95 duration-200 shadow-xl">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-bold text-gray-900">Novo Aluno</h3>
<button onClick={() => setIsCreateOpen(false)} className="text-gray-400 hover:text-gray-600">
<X size={20} />
</button>
</div>
<form onSubmit={handleCreateStudent} className="space-y-4">
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Nome Completo</label>
<input
required
type="text"
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500/50"
placeholder="Ex: Maria Silva"
value={newName}
onChange={e => setNewName(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Email</label>
<input
type="email"
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500/50"
placeholder="Ex: maria@email.com"
value={newEmail}
onChange={e => setNewEmail(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">Telefone / WhatsApp</label>
<input
type="tel"
className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500/50"
placeholder="Ex: 11 99999-9999"
value={newPhone}
onChange={e => setNewPhone(e.target.value)}
/>
</div>
<button
type="submit"
className="w-full bg-brand-600 hover:bg-brand-700 text-white font-bold py-3 rounded-xl transition-colors mt-2 shadow-lg shadow-brand-500/20"
>
Cadastrar Aluno
</button>
</form>
</div>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,27 @@
import React from 'react';
import { Dumbbell, Settings, PlusCircle } from 'lucide-react';
export const WorkoutsMock = () => (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
{['Hipertrofia Iniciante', 'Emagrecimento Avançado', 'Funcional Idosos'].map((t, i) => (
<div key={i} className="bg-white p-6 rounded-2xl border border-gray-200 shadow-sm hover:border-brand-300 transition-all cursor-pointer group">
<div className="flex justify-between items-start mb-4">
<div className="p-3 bg-brand-50 text-brand-600 rounded-xl group-hover:bg-brand-600 group-hover:text-white transition-colors">
<Dumbbell size={24} />
</div>
<button className="text-gray-400 hover:text-gray-600"><Settings size={18} /></button>
</div>
<h3 className="font-bold text-gray-900 text-lg mb-1">{t}</h3>
<p className="text-sm text-gray-500 mb-4">30 alunos vinculados</p>
<div className="flex gap-2">
<span className="bg-gray-100 px-2 py-1 rounded text-xs font-medium text-gray-600">ABC</span>
<span className="bg-gray-100 px-2 py-1 rounded text-xs font-medium text-gray-600">45-60min</span>
</div>
</div>
))}
<div className="border-2 border-dashed border-gray-300 rounded-2xl flex flex-col items-center justify-center p-6 text-gray-400 hover:border-brand-400 hover:text-brand-600 hover:bg-brand-50 transition-all cursor-pointer min-h-[200px]">
<PlusCircle size={32} className="mb-2" />
<span className="font-bold">Criar Novo Treino</span>
</div>
</div>
);

File diff suppressed because it is too large Load diff

52
src/hooks/useCoachPlan.ts Normal file
View file

@ -0,0 +1,52 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabase';
export const useCoachPlan = (userId: string) => {
const [coachPlan, setCoachPlan] = useState<any>(null);
const [coachHistory, setCoachHistory] = useState<any[]>([]);
const [loadingCoachPlan, setLoadingCoachPlan] = useState(false);
const fetchCoachPlan = async () => {
if (!userId) return;
setLoadingCoachPlan(true);
try {
const { data, error } = await supabase
.from('coach_analyses')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false });
if (error) {
console.error("Error fetching coach plan:", error);
return;
}
console.log("Coach Plan Data:", data); // DEBUG
if (data) {
setCoachHistory(data);
if (data.length > 0) {
// Set latest as default
const latest = data[0];
const structured = typeof latest.ai_structured === 'string'
? JSON.parse(latest.ai_structured)
: latest.ai_structured;
setCoachPlan(structured);
} else {
setCoachPlan(null);
}
}
} catch (err) {
console.error("Error fetching coach plan:", err);
} finally {
setLoadingCoachPlan(false);
}
};
useEffect(() => {
fetchCoachPlan();
}, [userId]);
return { coachPlan, setCoachPlan, coachHistory, loadingCoachPlan, refetchCoachPlan: fetchCoachPlan };
};

View file

@ -0,0 +1,73 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabase';
export const useDashboardHistory = (userId: string) => {
const [history, setHistory] = useState<any[]>([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const fetchHistory = async () => {
if (!userId) return;
setLoadingHistory(true);
try {
const { data, error } = await supabase
.from('food_analyses')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(20);
if (error) {
console.error("Error fetching history:", error);
setHistory([]);
return;
}
if (data) {
const formatted = data.map((item: any) => {
// Parse do ai_structured para pegar os itens
let itemDetails = '';
try {
// Verifica se é string antes de parsear, se já for objeto usa direto
const structured = typeof item.ai_structured === 'string'
? JSON.parse(item.ai_structured)
: item.ai_structured;
if (structured?.items && Array.isArray(structured.items)) {
itemDetails = structured.items.map((i: any) => i.name).join(', ');
}
} catch (e) {
console.log('Error parsing AI structure', e);
}
// Construção da URL do Bucket
const bucketUrl = `https://mnhgpnqkwuqzpvfrwftp.supabase.co/storage/v1/object/public/consultas/${item.user_id}/${item.id}.jpg`;
return {
id: item.id,
date: new Date(item.created_at).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }),
category: item.category || 'Refeição',
details: itemDetails,
score: item.nutrition_score || 0,
cals: Math.round(item.total_calories || 0),
protein: Math.round(item.total_protein || 0) + 'g',
carbs: Math.round(item.total_carbs || 0) + 'g',
fat: Math.round(item.total_fat || 0) + 'g',
img: bucketUrl
};
});
setHistory(formatted);
}
} catch (err) {
console.error("Error fetching history:", err);
} finally {
setLoadingHistory(false);
}
};
useEffect(() => {
fetchHistory();
}, [userId]);
return { history, loadingHistory, refetchHistory: fetchHistory };
};

View file

@ -0,0 +1,57 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabase';
interface DashboardStats {
totalCount: number;
avgCals: number;
}
export const useDashboardStats = (userId: string) => {
const [stats, setStats] = useState<DashboardStats>({ totalCount: 0, avgCals: 0 });
const [loadingStats, setLoadingStats] = useState(false);
const fetchStats = async () => {
if (!userId) return;
setLoadingStats(true);
try {
// 1. Get Total Count
const { count, error: countError } = await supabase
.from('food_analyses')
.select('*', { count: 'exact', head: true })
.eq('user_id', userId);
if (countError) throw countError;
// 2. Get Average Calories
const { data: calData, error: calError } = await supabase
.from('food_analyses')
.select('total_calories')
.eq('user_id', userId);
if (calError) throw calError;
let calculatedAvg = 0;
if (calData && calData.length > 0) {
const sum = calData.reduce((acc, curr) => acc + (curr.total_calories || 0), 0);
calculatedAvg = Math.round(sum / calData.length);
}
setStats({
totalCount: count || 0,
avgCals: calculatedAvg
});
} catch (err) {
console.error("Error fetching stats:", err);
} finally {
setLoadingStats(false);
}
};
useEffect(() => {
fetchStats();
}, [userId]);
return { stats, loadingStats, refetchStats: fetchStats };
};

113
src/index.css Normal file
View file

@ -0,0 +1,113 @@
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Base Styles */
:root {
--brand-primary: #059669;
}
html {
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: 'Plus Jakarta Sans', 'Inter', sans-serif;
background-color: #f8fafc;
/* Lighter, cleaner background */
}
/* Premium Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Glassmorphism Utilities */
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.5);
}
.glass-dark {
background: rgba(17, 24, 39, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.05);
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in {
animation: fadeIn 0.4s ease-out forwards;
}
/* Typography Enhancements */
h1,
h2,
h3,
h4,
h5,
h6 {
letter-spacing: -0.02em;
/* Tight tracking for headings */
}
/* Selection */
::selection {
background: rgba(16, 185, 129, 0.2);
color: #064e3b;
}
/* Utilities not in Tailwind default config */
.text-balance {
text-wrap: balance;
}
/* Premium Shadows & Depth */
.shadow-premium {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.02), 0 10px 15px -3px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.02);
}
.shadow-glow {
box-shadow: 0 0 20px rgba(5, 150, 105, 0.15);
}
.shadow-card-hover {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 10px 10px -5px rgba(0, 0, 0, 0.01);
}
/* Subtle Texture */
.bg-noise {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.03'/%3E%3C/svg%3E");
}

651
src/lib/database.types.ts Normal file
View file

@ -0,0 +1,651 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export interface Database {
public: {
Tables: {
app_settings: {
Row: {
key: string
value: string
created_at: string
updated_at: string
}
Insert: {
key: string
value: string
created_at?: string
updated_at?: string
}
Update: {
key?: string
value?: string
created_at?: string
updated_at?: string
}
Relationships: []
}
coupons: {
Row: {
id: string
code: string
discount_percent: number
max_uses: number | null
uses_count: number | null
is_active: boolean | null
valid_until: string | null
created_at: string | null
}
Insert: {
id?: string
code: string
discount_percent: number
max_uses?: number | null
uses_count?: number | null
is_active?: boolean | null
valid_until?: string | null
created_at?: string | null
}
Update: {
id?: string
code?: string
discount_percent?: number
max_uses?: number | null
uses_count?: number | null
is_active?: boolean | null
valid_until?: string | null
created_at?: string | null
}
Relationships: []
}
food_analyses: {
Row: {
id: string
user_id: string
source: string
image_url: string | null
ai_raw_response: string
ai_structured: Json
total_calories: number | null
total_protein: number | null
total_carbs: number | null
total_fat: number | null
total_fiber: number | null
total_sodium_mg: number | null
nutrition_score: number | null
confidence_level: string | null
used_free_quota: boolean | null
created_at: string | null
source_message_id: string | null
}
Insert: {
id?: string
user_id: string
source?: string
image_url?: string | null
ai_raw_response: string
ai_structured: Json
total_calories?: number | null
total_protein?: number | null
total_carbs?: number | null
total_fat?: number | null
total_fiber?: number | null
total_sodium_mg?: number | null
nutrition_score?: number | null
confidence_level?: string | null
used_free_quota?: boolean | null
created_at?: string | null
source_message_id?: string | null
}
Update: {
id?: string
user_id?: string
source?: string
image_url?: string | null
ai_raw_response?: string
ai_structured?: Json
total_calories?: number | null
total_protein?: number | null
total_carbs?: number | null
total_fat?: number | null
total_fiber?: number | null
total_sodium_mg?: number | null
nutrition_score?: number | null
confidence_level?: string | null
used_free_quota?: boolean | null
created_at?: string | null
source_message_id?: string | null
}
Relationships: [
{
foreignKeyName: "food_analyses_user_id_fkey"
columns: ["user_id"]
referencedRelation: "users" // implied, usually auth.users but referenced as generic
referencedColumns: ["id"]
}
]
}
food_analysis_items: {
Row: {
id: string
analysis_id: string
user_id: string
name: string | null
portion: string | null
calories: number | null
protein: number | null
carbs: number | null
fat: number | null
fiber: number | null
sugar: number | null
sodium_mg: number | null
flags: Json | null
created_at: string | null
}
Insert: {
id?: string
analysis_id: string
user_id: string
name?: string | null
portion?: string | null
calories?: number | null
protein?: number | null
carbs?: number | null
fat?: number | null
fiber?: number | null
sugar?: number | null
sodium_mg?: number | null
flags?: Json | null
created_at?: string | null
}
Update: {
id?: string
analysis_id?: string
user_id?: string
name?: string | null
portion?: string | null
calories?: number | null
protein?: number | null
carbs?: number | null
fat?: number | null
fiber?: number | null
sugar?: number | null
sodium_mg?: number | null
flags?: Json | null
created_at?: string | null
}
Relationships: [
{
foreignKeyName: "food_analysis_items_analysis_id_fkey"
columns: ["analysis_id"]
referencedRelation: "food_analyses"
referencedColumns: ["id"]
},
{
foreignKeyName: "food_analysis_items_user_id_fkey"
columns: ["user_id"]
referencedRelation: "users"
referencedColumns: ["id"]
}
]
}
payments: {
Row: {
id: string
user_id: string | null
amount_cents: number
currency: string | null
status: string | null
plan_type: string | null
stripe_payment_id: string | null
created_at: string | null
}
Insert: {
id?: string
user_id?: string | null
amount_cents: number
currency?: string | null
status?: string | null
plan_type?: string | null
stripe_payment_id?: string | null
created_at?: string | null
}
Update: {
id?: string
user_id?: string | null
amount_cents?: number
currency?: string | null
status?: string | null
plan_type?: string | null
stripe_payment_id?: string | null
created_at?: string | null
}
Relationships: [
{
foreignKeyName: "payments_user_id_fkey"
columns: ["user_id"]
referencedRelation: "users"
referencedColumns: ["id"]
}
]
}
pro_assessments: {
Row: {
id: string
professional_id: string
student_id: string
date: string | null
weight: number | null
height: number | null
age: number | null
bf_percent: number | null
muscle_percent: number | null
bmi: number | null
measurements: Json | null
methodology: Json | null
photos: string[] | null
created_at: string
}
Insert: {
id?: string
professional_id: string
student_id: string
date?: string | null
weight?: number | null
height?: number | null
age?: number | null
bf_percent?: number | null
muscle_percent?: number | null
bmi?: number | null
measurements?: Json | null
methodology?: Json | null
photos?: string[] | null
created_at?: string
}
Update: {
id?: string
professional_id?: string
student_id?: string
date?: string | null
weight?: number | null
height?: number | null
age?: number | null
bf_percent?: number | null
muscle_percent?: number | null
bmi?: number | null
measurements?: Json | null
methodology?: Json | null
photos?: string[] | null
created_at?: string
}
Relationships: [
{
foreignKeyName: "pro_assessments_professional_id_fkey"
columns: ["professional_id"]
referencedRelation: "professionals"
referencedColumns: ["id"]
},
{
foreignKeyName: "pro_assessments_student_id_fkey"
columns: ["student_id"]
referencedRelation: "pro_students"
referencedColumns: ["id"]
}
]
}
pro_assignments: {
Row: {
id: string
professional_id: string
student_id: string
workout_id: string
start_date: string | null
end_date: string | null
notes: string | null
created_at: string
}
Insert: {
id?: string
professional_id: string
student_id: string
workout_id: string
start_date?: string | null
end_date?: string | null
notes?: string | null
created_at?: string
}
Update: {
id?: string
professional_id?: string
student_id?: string
workout_id?: string
start_date?: string | null
end_date?: string | null
notes?: string | null
created_at?: string
}
Relationships: [
{
foreignKeyName: "pro_assignments_professional_id_fkey"
columns: ["professional_id"]
referencedRelation: "professionals"
referencedColumns: ["id"]
},
{
foreignKeyName: "pro_assignments_student_id_fkey"
columns: ["student_id"]
referencedRelation: "pro_students"
referencedColumns: ["id"]
},
{
foreignKeyName: "pro_assignments_workout_id_fkey"
columns: ["workout_id"]
referencedRelation: "pro_workouts"
referencedColumns: ["id"]
}
]
}
pro_students: {
Row: {
id: string
professional_id: string
name: string
email: string | null
phone: string | null
status: 'active' | 'inactive' | 'pending' | null
linked_user_id: string | null
goals: string | null
notes: string | null
created_at: string
updated_at: string
}
Insert: {
id?: string
professional_id: string
name: string
email?: string | null
phone?: string | null
status?: 'active' | 'inactive' | 'pending' | null
linked_user_id?: string | null
goals?: string | null
notes?: string | null
created_at?: string
updated_at?: string
}
Update: {
id?: string
professional_id?: string
name?: string
email?: string | null
phone?: string | null
status?: 'active' | 'inactive' | 'pending' | null
linked_user_id?: string | null
goals?: string | null
notes?: string | null
created_at?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "pro_students_professional_id_fkey"
columns: ["professional_id"]
referencedRelation: "professionals"
referencedColumns: ["id"]
},
{
foreignKeyName: "pro_students_linked_user_id_fkey"
columns: ["linked_user_id"]
referencedRelation: "users"
referencedColumns: ["id"]
}
]
}
pro_workouts: {
Row: {
id: string
professional_id: string
title: string
description: string | null
difficulty: 'beginner' | 'intermediate' | 'advanced' | null
exercises: Json | null
tags: string[] | null
created_at: string
}
Insert: {
id?: string
professional_id: string
title: string
description?: string | null
difficulty?: 'beginner' | 'intermediate' | 'advanced' | null
exercises?: Json | null
tags?: string[] | null
created_at?: string
}
Update: {
id?: string
professional_id?: string
title?: string
description?: string | null
difficulty?: 'beginner' | 'intermediate' | 'advanced' | null
exercises?: Json | null
tags?: string[] | null
created_at?: string
}
Relationships: [
{
foreignKeyName: "pro_workouts_professional_id_fkey"
columns: ["professional_id"]
referencedRelation: "professionals"
referencedColumns: ["id"]
}
]
}
professionals: {
Row: {
id: string
business_name: string | null
cref_crn: string | null
bio: string | null
specialties: string[] | null
logo_url: string | null
primary_color: string | null
contacts: Json | null
created_at: string
updated_at: string
}
Insert: {
id: string
business_name?: string | null
cref_crn?: string | null
bio?: string | null
specialties?: string[] | null
logo_url?: string | null
primary_color?: string | null
contacts?: Json | null
created_at?: string
updated_at?: string
}
Update: {
id?: string
business_name?: string | null
cref_crn?: string | null
bio?: string | null
specialties?: string[] | null
logo_url?: string | null
primary_color?: string | null
contacts?: Json | null
created_at?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "professionals_id_fkey"
columns: ["id"]
referencedRelation: "users"
referencedColumns: ["id"]
}
]
}
profiles: {
Row: {
id: string
full_name: string
email: string
phone: string | null
created_at: string | null
updated_at: string | null
public_id: string | null
phone_e164: string | null
is_admin: boolean | null
is_professional: boolean | null
avatar_url: string | null
}
Insert: {
id: string
full_name: string
email: string
phone?: string | null
created_at?: string | null
updated_at?: string | null
public_id?: string | null
phone_e164?: string | null
is_admin?: boolean | null
is_professional?: boolean | null
avatar_url?: string | null
}
Update: {
id?: string
full_name?: string
email?: string
phone?: string | null
created_at?: string | null
updated_at?: string | null
public_id?: string | null
phone_e164?: string | null
is_admin?: boolean | null
is_professional?: boolean | null
avatar_url?: string | null
}
Relationships: [
{
foreignKeyName: "profiles_id_fkey"
columns: ["id"]
referencedRelation: "users"
referencedColumns: ["id"]
}
]
}
stripe_customers: {
Row: {
user_id: string
stripe_customer_id: string
email: string | null
created_at: string
updated_at: string
}
Insert: {
user_id: string
stripe_customer_id: string
email?: string | null
created_at?: string
updated_at?: string
}
Update: {
user_id?: string
stripe_customer_id?: string
email?: string | null
created_at?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "stripe_customers_pkey" // It's actually a PK but often a FK too
columns: ["user_id"]
referencedRelation: "users" // implicit
referencedColumns: ["id"]
}
]
}
stripe_events: {
Row: {
id: string
type: string | null
created_at: string
}
Insert: {
id: string
type?: string | null
created_at?: string
}
Update: {
id?: string
type?: string | null
created_at?: string
}
Relationships: []
}
user_entitlements: {
Row: {
user_id: string
entitlement_code: string
is_trial: boolean
is_active: boolean
valid_until: string | null
usage: Json
created_at: string
updated_at: string
plan_type: string | null
}
Insert: {
user_id: string
entitlement_code: string
is_trial?: boolean
is_active?: boolean
valid_until?: string | null
usage?: Json
created_at?: string
updated_at?: string
plan_type?: string | null
}
Update: {
user_id?: string
entitlement_code?: string
is_trial?: boolean
is_active?: boolean
valid_until?: string | null
usage?: Json
created_at?: string
updated_at?: string
plan_type?: string | null
}
Relationships: [
{
foreignKeyName: "user_entitlements_pkey"
columns: ["user_id"]
referencedRelation: "users"
referencedColumns: ["id"]
}
]
}
}
Views: {
user_access_summary: {
Row: {
user_id: string | null
free_used: number | null
free_remaining: number | null
plan_active: boolean | null
plan_code: string | null
plan_started_at: string | null
plan_valid_until: string | null
can_use_paid: boolean | null
}
}
}
}
}

112
src/lib/gemini.ts Normal file
View file

@ -0,0 +1,112 @@
import { GoogleGenAI } from "@google/genai";
const SYSTEM_PROMPT = `
Você é o FoodSnap.ai, um nutricionista comportamental e científico.
Analise a imagem enviada e retorne um JSON puro (sem markdown) seguindo estritamente este schema:
{
"items": [
{
"name": "Nome do alimento",
"portion": "Quantidade estimada (ex: 150g, 1 unidade)",
"calories": 0,
"protein": 0,
"carbs": 0,
"fat": 0,
"fiber": 0,
"sugar": 0,
"sodium_mg": 0,
"flags": ["fritura", "processado", "saudavel", "alto_acucar"]
}
],
"total": {
"calories": 0,
"protein": 0,
"carbs": 0,
"fat": 0,
"fiber": 0,
"sugar": 0,
"sodium_mg": 0
},
"category": "Café da Manhã" | "Almoço" | "Jantar" | "Lanche" | "Pré-Treino" | "Pós-Treino",
"health_score": 0,
"confidence": "alta" | "media" | "baixa",
"tip": {
"title": "Titulo curto",
"text": "Dica prática e motivadora de até 2 frases sobre a refeição.",
"reason": "Explicação científica curta"
}
}
Regras:
1. Health Score de 0 a 100. Considere densidade nutritiva, não apenas calorias.
2. Se não identificar comida, retorne lista de itens vazia e confidence "baixa".
`;
export interface AnalysisResult {
items: {
name: string;
portion: string;
calories: number;
protein: number;
carbs: number;
fat: number;
fiber: number;
sugar: number;
sodium_mg: number;
flags: string[];
}[];
total: {
calories: number;
protein: number;
carbs: number;
fat: number;
fiber: number;
sugar: number;
sodium_mg: number;
};
category: string;
health_score: number;
confidence: 'alta' | 'media' | 'baixa';
tip: {
title: string;
text: string;
reason: string;
};
}
export const analyzeImage = async (base64Image: string, mimeType: string = 'image/jpeg'): Promise<AnalysisResult> => {
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
try {
const response = await ai.models.generateContent({
model: 'gemini-2.5-flash',
contents: {
parts: [
{
inlineData: {
mimeType: mimeType,
data: base64Image
}
},
{
text: SYSTEM_PROMPT
}
]
},
config: {
responseMimeType: 'application/json',
temperature: 0.1
}
});
if (response.text) {
return JSON.parse(response.text) as AnalysisResult;
}
throw new Error("Resposta vazia da IA");
} catch (error) {
console.error("Erro na análise Gemini:", error);
throw error;
}
};

8
src/lib/supabase.ts Normal file
View file

@ -0,0 +1,8 @@
import { createClient } from "@supabase/supabase-js";
import { Database } from "./database.types";
// Credentials provided in the prompt
const SUPABASE_URL = "https://mnhgpnqkwuqzpvfrwftp.supabase.co";
const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1uaGdwbnFrd3VxenB2ZnJ3ZnRwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjU3MTk4NTUsImV4cCI6MjA4MTI5NTg1NX0.DBYmhgiZoCmA0AlejJRsTh85HxRDEnG_ihkEQ2cXcpk";
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_ANON_KEY);

17
src/main.tsx Normal file
View file

@ -0,0 +1,17 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
} else {
console.error("FATAL: Elemento root não encontrado no HTML.");
}

572
src/n8n-coach-whatsapp.json Normal file
View file

@ -0,0 +1,572 @@
{
"name": "FoodSnap - Coach AI (WhatsApp)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "wa/coach-inbound",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-500,
-940
],
"id": "webhook-coach",
"name": "Webhook (Whatsapp)"
},
{
"parameters": {
"jsCode": "const body = $json.body ?? $json;\nconst data = body.data ?? {};\n\nconst remoteJid = data?.key?.remoteJid?.includes('@s.whatsapp.net') ? data.key.remoteJid : data?.key?.remoteJidAlt || '';\nconst number = remoteJid.replace(/\\D/g, '');\nconst message_id = data?.key?.id || '';\nconst text = data?.message?.conversation || data?.message?.extendedTextMessage?.text || '';\n\n// Check for image\nconst imageMessage = data?.message?.imageMessage || data?.message?.extendedTextMessage?.contextInfo?.quotedMessage?.imageMessage || null;\n\nreturn [{\n number,\n remoteJid,\n message_id,\n text,\n hasImage: !!imageMessage,\n imageMessage,\n username: data?.pushName || 'Atleta'\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-280,
-940
],
"id": "normalize-inbound",
"name": "Normalizar Dados"
},
{
"parameters": {
"operation": "executeQuery",
"query": "select * from check_access_by_whatsapp('{{ $json.number }}')",
"options": {}
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [
-60,
-940
],
"id": "validate-user",
"name": "Validar Usuario (RPC)",
"credentials": {
"postgres": {
"id": "2JDD2OJz4cAsWb0J",
"name": "foodsnap_supabase"
}
}
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "check-user",
"leftValue": "={{ $json.exists }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
160,
-940
],
"id": "if-exists",
"name": "Usuario Existe?"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "check-process",
"leftValue": "={{ $json.can_process }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
400,
-1040
],
"id": "if-quota",
"name": "Tem Quota/Plano?"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "check-image",
"leftValue": "={{ $node[\"Normalizar Dados\"].json.hasImage }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
640,
-940
],
"id": "if-image",
"name": "Tem Imagem?"
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('Normalizar Dados').item.json.number }}",
"messageText": "💪 *Coach AI*: Olá! Envie uma foto do seu corpo (preferencialmente de frente, roupa de treino) para eu fazer uma análise rápida do seu biótipo e sugestão de treino.",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
860,
-840
],
"id": "msg-intro",
"name": "Msg Intro",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('Normalizar Dados').item.json.number }}",
"messageText": "🧐 Analisando seu físico... Um momento!",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
860,
-1040
],
"id": "msg-ack",
"name": "Msg Ack",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"resource": "chat-api",
"operation": "get-media-base64",
"instanceName": "FoodSnap",
"messageId": "={{ $('Normalizar Dados').item.json.message_id }}"
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
1080,
-1040
],
"id": "get-image",
"name": "Baixar Imagem",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"operation": "toBinary",
"sourceProperty": "data.base64",
"options": {}
},
"type": "n8n-nodes-base.convertToFile",
"typeVersion": 1.1,
"position": [
1300,
-1040
],
"id": "convert-binary",
"name": "Converter Binario"
},
{
"parameters": {
"resource": "image",
"operation": "analyze",
"modelId": {
"__rl": true,
"value": "models/gemini-pro-vision",
"mode": "list",
"cachedResultName": "models/gemini-pro-vision"
},
"text": "=Você é um Treinador Físico de Elite e Nutricionista Esportivo.\nAnalise a imagem fornecida (foto de corpo inteiro/físico).\n\n1. Verifique se é uma foto de corpo humano válida para análise fitness. Se não, retorne \"valid_body\": false.\n2. Se for válida, estime:\n - Biótipo Predominante (Ectomorfo, Mesomorfo, Endomorfo)\n - Percentual de Gordura (BF% aproximado)\n - Nível de Massa Muscular (Baixo, Médio, Alto)\n3. Sugira:\n - Objetivo Principal (Cutting, Bulking, Manutenção)\n - Divisão de Treino Recomendada (ex: ABC, ABCDE, Upper/Lower)\n - Foco Dietético (ex: Carb Cycling, Alto Carbo, Keto)\n - Dica de Ouro (uma frase curta de impacto)\n\nResponda APENAS em JSON estrito (sem markdown):\n{\n \"valid_body\": true,\n \"biotype\": \"...\",\n \"estimated_body_fat\": 15,\n \"muscle_mass\": \"Médio\",\n \"goal\": \"Bulking\",\n \"workout\": \"...\",\n \"diet\": \"...\",\n \"tip\": \"...\"\n}",
"inputType": "binary",
"simplify": false,
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.googleGemini",
"typeVersion": 1,
"position": [
1520,
-1100
],
"id": "analyze-gemini",
"name": "Gemini Coach Analysis",
"credentials": {
"googlePalmApi": {
"id": "T2uIVBcjJ9h8BFCC",
"name": "Backup APIKEY"
}
}
},
{
"parameters": {
"jsCode": "const raw = $json?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';\nconst clean = raw.replace(/```json/g, '').replace(/```/g, '').trim();\nlet data = {};\ntry { \n data = JSON.parse(clean); \n} catch(e) {\n data = { valid_body: false, error: 'Failed to parse AI' };\n}\n\nreturn [{ ...data }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1740,
-1100
],
"id": "parse-response",
"name": "Parse AI JSON"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "check-validity",
"leftValue": "={{ $json.valid_body }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
1960,
-1100
],
"id": "if-valid-body",
"name": "Corpo Valido?"
},
{
"parameters": {
"operation": "executeQuery",
"query": "insert into public.coach_analyses\n(\n user_id,\n source,\n image_url,\n ai_raw_response,\n ai_structured,\n biotype,\n estimated_body_fat,\n goal_suggestion,\n muscle_mass_level,\n used_free_quota\n)\nvalues\n(\n cast('{{ $(\"Validar Usuario & Quota\").item.json.user_id }}' as uuid),\n 'whatsapp',\n null,\n '{{ JSON.stringify($json) }}',\n '{{ JSON.stringify($json) }}',\n '{{ $json.biotype }}',\n cast({{ $json.estimated_body_fat || 0 }} as numeric),\n '{{ $json.goal }}',\n '{{ $json.muscle_mass }}',\n CASE\n WHEN {{ $(\"Validar Usuario & Quota\").item.json.plan_active }} = true THEN false\n ELSE true\n END\n)\nreturning id as analysis_id;",
"options": {}
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [
2200,
-1180
],
"id": "save-db",
"name": "Salvar DB",
"credentials": {
"postgres": {
"id": "2JDD2OJz4cAsWb0J",
"name": "foodsnap_supabase"
}
}
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('Normalizar Dados').item.json.number }}",
"messageText": "=⚡ *Análise Coach AI*\n\n🧬 *Biótipo*: {{$json.biotype}}\n⚖ *Gordura (BF)*: ~{{$json.estimated_body_fat}}%\n💪 *Massa Muscular*: {{$json.muscle_mass}}\n\n🎯 *Objetivo Sugerido*: {{$json.goal}}\n🏋 *Treino*: {{$json.workout}}\n🥗 *Dieta*: {{$json.diet}}\n\n💡 *Dica*: {{$json.tip}}\n\n_Para ver o plano completo, acesse o App!_",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
2420,
-1180
],
"id": "reply-success",
"name": "Responder Resultado",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('Normalizar Dados').item.json.number }}",
"messageText": "⚠️ Não consegui identificar um físico claro nesta foto. Tente enviar uma foto de corpo inteiro ou tronco, com boa iluminação.",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
2200,
-980
],
"id": "reply-invalid",
"name": "Responder Invalido",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('Normalizar Dados').item.json.number }}",
"messageText": "🚫 *Limite do Coach Atingido*\n\nVocê já usou suas 3 análises de Coach gratuitas. Assine o plano PRO para avaliações ilimitadas! 🚀",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
640,
-1140
],
"id": "reply-limit",
"name": "Msg Limite",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
}
],
"connections": {
"Webhook (Whatsapp)": {
"main": [
[
{
"node": "Normalizar Dados",
"type": "main",
"index": 0
}
]
]
},
"Normalizar Dados": {
"main": [
[
{
"node": "Validar Usuario & Quota",
"type": "main",
"index": 0
}
]
]
},
"Validar Usuario & Quota": {
"main": [
[
{
"node": "Usuario Existe?",
"type": "main",
"index": 0
}
]
]
},
"Usuario Existe?": {
"main": [
[
{
"node": "Tem Quota/Plano?",
"type": "main",
"index": 0
}
]
]
},
"Tem Quota/Plano?": {
"main": [
[
{
"node": "Tem Imagem?",
"type": "main",
"index": 0
}
],
[
{
"node": "Msg Limite",
"type": "main",
"index": 0
}
]
]
},
"Tem Imagem?": {
"main": [
[
{
"node": "Msg Ack",
"type": "main",
"index": 0
}
],
[
{
"node": "Msg Intro",
"type": "main",
"index": 0
}
]
]
},
"Msg Ack": {
"main": [
[
{
"node": "Baixar Imagem",
"type": "main",
"index": 0
}
]
]
},
"Baixar Imagem": {
"main": [
[
{
"node": "Converter Binario",
"type": "main",
"index": 0
}
]
]
},
"Converter Binario": {
"main": [
[
{
"node": "Gemini Coach Analysis",
"type": "main",
"index": 0
}
]
]
},
"Gemini Coach Analysis": {
"main": [
[
{
"node": "Parse AI JSON",
"type": "main",
"index": 0
}
]
]
},
"Parse AI JSON": {
"main": [
[
{
"node": "Corpo Valido?",
"type": "main",
"index": 0
}
]
]
},
"Corpo Valido?": {
"main": [
[
{
"node": "Salvar DB",
"type": "main",
"index": 0
}
],
[
{
"node": "Responder Invalido",
"type": "main",
"index": 0
}
]
]
},
"Salvar DB": {
"main": [
[
{
"node": "Responder Resultado",
"type": "main",
"index": 0
}
]
]
}
}
}

199
src/n8n-daily-report.json Normal file
View file

@ -0,0 +1,199 @@
{
"name": "FoodSnap - Daily Report (Cron)",
"nodes": [
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 20 * * *"
}
]
}
},
"type": "n8n-nodes-base.schedule",
"typeVersion": 1.1,
"position": [
-300,
-740
],
"id": "schedule-trigger",
"name": "Every Day 20h"
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT \n p.id as user_id,\n p.phone_e164\nFROM public.profiles p\nJOIN public.food_analyses f ON f.user_id = p.id\nWHERE f.created_at >= CURRENT_DATE\nGROUP BY p.id, p.phone_e164;",
"options": {}
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [
-80,
-740
],
"id": "get-active-users",
"name": "Get Users Active Today",
"credentials": {
"postgres": {
"id": "2JDD2OJz4cAsWb0J",
"name": "foodsnap_supabase"
}
}
},
{
"parameters": {
"batchSize": 1,
"options": {}
},
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [
140,
-740
],
"id": "split-users",
"name": "Split Users"
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT \n SUM(total_calories) as total_cals, \n SUM(total_protein) as total_prot,\n SUM(total_carbs) as total_carbs, \n SUM(total_fat) as total_fat,\n COUNT(*) as meal_count,\n AVG(nutrition_score)::numeric(10,1) as avg_score\nFROM public.food_analyses \nWHERE user_id = '{{ $json.user_id }}'::uuid \nAND created_at >= CURRENT_DATE;",
"options": {}
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [
360,
-740
],
"id": "get-daily-stats",
"name": "Get Daily Stats",
"credentials": {
"postgres": {
"id": "2JDD2OJz4cAsWb0J",
"name": "foodsnap_supabase"
}
}
},
{
"parameters": {
"modelId": {
"__rl": true,
"value": "models/gemini-pro",
"mode": "list",
"cachedResultName": "models/gemini-pro"
},
"promptType": "define",
"text": "=Você é um Nutricionista IA do FoodSnap.\n\nDados do Usuário Hoje:\n- Refeições: {{ $json.meal_count }}\n- Calorias Totais: {{ $json.total_cals }} kcal\n- Proteínas: {{ $json.total_prot }}g\n- Carbos: {{ $json.total_carbs }}g\n- Gorduras: {{ $json.total_fat }}g\n- Score Médio (0-100): {{ $json.avg_score }}\n\nCrie uma mensagem curta (máx 3 linhas) para o WhatsApp.\n1. Elogie se bateu meta (assuma 2000kcal base se não tiver dado).\n2. Dê uma dica rápida para amanhã baseada nos macros.\n3. Termine motivacional.\n4. Use emojis.\n5. Não use markdown bold (*) excessivamente, só em palavras chave.\n\nExemplo:\n\"Olá! Hoje você mandou bem nas proteínas (120g)! 💪 Amanhã tente reduzir um pouco a gordura no jantar. Continue assim! 🚀\"",
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.googleGemini",
"typeVersion": 1,
"position": [
580,
-740
],
"id": "generate-insight",
"name": "Generate Insight",
"credentials": {
"googlePalmApi": {
"id": "T2uIVBcjJ9h8BFCC",
"name": "Backup APIKEY"
}
}
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('Split Users').item.json.phone_e164.replace('+', '') }}",
"messageText": "=📊 *FoodSnap Diário*\n\nHoje você registrou {{ $('Get Daily Stats').item.json.meal_count }} refeições.\n🔥 *{{ $('Get Daily Stats').item.json.total_cals }} kcal* | 🥩 {{ $('Get Daily Stats').item.json.total_prot }}g Prot\n\n{{ $json.text }}\n\n_Até amanhã!_",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
800,
-740
],
"id": "send-whatsapp",
"name": "Enviar WhatsApp",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
}
],
"connections": {
"Every Day 20h": {
"main": [
[
{
"node": "Get Users Active Today",
"type": "main",
"index": 0
}
]
]
},
"Get Users Active Today": {
"main": [
[
{
"node": "Split Users",
"type": "main",
"index": 0
}
]
]
},
"Split Users": {
"main": [
[
{
"node": "Get Daily Stats",
"type": "main",
"index": 0
}
]
]
},
"Get Daily Stats": {
"main": [
[
{
"node": "Generate Insight",
"type": "main",
"index": 0
}
]
]
},
"Generate Insight": {
"main": [
[
{
"node": "Enviar WhatsApp",
"type": "main",
"index": 0
}
]
]
},
"Enviar WhatsApp": {
"main": [
[
{
"node": "Split Users",
"type": "main",
"index": 0
}
]
]
}
}
}

View file

@ -0,0 +1,911 @@
{
"name": "FoodSnap - Switch (Food & Coach)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "wa/inbound",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-740,
-940
],
"id": "f33b8fb6-babb-4beb-ab36-ec6a25f14eb2",
"name": "Requisicao - Whatsapp",
"webhookId": "2179d0c4-aaf5-4ce4-9463-332f09919612"
},
{
"parameters": {
"jsCode": "const body = $json.body ?? $json;\nconst data = body.data ?? {};\n\nconst remoteJid = data?.key?.remoteJid?.includes('@s.whatsapp.net') ? data.key.remoteJid : data?.key?.remoteJidAlt || '';\nconst number = remoteJid.replace(/\\D/g, '');\nconst message_id = data?.key?.id || '';\n\n// Texto (incluindo legenda de imagem)\nconst text = data?.message?.conversation || data?.message?.extendedTextMessage?.text || data?.message?.imageMessage?.caption || '';\n\nconst imageMessage = data?.message?.imageMessage || data?.message?.extendedTextMessage?.contextInfo?.quotedMessage?.imageMessage || null;\n\nreturn [{\n number,\n remoteJid,\n message_id,\n text,\n hasImage: !!imageMessage,\n imageMessage,\n pushName: data?.pushName || 'Usuário',\n timestamp: new Date().toISOString(),\n raw: body\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-520,
-940
],
"id": "32f29e03-c120-4425-b8da-5f9984503e63",
"name": "NormalizeInbound"
},
{
"parameters": {
"dataType": "string",
"value1": "={{ $json.text }}",
"rules": {
"rules": [
{
"operation": "contains",
"value2": "coach",
"output": 1
},
{
"operation": "contains",
"value2": "treino",
"output": 1
},
{
"operation": "contains",
"value2": "biotipo",
"output": 1
},
{
"operation": "contains",
"value2": "shape",
"output": 1
}
]
},
"fallbackOutput": 0
},
"type": "n8n-nodes-base.switch",
"typeVersion": 1,
"position": [
-300,
-940
],
"id": "switch-router",
"name": "Roteador (Food/Coach)"
},
{
"parameters": {
"operation": "executeQuery",
"query": "with u as ( select id from public.profiles where phone_e164 = cast({{ $('NormalizeInbound').item.json.number }} as text) limit 1 ), ent as ( select ue.user_id, ue.is_active, ue.entitlement_code, ue.valid_until from public.user_entitlements ue where ue.user_id = (select id from u) order by ue.valid_until desc nulls last limit 1 ), usage as ( select (select id from u) as user_id, count(*) filter (where fa.used_free_quota = true) as free_used from public.food_analyses fa where fa.user_id = (select id from u) ) select (select id from u) is not null as exists, (select id from u) as user_id, coalesce((select free_used from usage), 0)::int as free_used, greatest(0, 5 - coalesce((select free_used from usage), 0))::int as free_remaining, coalesce((select is_active from ent), false) as plan_active, ( (select id from u) is not null and ( ( coalesce((select is_active from ent), false) and ( (select valid_until from ent) is null or (select valid_until from ent) > now() ) ) or greatest(0, 5 - coalesce((select free_used from usage), 0)) > 0 ) ) as can_process;",
"options": {}
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [
-60,
-1040
],
"id": "a329a262-e03c-41f2-9d96-5d37ed5f6159",
"name": "Validar Usuario (Food)",
"credentials": {
"postgres": {
"id": "2JDD2OJz4cAsWb0J",
"name": "foodsnap_supabase"
}
}
},
{
"parameters": {
"operation": "executeQuery",
"query": "with u as ( select id from public.profiles where phone_e164 = cast({{ $('NormalizeInbound').item.json.number }} as text) limit 1 ), ent as ( select ue.is_active, ue.entitlement_code, ue.valid_until from public.user_entitlements ue where ue.user_id = (select id from u) order by ue.valid_until desc nulls last limit 1 ), usage as ( select count(*) as used_count from public.coach_analyses fa where fa.user_id = (select id from u) and fa.used_free_quota = true ) select (select id from u) as user_id, (select id from u) is not null as exists, coalesce((select used_count from usage), 0)::int as free_used, greatest(0, 3 - coalesce((select used_count from usage), 0))::int as free_remaining, coalesce((select is_active from ent), false) as plan_active, (coalesce((select is_active from ent), false) = true OR greatest(0, 3 - coalesce((select used_count from usage), 0)) > 0) as can_process",
"options": {}
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [
-60,
-700
],
"id": "validate-coach",
"name": "Validar Coach",
"credentials": {
"postgres": {
"id": "2JDD2OJz4cAsWb0J",
"name": "foodsnap_supabase"
}
}
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "check-process-coach",
"leftValue": "={{ $json.can_process }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
180,
-700
],
"id": "if-coach-quota",
"name": "Pode usar Coach?"
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('NormalizeInbound').item.json.number }}",
"messageText": "🏋️ *Coach AI*: Analisando seu físico... Aguarde!",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
420,
-700
],
"id": "msg-ack-coach",
"name": "Ack Coach",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"resource": "chat-api",
"operation": "get-media-base64",
"instanceName": "FoodSnap",
"messageId": "={{ $('NormalizeInbound').item.json.message_id }}"
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
640,
-700
],
"id": "get-img-coach",
"name": "Baixar IMG Coach",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"operation": "toBinary",
"sourceProperty": "data.base64",
"options": {}
},
"type": "n8n-nodes-base.convertToFile",
"typeVersion": 1.1,
"position": [
860,
-700
],
"id": "bin-coach",
"name": "Binary Coach"
},
{
"parameters": {
"resource": "image",
"operation": "analyze",
"modelId": {
"__rl": true,
"value": "models/gemini-2.5-flash",
"mode": "list",
"cachedResultName": "models/gemini-2.5-flash"
},
"text": "=Você é um Treinador Físico de Elite e Nutricionista Esportivo.\nAnalise a imagem fornecida (foto de corpo inteiro/físico).\n\n1. Verifique se é uma foto de corpo humano válida para análise fitness. Se não, retorne \"valid_body\": false.\n2. Se for válida, estime:\n - Biótipo Predominante (Ectomorfo, Mesomorfo, Endomorfo)\n - Percentual de Gordura (BF% aproximado)\n - Nível de Massa Muscular (Baixo, Médio, Alto)\n3. Sugira:\n - Objetivo Principal (Cutting, Bulking, Manutenção)\n - Divisão de Treino Recomendada (ex: ABC, ABCDE, Upper/Lower)\n - Foco Dietético (ex: Carb Cycling, Alto Carbo, Keto)\n - Dica de Ouro (uma frase curta de impacto)\n\nResponda APENAS em JSON estrito (sem markdown):\n{\n \"valid_body\": true,\n \"biotype\": \"...\",\n \"estimated_body_fat\": 15,\n \"muscle_mass\": \"Médio\",\n \"goal\": \"Bulking\",\n \"workout\": \"...\",\n \"diet\": \"...\",\n \"tip\": \"...\"\n}",
"inputType": "binary",
"simplify": false,
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.googleGemini",
"typeVersion": 1,
"position": [
1080,
-700
],
"id": "gemini-coach",
"name": "Gemini Coach",
"credentials": {
"googlePalmApi": {
"id": "T2uIVBcjJ9h8BFCC",
"name": "Backup APIKEY"
}
}
},
{
"parameters": {
"jsCode": "const raw = $json?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';\nconst clean = raw.replace(/```json/g, '').replace(/```/g, '').trim();\nlet data = {};\ntry { \n data = JSON.parse(clean); \n} catch(e) {\n data = { valid_body: false, error: 'Failed to parse AI' };\n}\n\nreturn [{ ...data }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1300,
-700
],
"id": "parse-coach",
"name": "Parse Coach"
},
{
"parameters": {
"operation": "executeQuery",
"query": "insert into public.coach_analyses\n(\n user_id,\n source,\n image_url,\n ai_raw_response,\n ai_structured,\n biotype,\n estimated_body_fat,\n goal_suggestion,\n muscle_mass_level,\n used_free_quota\n)\nvalues\n(\n cast('{{ $(\"Validar Coach\").item.json.user_id }}' as uuid),\n 'whatsapp',\n null,\n '{{ JSON.stringify($json) }}',\n '{{ JSON.stringify($json) }}',\n '{{ $json.biotype }}',\n cast({{ $json.estimated_body_fat || 0 }} as numeric),\n '{{ $json.goal }}',\n '{{ $json.muscle_mass }}',\n CASE\n WHEN {{ $(\"Validar Coach\").item.json.plan_active }} = true THEN false\n ELSE true\n END\n)\nreturning id as analysis_id;",
"options": {}
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [
1520,
-700
],
"id": "save-coach",
"name": "Salvar Coach DB",
"credentials": {
"postgres": {
"id": "2JDD2OJz4cAsWb0J",
"name": "foodsnap_supabase"
}
}
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('NormalizeInbound').item.json.number }}",
"messageText": "=⚡ *Coach AI*\n\n🧬 *Biótipo*: {{$json.biotype}}\n⚖ *BF*: ~{{$json.estimated_body_fat}}%\n💪 *Massa*: {{$json.muscle_mass}}\n\n🎯 *Foco*: {{$json.goal}}\n🏋 *Treino*: {{$json.workout}}\n\n💡 *Dica*: {{$json.tip}}\n\n_Veja mais no App!_",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
1740,
-700
],
"id": "reply-coach",
"name": "Responder Coach",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "70a7760f-4a83-4a80-bbbc-9eaf93a06a33",
"leftValue": "={{$json.exists}}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
160,
-1040
],
"id": "9e1ea558-2466-4426-9c9f-f5051e76da4f",
"name": "Usuário existe?"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "873dc279-9223-464a-b632-bf019f20c030",
"leftValue": "={{ $json.can_process }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
380,
-1120
],
"id": "63ea8c4f-ebe3-4c0b-95bb-51dc0c64d639",
"name": "Pode usar Food?"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "a5e5b4e5-ce26-4b2b-90e6-ba96e55006a8",
"leftValue": "={{$node[\"NormalizeInbound\"].json.hasImage}}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
600,
-1020
],
"id": "7130f3dd-fc60-4cba-b064-d7d98e846b86",
"name": "If texto? imagem?"
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('NormalizeInbound').item.json.number }}",
"messageText": "👋 Olá! Envie uma *foto do prato* para calorias ou escreva *'Coach'* e envie uma foto do corpo para análise física.",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
820,
-920
],
"id": "5f897b38-e120-4f68-8870-6d793d22a3ff",
"name": "Enviar texto help",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('NormalizeInbound').item.json.number }}",
"messageText": "📸 Recebi sua foto! Analisando o prato... ⏳",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
820,
-1120
],
"id": "704eec2f-2f98-4872-b414-c805f0642ef3",
"name": "Ack_Recebi_Foto",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"resource": "chat-api",
"operation": "get-media-base64",
"instanceName": "FoodSnap",
"messageId": "={{ $('Requisicao - Whatsapp').item.json.body.data.key.id }}"
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
1040,
-1120
],
"id": "200672ed-2daf-46ee-9853-08bff8b55c86",
"name": "Imagem Base64",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"operation": "toBinary",
"sourceProperty": "data.base64",
"options": {}
},
"type": "n8n-nodes-base.convertToFile",
"typeVersion": 1.1,
"position": [
1260,
-1120
],
"id": "0076759b-6f96-4f58-a828-bd329a803054",
"name": "Converter Base64/Binario"
},
{
"parameters": {
"resource": "image",
"operation": "analyze",
"modelId": {
"__rl": true,
"value": "models/gemini-2.5-flash",
"mode": "list",
"cachedResultName": "models/gemini-2.5-flash"
},
"text": "=Você é um assistente nutricional... (Prompt Original de Comida)... Retorne SOMENTE JSON.",
"inputType": "binary",
"simplify": false,
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.googleGemini",
"typeVersion": 1,
"position": [
1480,
-1120
],
"id": "629515c5-8021-4162-9a5f-aac2c5f4cb82",
"name": "Analyze an image Food",
"credentials": {
"googlePalmApi": {
"id": "T2uIVBcjJ9h8BFCC",
"name": "Backup APIKEY"
}
}
},
{
"parameters": {
"jsCode": "// Código original de limpeza do Food\nconst raw = $json?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';\n// ... continuação do código original ...\nconst clean = raw.replace(/```json/gi, \"\").replace(/```/g, \"\").trim();\nlet parsed = JSON.parse(clean);\n// ... normalizações ...\nreturn [parsed];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1700,
-1120
],
"id": "6c7ed1f5-61d5-4911-b518-6bb3ff1205b5",
"name": "Limpar Resultado Food"
},
{
"parameters": {
"jsCode": "const payload = Array.isArray($json) ? $json[0] : $json;\nconst sender = payload?.sender || $node[\"NormalizeInbound\"]?.json?.number || \"\";\nconst analysis_json = payload && typeof payload === \"object\" ? payload : {};\nconst updated_at = new Date().toISOString();\nreturn [{ sender, analysis_json, updated_at }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1920,
-1120
],
"id": "b6f194fa-71ba-4b05-b448-fdb67957ae1b",
"name": "Salvar Analise Prep"
},
{
"parameters": {
"operation": "executeQuery",
"query": "insert into public.food_analyses (user_id, source, ai_raw_response, ai_structured, total_calories) values (cast('{{ $(\"Validar Usuario (Food)\").item.json.user_id }}' as uuid), 'whatsapp', cast('{{ $node[\"Analyze an image Food\"].json.candidates[0].content.parts[0].text }}' as text), cast('{{ JSON.stringify($node[\"Limpar Resultado Food\"].json) }}' as jsonb), cast({{ $node[\"Limpar Resultado Food\"].json.total.calories }} as int)) returning id as analysis_id;",
"options": {}
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [
2140,
-1120
],
"id": "556c631a-4a54-4cc1-b6f1-dd0473135a0f",
"name": "Salvar historico Food",
"credentials": {
"postgres": {
"id": "2JDD2OJz4cAsWb0J",
"name": "foodsnap_supabase"
}
}
},
{
"parameters": {
"jsCode": "const items = $json.analysis_json?.items || [];\nconst total = $json.analysis_json?.total || {};\nconst lines = [\"🥗 *RELATÓRIO PRATOFIT*\"];\n// ... lógica original de formatação ...\nreturn [{ text: lines.join(\"\\n\") }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2360,
-1120
],
"id": "e3bdb17a-fb9c-4178-b751-3c80ee616bd7",
"name": "Formatar Resposta Food"
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{$node[\"NormalizeInbound\"].json.number}}",
"messageText": "={{$json.text}}",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
2580,
-1120
],
"id": "e887d0a0-9bcb-4820-95dc-9c115a9b2a48",
"name": "Resposta WPP Food",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('NormalizeInbound').item.json.number }}",
"messageText": "🚫 *Acesso restrito* Seu número não está cadastrado. Cadastre-se em: https://foodsnap.com.br",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
400,
-880
],
"id": "c9352ebc-63bc-421a-9e79-a98492ec996a",
"name": "Nao Cadastrado"
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('NormalizeInbound').item.json.number }}",
"messageText": "🚫 Limite gratuito atingido. Assine um plano em foodsnap.com.br",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
600,
-1220
],
"id": "baea905c-add5-48a8-9c9e-81441c6c56d9",
"name": "Sem Plano Food",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
}
],
"connections": {
"Requisicao - Whatsapp": {
"main": [
[
{
"node": "NormalizeInbound",
"type": "main",
"index": 0
}
]
]
},
"NormalizeInbound": {
"main": [
[
{
"node": "Roteador (Food/Coach)",
"type": "main",
"index": 0
}
]
]
},
"Roteador (Food/Coach)": {
"main": [
[
{
"node": "Validar Usuario (Food)",
"type": "main",
"index": 0
}
],
[
{
"node": "Validar Coach",
"type": "main",
"index": 0
}
]
]
},
"Validar Usuario (Food)": {
"main": [
[
{
"node": "Usuário existe?",
"type": "main",
"index": 0
}
]
]
},
"Usuário existe?": {
"main": [
[
{
"node": "Pode usar Food?",
"type": "main",
"index": 0
}
],
[
{
"node": "Nao Cadastrado",
"type": "main",
"index": 0
}
]
]
},
"Pode usar Food?": {
"main": [
[
{
"node": "If texto? imagem?",
"type": "main",
"index": 0
}
],
[
{
"node": "Sem Plano Food",
"type": "main",
"index": 0
}
]
]
},
"If texto? imagem?": {
"main": [
[
{
"node": "Ack_Recebi_Foto",
"type": "main",
"index": 0
}
],
[
{
"node": "Enviar texto help",
"type": "main",
"index": 0
}
]
]
},
"Ack_Recebi_Foto": {
"main": [
[
{
"node": "Imagem Base64",
"type": "main",
"index": 0
}
]
]
},
"Imagem Base64": {
"main": [
[
{
"node": "Converter Base64/Binario",
"type": "main",
"index": 0
}
]
]
},
"Converter Base64/Binario": {
"main": [
[
{
"node": "Analyze an image Food",
"type": "main",
"index": 0
}
]
]
},
"Analyze an image Food": {
"main": [
[
{
"node": "Limpar Resultado Food",
"type": "main",
"index": 0
}
]
]
},
"Limpar Resultado Food": {
"main": [
[
{
"node": "Salvar Analise Prep",
"type": "main",
"index": 0
}
]
]
},
"Salvar Analise Prep": {
"main": [
[
{
"node": "Salvar historico Food",
"type": "main",
"index": 0
},
{
"node": "Formatar Resposta Food",
"type": "main",
"index": 0
}
]
]
},
"Formatar Resposta Food": {
"main": [
[
{
"node": "Resposta WPP Food",
"type": "main",
"index": 0
}
]
]
},
"Validar Coach": {
"main": [
[
{
"node": "Pode usar Coach?",
"type": "main",
"index": 0
}
]
]
},
"Pode usar Coach?": {
"main": [
[
{
"node": "Ack Coach",
"type": "main",
"index": 0
}
]
]
},
"Ack Coach": {
"main": [
[
{
"node": "Baixar IMG Coach",
"type": "main",
"index": 0
}
]
]
},
"Baixar IMG Coach": {
"main": [
[
{
"node": "Binary Coach",
"type": "main",
"index": 0
}
]
]
},
"Binary Coach": {
"main": [
[
{
"node": "Gemini Coach",
"type": "main",
"index": 0
}
]
]
},
"Gemini Coach": {
"main": [
[
{
"node": "Parse Coach",
"type": "main",
"index": 0
}
]
]
},
"Parse Coach": {
"main": [
[
{
"node": "Salvar Coach DB",
"type": "main",
"index": 0
}
]
]
},
"Salvar Coach DB": {
"main": [
[
{
"node": "Responder Coach",
"type": "main",
"index": 0
}
]
]
}
}
}

View file

@ -0,0 +1,713 @@
{
"name": "FoodSnap - Unified (Food & Coach)",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "wa/inbound",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [
-680,
-940
],
"id": "webhook-unified",
"name": "Webhook (Whatsapp)"
},
{
"parameters": {
"jsCode": "const body = $json.body ?? $json;\nconst data = body.data ?? {};\n\n// =========================\n// RemoteJid (prioridade s.whatsapp.net)\n// =========================\nconst remoteJid =\n data?.key?.remoteJid?.includes('@s.whatsapp.net')\n ? data.key.remoteJid\n : data?.key?.remoteJidAlt || '';\n\n// número limpo (E.164 sem +)\nconst number = remoteJid.replace(/\\D/g, '');\n\n// =========================\n// Message ID\n// =========================\nconst message_id = data?.key?.id || '';\n\n// =========================\n// Texto e Caption\n// =========================\n// Verifica conversation, extendedTextMessage (text) e imageMessage (caption)\nconst text =\n data?.message?.conversation ||\n data?.message?.extendedTextMessage?.text ||\n data?.message?.imageMessage?.caption ||\n '';\n\n// =========================\n// Imagem\n// =========================\nconst imageMessage =\n data?.message?.imageMessage ||\n data?.message?.extendedTextMessage?.contextInfo?.quotedMessage?.imageMessage ||\n null;\n\n// =========================\n// Return normalizado\n// =========================\nreturn [\n {\n number,\n remoteJid,\n message_id,\n text,\n hasImage: !!imageMessage,\n imageMessage,\n pushName: data?.pushName || 'Atleta',\n timestamp: new Date().toISOString(),\n raw: body\n }\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-460,
-940
],
"id": "normalize-inbound",
"name": "Normalizar Dados"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": false,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "check-coach",
"leftValue": "={{ $json.text }}",
"rightValue": "coach,treino,shape,biotipo,fisico,musculo",
"operator": {
"type": "string",
"operation": "contains",
"singleValue": true
}
}
],
"combinator": "or"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
-240,
-940
],
"id": "router-intent",
"name": "É Coach?"
},
{
"parameters": {
"operation": "executeQuery",
"query": "with u as (\n select id\n from public.profiles\n where phone_e164 = cast({{ $('Normalizar Dados').item.json.number }} as text)\n limit 1\n),\nent as (\n select ue.is_active, ue.entitlement_code, ue.valid_until\n from public.user_entitlements ue\n where ue.user_id = (select id from u)\n order by ue.valid_until desc nulls last\n limit 1\n),\nusage as (\n select count(*) as used_count\n from public.coach_analyses fa\n where fa.user_id = (select id from u)\n and fa.used_free_quota = true\n)\nselect\n (select id from u) as user_id,\n (select id from u) is not null as exists,\n coalesce((select used_count from usage), 0)::int as free_used,\n greatest(0, 3 - coalesce((select used_count from usage), 0))::int as free_remaining,\n coalesce((select is_active from ent), false) as plan_active,\n (coalesce((select is_active from ent), false) = true OR greatest(0, 3 - coalesce((select used_count from usage), 0)) > 0) as can_process",
"options": {}
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [
20,
-1160
],
"id": "validate-coach",
"name": "Validação Coach",
"credentials": {
"postgres": {
"id": "2JDD2OJz4cAsWb0J",
"name": "foodsnap_supabase"
}
}
},
{
"parameters": {
"operation": "executeQuery",
"query": "with u as (\n select id\n from public.profiles\n where phone_e164 = cast({{ $('Normalizar Dados').item.json.number }} as text)\n limit 1\n),\nent as (\n select\n ue.user_id,\n ue.is_active,\n ue.entitlement_code\n from public.user_entitlements ue\n where ue.user_id = (select id from u)\n order by ue.valid_until desc nulls last\n limit 1\n),\nusage as (\n select count(*) filter (where fa.used_free_quota = true) as free_used\n from public.food_analyses fa\n where fa.user_id = (select id from u)\n)\nselect\n (select id from u) is not null as exists,\n (select id from u) as user_id,\n coalesce((select free_used from usage), 0)::int as free_used,\n greatest(0, 5 - coalesce((select free_used from usage), 0))::int as free_remaining,\n coalesce((select is_active from ent), false) as plan_active,\n (\n (select id from u) is not null\n and (\n coalesce((select is_active from ent), false) = true\n or greatest(0, 5 - coalesce((select free_used from usage), 0)) > 0\n )\n ) as can_process;",
"options": {}
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [
20,
-740
],
"id": "validate-food",
"name": "Validação Food",
"credentials": {
"postgres": {
"id": "2JDD2OJz4cAsWb0J",
"name": "foodsnap_supabase"
}
}
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "check-process",
"leftValue": "={{ $json.can_process }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
260,
-1160
],
"id": "if-coach-quota",
"name": "Coach OK?"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 3
},
"conditions": [
{
"id": "check-process-food",
"leftValue": "={{ $json.can_process }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.3,
"position": [
260,
-740
],
"id": "if-food-quota",
"name": "Food OK?"
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('Normalizar Dados').item.json.number }}",
"messageText": "🧐 *Coach AI*: Analisando seu biótipo e gerando seu treino... Aguarde!",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
500,
-1260
],
"id": "msg-ack-coach",
"name": "Ack Coach",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"resource": "chat-api",
"operation": "get-media-base64",
"instanceName": "FoodSnap",
"messageId": "={{ $('Normalizar Dados').item.json.message_id }}"
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
720,
-1260
],
"id": "get-image-coach",
"name": "Baixar IMG Coach",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"operation": "toBinary",
"sourceProperty": "data.base64",
"options": {}
},
"type": "n8n-nodes-base.convertToFile",
"typeVersion": 1.1,
"position": [
940,
-1260
],
"id": "convert-binary-coach",
"name": "Binário Coach"
},
{
"parameters": {
"resource": "image",
"operation": "analyze",
"modelId": {
"__rl": true,
"value": "models/gemini-1.5-flash",
"mode": "list",
"cachedResultName": "models/gemini-1.5-flash"
},
"text": "=Você é um Treinador Físico de Elite e Nutricionista Esportivo.\nAnalise a imagem fornecida (foto de corpo inteiro/físico).\n\n1. Verifique se é uma foto de corpo humano válida para análise fitness. Se não, retorne \"valid_body\": false.\n2. Se for válida, estime:\n - Biótipo Predominante (Ectomorfo, Mesomorfo, Endomorfo)\n - Percentual de Gordura (BF% aproximado)\n - Nível de Massa Muscular (Baixo, Médio, Alto)\n3. Sugira:\n - Objetivo Principal (Cutting, Bulking, Manutenção)\n - Divisão de Treino Recomendada (ex: ABC, ABCDE, Upper/Lower)\n - Foco Dietético (ex: Carb Cycling, Alto Carbo, Keto)\n - Dica de Ouro (uma frase curta de impacto)\n\nResponda APENAS em JSON estrito (sem markdown):\n{\n \"valid_body\": true,\n \"biotype\": \"...\",\n \"estimated_body_fat\": 15,\n \"muscle_mass\": \"Médio\",\n \"goal\": \"Bulking\",\n \"workout\": \"...\",\n \"diet\": \"...\",\n \"tip\": \"...\"\n}",
"inputType": "binary",
"simplify": false,
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.googleGemini",
"typeVersion": 1,
"position": [
1160,
-1260
],
"id": "gemini-coach",
"name": "Gemini Coach",
"credentials": {
"googlePalmApi": {
"id": "T2uIVBcjJ9h8BFCC",
"name": "Backup APIKEY"
}
}
},
{
"parameters": {
"jsCode": "const raw = $json?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';\nconst clean = raw.replace(/```json/g, '').replace(/```/g, '').trim();\nlet data = {};\ntry { \n data = JSON.parse(clean); \n} catch(e) {\n data = { valid_body: false, error: 'Failed to parse AI' };\n}\n\nreturn [{ ...data }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1380,
-1260
],
"id": "parse-coach-json",
"name": "Parse Coach JSON"
},
{
"parameters": {
"operation": "executeQuery",
"query": "insert into public.coach_analyses\n(\n user_id,\n source,\n image_url,\n ai_raw_response,\n ai_structured,\n biotype,\n estimated_body_fat,\n goal_suggestion,\n muscle_mass_level,\n used_free_quota\n)\nvalues\n(\n cast('{{ $(\"Validação Coach\").item.json.user_id }}' as uuid),\n 'whatsapp',\n null,\n '{{ JSON.stringify($json) }}',\n '{{ JSON.stringify($json) }}',\n '{{ $json.biotype }}',\n cast({{ $json.estimated_body_fat || 0 }} as numeric),\n '{{ $json.goal }}',\n '{{ $json.muscle_mass }}',\n CASE\n WHEN {{ $(\"Validação Coach\").item.json.plan_active }} = true THEN false\n ELSE true\n END\n)\nreturning id as analysis_id;",
"options": {}
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [
1600,
-1260
],
"id": "save-coach-db",
"name": "Salvar Coach DB",
"credentials": {
"postgres": {
"id": "2JDD2OJz4cAsWb0J",
"name": "foodsnap_supabase"
}
}
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('Normalizar Dados').item.json.number }}",
"messageText": "=⚡ *Coach AI Report*\n\n🧬 *Biótipo*: {{$json.biotype}}\n⚖ *BF Estimado*: ~{{$json.estimated_body_fat}}%\n💪 *Massa Muscular*: {{$json.muscle_mass}}\n\n🎯 *Foco*: {{$json.goal}}\n🏋 *Treino*: {{$json.workout}}\n🥗 *Dieta*: {{$json.diet}}\n\n💡 *Dica*: {{$json.tip}}\n\n_Acesse o App para ver a ficha completa!_",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
1820,
-1260
],
"id": "reply-coach",
"name": "Responder Coach",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('Normalizar Dados').item.json.number }}",
"messageText": "📸 Recebi sua foto! Analisando o prato... ⏳",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
500,
-740
],
"id": "ack-food",
"name": "Ack Food",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"resource": "chat-api",
"operation": "get-media-base64",
"instanceName": "FoodSnap",
"messageId": "={{ $('Normalizar Dados').item.json.message_id }}"
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
720,
-740
],
"id": "get-image-food",
"name": "Baixar IMG Food",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"operation": "toBinary",
"sourceProperty": "data.base64",
"options": {}
},
"type": "n8n-nodes-base.convertToFile",
"typeVersion": 1.1,
"position": [
940,
-740
],
"id": "convert-binary-food",
"name": "Binário Food"
},
{
"parameters": {
"resource": "image",
"operation": "analyze",
"modelId": {
"__rl": true,
"value": "models/gemini-1.5-flash",
"mode": "list",
"cachedResultName": "models/gemini-1.5-flash"
},
"text": "=Você é um assistente nutricional... (Prompt Original de Comida)... Retorne SOMENTE JSON.",
"inputType": "binary",
"simplify": false,
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.googleGemini",
"typeVersion": 1,
"position": [
1160,
-740
],
"id": "gemini-food",
"name": "Gemini Food",
"credentials": {
"googlePalmApi": {
"id": "T2uIVBcjJ9h8BFCC",
"name": "Backup APIKEY"
}
}
},
{
"parameters": {
"jsCode": "// Limpeza de JSON da Comida (Original)\nconst raw = $json?.candidates?.[0]?.content?.parts?.[0]?.text || '{}';\n// ... lógica existente de parse do FoodSnap ...\nreturn [{\n items: [],\n total: { calories: 500, protein: 30 },\n tip: { text: \"Exemplo de análise de comida\" }\n}];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1380,
-740
],
"id": "parse-food",
"name": "Parse Food",
"notes": "Lógica completa de parse de comida aqui (resumida para o arquivo)"
},
{
"parameters": {
"operation": "executeQuery",
"query": "insert into public.food_analyses ... (SQL Original)",
"options": {}
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 2.6,
"position": [
1600,
-740
],
"id": "save-food-db",
"name": "Salvar Food DB",
"credentials": {
"postgres": {
"id": "2JDD2OJz4cAsWb0J",
"name": "foodsnap_supabase"
}
}
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('Normalizar Dados').item.json.number }}",
"messageText": "=🥗 *FoodSnap*: Calorias: {{$json.total.calories}} ... (Formato Original)",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
1820,
-740
],
"id": "reply-food",
"name": "Responder Food",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
},
{
"parameters": {
"resource": "messages-api",
"instanceName": "FoodSnap",
"remoteJid": "={{ $('Normalizar Dados').item.json.number }}",
"messageText": "⚠️ Por favor, envie uma *imagem* para análise.",
"options_message": {}
},
"type": "n8n-nodes-evolution-api.evolutionApi",
"typeVersion": 1,
"position": [
260,
-500
],
"id": "msg-no-image",
"name": "Sem Imagem",
"credentials": {
"evolutionApi": {
"id": "nGWBERcZoQWgdxgk",
"name": "FoodSnap"
}
}
}
],
"connections": {
"Webhook (Whatsapp)": {
"main": [
[
{
"node": "Normalizar Dados",
"type": "main",
"index": 0
}
]
]
},
"Normalizar Dados": {
"main": [
[
{
"node": "É Coach?",
"type": "main",
"index": 0
}
]
]
},
"É Coach?": {
"main": [
[
{
"node": "Validação Coach",
"type": "main",
"index": 0
}
],
[
{
"node": "Validação Food",
"type": "main",
"index": 0
}
]
]
},
"Validação Coach": {
"main": [
[
{
"node": "Coach OK?",
"type": "main",
"index": 0
}
]
]
},
"Validação Food": {
"main": [
[
{
"node": "Food OK?",
"type": "main",
"index": 0
}
]
]
},
"Coach OK?": {
"main": [
[
{
"node": "Ack Coach",
"type": "main",
"index": 0
}
]
]
},
"Food OK?": {
"main": [
[
{
"node": "Ack Food",
"type": "main",
"index": 0
}
]
]
},
"Ack Coach": {
"main": [
[
{
"node": "Baixar IMG Coach",
"type": "main",
"index": 0
}
]
]
},
"Ack Food": {
"main": [
[
{
"node": "Baixar IMG Food",
"type": "main",
"index": 0
}
]
]
},
"Baixar IMG Coach": {
"main": [
[
{
"node": "Binário Coach",
"type": "main",
"index": 0
}
]
]
},
"Baixar IMG Food": {
"main": [
[
{
"node": "Binário Food",
"type": "main",
"index": 0
}
]
]
},
"Binário Coach": {
"main": [
[
{
"node": "Gemini Coach",
"type": "main",
"index": 0
}
]
]
},
"Binário Food": {
"main": [
[
{
"node": "Gemini Food",
"type": "main",
"index": 0
}
]
]
},
"Gemini Coach": {
"main": [
[
{
"node": "Parse Coach JSON",
"type": "main",
"index": 0
}
]
]
},
"Gemini Food": {
"main": [
[
{
"node": "Parse Food",
"type": "main",
"index": 0
}
]
]
},
"Parse Coach JSON": {
"main": [
[
{
"node": "Salvar Coach DB",
"type": "main",
"index": 0
}
]
]
},
"Parse Food": {
"main": [
[
{
"node": "Salvar Food DB",
"type": "main",
"index": 0
}
]
]
},
"Salvar Coach DB": {
"main": [
[
{
"node": "Responder Coach",
"type": "main",
"index": 0
}
]
]
},
"Salvar Food DB": {
"main": [
[
{
"node": "Responder Food",
"type": "main",
"index": 0
}
]
]
}
}
}

134
src/n8n-stripe-webhook.json Normal file
View file

@ -0,0 +1,134 @@
{
"name": "Stripe Payment Handler",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "stripe-webhook",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [
0,
0
],
"id": "webhook-stripe",
"name": "Webhook Stripe"
},
{
"parameters": {
"conditions": {
"string": [
{
"value1": "={{ $json.body.type }}",
"value2": "checkout.session.completed"
}
]
}
},
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [
200,
0
],
"id": "check-event-type",
"name": "Is Checkout Completed?"
},
{
"parameters": {
"operation": "executeQuery",
"query": "SELECT id FROM profiles WHERE email = '{{ $json.body.data.object.customer_details.email }}' LIMIT 1"
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 1,
"position": [
450,
-100
],
"id": "lookup-user",
"name": "Find User by Email",
"credentials": {
"postgres": {
"id": "2JDD2OJz4cAsWb0J",
"name": "foodsnap_supabase"
}
}
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO payments (user_id, amount_cents, currency, status, stripe_payment_id, plan_type) VALUES ('{{ $json.id }}', {{ $node[\"Webhook Stripe\"].json.body.data.object.amount_total }}, '{{ $node[\"Webhook Stripe\"].json.body.data.object.currency }}', 'succeeded', '{{ $node[\"Webhook Stripe\"].json.body.data.object.payment_intent }}', 'pro') RETURNING id"
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 1,
"position": [
650,
-100
],
"id": "log-payment",
"name": "Log Payment"
},
{
"parameters": {
"operation": "executeQuery",
"query": "INSERT INTO user_entitlements (user_id, entitlement_code, is_active, is_trial, valid_until, plan_type) VALUES ('{{ $node[\"Find User by Email\"].json.id }}', 'pro', true, false, NOW() + INTERVAL '30 days', 'pro') ON CONFLICT (user_id) DO UPDATE SET is_active = true, valid_until = NOW() + INTERVAL '30 days', entitlement_code = 'pro', updated_at = NOW();"
},
"type": "n8n-nodes-base.postgres",
"typeVersion": 1,
"position": [
850,
-100
],
"id": "activate-plan",
"name": "Activate Plan"
}
],
"connections": {
"Webhook Stripe": {
"main": [
[
{
"node": "Is Checkout Completed?",
"type": "main",
"index": 0
}
]
]
},
"Is Checkout Completed?": {
"main": [
[
{
"node": "Find User by Email",
"type": "main",
"index": 0
}
]
]
},
"Find User by Email": {
"main": [
[
{
"node": "Log Payment",
"type": "main",
"index": 0
}
]
]
},
"Log Payment": {
"main": [
[
{
"node": "Activate Plan",
"type": "main",
"index": 0
}
]
]
}
}
}

798
src/pages/AdminPanel.tsx Normal file
View file

@ -0,0 +1,798 @@
import React, { useEffect, useState } from 'react';
import {
LayoutDashboard,
Users,
CreditCard,
LogOut,
ArrowLeft,
TrendingUp,
Ticket,
Search,
ShieldAlert,
Download,
Plus,
DollarSign,
Calendar,
CheckCircle2,
XCircle,
MoreHorizontal,
UserPlus,
Activity,
AlertTriangle,
User,
Clock,
Info,
Settings,
Save,
Smartphone
} from 'lucide-react';
import { supabase } from '@/lib/supabase';
import { User as AppUser } from '@/types';
interface AdminPanelProps {
user: AppUser;
onExitAdmin: () => void;
onLogout: () => void;
}
// Tipos baseados nas novas tabelas SQL
type TabType = 'overview' | 'users' | 'financial' | 'coupons' | 'settings';
const AdminPanel: React.FC<AdminPanelProps> = ({ user, onExitAdmin, onLogout }) => {
const [activeTab, setActiveTab] = useState<TabType>('overview');
const [loading, setLoading] = useState(true);
// Data States
const [stats, setStats] = useState<any>(null);
const [usersList, setUsersList] = useState<any[]>([]);
const [coupons, setCoupons] = useState<any[]>([]);
// Settings State
const [config, setConfig] = useState({
whatsapp_number: '' // Inicializa vazio para não confundir
});
const [savingConfig, setSavingConfig] = useState(false);
// UI States
const [searchTerm, setSearchTerm] = useState('');
const [showCouponModal, setShowCouponModal] = useState(false);
const [newCoupon, setNewCoupon] = useState({ code: '', percent: 10, uses: 100 });
useEffect(() => {
fetchDashboardData();
}, []);
const fetchDashboardData = async () => {
setLoading(true);
try {
// 1. Stats
const { data: sData } = await supabase.rpc('get_admin_dashboard_stats');
if (sData) setStats(sData);
// 2. Users (Robust Fetch)
await fetchUsersSafe();
// 3. Coupons
const { data: cData } = await supabase.from('coupons').select('*').order('created_at', { ascending: false });
if (cData) setCoupons(cData);
// 4. Settings
const { data: configData } = await supabase
.from('app_settings')
.select('value')
.eq('key', 'whatsapp_number')
.maybeSingle();
if (configData) {
setConfig({ whatsapp_number: configData.value });
} else {
// Fallback visual apenas se não tiver nada no banco
setConfig({ whatsapp_number: '5541999999999' });
}
} catch (error) {
console.error("Admin fetch error", error);
} finally {
setLoading(false);
}
};
const fetchUsersSafe = async () => {
// Tenta usar a função avançada (RPC) que tem dados do plano
const { data: rpcData, error: rpcError } = await supabase.rpc('get_admin_users_list', { limit_count: 50 });
if (!rpcError && rpcData) {
setUsersList(rpcData);
return;
}
// Se falhar (ex: SQL não atualizado), busca o básico da tabela profiles para não deixar a tela vazia
console.warn("RPC falhou, usando fallback de perfis:", rpcError);
const { data: basicData } = await supabase
.from('profiles')
.select('*')
.order('created_at', { ascending: false })
.limit(50);
if (basicData) {
const mapped = basicData.map(p => ({
id: p.id,
full_name: p.full_name,
email: p.email,
phone: p.phone_e164,
created_at: p.created_at,
plan_status: 'free',
plan_interval: 'free',
lifetime_value: 0,
plan_start_date: null,
plan_end_date: null
}));
setUsersList(mapped);
}
};
const handleCreateCoupon = async (e: React.FormEvent) => {
e.preventDefault();
try {
const { error } = await supabase.rpc('admin_create_coupon', {
p_code: newCoupon.code,
p_percent: newCoupon.percent,
p_uses: newCoupon.uses
});
if (error) throw error;
const { data: cData } = await supabase.from('coupons').select('*').order('created_at', { ascending: false });
if (cData) setCoupons(cData);
setShowCouponModal(false);
setNewCoupon({ code: '', percent: 10, uses: 100 });
alert("Cupom criado com sucesso!");
} catch (err: any) {
alert("Erro ao criar cupom: " + err.message);
}
};
const handleToggleProfessional = async (userId: string, newValue: boolean) => {
try {
const { error } = await supabase
.from('profiles')
.update({ is_professional: newValue })
.eq('id', userId);
if (error) throw error;
// Optimistic update
setUsersList(prev => prev.map(u =>
u.id === userId ? { ...u, is_professional: newValue } : u
));
// If enhancing to Professional, check/create the professionals record
if (newValue) {
const { data: existing } = await supabase.from('professionals').select('id').eq('id', userId).maybeSingle();
if (!existing) {
// Auto-init profile
// We don't have the user name here easily unless we look it up,
// but we can trust the 'professionals' RLS or just let them create it on first login.
// Ideally, we create a stub here.
const user = usersList.find(u => u.id === userId);
if (user) {
await supabase.from('professionals').insert({
id: userId,
business_name: user.full_name || 'Novo Profissional',
primary_color: '#059669' // Default Green
});
}
}
}
// toast.success(`Status alterado para ${newValue ? 'Profissional' : 'Aluno'}`);
} catch (error) {
console.error("Error toggling pro status:", error);
alert("Erro ao alterar status!");
}
};
const handleSaveSettings = async (e: React.FormEvent) => {
e.preventDefault();
setSavingConfig(true);
try {
const { error } = await supabase
.from('app_settings')
.upsert({ key: 'whatsapp_number', value: config.whatsapp_number }, { onConflict: 'key' });
if (error) throw error;
alert("Configurações salvas com sucesso!");
} catch (err: any) {
console.error(err);
alert("Erro ao salvar: " + err.message);
} finally {
setSavingConfig(false);
}
};
// Safe filter logic (handles null names)
const filteredUsers = usersList.filter(u =>
(u.full_name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(u.email || '').toLowerCase().includes(searchTerm.toLowerCase())
);
const formatCurrency = (cents: number) => {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(cents / 100);
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('pt-BR');
};
return (
<div className="min-h-screen bg-[#F3F4F6] font-sans text-gray-900 flex">
{/* Sidebar Premium */}
<aside className="w-72 bg-gray-900 text-white fixed h-full z-30 hidden lg:flex flex-col shadow-2xl">
<div className="p-8 border-b border-gray-800">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-gradient-to-br from-brand-500 to-brand-700 rounded-xl flex items-center justify-center text-white shadow-lg shadow-brand-500/20">
<ShieldAlert size={20} />
</div>
<div>
<span className="font-bold text-xl tracking-tight block">FoodSnap</span>
<span className="text-[10px] text-gray-400 font-mono uppercase tracking-widest bg-gray-800 px-2 py-0.5 rounded-full">Master Admin</span>
</div>
</div>
</div>
<nav className="flex-1 p-6 space-y-2">
<NavButton
active={activeTab === 'overview'}
onClick={() => setActiveTab('overview')}
icon={<LayoutDashboard size={20} />}
label="Dashboard"
/>
<NavButton
active={activeTab === 'users'}
onClick={() => setActiveTab('users')}
icon={<Users size={20} />}
label="Usuários & Planos"
/>
<NavButton
active={activeTab === 'financial'}
onClick={() => setActiveTab('financial')}
icon={<DollarSign size={20} />}
label="Financeiro"
/>
<NavButton
active={activeTab === 'coupons'}
onClick={() => setActiveTab('coupons')}
icon={<Ticket size={20} />}
label="Cupons & Ofertas"
/>
<NavButton
active={activeTab === 'settings'}
onClick={() => setActiveTab('settings')}
icon={<Settings size={20} />}
label="Configurações"
/>
</nav>
<div className="p-6 border-t border-gray-800 space-y-2 bg-gray-950/30">
<button
onClick={onExitAdmin}
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-gray-400 hover:text-white hover:bg-gray-800 transition-colors text-sm font-medium"
>
<ArrowLeft size={18} />
Voltar ao App
</button>
<button
onClick={onLogout}
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg text-red-400 hover:text-red-300 hover:bg-red-900/20 transition-colors text-sm font-medium"
>
<LogOut size={18} />
Sair
</button>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 lg:ml-72 p-6 md:p-10 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-600"></div>
</div>
) : (
<div className="max-w-7xl mx-auto space-y-8">
{/* Top Bar */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900 tracking-tight">
{activeTab === 'overview' && 'Visão Geral'}
{activeTab === 'users' && 'Gestão de Usuários'}
{activeTab === 'financial' && 'Controle Financeiro'}
{activeTab === 'coupons' && 'Cupons de Desconto'}
{activeTab === 'settings' && 'Configurações do Sistema'}
</h1>
<p className="text-gray-500 mt-1 flex items-center gap-2 text-sm">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
Sistema Operacional {new Date().toLocaleDateString()}
</p>
</div>
<div className="flex gap-3">
{activeTab !== 'settings' && (
<button className="bg-white border border-gray-200 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium shadow-sm hover:bg-gray-50 flex items-center gap-2">
<Download size={16} /> Exportar Relatório
</button>
)}
{activeTab === 'coupons' && (
<button
onClick={() => setShowCouponModal(true)}
className="bg-brand-600 text-white px-4 py-2 rounded-lg text-sm font-medium shadow-md shadow-brand-500/20 hover:bg-brand-700 flex items-center gap-2"
>
<Plus size={16} /> Criar Cupom
</button>
)}
</div>
</div>
{/* --- OVERVIEW TAB --- */}
{activeTab === 'overview' && stats && (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<KpiCard
title="Receita Total"
value={formatCurrency(stats.total_revenue || 0)}
icon={<DollarSign className="text-white" size={24} />}
color="bg-emerald-500"
trend="+8.2%"
/>
<KpiCard
title="Assinantes Ativos"
value={stats.active_subs}
icon={<CreditCard className="text-white" size={24} />}
color="bg-blue-500"
trend="+12"
/>
<KpiCard
title="Total Usuários"
value={stats.total_users}
icon={<Users className="text-white" size={24} />}
color="bg-indigo-500"
trend="+24"
/>
<KpiCard
title="Novos (24h)"
value={stats.new_users_24h}
icon={<UserPlus className="text-white" size={24} />}
color="bg-purple-500"
trend="Hoje"
/>
</div>
{/* Recent Activity Section */}
<div className="grid lg:grid-cols-3 gap-8">
{/* Chart placeholder area */}
<div className="lg:col-span-2 bg-white p-6 rounded-2xl shadow-sm border border-gray-200">
<h3 className="font-bold text-gray-900 mb-6">Crescimento de Receita (Simulado)</h3>
<div className="h-64 flex items-end gap-4">
{[40, 65, 50, 80, 75, 90, 85, 100].map((h, i) => (
<div key={i} className="flex-1 bg-brand-50 rounded-t-lg relative group">
<div
className="absolute bottom-0 left-0 right-0 bg-brand-500 rounded-t-lg transition-all duration-1000 group-hover:bg-brand-600"
style={{ height: `${h}%` }}
></div>
</div>
))}
</div>
<div className="flex justify-between mt-4 text-xs text-gray-400 font-medium uppercase">
<span>Jan</span><span>Fev</span><span>Mar</span><span>Abr</span><span>Mai</span><span>Jun</span><span>Jul</span><span>Ago</span>
</div>
</div>
{/* Quick Actions / Recent */}
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200">
<h3 className="font-bold text-gray-900 mb-4">Ações Rápidas</h3>
<div className="space-y-3">
<button onClick={() => setShowCouponModal(true)} className="w-full text-left p-3 rounded-xl bg-gray-50 hover:bg-gray-100 transition-colors flex items-center gap-3">
<div className="p-2 bg-purple-100 text-purple-600 rounded-lg"><Ticket size={18} /></div>
<div>
<p className="font-bold text-sm text-gray-800">Criar Novo Cupom</p>
<p className="text-xs text-gray-500">Impulsione vendas hoje</p>
</div>
</button>
<button onClick={() => setActiveTab('users')} className="w-full text-left p-3 rounded-xl bg-gray-50 hover:bg-gray-100 transition-colors flex items-center gap-3">
<div className="p-2 bg-blue-100 text-blue-600 rounded-lg"><Search size={18} /></div>
<div>
<p className="font-bold text-sm text-gray-800">Buscar Usuário</p>
<p className="text-xs text-gray-500">Ver detalhes de conta</p>
</div>
</button>
</div>
</div>
</div>
</div>
)}
{/* --- USERS TAB --- */}
{activeTab === 'users' && (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-200 flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
<input
type="text"
placeholder="Buscar por nome ou email..."
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500/50"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 text-gray-500 uppercase text-xs font-bold">
<tr>
<th className="px-6 py-4">Usuário</th>
<th className="px-6 py-4">Status</th>
<th className="px-6 py-4">Plano</th>
<th className="px-6 py-4">Início</th>
<th className="px-6 py-4">Término</th>
<th className="px-6 py-4">Pro?</th>
<th className="px-6 py-4">LTV</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredUsers.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-gray-400">
Nenhum usuário encontrado na busca.
</td>
</tr>
) : filteredUsers.map((u) => (
<tr key={u.id} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-brand-100 text-brand-700 flex items-center justify-center font-bold text-xs">
{u.full_name ? u.full_name.substring(0, 2).toUpperCase() : 'US'}
</div>
<div>
<div className="font-bold text-gray-900">{u.full_name || 'Usuário Sem Nome'}</div>
<div className="text-xs text-gray-500">{u.email}</div>
</div>
</div>
</td>
<td className="px-6 py-4">
<StatusBadge status={u.plan_status} />
</td>
<td className="px-6 py-4">
<IntervalBadge interval={u.plan_interval} />
</td>
<td className="px-6 py-4 text-gray-600 font-medium text-xs">
{u.plan_start_date ? (
<span className="text-green-700 font-bold">{formatDate(u.plan_start_date)}</span>
) : (
<div className="flex items-center gap-1.5 text-gray-400" title="Data de Cadastro">
<Clock size={12} />
{formatDate(u.created_at)}
</div>
)}
</td>
<td className="px-6 py-4 text-gray-600 font-medium text-xs">
{u.plan_end_date ? formatDate(u.plan_end_date) : <span className="text-gray-300">-</span>}
</td>
<td className="px-6 py-4">
<button
onClick={() => handleToggleProfessional(u.id, !u.is_professional)}
className={`px-3 py-1 rounded-full text-xs font-bold transition-colors ${u.is_professional
? 'bg-purple-100 text-purple-700 hover:bg-purple-200'
: 'bg-gray-100 text-gray-400 hover:bg-gray-200'
}`}
>
{u.is_professional ? 'PRO' : 'ALUNO'}
</button>
</td>
<td className="px-6 py-4 font-mono font-medium text-gray-700">
{formatCurrency(u.lifetime_value || 0)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{/* --- COUPONS TAB --- */}
{activeTab === 'coupons' && (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="bg-gradient-to-r from-purple-600 to-indigo-600 rounded-2xl p-8 text-white flex flex-col md:flex-row items-center justify-between gap-6 shadow-lg">
<div>
<h2 className="text-2xl font-bold mb-2">Marketing & Ofertas</h2>
<p className="text-purple-100 opacity-90 max-w-lg">
Crie códigos promocionais para influenciadores, campanhas de email ou recuperação de carrinho.
</p>
</div>
<button
onClick={() => setShowCouponModal(true)}
className="bg-white text-purple-600 font-bold px-6 py-3 rounded-xl hover:bg-purple-50 transition-colors shadow-xl"
>
Criar Novo Cupom
</button>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{coupons.length === 0 ? (
<div className="col-span-full text-center py-12 text-gray-400 bg-white rounded-xl border border-dashed border-gray-300">
Nenhum cupom ativo no momento.
</div>
) : coupons.map(c => (
<div key={c.id} className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm hover:shadow-md transition-shadow relative overflow-hidden group">
<div className="absolute top-0 right-0 p-2 opacity-50">
<Ticket size={80} className="text-gray-100 -rotate-12 transform translate-x-4 -translate-y-4" />
</div>
<div className="relative z-10">
<div className="flex justify-between items-start mb-4">
<div className="bg-purple-50 text-purple-700 px-3 py-1 rounded-lg font-mono font-bold text-sm border border-purple-100">
{c.code}
</div>
<div className={`text-xs font-bold px-2 py-1 rounded-full ${c.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{c.is_active ? 'ATIVO' : 'INATIVO'}
</div>
</div>
<div className="mb-4">
<span className="text-4xl font-black text-gray-900">{c.discount_percent}%</span>
<span className="text-sm text-gray-500 font-medium ml-1">OFF</span>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500 border-t border-gray-100 pt-4">
<div className="flex items-center gap-1">
<Users size={14} />
<span>{c.uses_count} / {c.max_uses} usos</span>
</div>
<div className="flex items-center gap-1">
<Calendar size={14} />
<span>Criado em {new Date(c.created_at).toLocaleDateString()}</span>
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* --- SETTINGS TAB --- */}
{activeTab === 'settings' && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500 max-w-2xl">
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="p-6 border-b border-gray-100">
<h2 className="text-xl font-bold text-gray-900 flex items-center gap-2">
<Smartphone size={20} className="text-brand-600" />
Integração WhatsApp
</h2>
<p className="text-gray-500 text-sm mt-1">
Configure o número que receberá as mensagens e imagens dos usuários para análise.
</p>
</div>
<div className="p-6 space-y-6">
<form onSubmit={handleSaveSettings}>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Número do WhatsApp (Business/Bot)
</label>
<div className="flex gap-2">
<input
type="text"
required
className="flex-1 px-4 py-2 bg-white text-gray-900 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 placeholder-gray-400"
placeholder="Ex: 5541999999999"
value={config.whatsapp_number}
onChange={(e) => setConfig({ ...config, whatsapp_number: e.target.value.replace(/\D/g, '') })}
/>
<button
type="button"
className="bg-gray-100 text-gray-700 px-4 py-2 rounded-lg text-sm font-medium hover:bg-gray-200 transition-colors"
onClick={() => window.open(`https://wa.me/${config.whatsapp_number}`, '_blank')}
>
Testar Link
</button>
</div>
<p className="text-xs text-gray-500 mt-2">
Insira apenas números, incluindo o código do país (Ex: 55 para Brasil). Este número será usado para gerar o QR Code no painel do usuário.
</p>
</div>
<div className="pt-6 border-t border-gray-100 flex justify-end">
<button
type="submit"
disabled={savingConfig}
className="bg-brand-600 text-white px-6 py-2.5 rounded-lg font-bold hover:bg-brand-700 shadow-lg shadow-brand-500/20 flex items-center gap-2 disabled:opacity-50"
>
{savingConfig ? <div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-b-transparent"></div> : <Save size={18} />}
Salvar Configurações
</button>
</div>
</form>
</div>
</div>
</div>
)}
{/* --- FINANCIAL TAB --- */}
{activeTab === 'financial' && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="w-16 h-16 bg-blue-50 text-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<CreditCard size={32} />
</div>
<h2 className="text-xl font-bold text-gray-900 mb-2">Integração Stripe Connect</h2>
<p className="text-gray-500 max-w-lg mx-auto mb-6">
Para visualizar o histórico detalhado de transações em tempo real, configure os Webhooks do Stripe no backend. O sistema atual está pronto para receber os dados na tabela <code>payments</code>.
</p>
<button className="text-blue-600 font-bold hover:underline flex items-center justify-center gap-2">
<ExternalLinkIcon /> Acessar Dashboard do Stripe
</button>
</div>
</div>
)}
</div>
)}
</main>
{/* Coupon Modal */}
{showCouponModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm animate-in fade-in duration-200">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 animate-in zoom-in-95 duration-200 border border-gray-100">
<h3 className="text-xl font-bold text-gray-900 mb-4">Criar Novo Cupom</h3>
<div className="mb-6 bg-blue-50 text-blue-800 p-4 rounded-xl border border-blue-100 text-sm flex gap-3">
<Info className="shrink-0 mt-0.5" size={18} />
<p>
<strong>Atenção:</strong> Ao criar o cupom aqui, você apenas registra para métricas internas. <br />
<span className="block mt-2 font-medium underline">Você deve criar o mesmo código de cupom no Dashboard do Stripe</span> para que o desconto funcione no checkout.
</p>
</div>
<form onSubmit={handleCreateCoupon} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Código do Cupom</label>
<input
type="text"
required
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 uppercase bg-white text-gray-900"
placeholder="EX: VERÃO2025"
value={newCoupon.code}
onChange={e => setNewCoupon({ ...newCoupon, code: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Desconto (%)</label>
<input
type="number"
required
min="1" max="100"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 bg-white text-gray-900"
value={newCoupon.percent}
onChange={e => setNewCoupon({ ...newCoupon, percent: parseInt(e.target.value) })}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Limite de Usos</label>
<input
type="number"
required
min="1"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-500 bg-white text-gray-900"
value={newCoupon.uses}
onChange={e => setNewCoupon({ ...newCoupon, uses: parseInt(e.target.value) })}
/>
</div>
</div>
<div className="pt-4 flex gap-3">
<button
type="button"
onClick={() => setShowCouponModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 font-medium hover:bg-gray-50"
>
Cancelar
</button>
<button
type="submit"
className="flex-1 px-4 py-2 bg-brand-600 text-white rounded-lg font-bold hover:bg-brand-700 shadow-lg shadow-brand-500/20"
>
Criar Cupom
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
// UI Components
const NavButton = ({ active, onClick, icon, label }: any) => (
<button
onClick={onClick}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 ${active ? 'bg-brand-600 text-white shadow-lg shadow-brand-900/20' : 'text-gray-400 hover:bg-gray-800 hover:text-white'}`}
>
{icon}
<span className="text-sm font-medium">{label}</span>
</button>
);
const KpiCard = ({ title, value, icon, color, trend }: any) => (
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-200 flex items-center gap-4 relative overflow-hidden group">
<div className={`w-14 h-14 rounded-2xl ${color} flex items-center justify-center shadow-lg`}>
{icon}
</div>
<div>
<p className="text-gray-500 text-xs font-bold uppercase tracking-wider mb-1">{title}</p>
<h4 className="text-2xl font-black text-gray-900">{value}</h4>
{trend && (
<div className="flex items-center gap-1 mt-1 text-xs font-bold text-green-600 bg-green-50 px-2 py-0.5 rounded-full w-fit">
<TrendingUp size={12} /> {trend}
</div>
)}
</div>
</div>
);
const StatusBadge = ({ status }: { status: string }) => {
let styles = 'bg-gray-100 text-gray-600';
let icon = <MoreHorizontal size={12} />;
let label = status;
if (status === 'pro') {
styles = 'bg-green-100 text-green-700 border border-green-200';
icon = <CheckCircle2 size={12} />;
}
else if (status === 'trial') {
styles = 'bg-orange-100 text-orange-700 border border-orange-200';
icon = <Activity size={12} />;
}
else if (status === 'free' || !status) {
return (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold uppercase tracking-wide bg-gray-100 text-gray-600 border border-gray-200">
<User size={12} /> Gratuito
</span>
);
}
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold uppercase tracking-wide ${styles}`}>
{icon} {label}
</span>
);
};
const IntervalBadge = ({ interval }: { interval: string }) => {
if (interval === 'free' || !interval) {
return <span className="px-2.5 py-1 rounded-lg text-xs font-bold bg-gray-50 text-gray-500 border border-gray-200">Básico</span>;
}
let color = 'bg-gray-100 text-gray-600';
let label = interval;
if (interval === 'monthly') { color = 'bg-blue-50 text-blue-700 border border-blue-100'; label = 'Mensal'; }
if (interval === 'quarterly') { color = 'bg-indigo-50 text-indigo-700 border border-indigo-100'; label = 'Trimestral'; }
if (interval === 'annual') { color = 'bg-purple-50 text-purple-700 border border-purple-100'; label = 'Anual'; }
return (
<span className={`px-2.5 py-1 rounded-lg text-xs font-bold ${color}`}>
{label}
</span>
);
};
const ExternalLinkIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>
);
export default AdminPanel;

234
src/pages/Dashboard.tsx Normal file
View file

@ -0,0 +1,234 @@
import React, { useState, useEffect } from 'react';
import {
LayoutDashboard, History, CreditCard, Settings, LogOut, Plus, Search, Calendar, ChevronRight, Zap, ExternalLink, MessageCircle, Loader2, Utensils, ShieldAlert, Smartphone, QrCode, CheckCircle2, Dumbbell, Timer, PlayCircle, ScanEye, BrainCircuit, Activity, ScanLine, Sparkles, TrendingUp
} from 'lucide-react';
import CoachWizard from '@/components/coach/CoachWizard';
import { User } from '@/types';
import { supabase } from '@/lib/supabase';
import { useLanguage } from '@/contexts/LanguageContext';
// Custom Hooks
import { useDashboardStats } from '@/hooks/useDashboardStats';
import { useDashboardHistory } from '@/hooks/useDashboardHistory';
import { useCoachPlan } from '@/hooks/useCoachPlan';
// Layout Components
import Sidebar from '@/components/layout/Sidebar';
import MobileNav from '@/components/layout/MobileNav';
// Feature Components
import DashboardOverview from '@/components/dashboard/DashboardOverview';
import DashboardHistory from '@/components/dashboard/DashboardHistory';
import DashboardSubscription from '@/components/dashboard/DashboardSubscription';
import DashboardCoach from '@/components/dashboard/DashboardCoach';
interface DashboardProps {
user: User;
onLogout: () => void;
onOpenAdmin?: () => void; // Optional prop for admin toggle
onOpenPro?: () => void; // Optional prop for professional toggle
}
const Dashboard: React.FC<DashboardProps> = ({ user, onLogout, onOpenAdmin, onOpenPro }) => {
const { t, language } = useLanguage();
const [activeTab, setActiveTab] = useState<'overview' | 'history' | 'subscription' | 'coach'>('overview');
const [isCoachWizardOpen, setIsCoachWizardOpen] = useState(false);
// Custom Hooks
const { stats, loadingStats } = useDashboardStats(user.id);
const { history, loadingHistory } = useDashboardHistory(user.id);
const { coachPlan, setCoachPlan, coachHistory } = useCoachPlan(user.id);
// WhatsApp Config
const [whatsappNumber, setWhatsappNumber] = useState("5541999999999"); // Default fallback
const fetchSystemSettings = async () => {
try {
const { data } = await supabase
.from('app_settings')
.select('value')
.eq('key', 'whatsapp_number')
.maybeSingle();
if (data && data.value) {
setWhatsappNumber(data.value);
}
} catch (err) {
console.error("Failed to fetch settings", err);
}
};
useEffect(() => {
fetchSystemSettings();
// Realtime Subscription: Escuta alterações na tabela app_settings
const settingsChannel = supabase
.channel('public:app_settings')
.on(
'postgres_changes',
{
event: '*', // Escuta INSERT e UPDATE
schema: 'public',
table: 'app_settings',
filter: 'key=eq.whatsapp_number',
},
(payload) => {
if (payload.new && (payload.new as any).value) {
setWhatsappNumber((payload.new as any).value);
}
}
)
.subscribe();
return () => {
supabase.removeChannel(settingsChannel);
};
}, [user.id]);
const whatsappUrl = `https://wa.me/${whatsappNumber}?text=Oi`;
const qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(whatsappUrl)}`;
const handleStripePortal = async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
alert("Sessão expirada. Faça login novamente.");
return;
}
const response = await fetch(`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/stripe-checkout`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${session.access_token}`,
},
});
if (!response.ok) throw new Error("Erro ao gerar link do portal");
const { url } = await response.json();
if (url) {
window.location.href = url;
} else {
alert("Erro: URL do portal não retornada.");
}
} catch (error) {
console.error("Erro no portal:", error);
alert("Não foi possível acessar o portal de pagamentos.");
}
};
// Helper para o nome do plano (Correção do bug de nome vazio)
const getPlanLabel = () => {
if (user.plan === 'pro') return 'PRO';
if (user.plan === 'trial') return 'Trial';
// Traduções manuais para o plano gratuito
if (language === 'pt') return 'Gratuito';
if (language === 'es') return 'Gratis';
return 'Free';
};
const planName = getPlanLabel();
const fallbackImage = "https://images.unsplash.com/photo-1546069901-ba9599a7e63c?w=400&q=80";
return (
<div className="min-h-screen bg-gray-50 flex font-sans text-gray-900">
{/* Sidebar Navigation */}
<Sidebar
user={user}
activeTab={activeTab}
setActiveTab={setActiveTab}
onLogout={onLogout}
onOpenAdmin={onOpenAdmin}
onOpenPro={onOpenPro}
t={t}
coachHistory={coachHistory}
onSelectCoachPlan={(plan) => {
setCoachPlan(plan);
setActiveTab('coach');
}}
/>
{/* Mobile Bottom Navigation */}
<MobileNav
activeTab={activeTab}
setActiveTab={setActiveTab}
t={t}
/>
<main className="flex-1 md:ml-64 p-4 md:p-8 pb-24 md:pb-8">
{/* Mobile Header */}
<div className="md:hidden flex justify-between items-center mb-6">
<span className="font-bold text-lg">FoodSnap</span>
<div className="flex gap-2">
{onOpenAdmin && (
<button onClick={onOpenAdmin} className="p-2 text-gray-500 hover:text-red-600">
<ShieldAlert size={20} />
</button>
)}
<button onClick={onLogout}><LogOut size={20} className="text-gray-500" /></button>
</div>
</div >
{/* Content Switcher */}
{activeTab === 'overview' && (
<DashboardOverview
user={user}
stats={stats}
loadingStats={loadingStats}
history={history}
loadingHistory={loadingHistory}
planName={planName}
t={t}
whatsappUrl={whatsappUrl}
qrCodeUrl={qrCodeUrl}
whatsappNumber={whatsappNumber}
setActiveTab={setActiveTab}
fallbackImage={fallbackImage}
/>
)}
{activeTab === 'history' && (
<DashboardHistory
history={history}
loadingHistory={loadingHistory}
t={t}
fallbackImage={fallbackImage}
/>
)}
{activeTab === 'subscription' && (
<DashboardSubscription
user={user}
planName={planName}
t={t}
handleStripePortal={handleStripePortal}
/>
)}
{activeTab === 'coach' && (
<DashboardCoach
coachPlan={coachPlan}
setCoachPlan={setCoachPlan}
coachHistory={coachHistory}
setIsCoachWizardOpen={setIsCoachWizardOpen}
/>
)}
</main >
<CoachWizard
isOpen={isCoachWizardOpen}
onClose={() => setIsCoachWizardOpen(false)}
onComplete={(data: any) => {
console.log("Wizard Completed:", data);
setCoachPlan(data);
setIsCoachWizardOpen(false);
}}
/>
</div >
);
};
export default Dashboard;

121
src/pages/FAQPage.tsx Normal file
View file

@ -0,0 +1,121 @@
import React, { useState } from 'react';
import { Search, ChevronDown, ChevronUp, ArrowLeft, HelpCircle, FileText, CreditCard, Wrench } from 'lucide-react';
import { useLanguage } from '@/contexts/LanguageContext';
interface FAQPageProps {
onBack: () => void;
}
const FAQPage: React.FC<FAQPageProps> = ({ onBack }) => {
const { t } = useLanguage();
const [search, setSearch] = useState('');
const [openItem, setOpenItem] = useState<string | null>(null);
const categories = [
{ id: 'general', title: t.faqPage.categories.general.title, icon: <HelpCircle size={20} />, items: t.faqPage.categories.general.items },
{ id: 'account', title: t.faqPage.categories.account.title, icon: <FileText size={20} />, items: t.faqPage.categories.account.items },
{ id: 'billing', title: t.faqPage.categories.billing.title, icon: <CreditCard size={20} />, items: t.faqPage.categories.billing.items },
{ id: 'technical', title: t.faqPage.categories.technical.title, icon: <Wrench size={20} />, items: t.faqPage.categories.technical.items },
];
// Filtra as perguntas baseado na busca
const filteredCategories = categories.map(cat => ({
...cat,
items: cat.items.filter(item =>
item.q.toLowerCase().includes(search.toLowerCase()) ||
item.a.toLowerCase().includes(search.toLowerCase())
)
})).filter(cat => cat.items.length > 0);
const toggleItem = (id: string) => {
setOpenItem(openItem === id ? null : id);
};
return (
<div className="bg-gray-50 min-h-screen pt-28 pb-20">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header Section */}
<div className="text-center mb-12">
<button
onClick={onBack}
className="inline-flex items-center gap-2 text-gray-500 hover:text-brand-600 font-medium mb-8 transition-colors"
>
<ArrowLeft size={18} /> {t.faqPage.backHome}
</button>
<h1 className="text-4xl font-extrabold text-gray-900 mb-4">{t.faqPage.title}</h1>
<p className="text-lg text-gray-600 mb-8">{t.faqPage.subtitle}</p>
<div className="relative max-w-xl mx-auto">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
className="block w-full pl-11 pr-4 py-4 bg-white border border-gray-200 rounded-xl shadow-sm focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-all text-gray-900 placeholder-gray-400"
placeholder={t.faqPage.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{/* Categories & Questions */}
<div className="space-y-8">
{filteredCategories.length === 0 ? (
<div className="text-center py-12 text-gray-500">
Nenhuma pergunta encontrada para sua busca.
</div>
) : (
filteredCategories.map((cat) => (
<div key={cat.id} className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div className="px-6 py-4 bg-gray-50 border-b border-gray-100 flex items-center gap-3">
<div className="text-brand-600">{cat.icon}</div>
<h2 className="text-lg font-bold text-gray-900">{cat.title}</h2>
</div>
<div className="divide-y divide-gray-100">
{cat.items.map((item, idx) => {
const itemId = `${cat.id}-${idx}`;
const isOpen = openItem === itemId;
return (
<div key={idx} className="bg-white">
<button
onClick={() => toggleItem(itemId)}
className="w-full text-left px-6 py-5 flex justify-between items-center hover:bg-gray-50 transition-colors focus:outline-none"
>
<span className={`font-medium ${isOpen ? 'text-brand-600' : 'text-gray-900'}`}>
{item.q}
</span>
{isOpen ? <ChevronUp size={20} className="text-brand-500 shrink-0 ml-4" /> : <ChevronDown size={20} className="text-gray-400 shrink-0 ml-4" />}
</button>
<div
className={`px-6 overflow-hidden transition-all duration-300 ease-in-out ${isOpen ? 'max-h-96 pb-6 opacity-100' : 'max-h-0 opacity-0'
}`}
>
<p className="text-gray-600 leading-relaxed text-sm">
{item.a}
</p>
</div>
</div>
);
})}
</div>
</div>
))
)}
</div>
{/* Contact CTA */}
<div className="mt-16 text-center">
<p className="text-gray-600 mb-4">Ainda tem dúvidas?</p>
<a href="https://wa.me/5541999999999" target="_blank" rel="noreferrer" className="inline-flex items-center gap-2 text-brand-600 font-bold hover:underline">
Fale com nosso suporte no WhatsApp <ChevronUp className="rotate-90" size={16} />
</a>
</div>
</div>
</div>
);
};
export default FAQPage;

View file

@ -0,0 +1,176 @@
import React, { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabase';
import {
Users,
Dumbbell,
FileText,
Settings,
LogOut,
LayoutDashboard,
Video,
CreditCard,
Menu,
X,
Search,
Bell,
PlusCircle
} from 'lucide-react';
import { User } from '@/types';
import { StudentsList } from '@/components/professional/dashboard/StudentsList';
import { OverviewMock } from '@/components/professional/dashboard/Overview';
import { WorkoutsMock } from '@/components/professional/dashboard/Workouts';
import { PlaceholderModule } from '@/components/professional/common/PlaceholderModule';
interface ProfessionalDashboardProps {
user: User;
onExit: () => void;
onLogout: () => void;
}
type Tab = 'overview' | 'students' | 'workouts' | 'assessments' | 'library' | 'financial' | 'settings';
const ProfessionalDashboard: React.FC<ProfessionalDashboardProps> = ({ user, onExit, onLogout }) => {
const [activeTab, setActiveTab] = useState<Tab>('overview');
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
return (
<div className="min-h-screen bg-gray-50 flex font-sans text-gray-900">
{/* Mobile Overlay */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
)}
{/* Sidebar (SaaS Style - Dark) */}
<aside className={`w-72 bg-[#0F172A] text-gray-400 fixed h-full z-50 transition-transform duration-300 ${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'} md:translate-x-0 flex flex-col`}>
<div className="p-6 flex items-center justify-between border-b border-gray-800">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-brand-600 rounded-lg flex items-center justify-center text-white font-bold">
F
</div>
<span className="font-bold text-white text-lg tracking-tight">FoodSnap <span className="text-brand-500">Pro</span></span>
</div>
<button onClick={() => setIsSidebarOpen(false)} className="md:hidden text-gray-400 hover:text-white">
<X size={24} />
</button>
</div>
<div className="p-4">
<div className="flex items-center gap-3 p-3 bg-gray-800/50 rounded-xl mb-6">
<img src={user.avatar} alt="Pro" className="w-10 h-10 rounded-full bg-gray-700" />
<div className="overflow-hidden">
<p className="text-sm font-bold text-white truncate">{user.name}</p>
<p className="text-xs text-brand-400 font-medium truncate uppercase tracking-wider">Personal Trainer</p>
</div>
</div>
<nav className="space-y-1">
<NavItem
icon={<LayoutDashboard size={20} />}
label="Visão Geral"
active={activeTab === 'overview'}
onClick={() => setActiveTab('overview')}
/>
<div className="pt-4 pb-2 px-3 text-xs font-bold uppercase tracking-wider text-gray-600">Gestão</div>
<NavItem
icon={<Users size={20} />}
label="Alunos"
active={activeTab === 'students'}
onClick={() => setActiveTab('students')}
/>
<NavItem
icon={<CreditCard size={20} />}
label="Financeiro"
active={activeTab === 'financial'}
onClick={() => setActiveTab('financial')}
/>
<div className="pt-4 pb-2 px-3 text-xs font-bold uppercase tracking-wider text-gray-600">Técnico</div>
<NavItem
icon={<Dumbbell size={20} />}
label="Treinos"
active={activeTab === 'workouts'}
onClick={() => setActiveTab('workouts')}
/>
<NavItem
icon={<FileText size={20} />}
label="Avaliações"
active={activeTab === 'assessments'}
onClick={() => setActiveTab('assessments')}
/>
<NavItem
icon={<Video size={20} />}
label="Biblioteca"
active={activeTab === 'library'}
onClick={() => setActiveTab('library')}
/>
</nav>
</div>
<div className="mt-auto p-4 border-t border-gray-800">
<button
onClick={onExit}
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors mb-2"
>
<LogOut size={18} className="rotate-180" />
Voltar para Aluno
</button>
</div>
</aside>
{/* Main Content Area */}
<main className="flex-1 md:ml-72 min-h-screen flex flex-col">
{/* Top Header */}
<header className="bg-white border-b border-gray-200 h-16 px-4 md:px-8 flex items-center justify-between sticky top-0 z-30">
<div className="flex items-center gap-4">
<button onClick={() => setIsSidebarOpen(true)} className="md:hidden text-gray-600">
<Menu size={24} />
</button>
<h2 className="font-bold text-gray-900 text-lg capitalize">{activeTab === 'overview' ? 'Visão Geral' : activeTab}</h2>
</div>
<div className="flex items-center gap-4">
<button className="p-2 text-gray-400 hover:text-gray-600 relative">
<Bell size={20} />
<span className="absolute top-2 right-2 w-2 h-2 bg-red-500 rounded-full border border-white"></span>
</button>
<button className="bg-brand-600 hover:bg-brand-700 text-white px-4 py-2 rounded-lg text-sm font-bold flex items-center gap-2 shadow-sm transition-all">
<PlusCircle size={16} />
<span className="hidden sm:inline">Novo Aluno</span>
</button>
</div>
</header>
<div className="p-4 md:p-8">
{/* Dynamic Content */}
{activeTab === 'overview' && <OverviewMock />}
{activeTab === 'students' && <StudentsList user={user} />}
{activeTab === 'workouts' && <WorkoutsMock />}
{activeTab === 'assessments' && <PlaceholderModule title="Avaliação Física" desc="Formulário de dobras cutâneas, fotos comparativas e gráficos de evolução." icon={<FileText size={48} />} />}
{activeTab === 'library' && <PlaceholderModule title="Biblioteca de Exercícios" desc="Upload de vídeos, GIFs e gestão de banco de movimentos." icon={<Video size={48} />} />}
{activeTab === 'financial' && <PlaceholderModule title="Gestão Financeira" desc="Controle de mensalidades, planos e recebimentos." icon={<CreditCard size={48} />} />}
</div>
</main>
</div>
);
};
const NavItem = ({ icon, label, active, onClick }: any) => (
<button
onClick={onClick}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all text-sm font-medium ${active
? 'bg-brand-600 text-white shadow-lg shadow-brand-900/20'
: 'text-gray-400 hover:text-white hover:bg-gray-800'
}`}
>
{icon}
{label}
</button>
);
export default ProfessionalDashboard;

12
src/types/index.ts Normal file
View file

@ -0,0 +1,12 @@
export interface User {
id: string;
name: string;
email: string;
phone?: string;
plan: 'free' | 'pro' | 'trial';
public_id: string;
avatar?: string;
plan_valid_until?: string;
is_admin?: boolean;
is_professional?: boolean;
}

View file

@ -0,0 +1 @@
v2.67.1

View file

@ -0,0 +1 @@
v2.184.0

View file

@ -0,0 +1 @@
postgresql://postgres.mnhgpnqkwuqzpvfrwftp@aws-1-sa-east-1.pooler.supabase.com:5432/postgres

View file

@ -0,0 +1 @@
17.6.1.054

View file

@ -0,0 +1 @@
mnhgpnqkwuqzpvfrwftp

View file

@ -0,0 +1 @@
v13.0.5

View file

@ -0,0 +1 @@
buckets-objects-grants-postgres

View file

@ -0,0 +1 @@
v1.33.0

View file

@ -0,0 +1,115 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { COACH_SYSTEM_PROMPT } from "./prompt.ts";
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
// Handle CORS preflight requests
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
const { photos, goal } = await req.json();
if (!photos || (!photos.front && !photos.side && !photos.back)) {
throw new Error("Pelo menos uma foto é necessária.");
}
const GEMINI_API_KEY = Deno.env.get("GEMINI_API_KEY");
if (!GEMINI_API_KEY) {
throw new Error("Servidor não configurado (API Key ausente).");
}
// Prepare Image Parts
const parts = [];
// System Prompt
parts.push({ text: COACH_SYSTEM_PROMPT });
// User Goal
parts.push({ text: `Objetivo do Usuário: ${goal}\nAnalise as fotos e gere o protocolo.` });
// Images
for (const [key, value] of Object.entries(photos)) {
if (typeof value === 'string' && value.includes('base64,')) {
// value example: "data:image/jpeg;base64,/9j/4AAQSkZJRg..."
const base64Data = value.split(',')[1];
// Detect mime type
const mimeMatch = value.match(/^data:(.*);base64/);
const mimeType = mimeMatch ? mimeMatch[1] : 'image/jpeg';
parts.push({
inline_data: {
mime_type: mimeType,
data: base64Data
}
});
}
}
// Call Gemini API via Fetch (More stable than SDK in Deno Edge)
// Using user-specified model: gemini-2.5-flash
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
contents: [{ parts: parts }],
generationConfig: {
temperature: 0.2,
response_mime_type: "application/json"
}
})
}
);
if (!response.ok) {
const errorText = await response.text();
console.error("Gemini API Error:", errorText);
throw new Error(`Erro na IA (${response.status}): ${errorText}`);
}
const data = await response.json();
const generatedText = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!generatedText) {
console.error("Gemini Empty Response:", JSON.stringify(data));
throw new Error("A IA não conseguiu analisar as imagens. Tente fotos com melhor iluminação.");
}
let jsonResponse;
try {
// Clean markdown blocks if present (common in Gemini responses)
const cleaned = generatedText.replace(/```json/g, '').replace(/```/g, '').trim();
jsonResponse = JSON.parse(cleaned);
} catch (e) {
console.error("JSON Parse Error:", generatedText);
throw new Error("Erro ao processar a resposta da IA. Tente novamente.");
}
// Basic validation of the response structure
if (!jsonResponse.analysis || !jsonResponse.diet || !jsonResponse.workout) {
throw new Error("A resposta da IA veio incompleta. Por favor, tente novamente.");
}
return new Response(JSON.stringify(jsonResponse), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200
});
} catch (error) {
console.error("Function Error:", error);
return new Response(JSON.stringify({ error: error.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400 // Return 400 so client sees it as error, but with body
});
}
});

View file

@ -0,0 +1,100 @@
export const COACH_SYSTEM_PROMPT = `
Você é o "Titan Coach", um treinador olímpico de elite e nutricionista esportivo PhD.
Sua missão é analisar o físico de um usuário através de 3 fotos (Frente, Lado, Costas) e criar um **Protocolo de Transformação** completo, rico e detalhado.
RETORNE APENAS JSON.
NÃO use Markdown.
Formato de Resposta (Siga estritamente esta estrutura):
{
"analysis": {
"body_fat_percentage": 0,
"somatotype": "Ectomorfo" | "Mesomorfo" | "Endomorfo",
"muscle_mass_level": "Baixo" | "Médio" | "Alto",
"posture_analysis": "Texto detalhado sobre postura (ex: leve cifose, lordose, desvios laterais)",
"strengths": ["Ombros largos", "Cintura fina", "Bons quadríceps"],
"weaknesses": ["Panturrilhas pouco desenvolvidas", "Peitoral superior fraco"]
},
"diet": {
"total_calories": 0,
"macros": {
"protein_g": 0,
"carbs_g": 0,
"fats_g": 0
},
"hydration_liters": 0,
"supplements": [
{ "name": "Creatina", "dosage": "5g pós-treino", "reason": "Aumento de força e recuperação" },
{ "name": "Whey Protein", "dosage": "30g se não bater a meta", "reason": "Praticidade para bater proteínas" },
{ "name": "Multivitamínico", "dosage": "1 caps almoço", "reason": "Micro-nutrientes essenciais" }
],
"meal_plan_example": [
{
"name": "Café da Manhã",
"time_range": "07:00 - 08:00",
"options": [
"Opção 1: 3 Ovos mexidos + 1 Banana + 40g Aveia",
"Opção 2: 2 Fatias Pão Integral + 100g Frango Desfiado + Queijo Cotagge"
],
"substitution_suggestion": "Para vegetarianos: Trocar frango por Tofu ou ovos por Shake proteico vegano."
},
{
"name": "Almoço",
"time_range": "12:00 - 13:00",
"options": [
"Opção 1: 150g Frango Grelhado + 120g Arroz Branco + Vegetais Verdes à vontade",
"Opção 2: 150g Patinho Moído + 150g Batata Inglesa + Salada Mista"
],
"substitution_suggestion": "Se enjoar de arroz, use Macarrão Integral (mesmo peso) ou Batata Doce (peso x1.3)."
},
{
"name": "Lanche da Tarde",
"time_range": "16:00 - 16:30",
"options": [
"Opção 1: 1 Iogurte Grego Zero + 20g Nozes",
"Opção 2: 1 Fruta + 1 Dose de Whey"
],
"substitution_suggestion": "Pode trocar as gorduras (nozes) por Pasta de Amendoim."
},
{
"name": "Jantar",
"time_range": "20:00 - 21:00",
"options": [
"Opção 1: 150g Peixe Branco (Tilápia) + Salada Completa + Azeite de Oliva",
"Opção 2: Omelete de 3 Ovos com Espinafre e Tomate"
],
"substitution_suggestion": "Evite carboidratos pesados a noite se o objetivo for secar."
}
]
},
"workout": {
"split": "ABC" | "ABCD" | "ABCDE" | "Fullbody",
"focus": "Hipertrofia" | "Força" | "Perda de Gordura",
"frequency_days": 0,
"injury_adaptations": {
"knee_pain": "Substituir Agachamento por Leg Press 45 com pés altos",
"shoulder_pain": "Fazer Supino com Halteres pegada neutra ao invés de barra",
"back_pain": "Evitar Terra e Remada Curvada, preferir máquinas apoiadas"
},
"routine": [
{
"day": "Segunda",
"muscle_group": "Peito + Tríceps",
"exercises": [
{ "name": "Supino Inclinado com Halteres", "sets": 4, "reps": "8-12", "technique": "Focar na parte superior, descida controlada" },
{ "name": "Crucifixo Máquina", "sets": 3, "reps": "12-15", "technique": "Pico de contração de 1s" }
]
}
]
},
"motivation_quote": "Uma frase curta de impacto."
}
Regras IMPORTANTES:
1. Seja MUITO DETALHADO na dieta. SEMPRE pelo menos 2 opções para CADA refeição ("options").
2. Inclua o horário sugerido ("time_range") para cada refeição.
3. O campo "substitution_suggestion" deve dar uma alternativa clara de troca de alimentos (ex: trocar carbo X por Y).
4. Adapte o treino ao biotipo (ex: Ectomorfo menos volume, Endomorfo mais cardio).
5. Nos suplementos, especifique COMO tomar e PORQUE.
6. A resposta DEVE ser um JSON válido.
`;

View file

@ -0,0 +1,125 @@
/// <reference lib="deno.ns" />
import Stripe from "https://esm.sh/stripe@16.12.0?target=deno";
const STRIPE_SECRET_KEY = Deno.env.get("STRIPE_SECRET_KEY")!;
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_ANON_KEY = Deno.env.get("SUPABASE_ANON_KEY")!;
const SITE_URL = Deno.env.get("SITE_URL")!;
// ✅ seus PRICE IDs (recorrentes)
const PRICE_MENSAL = "price_1SeOVpPHwVDouhbBWZj9beS3";
const PRICE_TRIMESTRAL = "price_1SeOeXPHwVDouhbBcaiUy3vu";
const PRICE_ANUAL = "price_1SeOg4PHwVDouhbBTEiUPhMl";
const stripe = new Stripe(STRIPE_SECRET_KEY, { apiVersion: "2025-11-17.clover" });
const corsHeaders = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "authorization, x-client-info, apikey, content-type",
"access-control-allow-methods": "POST, OPTIONS",
};
function json(data: unknown, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json", ...corsHeaders },
});
}
async function getUserFromJwt(jwt: string) {
const res = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
headers: {
apikey: SUPABASE_ANON_KEY,
authorization: `Bearer ${jwt}`,
},
});
if (!res.ok) return null;
return await res.json();
}
function assertBaseUrl(raw: string, name: string) {
let u: URL;
try {
u = new URL(raw);
} catch {
throw new Error(`${name} inválida. Use https://... (ex: https://foodsnap.com.br)`);
}
if (u.protocol !== "https:" && u.hostname !== "localhost") {
throw new Error(`${name} deve ser https:// (ou localhost em dev)`);
}
return u;
}
function normalizePlan(planRaw: unknown) {
const p = String(planRaw ?? "").toLowerCase().trim();
if (p === "mensal" || p === "monthly") return "mensal";
if (p === "trimestral" || p === "quarterly") return "trimestral";
if (p === "anual" || p === "annual" || p === "yearly") return "anual";
return "";
}
function priceIdForPlan(plan: string) {
if (plan === "mensal") return PRICE_MENSAL;
if (plan === "trimestral") return PRICE_TRIMESTRAL;
if (plan === "anual") return PRICE_ANUAL;
return null;
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
if (req.method !== "POST") return json({ ok: false, error: "Method not allowed" }, 405);
try {
if (!STRIPE_SECRET_KEY) return json({ ok: false, error: "Missing STRIPE_SECRET_KEY" }, 500);
if (!SUPABASE_URL) return json({ ok: false, error: "Missing SUPABASE_URL" }, 500);
if (!SUPABASE_ANON_KEY) return json({ ok: false, error: "Missing SUPABASE_ANON_KEY" }, 500);
if (!SITE_URL) return json({ ok: false, error: "Missing SITE_URL" }, 500);
const site = assertBaseUrl(SITE_URL, "SITE_URL");
const auth = req.headers.get("authorization") || "";
const jwt = auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (!jwt) return json({ ok: false, error: "Missing Authorization Bearer token" }, 401);
const user = await getUserFromJwt(jwt);
if (!user?.id) return json({ ok: false, error: "Invalid token" }, 401);
const body = await req.json().catch(() => ({}));
const plan = normalizePlan(body?.plan);
if (!plan) return json({ ok: false, error: "Plano inválido. Use: mensal|trimestral|anual" }, 400);
const priceId = priceIdForPlan(plan);
if (!priceId) return json({ ok: false, error: "Price não configurado para este plano" }, 500);
// ✅ garante que é recorrente
const price = await stripe.prices.retrieve(priceId);
const isRecurring = (price as any)?.type === "recurring" || !!(price as any)?.recurring;
if (!isRecurring) {
return json(
{ ok: false, error: `O price ${priceId} não é recorrente. Precisa ser Recurring para subscription.` },
400,
);
}
const successUrl = new URL("/dashboard?checkout=success", site).toString();
const cancelUrl = new URL("/dashboard?checkout=cancel", site).toString();
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: priceId, quantity: 1 }],
success_url: successUrl,
cancel_url: cancelUrl,
// ✅ amarra no usuário
customer_email: user.email ?? undefined,
metadata: { user_id: user.id, plan_code: plan },
subscription_data: { metadata: { user_id: user.id, plan_code: plan } },
});
return json({ ok: true, url: session.url, plan, priceId });
} catch (err) {
console.error("stripe-checkout error:", err);
return json({ ok: false, error: String((err as any)?.message ?? err) }, 500);
}
});

View file

@ -0,0 +1,299 @@
/// <reference lib="deno.ns" />
import Stripe from "npm:stripe@16.12.0";
type EntitlementCode = "free" | "mensal" | "trimestral" | "anual" | "pro" | "trial";
const STRIPE_SECRET_KEY = Deno.env.get("STRIPE_SECRET_KEY") ?? "";
const STRIPE_WEBHOOK_SECRET = Deno.env.get("STRIPE_WEBHOOK_SECRET") ?? "";
// ✅ nomes oficiais no Supabase Edge
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
const REQUIRED_OK = !!(
STRIPE_SECRET_KEY &&
STRIPE_WEBHOOK_SECRET &&
SUPABASE_URL &&
SUPABASE_SERVICE_ROLE_KEY
);
const stripe = new Stripe(STRIPE_SECRET_KEY, { apiVersion: "2025-11-17.clover" });
function json(data: unknown, status = 200, extraHeaders: Record<string, string> = {}) {
return new Response(JSON.stringify(data), {
status,
headers: {
"content-type": "application/json",
...extraHeaders,
},
});
}
function corsHeaders(origin: string | null) {
const allowOrigin = origin ?? "*";
return {
"Access-Control-Allow-Origin": allowOrigin,
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, stripe-signature",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
}
async function supabaseAdmin(path: string, init?: RequestInit) {
const url = `${SUPABASE_URL}${path}`;
return fetch(url, {
...init,
headers: {
"content-type": "application/json",
apikey: SUPABASE_SERVICE_ROLE_KEY,
authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}`,
...(init?.headers || {}),
},
});
}
async function upsertStripeCustomer(
user_id: string,
stripe_customer_id: string,
email?: string | null,
) {
const res = await supabaseAdmin(`/rest/v1/stripe_customers?on_conflict=user_id`, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates" },
body: JSON.stringify({
user_id,
stripe_customer_id,
email: email ?? null,
updated_at: new Date().toISOString(),
}),
});
if (!res.ok) {
const t = await res.text();
throw new Error(`stripe_customers upsert failed: ${res.status} ${t}`);
}
}
async function upsertEntitlement(
user_id: string,
entitlement_code: EntitlementCode,
is_active: boolean,
valid_until: string | null,
) {
const res = await supabaseAdmin(`/rest/v1/user_entitlements?on_conflict=user_id`, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates" },
body: JSON.stringify({
user_id,
entitlement_code,
is_active,
valid_until,
updated_at: new Date().toISOString(),
}),
});
if (!res.ok) {
const t = await res.text();
throw new Error(`user_entitlements upsert failed: ${res.status} ${t}`);
}
}
function safePlanCode(v: unknown): EntitlementCode {
const s = String(v ?? "").toLowerCase().trim();
if (s === "mensal" || s === "trimestral" || s === "anual" || s === "pro" || s === "trial" || s === "free") {
return s;
}
return "free";
}
function secondsToISO(sec?: number | null) {
if (!sec || !Number.isFinite(sec)) return null;
return new Date(sec * 1000).toISOString();
}
/**
* Correção do valid_until:
* Em alguns payloads, `current_period_end` NÃO vem no root da subscription.
* Ele vem em `items.data[0].current_period_end`.
*/
function getPeriodEndISO(sub: Stripe.Subscription) {
const sec =
(sub as any).current_period_end ??
(sub as any)?.items?.data?.[0]?.current_period_end ??
null;
return secondsToISO(sec);
}
async function resolveUserId(customerId?: string | null, metadataUserId?: string | null) {
if (metadataUserId) return metadataUserId;
if (!customerId) return null;
const q = new URLSearchParams();
q.set("stripe_customer_id", `eq.${customerId}`);
q.set("select", "user_id");
q.set("limit", "1");
const res = await supabaseAdmin(`/rest/v1/stripe_customers?${q.toString()}`, { method: "GET" });
if (!res.ok) return null;
const rows = await res.json();
return rows?.[0]?.user_id ?? null;
}
Deno.serve(async (req) => {
const origin = req.headers.get("origin");
const cors = corsHeaders(origin);
// Preflight (não é obrigatório pro Stripe, mas não atrapalha)
if (req.method === "OPTIONS") return new Response("ok", { headers: cors });
if (!REQUIRED_OK) {
console.error("Missing required env vars.", {
hasStripeKey: !!STRIPE_SECRET_KEY,
hasWhsec: !!STRIPE_WEBHOOK_SECRET,
hasSbUrl: !!SUPABASE_URL,
hasSr: !!SUPABASE_SERVICE_ROLE_KEY,
});
return json({ ok: false, error: "Missing required env vars" }, 500, cors);
}
// Stripe manda POST
if (req.method !== "POST") return json({ ok: false, error: "Method not allowed" }, 405, cors);
const sig = req.headers.get("stripe-signature") ?? "";
if (!sig) return json({ ok: false, error: "Missing stripe-signature" }, 400, cors);
const raw = await req.text();
let event: Stripe.Event;
try {
event = await stripe.webhooks.constructEventAsync(raw, sig, STRIPE_WEBHOOK_SECRET);
} catch (err) {
console.error("Webhook signature verification failed:", err);
return json({ ok: false, error: "Invalid signature" }, 400, cors);
}
try {
const t = event.type;
// 1) Checkout finalizado
if (t === "checkout.session.completed") {
const s = event.data.object as Stripe.Checkout.Session;
const customerId = (s.customer as string | null) ?? null;
const userId = await resolveUserId(customerId, (s.metadata?.user_id as string | undefined) ?? null);
if (!userId) return json({ ok: true, skipped: true, reason: "no_user_id" }, 200, cors);
const plan = safePlanCode(s.metadata?.plan_code);
const email = (s.customer_details?.email ?? s.customer_email ?? null) as string | null;
if (customerId) await upsertStripeCustomer(userId, customerId, email);
// ✅ tenta já trazer o valid_until buscando a subscription (quando existir)
let validUntil: string | null = null;
if (s.subscription) {
const sub = await stripe.subscriptions.retrieve(String(s.subscription));
validUntil = getPeriodEndISO(sub) ?? null;
}
await upsertEntitlement(userId, plan, true, validUntil);
return json({ ok: true }, 200, cors);
}
// 2) Subscription é a fonte da verdade
if (t === "customer.subscription.created" || t === "customer.subscription.updated") {
const sub = event.data.object as Stripe.Subscription;
const customerId = (sub.customer as string | null) ?? null;
const userId = await resolveUserId(customerId, (sub.metadata?.user_id as string | undefined) ?? null);
if (!userId) return json({ ok: true, skipped: true, reason: "no_user_id" }, 200, cors);
const plan = safePlanCode(sub.metadata?.plan_code);
const isActive = sub.status === "active" || sub.status === "trialing";
const validUntil = getPeriodEndISO(sub) ?? null;
if (customerId) await upsertStripeCustomer(userId, customerId, null);
await upsertEntitlement(userId, plan, isActive, validUntil);
return json({ ok: true, plan, isActive, validUntil }, 200, cors);
}
// 3) Pause/Delete: volta pro free
if (t === "customer.subscription.paused" || t === "customer.subscription.deleted") {
const sub = event.data.object as Stripe.Subscription;
const customerId = (sub.customer as string | null) ?? null;
const userId = await resolveUserId(customerId, (sub.metadata?.user_id as string | undefined) ?? null);
if (!userId) return json({ ok: true, skipped: true, reason: "no_user_id" }, 200, cors);
await upsertEntitlement(userId, "free", false, null);
return json({ ok: true }, 200, cors);
}
// 4) Pagamento Confirmado (Salvar no Histórico)
if (t === "invoice.payment_succeeded") {
const invoice = event.data.object as Stripe.Invoice;
const customerId = (invoice.customer as string | null) ?? null;
// Tenta pegar user_id do metadata da subscription ou do cliente
let userId = await resolveUserId(customerId, null);
// Fallback: Tenta pegar da subscription associada à invoice
if (!userId && invoice.subscription) {
try {
const sub = await stripe.subscriptions.retrieve(String(invoice.subscription));
userId = await resolveUserId(customerId, (sub.metadata?.user_id as string | undefined) ?? null);
} catch (e) {
console.error("Error retrieving subscription for userId fallback:", e);
}
}
if (!userId) {
console.error("Invoice payment succeeded but could not resolve userId", { customerId, invoiceId: invoice.id });
return json({ ok: true, skipped: true, reason: "no_user_id_for_invoice" }, 200, cors);
}
// Mapeia dados
const amount = (invoice.amount_paid || 0) / 100; // Centavos para Real
const currency = invoice.currency;
const status = "completed";
const method = invoice.collection_method === "charge_automatically" ? "credit_card" : "other"; // Simplificado
// Tenta adivinhar o plano pelo valor ou linhas da fatura (básico)
// Idealmente viria do metadata, mas na invoice pode ser mais chato de pegar sem chamada extra
const lines = invoice.lines?.data || [];
const planDescription = lines.length > 0 ? lines[0].description : "Assinatura";
let planType = "monthly";
if (planDescription?.toLowerCase().includes("anual")) planType = "yearly";
if (planDescription?.toLowerCase().includes("trimestral")) planType = "quarterly";
// Insere na tabela payments
const { error: payErr } = await supabaseAdmin(`/rest/v1/payments`, {
method: "POST",
body: JSON.stringify({
user_id: userId,
amount: amount,
status: status,
plan_type: planType,
payment_method: method,
created_at: new Date().toISOString()
}),
});
if (payErr) {
// Loga erro mas não retorna 500 para não travar o webhook do Stripe (que tentaria reenviar)
console.error("Error inserting payment record:", payErr);
}
return json({ ok: true, message: "Payment recorded" }, 200, cors);
}
return json({ ok: true, ignored: true, type: t }, 200, cors);
} catch (err) {
console.error("stripe-webhook handler error:", err);
return json({ ok: false, error: String((err as any)?.message ?? err) }, 500, cors);
}
});

View file

@ -0,0 +1,117 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
serve(async (req) => {
// Handle CORS preflight requests
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
// 1. Initialize Supabase Client with the incoming user's Auth context
// This allows us to use `auth.getUser()` securely based on the JWT sent by the frontend.
const supabaseClient = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
{
global: {
headers: { Authorization: req.headers.get('Authorization')! },
},
}
);
// 2. Get User from Token
const {
data: { user },
error: authError,
} = await supabaseClient.auth.getUser();
if (authError || !user) {
return new Response(
JSON.stringify({ allowed: false, error: 'Unauthorized', reason: 'auth_failed' }),
{ status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// 3. Check Entitlements (Active Plan?)
// We look for the most recent entitlement that is active.
const { data: entitlement, error: entError } = await supabaseClient
.from('user_entitlements')
.select('is_active, valid_until, entitlement_code')
.eq('user_id', user.id)
.order('valid_until', { ascending: false })
.maybeSingle();
if (entError) {
console.error("Entitlement check error:", entError);
}
// A plan is active if is_active=true AND (valid_until is NULL (lifetime) OR valid_until > now)
const isActive = entitlement?.is_active && (!entitlement.valid_until || new Date(entitlement.valid_until) > new Date());
if (isActive) {
return new Response(
JSON.stringify({
allowed: true,
plan: entitlement.entitlement_code,
reason: 'plan_active',
quota_remaining: -1 // Infinite/Plan
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// 4. Check Free Quota (Coach Analyses)
// Counts how many analyses already consumed the free quota.
const { count, error: countError } = await supabaseClient
.from('coach_analyses')
.select('*', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('used_free_quota', true);
if (countError) {
console.error("Quota check error:", countError);
throw new Error("Failed to check quota usage.");
}
const FREE_LIMIT = 3; // Defined limit for Coach
const used = count || 0;
const remaining = Math.max(0, FREE_LIMIT - used);
if (remaining > 0) {
return new Response(
JSON.stringify({
allowed: true,
plan: 'free',
reason: 'free_quota',
quota_remaining: remaining
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
// 5. Quota Exceeded
return new Response(
JSON.stringify({
allowed: false,
plan: 'free',
reason: 'quota_exceeded',
quota_remaining: 0
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } } // 200 OK because logic was successful, just access denied
);
} catch (error: any) {
console.error("Validate Access Error:", error);
return new Response(
JSON.stringify({ allowed: false, error: error.message }),
{ status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
);
}
});

View file

@ -0,0 +1,954 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.39.7";
import { GoogleGenerativeAI } from "https://esm.sh/@google/generative-ai@0.21.0";
import { SYSTEM_PROMPT, COACH_SYSTEM_PROMPT } from "./prompt.ts";
import { buildCoachPdfHtml } from "./pdf-template.ts";
// ─── Config ────────────────────────────────────────────────────────
const EVOLUTION_API_URL = Deno.env.get("EVOLUTION_API_URL") ?? "";
const EVOLUTION_API_KEY = Deno.env.get("EVOLUTION_API_KEY") ?? "";
const GEMINI_API_KEY = Deno.env.get("GEMINI_API_KEY") ?? "";
const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? "";
const SUPABASE_SRK = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "";
const INSTANCE_NAME = "foodsnap";
const FREE_FOOD_LIMIT = 5;
// ─── Types ─────────────────────────────────────────────────────────
interface EvolutionPayload {
event: string;
instance: string;
data: {
key: { remoteJid: string; fromMe: boolean; id: string };
pushName?: string;
messageType?: string;
messageTimestamp?: number;
message?: {
imageMessage?: { mimetype: string };
conversation?: string;
extendedTextMessage?: { text: string };
};
};
sender?: string;
}
// ─── Helpers ───────────────────────────────────────────────────────
/** Remove tudo que não é dígito */
const onlyDigits = (s: string) => s.replace(/\D/g, "");
/**
* Gera candidatos de número brasileiro (com/sem DDI 55, com/sem 9º dígito).
* Usado para fazer match com profiles.phone_e164 e profiles.phone.
*/
function generatePhoneCandidates(raw: string): string[] {
const candidates: string[] = [];
const num = onlyDigits(raw);
if (!num) return candidates;
candidates.push(num);
const withoutDDI = num.startsWith("55") ? num.slice(2) : num;
if (withoutDDI !== num) candidates.push(withoutDDI);
if (!num.startsWith("55")) candidates.push("55" + num);
const ddd = withoutDDI.slice(0, 2);
const rest = withoutDDI.slice(2);
// Adiciona 9º dígito se tem 8 dígitos após DDD
if (rest.length === 8) {
const with9 = ddd + "9" + rest;
candidates.push(with9);
candidates.push("55" + with9);
}
// Remove 9º dígito se tem 9 dígitos após DDD
if (rest.length === 9 && rest.startsWith("9")) {
const without9 = ddd + rest.slice(1);
candidates.push(without9);
candidates.push("55" + without9);
}
return candidates;
}
/** Envia mensagem de texto via Evolution API */
async function sendWhatsAppMessage(remoteJid: string, text: string) {
if (!EVOLUTION_API_URL) {
console.error("[WH] EVOLUTION_API_URL not set! Cannot send message.");
return;
}
try {
const url = `${EVOLUTION_API_URL}/message/sendText/${INSTANCE_NAME}`;
console.log(`[WH] Sending message to ${remoteJid.slice(0, 8)}... via ${url}`);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", apikey: EVOLUTION_API_KEY },
body: JSON.stringify({
number: remoteJid,
text: text,
delay: 1200,
}),
});
const resBody = await res.text();
console.log(`[WH] Evolution API response: ${res.status} ${resBody.slice(0, 200)}`);
} catch (err) {
console.error("[WH] Error sending WhatsApp message:", err);
}
}
/** Envia documento (PDF) via Evolution API */
async function sendWhatsAppDocument(remoteJid: string, mediaUrl: string, fileName: string, caption?: string) {
if (!EVOLUTION_API_URL) {
console.error("[WH] EVOLUTION_API_URL not set! Cannot send document.");
return;
}
try {
const url = `${EVOLUTION_API_URL}/message/sendMedia/${INSTANCE_NAME}`;
console.log(`[WH] Sending document to ${remoteJid.slice(0, 8)}... file=${fileName}`);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", apikey: EVOLUTION_API_KEY },
body: JSON.stringify({
number: remoteJid,
mediatype: "document",
media: mediaUrl,
fileName: fileName,
caption: caption || "",
delay: 1200,
}),
});
const resBody = await res.text();
console.log(`[WH] Evolution sendMedia response: ${res.status} ${resBody.slice(0, 200)}`);
} catch (err) {
console.error("[WH] Error sending WhatsApp document:", err);
}
}
/** Busca imagem em base64 da Evolution API */
async function getWhatsAppMedia(messageId: string): Promise<string | null> {
if (!EVOLUTION_API_URL) {
console.error("[WH] EVOLUTION_API_URL not set for media download!");
return null;
}
try {
const url = `${EVOLUTION_API_URL}/chat/getBase64FromMediaMessage/${INSTANCE_NAME}`;
console.log(`[WH] Fetching media: ${url}, messageId=${messageId}`);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", apikey: EVOLUTION_API_KEY },
body: JSON.stringify({
message: { key: { id: messageId } },
convertToMp4: false,
}),
});
const resText = await res.text();
console.log(`[WH] Media API response: ${res.status} ${resText.slice(0, 300)}`);
if (!res.ok) return null;
const data = JSON.parse(resText);
// A API pode retornar em diferentes formatos
const base64 = data.base64 || data.data?.base64 || null;
console.log(`[WH] Got base64: ${base64 ? `${base64.length} chars` : "NULL"}`);
return base64;
} catch (err) {
console.error("[WH] Error fetching media:", err);
return null;
}
}
/** Converte base64 → Uint8Array (para upload storage) */
function base64ToUint8Array(base64: string): Uint8Array {
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
// ─── Geração de HTML para PDF do Coach ────────────────────────────
// (Movido para pdf-template.ts)
// ─── Normalização e limpeza do JSON do Gemini (portado do n8n) ────
const toNum = (v: unknown): number => {
if (typeof v === "number") return v;
if (typeof v === "string") {
const n = Number(v.replace(",", ".").trim());
return Number.isFinite(n) ? n : 0;
}
return 0;
};
const ensureArray = (v: unknown): any[] => (Array.isArray(v) ? v : []);
const keyName = (s: string) =>
(s || "")
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
const clampConfidence = (c: string) => {
const k = keyName(c);
if (k.includes("alta")) return "alta";
if (k.includes("baixa")) return "baixa";
return "media";
};
const CITRUS_VARIANTS = /^(tangerina|bergamota|mandarina|clementina|mexerica)/;
const CANONICAL_MAP = [
{ match: /^laranja/, canonical: "Laranja" },
{ match: /^banana/, canonical: "Banana" },
{ match: /^maca|^maçã/, canonical: "Maçã" },
{ match: /^pera/, canonical: "Pera" },
{ match: /^uva/, canonical: "Uva" },
{ match: /^abacaxi/, canonical: "Abacaxi" },
{ match: /^melancia/, canonical: "Melancia" },
{ match: /^melao|^melão/, canonical: "Melão" },
];
function canonicalizeName(name: string): string {
const k = keyName(name);
if (CITRUS_VARIANTS.test(k)) return "Laranja";
for (const rule of CANONICAL_MAP) {
if (rule.match.test(k)) return rule.canonical;
}
return (name || "").trim();
}
const stripCitrusMention = (s: string) => {
const k = keyName(s);
if (/(tangerina|bergamota|mandarina|clementina|mexerica)/.test(k)) {
return s
.replace(/tangerina\/bergamota/gi, "laranja")
.replace(/tangerina|bergamota|mandarina|clementina|mexerica/gi, "laranja")
.trim();
}
return s;
};
const parseUnitsPortion = (portion: string) => {
const p = (portion || "").toLowerCase().replace(",", ".");
const um = p.match(/(\d+)\s*unidades?/);
const g = p.match(/(\d+(\.\d+)?)\s*g/);
return {
units: um ? Number(um[1]) : null,
grams: g ? Math.round(Number(g[1])) : null,
};
};
const buildUnitsPortion = (units: number | null, grams: number | null) => {
const u = units && units > 0 ? units : null;
const g = grams && grams > 0 ? grams : null;
if (u && g) return `${u} unidades (${g}g)`;
if (u) return `${u} unidades`;
if (g) return `${g}g`;
return "";
};
/**
* Recebe o texto cru do Gemini e retorna o objeto normalizado
* (portado do "Limpar Resultado" do n8n)
*/
function parseAndCleanGeminiResponse(rawText: string): any {
// Limpa markdown
let cleaned = rawText.replace(/```json/gi, "").replace(/```/g, "").trim();
// Extrai JSON
const m = cleaned.match(/\{[\s\S]*\}/);
if (!m) throw new Error("JSON não encontrado na resposta do Gemini.");
let jsonStr = m[0];
// Corrige JSON mal formado
jsonStr = jsonStr.replace(/:\s*\+(\d+(\.\d+)?)/g, ": $1");
jsonStr = jsonStr.replace(/,\s*([}\]])/g, "$1");
const parsed = JSON.parse(jsonStr);
// Normaliza items
parsed.items = ensureArray(parsed.items).map((it: any) => {
const rawName = (it.name || "").trim();
const k = keyName(rawName);
const flags = ensureArray(it.flags);
const name = canonicalizeName(rawName);
const nextFlags = CITRUS_VARIANTS.test(k)
? Array.from(new Set([...flags, "tipo_duvidoso"]))
: flags;
return {
...it,
name,
portion: (it.portion || "").trim(),
calories: toNum(it.calories),
protein: toNum(it.protein),
carbs: toNum(it.carbs),
fat: toNum(it.fat),
fiber: toNum(it.fiber),
sugar: toNum(it.sugar),
sodium_mg: toNum(it.sodium_mg),
flags: nextFlags,
};
});
// Deduplica por nome
const byName = new Map<string, any>();
for (const it of parsed.items) {
const k = keyName(it.name);
if (!k) continue;
if (!byName.has(k)) {
byName.set(k, it);
continue;
}
const cur = byName.get(k);
const a = parseUnitsPortion(cur.portion);
const b = parseUnitsPortion(it.portion);
let mergedPortion = cur.portion;
if (a.units !== null || b.units !== null || a.grams !== null || b.grams !== null) {
const units = (a.units || 0) + (b.units || 0);
const grams = (a.grams || 0) + (b.grams || 0);
const rebuilt = buildUnitsPortion(units || null, grams || null);
if (rebuilt) mergedPortion = rebuilt;
}
byName.set(k, {
...cur,
portion: mergedPortion,
calories: toNum(cur.calories) + toNum(it.calories),
protein: toNum(cur.protein) + toNum(it.protein),
carbs: toNum(cur.carbs) + toNum(it.carbs),
fat: toNum(cur.fat) + toNum(it.fat),
fiber: toNum(cur.fiber) + toNum(it.fiber),
sugar: toNum(cur.sugar) + toNum(it.sugar),
sodium_mg: toNum(cur.sodium_mg) + toNum(it.sodium_mg),
flags: Array.from(
new Set([...ensureArray(cur.flags), ...ensureArray(it.flags), "deduplicado"])
),
});
}
parsed.items = Array.from(byName.values());
// Recalcula totais
const sum = (arr: any[], f: string) => arr.reduce((a: number, b: any) => a + toNum(b[f]), 0);
parsed.total = {
calories: Math.round(sum(parsed.items, "calories")),
protein: +sum(parsed.items, "protein").toFixed(1),
carbs: +sum(parsed.items, "carbs").toFixed(1),
fat: +sum(parsed.items, "fat").toFixed(1),
fiber: +sum(parsed.items, "fiber").toFixed(1),
sugar: +sum(parsed.items, "sugar").toFixed(1),
sodium_mg: Math.round(sum(parsed.items, "sodium_mg")),
};
// Outros campos
parsed.health_score = toNum(parsed.health_score);
parsed.confidence = clampConfidence(parsed.confidence || "");
parsed.assumptions = ensureArray(parsed.assumptions).map(stripCitrusMention);
parsed.questions = ensureArray(parsed.questions);
parsed.insights = ensureArray(parsed.insights).map(stripCitrusMention);
parsed.swap_suggestions = ensureArray(parsed.swap_suggestions);
parsed.next_best_actions = ensureArray(parsed.next_best_actions);
parsed.tip =
parsed.tip && typeof parsed.tip === "object"
? parsed.tip
: { title: "", text: "", reason: "" };
parsed.tip.title = String(parsed.tip.title || "");
parsed.tip.text = stripCitrusMention(String(parsed.tip.text || ""));
parsed.tip.reason = stripCitrusMention(String(parsed.tip.reason || ""));
return parsed;
}
/**
* Formata a análise em mensagem rica para WhatsApp
* (portado do "Formatar Resposta WHATS" do n8n)
*/
function formatWhatsAppResponse(analysis: any): string {
if (!analysis || !Array.isArray(analysis.items) || !analysis.items.length) {
return "Não foi possível identificar um alimento válido na imagem.";
}
const items = analysis.items;
const total = analysis.total || {};
const fmt = (n: unknown) => {
if (n === undefined || n === null || n === "") return "—";
const num = Number(n);
if (!Number.isFinite(num)) return String(n);
return (Math.round(num * 10) / 10).toString();
};
const v = (x: unknown) => (x === undefined || x === null || x === "" ? "—" : x);
const lines: string[] = [];
lines.push("🥗 *RELATÓRIO PRATOFIT*");
lines.push("");
lines.push("*Itens identificados*");
items.forEach((it: any, idx: number) => {
lines.push(`${idx + 1}) ${v(it.name)}${v(it.portion)}${fmt(it.calories)} kcal`);
});
lines.push("");
lines.push("*Total do prato*");
lines.push(`Energia: ${fmt(total.calories)} kcal`);
lines.push("");
lines.push("*Macronutrientes (total)*");
lines.push(`Proteínas: ${fmt(total.protein)} g`);
lines.push(`Carboidratos: ${fmt(total.carbs)} g`);
lines.push(`Gorduras: ${fmt(total.fat)} g`);
lines.push("");
lines.push("*Outros nutrientes (total)*");
lines.push(`Fibras: ${fmt(total.fiber)} g`);
lines.push(`Açúcares: ${fmt(total.sugar)} g`);
lines.push(`Sódio: ${fmt(total.sodium_mg)} mg`);
if (analysis.health_score !== undefined) {
lines.push(`Score nutricional: ${fmt(analysis.health_score)} / 100`);
}
if (analysis.confidence) {
lines.push(`Confiabilidade: ${String(analysis.confidence).toLowerCase()}`);
}
lines.push("");
if (analysis.tip && analysis.tip.text) {
lines.push("💡 *Dica prática*");
lines.push(analysis.tip.text);
}
return lines.join("\n");
}
// ─── Main Handler ──────────────────────────────────────────────────
serve(async (req) => {
if (req.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
try {
const payload: EvolutionPayload = await req.json();
// ── 0. Filtrar eventos irrelevantes ─────────────────────────
const event = payload.event || "";
console.log(`[WH] Event received: ${event}`);
const IGNORED_EVENTS = [
"connection.update",
"qrcode.updated",
"presence.update",
"contacts.update",
"groups.update",
"chats.update",
];
if (IGNORED_EVENTS.includes(event)) {
console.log(`[WH] Event ignored: ${event}`);
return new Response("Event ignored", { status: 200 });
}
const data = payload.data;
if (!data || !data.key) {
console.log(`[WH] Invalid payload — missing data or data.key`);
return new Response("Invalid payload", { status: 200 });
}
const remoteJid = data.key.remoteJid;
// Ignorar mensagens próprias ou de status
if (data.key.fromMe || remoteJid.includes("status@")) {
console.log(`[WH] Ignored: fromMe=${data.key.fromMe}, jid=${remoteJid}`);
return new Response("Ignored", { status: 200 });
}
// ── 1. Extrair dados ────────────────────────────────────────
const senderNumber = onlyDigits(remoteJid.replace(/@.*$/, ""));
const senderFromPayload = payload.sender
? onlyDigits(String(payload.sender).replace(/@.*$/, ""))
: "";
const messageId = data.key.id;
const isImage = !!data.message?.imageMessage;
const textMessage =
data.message?.conversation || data.message?.extendedTextMessage?.text || "";
console.log(`[WH] sender=${senderNumber}, isImage=${isImage}, text="${textMessage.slice(0, 50)}"`);
// Gerar candidatos de número BR
const allCandidates = [
...generatePhoneCandidates(senderNumber),
...(senderFromPayload ? generatePhoneCandidates(senderFromPayload) : []),
];
const phoneCandidates = [...new Set(allCandidates)];
console.log(`[WH] phoneCandidates: ${JSON.stringify(phoneCandidates)}`);
// ── 2. Init Supabase ────────────────────────────────────────
const supabase = createClient(SUPABASE_URL, SUPABASE_SRK);
// ── 3. Buscar usuário com phone_candidates ──────────────────
let user: { id: string } | null = null;
for (const candidate of phoneCandidates) {
const { data: directMatch, error: matchErr } = await supabase
.from("profiles")
.select("id")
.or(`phone_e164.eq.${candidate},phone.eq.${candidate}`)
.maybeSingle();
if (matchErr) {
console.error(`[WH] DB error matching candidate ${candidate}:`, matchErr.message);
}
if (directMatch) {
user = directMatch;
console.log(`[WH] User found: ${user.id} (matched candidate: ${candidate})`);
break;
}
}
if (!user) {
console.log(`[WH] User NOT found for candidates: ${phoneCandidates.join(", ")}`);
await sendWhatsAppMessage(
remoteJid,
"🚫 *Acesso restrito*\nSeu número não está cadastrado no *FoodSnap*.\n\nCadastre-se em: https://foodsnap.com.br\n\nApós o cadastro, envie novamente a foto do prato 🍽️"
);
return new Response("User not found", { status: 200 });
}
const userId = user.id;
// ── 4. Estado da conversa (Coach state machine) ─────────────
let { data: conv } = await supabase
.from("whatsapp_conversations")
.select("*")
.eq("phone_number", senderNumber)
.maybeSingle();
if (!conv) {
const { data: newConv } = await supabase
.from("whatsapp_conversations")
.insert({ phone_number: senderNumber, state: "IDLE", temp_data: {} })
.select()
.single();
conv = newConv;
}
const state = conv?.state || "IDLE";
console.log(`[WH] Conversation state: ${state}, conv exists: ${!!conv}`);
// ── 5. Coach Flow ───────────────────────────────────────────
// TRIGGER: texto contendo palavras-chave coach
if (
state === "IDLE" &&
textMessage &&
/coach|treino|avalia[çc][aã]o/i.test(textMessage)
) {
// [LOGIC START] Verificar última avaliação (Limite de 7 dias)
const { data: lastAnalysis } = await supabase
.from("coach_analyses")
.select("created_at")
.eq("user_id", userId)
.order("created_at", { ascending: false })
.limit(1)
.maybeSingle();
if (lastAnalysis && lastAnalysis.created_at) {
const lastDate = new Date(lastAnalysis.created_at);
const now = new Date();
const diffTime = Math.abs(now.getTime() - lastDate.getTime());
const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000;
if (diffTime < sevenDaysInMs) {
const daysRemaining = Math.ceil((sevenDaysInMs - diffTime) / (1000 * 60 * 60 * 24));
await sendWhatsAppMessage(
remoteJid,
`⏳ *Calma, atleta!* O corpo precisa de tempo para evoluir.\n\nSua última avaliação foi há menos de uma semana.\nVocê poderá fazer uma nova avaliação em *${daysRemaining} dia(s)*.\n\nFoque no plano atual! 💪`
);
return new Response("Coach Cooldown", { status: 200 });
}
}
// [LOGIC END]
await supabase
.from("whatsapp_conversations")
.update({ state: "COACH_FRONT", temp_data: {} })
.eq("phone_number", senderNumber);
await sendWhatsAppMessage(
remoteJid,
"🏋️‍♂️ *Coach AI Iniciado!*\n\nVamos montar seu protocolo de treino e dieta.\nPara começar, envie uma foto do seu corpo de *FRENTE* (mostrando do pescoço até os joelhos, se possível)."
);
return new Response("Coach Started", { status: 200 });
}
// COACH_FRONT
if (state === "COACH_FRONT") {
if (!isImage) {
await sendWhatsAppMessage(remoteJid, "⚠️ Por favor, envie a foto de *FRENTE* para continuarmos.");
return new Response("Waiting Front", { status: 200 });
}
const base64 = await getWhatsAppMedia(messageId);
if (!base64) return new Response("Error downloading media", { status: 200 });
const fileName = `${userId}_front_${Date.now()}.jpg`;
await supabase.storage
.from("coach-uploads")
.upload(fileName, base64ToUint8Array(base64), { contentType: "image/jpeg" });
await supabase
.from("whatsapp_conversations")
.update({ state: "COACH_SIDE", temp_data: { ...conv!.temp_data, front_image: fileName } })
.eq("phone_number", senderNumber);
await sendWhatsAppMessage(remoteJid, "✅ Foto de frente recebida!\nAgora, envie uma foto de *LADO* (Perfil).");
return new Response("Front Received", { status: 200 });
}
// COACH_SIDE
if (state === "COACH_SIDE") {
if (!isImage) {
await sendWhatsAppMessage(remoteJid, "⚠️ Por favor, envie a foto de *LADO*.");
return new Response("Waiting Side", { status: 200 });
}
const base64 = await getWhatsAppMedia(messageId);
if (!base64) return new Response("Error downloading media", { status: 200 });
const fileName = `${userId}_side_${Date.now()}.jpg`;
await supabase.storage
.from("coach-uploads")
.upload(fileName, base64ToUint8Array(base64), { contentType: "image/jpeg" });
await supabase
.from("whatsapp_conversations")
.update({ state: "COACH_BACK", temp_data: { ...conv!.temp_data, side_image: fileName } })
.eq("phone_number", senderNumber);
await sendWhatsAppMessage(remoteJid, "✅ Perfil recebido!\nPor último, envie uma foto de *COSTAS*.");
return new Response("Side Received", { status: 200 });
}
// COACH_BACK
if (state === "COACH_BACK") {
if (!isImage) {
await sendWhatsAppMessage(remoteJid, "⚠️ Por favor, envie a foto de *COSTAS*.");
return new Response("Waiting Back", { status: 200 });
}
const base64 = await getWhatsAppMedia(messageId);
if (!base64) return new Response("Error downloading media", { status: 200 });
const fileName = `${userId}_back_${Date.now()}.jpg`;
await supabase.storage
.from("coach-uploads")
.upload(fileName, base64ToUint8Array(base64), { contentType: "image/jpeg" });
await supabase
.from("whatsapp_conversations")
.update({ state: "COACH_GOAL", temp_data: { ...conv!.temp_data, back_image: fileName } })
.eq("phone_number", senderNumber);
await sendWhatsAppMessage(
remoteJid,
"📸 Todas as fotos recebidas!\n\nAgora digite o número do seu objetivo principal:\n1⃣ Hipertrofia (Ganhar massa)\n2⃣ Emagrecimento (Secar)\n3⃣ Definição (Manter peso/trocar gordura por músculo)"
);
return new Response("Back Received", { status: 200 });
}
// COACH_GOAL
if (state === "COACH_GOAL") {
let goal = "Hipertrofia";
if (textMessage.includes("2") || /emagreci/i.test(textMessage)) goal = "Emagrecimento";
else if (textMessage.includes("3") || /defini/i.test(textMessage)) goal = "Definição";
else if (!textMessage.includes("1") && !/hiper/i.test(textMessage)) {
await sendWhatsAppMessage(remoteJid, "⚠️ Não entendi. Responda com 1, 2 ou 3.");
return new Response("Waiting Goal", { status: 200 });
}
await sendWhatsAppMessage(
remoteJid,
"🤖 Estou analisando seu físico e montando o plano com a IA...\nIsso pode levar cerca de 10-15 segundos."
);
try {
const { front_image, side_image, back_image } = conv!.temp_data;
const images = [front_image, side_image, back_image];
const parts: any[] = [{ text: COACH_SYSTEM_PROMPT }, { text: `Objetivo: ${goal}` }];
for (const imgPath of images) {
if (imgPath) {
const { data: blob } = await supabase.storage.from("coach-uploads").download(imgPath);
if (blob) {
const buffer = await blob.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
parts.push({ inlineData: { mimeType: "image/jpeg", data: base64 } });
}
}
}
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
const result = await model.generateContent({
contents: [{ role: "user", parts }],
generationConfig: { temperature: 0.2, responseMimeType: "application/json" },
});
const responseText = result.response.text();
const plan = JSON.parse(responseText);
let msg = `🔥 *SEU PROTOCOLO TITAN* 🔥\n\n`;
msg += `🧬 *Análise*: ${plan.analysis?.somatotype}, ${plan.analysis?.muscle_mass_level} massa muscular.\n`;
msg += `🎯 *Foco*: ${plan.workout?.focus}\n\n`;
msg += `🏋️ *Treino*: Divisão ${plan.workout?.split} (${plan.workout?.frequency_days}x/semana)\n`;
msg += `🥗 *Dieta*: ${Math.round(plan.diet?.total_calories)} kcal\n`;
msg += ` • P: ${plan.diet?.macros?.protein_g}g | C: ${plan.diet?.macros?.carbs_g}g | G: ${plan.diet?.macros?.fats_g}g\n\n`;
msg += `💊 *Suplementos*: ${plan.diet?.supplements?.map((s: any) => s.name).join(", ")}\n\n`;
msg += `💡 *Dica*: ${plan.motivation_quote}\n\n`;
msg += `📲 *Acesse o app para ver o plano completo e detalhado!*`;
await sendWhatsAppMessage(remoteJid, msg);
// ── Gerar PDF e enviar via WhatsApp ─────────────────
try {
const pdfFileName = `FoodSnap_Titan_${new Date().toISOString().split("T")[0]}`;
const pdfHtml = buildCoachPdfHtml(plan);
console.log("[WH] Generating PDF via n8n/Gotenberg...");
const pdfResponse = await fetch("https://n8n.seureview.com.br/webhook/pdf-coach", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ html: pdfHtml, file_name: pdfFileName }),
});
if (pdfResponse.ok) {
const pdfBlob = await pdfResponse.arrayBuffer();
const pdfBytes = new Uint8Array(pdfBlob);
const storagePath = `${userId}/${pdfFileName}.pdf`;
// Upload para Supabase Storage
const { error: uploadErr } = await supabase.storage
.from("coach-pdfs")
.upload(storagePath, pdfBytes, {
contentType: "application/pdf",
upsert: true,
});
if (uploadErr) {
console.error("[WH] PDF upload error:", uploadErr);
} else {
// URL Assinada (funciona mesmo com bucket privado)
const { data: urlData, error: signErr } = await supabase.storage
.from("coach-pdfs")
.createSignedUrl(storagePath, 60 * 60); // 1 hora de validade
if (signErr || !urlData?.signedUrl) {
console.error("[WH] Signed URL error:", signErr);
} else {
await sendWhatsAppDocument(
remoteJid,
urlData.signedUrl,
`${pdfFileName}.pdf`,
"📄 Seu Protocolo Titan completo em PDF!"
);
}
}
} else {
console.error("[WH] n8n PDF error:", pdfResponse.status, await pdfResponse.text());
}
} catch (pdfErr) {
console.error("[WH] PDF generation/send error (non-blocking):", pdfErr);
// PDF is non-blocking — user already got the text summary
}
// ── Salvar análise coach (enriquecido p/ dashboard) ─
const { error: saveCoachErr } = await supabase.from("coach_analyses").insert({
user_id: userId,
source: "whatsapp",
ai_raw_response: responseText,
ai_structured: plan,
goal_suggestion: goal,
biotype: plan.analysis?.somatotype || null,
estimated_body_fat: parseFloat(String(plan.analysis?.body_fat_percentage || 0)) || 0,
muscle_mass_level: plan.analysis?.muscle_mass_level || null,
});
if (saveCoachErr) {
console.error("[WH] Error saving coach analysis to DB:", saveCoachErr);
} else {
console.log("[WH] Coach analysis saved successfully for user:", userId);
}
// Reset state
await supabase
.from("whatsapp_conversations")
.update({ state: "IDLE", temp_data: {} })
.eq("phone_number", senderNumber);
} catch (err) {
console.error("Coach Gen Error:", err);
await sendWhatsAppMessage(
remoteJid,
"⚠️ Ocorreu um erro ao gerar seu plano. Tente novamente digitando 'Coach'."
);
await supabase
.from("whatsapp_conversations")
.update({ state: "IDLE", temp_data: {} })
.eq("phone_number", senderNumber);
}
return new Response("Coach Workflow Completed", { status: 200 });
}
// ── 6. Food Scan Flow (IDLE) ────────────────────────────────
if (state === "IDLE") {
console.log(`[WH] Entering Food Scan flow. isImage=${isImage}`);
// 6a. Verificar plano e quota
const { data: entitlement } = await supabase
.from("user_entitlements")
.select("is_active, valid_until, entitlement_code")
.eq("user_id", userId)
.eq("is_active", true)
.order("valid_until", { ascending: false, nullsFirst: false })
.maybeSingle();
const isPaid =
entitlement?.is_active &&
(!entitlement.valid_until || new Date(entitlement.valid_until) > new Date());
if (!isPaid) {
const { count: freeUsed } = await supabase
.from("food_analyses")
.select("*", { count: "exact", head: true })
.eq("user_id", userId)
.eq("used_free_quota", true);
if ((freeUsed || 0) >= FREE_FOOD_LIMIT) {
await sendWhatsAppMessage(
remoteJid,
`🚫 Limite gratuito atingido\nVocê já usou suas ${FREE_FOOD_LIMIT} análises grátis.\n\nPara continuar, assine um plano em:\nhttps://foodsnap.com.br\n\nDepois é só enviar outra foto 📸`
);
return new Response("Quota exceeded", { status: 200 });
}
}
// 6b. Sem imagem → mensagem de boas-vindas
if (!isImage) {
await sendWhatsAppMessage(
remoteJid,
"👋 Olá! Envie uma *foto do seu prato* (bem nítida e de cima 📸) que eu te retorno *calorias e macronutrientes* em segundos.\n\nOu digite *Coach* para iniciar uma consultoria completa."
);
return new Response("Text handled", { status: 200 });
}
// 6c. Processar imagem
await sendWhatsAppMessage(remoteJid, "📸 Recebi sua foto! Estou analisando o prato agora… ⏳");
const base64Image = await getWhatsAppMedia(messageId);
if (!base64Image) {
await sendWhatsAppMessage(remoteJid, "⚠️ Não consegui baixar a imagem. Tente enviar novamente.");
return new Response("Error downloading image", { status: 200 });
}
// 6d. Chamar Gemini
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash" });
const geminiResult = await model.generateContent({
contents: [
{
role: "user",
parts: [
{ text: SYSTEM_PROMPT },
{ inlineData: { mimeType: "image/jpeg", data: base64Image } },
],
},
],
generationConfig: { temperature: 0.1, responseMimeType: "application/json" },
});
const rawResponseText = geminiResult.response.text();
// 6e. Limpar e normalizar resultado
let analysis: any;
try {
analysis = parseAndCleanGeminiResponse(rawResponseText);
} catch (parseErr) {
console.error("Parse error:", parseErr);
await sendWhatsAppMessage(
remoteJid,
"⚠️ Houve um erro ao interpretar a análise. Tente enviar a foto novamente com boa iluminação."
);
return new Response("Parse error", { status: 200 });
}
// 6f. Formatar e enviar resposta
const replyText = formatWhatsAppResponse(analysis);
await sendWhatsAppMessage(remoteJid, replyText);
// 6g. Mapear confidence para enum do banco
const confidenceMap: Record<string, string> = {
alta: "high",
media: "medium",
média: "medium",
baixa: "low",
};
// 6h. Salvar no banco
const { data: inserted } = await supabase
.from("food_analyses")
.insert({
user_id: userId,
source: "whatsapp",
image_url: null, // será atualizado após upload
ai_raw_response: rawResponseText,
ai_structured: analysis,
total_calories: analysis.total?.calories || 0,
total_protein: analysis.total?.protein || 0,
total_carbs: analysis.total?.carbs || 0,
total_fat: analysis.total?.fat || 0,
total_fiber: analysis.total?.fiber || 0,
total_sodium_mg: analysis.total?.sodium_mg || 0,
nutrition_score: analysis.health_score || 0,
confidence_level: confidenceMap[analysis.confidence] || "medium",
used_free_quota: !isPaid,
})
.select("id")
.single();
// 6i. Upload imagem para Supabase Storage (bucket consultas)
if (inserted?.id) {
try {
const imgPath = `${userId}/${inserted.id}.jpg`;
const imgBytes = base64ToUint8Array(base64Image);
await supabase.storage
.from("consultas")
.upload(imgPath, imgBytes, { contentType: "image/jpeg", upsert: true });
// Atualizar image_url no registro
const { data: { publicUrl } } = supabase.storage
.from("consultas")
.getPublicUrl(imgPath);
await supabase
.from("food_analyses")
.update({ image_url: publicUrl })
.eq("id", inserted.id);
} catch (uploadErr) {
console.error("Image upload error (non-fatal):", uploadErr);
// Não falha o fluxo principal por erro de upload
}
}
return new Response("Food Analyzed", { status: 200 });
}
return new Response("Nothing happened", { status: 200 });
} catch (err: any) {
console.error("Critical Error:", err);
return new Response(`Server error: ${err.message}`, { status: 500 });
}
});

View file

@ -0,0 +1,249 @@
// ─── Geração de HTML para PDF do Coach (Premium 3 Páginas Compacto) ────────
function truncateText(text: string, max = 500): string {
const t = (text || "").trim();
if (!t) return "-";
return t.length > max ? t.slice(0, max - 1) + "…" : t;
}
function safeStr(v: any, fallback = "-"): string {
if (v === null || v === undefined) return fallback;
if (typeof v === "string") return v.trim() || fallback;
if (typeof v === "number") return Number.isFinite(v) ? String(v) : fallback;
return fallback;
}
export function buildCoachPdfHtml(plan: any): string {
const diet = plan.diet || {};
const workout = plan.workout || {};
const analysis = plan.analysis || {};
const quote = plan.motivation_quote || "Disciplina é a ponte entre metas e conquistas.";
// --- Data Prep ---
const protein = diet.macros?.protein_g ?? "";
const carbs = diet.macros?.carbs_g ?? "";
const fats = diet.macros?.fats_g ?? "";
const water = diet.hydration_liters ?? "";
const calories = Math.round(diet.total_calories || 0);
const somatotype = safeStr(analysis.somatotype);
const goal = safeStr(workout.focus);
const split = safeStr(workout.split);
// Lists
const positives = (Array.isArray(analysis.strengths) ? analysis.strengths : [])
.map((x: any) => typeof x === "string" ? x : x?.text).filter(Boolean); // Removed slice limit
// Map 'weaknesses' to 'improvements' (Prompt returns weaknesses)
const improvements = (Array.isArray(analysis.weaknesses) ? analysis.weaknesses : [])
.map((x: any) => typeof x === "string" ? x : x?.text).filter(Boolean);
const meals: any[] = Array.isArray(diet.meal_plan_example) ? diet.meal_plan_example : [];
const supplements: any[] = Array.isArray(diet.supplements) ? diet.supplements : [];
const routine: any[] = Array.isArray(workout.routine) ? workout.routine : [];
// --- HTML Generators ---
const positivesHtml = positives.length
? `<ul class="list-disc pl-3 space-y-0.5 text-[10px] leading-snug text-gray-700">${positives.map((t: string) => `<li>${truncateText(t, 500)}</li>`).join("")}</ul>`
: `<p class="text-[10px] text-gray-600">${safeStr(analysis.summary, "Sem detalhes.")}</p>`;
const improvementsHtml = improvements.length
? `<ul class="list-disc pl-3 space-y-0.5 text-[10px] leading-snug text-gray-700">${improvements.map((t: string) => `<li>${truncateText(t, 500)}</li>`).join("")}</ul>`
: `<p class="text-[10px] text-gray-600">${safeStr(analysis.improvement_summary, "Sem detalhes.")}</p>`;
const mealsHtml = meals.map((meal: any, i: number) => {
const options = Array.isArray(meal.options) ? meal.options : [];
const opt1 = options[0] || meal.main_option || "";
const opt2 = options[1] || "";
const sub = meal.substitution_suggestion || meal.substitution || "";
let html = `<div class="rounded-xl border border-gray-200 p-1.5 avoid-break mb-1">`;
html += `<div class="flex items-start justify-between gap-2"><div>`;
html += `<div class="text-[10px] font-extrabold text-gray-900 leading-none">${meal.name || `Refeição ${i + 1}`}</div>`;
if (meal.time_range) html += `<div class="text-[9px] text-brand-700 font-semibold">${meal.time_range}</div>`;
html += `</div><div class="text-[9px] text-gray-400 font-bold">#${i + 1}</div></div>`;
html += `<div class="mt-1 space-y-1">`;
if (opt1) html += `<div class="text-[9px] leading-tight text-gray-800 bg-gray-50 border border-gray-100 rounded-lg p-1"><span class="font-bold text-gray-700">Opção 1: </span>${truncateText(String(opt1), 500)}</div>`;
if (opt2) html += `<div class="text-[9px] leading-tight text-gray-800 bg-gray-50 border border-gray-100 rounded-lg p-1"><span class="font-bold text-gray-700">Opção 2: </span>${truncateText(String(opt2), 500)}</div>`;
if (sub) html += `<div class="text-[9px] leading-tight text-green-900 bg-green-50/70 border border-green-100 rounded-lg p-1"><span class="font-bold uppercase text-[8px] text-green-800">Substituição:</span> ${truncateText(String(sub), 300)}</div>`;
html += `</div></div>`;
return html;
}).join("");
const supplementsHtml = supplements.map((sup: any) => {
const name = typeof sup === "string" ? sup : sup.name || "Suplemento";
const dosage = typeof sup === "string" ? "" : sup.dosage || "";
const reason = typeof sup === "string" ? "" : sup.reason || ""; // Added reason if available
let html = `<div class="border-l-2 border-brand-500 pl-2 mb-1">`;
html += `<div class="flex items-center gap-1"><span class="text-brand-500 text-[10px]">💊</span><div class="text-[10px] font-bold leading-none">${truncateText(String(name), 100)}</div></div>`;
if (dosage) html += `<div class="text-[9px] text-gray-500 leading-none mt-0.5">${truncateText(String(dosage), 100)}</div>`;
if (reason) html += `<div class="text-[8px] text-gray-400 leading-none mt-0.5 italic">${truncateText(String(reason), 150)}</div>`;
html += `</div>`;
return html;
}).join("");
const daysHtml = routine.map((day: any, idx: number) => {
const exs: any[] = Array.isArray(day.exercises) ? day.exercises : [];
const dayName = day.day || day.name || `Dia ${idx + 1}`;
const muscle = day.muscle_group || day.focus || "";
const exLines = exs.map((ex: any) => {
if (typeof ex === "string") return `<li class="text-[9px] text-gray-700 leading-tight break-words">${ex}</li>`;
const name = ex.name || ex.exercise || "";
const sets = ex.sets ?? "";
const reps = ex.reps ?? "";
const technique = ex.technique || ex.notes || "";
const sr = [sets ? `${sets}x` : "", reps].filter(Boolean).join(" ");
const left = [name, sr].filter(Boolean).join(" — ");
const full = [left, technique].filter(Boolean).join(" • ");
return `<li class="text-[9px] text-gray-700 leading-tight break-words">${truncateText(full, 500) || "-"}</li>`;
}).join("");
return `<div class="rounded-xl border border-gray-200 p-2 overflow-hidden mb-1">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="text-[10px] font-black text-gray-900 leading-none truncate">${dayName}</div>
<div class="text-[9px] text-gray-500 leading-none">${muscle}</div>
</div>
<div class="text-[9px] text-gray-400 font-mono whitespace-nowrap">${workout.split || "Diff"}</div>
</div>
<div class="mt-1 space-y-0.5"><ul class="list-disc pl-3 space-y-0.5">${exLines}</ul></div>
</div>`;
}).join("");
// --- Template Compacto ---
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: { 50: '#f0fdfa', 100: '#ccfbf1', 500: '#14b8a6', 700: '#0f766e', 900: '#134e4a' }
},
fontSize: { xs: '0.6rem', sm: '0.7rem', base: '0.8rem', lg: '1rem', xl: '1.25rem' }
}
}
}
</script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700;900&display=swap');
@page { size: A4; margin: 0; }
html, body { margin: 0; padding: 0; background: #fff; }
body { font-family: 'Inter', sans-serif; -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
* { box-sizing: border-box; }
/* Compact A4 Layout */
.pdf-page {
width: 210mm;
height: 297mm;
padding: 8mm; /* Padrao 8mm (compacto) */
overflow: hidden;
page-break-after: always;
break-after: page;
display: flex;
flex-direction: column;
}
.pdf-page:last-child { page-break-after: auto; break-after: auto; }
.avoid-break { break-inside: avoid; page-break-inside: avoid; }
</style>
</head>
<body>
<!-- PÁGINA 1: RESUMO -->
<div class="pdf-root">
<div class="pdf-page">
<div class="h-full flex flex-col">
<div class="flex items-end justify-between border-b border-gray-200 pb-2 mb-2">
<div>
<div class="text-[9px] uppercase tracking-[0.2em] text-gray-400 font-semibold">Protocolo Titan FoodSnap Coach</div>
<h2 class="text-xl font-black text-gray-900 leading-tight">01. Diagnóstico</h2>
</div>
<div class="text-gray-300 text-2xl"></div>
</div>
<div class="grid grid-cols-4 gap-2 mb-2">
<div class="rounded-lg border border-gray-200 p-1.5"><div class="text-[9px] uppercase tracking-widest text-gray-400 font-semibold">Biótipo</div><div class="text-[11px] font-bold text-gray-900">${somatotype}</div></div>
<div class="rounded-lg border border-gray-200 p-1.5"><div class="text-[9px] uppercase tracking-widest text-gray-400 font-semibold">Objetivo</div><div class="text-[11px] font-bold text-gray-900">${goal}</div></div>
<div class="rounded-lg border border-gray-200 p-1.5"><div class="text-[9px] uppercase tracking-widest text-gray-400 font-semibold">Calorias</div><div class="text-[11px] font-bold text-gray-900">${calories}</div></div>
<div class="rounded-lg border border-gray-200 p-1.5"><div class="text-[9px] uppercase tracking-widest text-gray-400 font-semibold">Split</div><div class="text-[11px] font-bold text-gray-900">${split}</div></div>
</div>
<div class="grid grid-cols-2 gap-2 flex-1 min-h-0">
<div class="rounded-xl border border-gray-200 p-2 overflow-hidden">
<div class="text-[10px] font-black text-gray-900 mb-1">Pontos Fortes</div>
${positivesHtml}
</div>
<div class="rounded-xl border border-gray-200 p-2 overflow-hidden">
<div class="text-[10px] font-black text-gray-900 mb-1">Melhorias</div>
${improvementsHtml}
</div>
</div>
<div class="mt-2 rounded-xl border border-gray-200 p-2">
<p class="text-[9px] text-gray-500 italic text-center">"O sucesso é a soma de pequenos esforços repetidos dia após dia."</p>
</div>
</div>
</div>
<!-- PÁGINA 2: DIETA -->
<div class="pdf-page">
<div class="h-full flex flex-col">
<div class="flex items-end justify-between border-b border-gray-200 pb-2 mb-2">
<div><h2 class="text-xl font-black text-gray-900 leading-tight">02. Dieta</h2></div>
<div class="text-gray-300 text-2xl">🥗</div>
</div>
<div class="rounded-lg border border-gray-200 p-1.5 mb-2 avoid-break">
<div class="flex justify-between items-center text-[10px]">
<div><span class="text-gray-400 font-bold uppercase">PROT:</span> <span class="font-bold">${protein}</span></div>
<div><span class="text-gray-400 font-bold uppercase">CARB:</span> <span class="font-bold">${carbs}</span></div>
<div><span class="text-gray-400 font-bold uppercase">GORD:</span> <span class="font-bold">${fats}</span></div>
<div class="text-blue-600 font-bold">💧 ${water}L</div>
</div>
</div>
<div class="grid grid-cols-3 gap-2 flex-1 min-h-0">
<div class="col-span-2 space-y-1 min-h-0">
<div class="text-[10px] font-black text-gray-900">Refeições</div>
<div class="space-y-1">${mealsHtml}</div>
</div>
<div class="col-span-1 min-h-0 flex flex-col">
<div class="text-[10px] font-black text-gray-900 mb-1">Suplementos</div>
<div class="bg-gray-50 rounded-xl p-2 flex-1 min-h-0 overflow-hidden avoid-break border border-gray-100">
<div class="space-y-2">${supplementsHtml}</div>
</div>
</div>
</div>
</div>
</div>
<!-- PÁGINA 3: TREINO -->
<div class="pdf-page">
<div class="h-full flex flex-col">
<div class="flex items-end justify-between border-b border-gray-200 pb-2 mb-2">
<div><h2 class="text-xl font-black text-gray-900 leading-tight">03. Treino</h2></div>
<div class="text-gray-300 text-2xl">🏋</div>
</div>
<div class="grid grid-cols-2 gap-2 flex-1 min-h-0 overflow-hidden">
${daysHtml}
</div>
<div class="mt-2 pt-2 border-t border-gray-200 text-center">
<span class="text-[9px] italic text-gray-400">"${truncateText(quote, 100)}"</span>
</div>
</div>
</div>
</div>
</body>
</html>`;
}

View file

@ -0,0 +1,257 @@
export const SYSTEM_PROMPT = `
Você é um assistente nutricional especializado em análise visual de alimentos.
Faça apenas estimativas baseadas na imagem e em tabelas nutricionais padrão.
Não aconselhamento médico, nem diagnóstico.
Use linguagem objetiva, estilo app fitness.
Seja claro sobre incertezas, sem usar palavras como aproximadamente.
Retorne SOMENTE JSON puro.
NÃO use markdown.
NÃO use \`\`\` .
NÃO escreva qualquer texto fora do JSON.
A resposta DEVE ser um objeto JSON único (nunca um array solto).
ANALISE a imagem de um alimento ou prato.
REGRAS IMPORTANTES DE IDENTIFICAÇÃO (OBRIGATÓRIAS)
Identifique e liste TODOS os alimentos CLARAMENTE VISÍVEIS e EM FOCO na imagem.
IGNORE completamente:
Itens desfocados ou fora de foco (bokeh/blur de fundo)
Reflexos, sombras ou duplicações visuais do mesmo alimento
Alimentos em segundo plano, mesas vizinhas ou embalagens decorativas
Qualquer coisa que NÃO esteja no prato/recipiente principal sendo fotografado
Considere APENAS o prato/recipiente principal que é o foco da foto.
Nunca retorne apenas um item se mais de um alimento estiver visível.
Não agrupe alimentos diferentes em um único item.
Cada alimento identificado deve gerar um objeto separado dentro de items.
Se algum alimento estiver parcialmente visível ou gerar dúvida, inclua mesmo assim e marque em flags (ex.: "parcial", "porcao_duvidosa").
Não repita o mesmo item duas vezes.
Se houver mais de uma unidade do MESMO alimento e isso estiver claramente visível, use um único item com portion no formato:
X unidades (Y g).
Se a quantidade NÃO estiver clara, assuma 1 unidade e marque flags com "porcao_duvidosa".
REGRAS CRÍTICAS DE PORÇÃO (MUITO IMPORTANTE)
ALIMENTOS PREPARADOS, COZIDOS OU MISTURADOS:
(ex.: ovos mexidos, arroz, feijão, carne moída, frango desfiado, massas, purês, refogados, preparações caseiras)
NUNCA use número de unidades.
NUNCA use termos como:
2 ovos, 1 filé, 3 colheres, 200 g, 1 pedaço.
NUNCA tente converter visualmente em quantidade de ingredientes crus.
Para esses alimentos, o campo portion DEVE:
descrever o preparo
usar apenas referência visual
Exemplos CORRETOS:
Ovos mexidos porção média no prato
Arroz branco cozido porção média
Feijão carioca porção pequena
Carne moída refogada porção média
Macarrão cozido porção grande
Exemplos PROIBIDOS:
2 ovos mexidos
1 concha de feijão\n 3 colheres de arroz
150 g de macarrão
SE ESTA REGRA FOR VIOLADA, CONSIDERE A RESPOSTA INVÁLIDA E REFAÇA INTERNAMENTE ANTES DE RESPONDER.
ALIMENTOS INTEIROS E SEPARÁVEIS (ÚNICO CASO EM QUE UNIDADES SÃO PERMITIDAS)
Use unidades APENAS quando o alimento estiver:
inteiro
claramente separável
não misturado
Exemplos permitidos:
frutas inteiras (banana, maçã, laranja)
ovos cozidos inteiros
pães inteiros
itens embalados individuais visíveis
Para frutas inteiras, use limites conservadores:
Banana: 1 a 2 unidades (a menos que a imagem mostre claramente mais)
Maçã / Laranja: 1 unidade cada (a menos que apareçam múltiplas claramente)
REGRAS DE CÁLCULO
O objeto total DEVE ser a soma exata de todos os itens listados:
calories
protein
carbs
fat
fiber
sugar\n sodium_mg
Use valores coerentes com bases nutricionais reais.
category deve refletir o tipo do prato (ex.: Almoço, Jantar, Café da manhã, Lanche, Refeição caseira).
QUALIDADE E CONSISTÊNCIA
Se houver mais de um alimento identificado e apenas um item for retornado, considere a resposta inválida e refaça internamente.
confidence deve refletir a clareza da imagem.
assumptions deve listar de 1 a 3 suposições feitas (tamanho visual, preparo, quantidade).
insights: no máximo 3 frases curtas, sem moralismo.
CASO NÃO SEJA COMIDA
Se a imagem não contiver alimento:
retorne items vazio
explique o motivo em confidence
tip.title e tip.text devem orientar o usuário a enviar uma foto de alimento
FORMATO DE RESPOSTA (OBRIGATÓRIO)
{
"items":[
{
"name":"",
"portion":"",
"calories":0,
"protein":0,
"carbs":0,
"fat":0,
"fiber":0,
"sugar":0,
"sodium_mg":0,
"flags":[]
}
],
"total":{
"calories":0,
"protein":0,
"carbs":0,
"fat":0,
"fiber":0,
"sugar":0,
"sodium_mg":0
},
"category":"",
"health_score":0,
"confidence":"",
"assumptions":[],
"questions":[],
"insights":[],
"tip":{
"title":"",
"text":"",
"reason":""
},
"swap_suggestions":[],
"next_best_actions":[]
}
`;
export const COACH_SYSTEM_PROMPT = `
Você é o "Titan Coach", um treinador olímpico de elite e nutricionista esportivo PhD.
Sua missão é analisar o físico de um usuário através de 3 fotos (Frente, Lado, Costas) e criar um **Protocolo de Transformação** completo, rico e detalhado.
RETORNE APENAS JSON.
NÃO use Markdown.
Formato de Resposta (Siga estritamente esta estrutura):
{
"analysis": {
"body_fat_percentage": 0,
"somatotype": "Ectomorfo" | "Mesomorfo" | "Endomorfo",
"muscle_mass_level": "Baixo" | "Médio" | "Alto",
"posture_analysis": "Texto detalhado sobre postura (ex: leve cifose, lordose, desvios laterais)",
"strengths": ["Ombros largos", "Cintura fina", "Bons quadríceps"],
"weaknesses": ["Panturrilhas pouco desenvolvidas", "Peitoral superior fraco"]
},
"diet": {
"total_calories": 0,
"macros": {
"protein_g": 0,
"carbs_g": 0,
"fats_g": 0
},
"hydration_liters": 0,
"supplements": [
{ "name": "Creatina", "dosage": "5g pós-treino", "reason": "Aumento de força e recuperação" },
{ "name": "Whey Protein", "dosage": "30g se não bater a meta", "reason": "Praticidade para bater proteínas" },
{ "name": "Multivitamínico", "dosage": "1 caps almoço", "reason": "Micro-nutrientes essenciais" }
],
"meal_plan_example": [
{
"name": "Café da Manhã",
"time_range": "07:00 - 08:00",
"options": [
"Opção 1: 3 Ovos mexidos + 1 Banana + 40g Aveia",
"Opção 2: 2 Fatias Pão Integral + 100g Frango Desfiado + Queijo Cottage"
],
"substitution_suggestion": "Para vegetarianos: Trocar frango por Tofu ou ovos por Shake proteico vegano."
},
{
"name": "Almoço",
"time_range": "12:00 - 13:00",
"options": [
"Opção 1: 150g Frango Grelhado + 120g Arroz Branco + Vegetais Verdes à vontade",
"Opção 2: 150g Patinho Moído + 150g Batata Inglesa + Salada Mista"
],
"substitution_suggestion": "Se enjoar de arroz, use Macarrão Integral (mesmo peso) ou Batata Doce (peso x1.3)."
},
{
"name": "Lanche da Tarde",
"time_range": "16:00 - 16:30",
"options": [
"Opção 1: 1 Iogurte Grego Zero + 20g Nozes",
"Opção 2: 1 Fruta + 1 Dose de Whey"
],
"substitution_suggestion": "Pode trocar as gorduras (nozes) por Pasta de Amendoim."
},
{
"name": "Jantar",
"time_range": "20:00 - 21:00",
"options": [
"Opção 1: 150g Peixe Branco (Tilápia) + Salada Completa + Azeite de Oliva",
"Opção 2: Omelete de 3 Ovos com Espinafre e Tomate"
],
"substitution_suggestion": "Evite carboidratos pesados a noite se o objetivo for secar."
}
]
},
"workout": {
"split": "ABC" | "ABCD" | "ABCDE" | "Fullbody",
"focus": "Hipertrofia" | "Força" | "Perda de Gordura",
"frequency_days": 0,
"injury_adaptations": {
"knee_pain": "Substituir Agachamento por Leg Press 45 com pés altos",
"shoulder_pain": "Fazer Supino com Halteres pegada neutra ao invés de barra",
"back_pain": "Evitar Terra e Remada Curvada, preferir máquinas apoiadas"
},
"routine": [
{
"day": "Segunda",
"muscle_group": "Peito + Tríceps",
"exercises": [
{ "name": "Supino Inclinado com Halteres", "sets": 4, "reps": "8-12", "technique": "Focar na parte superior, descida controlada" },
{ "name": "Crucifixo Máquina", "sets": 3, "reps": "12-15", "technique": "Pico de contração de 1s" }
]
}
]
},
"motivation_quote": "Uma frase curta de impacto."
}
Regras IMPORTANTES:
1. Seja MUITO DETALHADO na dieta. SEMPRE pelo menos 2 opções para CADA refeição ("options").
2. Inclua o horário sugerido ("time_range") para cada refeição.
3. O campo "substitution_suggestion" deve dar uma alternativa clara de troca de alimentos (ex: trocar carbo X por Y).
4. Adapte o treino ao biotipo (ex: Ectomorfo menos volume, Endomorfo mais cardio).
5. Nos suplementos, especifique COMO tomar e PORQUE.
6. A resposta DEVE ser um JSON válido.
`;

View file

@ -0,0 +1,31 @@
-- Create table to store conversation state
create table if not exists public.whatsapp_conversations (
phone_number text primary key,
state text not null default 'IDLE', -- IDLE, COACH_FRONT, COACH_SIDE, COACH_BACK, COACH_GOAL
temp_data jsonb default '{}'::jsonb,
updated_at timestamp with time zone default now()
);
-- Turn on RLS
alter table public.whatsapp_conversations enable row level security;
-- Allow service role full access
create policy "Service role full access"
on public.whatsapp_conversations
for all
to service_role
using (true)
with check (true);
-- Create a bucket for temporary coach uploads if it doesn't exist
insert into storage.buckets (id, name, public)
values ('coach-uploads', 'coach-uploads', true)
on conflict (id) do nothing;
create policy "Public Access"
on storage.objects for select
using ( bucket_id = 'coach-uploads' );
create policy "Service Role Upload"
on storage.objects for insert
with check ( bucket_id = 'coach-uploads' );

View file

@ -0,0 +1,39 @@
-- Create table for Coach AI analyses
create table if not exists public.coach_analyses (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete set null,
created_at timestamptz default now(),
-- Metadata
source text default 'whatsapp', -- 'web', 'whatsapp'
image_url text,
-- AI Data
ai_raw_response text,
ai_structured jsonb, -- Full JSON response
-- Structured Fields for Analytics
biotype text, -- 'Ectomorph', 'Mesomorph', 'Endomorph'
estimated_body_fat numeric,
muscle_mass_level text, -- 'Low', 'Medium', 'High'
goal_suggestion text, -- 'Cut', 'Bulk', 'Recomp'
-- Plan Usage
used_free_quota boolean default false
);
-- Enable RLS
alter table public.coach_analyses enable row level security;
-- Policies
create policy "Users can view their own coach analyses"
on public.coach_analyses for select
using (auth.uid() = user_id);
create policy "Service role insert coach analyses"
on public.coach_analyses for insert
with check (true);
create policy "Service role updates"
on public.coach_analyses for update
using (true);

View file

@ -0,0 +1,190 @@
-- =============================================
-- MIGRATION: PROFESSIONAL SAAS MODULE
-- Description: Creates tables for Professionals, Students, Assessments, and Workouts.
-- =============================================
-- 1. PROFESSIONALS TABLE (Extends Profile for Pro Users)
CREATE TABLE IF NOT EXISTS public.professionals (
id UUID REFERENCES auth.users(id) ON DELETE CASCADE PRIMARY KEY,
business_name TEXT,
cref_crn TEXT, -- License number
bio TEXT,
specialties TEXT[],
logo_url TEXT,
primary_color TEXT DEFAULT '#059669', -- Brand Color
contacts JSONB DEFAULT '{}'::jsonb, -- { "whatsapp": "...", "instagram": "..." }
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Enable RLS
ALTER TABLE public.professionals ENABLE ROW LEVEL SECURITY;
-- Policies for Professionals
CREATE POLICY "Professionals can view/edit own profile"
ON public.professionals
FOR ALL
USING (auth.uid() = id)
WITH CHECK (auth.uid() = id);
CREATE POLICY "Public can view professionals (optional, for directory)"
ON public.professionals
FOR SELECT
USING (true);
-- 2. PRO_STUDENTS TABLE (The Professional's CRM)
CREATE TABLE IF NOT EXISTS public.pro_students (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
professional_id UUID REFERENCES public.professionals(id) ON DELETE CASCADE NOT NULL,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
status TEXT DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'pending')),
-- Optional: Link to a real app user if they convert
linked_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL,
goals TEXT,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Enable RLS
ALTER TABLE public.pro_students ENABLE ROW LEVEL SECURITY;
-- Policies for Pro Students
CREATE POLICY "Professionals can manage own students"
ON public.pro_students
FOR ALL
USING (auth.uid() = professional_id)
WITH CHECK (auth.uid() = professional_id);
CREATE POLICY "Students can view their own record"
ON public.pro_students
FOR SELECT
USING (auth.uid() = linked_user_id);
-- 3. PRO_ASSESSMENTS TABLE (Physical Evaluations)
CREATE TABLE IF NOT EXISTS public.pro_assessments (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
professional_id UUID REFERENCES public.professionals(id) ON DELETE CASCADE NOT NULL,
student_id UUID REFERENCES public.pro_students(id) ON DELETE CASCADE NOT NULL,
date DATE DEFAULT CURRENT_DATE,
-- Basic Metrics
weight DECIMAL(5,2), -- kg
height DECIMAL(3,2), -- meters
age INTEGER,
-- Calculated
bf_percent DECIMAL(4,1), -- Body Fat %
muscle_percent DECIMAL(4,1),
bmi DECIMAL(4,1),
-- JSON Data for flexibility (skinfolds, circumferences, photos)
-- Structure: { "chest": 90, "waist": 80, ... }
measurements JSONB DEFAULT '{}'::jsonb,
-- Structure: { "method": "pollock7", "folds": { ... } }
methodology JSONB DEFAULT '{}'::jsonb,
-- Structure: ["url1", "url2"]
photos TEXT[],
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Enable RLS
ALTER TABLE public.pro_assessments ENABLE ROW LEVEL SECURITY;
-- Policies for Assessments
CREATE POLICY "Professionals can manage assessments"
ON public.pro_assessments
FOR ALL
USING (auth.uid() = professional_id)
WITH CHECK (auth.uid() = professional_id);
CREATE POLICY "Students can view their own assessments"
ON public.pro_assessments
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM public.pro_students
WHERE id = pro_assessments.student_id
AND linked_user_id = auth.uid()
)
);
-- 4. PRO_WORKOUTS TABLE (Workout Library)
CREATE TABLE IF NOT EXISTS public.pro_workouts (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
professional_id UUID REFERENCES public.professionals(id) ON DELETE CASCADE NOT NULL,
title TEXT NOT NULL,
description TEXT,
difficulty TEXT CHECK (difficulty IN ('beginner', 'intermediate', 'advanced')),
-- Structure: [{ "name": "Supino", "sets": 3, "reps": "10-12", "video": "..." }]
exercises JSONB DEFAULT '[]'::jsonb,
tags TEXT[], -- ['hipertrofia', 'emagrecimento']
is_template BOOLEAN DEFAULT false, -- If true, it's a library item. If false, assigned to a specific student?
-- Actually, let's keep it simple: Workouts are templates or assigned.
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Enable RLS
ALTER TABLE public.pro_workouts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Professionals can manage own workouts"
ON public.pro_workouts
FOR ALL
USING (auth.uid() = professional_id)
WITH CHECK (auth.uid() = professional_id);
-- 5. PRO_ASSIGNMENTS (Assigning Workouts to Students)
CREATE TABLE IF NOT EXISTS public.pro_assignments (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
professional_id UUID REFERENCES public.professionals(id) ON DELETE CASCADE NOT NULL,
student_id UUID REFERENCES public.pro_students(id) ON DELETE CASCADE NOT NULL,
workout_id UUID REFERENCES public.pro_workouts(id) ON DELETE CASCADE NOT NULL,
start_date DATE DEFAULT CURRENT_DATE,
end_date DATE,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Enable RLS
ALTER TABLE public.pro_assignments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Professionals can manage assignments"
ON public.pro_assignments
FOR ALL
USING (auth.uid() = professional_id)
WITH CHECK (auth.uid() = professional_id);
CREATE POLICY "Students can view their assignments"
ON public.pro_assignments
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM public.pro_students
WHERE id = pro_assignments.student_id
AND linked_user_id = auth.uid()
)
);

View file

@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS payments (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
status TEXT NOT NULL DEFAULT 'completed', -- completed, pending, failed
plan_type TEXT NOT NULL, -- monthly, yearly, lifetime
payment_method TEXT, -- credit_card, pix, etc
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
ALTER TABLE payments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view own payments"
ON payments FOR SELECT
USING (auth.uid() = user_id);

208
supabase_introspection.sql Normal file
View file

@ -0,0 +1,208 @@
-- ============================================================
-- FOODSNAP - SUPABASE FULL INTROSPECTION (SINGLE JSON OUTPUT)
-- Execute no SQL Editor do Supabase (Dashboard > SQL Editor)
-- Retorna TUDO em um único JSON
-- ============================================================
SELECT jsonb_build_object(
-- 1. TABLES & COLUMNS
'tables', (
SELECT jsonb_agg(jsonb_build_object(
'table', t.table_name,
'column', c.column_name,
'type', c.data_type,
'udt', c.udt_name,
'default', c.column_default,
'nullable', c.is_nullable
) ORDER BY t.table_name, c.ordinal_position)
FROM information_schema.tables t
JOIN information_schema.columns c
ON t.table_name = c.table_name AND t.table_schema = c.table_schema
WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE'
),
-- 2. VIEWS
'views', (
SELECT jsonb_agg(jsonb_build_object(
'name', table_name,
'definition', view_definition
))
FROM information_schema.views
WHERE table_schema = 'public'
),
-- 3. FOREIGN KEYS
'foreign_keys', (
SELECT jsonb_agg(jsonb_build_object(
'table', tc.table_name,
'column', kcu.column_name,
'ref_table', ccu.table_name,
'ref_column', ccu.column_name,
'constraint', tc.constraint_name
))
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'
),
-- 4. PRIMARY KEYS & UNIQUE
'primary_keys', (
SELECT jsonb_agg(jsonb_build_object(
'table', tc.table_name,
'constraint', tc.constraint_name,
'type', tc.constraint_type,
'column', kcu.column_name
))
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
WHERE tc.table_schema = 'public' AND tc.constraint_type IN ('PRIMARY KEY', 'UNIQUE')
),
-- 5. CHECK CONSTRAINTS
'check_constraints', (
SELECT jsonb_agg(jsonb_build_object(
'table', tc.table_name,
'constraint', tc.constraint_name,
'check', cc.check_clause
))
FROM information_schema.table_constraints tc
JOIN information_schema.check_constraints cc
ON tc.constraint_name = cc.constraint_name AND tc.constraint_schema = cc.constraint_schema
WHERE tc.table_schema = 'public' AND tc.constraint_type = 'CHECK'
),
-- 6. INDEXES
'indexes', (
SELECT jsonb_agg(jsonb_build_object(
'table', tablename,
'index', indexname,
'def', indexdef
))
FROM pg_indexes
WHERE schemaname = 'public'
),
-- 7. RLS POLICIES
'rls_policies', (
SELECT jsonb_agg(jsonb_build_object(
'table', tablename,
'policy', policyname,
'permissive', permissive,
'roles', roles,
'cmd', cmd,
'qual', qual,
'with_check', with_check
))
FROM pg_policies
WHERE schemaname = 'public'
),
-- 8. FUNCTIONS
'functions', (
SELECT jsonb_agg(jsonb_build_object(
'name', p.proname,
'args', pg_get_function_arguments(p.oid),
'returns', pg_get_function_result(p.oid),
'definition', pg_get_functiondef(p.oid)
))
FROM pg_proc p
JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'public'
),
-- 9. TRIGGERS
'triggers', (
SELECT jsonb_agg(jsonb_build_object(
'name', trigger_name,
'event', event_manipulation,
'table', event_object_table,
'action', action_statement,
'timing', action_timing
))
FROM information_schema.triggers
WHERE trigger_schema = 'public'
),
-- 10. STORAGE BUCKETS
'storage_buckets', (
SELECT jsonb_agg(jsonb_build_object(
'id', id,
'name', name,
'public', public,
'size_limit', file_size_limit,
'mime_types', allowed_mime_types
))
FROM storage.buckets
),
-- 11. STORAGE POLICIES
'storage_policies', (
SELECT jsonb_agg(jsonb_build_object(
'policy', policyname,
'table', tablename,
'cmd', cmd,
'qual', qual,
'with_check', with_check
))
FROM pg_policies
WHERE schemaname = 'storage'
),
-- 12. AUTH STATS
'auth_stats', (
SELECT jsonb_build_object(
'total_users', count(*),
'confirmed', count(*) FILTER (WHERE email_confirmed_at IS NOT NULL),
'active_30d', count(*) FILTER (WHERE last_sign_in_at > now() - interval '30 days')
)
FROM auth.users
),
-- 13. ROW COUNTS
'row_counts', (
SELECT jsonb_agg(jsonb_build_object(
'table', relname,
'rows', n_live_tup
) ORDER BY n_live_tup DESC)
FROM pg_stat_user_tables
WHERE schemaname = 'public'
),
-- 14. EXTENSIONS
'extensions', (
SELECT jsonb_agg(jsonb_build_object(
'name', extname,
'version', extversion
))
FROM pg_extension
),
-- 15. REALTIME
'realtime_tables', (
SELECT jsonb_agg(jsonb_build_object(
'pub', pubname,
'schema', schemaname,
'table', tablename
))
FROM pg_publication_tables
WHERE pubname = 'supabase_realtime'
),
-- 16. ENUMS
'enums', (
SELECT jsonb_agg(jsonb_build_object(
'type', t.typname,
'value', e.enumlabel
))
FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
JOIN pg_namespace n ON t.typnamespace = n.oid
WHERE n.nspname = 'public'
)
) AS full_introspection;

29
tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./src/*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

23
vite.config.ts Normal file
View file

@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
}
}
};
});