Compare commits
No commits in common. "1939d2d7779f2e7c79cd877286fc2389b725a7a7" and "5758504d4ebfb850b68bd900a20c7ddcd31364a6" have entirely different histories.
1939d2d777
...
5758504d4e
18 changed files with 318 additions and 4701 deletions
2
.env
2
.env
|
|
@ -1,2 +0,0 @@
|
||||||
VITE_SUPABASE_URL=https://mnhgpnqkwuqzpvfrwftp.supabase.co
|
|
||||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1uaGdwbnFrd3VxenB2ZnJ3ZnRwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjU3MTk4NTUsImV4cCI6MjA4MTI5NTg1NX0.DBYmhgiZoCmA0AlejJRsTh85HxRDEnG_ihkEQ2cXcpk
|
|
||||||
BIN
foodsnap-main.rar
Normal file
BIN
foodsnap-main.rar
Normal file
Binary file not shown.
BIN
foodsnap.rar
Normal file
BIN
foodsnap.rar
Normal file
Binary file not shown.
Binary file not shown.
95
index.html
95
index.html
|
|
@ -5,66 +5,49 @@
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#059669" />
|
<title>FoodSnap - Nutritional Intelligence</title>
|
||||||
<link rel="apple-touch-icon" href="/pwa-192x192.png" />
|
|
||||||
<link rel="mask-icon" href="/vite.svg" color="#059669" />
|
|
||||||
<!-- Primary Meta Tags -->
|
|
||||||
<title>FoodSnap.ai - Nutricionista de Bolso com Inteligência Artificial</title>
|
|
||||||
<meta name="title" content="FoodSnap.ai - Nutricionista de Bolso com Inteligência Artificial" />
|
|
||||||
<meta name="description"
|
|
||||||
content="Transforme sua dieta com o FoodSnap.ai. Fotografe seu prato e receba análise nutricional completa (calorias, macros) via WhatsApp em segundos. Teste Grátis!" />
|
|
||||||
<meta name="keywords"
|
|
||||||
content="nutrição ia, contador de calorias foto, dieta whatsapp, nutricionista artificial, emagrecimento ia, food tracker, macro calculator" />
|
|
||||||
<meta name="author" content="FoodSnap AI" />
|
|
||||||
<meta name="robots" content="index, follow" />
|
|
||||||
<link rel="canonical" href="https://foodsnap.ai" />
|
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Tailwind CSS -->
|
||||||
<meta property="og:type" content="website" />
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<meta property="og:url" content="https://foodsnap.ai/" />
|
<script>
|
||||||
<meta property="og:title" content="FoodSnap.ai - Seu Nutricionista IA no WhatsApp" />
|
tailwind.config = {
|
||||||
<meta property="og:description"
|
theme: {
|
||||||
content="Analise calorias e macros apenas tirando uma foto. Sem digitação, sem apps pesados. Tudo pelo WhatsApp." />
|
extend: {
|
||||||
<meta property="og:image" content="https://foodsnap.ai/og-image.jpg" />
|
colors: {
|
||||||
|
brand: {
|
||||||
<!-- Twitter -->
|
50: '#ecfdf5',
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
100: '#d1fae5',
|
||||||
<meta property="twitter:url" content="https://foodsnap.ai/" />
|
200: '#a7f3d0',
|
||||||
<meta property="twitter:title" content="FoodSnap.ai - Nutrição Inteligente" />
|
300: '#6ee7b7',
|
||||||
<meta property="twitter:description"
|
400: '#34d399',
|
||||||
content="Chega de contar calorias manualmente. Deixe a IA fazer isso por você." />
|
500: '#10b981',
|
||||||
<meta property="twitter:image" content="https://foodsnap.ai/og-image.jpg" />
|
600: '#059669',
|
||||||
|
700: '#047857',
|
||||||
<!-- JSON-LD Structured Data -->
|
800: '#065f46',
|
||||||
<script type="application/ld+json">
|
900: '#064e3b',
|
||||||
{
|
950: '#022c22',
|
||||||
"@context": "https://schema.org",
|
}
|
||||||
"@type": "SoftwareApplication",
|
},
|
||||||
"name": "FoodSnap.ai",
|
fontFamily: {
|
||||||
"applicationCategory": "HealthApplication",
|
sans: ['"Plus Jakarta Sans"', 'Inter', 'sans-serif'],
|
||||||
"operatingSystem": "Web, WhatsApp",
|
},
|
||||||
"offers": {
|
boxShadow: {
|
||||||
"@type": "Offer",
|
'premium': '0 20px 40px -6px rgba(0, 0, 0, 0.1)',
|
||||||
"price": "0",
|
'glow': '0 0 20px rgba(5, 150, 105, 0.3)',
|
||||||
"priceCurrency": "BRL"
|
'card-hover': '0 10px 30px -5px rgba(0, 0, 0, 0.08)',
|
||||||
},
|
},
|
||||||
"description": "Aplicativo de nutrição baseado em IA que analisa fotos de comida para contagem de calorias e macros através do WhatsApp.",
|
backgroundImage: {
|
||||||
"aggregateRating": {
|
'noise': "url('https://grainy-gradients.vercel.app/noise.svg')",
|
||||||
"@type": "AggregateRating",
|
}
|
||||||
"ratingValue": "4.8",
|
}
|
||||||
"ratingCount": "1250"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Tailwind CSS -->
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
|
|
||||||
<!-- Fonts -->
|
<!-- Fonts -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap"
|
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
|
|
||||||
<!-- Configurações de Tema e Tratamento de Erros -->
|
<!-- Configurações de Tema e Tratamento de Erros -->
|
||||||
|
|
@ -77,11 +60,11 @@
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['"Plus Jakarta Sans"', 'Inter', 'sans-serif'],
|
sans: ['"Plus Jakarta Sans"', 'Inter', 'sans-serif'], // More modern font stack
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
gray: {
|
gray: {
|
||||||
25: '#fcfcfd',
|
25: '#fcfcfd', // Lighter background
|
||||||
50: '#f9fafb',
|
50: '#f9fafb',
|
||||||
100: '#f3f4f6',
|
100: '#f3f4f6',
|
||||||
200: '#e5e7eb',
|
200: '#e5e7eb',
|
||||||
|
|
@ -116,8 +99,8 @@
|
||||||
'xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',
|
'xl': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)',
|
||||||
'2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
'2xl': '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||||
'glow': '0 0 40px rgba(16, 185, 129, 0.2)',
|
'glow': '0 0 40px rgba(16, 185, 129, 0.2)',
|
||||||
'premium': '0 20px 40px -5px rgba(0, 0, 0, 0.1), 0 10px 20px -5px rgba(0, 0, 0, 0.04)',
|
'premium': '0 20px 40px -5px rgba(0, 0, 0, 0.1), 0 10px 20px -5px rgba(0, 0, 0, 0.04)', // New premium shadow
|
||||||
'card': '0 0 0 1px rgba(0,0,0,0.03), 0 2px 8px rgba(0,0,0,0.04), 0 8px 32px rgba(0,0,0,0.04)',
|
'card': '0 0 0 1px rgba(0,0,0,0.03), 0 2px 8px rgba(0,0,0,0.04), 0 8px 32px rgba(0,0,0,0.04)', // Enhanced card shadow
|
||||||
},
|
},
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||||
|
|
|
||||||
4368
package-lock.json
generated
4368
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -21,7 +21,6 @@
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.0",
|
"vite": "^6.2.0"
|
||||||
"vite-plugin-pwa": "^1.2.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
|
|
@ -1,6 +0,0 @@
|
||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
Disallow: /admin
|
|
||||||
Disallow: /dashboard
|
|
||||||
|
|
||||||
Sitemap: https://foodsnap.ai/sitemap.xml
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
<url>
|
|
||||||
<loc>https://foodsnap.ai/</loc>
|
|
||||||
<lastmod>2026-02-17</lastmod>
|
|
||||||
<changefreq>daily</changefreq>
|
|
||||||
<priority>1.0</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://foodsnap.ai/login</loc>
|
|
||||||
<lastmod>2026-02-17</lastmod>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.8</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc>https://foodsnap.ai/register</loc>
|
|
||||||
<lastmod>2026-02-17</lastmod>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.8</priority>
|
|
||||||
</url>
|
|
||||||
</urlset>
|
|
||||||
236
src/App.tsx
236
src/App.tsx
|
|
@ -15,58 +15,228 @@ import AdminPanel from './pages/AdminPanel';
|
||||||
import ProfessionalDashboard from './pages/ProfessionalDashboard';
|
import ProfessionalDashboard from './pages/ProfessionalDashboard';
|
||||||
import FAQPage from './pages/FAQPage';
|
import FAQPage from './pages/FAQPage';
|
||||||
import { LanguageProvider } from './contexts/LanguageContext';
|
import { LanguageProvider } from './contexts/LanguageContext';
|
||||||
import { UserProvider, useUser } from './contexts/UserContext'; // Import UserContext
|
import { supabase } from './lib/supabase';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { User } from './types';
|
||||||
|
|
||||||
|
// removed User interface definition
|
||||||
|
|
||||||
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');
|
const [currentView, setCurrentView] = useState<'home' | 'faq'>('home'); // Estado de navegação
|
||||||
|
const [isCompletingProfile, setIsCompletingProfile] = useState(false); // Novo estado para controle de perfil incompleto
|
||||||
|
|
||||||
// Consume UserContext
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const {
|
const [isAdminView, setIsAdminView] = useState(false);
|
||||||
user,
|
const [isProfessionalView, setIsProfessionalView] = useState(false);
|
||||||
loading,
|
const [isLoadingSession, setIsLoadingSession] = useState(true);
|
||||||
isAdminView,
|
|
||||||
isProfessionalView,
|
|
||||||
isCompletingProfile,
|
|
||||||
toggleAdminView,
|
|
||||||
setIsProfessionalView,
|
|
||||||
logout,
|
|
||||||
refreshProfile
|
|
||||||
} = useUser();
|
|
||||||
|
|
||||||
// Effect to handle "Complete Profile" flow automatically
|
// Check active session on load
|
||||||
|
// Check active session on load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCompletingProfile) {
|
let mounted = true;
|
||||||
setAuthMode('register');
|
|
||||||
setIsModalOpen(true);
|
const initializeAuth = async () => {
|
||||||
|
try {
|
||||||
|
// Obter sessão inicial sem race conditions complexas
|
||||||
|
const { data: { session }, error } = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Erro ao obter sessão inicial:", error);
|
||||||
|
if (mounted) setIsLoadingSession(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session?.user) {
|
||||||
|
console.log("App: Sessão encontrada, carregando perfil...");
|
||||||
|
if (mounted) {
|
||||||
|
await fetchUserProfile(session.user.id, session.user.email);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("App: Nenhuma sessão ativa.");
|
||||||
|
if (mounted) setIsLoadingSession(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Erro inesperado na autenticação:", err);
|
||||||
|
if (mounted) setIsLoadingSession(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeAuth();
|
||||||
|
|
||||||
|
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||||
|
console.log(`Auth event: ${event}`);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (event === 'SIGNED_OUT') {
|
||||||
|
setUser(null);
|
||||||
|
setIsAdminView(false);
|
||||||
|
setIsProfessionalView(false);
|
||||||
|
setIsLoadingSession(false);
|
||||||
|
setCurrentView('home');
|
||||||
|
setIsCompletingProfile(false);
|
||||||
|
} else if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') {
|
||||||
|
if (session?.user) {
|
||||||
|
// Apenas recarrega se o usuário ainda não estiver setado ou se mudou
|
||||||
|
// Mas para garantir atualização de claims/perfil, recarregamos.
|
||||||
|
await fetchUserProfile(session.user.id, session.user.email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchUserProfile = async (userId: string, email?: string) => {
|
||||||
|
try {
|
||||||
|
let profile = null;
|
||||||
|
|
||||||
|
// Tentativa única de buscar perfil. O cliente Supabase já trata retries de rede.
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', userId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
profile = data;
|
||||||
|
|
||||||
|
// Se não tem perfil ou falta telefone, solicitamos completar cadastro
|
||||||
|
if (!profile || !profile.phone_e164) {
|
||||||
|
console.warn("Perfil incompleto. Solicitando dados.");
|
||||||
|
// Não fazemos signOut, apenas abrimos o modal para completar
|
||||||
|
setIsCompletingProfile(true);
|
||||||
|
setAuthMode('register'); // Visualmente irrelevante pois isCompletingProfile domina
|
||||||
|
setIsModalOpen(true);
|
||||||
|
// User fica null, então cai na Landing Page com Modal aberto. Perfeito.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se perfil ok, garante que flag de completar está false
|
||||||
|
setIsCompletingProfile(false);
|
||||||
|
|
||||||
|
const { data: entitlement } = await supabase
|
||||||
|
.from('user_entitlements')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
let plan: 'free' | 'pro' | 'trial' = 'free';
|
||||||
|
if (entitlement) {
|
||||||
|
const code = entitlement.entitlement_code;
|
||||||
|
const isActive = entitlement.is_active;
|
||||||
|
|
||||||
|
// Map various paid plans to 'pro' status
|
||||||
|
if (isActive && (code === 'pro' || code === 'mensal' || code === 'trimestral' || code === 'anual')) {
|
||||||
|
plan = 'pro';
|
||||||
|
} else if (isActive && code === 'trial') {
|
||||||
|
plan = 'trial';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser({
|
||||||
|
id: userId,
|
||||||
|
name: profile.full_name || 'Usuário',
|
||||||
|
email: email || profile.email || '',
|
||||||
|
phone: profile.phone_e164,
|
||||||
|
public_id: profile.public_id,
|
||||||
|
avatar: undefined, // Column does not exist in DB, using undefined to trigger UI fallback
|
||||||
|
is_admin: profile.is_admin,
|
||||||
|
is_professional: profile.is_professional,
|
||||||
|
plan: plan,
|
||||||
|
plan_valid_until: entitlement?.valid_until
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-switch logic:
|
||||||
|
// If user is professional, default to Professional View.
|
||||||
|
// If user is Admin, they usually see Admin View but we might default to user dashboard for them unless they toggle.
|
||||||
|
// Logic requested: "System verifies and logs him in correct panel".
|
||||||
|
|
||||||
|
const loginIntent = localStorage.getItem('login_intent');
|
||||||
|
|
||||||
|
if (profile.is_professional) {
|
||||||
|
setIsProfessionalView(true);
|
||||||
|
} else {
|
||||||
|
setIsProfessionalView(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override if explicit intent was set (though we removed the button, old intents might linger, safe to ignore or keep)
|
||||||
|
if (loginIntent === 'user') {
|
||||||
|
// If they explicitly wanted user view but are pro, maybe respect it?
|
||||||
|
// For now, let's stick to the requested "System verifies" rule above.
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching profile:', error);
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingSession(false);
|
||||||
}
|
}
|
||||||
}, [isCompletingProfile]);
|
|
||||||
|
};
|
||||||
|
|
||||||
const handleOpenRegister = (plan: string = 'starter') => {
|
const handleOpenRegister = (plan: string = 'starter') => {
|
||||||
setSelectedPlan(plan);
|
setSelectedPlan(plan);
|
||||||
setAuthMode('register');
|
setAuthMode('register');
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
|
setIsCompletingProfile(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenLogin = (context?: 'user' | 'professional') => {
|
const handleOpenLogin = (context?: 'user' | 'professional') => {
|
||||||
|
// If context is professional, we can store this intent to redirect after login
|
||||||
|
// For now, we'll use a simple localStorage flag or state
|
||||||
if (context === 'professional') {
|
if (context === 'professional') {
|
||||||
localStorage.setItem('login_intent', 'professional');
|
localStorage.setItem('login_intent', 'professional');
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem('login_intent', 'user');
|
localStorage.setItem('login_intent', 'user');
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuthMode('login');
|
setAuthMode('login');
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
|
setIsCompletingProfile(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAuthSuccess = async () => {
|
const handleAuthSuccess = async () => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
await refreshProfile();
|
setIsCompletingProfile(false);
|
||||||
// Login intent logic handled inside context or simply by state update
|
// Force refresh profile to ensure we have latest data
|
||||||
localStorage.removeItem('login_intent');
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
if (session?.user) {
|
||||||
|
await fetchUserProfile(session.user.id, session.user.email);
|
||||||
|
|
||||||
|
// Check intent
|
||||||
|
const intent = localStorage.getItem('login_intent');
|
||||||
|
if (intent === 'professional') {
|
||||||
|
// We can't know for sure if they are pro yet inside this function scope easily unless we use the user state which might be stale
|
||||||
|
// But fetchUserProfile updates 'user'.
|
||||||
|
// Ideally we wait for user state to update.
|
||||||
|
// For simplicity, let's rely on the useEffect that watches 'user' or just check 'isProfessionalView' toggle inside fetchUserProfile?
|
||||||
|
// Lets keep it simple: We just set the view if the profile allows it.
|
||||||
|
// Actually, fetchUserProfile runs, updates User.
|
||||||
|
// We can check localstorage IN fetchUserProfile.
|
||||||
|
}
|
||||||
|
localStorage.removeItem('login_intent');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
setUser(null);
|
||||||
|
setIsAdminView(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAdminView = () => {
|
||||||
|
if (user?.is_admin) {
|
||||||
|
setIsAdminView(!isAdminView);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function for navigating
|
// Helper function for navigating
|
||||||
|
|
@ -75,7 +245,7 @@ const AppContent: React.FC = () => {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (isLoadingSession) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-white">
|
<div className="min-h-screen flex items-center justify-center bg-white">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-brand-600" />
|
<Loader2 className="w-8 h-8 animate-spin text-brand-600" />
|
||||||
|
|
@ -85,12 +255,12 @@ 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 <AdminPanel user={user} onExitAdmin={toggleAdminView} onLogout={handleLogout} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rota Profissional
|
// Rota Profissional
|
||||||
if (user && isProfessionalView) {
|
if (user && isProfessionalView) {
|
||||||
return <ProfessionalDashboard user={user} onExit={() => setIsProfessionalView(false)} onLogout={logout} />;
|
return <ProfessionalDashboard user={user} onExit={() => setIsProfessionalView(false)} onLogout={handleLogout} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rota Dashboard Usuário
|
// Rota Dashboard Usuário
|
||||||
|
|
@ -98,7 +268,7 @@ const AppContent: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<Dashboard
|
<Dashboard
|
||||||
user={user}
|
user={user}
|
||||||
onLogout={logout}
|
onLogout={handleLogout}
|
||||||
onOpenAdmin={user.is_admin ? toggleAdminView : undefined}
|
onOpenAdmin={user.is_admin ? toggleAdminView : undefined}
|
||||||
onOpenPro={() => setIsProfessionalView(true)}
|
onOpenPro={() => setIsProfessionalView(true)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -112,7 +282,7 @@ const AppContent: React.FC = () => {
|
||||||
onRegister={() => handleOpenRegister('starter')}
|
onRegister={() => handleOpenRegister('starter')}
|
||||||
onLogin={handleOpenLogin}
|
onLogin={handleOpenLogin}
|
||||||
onOpenTools={() => setIsToolsOpen(true)}
|
onOpenTools={() => setIsToolsOpen(true)}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate} // Passa navegação
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
|
@ -133,7 +303,7 @@ const AppContent: React.FC = () => {
|
||||||
|
|
||||||
<Footer
|
<Footer
|
||||||
onRegister={() => handleOpenRegister('starter')}
|
onRegister={() => handleOpenRegister('starter')}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate} // Passa navegação
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RegistrationModal
|
<RegistrationModal
|
||||||
|
|
@ -141,7 +311,7 @@ const AppContent: React.FC = () => {
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => setIsModalOpen(false)}
|
||||||
plan={selectedPlan}
|
plan={selectedPlan}
|
||||||
mode={authMode}
|
mode={authMode}
|
||||||
isCompletingProfile={isCompletingProfile}
|
isCompletingProfile={isCompletingProfile} // Passa o estado de completar perfil
|
||||||
onSuccess={handleAuthSuccess}
|
onSuccess={handleAuthSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -155,11 +325,9 @@ const AppContent: React.FC = () => {
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<UserProvider>
|
<LanguageProvider>
|
||||||
<LanguageProvider>
|
<AppContent />
|
||||||
<AppContent />
|
</LanguageProvider>
|
||||||
</LanguageProvider>
|
|
||||||
</UserProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
|
||||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
hasError: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ErrorBoundary extends Component<Props, State> {
|
|
||||||
public state: State = {
|
|
||||||
hasError: false,
|
|
||||||
error: null
|
|
||||||
};
|
|
||||||
|
|
||||||
public static getDerivedStateFromError(error: Error): State {
|
|
||||||
return { hasError: true, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
||||||
console.error('Uncaught error:', error, errorInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
public render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6 text-center">
|
|
||||||
<div className="bg-white p-8 rounded-2xl shadow-xl max-w-md w-full border border-gray-100">
|
|
||||||
<div className="w-16 h-16 bg-red-50 rounded-full flex items-center justify-center mx-auto mb-6">
|
|
||||||
<AlertCircle size={32} className="text-red-500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-3">Ops! Algo deu errado.</h1>
|
|
||||||
<p className="text-gray-500 mb-8">
|
|
||||||
Encontramos um erro inesperado. Tente recarregar a página.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Opcional: Mostrar erro técnico em desenvolvimento */}
|
|
||||||
{import.meta.env.DEV && this.state.error && (
|
|
||||||
<div className="mb-6 p-4 bg-gray-100 rounded-lg text-left overflow-auto max-h-40 text-xs font-mono text-gray-600">
|
|
||||||
{this.state.error.toString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className="w-full py-3 bg-brand-600 hover:bg-brand-700 text-white rounded-xl font-bold flex items-center justify-center gap-2 transition-colors shadow-lg shadow-brand-500/20"
|
|
||||||
>
|
|
||||||
<RefreshCw size={20} />
|
|
||||||
Recarregar Página
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ErrorBoundary;
|
|
||||||
|
|
@ -1,172 +0,0 @@
|
||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
|
||||||
import { supabase } from '../lib/supabase';
|
|
||||||
import { User } from '../types';
|
|
||||||
|
|
||||||
interface UserContextType {
|
|
||||||
user: User | null;
|
|
||||||
loading: boolean;
|
|
||||||
isAdminView: boolean;
|
|
||||||
isProfessionalView: boolean;
|
|
||||||
isCompletingProfile: boolean;
|
|
||||||
toggleAdminView: () => void;
|
|
||||||
setIsProfessionalView: (isPro: boolean) => void;
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
refreshProfile: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UserContext = createContext<UserContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export const UserProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [isAdminView, setIsAdminView] = useState(false);
|
|
||||||
const [isProfessionalView, setIsProfessionalView] = useState(false);
|
|
||||||
const [isCompletingProfile, setIsCompletingProfile] = useState(false);
|
|
||||||
|
|
||||||
const fetchUserProfile = async (userId: string, email?: string) => {
|
|
||||||
try {
|
|
||||||
const { data: profile, error } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', userId)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
if (!profile || !profile.phone_e164) {
|
|
||||||
console.warn("UserContext: Perfil incompleto.");
|
|
||||||
setIsCompletingProfile(true);
|
|
||||||
// Mantemos user null ou parcial? App.tsx usava null para triggerar modal na landing
|
|
||||||
// Vamos setar null para garantir que caia na landing, mas com flag isCompletingProfile true
|
|
||||||
setUser(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsCompletingProfile(false);
|
|
||||||
|
|
||||||
// Fetch Entitlements
|
|
||||||
const { data: entitlement } = await supabase
|
|
||||||
.from('user_entitlements')
|
|
||||||
.select('*')
|
|
||||||
.eq('user_id', userId)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
let plan: 'free' | 'pro' | 'trial' = 'free';
|
|
||||||
if (entitlement?.is_active) {
|
|
||||||
const code = entitlement.entitlement_code;
|
|
||||||
if (['pro', 'mensal', 'trimestral', 'anual'].includes(code)) plan = 'pro';
|
|
||||||
else if (code === 'trial') plan = 'trial';
|
|
||||||
}
|
|
||||||
|
|
||||||
const userData: User = {
|
|
||||||
id: userId,
|
|
||||||
name: profile.full_name || 'Usuário',
|
|
||||||
email: email || profile.email || '',
|
|
||||||
phone: profile.phone_e164,
|
|
||||||
public_id: profile.public_id,
|
|
||||||
is_admin: profile.is_admin,
|
|
||||||
is_professional: profile.is_professional,
|
|
||||||
plan: plan,
|
|
||||||
plan_valid_until: entitlement?.valid_until
|
|
||||||
};
|
|
||||||
|
|
||||||
setUser(userData);
|
|
||||||
|
|
||||||
// Auto-switch view logic
|
|
||||||
if (profile.is_professional) {
|
|
||||||
setIsProfessionalView(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('UserContext: Erro ao carregar perfil', error);
|
|
||||||
setUser(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
// Função única de inicialização para evitar race conditions
|
|
||||||
const initSession = async () => {
|
|
||||||
try {
|
|
||||||
const { data: { session }, error } = await supabase.auth.getSession();
|
|
||||||
if (error) throw error;
|
|
||||||
|
|
||||||
if (session?.user) {
|
|
||||||
if (mounted) await fetchUserProfile(session.user.id, session.user.email);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("UserContext: Falha na sessão inicial", error);
|
|
||||||
} finally {
|
|
||||||
if (mounted) setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initSession();
|
|
||||||
|
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => {
|
|
||||||
console.log(`UserContext Auth Event: ${event}`);
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
if (event === 'SIGNED_OUT') {
|
|
||||||
setUser(null);
|
|
||||||
setIsAdminView(false);
|
|
||||||
setIsProfessionalView(false);
|
|
||||||
setIsCompletingProfile(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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
subscription.unsubscribe();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const logout = async () => {
|
|
||||||
await supabase.auth.signOut();
|
|
||||||
// State updates handled by onAuthStateChange
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAdminView = () => {
|
|
||||||
if (user?.is_admin) setIsAdminView(prev => !prev);
|
|
||||||
};
|
|
||||||
|
|
||||||
const refreshProfile = async () => {
|
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
|
||||||
if (session?.user) {
|
|
||||||
await fetchUserProfile(session.user.id, session.user.email);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UserContext.Provider value={{
|
|
||||||
user,
|
|
||||||
loading,
|
|
||||||
isAdminView,
|
|
||||||
isProfessionalView,
|
|
||||||
isCompletingProfile,
|
|
||||||
toggleAdminView,
|
|
||||||
setIsProfessionalView,
|
|
||||||
logout,
|
|
||||||
refreshProfile
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</UserContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useUser = () => {
|
|
||||||
const context = useContext(UserContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useUser must be used within a UserProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import { createClient } from "@supabase/supabase-js";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
import { Database } from "./database.types";
|
import { Database } from "./database.types";
|
||||||
|
|
||||||
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
|
// Credentials provided in the prompt
|
||||||
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
const SUPABASE_URL = "https://mnhgpnqkwuqzpvfrwftp.supabase.co";
|
||||||
|
const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1uaGdwbnFrd3VxenB2ZnJ3ZnRwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjU3MTk4NTUsImV4cCI6MjA4MTI5NTg1NX0.DBYmhgiZoCmA0AlejJRsTh85HxRDEnG_ihkEQ2cXcpk";
|
||||||
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
|
||||||
throw new Error("Missing Supabase environment variables. Please check your .env file.");
|
|
||||||
}
|
|
||||||
|
|
||||||
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_ANON_KEY);
|
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_ANON_KEY);
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import ErrorBoundary from './components/common/ErrorBoundary';
|
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
const container = document.getElementById('root');
|
const container = document.getElementById('root');
|
||||||
|
|
@ -10,9 +9,7 @@ if (container) {
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
root.render(
|
root.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ErrorBoundary>
|
<App />
|
||||||
<App />
|
|
||||||
</ErrorBoundary>
|
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
v2.75.0
|
v2.67.1
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { defineConfig, loadEnv } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, '.', '');
|
const env = loadEnv(mode, '.', '');
|
||||||
|
|
@ -10,40 +9,7 @@ export default defineConfig(({ mode }) => {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [react()],
|
||||||
react(),
|
|
||||||
VitePWA({
|
|
||||||
registerType: 'autoUpdate',
|
|
||||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'],
|
|
||||||
manifest: {
|
|
||||||
name: 'FoodSnap.ai',
|
|
||||||
short_name: 'FoodSnap',
|
|
||||||
description: 'Nutricionista de Bolso com Inteligência Artificial',
|
|
||||||
theme_color: '#059669',
|
|
||||||
background_color: '#ffffff',
|
|
||||||
display: 'standalone',
|
|
||||||
orientation: 'portrait',
|
|
||||||
icons: [
|
|
||||||
{
|
|
||||||
src: 'pwa-192x192.png',
|
|
||||||
sizes: '192x192',
|
|
||||||
type: 'image/png'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: 'pwa-512x512.png',
|
|
||||||
sizes: '512x512',
|
|
||||||
type: 'image/png'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: 'pwa-512x512.png',
|
|
||||||
sizes: '512x512',
|
|
||||||
type: 'image/png',
|
|
||||||
purpose: 'any maskable'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
],
|
|
||||||
define: {
|
define: {
|
||||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
||||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue