feat: Add legal pages and Meta API webhook setup
This commit is contained in:
parent
d604322863
commit
0bac63d4db
15 changed files with 562 additions and 86 deletions
82
src/App.tsx
82
src/App.tsx
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, Suspense, lazy } from 'react';
|
||||||
import Header from './components/landing/Header';
|
import Header from './components/landing/Header';
|
||||||
import Hero from './components/landing/Hero';
|
import Hero from './components/landing/Hero';
|
||||||
import CoachHighlight from './components/landing/CoachHighlight';
|
import CoachHighlight from './components/landing/CoachHighlight';
|
||||||
|
|
@ -10,20 +10,47 @@ import FAQ from './components/landing/FAQ';
|
||||||
import Footer from './components/landing/Footer';
|
import Footer from './components/landing/Footer';
|
||||||
import RegistrationModal from './components/modals/RegistrationModal';
|
import RegistrationModal from './components/modals/RegistrationModal';
|
||||||
import CalculatorsModal from './components/modals/CalculatorsModal';
|
import CalculatorsModal from './components/modals/CalculatorsModal';
|
||||||
import Dashboard from './pages/Dashboard';
|
|
||||||
import AdminPanel from './pages/AdminPanel';
|
|
||||||
import ProfessionalDashboard from './pages/ProfessionalDashboard';
|
|
||||||
import FAQPage from './pages/FAQPage';
|
|
||||||
import { LanguageProvider } from './contexts/LanguageContext';
|
import { LanguageProvider } from './contexts/LanguageContext';
|
||||||
import { UserProvider, useUser } from './contexts/UserContext'; // Import UserContext
|
import { UserProvider, useUser } from './contexts/UserContext';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
|
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
|
||||||
|
const ProfessionalDashboard = lazy(() => import('./pages/ProfessionalDashboard'));
|
||||||
|
const FAQPage = lazy(() => import('./pages/FAQPage'));
|
||||||
|
const PrivacyPolicy = lazy(() => import('./pages/legal/PrivacyPolicy'));
|
||||||
|
const TermsOfService = lazy(() => import('./pages/legal/TermsOfService'));
|
||||||
|
const DataDeletion = lazy(() => import('./pages/legal/DataDeletion'));
|
||||||
|
|
||||||
|
export type ViewState = 'home' | 'faq' | 'privacy' | 'terms' | 'data-deletion';
|
||||||
|
|
||||||
const AppContent: React.FC = () => {
|
const AppContent: React.FC = () => {
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isToolsOpen, setIsToolsOpen] = useState(false);
|
const [isToolsOpen, setIsToolsOpen] = useState(false);
|
||||||
const [authMode, setAuthMode] = useState<'login' | 'register'>('register');
|
const [authMode, setAuthMode] = useState<'login' | 'register'>('register');
|
||||||
const [selectedPlan, setSelectedPlan] = useState('starter');
|
const [selectedPlan, setSelectedPlan] = useState('starter');
|
||||||
const [currentView, setCurrentView] = useState<'home' | 'faq'>('home');
|
|
||||||
|
// Custom simple router state based on URL
|
||||||
|
const [currentPath, setCurrentPath] = useState(window.location.pathname);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleLocationChange = () => setCurrentPath(window.location.pathname);
|
||||||
|
window.addEventListener('popstate', handleLocationChange);
|
||||||
|
return () => window.removeEventListener('popstate', handleLocationChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Helper mapping from path to ViewState internally if needed
|
||||||
|
const getCurrentView = (): ViewState => {
|
||||||
|
switch (currentPath) {
|
||||||
|
case '/faq': return 'faq';
|
||||||
|
case '/privacidade': return 'privacy';
|
||||||
|
case '/termos': return 'terms';
|
||||||
|
case '/exclusao-de-dados': return 'data-deletion';
|
||||||
|
default: return 'home';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentView = getCurrentView();
|
||||||
|
|
||||||
// Consume UserContext
|
// Consume UserContext
|
||||||
const {
|
const {
|
||||||
|
|
@ -69,9 +96,16 @@ const AppContent: React.FC = () => {
|
||||||
localStorage.removeItem('login_intent');
|
localStorage.removeItem('login_intent');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function for navigating
|
// Helper function for navigating with real URLs
|
||||||
const handleNavigate = (view: 'home' | 'faq') => {
|
const handleNavigate = (view: ViewState) => {
|
||||||
setCurrentView(view);
|
let path = '/';
|
||||||
|
if (view === 'faq') path = '/faq';
|
||||||
|
if (view === 'privacy') path = '/privacidade';
|
||||||
|
if (view === 'terms') path = '/termos';
|
||||||
|
if (view === 'data-deletion') path = '/exclusao-de-dados';
|
||||||
|
|
||||||
|
window.history.pushState({}, '', path);
|
||||||
|
setCurrentPath(path);
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -85,23 +119,33 @@ const AppContent: React.FC = () => {
|
||||||
|
|
||||||
// Rota Admin
|
// Rota Admin
|
||||||
if (user && isAdminView && user.is_admin) {
|
if (user && isAdminView && user.is_admin) {
|
||||||
return <AdminPanel user={user} onExitAdmin={toggleAdminView} onLogout={logout} />;
|
return (
|
||||||
|
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-white"><Loader2 className="w-8 h-8 animate-spin text-brand-600" /></div>}>
|
||||||
|
<AdminPanel user={user} onExitAdmin={toggleAdminView} onLogout={logout} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rota Profissional
|
// Rota Profissional
|
||||||
if (user && isProfessionalView) {
|
if (user && isProfessionalView) {
|
||||||
return <ProfessionalDashboard user={user} onExit={() => setIsProfessionalView(false)} onLogout={logout} />;
|
return (
|
||||||
|
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-white"><Loader2 className="w-8 h-8 animate-spin text-brand-600" /></div>}>
|
||||||
|
<ProfessionalDashboard user={user} onExit={() => setIsProfessionalView(false)} onLogout={logout} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rota Dashboard Usuário
|
// Rota Dashboard Usuário
|
||||||
if (user) {
|
if (user) {
|
||||||
return (
|
return (
|
||||||
|
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-gray-50"><Loader2 className="w-8 h-8 animate-spin text-brand-600" /></div>}>
|
||||||
<Dashboard
|
<Dashboard
|
||||||
user={user}
|
user={user}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
onOpenAdmin={user.is_admin ? toggleAdminView : undefined}
|
onOpenAdmin={user.is_admin ? toggleAdminView : undefined}
|
||||||
onOpenPro={() => setIsProfessionalView(true)}
|
onOpenPro={() => setIsProfessionalView(true)}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,8 +170,22 @@ const AppContent: React.FC = () => {
|
||||||
<Pricing onRegister={handleOpenRegister} />
|
<Pricing onRegister={handleOpenRegister} />
|
||||||
<FAQ />
|
<FAQ />
|
||||||
</>
|
</>
|
||||||
|
) : currentView === 'privacy' ? (
|
||||||
|
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-white"><Loader2 className="w-8 h-8 animate-spin text-brand-600" /></div>}>
|
||||||
|
<PrivacyPolicy onBack={() => handleNavigate('home')} />
|
||||||
|
</Suspense>
|
||||||
|
) : currentView === 'terms' ? (
|
||||||
|
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-white"><Loader2 className="w-8 h-8 animate-spin text-brand-600" /></div>}>
|
||||||
|
<TermsOfService onBack={() => handleNavigate('home')} />
|
||||||
|
</Suspense>
|
||||||
|
) : currentView === 'data-deletion' ? (
|
||||||
|
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-white"><Loader2 className="w-8 h-8 animate-spin text-brand-600" /></div>}>
|
||||||
|
<DataDeletion onBack={() => handleNavigate('home')} />
|
||||||
|
</Suspense>
|
||||||
) : (
|
) : (
|
||||||
|
<Suspense fallback={<div className="min-h-screen flex items-center justify-center bg-white"><Loader2 className="w-8 h-8 animate-spin text-brand-600" /></div>}>
|
||||||
<FAQPage onBack={() => handleNavigate('home')} />
|
<FAQPage onBack={() => handleNavigate('home')} />
|
||||||
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,16 @@ const AnalysisSection: React.FC<AnalysisSectionProps> = ({ analysis }) => {
|
||||||
{analysis?.posture_analysis || "Nenhum desvio significativo detectado."}
|
{analysis?.posture_analysis || "Nenhum desvio significativo detectado."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{analysis?.evolution_notes && (
|
||||||
|
<div className="mt-6 border-t border-gray-100 pt-6">
|
||||||
|
<h4 className="font-bold text-brand-700 mb-2 flex items-center gap-2">
|
||||||
|
<Trophy size={18} className="text-brand-500" /> Comparativo de Evolução
|
||||||
|
</h4>
|
||||||
|
<p className="text-gray-700 leading-relaxed bg-brand-50 p-4 rounded-xl border border-brand-100 text-sm font-medium">
|
||||||
|
{analysis.evolution_notes}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="Pontos Chave" icon={<Trophy className="text-yellow-500" />}>
|
<Card title="Pontos Chave" icon={<Trophy className="text-yellow-500" />}>
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,12 @@ interface CoachWizardProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onComplete: (data: any) => void;
|
onComplete: (data: any) => void;
|
||||||
|
coachHistory?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type Step = 'photos' | 'goal' | 'processing';
|
type Step = 'photos' | 'goal' | 'processing';
|
||||||
|
|
||||||
const CoachWizard: React.FC<CoachWizardProps> = ({ isOpen, onClose, onComplete }) => {
|
const CoachWizard: React.FC<CoachWizardProps> = ({ isOpen, onClose, onComplete, coachHistory = [] }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [step, setStep] = useState<Step>('photos');
|
const [step, setStep] = useState<Step>('photos');
|
||||||
const [photos, setPhotos] = useState<{ front?: string, side?: string, back?: string }>({});
|
const [photos, setPhotos] = useState<{ front?: string, side?: string, back?: string }>({});
|
||||||
|
|
@ -114,8 +115,31 @@ const CoachWizard: React.FC<CoachWizardProps> = ({ isOpen, onClose, onComplete }
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Extrair contexto histórico
|
||||||
|
let last_evaluation = '';
|
||||||
|
if (coachHistory && coachHistory.length > 0) {
|
||||||
|
const lastRecord = coachHistory[0]; // Assumindo ordenado por mais recente
|
||||||
|
|
||||||
|
// Parse AI structured para extrair os dados importantes
|
||||||
|
let parsedAi = null;
|
||||||
|
if (typeof lastRecord.ai_structured === 'string') {
|
||||||
|
try { parsedAi = JSON.parse(lastRecord.ai_structured); } catch (e) { }
|
||||||
|
} else {
|
||||||
|
parsedAi = lastRecord.ai_structured;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedAi && parsedAi.analysis) {
|
||||||
|
const bf = parsedAi.analysis.body_fat_percentage;
|
||||||
|
const date = new Date(lastRecord.created_at).toLocaleDateString('pt-BR');
|
||||||
|
|
||||||
|
last_evaluation = `Avaliação Anterior (${date}): `;
|
||||||
|
if (bf) last_evaluation += `Percentual de Gordura Estimado: ${bf}%. `;
|
||||||
|
if (parsedAi.analysis.muscle_mass_level) last_evaluation += `Massa Muscular: ${parsedAi.analysis.muscle_mass_level}. `;
|
||||||
|
if (parsedAi.analysis.strengths) last_evaluation += `Pontos Fortes: ${parsedAi.analysis.strengths.join(', ')}. `;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create a timeout promise that rejects after 55 seconds
|
// Create a timeout promise that rejects after 55 seconds
|
||||||
const timeoutPromise = new Promise((_, reject) => {
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
const id = setTimeout(() => {
|
const id = setTimeout(() => {
|
||||||
|
|
@ -124,10 +148,15 @@ const CoachWizard: React.FC<CoachWizardProps> = ({ isOpen, onClose, onComplete }
|
||||||
}, 55000); // 55s strict timeout
|
}, 55000); // 55s strict timeout
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const payload: any = { photos, goal, intent: 'coach' };
|
||||||
|
if (last_evaluation) {
|
||||||
|
payload.last_evaluation = last_evaluation;
|
||||||
|
}
|
||||||
|
|
||||||
// Race between the API call and the timeout
|
// Race between the API call and the timeout
|
||||||
const response: any = await Promise.race([
|
const response: any = await Promise.race([
|
||||||
supabase.functions.invoke('coach-generator', {
|
supabase.functions.invoke('coach-generator', {
|
||||||
body: { photos, goal, intent: 'coach' }
|
body: payload
|
||||||
}),
|
}),
|
||||||
timeoutPromise
|
timeoutPromise
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useLanguage } from '@/contexts/LanguageContext';
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
onRegister: () => void;
|
onRegister: () => void;
|
||||||
onNavigate?: (view: 'home' | 'faq') => void; // Optional prop to support navigation
|
onNavigate?: (view: 'home' | 'faq' | 'privacy' | 'terms' | 'data-deletion') => void; // Optional prop to support navigation
|
||||||
}
|
}
|
||||||
|
|
||||||
const Footer: React.FC<FooterProps> = ({ onRegister, onNavigate }) => {
|
const Footer: React.FC<FooterProps> = ({ onRegister, onNavigate }) => {
|
||||||
|
|
@ -81,9 +81,21 @@ const Footer: React.FC<FooterProps> = ({ onRegister, onNavigate }) => {
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-white font-semibold mb-6 text-sm uppercase tracking-wider">{t.footer.legal}</h3>
|
<h3 className="text-white font-semibold mb-6 text-sm uppercase tracking-wider">{t.footer.legal}</h3>
|
||||||
<ul className="space-y-3 text-sm">
|
<ul className="space-y-3 text-sm">
|
||||||
<li><a href="#" className="hover:text-brand-400 transition-colors">Termos de Uso</a></li>
|
<li>
|
||||||
<li><a href="#" className="hover:text-brand-400 transition-colors">Privacidade</a></li>
|
<button onClick={(e) => { e.preventDefault(); if (onNavigate) onNavigate('terms'); }} className="hover:text-brand-400 transition-colors text-left">
|
||||||
<li><a href="#" className="hover:text-brand-400 transition-colors">Disclaimer</a></li>
|
Termos de Uso
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button onClick={(e) => { e.preventDefault(); if (onNavigate) onNavigate('privacy'); }} className="hover:text-brand-400 transition-colors text-left">
|
||||||
|
Política de Privacidade
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button onClick={(e) => { e.preventDefault(); if (onNavigate) onNavigate('data-deletion'); }} className="hover:text-brand-400 transition-colors text-left">
|
||||||
|
Exclusão de Dados
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -88,41 +88,41 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
// Função única de inicialização para evitar race conditions
|
const handleSession = async (session: any) => {
|
||||||
const initSession = async () => {
|
|
||||||
try {
|
|
||||||
const { data: { session }, error } = await supabase.auth.getSession();
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
if (session?.user) {
|
if (session?.user) {
|
||||||
if (mounted) await fetchUserProfile(session.user.id, session.user.email);
|
await fetchUserProfile(session.user.id, session.user.email);
|
||||||
}
|
} else {
|
||||||
} catch (error) {
|
setUser(null);
|
||||||
console.error("UserContext: Falha na sessão inicial", error);
|
setLoading(false);
|
||||||
} finally {
|
|
||||||
if (mounted) setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initSession();
|
|
||||||
|
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||||
console.log(`UserContext Auth Event: ${event}`);
|
console.log(`UserContext Auth Event: ${event}`);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (event === 'SIGNED_OUT') {
|
if (event === 'INITIAL_SESSION') {
|
||||||
|
await handleSession(session);
|
||||||
|
} else if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
|
||||||
|
await handleSession(session);
|
||||||
|
} else if (event === 'SIGNED_OUT') {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsAdminView(false);
|
setIsAdminView(false);
|
||||||
setIsProfessionalView(false);
|
setIsProfessionalView(false);
|
||||||
setIsCompletingProfile(false);
|
setIsCompletingProfile(false);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} else if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
|
|
||||||
// Apenas recarrega se necessário.
|
|
||||||
// Para simplificar e garantir dados frescos, recarregamos.
|
|
||||||
if (session?.user) {
|
|
||||||
await fetchUserProfile(session.user.id, session.user.email);
|
|
||||||
}
|
}
|
||||||
setLoading(false);
|
});
|
||||||
|
|
||||||
|
// Initialize session manually in case INITIAL_SESSION doesn't trigger
|
||||||
|
// (sometimes needed depending on supabase-js version and local storage state)
|
||||||
|
supabase.auth.getSession().then(({ data: { session }, error }) => {
|
||||||
|
if (error) {
|
||||||
|
console.error("UserContext: Falha na sessão inicial", error);
|
||||||
|
if (mounted) setLoading(false);
|
||||||
|
} else if (!session) {
|
||||||
|
// Se não há sessão e onAuthStateChange não disparou INITIAL_SESSION
|
||||||
|
if (mounted) setLoading(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
84
src/pages/legal/DataDeletion.tsx
Normal file
84
src/pages/legal/DataDeletion.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { ArrowLeft, Trash2, Mail } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DataDeletionProps {
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataDeletion: React.FC<DataDeletionProps> = ({ onBack }) => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-3xl mx-auto bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center text-sm text-gray-500 hover:text-brand-600 mb-8 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Voltar para Home
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="p-3 bg-red-100 text-red-600 rounded-xl">
|
||||||
|
<Trash2 className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">Exclusão de Dados</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 mb-8">Última atualização: {new Date().toLocaleDateString('pt-BR')}</p>
|
||||||
|
|
||||||
|
<div className="prose prose-brand max-w-none text-gray-600 space-y-6">
|
||||||
|
<p className="text-lg text-gray-700">
|
||||||
|
De acordo com o Regulamento Geral sobre a Proteção de Dados (GDPR), a Lei Geral de Proteção de Dados Pessoais do Brasil (LGPD) e as políticas da Apple e da Meta, você tem o direito de solicitar a exclusão completa de todos os seus dados armazenados pela nossa plataforma.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section className="bg-gray-50 p-6 rounded-xl border border-gray-200 mt-8">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-4 flex items-center gap-2">
|
||||||
|
Como Solicitar a Exclusão?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="mb-4">
|
||||||
|
A exclusão de dados do "FoodSnap" engloba a deleção completa de:
|
||||||
|
</p>
|
||||||
|
<ul className="list-disc pl-5 mt-2 space-y-2 mb-6">
|
||||||
|
<li>Sua conta da plataforma Web (Email e Senha).</li>
|
||||||
|
<li>Todo o seu histórico de peso, perfil físico e objetivos registrados.</li>
|
||||||
|
<li>Ligação e histórico de WhatsApp do nosso banco de dados.</li>
|
||||||
|
<li>Dietas geradas e fotos de pratos não anonimizadas.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mt-6 mb-2">Método 1: Pela Plataforma</h3>
|
||||||
|
<p className="mb-4">
|
||||||
|
Se você possui uma conta de acesso na Dashboard:
|
||||||
|
<br />
|
||||||
|
1. Faça Login em no FoodSnap. <br />
|
||||||
|
2. Navegue até "Meu Perfil" no canto superior direito. <br />
|
||||||
|
3. Rolando até o fim, clique no botão vermelho "Excluir Minha Conta Permanentemente".
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mt-6 mb-2 flex items-center gap-2">
|
||||||
|
<Mail className="w-5 h-5" />
|
||||||
|
Método 2: Por E-Mail (Suporte)
|
||||||
|
</h3>
|
||||||
|
<p>
|
||||||
|
Você também pode optar por enviar um e-mail direto para nossa equipe de dados solicitando a exclusão manual.
|
||||||
|
<br /><br />
|
||||||
|
<strong>E-mail de Contato:</strong> <em>privacidade@foodsnap.com.br</em>
|
||||||
|
<br />(ou o email principal de suporte do aplicativo).
|
||||||
|
<br /><br />
|
||||||
|
No corpo do e-mail, inclua seu nome, número de telefone cadastrado no app e email associado. O prazo máximo para o processamento deste tipo de pedido de deleção é de 7 dias úteis.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-8">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">O que acontece após a Exclusão?</h2>
|
||||||
|
<p>
|
||||||
|
Assim que seus dados forem apagados, você perderá acesso ao seu histórico de dietas e treinos. Essa ação é irreversível. Por questões de exigências e documentações fiscais/contábeis (como recibos de planos adquiridos do Stripe), partes da sua transação financeira poderão ser retidas sob obrigações da legislação legal de armazenamento, mas desassociadas do seu uso diário do aplicativo.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DataDeletion;
|
||||||
77
src/pages/legal/PrivacyPolicy.tsx
Normal file
77
src/pages/legal/PrivacyPolicy.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PrivacyPolicyProps {
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PrivacyPolicy: React.FC<PrivacyPolicyProps> = ({ onBack }) => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-3xl mx-auto bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center text-sm text-gray-500 hover:text-brand-600 mb-8 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Voltar para Home
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">Política de Privacidade</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-8">Última atualização: {new Date().toLocaleDateString('pt-BR')}</p>
|
||||||
|
|
||||||
|
<div className="prose prose-brand max-w-none text-gray-600 space-y-6">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">1. Introdução</h2>
|
||||||
|
<p>
|
||||||
|
A sua privacidade é importante para nós. Esta Política de Privacidade explica como o FoodSnap coleta, usa, compartilha e protege as suas informações pessoais quando você utiliza nossos serviços, site e integração com o WhatsApp.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">2. Coleta de Dados</h2>
|
||||||
|
<p>Os tipos de informações que coletamos incluem:</p>
|
||||||
|
<ul className="list-disc pl-5 mt-2 space-y-2">
|
||||||
|
<li><strong>Informações de Contato:</strong> Número de telefone do WhatsApp para o envio das análises.</li>
|
||||||
|
<li><strong>Dados de Análise:</strong> Imagens de alimentos enviadas voluntariamente pelo usuário para processamento pela nossa Inteligência Artificial.</li>
|
||||||
|
<li><strong>Dados de Perfil:</strong> Altura, peso, gênero e objetivos, caso fornecidos para o plano alimentar.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">3. Como Usamos Seus Dados</h2>
|
||||||
|
<p>Nós utilizamos os dados coletados para:</p>
|
||||||
|
<ul className="list-disc pl-5 mt-2 space-y-2">
|
||||||
|
<li>Processar e estimar as calorias dos pratos enviados via IA.</li>
|
||||||
|
<li>Fornecer respostas diretas no WhatsApp pelo modelo do Meta Cloud API.</li>
|
||||||
|
<li>Personalizar a sua experiência e ajustar nossos treinos e dietas gerados por IA.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">4. Compartilhamento e Serviços de Terceiros</h2>
|
||||||
|
<p>
|
||||||
|
Suas imagens e textos podem ser processados com segurança por provedores de IA confiáveis (como Google Gemini) exclusivamente para o ato de gerar os resultados. Não vendemos suas informações para terceiros para fins publicitários em hipótese alguma.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">5. Segurança</h2>
|
||||||
|
<p>
|
||||||
|
Empregamos medidas de segurança técnicas e organizacionais para proteger as informações pessoais contra acesso, uso e divulgação não autorizados, em conformidade com as diretrizes da Meta e provedores de nuvem (Supabase).
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">6. Contato</h2>
|
||||||
|
<p>
|
||||||
|
Se você tiver dúvidas sobre esta Política de Privacidade, entre em contato através dos nossos canais oficiais de suporte disponíveis no nosso site.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PrivacyPolicy;
|
||||||
74
src/pages/legal/TermsOfService.tsx
Normal file
74
src/pages/legal/TermsOfService.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TermsOfServiceProps {
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TermsOfService: React.FC<TermsOfServiceProps> = ({ onBack }) => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-3xl mx-auto bg-white p-8 rounded-2xl shadow-sm border border-gray-100">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center text-sm text-gray-500 hover:text-brand-600 mb-8 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Voltar para Home
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">Termos de Serviço</h1>
|
||||||
|
<p className="text-sm text-gray-500 mb-8">Última atualização: {new Date().toLocaleDateString('pt-BR')}</p>
|
||||||
|
|
||||||
|
<div className="prose prose-brand max-w-none text-gray-600 space-y-6">
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">1. Aceitação dos Termos</h2>
|
||||||
|
<p>
|
||||||
|
Ao acessar ou utilizar a plataforma FoodSnap via site, aplicativo ou integração de WhatsApp, você confirma que leu, compreendeu e concorda em ficar vinculado a estes Termos de Serviço. Se não concordar, você não deve usar nossos serviços.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">2. Descrição dos Serviços</h2>
|
||||||
|
<p>
|
||||||
|
O FoodSnap é uma plataforma que utiliza inteligência artificial para análise de imagens de pratos visando a estimativa de calorias e macros, e a geração de planos de exercícios e dieta. O envio pode ser feito primariamente pela interface web ou pelo nosso robô oficial (Cloud API) do WhatsApp.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Nota médica: As sugestões de dieta e calorias oferecidas pelo sistema baseiam-se em modelos matemáticos e de IA. <strong>Eles não substituem orientação de médicos e nutricionistas reais.</strong>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">3. Contas de Usuário</h2>
|
||||||
|
<p>
|
||||||
|
Para acessar certos recursos, inclusive planos PRO, relatórios aprimorados e limites mais altos, você pode ser solicitado a criar uma conta. Você é responsável por manter a confidencialidade de sua senha (quando aplicável) e atividades. Contas podem ser encerradas ou limitadas se o serviço for abusado.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">4. Conduta no WhatsApp e Site</h2>
|
||||||
|
<p>
|
||||||
|
Ao usar a integração do WhatsApp, você concorda em usar os recursos apenas para o fim de escanear pratos e conversar com o "Coach" sobre treinos e dietas. Abuso visual (envio de imagens pornográficas, de ódio ou ilegais) para o modelo de IA pode resultar no banimento imediato e sem reembolso do número do WhatsApp e conta.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">5. Assinaturas e Pagamentos</h2>
|
||||||
|
<p>
|
||||||
|
Em compras do "Plano PRO", o acesso às funcionalidades premium é fornecido enquanto a respectiva assinatura ou compra estiver ativa e/ou válida conforme definido no checkout. Os valores e recorrências serão indicados em nosso "checkout".
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mt-8 mb-4">6. Limitação de Responsabilidade</h2>
|
||||||
|
<p>
|
||||||
|
Em nenhuma circunstância o FoodSnap se responsabilizará por danos diretos, indiretos, perdas de lucros ou físicos causados pela adoção imprudente dos treinos sugeridos ou lesões ocorridas. A adoção dos modelos é por conta e risco do usuário, avaliando sua própria saúde prévia.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TermsOfService;
|
||||||
11
supabase/config.toml
Normal file
11
supabase/config.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
|
||||||
|
[functions.meta-whatsapp-webhook]
|
||||||
|
enabled = true
|
||||||
|
verify_jwt = true
|
||||||
|
import_map = "./functions/meta-whatsapp-webhook/deno.json"
|
||||||
|
# Uncomment to specify a custom file path to the entrypoint.
|
||||||
|
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
|
||||||
|
entrypoint = "./functions/meta-whatsapp-webhook/index.ts"
|
||||||
|
# Specifies static files to be bundled with the function. Supports glob patterns.
|
||||||
|
# For example, if you want to serve static HTML pages in your function:
|
||||||
|
# static_files = [ "./functions/meta-whatsapp-webhook/*.html" ]
|
||||||
|
|
@ -14,7 +14,7 @@ serve(async (req) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { photos, goal } = await req.json();
|
const { photos, goal, last_evaluation } = await req.json();
|
||||||
|
|
||||||
if (!photos || (!photos.front && !photos.side && !photos.back)) {
|
if (!photos || (!photos.front && !photos.side && !photos.back)) {
|
||||||
throw new Error("Pelo menos uma foto é necessária.");
|
throw new Error("Pelo menos uma foto é necessária.");
|
||||||
|
|
@ -31,8 +31,14 @@ serve(async (req) => {
|
||||||
// System Prompt
|
// System Prompt
|
||||||
parts.push({ text: COACH_SYSTEM_PROMPT });
|
parts.push({ text: COACH_SYSTEM_PROMPT });
|
||||||
|
|
||||||
// User Goal
|
// User Goal & History
|
||||||
parts.push({ text: `Objetivo do Usuário: ${goal}\nAnalise as fotos e gere o protocolo.` });
|
let userPrompt = `Objetivo do Usuário: ${goal}\n`;
|
||||||
|
if (last_evaluation) {
|
||||||
|
userPrompt += `\nHistórico (Última Avaliação do Usuário): ${last_evaluation}\nAnalise as fotos comparando o físico atual com esse histórico e explique as mudanças notadas.\n`;
|
||||||
|
} else {
|
||||||
|
userPrompt += `\nAnalise as fotos e gere o protocolo inicial.\n`;
|
||||||
|
}
|
||||||
|
parts.push({ text: userPrompt });
|
||||||
|
|
||||||
// Images
|
// Images
|
||||||
for (const [key, value] of Object.entries(photos)) {
|
for (const [key, value] of Object.entries(photos)) {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ Formato de Resposta (Siga estritamente esta estrutura):
|
||||||
"somatotype": "Ectomorfo" | "Mesomorfo" | "Endomorfo",
|
"somatotype": "Ectomorfo" | "Mesomorfo" | "Endomorfo",
|
||||||
"muscle_mass_level": "Baixo" | "Médio" | "Alto",
|
"muscle_mass_level": "Baixo" | "Médio" | "Alto",
|
||||||
"posture_analysis": "Texto detalhado sobre postura (ex: leve cifose, lordose, desvios laterais)",
|
"posture_analysis": "Texto detalhado sobre postura (ex: leve cifose, lordose, desvios laterais)",
|
||||||
|
"evolution_notes": "Comparação detalhada e motivacional com a avaliação anterior (se enviada) ou dicas para progresso se for a primeira vez.",
|
||||||
"strengths": ["Ombros largos", "Cintura fina", "Bons quadríceps"],
|
"strengths": ["Ombros largos", "Cintura fina", "Bons quadríceps"],
|
||||||
"weaknesses": ["Panturrilhas pouco desenvolvidas", "Peitoral superior fraco"]
|
"weaknesses": ["Panturrilhas pouco desenvolvidas", "Peitoral superior fraco"]
|
||||||
},
|
},
|
||||||
|
|
@ -97,4 +98,5 @@ Regras IMPORTANTES:
|
||||||
4. Adapte o treino ao biotipo (ex: Ectomorfo menos volume, Endomorfo mais cardio).
|
4. Adapte o treino ao biotipo (ex: Ectomorfo menos volume, Endomorfo mais cardio).
|
||||||
5. Nos suplementos, especifique COMO tomar e PORQUE.
|
5. Nos suplementos, especifique COMO tomar e PORQUE.
|
||||||
6. A resposta DEVE ser um JSON válido.
|
6. A resposta DEVE ser um JSON válido.
|
||||||
|
7. Se um histórico (Última Avaliação) for fornecido no objetivo do usuário, compare o físico atual com os dados anteriores e preencha o campo "evolution_notes" com um parecer técnico e motivacional sobre as mudanças reais notadas nas fotos versus o histórico. Estime a redução ou aumento de medidas ou gordura com precisão baseada em referências anatômicas. A estimativa de body_fat_percentage deve refletir o julgamento visual crítico de um especialista.
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
3
supabase/functions/meta-whatsapp-webhook/.npmrc
Normal file
3
supabase/functions/meta-whatsapp-webhook/.npmrc
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Configuration for private npm package dependencies
|
||||||
|
# For more information on using private registries with Edge Functions, see:
|
||||||
|
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries
|
||||||
5
supabase/functions/meta-whatsapp-webhook/deno.json
Normal file
5
supabase/functions/meta-whatsapp-webhook/deno.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"@supabase/functions-js": "jsr:@supabase/functions-js@^2"
|
||||||
|
}
|
||||||
|
}
|
||||||
46
supabase/functions/meta-whatsapp-webhook/index.ts
Normal file
46
supabase/functions/meta-whatsapp-webhook/index.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
|
||||||
|
|
||||||
|
const META_VERIFY_TOKEN = Deno.env.get("META_VERIFY_TOKEN") || "foodsnap_meta_webhook_2026";
|
||||||
|
const META_ACCESS_TOKEN = Deno.env.get("META_ACCESS_TOKEN") || "";
|
||||||
|
const META_PHONE_NUMBER_ID = Deno.env.get("META_PHONE_NUMBER_ID") || "";
|
||||||
|
|
||||||
|
serve(async (req) => {
|
||||||
|
// ── 1. Rota Obrigatória: Verificação do Webhook (GET) ───────────
|
||||||
|
if (req.method === "GET") {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const mode = url.searchParams.get("hub.mode");
|
||||||
|
const token = url.searchParams.get("hub.verify_token");
|
||||||
|
const challenge = url.searchParams.get("hub.challenge");
|
||||||
|
|
||||||
|
if (mode === "subscribe" && token === META_VERIFY_TOKEN) {
|
||||||
|
console.log("[META-WH] Webhook verificado com sucesso!");
|
||||||
|
return new Response(challenge, { status: 200 });
|
||||||
|
} else {
|
||||||
|
console.error("[META-WH] Falha na verificação. Token inválido.");
|
||||||
|
return new Response("Forbidden", { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Rota Principal de Mensagem (POST) ────────────────────────
|
||||||
|
if (req.method === "POST") {
|
||||||
|
try {
|
||||||
|
const payload = await req.json();
|
||||||
|
|
||||||
|
// Validação básica do Payload da Graph API
|
||||||
|
if (payload.object !== "whatsapp_business_account") {
|
||||||
|
return new Response("Ignored", { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[META-WH] Payload Recebido:", JSON.stringify(payload, null, 2));
|
||||||
|
|
||||||
|
// Aqui implantaremos a leitura das mensagens, imagens e disparo do bot em breve.
|
||||||
|
|
||||||
|
return new Response("OK", { status: 200 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[META-WH] Erro interno:", err);
|
||||||
|
return new Response("Internal Server Error", { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Method Not Allowed", { status: 405 });
|
||||||
|
});
|
||||||
|
|
@ -97,6 +97,55 @@ async function sendWhatsAppMessage(remoteJid: string, text: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Envia mensagem interativa CTA (Botão de Link) via Evolution API com Fallback */
|
||||||
|
async function sendWhatsAppInteractiveMessage(remoteJid: string, text: string, buttonText: string, linkUrl: string) {
|
||||||
|
if (!EVOLUTION_API_URL) {
|
||||||
|
console.error("[WH] EVOLUTION_API_URL not set! Cannot send interactive message.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = `${EVOLUTION_API_URL}/message/sendInteractive/${INSTANCE_NAME}`;
|
||||||
|
console.log(`[WH] Sending interactive msg to ${remoteJid.slice(0, 8)}... via ${url}`);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
number: remoteJid,
|
||||||
|
options: { delay: 1200, presence: "composing" },
|
||||||
|
interactiveMessage: {
|
||||||
|
body: { text: text },
|
||||||
|
footer: { text: "FoodSnap PRO" },
|
||||||
|
nativeFlowMessage: {
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
name: "cta_url",
|
||||||
|
buttonParamsJson: JSON.stringify({
|
||||||
|
display_text: buttonText,
|
||||||
|
url: linkUrl
|
||||||
|
})
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", apikey: EVOLUTION_API_KEY },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn(`[WH] Interactive msg failed (${res.status}). Falling back to sendText.`);
|
||||||
|
await sendWhatsAppMessage(remoteJid, `${text}\n\n👉 Acesse: ${linkUrl}`);
|
||||||
|
} else {
|
||||||
|
const resBody = await res.text();
|
||||||
|
console.log(`[WH] Evolution sendInteractive response: ${res.status} ${resBody.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[WH] Error sending interactive msg, falling back:", err);
|
||||||
|
await sendWhatsAppMessage(remoteJid, `${text}\n\n👉 Acesse: ${linkUrl}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Envia documento (PDF) via Evolution API */
|
/** Envia documento (PDF) via Evolution API */
|
||||||
async function sendWhatsAppDocument(remoteJid: string, mediaUrl: string, fileName: string, caption?: string) {
|
async function sendWhatsAppDocument(remoteJid: string, mediaUrl: string, fileName: string, caption?: string) {
|
||||||
if (!EVOLUTION_API_URL) {
|
if (!EVOLUTION_API_URL) {
|
||||||
|
|
@ -369,7 +418,7 @@ function parseAndCleanGeminiResponse(rawText: string): any {
|
||||||
*/
|
*/
|
||||||
function formatWhatsAppResponse(analysis: any): string {
|
function formatWhatsAppResponse(analysis: any): string {
|
||||||
if (!analysis || !Array.isArray(analysis.items) || !analysis.items.length) {
|
if (!analysis || !Array.isArray(analysis.items) || !analysis.items.length) {
|
||||||
return "Não foi possível identificar um alimento válido na imagem.";
|
return "⚠️ Não foi possível identificar um alimento válido na imagem. Tente uma foto com melhor iluminação ou de um ângulo diferente.";
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = analysis.items;
|
const items = analysis.items;
|
||||||
|
|
@ -385,41 +434,45 @@ function formatWhatsAppResponse(analysis: any): string {
|
||||||
const v = (x: unknown) => (x === undefined || x === null || x === "" ? "—" : x);
|
const v = (x: unknown) => (x === undefined || x === null || x === "" ? "—" : x);
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
lines.push("🥗 *RELATÓRIO PRATOFIT*");
|
lines.push("✨ *RELATÓRIO FOODSNAP* ✨");
|
||||||
|
lines.push("━━━━━━━━━━━━━━━━━━━━");
|
||||||
|
|
||||||
|
lines.push(`🔥 *Energia Total:* ${fmt(total.calories)} kcal`);
|
||||||
|
if (analysis.health_score !== undefined) {
|
||||||
|
const score = Number(analysis.health_score);
|
||||||
|
let scoreEmoji = "🟢"; // High score
|
||||||
|
if (score < 50) scoreEmoji = "🔴";
|
||||||
|
else if (score < 80) scoreEmoji = "🟡";
|
||||||
|
lines.push(`🏆 *Score Nutricional:* ${fmt(score)}/100 ${scoreEmoji}`);
|
||||||
|
}
|
||||||
|
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push("*Itens identificados*");
|
lines.push("🧬 *MACRONUTRIENTES*");
|
||||||
items.forEach((it: any, idx: number) => {
|
lines.push(`🥩 *Proteínas:* ${fmt(total.protein)}g`);
|
||||||
lines.push(`${idx + 1}) ${v(it.name)} — ${v(it.portion)} — ${fmt(it.calories)} kcal`);
|
lines.push(`🍞 *Carboidratos:* ${fmt(total.carbs)}g`);
|
||||||
|
lines.push(`🥑 *Gorduras:* ${fmt(total.fat)}g`);
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("📊 *DETALHE DOS ITENS*");
|
||||||
|
items.forEach((it: any) => {
|
||||||
|
lines.push(`▪️ *${v(it.name)}* _(${v(it.portion)})_ ➔ ${fmt(it.calories)} kcal`);
|
||||||
});
|
});
|
||||||
|
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push("*Total do prato*");
|
lines.push("📌 *FIBRAS & EXTRAS*");
|
||||||
lines.push(`Energia: ${fmt(total.calories)} kcal`);
|
lines.push(`🌾 Fibras: ${fmt(total.fiber)}g | 🍬 Açúcares: ${fmt(total.sugar)}g | 🧂 Sódio: ${fmt(total.sodium_mg)}mg`);
|
||||||
lines.push("");
|
|
||||||
lines.push("*Macronutrientes (total)*");
|
|
||||||
lines.push(`Proteínas: ${fmt(total.protein)} g`);
|
|
||||||
lines.push(`Carboidratos: ${fmt(total.carbs)} g`);
|
|
||||||
lines.push(`Gorduras: ${fmt(total.fat)} g`);
|
|
||||||
lines.push("");
|
|
||||||
lines.push("*Outros nutrientes (total)*");
|
|
||||||
lines.push(`Fibras: ${fmt(total.fiber)} g`);
|
|
||||||
lines.push(`Açúcares: ${fmt(total.sugar)} g`);
|
|
||||||
lines.push(`Sódio: ${fmt(total.sodium_mg)} mg`);
|
|
||||||
|
|
||||||
if (analysis.health_score !== undefined) {
|
|
||||||
lines.push(`Score nutricional: ${fmt(analysis.health_score)} / 100`);
|
|
||||||
}
|
|
||||||
if (analysis.confidence) {
|
|
||||||
lines.push(`Confiabilidade: ${String(analysis.confidence).toLowerCase()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("");
|
|
||||||
|
|
||||||
if (analysis.tip && analysis.tip.text) {
|
if (analysis.tip && analysis.tip.text) {
|
||||||
lines.push("💡 *Dica prática*");
|
lines.push("━━━━━━━━━━━━━━━━━━━━");
|
||||||
lines.push(analysis.tip.text);
|
lines.push("💡 *Dica de Nutrição*");
|
||||||
|
lines.push(`_${analysis.tip.text}_`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lines.push("");
|
||||||
|
lines.push("━━━━━━━━━━━━━━━━━━━━");
|
||||||
|
lines.push("🏋️♂️ *Quer um plano completo?*");
|
||||||
|
lines.push("Digite *Coach* para iniciar a IA.");
|
||||||
|
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -509,9 +562,11 @@ serve(async (req) => {
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
console.log(`[WH] User NOT found for candidates: ${phoneCandidates.join(", ")}`);
|
console.log(`[WH] User NOT found for candidates: ${phoneCandidates.join(", ")}`);
|
||||||
await sendWhatsAppMessage(
|
await sendWhatsAppInteractiveMessage(
|
||||||
remoteJid,
|
remoteJid,
|
||||||
"🚫 *Acesso restrito*\nSeu número não está cadastrado no *FoodSnap*.\n\nCadastre-se em: https://foodsnap.com.br\n\nApós o cadastro, envie novamente a foto do prato 🍽️"
|
"🚫 *Seu número não está cadastrado no FoodSnap*.\n\nPara usar a inteligência artificial via WhatsApp, você precisa ter uma conta ativa e o seu celular atualizado no perfil.",
|
||||||
|
"📲 Criar Conta Grátis",
|
||||||
|
"https://foodsnap.com.br"
|
||||||
);
|
);
|
||||||
return new Response("User not found", { status: 200 });
|
return new Response("User not found", { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
@ -558,9 +613,11 @@ serve(async (req) => {
|
||||||
(!entitlement.valid_until || new Date(entitlement.valid_until) > new Date());
|
(!entitlement.valid_until || new Date(entitlement.valid_until) > new Date());
|
||||||
|
|
||||||
if (!isPaid) {
|
if (!isPaid) {
|
||||||
await sendWhatsAppMessage(
|
await sendWhatsAppInteractiveMessage(
|
||||||
remoteJid,
|
remoteJid,
|
||||||
"🔒 *Funcionalidade Exclusiva PRO*\n\nO *Personal Coach IA* está disponível apenas para assinantes PRO.\n\nCom o plano PRO você tem:\n✅ Treinos personalizados\n✅ Dieta sob medida\n✅ Ajustes mensais\n\nFaça o upgrade agora em: https://foodsnap.com.br"
|
"🔒 *Funcionalidade Exclusiva PRO*\n\nO *Personal Coach IA* está disponível apenas para assinantes Premium.\n\nCom o plano PRO você tem:\n✅ IA Analisadora de Físico (Fotos)\n✅ Treinos hiper-personalizados\n✅ Estratégia de Dieta com Macros",
|
||||||
|
"⭐ Desbloquear Coach",
|
||||||
|
"https://foodsnap.com.br"
|
||||||
);
|
);
|
||||||
return new Response("Coach Blocked (Free)", { status: 200 });
|
return new Response("Coach Blocked (Free)", { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
@ -848,9 +905,11 @@ serve(async (req) => {
|
||||||
.eq("used_free_quota", true);
|
.eq("used_free_quota", true);
|
||||||
|
|
||||||
if ((freeUsed || 0) >= FREE_FOOD_LIMIT) {
|
if ((freeUsed || 0) >= FREE_FOOD_LIMIT) {
|
||||||
await sendWhatsAppMessage(
|
await sendWhatsAppInteractiveMessage(
|
||||||
remoteJid,
|
remoteJid,
|
||||||
`🚫 Limite gratuito atingido\nVocê já usou suas ${FREE_FOOD_LIMIT} análises grátis.\n\nPara continuar, assine um plano em:\nhttps://foodsnap.com.br\n\nDepois é só enviar outra foto 📸`
|
`🚫 *Limite gratuito atingido*\nVocê já usou suas ${FREE_FOOD_LIMIT} análises grátis.\n\nAssine o plano PRO para escaneamento de alimentos e uso ilimitado do bot inteligente.`,
|
||||||
|
"🚀 Assinar Plano PRO",
|
||||||
|
"https://foodsnap.com.br"
|
||||||
);
|
);
|
||||||
return new Response("Quota exceeded", { status: 200 });
|
return new Response("Quota exceeded", { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue