feat: migrate to Meta API, add Gamification streaks, Recharts dashboard, and Image Renderer component

This commit is contained in:
Marcio Bevervanso 2026-02-25 21:17:54 -03:00
parent 2195d19178
commit 2e28f9d27c
15 changed files with 5051 additions and 4270 deletions

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

4529
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -15,7 +15,8 @@
"html2pdf.js": "^0.12.1", "html2pdf.js": "^0.12.1",
"lucide-react": "0.344.0", "lucide-react": "0.344.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1" "react-dom": "18.3.1",
"recharts": "^3.7.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.0", "@types/node": "^22.14.0",

View file

@ -145,6 +145,7 @@ const AppContent: React.FC = () => {
onLogout={logout} onLogout={logout}
onOpenAdmin={user.is_admin ? toggleAdminView : undefined} onOpenAdmin={user.is_admin ? toggleAdminView : undefined}
onOpenPro={() => setIsProfessionalView(true)} onOpenPro={() => setIsProfessionalView(true)}
initialTab={currentPath.includes('/meu-plano') ? 'coach' : 'overview'}
/> />
</Suspense> </Suspense>
); );

View file

@ -1,6 +1,7 @@
import React from 'react'; 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 HistoryCard from '@/components/common/HistoryCard';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
interface DashboardOverviewProps { interface DashboardOverviewProps {
user: { user: {
@ -12,6 +13,9 @@ interface DashboardOverviewProps {
stats: { stats: {
totalCount: number; totalCount: number;
avgCals: number; avgCals: number;
currentStreak: number;
longestStreak: number;
chartData: any[];
}; };
loadingStats: boolean; loadingStats: boolean;
history: any[]; history: any[];
@ -79,19 +83,26 @@ const DashboardOverview: React.FC<DashboardOverviewProps> = ({
</div> </div>
{/* Quick Stats in Hero */} {/* Quick Stats in Hero */}
<div className="grid grid-cols-2 gap-4 w-full md:w-auto"> <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 md:min-w-[160px]"> <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-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> <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"> <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> </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-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"> <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> </div>
</div> </div>
@ -99,8 +110,44 @@ const DashboardOverview: React.FC<DashboardOverviewProps> = ({
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 2. Recent History (Main Column) */} {/* 2. Main Column */}
<div className="lg:col-span-2 space-y-6"> <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"> <div className="flex items-center justify-between">
<h2 className="text-xl font-bold text-gray-900 flex items-center gap-2"> <h2 className="text-xl font-bold text-gray-900 flex items-center gap-2">
<TrendingUp className="text-brand-500" size={24} /> <TrendingUp className="text-brand-500" size={24} />
@ -137,6 +184,7 @@ const DashboardOverview: React.FC<DashboardOverviewProps> = ({
</div> </div>
)} )}
</div> </div>
</div>
{/* 3. Side Widget (WhatsApp Connect) */} {/* 3. Side Widget (WhatsApp Connect) */}
<div className="space-y-6"> <div className="space-y-6">

View file

@ -1,13 +1,31 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
interface DailyMacro {
date: string;
calories: number;
protein: number;
carbs: number;
fat: number;
score: number;
}
interface DashboardStats { interface DashboardStats {
totalCount: number; totalCount: number;
avgCals: number; avgCals: number;
currentStreak: number;
longestStreak: number;
chartData: DailyMacro[];
} }
export const useDashboardStats = (userId: string) => { 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 [loadingStats, setLoadingStats] = useState(false);
const fetchStats = async () => { const fetchStats = async () => {
@ -15,7 +33,14 @@ export const useDashboardStats = (userId: string) => {
setLoadingStats(true); setLoadingStats(true);
try { 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 const { count, error: countError } = await supabase
.from('food_analyses') .from('food_analyses')
.select('*', { count: 'exact', head: true }) .select('*', { count: 'exact', head: true })
@ -23,23 +48,69 @@ export const useDashboardStats = (userId: string) => {
if (countError) throw countError; 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 const { data: calData, error: calError } = await supabase
.from('food_analyses') .from('food_analyses')
.select('total_calories') .select('total_calories, total_protein, total_carbs, total_fat, nutrition_score, created_at')
.eq('user_id', userId); .eq('user_id', userId)
.gte('created_at', sevenDaysAgo.toISOString())
.order('created_at', { ascending: true });
if (calError) throw calError; if (calError) throw calError;
let calculatedAvg = 0; let calculatedAvg = 0;
if (calData && calData.length > 0) { const dailyData: Record<string, { cals: number[], prot: number[], carbs: number[], fat: number[], score: number[] }> = {};
const sum = calData.reduce((acc, curr) => acc + (curr.total_calories || 0), 0);
calculatedAvg = Math.round(sum / calData.length); // 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({ setStats({
totalCount: count || 0, totalCount: count || 0,
avgCals: calculatedAvg avgCals: calculatedAvg,
currentStreak: profile?.current_streak || 0,
longestStreak: profile?.longest_streak || 0,
chartData: chartData
}); });
} catch (err) { } catch (err) {

View file

@ -504,6 +504,9 @@ export interface Database {
is_admin: boolean | null is_admin: boolean | null
is_professional: boolean | null is_professional: boolean | null
avatar_url: string | null avatar_url: string | null
current_streak?: number
longest_streak?: number
last_scan_date?: string | null
} }
Insert: { Insert: {
id: string id: string
@ -517,6 +520,9 @@ export interface Database {
is_admin?: boolean | null is_admin?: boolean | null
is_professional?: boolean | null is_professional?: boolean | null
avatar_url?: string | null avatar_url?: string | null
current_streak?: number
longest_streak?: number
last_scan_date?: string | null
} }
Update: { Update: {
id?: string id?: string
@ -530,6 +536,9 @@ export interface Database {
is_admin?: boolean | null is_admin?: boolean | null
is_professional?: boolean | null is_professional?: boolean | null
avatar_url?: string | null avatar_url?: string | null
current_streak?: number
longest_streak?: number
last_scan_date?: string | null
} }
Relationships: [ Relationships: [
{ {

View file

@ -27,11 +27,12 @@ interface DashboardProps {
onLogout: () => void; onLogout: () => void;
onOpenAdmin?: () => void; // Optional prop for admin toggle onOpenAdmin?: () => void; // Optional prop for admin toggle
onOpenPro?: () => void; // Optional prop for professional 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 { 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); const [isCoachWizardOpen, setIsCoachWizardOpen] = useState(false);
// Custom Hooks // Custom Hooks
const { stats, loadingStats } = useDashboardStats(user.id); const { stats, loadingStats } = useDashboardStats(user.id);

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,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;