feat: SEO implementation and Stability Audit fixes (use UserContext, ErrorBoundary, Env Vars)
This commit is contained in:
parent
5758504d4e
commit
3849edad10
10 changed files with 364 additions and 246 deletions
2
.env
Normal file
2
.env
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
VITE_SUPABASE_URL=https://mnhgpnqkwuqzpvfrwftp.supabase.co
|
||||||
|
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1uaGdwbnFrd3VxenB2ZnJ3ZnRwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjU3MTk4NTUsImV4cCI6MjA4MTI5NTg1NX0.DBYmhgiZoCmA0AlejJRsTh85HxRDEnG_ihkEQ2cXcpk
|
||||||
91
index.html
91
index.html
|
|
@ -5,49 +5,64 @@
|
||||||
<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" />
|
||||||
<title>FoodSnap - Nutritional Intelligence</title>
|
|
||||||
|
|
||||||
<!-- Tailwind CSS -->
|
<!-- Primary Meta Tags -->
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<title>FoodSnap.ai - Nutricionista de Bolso com Inteligência Artificial</title>
|
||||||
<script>
|
<meta name="title" content="FoodSnap.ai - Nutricionista de Bolso com Inteligência Artificial" />
|
||||||
tailwind.config = {
|
<meta name="description"
|
||||||
theme: {
|
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!" />
|
||||||
extend: {
|
<meta name="keywords"
|
||||||
colors: {
|
content="nutrição ia, contador de calorias foto, dieta whatsapp, nutricionista artificial, emagrecimento ia, food tracker, macro calculator" />
|
||||||
brand: {
|
<meta name="author" content="FoodSnap AI" />
|
||||||
50: '#ecfdf5',
|
<meta name="robots" content="index, follow" />
|
||||||
100: '#d1fae5',
|
<link rel="canonical" href="https://foodsnap.ai" />
|
||||||
200: '#a7f3d0',
|
|
||||||
300: '#6ee7b7',
|
<!-- Open Graph / Facebook -->
|
||||||
400: '#34d399',
|
<meta property="og:type" content="website" />
|
||||||
500: '#10b981',
|
<meta property="og:url" content="https://foodsnap.ai/" />
|
||||||
600: '#059669',
|
<meta property="og:title" content="FoodSnap.ai - Seu Nutricionista IA no WhatsApp" />
|
||||||
700: '#047857',
|
<meta property="og:description"
|
||||||
800: '#065f46',
|
content="Analise calorias e macros apenas tirando uma foto. Sem digitação, sem apps pesados. Tudo pelo WhatsApp." />
|
||||||
900: '#064e3b',
|
<meta property="og:image" content="https://foodsnap.ai/og-image.jpg" />
|
||||||
950: '#022c22',
|
|
||||||
}
|
<!-- Twitter -->
|
||||||
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
|
<meta property="twitter:url" content="https://foodsnap.ai/" />
|
||||||
|
<meta property="twitter:title" content="FoodSnap.ai - Nutrição Inteligente" />
|
||||||
|
<meta property="twitter:description"
|
||||||
|
content="Chega de contar calorias manualmente. Deixe a IA fazer isso por você." />
|
||||||
|
<meta property="twitter:image" content="https://foodsnap.ai/og-image.jpg" />
|
||||||
|
|
||||||
|
<!-- JSON-LD Structured Data -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
"name": "FoodSnap.ai",
|
||||||
|
"applicationCategory": "HealthApplication",
|
||||||
|
"operatingSystem": "Web, WhatsApp",
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"price": "0",
|
||||||
|
"priceCurrency": "BRL"
|
||||||
},
|
},
|
||||||
fontFamily: {
|
"description": "Aplicativo de nutrição baseado em IA que analisa fotos de comida para contagem de calorias e macros através do WhatsApp.",
|
||||||
sans: ['"Plus Jakarta Sans"', 'Inter', 'sans-serif'],
|
"aggregateRating": {
|
||||||
},
|
"@type": "AggregateRating",
|
||||||
boxShadow: {
|
"ratingValue": "4.8",
|
||||||
'premium': '0 20px 40px -6px rgba(0, 0, 0, 0.1)',
|
"ratingCount": "1250"
|
||||||
'glow': '0 0 20px rgba(5, 150, 105, 0.3)',
|
|
||||||
'card-hover': '0 10px 30px -5px rgba(0, 0, 0, 0.08)',
|
|
||||||
},
|
|
||||||
backgroundImage: {
|
|
||||||
'noise': "url('https://grainy-gradients.vercel.app/noise.svg')",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</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 href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet">
|
||||||
|
|
||||||
<!-- Configurações de Tema e Tratamento de Erros -->
|
<!-- Configurações de Tema e Tratamento de Erros -->
|
||||||
|
|
@ -60,11 +75,11 @@
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ['"Plus Jakarta Sans"', 'Inter', 'sans-serif'], // More modern font stack
|
sans: ['"Plus Jakarta Sans"', 'Inter', 'sans-serif'],
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
gray: {
|
gray: {
|
||||||
25: '#fcfcfd', // Lighter background
|
25: '#fcfcfd',
|
||||||
50: '#f9fafb',
|
50: '#f9fafb',
|
||||||
100: '#f3f4f6',
|
100: '#f3f4f6',
|
||||||
200: '#e5e7eb',
|
200: '#e5e7eb',
|
||||||
|
|
@ -99,8 +114,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)', // New premium shadow
|
'premium': '0 20px 40px -5px rgba(0, 0, 0, 0.1), 0 10px 20px -5px rgba(0, 0, 0, 0.04)',
|
||||||
'card': '0 0 0 1px rgba(0,0,0,0.03), 0 2px 8px rgba(0,0,0,0.04), 0 8px 32px rgba(0,0,0,0.04)', // Enhanced card 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)',
|
||||||
},
|
},
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||||
|
|
|
||||||
6
public/robots.txt
Normal file
6
public/robots.txt
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /admin
|
||||||
|
Disallow: /dashboard
|
||||||
|
|
||||||
|
Sitemap: https://foodsnap.ai/sitemap.xml
|
||||||
21
public/sitemap.xml
Normal file
21
public/sitemap.xml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://foodsnap.ai/</loc>
|
||||||
|
<lastmod>2026-02-17</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://foodsnap.ai/login</loc>
|
||||||
|
<lastmod>2026-02-17</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://foodsnap.ai/register</loc>
|
||||||
|
<lastmod>2026-02-17</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
226
src/App.tsx
226
src/App.tsx
|
|
@ -15,228 +15,58 @@ 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 { supabase } from './lib/supabase';
|
import { UserProvider, useUser } from './contexts/UserContext'; // Import UserContext
|
||||||
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'); // Estado de navegação
|
const [currentView, setCurrentView] = useState<'home' | 'faq'>('home');
|
||||||
const [isCompletingProfile, setIsCompletingProfile] = useState(false); // Novo estado para controle de perfil incompleto
|
|
||||||
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
// Consume UserContext
|
||||||
const [isAdminView, setIsAdminView] = useState(false);
|
const {
|
||||||
const [isProfessionalView, setIsProfessionalView] = useState(false);
|
user,
|
||||||
const [isLoadingSession, setIsLoadingSession] = useState(true);
|
loading,
|
||||||
|
isAdminView,
|
||||||
|
isProfessionalView,
|
||||||
|
isCompletingProfile,
|
||||||
|
toggleAdminView,
|
||||||
|
setIsProfessionalView,
|
||||||
|
logout,
|
||||||
|
refreshProfile
|
||||||
|
} = useUser();
|
||||||
|
|
||||||
// Check active session on load
|
// Effect to handle "Complete Profile" flow automatically
|
||||||
// Check active session on load
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
if (isCompletingProfile) {
|
||||||
|
setAuthMode('register');
|
||||||
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);
|
setIsModalOpen(true);
|
||||||
// User fica null, então cai na Landing Page com Modal aberto. Perfeito.
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [isCompletingProfile]);
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
||||||
setIsCompletingProfile(false);
|
await refreshProfile();
|
||||||
// Force refresh profile to ensure we have latest data
|
// Login intent logic handled inside context or simply by state update
|
||||||
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');
|
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
|
||||||
|
|
@ -245,7 +75,7 @@ const AppContent: React.FC = () => {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoadingSession) {
|
if (loading) {
|
||||||
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" />
|
||||||
|
|
@ -255,12 +85,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={handleLogout} />;
|
return <AdminPanel user={user} onExitAdmin={toggleAdminView} onLogout={logout} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rota Profissional
|
// Rota Profissional
|
||||||
if (user && isProfessionalView) {
|
if (user && isProfessionalView) {
|
||||||
return <ProfessionalDashboard user={user} onExit={() => setIsProfessionalView(false)} onLogout={handleLogout} />;
|
return <ProfessionalDashboard user={user} onExit={() => setIsProfessionalView(false)} onLogout={logout} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rota Dashboard Usuário
|
// Rota Dashboard Usuário
|
||||||
|
|
@ -268,7 +98,7 @@ const AppContent: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<Dashboard
|
<Dashboard
|
||||||
user={user}
|
user={user}
|
||||||
onLogout={handleLogout}
|
onLogout={logout}
|
||||||
onOpenAdmin={user.is_admin ? toggleAdminView : undefined}
|
onOpenAdmin={user.is_admin ? toggleAdminView : undefined}
|
||||||
onOpenPro={() => setIsProfessionalView(true)}
|
onOpenPro={() => setIsProfessionalView(true)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -282,7 +112,7 @@ const AppContent: React.FC = () => {
|
||||||
onRegister={() => handleOpenRegister('starter')}
|
onRegister={() => handleOpenRegister('starter')}
|
||||||
onLogin={handleOpenLogin}
|
onLogin={handleOpenLogin}
|
||||||
onOpenTools={() => setIsToolsOpen(true)}
|
onOpenTools={() => setIsToolsOpen(true)}
|
||||||
onNavigate={handleNavigate} // Passa navegação
|
onNavigate={handleNavigate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
|
@ -303,7 +133,7 @@ const AppContent: React.FC = () => {
|
||||||
|
|
||||||
<Footer
|
<Footer
|
||||||
onRegister={() => handleOpenRegister('starter')}
|
onRegister={() => handleOpenRegister('starter')}
|
||||||
onNavigate={handleNavigate} // Passa navegação
|
onNavigate={handleNavigate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RegistrationModal
|
<RegistrationModal
|
||||||
|
|
@ -311,7 +141,7 @@ const AppContent: React.FC = () => {
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={() => setIsModalOpen(false)}
|
||||||
plan={selectedPlan}
|
plan={selectedPlan}
|
||||||
mode={authMode}
|
mode={authMode}
|
||||||
isCompletingProfile={isCompletingProfile} // Passa o estado de completar perfil
|
isCompletingProfile={isCompletingProfile}
|
||||||
onSuccess={handleAuthSuccess}
|
onSuccess={handleAuthSuccess}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -325,9 +155,11 @@ const AppContent: React.FC = () => {
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
|
<UserProvider>
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<AppContent />
|
<AppContent />
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
|
</UserProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
64
src/components/common/ErrorBoundary.tsx
Normal file
64
src/components/common/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-50 p-6 text-center">
|
||||||
|
<div className="bg-white p-8 rounded-2xl shadow-xl max-w-md w-full border border-gray-100">
|
||||||
|
<div className="w-16 h-16 bg-red-50 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<AlertCircle size={32} className="text-red-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-3">Ops! Algo deu errado.</h1>
|
||||||
|
<p className="text-gray-500 mb-8">
|
||||||
|
Encontramos um erro inesperado. Tente recarregar a página.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Opcional: Mostrar erro técnico em desenvolvimento */}
|
||||||
|
{import.meta.env.DEV && this.state.error && (
|
||||||
|
<div className="mb-6 p-4 bg-gray-100 rounded-lg text-left overflow-auto max-h-40 text-xs font-mono text-gray-600">
|
||||||
|
{this.state.error.toString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="w-full py-3 bg-brand-600 hover:bg-brand-700 text-white rounded-xl font-bold flex items-center justify-center gap-2 transition-colors shadow-lg shadow-brand-500/20"
|
||||||
|
>
|
||||||
|
<RefreshCw size={20} />
|
||||||
|
Recarregar Página
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
172
src/contexts/UserContext.tsx
Normal file
172
src/contexts/UserContext.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
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,8 +1,11 @@
|
||||||
import { createClient } from "@supabase/supabase-js";
|
import { createClient } from "@supabase/supabase-js";
|
||||||
import { Database } from "./database.types";
|
import { Database } from "./database.types";
|
||||||
|
|
||||||
// Credentials provided in the prompt
|
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL;
|
||||||
const SUPABASE_URL = "https://mnhgpnqkwuqzpvfrwftp.supabase.co";
|
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
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,6 +1,7 @@
|
||||||
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');
|
||||||
|
|
@ -9,7 +10,9 @@ 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.67.1
|
v2.75.0
|
||||||
Loading…
Reference in a new issue