feat: complete dashboard redesign, payment history, and backend limits
This commit is contained in:
commit
0741b4e03c
87 changed files with 18241 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
20
README.md
Normal 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
BIN
foodsnap-main.rar
Normal file
Binary file not shown.
BIN
foodsnap.rar
Normal file
BIN
foodsnap.rar
Normal file
Binary file not shown.
143
index.html
Normal file
143
index.html
Normal 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
5
metadata.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"name": "FoodSnap",
|
||||||
|
"description": "Instant nutritional analysis from a simple photo.",
|
||||||
|
"requestFramePermissions": []
|
||||||
|
}
|
||||||
3286
package-lock.json
generated
Normal file
3286
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
package.json
Normal file
26
package.json
Normal 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
327
src/App.tsx
Normal 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;
|
||||||
59
src/components/coach/AnalysisSection.tsx
Normal file
59
src/components/coach/AnalysisSection.tsx
Normal 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;
|
||||||
255
src/components/coach/CoachResult.tsx
Normal file
255
src/components/coach/CoachResult.tsx
Normal 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;
|
||||||
405
src/components/coach/CoachWizard.tsx
Normal file
405
src/components/coach/CoachWizard.tsx
Normal 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;
|
||||||
115
src/components/coach/DietSection.tsx
Normal file
115
src/components/coach/DietSection.tsx
Normal 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;
|
||||||
62
src/components/coach/Shared.tsx
Normal file
62
src/components/coach/Shared.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
100
src/components/coach/WorkoutSection.tsx
Normal file
100
src/components/coach/WorkoutSection.tsx
Normal 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;
|
||||||
102
src/components/coach/pdf/PdfAnalysisCompact.tsx
Normal file
102
src/components/coach/pdf/PdfAnalysisCompact.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
181
src/components/coach/pdf/PdfDietCompact.tsx
Normal file
181
src/components/coach/pdf/PdfDietCompact.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
34
src/components/coach/pdf/PdfShared.tsx
Normal file
34
src/components/coach/pdf/PdfShared.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
119
src/components/coach/pdf/PdfWorkoutCompact.tsx
Normal file
119
src/components/coach/pdf/PdfWorkoutCompact.tsx
Normal 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 || '4–8 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
67
src/components/common/HistoryCard.tsx
Normal file
67
src/components/common/HistoryCard.tsx
Normal 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;
|
||||||
16
src/components/common/MacroBadge.tsx
Normal file
16
src/components/common/MacroBadge.tsx
Normal 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;
|
||||||
27
src/components/common/StatCard.tsx
Normal file
27
src/components/common/StatCard.tsx
Normal 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;
|
||||||
149
src/components/dashboard/DashboardCoach.tsx
Normal file
149
src/components/dashboard/DashboardCoach.tsx
Normal 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;
|
||||||
81
src/components/dashboard/DashboardHistory.tsx
Normal file
81
src/components/dashboard/DashboardHistory.tsx
Normal 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;
|
||||||
189
src/components/dashboard/DashboardOverview.tsx
Normal file
189
src/components/dashboard/DashboardOverview.tsx
Normal 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;
|
||||||
181
src/components/dashboard/DashboardSubscription.tsx
Normal file
181
src/components/dashboard/DashboardSubscription.tsx
Normal 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;
|
||||||
110
src/components/landing/CoachHighlight.tsx
Normal file
110
src/components/landing/CoachHighlight.tsx
Normal 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;
|
||||||
58
src/components/landing/FAQ.tsx
Normal file
58
src/components/landing/FAQ.tsx
Normal 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;
|
||||||
213
src/components/landing/Features.tsx
Normal file
213
src/components/landing/Features.tsx
Normal 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;
|
||||||
117
src/components/landing/Footer.tsx
Normal file
117
src/components/landing/Footer.tsx
Normal 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>© {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;
|
||||||
218
src/components/landing/Header.tsx
Normal file
218
src/components/landing/Header.tsx
Normal 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;
|
||||||
360
src/components/landing/Hero.tsx
Normal file
360
src/components/landing/Hero.tsx
Normal 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;
|
||||||
64
src/components/landing/HowItWorks.tsx
Normal file
64
src/components/landing/HowItWorks.tsx
Normal 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;
|
||||||
184
src/components/landing/Pricing.tsx
Normal file
184
src/components/landing/Pricing.tsx
Normal 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;
|
||||||
67
src/components/landing/Testimonials.tsx
Normal file
67
src/components/landing/Testimonials.tsx
Normal 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;
|
||||||
45
src/components/layout/MobileNav.tsx
Normal file
45
src/components/layout/MobileNav.tsx
Normal 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;
|
||||||
172
src/components/layout/Sidebar.tsx
Normal file
172
src/components/layout/Sidebar.tsx
Normal 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;
|
||||||
|
|
||||||
659
src/components/modals/CalculatorsModal.tsx
Normal file
659
src/components/modals/CalculatorsModal.tsx
Normal 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;
|
||||||
409
src/components/modals/RegistrationModal.tsx
Normal file
409
src/components/modals/RegistrationModal.tsx
Normal 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;
|
||||||
195
src/components/professional/ProfessionalModule.tsx
Normal file
195
src/components/professional/ProfessionalModule.tsx
Normal 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 só 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;
|
||||||
14
src/components/professional/common/PlaceholderModule.tsx
Normal file
14
src/components/professional/common/PlaceholderModule.tsx
Normal 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>
|
||||||
|
);
|
||||||
9
src/components/professional/common/StatsCard.tsx
Normal file
9
src/components/professional/common/StatsCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
27
src/components/professional/dashboard/Overview.tsx
Normal file
27
src/components/professional/dashboard/Overview.tsx
Normal 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">Há 2 horas • Duração: 45min</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
231
src/components/professional/dashboard/StudentsList.tsx
Normal file
231
src/components/professional/dashboard/StudentsList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
27
src/components/professional/dashboard/Workouts.tsx
Normal file
27
src/components/professional/dashboard/Workouts.tsx
Normal 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>
|
||||||
|
);
|
||||||
1377
src/contexts/LanguageContext.tsx
Normal file
1377
src/contexts/LanguageContext.tsx
Normal file
File diff suppressed because it is too large
Load diff
52
src/hooks/useCoachPlan.ts
Normal file
52
src/hooks/useCoachPlan.ts
Normal 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 };
|
||||||
|
};
|
||||||
73
src/hooks/useDashboardHistory.ts
Normal file
73
src/hooks/useDashboardHistory.ts
Normal 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 };
|
||||||
|
};
|
||||||
57
src/hooks/useDashboardStats.ts
Normal file
57
src/hooks/useDashboardStats.ts
Normal 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
113
src/index.css
Normal 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
651
src/lib/database.types.ts
Normal 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
112
src/lib/gemini.ts
Normal 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
8
src/lib/supabase.ts
Normal 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
17
src/main.tsx
Normal 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
572
src/n8n-coach-whatsapp.json
Normal 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
199
src/n8n-daily-report.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
911
src/n8n-foodsnap-branched.json
Normal file
911
src/n8n-foodsnap-branched.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
713
src/n8n-foodsnap-unified.json
Normal file
713
src/n8n-foodsnap-unified.json
Normal 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
134
src/n8n-stripe-webhook.json
Normal 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
798
src/pages/AdminPanel.tsx
Normal 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
234
src/pages/Dashboard.tsx
Normal 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
121
src/pages/FAQPage.tsx
Normal 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;
|
||||||
176
src/pages/ProfessionalDashboard.tsx
Normal file
176
src/pages/ProfessionalDashboard.tsx
Normal 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
12
src/types/index.ts
Normal 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;
|
||||||
|
}
|
||||||
1
supabase/.temp/cli-latest
Normal file
1
supabase/.temp/cli-latest
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v2.67.1
|
||||||
1
supabase/.temp/gotrue-version
Normal file
1
supabase/.temp/gotrue-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v2.184.0
|
||||||
1
supabase/.temp/pooler-url
Normal file
1
supabase/.temp/pooler-url
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
postgresql://postgres.mnhgpnqkwuqzpvfrwftp@aws-1-sa-east-1.pooler.supabase.com:5432/postgres
|
||||||
1
supabase/.temp/postgres-version
Normal file
1
supabase/.temp/postgres-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
17.6.1.054
|
||||||
1
supabase/.temp/project-ref
Normal file
1
supabase/.temp/project-ref
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
mnhgpnqkwuqzpvfrwftp
|
||||||
1
supabase/.temp/rest-version
Normal file
1
supabase/.temp/rest-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v13.0.5
|
||||||
1
supabase/.temp/storage-migration
Normal file
1
supabase/.temp/storage-migration
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
buckets-objects-grants-postgres
|
||||||
1
supabase/.temp/storage-version
Normal file
1
supabase/.temp/storage-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
v1.33.0
|
||||||
115
supabase/functions/coach-generator/index.ts
Normal file
115
supabase/functions/coach-generator/index.ts
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
100
supabase/functions/coach-generator/prompt.ts
Normal file
100
supabase/functions/coach-generator/prompt.ts
Normal 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. Dê 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.
|
||||||
|
`;
|
||||||
125
supabase/functions/stripe-checkout/index.ts
Normal file
125
supabase/functions/stripe-checkout/index.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
299
supabase/functions/stripe-webhook/index.ts
Normal file
299
supabase/functions/stripe-webhook/index.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
117
supabase/functions/validate-access/index.ts
Normal file
117
supabase/functions/validate-access/index.ts
Normal 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' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
954
supabase/functions/whatsapp-webhook/index.ts
Normal file
954
supabase/functions/whatsapp-webhook/index.ts
Normal 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 nó "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 nó "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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
249
supabase/functions/whatsapp-webhook/pdf-template.ts
Normal file
249
supabase/functions/whatsapp-webhook/pdf-template.ts
Normal 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>`;
|
||||||
|
}
|
||||||
257
supabase/functions/whatsapp-webhook/prompt.ts
Normal file
257
supabase/functions/whatsapp-webhook/prompt.ts
Normal 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 dê 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. Dê 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.
|
||||||
|
`;
|
||||||
31
supabase/migrations/20240105000001_whatsapp_state.sql
Normal file
31
supabase/migrations/20240105000001_whatsapp_state.sql
Normal 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' );
|
||||||
39
supabase/migrations/20260120_create_coach_analyses.sql
Normal file
39
supabase/migrations/20260120_create_coach_analyses.sql
Normal 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);
|
||||||
190
supabase/migrations/20260120_professional_schema.sql
Normal file
190
supabase/migrations/20260120_professional_schema.sql
Normal 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()
|
||||||
|
)
|
||||||
|
);
|
||||||
15
supabase/migrations/20260217_create_payments.sql
Normal file
15
supabase/migrations/20260217_create_payments.sql
Normal 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
208
supabase_introspection.sql
Normal 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
29
tsconfig.json
Normal 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
23
vite.config.ts
Normal 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'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue