feat: migrate to Meta API, add Gamification streaks, Recharts dashboard, and Image Renderer component
This commit is contained in:
parent
2195d19178
commit
2e28f9d27c
15 changed files with 5051 additions and 4270 deletions
284
image-renderer/index.js
Normal file
284
image-renderer/index.js
Normal 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
2234
image-renderer/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
14
image-renderer/package.json
Normal file
14
image-renderer/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
image-renderer/test_card.png
Normal file
BIN
image-renderer/test_card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 528 KiB |
4531
package-lock.json
generated
4531
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -15,7 +15,8 @@
|
|||
"html2pdf.js": "^0.12.1",
|
||||
"lucide-react": "0.344.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
"react-dom": "18.3.1",
|
||||
"recharts": "^3.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
|
|
@ -23,4 +24,4 @@
|
|||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ const AppContent: React.FC = () => {
|
|||
onLogout={logout}
|
||||
onOpenAdmin={user.is_admin ? toggleAdminView : undefined}
|
||||
onOpenPro={() => setIsProfessionalView(true)}
|
||||
initialTab={currentPath.includes('/meu-plano') ? 'coach' : 'overview'}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Search, Zap, CreditCard, MessageCircle, Smartphone, QrCode, ChevronRight, Loader2, CheckCircle2, TrendingUp, Calendar, ArrowRight } from 'lucide-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: {
|
||||
|
|
@ -12,6 +13,9 @@ interface DashboardOverviewProps {
|
|||
stats: {
|
||||
totalCount: number;
|
||||
avgCals: number;
|
||||
currentStreak: number;
|
||||
longestStreak: number;
|
||||
chartData: any[];
|
||||
};
|
||||
loadingStats: boolean;
|
||||
history: any[];
|
||||
|
|
@ -79,19 +83,26 @@ const DashboardOverview: React.FC<DashboardOverviewProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Quick Stats in Hero */}
|
||||
<div className="grid grid-cols-2 gap-4 w-full md:w-auto">
|
||||
<div className="bg-white/5 backdrop-blur-md border border-white/10 p-5 rounded-2xl md:min-w-[160px]">
|
||||
<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-3xl 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-3xl font-black text-white">{loadingStats ? '...' : stats.totalCount}</p>
|
||||
<div className="mt-2 text-[10px] text-brand-300 bg-brand-900/50 px-2 py-1 rounded inline-block">
|
||||
+12% vs mês
|
||||
Total de Análises
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white/5 backdrop-blur-md border border-white/10 p-5 rounded-2xl md:min-w-[160px]">
|
||||
<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-3xl font-black text-white">{loadingStats ? '...' : Math.round(stats.avgCals)}</p>
|
||||
<p className="text-3xl 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 Diária
|
||||
Média Histórica
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -99,43 +110,80 @@ const DashboardOverview: React.FC<DashboardOverviewProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* 2. Recent History (Main Column) */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900 flex items-center gap-2">
|
||||
<TrendingUp className="text-brand-500" size={24} />
|
||||
{t.dashboard.recentTitle}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setActiveTab('history')}
|
||||
className="text-sm font-semibold text-gray-500 hover:text-brand-600 flex items-center gap-1 transition-colors"
|
||||
>
|
||||
Ver Todos <ArrowRight size={16} />
|
||||
</button>
|
||||
{/* 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 há dados suficientes</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
{/* 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={() => window.open(whatsappUrl, '_blank')}
|
||||
className="mt-4 text-brand-600 font-bold hover:underline"
|
||||
onClick={() => setActiveTab('history')}
|
||||
className="text-sm font-semibold text-gray-500 hover:text-brand-600 flex items-center gap-1 transition-colors"
|
||||
>
|
||||
Começar Agora
|
||||
Ver Todos <ArrowRight size={16} />
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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) */}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,31 @@
|
|||
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[];
|
||||
}
|
||||
|
||||
export const useDashboardStats = (userId: string) => {
|
||||
const [stats, setStats] = useState<DashboardStats>({ totalCount: 0, avgCals: 0 });
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
totalCount: 0,
|
||||
avgCals: 0,
|
||||
currentStreak: 0,
|
||||
longestStreak: 0,
|
||||
chartData: []
|
||||
});
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
|
||||
const fetchStats = async () => {
|
||||
|
|
@ -15,7 +33,14 @@ export const useDashboardStats = (userId: string) => {
|
|||
|
||||
setLoadingStats(true);
|
||||
try {
|
||||
// 1. Get Total Count
|
||||
// 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 })
|
||||
|
|
@ -23,23 +48,69 @@ export const useDashboardStats = (userId: string) => {
|
|||
|
||||
if (countError) throw countError;
|
||||
|
||||
// 2. Get Average Calories
|
||||
// 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')
|
||||
.eq('user_id', userId);
|
||||
.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;
|
||||
if (calData && calData.length > 0) {
|
||||
const sum = calData.reduce((acc, curr) => acc + (curr.total_calories || 0), 0);
|
||||
calculatedAvg = Math.round(sum / calData.length);
|
||||
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
|
||||
avgCals: calculatedAvg,
|
||||
currentStreak: profile?.current_streak || 0,
|
||||
longestStreak: profile?.longest_streak || 0,
|
||||
chartData: chartData
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -483,7 +483,7 @@ export interface Database {
|
|||
updated_at?: string
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
{
|
||||
foreignKeyName: "professionals_id_fkey"
|
||||
columns: ["id"]
|
||||
referencedRelation: "users"
|
||||
|
|
@ -492,53 +492,62 @@ export interface Database {
|
|||
]
|
||||
}
|
||||
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
|
||||
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"]
|
||||
}
|
||||
Insert: {
|
||||
id: string
|
||||
full_name: string
|
||||
email: string
|
||||
phone?: string | null
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
public_id?: string | null
|
||||
phone_e164?: string | null
|
||||
is_admin?: boolean | null
|
||||
is_professional?: boolean | null
|
||||
avatar_url?: string | null
|
||||
}
|
||||
Update: {
|
||||
id?: string
|
||||
full_name?: string
|
||||
email?: string
|
||||
phone?: string | null
|
||||
created_at?: string | null
|
||||
updated_at?: string | null
|
||||
public_id?: string | null
|
||||
phone_e164?: string | null
|
||||
is_admin?: boolean | null
|
||||
is_professional?: boolean | null
|
||||
avatar_url?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "profiles_id_fkey"
|
||||
columns: ["id"]
|
||||
referencedRelation: "users"
|
||||
referencedColumns: ["id"]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
stripe_customers: {
|
||||
Row: {
|
||||
|
|
@ -563,12 +572,12 @@ export interface Database {
|
|||
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"]
|
||||
}
|
||||
columns: ["user_id"]
|
||||
referencedRelation: "users" // implicit
|
||||
referencedColumns: ["id"]
|
||||
}
|
||||
]
|
||||
}
|
||||
stripe_events: {
|
||||
|
|
@ -624,12 +633,12 @@ export interface Database {
|
|||
plan_type?: string | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "user_entitlements_pkey"
|
||||
columns: ["user_id"]
|
||||
referencedRelation: "users"
|
||||
referencedColumns: ["id"]
|
||||
}
|
||||
{
|
||||
foreignKeyName: "user_entitlements_pkey"
|
||||
columns: ["user_id"]
|
||||
referencedRelation: "users"
|
||||
referencedColumns: ["id"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,11 +27,12 @@ interface DashboardProps {
|
|||
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 }) => {
|
||||
const Dashboard: React.FC<DashboardProps> = ({ user, onLogout, onOpenAdmin, onOpenPro, initialTab }) => {
|
||||
const { t, language } = useLanguage();
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'history' | 'subscription' | 'coach'>('overview');
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'history' | 'subscription' | 'coach'>(initialTab || 'overview');
|
||||
const [isCoachWizardOpen, setIsCoachWizardOpen] = useState(false);
|
||||
// Custom Hooks
|
||||
const { stats, loadingStats } = useDashboardStats(user.id);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
249
supabase/functions/meta-whatsapp-webhook/pdf-template.ts
Normal file
249
supabase/functions/meta-whatsapp-webhook/pdf-template.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
|
||||
// ─── Geração de HTML para PDF do Coach (Premium 3 Páginas Compacto) ────────
|
||||
|
||||
function truncateText(text: string, max = 500): string {
|
||||
const t = (text || "").trim();
|
||||
if (!t) return "-";
|
||||
return t.length > max ? t.slice(0, max - 1) + "…" : t;
|
||||
}
|
||||
|
||||
function safeStr(v: any, fallback = "-"): string {
|
||||
if (v === null || v === undefined) return fallback;
|
||||
if (typeof v === "string") return v.trim() || fallback;
|
||||
if (typeof v === "number") return Number.isFinite(v) ? String(v) : fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function buildCoachPdfHtml(plan: any): string {
|
||||
const diet = plan.diet || {};
|
||||
const workout = plan.workout || {};
|
||||
const analysis = plan.analysis || {};
|
||||
const quote = plan.motivation_quote || "Disciplina é a ponte entre metas e conquistas.";
|
||||
|
||||
// --- Data Prep ---
|
||||
const protein = diet.macros?.protein_g ?? "–";
|
||||
const carbs = diet.macros?.carbs_g ?? "–";
|
||||
const fats = diet.macros?.fats_g ?? "–";
|
||||
const water = diet.hydration_liters ?? "–";
|
||||
const calories = Math.round(diet.total_calories || 0);
|
||||
|
||||
const somatotype = safeStr(analysis.somatotype);
|
||||
const goal = safeStr(workout.focus);
|
||||
const split = safeStr(workout.split);
|
||||
|
||||
// Lists
|
||||
const positives = (Array.isArray(analysis.strengths) ? analysis.strengths : [])
|
||||
.map((x: any) => typeof x === "string" ? x : x?.text).filter(Boolean); // Removed slice limit
|
||||
|
||||
// Map 'weaknesses' to 'improvements' (Prompt returns weaknesses)
|
||||
const improvements = (Array.isArray(analysis.weaknesses) ? analysis.weaknesses : [])
|
||||
.map((x: any) => typeof x === "string" ? x : x?.text).filter(Boolean);
|
||||
|
||||
const meals: any[] = Array.isArray(diet.meal_plan_example) ? diet.meal_plan_example : [];
|
||||
const supplements: any[] = Array.isArray(diet.supplements) ? diet.supplements : [];
|
||||
const routine: any[] = Array.isArray(workout.routine) ? workout.routine : [];
|
||||
|
||||
// --- HTML Generators ---
|
||||
|
||||
const positivesHtml = positives.length
|
||||
? `<ul class="list-disc pl-3 space-y-0.5 text-[10px] leading-snug text-gray-700">${positives.map((t: string) => `<li>${truncateText(t, 500)}</li>`).join("")}</ul>`
|
||||
: `<p class="text-[10px] text-gray-600">${safeStr(analysis.summary, "Sem detalhes.")}</p>`;
|
||||
|
||||
const improvementsHtml = improvements.length
|
||||
? `<ul class="list-disc pl-3 space-y-0.5 text-[10px] leading-snug text-gray-700">${improvements.map((t: string) => `<li>${truncateText(t, 500)}</li>`).join("")}</ul>`
|
||||
: `<p class="text-[10px] text-gray-600">${safeStr(analysis.improvement_summary, "Sem detalhes.")}</p>`;
|
||||
|
||||
const mealsHtml = meals.map((meal: any, i: number) => {
|
||||
const options = Array.isArray(meal.options) ? meal.options : [];
|
||||
const opt1 = options[0] || meal.main_option || "";
|
||||
const opt2 = options[1] || "";
|
||||
const sub = meal.substitution_suggestion || meal.substitution || "";
|
||||
|
||||
let html = `<div class="rounded-xl border border-gray-200 p-1.5 avoid-break mb-1">`;
|
||||
html += `<div class="flex items-start justify-between gap-2"><div>`;
|
||||
html += `<div class="text-[10px] font-extrabold text-gray-900 leading-none">${meal.name || `Refeição ${i + 1}`}</div>`;
|
||||
if (meal.time_range) html += `<div class="text-[9px] text-brand-700 font-semibold">${meal.time_range}</div>`;
|
||||
html += `</div><div class="text-[9px] text-gray-400 font-bold">#${i + 1}</div></div>`;
|
||||
html += `<div class="mt-1 space-y-1">`;
|
||||
if (opt1) html += `<div class="text-[9px] leading-tight text-gray-800 bg-gray-50 border border-gray-100 rounded-lg p-1"><span class="font-bold text-gray-700">Opção 1: </span>${truncateText(String(opt1), 500)}</div>`;
|
||||
if (opt2) html += `<div class="text-[9px] leading-tight text-gray-800 bg-gray-50 border border-gray-100 rounded-lg p-1"><span class="font-bold text-gray-700">Opção 2: </span>${truncateText(String(opt2), 500)}</div>`;
|
||||
if (sub) html += `<div class="text-[9px] leading-tight text-green-900 bg-green-50/70 border border-green-100 rounded-lg p-1"><span class="font-bold uppercase text-[8px] text-green-800">Substituição:</span> ${truncateText(String(sub), 300)}</div>`;
|
||||
html += `</div></div>`;
|
||||
return html;
|
||||
}).join("");
|
||||
|
||||
const supplementsHtml = supplements.map((sup: any) => {
|
||||
const name = typeof sup === "string" ? sup : sup.name || "Suplemento";
|
||||
const dosage = typeof sup === "string" ? "" : sup.dosage || "";
|
||||
const reason = typeof sup === "string" ? "" : sup.reason || ""; // Added reason if available
|
||||
let html = `<div class="border-l-2 border-brand-500 pl-2 mb-1">`;
|
||||
html += `<div class="flex items-center gap-1"><span class="text-brand-500 text-[10px]">💊</span><div class="text-[10px] font-bold leading-none">${truncateText(String(name), 100)}</div></div>`;
|
||||
if (dosage) html += `<div class="text-[9px] text-gray-500 leading-none mt-0.5">${truncateText(String(dosage), 100)}</div>`;
|
||||
if (reason) html += `<div class="text-[8px] text-gray-400 leading-none mt-0.5 italic">${truncateText(String(reason), 150)}</div>`;
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}).join("");
|
||||
|
||||
const daysHtml = routine.map((day: any, idx: number) => {
|
||||
const exs: any[] = Array.isArray(day.exercises) ? day.exercises : [];
|
||||
const dayName = day.day || day.name || `Dia ${idx + 1}`;
|
||||
const muscle = day.muscle_group || day.focus || "";
|
||||
|
||||
const exLines = exs.map((ex: any) => {
|
||||
if (typeof ex === "string") return `<li class="text-[9px] text-gray-700 leading-tight break-words">${ex}</li>`;
|
||||
const name = ex.name || ex.exercise || "";
|
||||
const sets = ex.sets ?? "";
|
||||
const reps = ex.reps ?? "";
|
||||
const technique = ex.technique || ex.notes || "";
|
||||
const sr = [sets ? `${sets}x` : "", reps].filter(Boolean).join(" ");
|
||||
const left = [name, sr].filter(Boolean).join(" — ");
|
||||
const full = [left, technique].filter(Boolean).join(" • ");
|
||||
return `<li class="text-[9px] text-gray-700 leading-tight break-words">${truncateText(full, 500) || "-"}</li>`;
|
||||
}).join("");
|
||||
|
||||
return `<div class="rounded-xl border border-gray-200 p-2 overflow-hidden mb-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[10px] font-black text-gray-900 leading-none truncate">${dayName}</div>
|
||||
<div class="text-[9px] text-gray-500 leading-none">${muscle}</div>
|
||||
</div>
|
||||
<div class="text-[9px] text-gray-400 font-mono whitespace-nowrap">${workout.split || "Diff"}</div>
|
||||
</div>
|
||||
<div class="mt-1 space-y-0.5"><ul class="list-disc pl-3 space-y-0.5">${exLines}</ul></div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
|
||||
// --- Template Compacto ---
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: { 50: '#f0fdfa', 100: '#ccfbf1', 500: '#14b8a6', 700: '#0f766e', 900: '#134e4a' }
|
||||
},
|
||||
fontSize: { xs: '0.6rem', sm: '0.7rem', base: '0.8rem', lg: '1rem', xl: '1.25rem' }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700;900&display=swap');
|
||||
|
||||
@page { size: A4; margin: 0; }
|
||||
html, body { margin: 0; padding: 0; background: #fff; }
|
||||
body { font-family: 'Inter', sans-serif; -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* Compact A4 Layout */
|
||||
.pdf-page {
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
padding: 8mm; /* Padrao 8mm (compacto) */
|
||||
overflow: hidden;
|
||||
page-break-after: always;
|
||||
break-after: page;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.pdf-page:last-child { page-break-after: auto; break-after: auto; }
|
||||
.avoid-break { break-inside: avoid; page-break-inside: avoid; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- PÁGINA 1: RESUMO -->
|
||||
<div class="pdf-root">
|
||||
<div class="pdf-page">
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex items-end justify-between border-b border-gray-200 pb-2 mb-2">
|
||||
<div>
|
||||
<div class="text-[9px] uppercase tracking-[0.2em] text-gray-400 font-semibold">Protocolo Titan • FoodSnap Coach</div>
|
||||
<h2 class="text-xl font-black text-gray-900 leading-tight">01. Diagnóstico</h2>
|
||||
</div>
|
||||
<div class="text-gray-300 text-2xl">⚡</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 gap-2 mb-2">
|
||||
<div class="rounded-lg border border-gray-200 p-1.5"><div class="text-[9px] uppercase tracking-widest text-gray-400 font-semibold">Biótipo</div><div class="text-[11px] font-bold text-gray-900">${somatotype}</div></div>
|
||||
<div class="rounded-lg border border-gray-200 p-1.5"><div class="text-[9px] uppercase tracking-widest text-gray-400 font-semibold">Objetivo</div><div class="text-[11px] font-bold text-gray-900">${goal}</div></div>
|
||||
<div class="rounded-lg border border-gray-200 p-1.5"><div class="text-[9px] uppercase tracking-widest text-gray-400 font-semibold">Calorias</div><div class="text-[11px] font-bold text-gray-900">${calories}</div></div>
|
||||
<div class="rounded-lg border border-gray-200 p-1.5"><div class="text-[9px] uppercase tracking-widest text-gray-400 font-semibold">Split</div><div class="text-[11px] font-bold text-gray-900">${split}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 flex-1 min-h-0">
|
||||
<div class="rounded-xl border border-gray-200 p-2 overflow-hidden">
|
||||
<div class="text-[10px] font-black text-gray-900 mb-1">Pontos Fortes</div>
|
||||
${positivesHtml}
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200 p-2 overflow-hidden">
|
||||
<div class="text-[10px] font-black text-gray-900 mb-1">Melhorias</div>
|
||||
${improvementsHtml}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 rounded-xl border border-gray-200 p-2">
|
||||
<p class="text-[9px] text-gray-500 italic text-center">"O sucesso é a soma de pequenos esforços repetidos dia após dia."</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PÁGINA 2: DIETA -->
|
||||
<div class="pdf-page">
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex items-end justify-between border-b border-gray-200 pb-2 mb-2">
|
||||
<div><h2 class="text-xl font-black text-gray-900 leading-tight">02. Dieta</h2></div>
|
||||
<div class="text-gray-300 text-2xl">🥗</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200 p-1.5 mb-2 avoid-break">
|
||||
<div class="flex justify-between items-center text-[10px]">
|
||||
<div><span class="text-gray-400 font-bold uppercase">PROT:</span> <span class="font-bold">${protein}</span></div>
|
||||
<div><span class="text-gray-400 font-bold uppercase">CARB:</span> <span class="font-bold">${carbs}</span></div>
|
||||
<div><span class="text-gray-400 font-bold uppercase">GORD:</span> <span class="font-bold">${fats}</span></div>
|
||||
<div class="text-blue-600 font-bold">💧 ${water}L</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2 flex-1 min-h-0">
|
||||
<div class="col-span-2 space-y-1 min-h-0">
|
||||
<div class="text-[10px] font-black text-gray-900">Refeições</div>
|
||||
<div class="space-y-1">${mealsHtml}</div>
|
||||
</div>
|
||||
<div class="col-span-1 min-h-0 flex flex-col">
|
||||
<div class="text-[10px] font-black text-gray-900 mb-1">Suplementos</div>
|
||||
<div class="bg-gray-50 rounded-xl p-2 flex-1 min-h-0 overflow-hidden avoid-break border border-gray-100">
|
||||
<div class="space-y-2">${supplementsHtml}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PÁGINA 3: TREINO -->
|
||||
<div class="pdf-page">
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex items-end justify-between border-b border-gray-200 pb-2 mb-2">
|
||||
<div><h2 class="text-xl font-black text-gray-900 leading-tight">03. Treino</h2></div>
|
||||
<div class="text-gray-300 text-2xl">🏋️</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 flex-1 min-h-0 overflow-hidden">
|
||||
${daysHtml}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 pt-2 border-t border-gray-200 text-center">
|
||||
<span class="text-[9px] italic text-gray-400">"${truncateText(quote, 100)}"</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
266
supabase/functions/meta-whatsapp-webhook/prompt.ts
Normal file
266
supabase/functions/meta-whatsapp-webhook/prompt.ts
Normal 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 dê aconselhamento médico, nem diagnóstico.
|
||||
Use linguagem objetiva, estilo app fitness.
|
||||
Seja claro sobre incertezas, sem usar palavras como “aproximadamente”.
|
||||
|
||||
Retorne SOMENTE JSON puro.
|
||||
NÃO use markdown.
|
||||
NÃO use \`\`\` .
|
||||
NÃO escreva qualquer texto fora do JSON.
|
||||
A resposta DEVE ser um objeto JSON único (nunca um array solto).
|
||||
|
||||
⸻
|
||||
|
||||
ANALISE a imagem de um alimento ou prato.
|
||||
|
||||
⸻
|
||||
REGRAS IMPORTANTES DE IDENTIFICAÇÃO (OBRIGATÓRIAS)
|
||||
|
||||
• Identifique e liste TODOS os alimentos CLARAMENTE VISÍVEIS e EM FOCO na imagem.
|
||||
• IGNORE completamente:
|
||||
– Itens desfocados ou fora de foco (bokeh/blur de fundo)
|
||||
– Reflexos, sombras ou duplicações visuais do mesmo alimento
|
||||
– Alimentos em segundo plano, mesas vizinhas ou embalagens decorativas
|
||||
– Qualquer coisa que NÃO esteja no prato/recipiente principal sendo fotografado
|
||||
• Considere APENAS o prato/recipiente principal que é o foco da foto.
|
||||
• Nunca retorne apenas um item se mais de um alimento estiver visível.
|
||||
• Não agrupe alimentos diferentes em um único item.
|
||||
• Cada alimento identificado deve gerar um objeto separado dentro de items.
|
||||
• Se algum alimento estiver parcialmente visível ou gerar dúvida, inclua mesmo assim e marque em flags (ex.: "parcial", "porcao_duvidosa").
|
||||
• Não repita o mesmo item duas vezes.
|
||||
• Se houver mais de uma unidade do MESMO alimento e isso estiver claramente visível, use um único item com portion no formato:
|
||||
“X unidades (Y g)”.
|
||||
• Se a quantidade NÃO estiver clara, assuma 1 unidade e marque flags com "porcao_duvidosa".
|
||||
|
||||
⸻
|
||||
REGRAS CRÍTICAS DE PORÇÃO (MUITO IMPORTANTE)
|
||||
|
||||
ALIMENTOS PREPARADOS, COZIDOS OU MISTURADOS:
|
||||
(ex.: ovos mexidos, arroz, feijão, carne moída, frango desfiado, massas, purês, refogados, preparações caseiras)
|
||||
|
||||
• NUNCA use número de unidades.
|
||||
• NUNCA use termos como:
|
||||
“2 ovos”, “1 filé”, “3 colheres”, “200 g”, “1 pedaço”.
|
||||
• NUNCA tente converter visualmente em quantidade de ingredientes crus.
|
||||
|
||||
• Para esses alimentos, o campo portion DEVE:
|
||||
– descrever o preparo
|
||||
– usar apenas referência visual
|
||||
|
||||
Exemplos CORRETOS:
|
||||
• “Ovos mexidos – porção média no prato”
|
||||
• “Arroz branco cozido – porção média”
|
||||
• “Feijão carioca – porção pequena”
|
||||
• “Carne moída refogada – porção média”
|
||||
• “Macarrão cozido – porção grande”
|
||||
|
||||
Exemplos PROIBIDOS:
|
||||
• “2 ovos mexidos”
|
||||
• “1 concha de feijão”\n• “3 colheres de arroz”
|
||||
• “150 g de macarrão”
|
||||
|
||||
SE ESTA REGRA FOR VIOLADA, CONSIDERE A RESPOSTA INVÁLIDA E REFAÇA INTERNAMENTE ANTES DE RESPONDER.
|
||||
|
||||
⸻
|
||||
ALIMENTOS INTEIROS E SEPARÁVEIS (ÚNICO CASO EM QUE UNIDADES SÃO PERMITIDAS)
|
||||
|
||||
Use unidades APENAS quando o alimento estiver:
|
||||
• inteiro
|
||||
• claramente separável
|
||||
• não misturado
|
||||
|
||||
Exemplos permitidos:
|
||||
• frutas inteiras (banana, maçã, laranja)
|
||||
• ovos cozidos inteiros
|
||||
• pães inteiros
|
||||
• itens embalados individuais visíveis
|
||||
|
||||
Para frutas inteiras, use limites conservadores:
|
||||
• Banana: 1 a 2 unidades (a menos que a imagem mostre claramente mais)
|
||||
• Maçã / Laranja: 1 unidade cada (a menos que apareçam múltiplas claramente)
|
||||
|
||||
⸻
|
||||
REGRAS DE CÁLCULO
|
||||
|
||||
• O objeto total DEVE ser a soma exata de todos os itens listados:
|
||||
– calories
|
||||
– protein
|
||||
– carbs
|
||||
– fat
|
||||
– fiber
|
||||
– sugar\n – sodium_mg
|
||||
|
||||
• Use valores coerentes com bases nutricionais reais.
|
||||
• category deve refletir o tipo do prato (ex.: “Almoço”, “Jantar”, “Café da manhã”, “Lanche”, “Refeição caseira”).
|
||||
|
||||
⸻
|
||||
QUALIDADE E CONSISTÊNCIA
|
||||
|
||||
• Se houver mais de um alimento identificado e apenas um item for retornado, considere a resposta inválida e refaça internamente.
|
||||
• confidence deve refletir a clareza da imagem.
|
||||
• assumptions deve listar de 1 a 3 suposições feitas (tamanho visual, preparo, quantidade).
|
||||
• insights: no máximo 3 frases curtas, sem moralismo.
|
||||
|
||||
⸻
|
||||
CASO 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. Dê SEMPRE pelo menos 2 opções para CADA refeição ("options").
|
||||
2. Inclua o horário sugerido ("time_range") para cada refeição.
|
||||
3. O campo "substitution_suggestion" deve dar uma alternativa clara de troca de alimentos (ex: trocar carbo X por Y).
|
||||
4. Adapte o treino ao biotipo (ex: Ectomorfo menos volume, Endomorfo mais cardio).
|
||||
5. Nos suplementos, especifique COMO tomar e PORQUE.
|
||||
6. A resposta DEVE ser um JSON válido.
|
||||
`;
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
alter table "public"."profiles"
|
||||
add column "current_streak" integer not null default 0,
|
||||
add column "longest_streak" integer not null default 0,
|
||||
add column "last_scan_date" date;
|
||||
Loading…
Reference in a new issue