feat: premium UI redesign, WhatsApp list menus, freemium coach flow, Stripe direct checkout

This commit is contained in:
marciobever 2026-04-14 11:36:48 -03:00
commit f7753cfeb1
113 changed files with 23965 additions and 0 deletions

7
.dockerignore Normal file
View file

@ -0,0 +1,7 @@
node_modules
dist
.git
.env
.vscode
supabase
.DS_Store

2
.env Normal file
View file

@ -0,0 +1,2 @@
VITE_SUPABASE_URL=https://mnhgpnqkwuqzpvfrwftp.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1uaGdwbnFrd3VxenB2ZnJ3ZnRwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjU3MTk4NTUsImV4cCI6MjA4MTI5NTg1NX0.DBYmhgiZoCmA0AlejJRsTh85HxRDEnG_ihkEQ2cXcpk

24
.gitignore vendored Normal file
View file

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

20
Dockerfile Normal file
View file

@ -0,0 +1,20 @@
# Stage 1: Build
FROM node:20-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Serve
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

20
README.md Normal file
View file

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

BIN
foodsnappronto.rar Normal file

Binary file not shown.

35
image-renderer/Dockerfile Normal file
View file

@ -0,0 +1,35 @@
# Usa a imagem oficial do Node.js baseada em Alpine (Leve mas precisa de libs do browser)
# Trocando pra Debian/Ubuntu leve por causa das dependencias complexas do Chrome
FROM node:18-slim
# Instalar dependências necessárias para rodar o Puppeteer (Chromium) no Linux
RUN apt-get update \
&& apt-get install -y wget gnupg \
&& wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \
&& sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
&& apt-get update \
&& apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 \
--no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
# Definir a variável de ambiente para forçar o Puppeteer a usar o Chrome instalado ao invés de baixar um próprio
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable
# Criar o diretório de trabalho da aplicação
WORKDIR /usr/src/app
# Copiar os arquivos de dependência
COPY package*.json ./
# Instalar as dependências do Node
RUN npm install
# Copiar o resto do código do servidor
COPY . .
# Expor a porta que a aplicação Express escuta
EXPOSE 3001
# Comando para iniciar o servidor
CMD [ "npm", "start" ]

View file

@ -0,0 +1,14 @@
# Configuração do Coolify Nixpacks
# O Coolify vai ler isso na raiz para o serviço específico "image-renderer"
preBuild:
- npm ci
start:
- node index.js
# Variáveis do Node / Chrome
envs:
- PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
- PUPPETEER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable
- NODE_ENV=production

284
image-renderer/index.js Normal file
View file

@ -0,0 +1,284 @@
const express = require('express');
const puppeteer = require('puppeteer');
const cors = require('cors');
const app = express();
app.use(cors());
app.use(express.json({ limit: '10mb' }));
app.post('/api/render', async (req, res) => {
const { data } = req.body;
if (!data || !data.items) {
return res.status(400).json({ error: 'Missing analysis data' });
}
const { items, total, health_score, confidence, tip, insights } = data;
let scoreEmoji = '🟢';
if (health_score < 50) scoreEmoji = '🔴';
else if (health_score < 80) scoreEmoji = '🟡';
// Build items HTML
const itemsHtml = items.map(it => `
<div class="item-row">
<div>
<span class="item-name">${it.name || 'Desconhecido'}</span><br/>
<span class="item-portion">${it.portion || 'N/A'}</span>
</div>
<div class="item-calories">${Math.round(it.calories || 0)} kcal</div>
</div>
`).join('');
// Build insights HTML
let insightsHtml = '';
if (insights && insights.length > 0) {
insightsHtml = `
<div class="section-title">VEREDITO DA IA</div>
<ul class="insights-list">
${insights.map(v => `<li>${v}</li>`).join('')}
</ul>
`;
}
// Beautiful Instagram-like Card HTML
const html = `
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap');
body {
margin: 0;
padding: 0;
font-family: 'Inter', sans-serif;
background-color: transparent;
display: flex;
justify-content: center;
align-items: center;
width: 1080px;
height: 1080px;
}
.card {
width: 900px;
height: auto;
background: linear-gradient(145deg, #1f2937, #111827);
border-radius: 40px;
padding: 60px;
box-shadow: 0 40px 60px -15px rgba(0,0,0,0.5), inset 0 2px 4px rgba(255,255,255,0.1);
color: white;
border: 1px solid #374151;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 40px;
border-bottom: 2px solid #374151;
padding-bottom: 30px;
}
.brand {
font-size: 36px;
font-weight: 800;
background: linear-gradient(90deg, #34d399, #10b981);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.score-box {
background: rgba(255,255,255,0.05);
padding: 15px 30px;
border-radius: 20px;
border: 1px solid #374151;
font-size: 28px;
font-weight: 800;
display: flex;
align-items: center;
gap: 15px;
}
.main-stats {
display: flex;
gap: 30px;
margin-bottom: 40px;
}
.stat-box {
flex: 1;
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: 24px;
padding: 30px;
text-align: center;
}
.stat-box.calories {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.2);
}
.stat-value {
font-size: 48px;
font-weight: 800;
margin-bottom: 10px;
}
.stat-label {
font-size: 18px;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 2px;
font-weight: 600;
}
.macros {
display: flex;
gap: 20px;
margin-bottom: 40px;
}
.macro {
flex: 1;
background: rgba(255,255,255,0.03);
border-radius: 20px;
padding: 25px;
border: 1px solid #374151;
}
.macro-label {
color: #9ca3af;
font-size: 18px;
font-weight: 600;
margin-bottom: 5px;
}
.macro-value {
font-size: 32px;
font-weight: 800;
}
.blue-text { color: #60a5fa; }
.purple-text { color: #a78bfa; }
.yellow-text { color: #fbbf24; }
.section-title {
font-size: 20px;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 2px;
font-weight: 600;
margin-bottom: 20px;
border-bottom: 1px solid #374151;
padding-bottom: 10px;
}
.item-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
font-size: 24px;
}
.item-name { font-weight: 600; }
.item-portion { color: #9ca3af; font-size: 18px; }
.item-calories { font-weight: 800; color: #10b981; }
.insights-list {
list-style-type: none;
padding: 0;
margin: 0 0 40px 0;
}
.insights-list li {
font-size: 22px;
margin-bottom: 15px;
line-height: 1.5;
background: rgba(255,255,255,0.03);
padding: 15px 20px;
border-radius: 12px;
border: 1px solid #374151;
}
.tip-box {
margin-top: 30px;
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.2);
padding: 25px;
border-radius: 20px;
font-size: 22px;
line-height: 1.5;
}
.tip-title {
color: #60a5fa;
font-weight: 800;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 1px;
}
</style>
</head>
<body id="capture">
<div class="card">
<div class="header">
<div class="brand">FoodSnap AI</div>
<div class="score-box">
Score Nutricional: ${health_score || 0}/100 ${scoreEmoji}
</div>
</div>
<div class="main-stats">
<div class="stat-box calories">
<div class="stat-value text-red-400" style="color: #f87171;">${Math.round(total.calories || 0)}</div>
<div class="stat-label">Kcal</div>
</div>
<div class="stat-box">
<div class="stat-value text-emerald-400" style="color: #34d399;">${confidence === 'high' ? '98%' : '85%'}</div>
<div class="stat-label">Precisão IA</div>
</div>
</div>
<div class="macros">
<div class="macro">
<div class="macro-label">Proteínas</div>
<div class="macro-value blue-text">${total.protein || 0}g</div>
</div>
<div class="macro">
<div class="macro-label">Carboidratos</div>
<div class="macro-value purple-text">${total.carbs || 0}g</div>
</div>
<div class="macro">
<div class="macro-label">Gorduras</div>
<div class="macro-value yellow-text">${total.fat || 0}g</div>
</div>
</div>
${insightsHtml}
<div class="section-title">O QUE A IA IDENTIFICOU</div>
${itemsHtml}
${tip && tip.text ? `
<div class="tip-box">
<div class="tip-title">💡 Insight do Nutricionista IA</div>
${tip.text}
</div>
` : ''}
</div>
</body>
</html>
`;
try {
const browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox'],
headless: "new"
});
const page = await browser.newPage();
await page.setViewport({ width: 1080, height: 1080, deviceScaleFactor: 2 });
await page.setContent(html, { waitUntil: 'networkidle0' });
// Wait for fonts to load implicitly or network idle
const element = await page.$('#capture');
const buffer = await element.screenshot({ type: 'png' });
await browser.close();
res.set('Content-Type', 'image/png');
res.send(buffer);
} catch (err) {
console.error("Puppeteer render error:", err);
res.status(500).json({ error: 'Failed to generate image' });
}
});
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log("Image Renderer active on port", PORT);
});

2234
image-renderer/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,14 @@
{
"name": "image-renderer",
"version": "1.0.0",
"description": "Puppeteer HTML-to-PNG renderer for FoodSnap",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.18.2",
"puppeteer": "^22.0.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

163
index.html Normal file
View file

@ -0,0 +1,163 @@
<!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" />
<!-- Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-QY9FY8NCVH"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-QY9FY8NCVH');
</script>
<!-- Primary Meta Tags -->
<meta name="facebook-domain-verification" content="xqkr3loshrxzknypwh8llsqiby8xh6" />
<title>FoodSnap.ai - Nutricionista de Bolso com Inteligência Artificial</title>
<meta name="title" content="FoodSnap.ai - Nutricionista de Bolso com Inteligência Artificial" />
<meta name="description"
content="Transforme sua dieta com o FoodSnap.ai. Fotografe seu prato e receba análise nutricional completa (calorias, macros) via WhatsApp em segundos. Teste Grátis!" />
<meta name="keywords"
content="nutrição ia, contador de calorias foto, dieta whatsapp, nutricionista artificial, emagrecimento ia, food tracker, macro calculator" />
<meta name="author" content="FoodSnap AI" />
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://foodsnap.ai" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://foodsnap.ai/" />
<meta property="og:title" content="FoodSnap.ai - Seu Nutricionista IA no WhatsApp" />
<meta property="og:description"
content="Analise calorias e macros apenas tirando uma foto. Sem digitação, sem apps pesados. Tudo pelo WhatsApp." />
<meta property="og:image" content="https://foodsnap.ai/og-image.jpg" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://foodsnap.ai/" />
<meta property="twitter:title" content="FoodSnap.ai - Nutrição Inteligente" />
<meta property="twitter:description"
content="Chega de contar calorias manualmente. Deixe a IA fazer isso por você." />
<meta property="twitter:image" content="https://foodsnap.ai/og-image.jpg" />
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "FoodSnap.ai",
"applicationCategory": "HealthApplication",
"operatingSystem": "Web, WhatsApp",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "BRL"
},
"description": "Aplicativo de nutrição baseado em IA que analisa fotos de comida para contagem de calorias e macros através do WhatsApp."
}
</script>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></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&family=Plus+Jakarta+Sans:wght@400;500;600;700&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'],
},
colors: {
gray: {
25: '#fcfcfd',
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)',
'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)',
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'shimmer': 'linear-gradient(45deg, rgba(255,255,255,0) 40%, rgba(255,255,255,0.5) 50%, rgba(255,255,255,0) 60%)',
}
}
}
}
window.onerror = function (msg, url, line, col, error) {
console.error("Critical Error:", msg, error);
};
</script>
<!-- Import Map OTIMIZADO -->
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.3.1",
"react/": "https://esm.sh/react@18.3.1/",
"react-dom": "https://esm.sh/react-dom@18.3.1",
"react-dom/": "https://esm.sh/react-dom@18.3.1/",
"react-dom/client": "https://esm.sh/react-dom@18.3.1/client",
"react/jsx-runtime": "https://esm.sh/react@18.3.1/jsx-runtime",
"lucide-react": "https://esm.sh/lucide-react@0.344.0?deps=react@18.3.1,react-dom@18.3.1",
"framer-motion": "https://esm.sh/framer-motion@11.0.8?deps=react@18.3.1,react-dom@18.3.1",
"@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2.39.7",
"@google/genai": "https://esm.sh/@google/genai@^1.33.0"
}
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5
metadata.json Normal file
View file

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

17
nginx.conf Normal file
View file

@ -0,0 +1,17 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, no-transform";
}
}

3395
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
package.json Normal file
View file

@ -0,0 +1,27 @@
{
"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",
"recharts": "^3.7.0"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

BIN
public/login-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

BIN
public/og-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 KiB

6
public/robots.txt Normal file
View file

@ -0,0 +1,6 @@
User-agent: *
Allow: /
Disallow: /admin
Disallow: /dashboard
Sitemap: https://foodsnap.ai/sitemap.xml

21
public/sitemap.xml Normal file
View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://foodsnap.ai/</loc>
<lastmod>2026-02-17</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://foodsnap.ai/login</loc>
<lastmod>2026-02-17</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://foodsnap.ai/register</loc>
<lastmod>2026-02-17</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>

305
src/App.tsx Normal file
View file

@ -0,0 +1,305 @@
import React, { useState, useEffect, Suspense, lazy } 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 { LanguageProvider } from './contexts/LanguageContext';
import { UserProvider, useUser } from './contexts/UserContext';
import { Loader2 } from 'lucide-react';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
const ProfessionalDashboard = lazy(() => import('./pages/ProfessionalDashboard'));
const FAQPage = lazy(() => import('./pages/FAQPage'));
const PrivacyPolicy = lazy(() => import('./pages/legal/PrivacyPolicy'));
const TermsOfService = lazy(() => import('./pages/legal/TermsOfService'));
const DataDeletion = lazy(() => import('./pages/legal/DataDeletion'));
export type ViewState = 'home' | 'faq' | 'privacy' | 'terms' | 'data-deletion';
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');
// Custom simple router state based on URL
const [currentPath, setCurrentPath] = useState(window.location.pathname);
useEffect(() => {
const handleLocationChange = () => setCurrentPath(window.location.pathname);
window.addEventListener('popstate', handleLocationChange);
// Flow integration for WhatsApp: Direct to Stripe
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('checkout') === 'true') {
localStorage.setItem('intended_plan', 'monthly');
setSelectedPlan('monthly');
setAuthMode('login'); // Pode ser conta existente
setIsModalOpen(true);
window.history.replaceState({}, document.title, window.location.pathname);
}
return () => window.removeEventListener('popstate', handleLocationChange);
}, []);
// Helper mapping from path to ViewState internally if needed
const getCurrentView = (): ViewState => {
const normalizedPath = currentPath.replace(/\/$/, '') || '/';
if (normalizedPath.includes('/faq')) return 'faq';
if (normalizedPath.includes('/privacidade')) return 'privacy';
if (normalizedPath.includes('/termos')) return 'terms';
if (normalizedPath.includes('/exclusao-de-dados')) return 'data-deletion';
return 'home';
};
const currentView = getCurrentView();
// Dynamic SEO Title Handling
useEffect(() => {
switch (currentView) {
case 'faq':
document.title = "Perguntas Frequentes (FAQ) | FoodSnap.ai";
break;
case 'privacy':
document.title = "Aviso de Privacidade | FoodSnap.ai";
break;
case 'terms':
document.title = "Termos de Serviço | FoodSnap.ai";
break;
case 'data-deletion':
document.title = "Política de Exclusão de Dados | FoodSnap.ai";
break;
default:
document.title = "FoodSnap.ai - Nutricionista de Bolso com Inteligência Artificial";
}
}, [currentView]);
// Consume UserContext
const {
user,
loading,
isAdminView,
isProfessionalView,
isCompletingProfile,
toggleAdminView,
setIsProfessionalView,
logout,
refreshProfile
} = useUser();
// Effect to handle "Complete Profile" flow automatically
useEffect(() => {
if (isCompletingProfile) {
setAuthMode('register');
setIsModalOpen(true);
}
}, [isCompletingProfile]);
const handleOpenRegister = (plan: string = 'starter') => {
setSelectedPlan(plan);
localStorage.removeItem('intended_plan'); // Segurança contra redirecionamentos não desejados
setAuthMode('register');
setIsModalOpen(true);
};
const handleOpenLogin = (context?: 'user' | 'professional') => {
if (context === 'professional') {
localStorage.setItem('login_intent', 'professional');
} else {
localStorage.setItem('login_intent', 'user');
}
localStorage.removeItem('intended_plan'); // Segurança contra redirecionamentos não desejados
setAuthMode('login');
setIsModalOpen(true);
};
const handleAuthSuccess = async () => {
// Recupera a intenção de plano caso tenha recarregado a página (ex: Login via Google)
const savedPlan = localStorage.getItem('intended_plan');
const finalPlan = selectedPlan === 'monthly' ? 'monthly' : savedPlan;
// Sempre redireciona para o Stripe se o plano final for monthly,
// independente de ser login ou register, respeitando a integração do WhatsApp!
const isRedirecting = finalPlan === 'monthly';
if (!isRedirecting) {
setIsModalOpen(false);
}
await refreshProfile();
// Se acabou de fazer o cadastro clicando em um plano pago (monthly), leva direto pro Stripe!
if (isRedirecting) {
try {
const { data: { session } } = await supabase.auth.getSession();
if (session) {
const res = await fetch(`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/stripe-checkout`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${session.access_token}`,
},
body: JSON.stringify({ plan: "mensal" })
});
const { url, error } = await res.json();
if (error) {
setIsModalOpen(false);
alert('Falha interna ao inicializar carrinho: ' + JSON.stringify(error));
} else if (url) {
localStorage.removeItem('intended_plan');
window.location.href = url; // Redireciona pro Checkout magicamente
return;
}
} else {
setIsModalOpen(false);
alert('Sessão não encontrada para redirecionamento. Verifique se precisa confirmar o email.');
}
} catch (e) {
setIsModalOpen(false);
console.error("Falha ao redirecionar para o checkout:", e);
alert('Erro ao comunicar com o servidor: ' + e);
}
}
// Login intent logic handled inside context or simply by state update
localStorage.removeItem('login_intent');
localStorage.removeItem('intended_plan');
};
// Helper function for navigating with real URLs
const handleNavigate = (view: ViewState) => {
let path = '/';
if (view === 'faq') path = '/faq';
if (view === 'privacy') path = '/privacidade';
if (view === 'terms') path = '/termos';
if (view === 'data-deletion') path = '/exclusao-de-dados';
window.history.pushState({}, '', path);
setCurrentPath(path);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
if (loading) {
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 (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-white"><Loader2 className="w-8 h-8 animate-spin text-brand-600" /></div>}>
<AdminPanel user={user} onExitAdmin={toggleAdminView} onLogout={logout} />
</Suspense>
);
}
// Rota Profissional
if (user && isProfessionalView) {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-white"><Loader2 className="w-8 h-8 animate-spin text-brand-600" /></div>}>
<ProfessionalDashboard user={user} onExit={() => setIsProfessionalView(false)} onLogout={logout} />
</Suspense>
);
}
// Rota Dashboard Usuário
if (user) {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-gray-50"><Loader2 className="w-8 h-8 animate-spin text-brand-600" /></div>}>
<Dashboard
user={user}
onLogout={logout}
onOpenAdmin={user.is_admin ? toggleAdminView : undefined}
onOpenPro={() => setIsProfessionalView(true)}
initialTab={currentPath.includes('/meu-plano') ? 'coach' : 'overview'}
/>
</Suspense>
);
}
// 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}
/>
<main>
{currentView === 'home' ? (
<>
<Hero onRegister={() => handleOpenRegister('starter')} />
<CoachHighlight onRegister={() => handleOpenRegister('starter')} />
<HowItWorks />
<Features />
<Testimonials />
<Pricing onRegister={handleOpenRegister} />
<FAQ />
</>
) : currentView === 'privacy' ? (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-white"><Loader2 className="w-8 h-8 animate-spin text-brand-600" /></div>}>
<PrivacyPolicy onBack={() => handleNavigate('home')} />
</Suspense>
) : currentView === 'terms' ? (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-white"><Loader2 className="w-8 h-8 animate-spin text-brand-600" /></div>}>
<TermsOfService onBack={() => handleNavigate('home')} />
</Suspense>
) : currentView === 'data-deletion' ? (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-white"><Loader2 className="w-8 h-8 animate-spin text-brand-600" /></div>}>
<DataDeletion onBack={() => handleNavigate('home')} />
</Suspense>
) : (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-white"><Loader2 className="w-8 h-8 animate-spin text-brand-600" /></div>}>
<FAQPage onBack={() => handleNavigate('home')} />
</Suspense>
)}
</main>
<Footer
onRegister={() => handleOpenRegister('starter')}
onNavigate={handleNavigate}
/>
<RegistrationModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
plan={selectedPlan}
mode={authMode}
isCompletingProfile={isCompletingProfile}
onSuccess={handleAuthSuccess}
/>
<CalculatorsModal
isOpen={isToolsOpen}
onClose={() => setIsToolsOpen(false)}
/>
</div>
);
};
const App: React.FC = () => {
return (
<UserProvider>
<LanguageProvider>
<AppContent />
</LanguageProvider>
</UserProvider>
);
};
export default App;

View file

@ -0,0 +1,69 @@
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>
{analysis?.evolution_notes && (
<div className="mt-6 border-t border-gray-100 pt-6">
<h4 className="font-bold text-brand-700 mb-2 flex items-center gap-2">
<Trophy size={18} className="text-brand-500" /> Comparativo de Evolução
</h4>
<p className="text-gray-700 leading-relaxed bg-brand-50 p-4 rounded-xl border border-brand-100 text-sm font-medium">
{analysis.evolution_notes}
</p>
</div>
)}
</Card>
<Card title="Pontos Chave" icon={<Trophy className="text-yellow-500" />}>
<div className="space-y-6">
<div>
<h4 className="text-sm font-bold text-green-700 uppercase tracking-wider mb-3 flex items-center gap-2">
<CheckCircle2 size={16} /> Pontos Fortes
</h4>
<div className="flex flex-wrap gap-2">
{analysis?.strengths?.map((s: string, i: number) => (
<Badge key={i} text={s} color="green" />
))}
</div>
</div>
<div>
<h4 className="text-sm font-bold text-orange-700 uppercase tracking-wider mb-3 flex items-center gap-2">
<AlertCircle size={16} /> Foco Total
</h4>
<div className="flex flex-wrap gap-2">
{analysis?.weaknesses?.map((s: string, i: number) => (
<Badge key={i} text={s} color="orange" />
))}
</div>
</div>
</div>
</Card>
</section>
);
};
export default AnalysisSection;

View file

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

View file

@ -0,0 +1,434 @@
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;
coachHistory?: any[];
}
type Step = 'photos' | 'goal' | 'processing';
const CoachWizard: React.FC<CoachWizardProps> = ({ isOpen, onClose, onComplete, coachHistory = [] }) => {
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 {
// Extrair contexto histórico
let last_evaluation = '';
if (coachHistory && coachHistory.length > 0) {
const lastRecord = coachHistory[0]; // Assumindo ordenado por mais recente
// Parse AI structured para extrair os dados importantes
let parsedAi = null;
if (typeof lastRecord.ai_structured === 'string') {
try { parsedAi = JSON.parse(lastRecord.ai_structured); } catch (e) { }
} else {
parsedAi = lastRecord.ai_structured;
}
if (parsedAi && parsedAi.analysis) {
const bf = parsedAi.analysis.body_fat_percentage;
const date = new Date(lastRecord.created_at).toLocaleDateString('pt-BR');
last_evaluation = `Avaliação Anterior (${date}): `;
if (bf) last_evaluation += `Percentual de Gordura Estimado: ${bf}%. `;
if (parsedAi.analysis.muscle_mass_level) last_evaluation += `Massa Muscular: ${parsedAi.analysis.muscle_mass_level}. `;
if (parsedAi.analysis.strengths) last_evaluation += `Pontos Fortes: ${parsedAi.analysis.strengths.join(', ')}. `;
}
}
// 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
});
const payload: any = { photos, goal, intent: 'coach' };
if (last_evaluation) {
payload.last_evaluation = last_evaluation;
}
// Race between the API call and the timeout
const response: any = await Promise.race([
supabase.functions.invoke('coach-generator', {
body: payload
}),
timeoutPromise
]);
// If we get here, it means the API responded before timeout
const { data, error } = response;
if (error) {
console.error("Supabase Invoke Error:", error);
// Tenta extrair a mensagem de erro real do backend se existir
let errorMsg = "Falha na comunicação com a IA.";
if (error && typeof error === 'object') {
// Supabase functions usually return { context: ..., error: { message: "..." } } or just error
if ('message' in error) errorMsg = (error as any).message;
else errorMsg = JSON.stringify(error);
}
throw new Error(errorMsg);
}
if (!data) {
throw new Error("Nenhuma resposta recebida da IA.");
}
console.log("Coach Result:", data);
// Validate essential data presence
if (!data.analysis || !data.diet || !data.workout) {
throw new Error("A resposta da IA veio incompleta. Tente com fotos mais claras.");
}
onComplete(data);
onClose();
} catch (err: any) {
console.error("Coach Logic Error:", err);
let message = "Erro ao gerar protocolo. Verifique sua conexão e tente novamente.";
if (err.name === 'AbortError') message = "O servidor demorou muito para responder. Tente fotos menores.";
if (err.message) message = err.message;
setErrorMessage(message);
// Don't auto-close, let user see error and retry
} finally {
setLoading(false);
// If error, stay on processing step or go back?
// Better to show error on processing screen with a "Retry" button or "Back"
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-md">
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="bg-white rounded-3xl w-full max-w-2xl overflow-hidden shadow-2xl flex flex-col max-h-[90vh] border border-white/20 ring-1 ring-black/5"
>
{/* Header */}
<div className="p-6 border-b border-gray-100 flex justify-between items-center bg-gray-50">
<div>
<h2 className="text-xl font-bold text-gray-900 flex items-center gap-2">
<Activity className="text-brand-600" />
{t.coach.title}
</h2>
<p className="text-sm text-gray-500">{t.coach.subtitle}</p>
</div>
<button onClick={onClose} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
<X size={20} className="text-gray-500" />
</button>
</div>
{/* Content */}
<div className="p-8 overflow-y-auto flex-1">
<AnimatePresence mode="wait">
{step === 'photos' && (
<motion.div
key="photos"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
<div className="bg-blue-50/50 text-blue-800 p-4 rounded-2xl text-sm flex gap-3 items-start border border-blue-100/50">
<AlertCircle className="shrink-0 mt-0.5" size={18} />
<p>
<strong>{t.coach.photosStep.alert.split(':')[0]}:</strong> {t.coach.photosStep.alert.split(':')[1]}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{['front', 'side', 'back'].map((side) => (
<div key={side} className="flex flex-col gap-2">
<p className="font-bold text-gray-700 capitalize text-center text-sm tracking-wide">
{side === 'front' ? t.coach.photosStep.front : side === 'side' ? t.coach.photosStep.side : t.coach.photosStep.back}
</p>
<div className={`aspect-[3/4] rounded-2xl border-2 border-dashed flex flex-col items-center justify-center relative overflow-hidden group transition-all duration-300
${photos[side as keyof typeof photos]
? 'border-brand-500 bg-gray-50'
: 'border-gray-200 hover:border-brand-300 hover:bg-gray-50 hover:shadow-lg'
}
`}>
{photos[side as keyof typeof photos] ? (
<>
<img src={photos[side as keyof typeof photos]} className="absolute inset-0 w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px] opacity-0 group-hover:opacity-100 transition-all flex flex-col items-center justify-center gap-3 p-4">
<button
onClick={() => triggerUpload(side as any, 'camera')}
className="bg-white text-gray-900 text-xs font-bold py-2.5 px-4 rounded-xl flex items-center gap-2 w-full justify-center hover:bg-brand-50 transition-colors shadow-lg"
>
<Camera size={14} /> {t.coach.photosStep.camera}
</button>
<button
onClick={() => triggerUpload(side as any, 'gallery')}
className="bg-gray-900 text-white text-xs font-bold py-2.5 px-4 rounded-xl flex items-center gap-2 w-full justify-center hover:bg-black transition-colors shadow-lg"
>
<ImageIcon size={14} /> {t.coach.photosStep.gallery}
</button>
</div>
<div className="absolute top-2 right-2 bg-green-500 text-white p-1.5 rounded-full shadow-lg animate-in zoom-in">
<Check size={12} strokeWidth={4} />
</div>
</>
) : (
<div className="flex flex-col items-center gap-4 w-full px-4 text-center">
<span className="text-gray-300 group-hover:text-brand-300 transition-colors"><Camera size={32} /></span>
<div className="flex flex-col gap-2 w-full translate-y-0 transition-all duration-300">
<button
onClick={() => triggerUpload(side as any, 'camera')}
className="bg-brand-600 text-white text-xs font-bold py-2 rounded-lg flex items-center justify-center gap-2 hover:bg-brand-700 w-full shadow-sm"
>
{t.coach.photosStep.camera}
</button>
<button
onClick={() => triggerUpload(side as any, 'gallery')}
className="bg-white border border-gray-200 text-gray-700 text-xs font-bold py-2 rounded-lg flex items-center justify-center gap-2 hover:bg-gray-50 w-full"
>
{t.coach.photosStep.gallery}
</button>
</div>
{/* Helper text removed as buttons are visible */}
</div>
)}
</div>
</div>
))}
</div>
</motion.div>
)}
{step === 'goal' && (
<motion.div
key="goal"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="space-y-6"
>
<h3 className="text-lg font-bold text-gray-900 text-center mb-6">{t.coach.goalStep.title}</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{[
{ id: 'hypertrophy', icon: <Dumbbell />, title: t.coach.goalStep.hypertrophy.title, desc: t.coach.goalStep.hypertrophy.desc },
{ id: 'definition', icon: <Activity />, title: t.coach.goalStep.definition.title, desc: t.coach.goalStep.definition.desc },
{ id: 'maintenance', icon: <Apple />, title: t.coach.goalStep.maintenance.title, desc: t.coach.goalStep.maintenance.desc },
{ id: 'strength', icon: <Dumbbell />, title: t.coach.goalStep.strength.title, desc: t.coach.goalStep.strength.desc }
].map((opt) => (
<button
key={opt.id}
onClick={() => setGoal(opt.id)}
className={`p-6 rounded-xl border-2 text-left transition-all ${goal === opt.id
? 'border-brand-500 bg-brand-50 shadow-md ring-1 ring-brand-500'
: 'border-gray-200 hover:border-brand-200 hover:bg-gray-50'
}`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center mb-3 ${goal === opt.id ? 'bg-brand-500 text-white' : 'bg-white border border-gray-200 text-gray-600'}`}>
{opt.icon}
</div>
<h4 className="font-bold text-gray-900">{opt.title}</h4>
<p className="text-sm text-gray-500 mt-1">{opt.desc}</p>
</button>
))}
</div>
</motion.div>
)}
{step === 'processing' && (
<motion.div
key="processing"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center justify-center py-12 text-center"
>
{errorMessage ? (
<div className="flex flex-col items-center animate-in fade-in zoom-in duration-300">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mb-4 text-red-600">
<AlertCircle size={40} />
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2">{t.coach.processing.errorTitle}</h3>
<p className="text-gray-500 max-w-sm mb-6">{errorMessage}</p>
<button
onClick={() => setStep('goal')}
className="bg-gray-900 text-white px-6 py-2 rounded-xl font-bold hover:bg-black transition-colors"
>
{t.coach.processing.retry}
</button>
</div>
) : (
<>
<div className="relative mb-8">
<div className="w-24 h-24 border-4 border-gray-100 rounded-full"></div>
<div className="w-24 h-24 border-4 border-brand-600 rounded-full border-t-transparent animate-spin absolute top-0 left-0"></div>
<Activity className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-brand-600" size={32} />
</div>
<h3 className="text-2xl font-bold text-gray-900 mb-2 min-h-[40px] flex items-center justify-center">
{loadingMessages[loadingMsgIndex]}
</h3>
<p className="text-gray-500 max-w-md animate-pulse">
{t.coach.processing.wait}
</p>
</>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{/* Footer */}
{step !== 'processing' && (
<div className="p-6 border-t border-gray-100 bg-gray-50 flex justify-end gap-3">
{step === 'goal' && (
<button onClick={() => setStep('photos')} className="px-6 py-2.5 text-gray-600 font-medium hover:bg-gray-200 rounded-xl transition-colors">
{t.coach.buttons.back}
</button>
)}
<button
onClick={handleNext}
className={`px-8 py-2.5 rounded-xl font-bold flex items-center gap-2 transition-all shadow-lg shadow-brand-500/20
${(step === 'photos' && (!photos.front || !photos.side || !photos.back)) || (step === 'goal' && !goal)
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-brand-600 text-white hover:bg-brand-700 hover:-translate-y-0.5'
}
`}
disabled={(step === 'photos' && (!photos.front || !photos.side || !photos.back)) || (step === 'goal' && !goal)}
>
{step === 'goal' ? t.coach.buttons.generate : t.coach.buttons.next}
{step !== 'goal' && <ChevronRight size={18} />}
</button>
</div>
)}
</motion.div>
{/* Hidden Inputs */}
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileChange}
/>
<input
type="file"
ref={cameraInputRef}
className="hidden"
accept="image/*"
capture="environment"
onChange={handleFileChange}
/>
</div>
);
};
export default CoachWizard;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,64 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { AlertCircle, RefreshCw } from 'lucide-react';
interface Props {
children?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
public render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6 text-center">
<div className="bg-white p-8 rounded-2xl shadow-xl max-w-md w-full border border-gray-100">
<div className="w-16 h-16 bg-red-50 rounded-full flex items-center justify-center mx-auto mb-6">
<AlertCircle size={32} className="text-red-500" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-3">Ops! Algo deu errado.</h1>
<p className="text-gray-500 mb-8">
Encontramos um erro inesperado. Tente recarregar a página.
</p>
{/* Opcional: Mostrar erro técnico em desenvolvimento */}
{import.meta.env.DEV && this.state.error && (
<div className="mb-6 p-4 bg-gray-100 rounded-lg text-left overflow-auto max-h-40 text-xs font-mono text-gray-600">
{this.state.error.toString()}
</div>
)}
<button
onClick={() => window.location.reload()}
className="w-full py-3 bg-brand-600 hover:bg-brand-700 text-white rounded-xl font-bold flex items-center justify-center gap-2 transition-colors shadow-lg shadow-brand-500/20"
>
<RefreshCw size={20} />
Recarregar Página
</button>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View file

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

View file

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

View file

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

View file

@ -0,0 +1,199 @@
import React from 'react';
import { Sparkles, Zap, ScanLine, ScanEye, BrainCircuit, TrendingUp, Plus, Activity, ShieldAlert } 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;
userPlan: 'free' | 'pro' | 'trial';
}
const DashboardCoach: React.FC<DashboardCoachProps> = ({ coachPlan, setCoachPlan, coachHistory = [], setIsCoachWizardOpen, userPlan }) => {
const isPaid = userPlan === 'pro' || userPlan === 'trial';
// ─────────────────────────────────────────────────────────────────────────────
// 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">
{isPaid ? (
<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>
) : (
<button
disabled
className="px-8 py-3.5 bg-gray-700 text-gray-400 rounded-xl font-bold flex items-center gap-3 cursor-not-allowed opacity-75"
>
<Zap size={20} />
Disponível no Plano PRO
</button>
)}
</div>
</div>
{/* 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)
// ─────────────────────────────────────────────────────────────────────────────
// 🔒 FREE PLAN LOCK
if (!isPaid) {
return (
<div className="w-full animate-in fade-in duration-500">
<div className="bg-gray-900 rounded-3xl border border-gray-800 p-12 text-center h-[500px] flex flex-col items-center justify-center relative overflow-hidden group">
<div className="absolute inset-0 bg-brand-900/10"></div>
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-brand-900/20 rounded-full blur-[120px] translate-x-1/2 -translate-y-1/2"></div>
<div className="relative z-10 flex flex-col items-center">
<div className="w-20 h-20 bg-gray-800/50 rounded-full flex items-center justify-center mb-6 group-hover:scale-110 transition-transform duration-300 border border-white/10">
<ShieldAlert size={40} className="text-brand-500" />
</div>
<h3 className="text-2xl font-bold text-white mb-3">Funcionalidade PRO</h3>
<p className="text-gray-400 max-w-md mx-auto mb-8 text-lg">
O Personal IA está disponível apenas para membros PRO. Desbloqueie todo o potencial do seu corpo agora.
</p>
<button
onClick={() => {
// Reduz o active tab para subscription se possível, mas aqui estamos isolados.
const subTab = document.querySelector('[data-tab="subscription"]') as HTMLElement;
if (subTab) subTab.click();
else window.location.reload();
}}
className="px-8 py-3.5 bg-brand-600 hover:bg-brand-500 text-white rounded-xl font-bold flex items-center gap-2 transition-all shadow-lg hover:shadow-brand-500/20"
>
<Zap size={20} fill="currentColor" />
Fazer Upgrade Agora
</button>
</div>
</div>
</div>
);
}
return (
<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 relative overflow-hidden group">
<div className="w-16 h-16 bg-brand-50 rounded-full flex items-center justify-center mb-4 group-hover:scale-110 transition-transform duration-300">
<Activity size={32} className="text-brand-500" />
</div>
<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 mb-8">
Escolha um protocolo no menu lateral ("Coach AI → Histórico") ou gere um novo para transformar seus resultados.
</p>
<button
onClick={() => setIsCoachWizardOpen(true)}
className="px-8 py-3 bg-brand-600 hover:bg-brand-500 text-white rounded-xl font-bold flex items-center gap-2 transition-all shadow-lg shadow-brand-200 hover:shadow-brand-300 transform hover:-translate-y-0.5"
>
<Plus size={20} />
Nova Análise com IA
</button>
</div>
)}
</div>
);
};
export default DashboardCoach;

View file

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

View file

@ -0,0 +1,284 @@
import React from 'react';
import { Search, Zap, CreditCard, MessageCircle, Smartphone, QrCode, ChevronRight, Loader2, CheckCircle2, TrendingUp, Calendar, ArrowRight, Flame } from 'lucide-react';
import HistoryCard from '@/components/common/HistoryCard';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
interface DashboardOverviewProps {
user: {
name: string;
public_id: string;
plan: string;
plan_valid_until?: string;
};
stats: {
totalCount: number;
avgCals: number;
currentStreak: number;
longestStreak: number;
chartData: any[];
freeFoodUsed?: number;
freeCoachUsed?: 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-3xl bg-gray-900 p-8 md:p-10 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-3xl md:text-4xl font-bold mb-3 tracking-tight leading-tight">
Olá, {user.name.split(' ')[0]} 👋
</h1>
<p className="text-gray-400 text-base 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 lg:grid-cols-3 gap-4 w-full md:w-auto">
<div className="bg-white/5 backdrop-blur-md border border-white/10 p-5 rounded-2xl min-w-[140px]">
<p className="text-gray-400 text-xs font-bold uppercase tracking-wider mb-1 flex items-center gap-1"><Flame size={14} className="text-orange-500" /> Ofensiva</p>
<p className="text-2xl font-black text-white">{loadingStats ? '...' : stats.currentStreak} 🔥</p>
<div className="mt-2 text-[10px] text-orange-300 bg-orange-900/50 px-2 py-1 rounded inline-block">
Recorde: {stats.longestStreak}
</div>
</div>
<div className="bg-white/5 backdrop-blur-md border border-white/10 p-5 rounded-2xl min-w-[140px]">
<p className="text-gray-400 text-xs font-bold uppercase tracking-wider mb-1">{t.dashboard.statDishes}</p>
<p className="text-2xl 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">
Total de Análises
</div>
</div>
<div className="bg-white/5 backdrop-blur-md border border-white/10 p-5 rounded-2xl min-w-[140px] col-span-2 lg:col-span-1">
<p className="text-gray-400 text-xs font-bold uppercase tracking-wider mb-1">{t.dashboard.statCals}</p>
<p className="text-2xl font-black text-white">{loadingStats ? '...' : Math.round(stats.avgCals || 0)}</p>
<div className="mt-2 text-[10px] text-blue-300 bg-blue-900/50 px-2 py-1 rounded inline-block">
Média Histórica
</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 2. Main Column */}
<div className="lg:col-span-2 space-y-8">
{/* Gamification Chart */}
<div className="bg-white rounded-[2rem] p-6 border border-gray-100 shadow-sm">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-gray-900">Evolução de Calorias (7 Dias)</h2>
</div>
<div className="h-[250px] w-full">
{loadingStats ? (
<div className="h-full flex items-center justify-center"><Loader2 className="animate-spin text-brand-500" /></div>
) : stats.chartData && stats.chartData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stats.chartData} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="colorCals" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#4F46E5" stopOpacity={0.3} />
<stop offset="95%" stopColor="#4F46E5" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f3f4f6" />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#6B7280' }} dy={10} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#6B7280' }} />
<Tooltip
contentStyle={{ borderRadius: '1rem', border: 'none', boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)' }}
cursor={{ stroke: '#4F46E5', strokeWidth: 1, strokeDasharray: '3 3' }}
/>
<Area type="monotone" dataKey="calories" name="Calorias (kcal)" stroke="#4F46E5" strokeWidth={3} fillOpacity={1} fill="url(#colorCals)" />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-gray-400 font-medium">Não dados suficientes</div>
)}
</div>
</div>
{/* Recent History */}
<div className="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>
</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 & Quota */}
<div className="bg-white rounded-3xl p-6 border border-gray-100 shadow-sm flex flex-col gap-4">
<div className="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 cursor-pointer hover:bg-gray-100 transition-colors" onClick={() => setActiveTab('subscription')}>
<CreditCard size={24} />
</div>
</div>
{user.plan === 'free' && (
<div className="border-t border-gray-100 pt-4 space-y-3">
<p className="text-xs font-bold text-gray-400 uppercase tracking-wider">Créditos RESTANTES</p>
{/* Food Quota */}
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600 font-medium">Análise de Prato (Limite: 5)</span>
<span className="font-bold text-gray-900">{Math.max(0, 5 - (stats.freeFoodUsed || 0))}/5</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2">
<div
className={`bg-brand-500 h-2 rounded-full transition-all ${((stats.freeFoodUsed || 0) >= 5) ? 'bg-red-500' : ''}`}
style={{ width: `${Math.min(100, ((stats.freeFoodUsed || 0) / 5) * 100)}%` }}
></div>
</div>
</div>
{/* Coach Quota */}
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600 font-medium">Avaliação Coach (Limite: 3)</span>
<span className="font-bold text-gray-900">{Math.max(0, 3 - (stats.freeCoachUsed || 0))}/3</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-2">
<div
className={`bg-brand-500 h-2 rounded-full transition-all ${((stats.freeCoachUsed || 0) >= 3) ? 'bg-red-500' : ''}`}
style={{ width: `${Math.min(100, ((stats.freeCoachUsed || 0) / 3) * 100)}%` }}
></div>
</div>
</div>
{((stats.freeFoodUsed || 0) >= 5 || (stats.freeCoachUsed || 0) >= 3) && (
<button
onClick={() => setActiveTab('subscription')}
className="w-full mt-2 bg-gray-900 hover:bg-gray-800 text-white font-bold py-2.5 rounded-xl text-sm transition-colors"
>
Liberar Uso Ilimitado (PRO)
</button>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default DashboardOverview;

View file

@ -0,0 +1,210 @@
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 flex-col">
<h3 className="text-sm font-bold text-gray-500 uppercase tracking-widest mb-1">{t.dashboard.currentPlan}</h3>
<div className="flex items-center gap-3">
<span className={`text-2xl font-bold ${planName === 'PRO' ? 'text-brand-600' : 'text-gray-900'}`}>
{planName === 'PRO' ? 'Professional' : planName}
</span>
</div>
{/* Check if user has a valid date and is NOT on free plan (unless free plan has an expiry which is rare but possible for trials) */}
{user.plan !== 'free' && user.plan_valid_until && (
<p className="text-xs text-gray-400 mt-1">
Válido até {new Date(user.plan_valid_until).toLocaleDateString('pt-BR')}
</p>
)}
</div>
<div 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 mt-2">
<b className="text-brand-400">NO PLANO GRATUITO VOCÊ ESTÁ LIMITADO A:</b><br />
Apenas 5 Análises de Imagens de Pratos<br />
Apenas 3 Avaliações do Nutricionista IA (Coach)<br /><br />
Assine hoje o <b>Plano PRO</b> e obtenha análises de pratos e consultas ilimitadas para você evoluir mais rápido!
</p>
</div>
<button
onClick={async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session) return alert("Faça login novamente");
const res = await fetch(`${import.meta.env.VITE_SUPABASE_URL}/functions/v1/stripe-checkout`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${session.access_token}`,
},
body: JSON.stringify({ plan: "mensal" }) // Default to monthly
});
const { url, error } = await res.json();
if (error) throw new Error(error);
if (url) window.location.href = url;
} catch (e) {
alert("Erro ao iniciar checkout: " + e);
}
}}
className="bg-white hover:bg-gray-100 text-gray-900 font-bold px-8 py-3 rounded-xl transition-all shadow-lg hover:shadow-xl hover:-translate-y-0.5 whitespace-nowrap"
>
Fazer Upgrade Agora
</button>
</div>
{/* Decorative BG */}
<div className="absolute top-0 right-0 w-64 h-64 bg-white rounded-full blur-[100px] opacity-10 translate-x-1/2 -translate-y-1/2"></div>
</div>
)}
</div>
);
};
export default DashboardSubscription;

View file

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

View file

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

View file

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

View file

@ -0,0 +1,129 @@
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' | 'privacy' | 'terms' | 'data-deletion') => 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>
<button onClick={(e) => { e.preventDefault(); if (onNavigate) onNavigate('terms'); }} className="hover:text-brand-400 transition-colors text-left">
Termos de Uso
</button>
</li>
<li>
<button onClick={(e) => { e.preventDefault(); if (onNavigate) onNavigate('privacy'); }} className="hover:text-brand-400 transition-colors text-left">
Política de Privacidade
</button>
</li>
<li>
<button onClick={(e) => { e.preventDefault(); if (onNavigate) onNavigate('data-deletion'); }} className="hover:text-brand-400 transition-colors text-left">
Exclusão de Dados
</button>
</li>
</ul>
</div>
<div>
<h3 className="text-white font-semibold mb-6 text-sm uppercase tracking-wider">{t.footer.connect}</h3>
<div className="flex gap-4">
<a href="#" className="w-10 h-10 flex items-center justify-center rounded-full bg-gray-900 border border-gray-800 hover:border-brand-600 hover:bg-brand-600 hover:text-white transition-all duration-300 text-gray-400">
<Instagram size={18} />
</a>
<a href="#" className="w-10 h-10 flex items-center justify-center rounded-full bg-gray-900 border border-gray-800 hover:border-brand-600 hover:bg-brand-600 hover:text-white transition-all duration-300 text-gray-400">
<Twitter size={18} />
</a>
<a href="#" className="w-10 h-10 flex items-center justify-center rounded-full bg-gray-900 border border-gray-800 hover:border-brand-600 hover:bg-brand-600 hover:text-white transition-all duration-300 text-gray-400">
<Linkedin size={18} />
</a>
</div>
</div>
</div>
<div className="border-t border-gray-900 pt-8 flex flex-col md:flex-row justify-between items-center gap-4 text-xs text-gray-600">
<p>&copy; {new Date().getFullYear()} FoodSnap.ai. {t.footer.rights}</p>
<div className="flex gap-6">
<span className="flex items-center gap-2"><div className="w-2 h-2 rounded-full bg-green-500"></div> System Operational</span>
</div>
</div>
</div>
</footer>
);
};
export default Footer;

View file

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

View file

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

View file

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

View file

@ -0,0 +1,123 @@
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="max-w-md mx-auto relative z-10">
<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">
<div className="flex justify-between items-start mt-2">
<div>
<h3 className="flex items-center gap-2 text-xl font-bold text-white">
{t.pricing.plans.monthly.title}
<span className="bg-brand-500 rounded-full px-2 py-0.5 pb-[3px] text-[10px] font-bold uppercase tracking-wider text-white">PRO</span>
</h3>
<p className="text-brand-200 mt-1 text-sm opacity-90">{t.pricing.plans.monthly.description}</p>
</div>
<div className="bg-white/5 border-white/10 rounded-lg border p-2">
<Sparkles className="text-accent-400" size={24} />
</div>
</div>
<div className="mb-2 mt-6 flex items-baseline gap-1">
<span className="text-5xl font-extrabold tracking-tight text-white">{t.pricing.plans.monthly.price}</span>
<span className="text-brand-200 text-sm font-medium">{t.pricing.plans.monthly.period}</span>
</div>
<p className="text-brand-300 mb-8 text-xs font-medium uppercase tracking-wide">{t.pricing.plans.monthly.billingInfo}</p>
<div className="from-transparent via-brand-800 to-transparent mb-8 h-px bg-gradient-to-r"></div>
<ul className="mb-8 flex-grow space-y-4">
{t.pricing.plans.monthly.features.map((item, i) => (
<li key={i} className="flex items-start gap-3 text-sm text-gray-100">
<div className="bg-brand-600 mt-0.5 rounded-full p-0.5 text-white shadow-sm">
<Check className="h-3 w-3" strokeWidth={3} />
</div>
<span className="leading-snug">{item}</span>
</li>
))}
</ul>
<button
onClick={() => onRegister('monthly')}
className="group from-brand-500 to-brand-600 hover:from-brand-400 hover:to-brand-500 shadow-brand-500/20 hover:-translate-y-0.5 relative block w-full overflow-hidden rounded-xl bg-gradient-to-r py-4 text-center text-sm font-bold text-white shadow-lg transition-all"
>
<div className="absolute inset-0 translate-y-full bg-white/20 transition-transform duration-300 group-hover:translate-y-0"></div>
<span className="relative">{t.pricing.plans.monthly.btnText}</span>
</button>
</div>
</div>\n {/* --- PROFESSIONAL PLAN SECTION --- */}
<div className="max-w-2xl mx-auto mt-16 animate-in fade-in slide-in-from-bottom-6 duration-700">
<div className="relative bg-gray-900 rounded-2xl p-6 md:p-8 overflow-hidden shadow-xl border border-gray-800">
{/* Abstract Shapes */}
<div className="absolute top-0 right-0 w-64 h-64 bg-blue-600/10 rounded-full blur-[60px] -translate-y-1/2 translate-x-1/3"></div>
<div className="relative z-10 flex flex-col sm:flex-row items-center gap-6">
<div className="flex-1 text-center sm:text-left">
<div className="inline-flex items-center gap-2 px-2.5 py-1 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[10px] font-bold uppercase tracking-wider mb-3">
<Sparkles size={12} /> Para Nutris e Personais
</div>
<h3 className="text-xl font-bold text-white mb-2">Área Profissional</h3>
<p className="text-gray-400 text-sm mb-0">
Sistema completo para gestão de alunos, treinos e dietas.
</p>
</div>
<div className="bg-gray-800/50 backdrop-blur-md border border-gray-700 p-4 rounded-xl min-w-[200px] text-center">
<span className="inline-block px-3 py-1 mb-2 rounded-full bg-gray-700 text-gray-300 text-[10px] font-bold uppercase tracking-wider">
Em Breve
</span>
<button
disabled
className="w-full bg-white/10 text-white/50 font-bold py-2 rounded-lg text-sm cursor-not-allowed hover:bg-white/10"
>
Aguarde
</button>
</div>
</div>
</div>
</div>
</div>
</section>
);
};
export default Pricing;

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,452 @@
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();
// Mensagens claras em Português para o usuário final
if (m.includes('database error')) return 'Erro no servidor. Tente novamente mais tarde.';
if (m.includes('already registered') || m.includes('user already registered')) return 'Este e-mail já está cadastrado. Tente fazer login!';
if (m.includes('invalid login credentials')) return 'E-mail ou senha incorretos.';
if (m.includes('password should be at least')) return 'A senha é muito curta (mínimo de 6 caracteres).';
if (m.includes('email not confirmed')) return 'Por favor, confirme seu e-mail antes de entrar.';
if (m.includes('duplicate key') || m.includes('already exists') || m.includes('profiles_phone')) return 'Esse número de WhatsApp ou E-mail já está em uso em outra conta!';
return 'Ocorreu um erro. Verifique seus dados e tente novamente.';
};
const handleGoogleLogin = async () => {
setLoading(true);
localStorage.setItem('intended_plan', plan); // Salva o plano na memória antes de sair
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 rpcError;
}
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 rpcError;
if (plan === 'monthly') {
setSuccessMsg("Conta criada com sucesso! Redirecionando para o pagamento seguro...");
} else {
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-4xl rounded-3xl shadow-2xl overflow-hidden flex flex-col md:flex-row"
>
{/* Left Column (Image & Trust) */}
<div className="hidden md:flex md:w-5/12 bg-gray-900 relative p-10 lg:p-12 flex-col justify-between overflow-hidden">
<div className="absolute inset-0 z-0">
<img src="/login-bg.png" alt="FoodSnap AI Background" className="w-full h-full object-cover opacity-50 mix-blend-overlay" />
<div className="absolute inset-0 bg-gradient-to-t from-gray-900 via-gray-900/40 to-transparent"></div>
</div>
<div className="relative z-10 flex-1">
<div className="inline-flex items-center gap-2 mb-12">
<div className="w-8 h-8 rounded-full bg-brand-500 flex items-center justify-center text-white">
<span className="font-bold">F</span>
</div>
<span className="text-white font-bold text-xl tracking-tight">FoodSnap.ai</span>
</div>
</div>
<div className="relative z-10 text-white">
<h2 className="text-3xl font-bold mb-4 leading-tight">Nutrição de elite ao alcance de uma foto.</h2>
<p className="text-gray-300 mb-8 max-w-xs leading-relaxed text-sm">Milhares de usuários otimizaram suas dietas usando nossa IA avançada. Faça parte dessa revolução.</p>
<div className="flex items-center gap-3">
<div className="flex -space-x-3">
<img src="https://i.pravatar.cc/100?img=1" className="w-10 h-10 rounded-full border-2 border-gray-900" alt="user" />
<img src="https://i.pravatar.cc/100?img=2" className="w-10 h-10 rounded-full border-2 border-gray-900" alt="user" />
<img src="https://i.pravatar.cc/100?img=3" className="w-10 h-10 rounded-full border-2 border-gray-900" alt="user" />
<div className="w-10 h-10 rounded-full border-2 border-gray-900 bg-gray-800 flex items-center justify-center text-[10px] font-bold text-white">+5k</div>
</div>
<div className="text-xs text-gray-300">
<div className="flex text-amber-400 text-[10px] mb-0.5"></div>
<span className="font-semibold text-white">4.9/5</span> avaliações
</div>
</div>
</div>
</div>
{/* Right Column (Form) */}
<div className="w-full md:w-7/12 flex flex-col h-full bg-white relative">
<div className="p-8 md:p-12 flex-1">
<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 : (
plan === 'monthly' ? 'Continuar para Pagamento' : 'Criar Conta (5 Análises Grátis)'
)
)}
<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>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
};
export default RegistrationModal;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,174 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { supabase } from '../lib/supabase';
import { User } from '../types';
interface UserContextType {
user: User | null;
loading: boolean;
isAdminView: boolean;
isProfessionalView: boolean;
isCompletingProfile: boolean;
toggleAdminView: () => void;
setIsProfessionalView: (isPro: boolean) => void;
logout: () => Promise<void>;
refreshProfile: () => Promise<void>;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
export const UserProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [isAdminView, setIsAdminView] = useState(false);
const [isProfessionalView, setIsProfessionalView] = useState(false);
const [isCompletingProfile, setIsCompletingProfile] = useState(false);
const fetchUserProfile = async (userId: string, email?: string) => {
try {
const { data: profile, error } = await supabase
.from('profiles')
.select('*')
.eq('id', userId)
.maybeSingle();
if (error) throw error;
if (!profile || !profile.phone_e164) {
console.warn("UserContext: Perfil incompleto.");
setIsCompletingProfile(true);
// Mantemos user null ou parcial? App.tsx usava null para triggerar modal na landing
// Vamos setar null para garantir que caia na landing, mas com flag isCompletingProfile true
setUser(null);
return;
}
setIsCompletingProfile(false);
// Fetch Entitlements
const { data: entitlement } = await supabase
.from('user_entitlements')
.select('*')
.eq('user_id', userId)
.maybeSingle();
let plan: 'free' | 'pro' | 'trial' = 'free';
if (entitlement?.is_active) {
const code = entitlement.entitlement_code;
if (['pro', 'mensal', 'trimestral', 'anual'].includes(code)) plan = 'pro';
else if (code === 'trial') plan = 'trial';
}
const userData: User = {
id: userId,
name: profile.full_name || 'Usuário',
email: email || profile.email || '',
phone: profile.phone_e164,
public_id: profile.public_id,
is_admin: profile.is_admin,
is_professional: profile.is_professional,
plan: plan,
plan_valid_until: entitlement?.valid_until
};
setUser(userData);
// Auto-switch view logic
if (profile.is_professional) {
setIsProfessionalView(true);
}
} catch (error) {
console.error('UserContext: Erro ao carregar perfil', error);
setUser(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
let mounted = true;
const handleSession = async (session: any) => {
if (session?.user) {
await fetchUserProfile(session.user.id, session.user.email);
} else {
setUser(null);
setLoading(false);
}
};
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
console.log(`UserContext Auth Event: ${event}`);
if (!mounted) return;
if (event === 'INITIAL_SESSION') {
await handleSession(session);
} else if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
await handleSession(session);
} else if (event === 'SIGNED_OUT') {
setUser(null);
setIsAdminView(false);
setIsProfessionalView(false);
setIsCompletingProfile(false);
setLoading(false);
}
});
// Initialize session manually in case INITIAL_SESSION doesn't trigger
// (sometimes needed depending on supabase-js version and local storage state)
supabase.auth.getSession().then(({ data: { session }, error }) => {
if (error) {
console.error("UserContext: Falha na sessão inicial", error);
if (mounted) setLoading(false);
} else if (!session) {
// Se não há sessão e onAuthStateChange não disparou INITIAL_SESSION
if (mounted) setLoading(false);
}
});
return () => {
mounted = false;
subscription.unsubscribe();
};
}, []);
const logout = async () => {
await supabase.auth.signOut();
// State updates handled by onAuthStateChange
};
const toggleAdminView = () => {
if (user?.is_admin) setIsAdminView(prev => !prev);
};
const refreshProfile = async () => {
const { data: { session } } = await supabase.auth.getSession();
if (session?.user) {
await fetchUserProfile(session.user.id, session.user.email);
}
};
return (
<UserContext.Provider value={{
user,
loading,
isAdminView,
isProfessionalView,
isCompletingProfile,
toggleAdminView,
setIsProfessionalView,
logout,
refreshProfile
}}>
{children}
</UserContext.Provider>
);
};
export const useUser = () => {
const context = useContext(UserContext);
if (context === undefined) {
throw new Error('useUser must be used within a UserProvider');
}
return context;
};

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

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

View file

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

View file

@ -0,0 +1,147 @@
import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabase';
interface DailyMacro {
date: string;
calories: number;
protein: number;
carbs: number;
fat: number;
score: number;
}
interface DashboardStats {
totalCount: number;
avgCals: number;
currentStreak: number;
longestStreak: number;
chartData: DailyMacro[];
freeFoodUsed?: number;
freeCoachUsed?: number;
}
export const useDashboardStats = (userId: string) => {
const [stats, setStats] = useState<DashboardStats>({
totalCount: 0,
avgCals: 0,
currentStreak: 0,
longestStreak: 0,
chartData: [],
freeFoodUsed: 0,
freeCoachUsed: 0
});
const [loadingStats, setLoadingStats] = useState(false);
const fetchStats = async () => {
if (!userId) return;
setLoadingStats(true);
try {
// 1. Get User Profile for Streak
const { data: profile } = await supabase
.from('profiles')
.select('current_streak, longest_streak')
.eq('id', userId)
.maybeSingle();
// 2. 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.5 Get Free Quota Uses
const { count: freeFoodUsed } = await supabase
.from('food_analyses')
.select('*', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('used_free_quota', true);
const { count: freeCoachUsed } = await supabase
.from('coach_analyses')
.select('*', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('used_free_quota', true);
// 3. Get Last 7 Days Data for Charts
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6);
sevenDaysAgo.setHours(0, 0, 0, 0);
const { data: calData, error: calError } = await supabase
.from('food_analyses')
.select('total_calories, total_protein, total_carbs, total_fat, nutrition_score, created_at')
.eq('user_id', userId)
.gte('created_at', sevenDaysAgo.toISOString())
.order('created_at', { ascending: true });
if (calError) throw calError;
let calculatedAvg = 0;
const dailyData: Record<string, { cals: number[], prot: number[], carbs: number[], fat: number[], score: number[] }> = {};
// Initialize last 7 days
for (let i = 0; i < 7; i++) {
const d = new Date();
d.setDate(d.getDate() - (6 - i));
const dateStr = d.toLocaleDateString('pt-BR', { weekday: 'short' });
dailyData[dateStr] = { cals: [], prot: [], carbs: [], fat: [], score: [] };
}
if (calData && calData.length > 0) {
const totalSum = calData.reduce((acc, curr) => acc + (curr.total_calories || 0), 0);
calculatedAvg = Math.round(totalSum / calData.length);
calData.forEach(item => {
if (!item.created_at) return;
const d = new Date(item.created_at);
const k = d.toLocaleDateString('pt-BR', { weekday: 'short' });
if (dailyData[k]) {
dailyData[k].cals.push(item.total_calories || 0);
dailyData[k].prot.push(item.total_protein || 0);
dailyData[k].carbs.push(item.total_carbs || 0);
dailyData[k].fat.push(item.total_fat || 0);
dailyData[k].score.push(item.nutrition_score || 0);
}
});
}
// Averages per day
const chartData: DailyMacro[] = Object.keys(dailyData).map(k => {
const d = dailyData[k];
const avg = (arr: number[]) => arr.length ? Math.round(arr.reduce((a, b) => a + b, 0) / arr.length) : 0;
return {
date: k,
calories: avg(d.cals),
protein: avg(d.prot),
carbs: avg(d.carbs),
fat: avg(d.fat),
score: avg(d.score),
};
});
setStats({
totalCount: count || 0,
avgCals: calculatedAvg,
currentStreak: profile?.current_streak || 0,
longestStreak: profile?.longest_streak || 0,
chartData: chartData,
freeFoodUsed: freeFoodUsed || 0,
freeCoachUsed: freeCoachUsed || 0
});
} catch (err) {
console.error("Error fetching stats:", err);
} finally {
setLoadingStats(false);
}
};
useEffect(() => {
fetchStats();
}, [userId]);
return { stats, loadingStats, refetchStats: fetchStats };
};

113
src/index.css Normal file
View file

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

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

@ -0,0 +1,660 @@
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
current_streak?: number
longest_streak?: number
last_scan_date?: 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
current_streak?: number
longest_streak?: number
last_scan_date?: 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
current_streak?: number
longest_streak?: number
last_scan_date?: string | null
}
Relationships: [
{
foreignKeyName: "profiles_id_fkey"
columns: ["id"]
referencedRelation: "users"
referencedColumns: ["id"]
}
]
}
stripe_customers: {
Row: {
user_id: string
stripe_customer_id: string
email: string | null
created_at: string
updated_at: string
}
Insert: {
user_id: string
stripe_customer_id: string
email?: string | null
created_at?: string
updated_at?: string
}
Update: {
user_id?: string
stripe_customer_id?: string
email?: string | null
created_at?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "stripe_customers_pkey" // It's actually a PK but often a FK too
columns: ["user_id"]
referencedRelation: "users" // implicit
referencedColumns: ["id"]
}
]
}
stripe_events: {
Row: {
id: string
type: string | null
created_at: string
}
Insert: {
id: string
type?: string | null
created_at?: string
}
Update: {
id?: string
type?: string | null
created_at?: string
}
Relationships: []
}
user_entitlements: {
Row: {
user_id: string
entitlement_code: string
is_trial: boolean
is_active: boolean
valid_until: string | null
usage: Json
created_at: string
updated_at: string
plan_type: string | null
}
Insert: {
user_id: string
entitlement_code: string
is_trial?: boolean
is_active?: boolean
valid_until?: string | null
usage?: Json
created_at?: string
updated_at?: string
plan_type?: string | null
}
Update: {
user_id?: string
entitlement_code?: string
is_trial?: boolean
is_active?: boolean
valid_until?: string | null
usage?: Json
created_at?: string
updated_at?: string
plan_type?: string | null
}
Relationships: [
{
foreignKeyName: "user_entitlements_pkey"
columns: ["user_id"]
referencedRelation: "users"
referencedColumns: ["id"]
}
]
}
}
Views: {
user_access_summary: {
Row: {
user_id: string | null
free_used: number | null
free_remaining: number | null
plan_active: boolean | null
plan_code: string | null
plan_started_at: string | null
plan_valid_until: string | null
can_use_paid: boolean | null
}
}
}
}
}

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

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

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

@ -0,0 +1,11 @@
import { createClient } from "@supabase/supabase-js";
import { Database } from "./database.types";
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
throw new Error("Missing Supabase environment variables. Please check your .env file.");
}
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_ANON_KEY);

20
src/main.tsx Normal file
View file

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

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

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

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

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

View file

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

View file

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

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

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

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

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

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

@ -0,0 +1,236 @@
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
initialTab?: 'overview' | 'history' | 'subscription' | 'coach';
}
const Dashboard: React.FC<DashboardProps> = ({ user, onLogout, onOpenAdmin, onOpenPro, initialTab }) => {
const { t, language } = useLanguage();
const [activeTab, setActiveTab] = useState<'overview' | 'history' | 'subscription' | 'coach'>(initialTab || '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-portal`, {
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}
userPlan={user.plan}
/>
)}
</main >
<CoachWizard
isOpen={isCoachWizardOpen}
onClose={() => setIsCoachWizardOpen(false)}
onComplete={(data: any) => {
console.log("Wizard Completed:", data);
setCoachPlan(data);
setIsCoachWizardOpen(false);
}}
/>
</div >
);
};
export default Dashboard;

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

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

View file

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

View file

@ -0,0 +1,84 @@
import React from 'react';
import { ArrowLeft, Trash2, Mail } from 'lucide-react';
interface DataDeletionProps {
onBack: () => void;
}
const DataDeletion: React.FC<DataDeletionProps> = ({ onBack }) => {
return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl mx-auto bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
<button
onClick={onBack}
className="flex items-center text-sm text-gray-500 hover:text-brand-600 mb-8 transition-colors"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar para Home
</button>
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-red-100 text-red-600 rounded-xl">
<Trash2 className="w-6 h-6" />
</div>
<h1 className="text-3xl font-bold text-gray-900">Exclusão de Dados</h1>
</div>
<p className="text-sm text-gray-500 mb-8">Última atualização: {new Date().toLocaleDateString('pt-BR')}</p>
<div className="prose prose-brand max-w-none text-gray-600 space-y-6">
<p className="text-lg text-gray-700">
De acordo com o Regulamento Geral sobre a Proteção de Dados (GDPR), a Lei Geral de Proteção de Dados Pessoais do Brasil (LGPD) e as políticas da Apple e da Meta, você tem o direito de solicitar a exclusão completa de todos os seus dados armazenados pela nossa plataforma.
</p>
<section className="bg-gray-50 p-6 rounded-xl border border-gray-200 mt-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
Como Solicitar a Exclusão?
</h2>
<p className="mb-4">
A exclusão de dados do "FoodSnap" engloba a deleção completa de:
</p>
<ul className="list-disc pl-5 mt-2 space-y-2 mb-6">
<li>Sua conta da plataforma Web (Email e Senha).</li>
<li>Todo o seu histórico de peso, perfil físico e objetivos registrados.</li>
<li>Ligação e histórico de WhatsApp do nosso banco de dados.</li>
<li>Dietas geradas e fotos de pratos não anonimizadas.</li>
</ul>
<h3 className="text-lg font-medium text-gray-900 mt-6 mb-2">Método 1: Pela Plataforma</h3>
<p className="mb-4">
Se você possui uma conta de acesso na Dashboard:
<br />
1. Faça Login em no FoodSnap. <br />
2. Navegue até "Meu Perfil" no canto superior direito. <br />
3. Rolando até o fim, clique no botão vermelho "Excluir Minha Conta Permanentemente".
</p>
<h3 className="text-lg font-medium text-gray-900 mt-6 mb-2 flex items-center gap-2">
<Mail className="w-5 h-5" />
Método 2: Por E-Mail (Suporte)
</h3>
<p>
Você também pode optar por enviar um e-mail direto para nossa equipe de dados solicitando a exclusão manual.
<br /><br />
<strong>E-mail de Contato:</strong> <em>privacidade@foodsnap.com.br</em>
<br />(ou o email principal de suporte do aplicativo).
<br /><br />
No corpo do e-mail, inclua seu nome, número de telefone cadastrado no app e email associado. O prazo máximo para o processamento deste tipo de pedido de deleção é de 7 dias úteis.
</p>
</section>
<section className="mt-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">O que acontece após a Exclusão?</h2>
<p>
Assim que seus dados forem apagados, você perderá acesso ao seu histórico de dietas e treinos. Essa ação é irreversível. Por questões de exigências e documentações fiscais/contábeis (como recibos de planos adquiridos do Stripe), partes da sua transação financeira poderão ser retidas sob obrigações da legislação legal de armazenamento, mas desassociadas do seu uso diário do aplicativo.
</p>
</section>
</div>
</div>
</div>
);
};
export default DataDeletion;

View file

@ -0,0 +1,77 @@
import React from 'react';
import { ArrowLeft } from 'lucide-react';
interface PrivacyPolicyProps {
onBack: () => void;
}
const PrivacyPolicy: React.FC<PrivacyPolicyProps> = ({ onBack }) => {
return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl mx-auto bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
<button
onClick={onBack}
className="flex items-center text-sm text-gray-500 hover:text-brand-600 mb-8 transition-colors"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar para Home
</button>
<h1 className="text-3xl font-bold text-gray-900 mb-6">Política de Privacidade</h1>
<p className="text-sm text-gray-500 mb-8">Última atualização: {new Date().toLocaleDateString('pt-BR')}</p>
<div className="prose prose-brand max-w-none text-gray-600 space-y-6">
<section>
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">1. Introdução</h2>
<p>
A sua privacidade é importante para nós. Esta Política de Privacidade explica como o FoodSnap coleta, usa, compartilha e protege as suas informações pessoais quando você utiliza nossos serviços, site e integração com o WhatsApp.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">2. Coleta de Dados</h2>
<p>Os tipos de informações que coletamos incluem:</p>
<ul className="list-disc pl-5 mt-2 space-y-2">
<li><strong>Informações de Contato:</strong> Número de telefone do WhatsApp para o envio das análises.</li>
<li><strong>Dados de Análise:</strong> Imagens de alimentos enviadas voluntariamente pelo usuário para processamento pela nossa Inteligência Artificial.</li>
<li><strong>Dados de Perfil:</strong> Altura, peso, gênero e objetivos, caso fornecidos para o plano alimentar.</li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">3. Como Usamos Seus Dados</h2>
<p>Nós utilizamos os dados coletados para:</p>
<ul className="list-disc pl-5 mt-2 space-y-2">
<li>Processar e estimar as calorias dos pratos enviados via IA.</li>
<li>Fornecer respostas diretas no WhatsApp pelo modelo do Meta Cloud API.</li>
<li>Personalizar a sua experiência e ajustar nossos treinos e dietas gerados por IA.</li>
</ul>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">4. Compartilhamento e Serviços de Terceiros</h2>
<p>
Suas imagens e textos podem ser processados com segurança por provedores de IA confiáveis (como Google Gemini) exclusivamente para o ato de gerar os resultados. Não vendemos suas informações para terceiros para fins publicitários em hipótese alguma.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">5. Segurança</h2>
<p>
Empregamos medidas de segurança técnicas e organizacionais para proteger as informações pessoais contra acesso, uso e divulgação não autorizados, em conformidade com as diretrizes da Meta e provedores de nuvem (Supabase).
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">6. Contato</h2>
<p>
Se você tiver dúvidas sobre esta Política de Privacidade, entre em contato através dos nossos canais oficiais de suporte disponíveis no nosso site.
</p>
</section>
</div>
</div>
</div>
);
};
export default PrivacyPolicy;

View file

@ -0,0 +1,74 @@
import React from 'react';
import { ArrowLeft } from 'lucide-react';
interface TermsOfServiceProps {
onBack: () => void;
}
const TermsOfService: React.FC<TermsOfServiceProps> = ({ onBack }) => {
return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-3xl mx-auto bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
<button
onClick={onBack}
className="flex items-center text-sm text-gray-500 hover:text-brand-600 mb-8 transition-colors"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Voltar para Home
</button>
<h1 className="text-3xl font-bold text-gray-900 mb-6">Termos de Serviço</h1>
<p className="text-sm text-gray-500 mb-8">Última atualização: {new Date().toLocaleDateString('pt-BR')}</p>
<div className="prose prose-brand max-w-none text-gray-600 space-y-6">
<section>
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">1. Aceitação dos Termos</h2>
<p>
Ao acessar ou utilizar a plataforma FoodSnap via site, aplicativo ou integração de WhatsApp, você confirma que leu, compreendeu e concorda em ficar vinculado a estes Termos de Serviço. Se não concordar, você não deve usar nossos serviços.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">2. Descrição dos Serviços</h2>
<p>
O FoodSnap é uma plataforma que utiliza inteligência artificial para análise de imagens de pratos visando a estimativa de calorias e macros, e a geração de planos de exercícios e dieta. O envio pode ser feito primariamente pela interface web ou pelo nosso robô oficial (Cloud API) do WhatsApp.
</p>
<p>
Nota médica: As sugestões de dieta e calorias oferecidas pelo sistema baseiam-se em modelos matemáticos e de IA. <strong>Eles não substituem orientação de médicos e nutricionistas reais.</strong>
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">3. Contas de Usuário</h2>
<p>
Para acessar certos recursos, inclusive planos PRO, relatórios aprimorados e limites mais altos, você pode ser solicitado a criar uma conta. Você é responsável por manter a confidencialidade de sua senha (quando aplicável) e atividades. Contas podem ser encerradas ou limitadas se o serviço for abusado.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">4. Conduta no WhatsApp e Site</h2>
<p>
Ao usar a integração do WhatsApp, você concorda em usar os recursos apenas para o fim de escanear pratos e conversar com o "Coach" sobre treinos e dietas. Abuso visual (envio de imagens pornográficas, de ódio ou ilegais) para o modelo de IA pode resultar no banimento imediato e sem reembolso do número do WhatsApp e conta.
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">5. Assinaturas e Pagamentos</h2>
<p>
Em compras do "Plano PRO", o acesso às funcionalidades premium é fornecido enquanto a respectiva assinatura ou compra estiver ativa e/ou válida conforme definido no checkout. Os valores e recorrências serão indicados em nosso "checkout".
</p>
</section>
<section>
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">6. Limitação de Responsabilidade</h2>
<p>
Em nenhuma circunstância o FoodSnap se responsabilizará por danos diretos, indiretos, perdas de lucros ou físicos causados pela adoção imprudente dos treinos sugeridos ou lesões ocorridas. A adoção dos modelos é por conta e risco do usuário, avaliando sua própria saúde prévia.
</p>
</section>
</div>
</div>
</div>
);
};
export default TermsOfService;

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

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
17.6.1.054

View file

@ -0,0 +1 @@
mnhgpnqkwuqzpvfrwftp

View file

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

View file

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

View file

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

11
supabase/config.toml Normal file
View file

@ -0,0 +1,11 @@
[functions.meta-whatsapp-webhook]
enabled = true
verify_jwt = true
import_map = "./functions/meta-whatsapp-webhook/deno.json"
# Uncomment to specify a custom file path to the entrypoint.
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
entrypoint = "./functions/meta-whatsapp-webhook/index.ts"
# Specifies static files to be bundled with the function. Supports glob patterns.
# For example, if you want to serve static HTML pages in your function:
# static_files = [ "./functions/meta-whatsapp-webhook/*.html" ]

View file

@ -0,0 +1,121 @@
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, last_evaluation } = 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 & History
let userPrompt = `Objetivo do Usuário: ${goal}\n`;
if (last_evaluation) {
userPrompt += `\nHistórico (Última Avaliação do Usuário): ${last_evaluation}\nAnalise as fotos comparando o físico atual com esse histórico e explique as mudanças notadas.\n`;
} else {
userPrompt += `\nAnalise as fotos e gere o protocolo inicial.\n`;
}
parts.push({ text: userPrompt });
// Images
for (const [key, value] of Object.entries(photos)) {
if (typeof value === 'string' && value.includes('base64,')) {
// value example: "data:image/jpeg;base64,/9j/4AAQSkZJRg..."
const base64Data = value.split(',')[1];
// Detect mime type
const mimeMatch = value.match(/^data:(.*);base64/);
const mimeType = mimeMatch ? mimeMatch[1] : 'image/jpeg';
parts.push({
inline_data: {
mime_type: mimeType,
data: base64Data
}
});
}
}
// Call Gemini API via Fetch (More stable than SDK in Deno Edge)
// Using user-specified model: gemini-2.5-flash
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
contents: [{ parts: parts }],
generationConfig: {
temperature: 0.2,
response_mime_type: "application/json"
}
})
}
);
if (!response.ok) {
const errorText = await response.text();
console.error("Gemini API Error:", errorText);
throw new Error(`Erro na IA (${response.status}): ${errorText}`);
}
const data = await response.json();
const generatedText = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!generatedText) {
console.error("Gemini Empty Response:", JSON.stringify(data));
throw new Error("A IA não conseguiu analisar as imagens. Tente fotos com melhor iluminação.");
}
let jsonResponse;
try {
// Clean markdown blocks if present (common in Gemini responses)
const cleaned = generatedText.replace(/```json/g, '').replace(/```/g, '').trim();
jsonResponse = JSON.parse(cleaned);
} catch (e) {
console.error("JSON Parse Error:", generatedText);
throw new Error("Erro ao processar a resposta da IA. Tente novamente.");
}
// Basic validation of the response structure
if (!jsonResponse.analysis || !jsonResponse.diet || !jsonResponse.workout) {
throw new Error("A resposta da IA veio incompleta. Por favor, tente novamente.");
}
return new Response(JSON.stringify(jsonResponse), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 200
});
} catch (error) {
console.error("Function Error:", error);
return new Response(JSON.stringify({ error: error.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
status: 400 // Return 400 so client sees it as error, but with body
});
}
});

View file

@ -0,0 +1,102 @@
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)",
"evolution_notes": "Comparação detalhada e motivacional com a avaliação anterior (se enviada) ou dicas para progresso se for a primeira vez.",
"strengths": ["Ombros largos", "Cintura fina", "Bons quadríceps"],
"weaknesses": ["Panturrilhas pouco desenvolvidas", "Peitoral superior fraco"]
},
"diet": {
"total_calories": 0,
"macros": {
"protein_g": 0,
"carbs_g": 0,
"fats_g": 0
},
"hydration_liters": 0,
"supplements": [
{ "name": "Creatina", "dosage": "5g pós-treino", "reason": "Aumento de força e recuperação" },
{ "name": "Whey Protein", "dosage": "30g se não bater a meta", "reason": "Praticidade para bater proteínas" },
{ "name": "Multivitamínico", "dosage": "1 caps almoço", "reason": "Micro-nutrientes essenciais" }
],
"meal_plan_example": [
{
"name": "Café da Manhã",
"time_range": "07:00 - 08:00",
"options": [
"Opção 1: 3 Ovos mexidos + 1 Banana + 40g Aveia",
"Opção 2: 2 Fatias Pão Integral + 100g Frango Desfiado + Queijo Cotagge"
],
"substitution_suggestion": "Para vegetarianos: Trocar frango por Tofu ou ovos por Shake proteico vegano."
},
{
"name": "Almoço",
"time_range": "12:00 - 13:00",
"options": [
"Opção 1: 150g Frango Grelhado + 120g Arroz Branco + Vegetais Verdes à vontade",
"Opção 2: 150g Patinho Moído + 150g Batata Inglesa + Salada Mista"
],
"substitution_suggestion": "Se enjoar de arroz, use Macarrão Integral (mesmo peso) ou Batata Doce (peso x1.3)."
},
{
"name": "Lanche da Tarde",
"time_range": "16:00 - 16:30",
"options": [
"Opção 1: 1 Iogurte Grego Zero + 20g Nozes",
"Opção 2: 1 Fruta + 1 Dose de Whey"
],
"substitution_suggestion": "Pode trocar as gorduras (nozes) por Pasta de Amendoim."
},
{
"name": "Jantar",
"time_range": "20:00 - 21:00",
"options": [
"Opção 1: 150g Peixe Branco (Tilápia) + Salada Completa + Azeite de Oliva",
"Opção 2: Omelete de 3 Ovos com Espinafre e Tomate"
],
"substitution_suggestion": "Evite carboidratos pesados a noite se o objetivo for secar."
}
]
},
"workout": {
"split": "ABC" | "ABCD" | "ABCDE" | "Fullbody",
"focus": "Hipertrofia" | "Força" | "Perda de Gordura",
"frequency_days": 0,
"injury_adaptations": {
"knee_pain": "Substituir Agachamento por Leg Press 45 com pés altos",
"shoulder_pain": "Fazer Supino com Halteres pegada neutra ao invés de barra",
"back_pain": "Evitar Terra e Remada Curvada, preferir máquinas apoiadas"
},
"routine": [
{
"day": "Segunda",
"muscle_group": "Peito + Tríceps",
"exercises": [
{ "name": "Supino Inclinado com Halteres", "sets": 4, "reps": "8-12", "technique": "Focar na parte superior, descida controlada" },
{ "name": "Crucifixo Máquina", "sets": 3, "reps": "12-15", "technique": "Pico de contração de 1s" }
]
}
]
},
"motivation_quote": "Uma frase curta de impacto."
}
Regras IMPORTANTES:
1. Seja MUITO DETALHADO na dieta. SEMPRE pelo menos 2 opções para CADA refeição ("options").
2. Inclua o horário sugerido ("time_range") para cada refeição.
3. O campo "substitution_suggestion" deve dar uma alternativa clara de troca de alimentos (ex: trocar carbo X por Y).
4. Adapte o treino ao biotipo (ex: Ectomorfo menos volume, Endomorfo mais cardio).
5. Nos suplementos, especifique COMO tomar e PORQUE.
6. A resposta DEVE ser um JSON válido.
7. Se um histórico (Última Avaliação) for fornecido no objetivo do usuário, compare o físico atual com os dados anteriores e preencha o campo "evolution_notes" com um parecer técnico e motivacional sobre as mudanças reais notadas nas fotos versus o histórico. Estime a redução ou aumento de medidas ou gordura com precisão baseada em referências anatômicas. A estimativa de body_fat_percentage deve refletir o julgamento visual crítico de um especialista.
`;

View file

@ -0,0 +1,3 @@
# Configuration for private npm package dependencies
# For more information on using private registries with Edge Functions, see:
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries

View file

@ -0,0 +1,5 @@
{
"imports": {
"@supabase/functions-js": "jsr:@supabase/functions-js@^2"
}
}

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -0,0 +1,134 @@
/// <reference lib="deno.ns" />
import Stripe from "npm:stripe@16.12.0";
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 SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const SITE_URL = Deno.env.get("SITE_URL")!;
// ✅ Plano único: FoodSnap PRO (R$14,99/mês) — leitura de alimentos + Coach IA
const PRICE_MENSAL = "price_1TLsAFA5eAF7o14GeHRMJLzB";
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();
}
/** Busca email do usuário pelo ID (para chamadas server-to-server via WhatsApp webhook) */
async function getUserEmailById(userId: string): Promise<string | null> {
const res = await fetch(`${SUPABASE_URL}/auth/v1/admin/users/${userId}`, {
headers: {
apikey: SUPABASE_SERVICE_ROLE_KEY,
authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}`,
},
});
if (!res.ok) return null;
const data = await res.json();
return data?.email ?? null;
}
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 priceIdForPlan() {
// Plano único — sempre retorna o price mensal
return PRICE_MENSAL;
}
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 (!SITE_URL) return json({ ok: false, error: "Missing SITE_URL" }, 500);
const site = assertBaseUrl(SITE_URL, "SITE_URL");
const successUrl = new URL("/dashboard?checkout=success", site).toString();
const cancelUrl = new URL("/dashboard?checkout=cancel", site).toString();
const priceId = priceIdForPlan();
const auth = req.headers.get("authorization") || "";
const jwt = auth.startsWith("Bearer ") ? auth.slice(7) : "";
const isServiceRole = jwt === SUPABASE_SERVICE_ROLE_KEY;
if (isServiceRole) {
// ── Chamada server-to-server (WhatsApp webhook) ──────────────
// Usa user_id do body diretamente, sem precisar de JWT do usuário
const body = await req.json().catch(() => ({}));
if (!body?.user_id) return json({ ok: false, error: "Missing user_id for service role call" }, 400);
const userId = body.user_id;
const userEmail = await getUserEmailById(userId);
const session = await stripe.checkout.sessions.create({
mode: "subscription",
allow_promotion_codes: true,
line_items: [{ price: priceId, quantity: 1 }],
success_url: successUrl,
cancel_url: cancelUrl,
customer_email: userEmail ?? undefined,
metadata: { user_id: userId, plan_code: "mensal" },
subscription_data: { metadata: { user_id: userId, plan_code: "mensal" } },
});
return json({ ok: true, url: session.url });
}
// ── Chamada normal via frontend (JWT do usuário) ─────────────────
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 session = await stripe.checkout.sessions.create({
mode: "subscription",
allow_promotion_codes: true,
line_items: [{ price: priceId, quantity: 1 }],
success_url: successUrl,
cancel_url: cancelUrl,
customer_email: user.email ?? undefined,
metadata: { user_id: user.id, plan_code: "mensal" },
subscription_data: { metadata: { user_id: user.id, plan_code: "mensal" } },
});
return json({ ok: true, url: session.url });
} catch (err) {
console.error("stripe-checkout error:", err);
return json({ ok: false, error: String((err as any)?.message ?? err) }, 500);
}
});

View file

@ -0,0 +1,72 @@
/// <reference lib="deno.ns" />
import Stripe from "npm:stripe@16.12.0";
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") ?? "http://localhost:3000";
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();
}
async function getStripeCustomerId(userId: string) {
const res = await fetch(`${SUPABASE_URL}/rest/v1/stripe_customers?user_id=eq.${userId}&select=stripe_customer_id`, {
headers: {
apikey: SUPABASE_ANON_KEY,
authorization: `Bearer ${SUPABASE_ANON_KEY}`,
},
});
if (!res.ok) return null;
const rows = await res.json();
return rows?.[0]?.stripe_customer_id;
}
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 {
const auth = req.headers.get("authorization") || "";
const jwt = auth.startsWith("Bearer ") ? auth.slice(7) : "";
if (!jwt) return json({ ok: false, error: "Missing Authorization" }, 401);
const user = await getUserFromJwt(jwt);
if (!user?.id) return json({ ok: false, error: "Invalid token" }, 401);
const customerId = await getStripeCustomerId(user.id);
if (!customerId) return json({ ok: false, error: "Customer not found" }, 404);
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${SITE_URL}/dashboard`,
});
return json({ ok: true, url: session.url });
} catch (err) {
return json({ ok: false, error: String((err as any)?.message ?? err) }, 500);
}
});

Some files were not shown because too many files have changed in this diff Show more