feat: Refactor authentication to leverage AppContext, removing redundant API calls and simplifying loading state.

This commit is contained in:
2026-02-27 15:38:47 +00:00
parent c59e0ba1b0
commit 5456deb727
2 changed files with 95 additions and 159 deletions

View File

@@ -1,20 +1,14 @@
import { useEffect, useState } from 'react'
import { useApp } from '../../context/AppContext'
import { Navigate } from 'react-router-dom'
import { getUser } from '../../lib/auth'
/**
* Protege rotas que requerem autenticação.
* Usa o AppContext (que já tem o estado auth sincronizado com Supabase)
* para evitar uma chamada de rede extra que causava blank screen.
*/
export function RequireAuth({ children }: { children: React.ReactNode }) {
const [loading, setLoading] = useState(true)
const [ok, setOk] = useState(false)
const { user } = useApp()
useEffect(() => {
;(async () => {
const user = await getUser()
setOk(!!user)
setLoading(false)
})()
}, [])
if (loading) return null
if (!ok) return <Navigate to="/login" replace />
if (!user) return <Navigate to="/login" replace />
return <>{children}</>
}

View File

@@ -1,9 +1,6 @@
/**
* @file AppContext.tsx
* @description Gestor de Estado Global (Context API) da aplicação Web.
* Este ficheiro gere uma arquitetura híbrida: coordena a Autenticação e Perfis (Profiles)
* diretamente com o Supabase, enquanto mantém as entidades transacionais
* (Shops, Appointments, Cart) num regime de persistência local 'mockada' via LocalStorage.
*/
import React, { createContext, useContext, useEffect, useState } from 'react';
import { nanoid } from 'nanoid';
@@ -51,7 +48,7 @@ type AppContextValue = State & {
const initialState: State = {
user: undefined,
users: mockUsers,
shops: [], // Removes mockShops integration
shops: [],
appointments: [],
orders: [],
cart: [],
@@ -61,16 +58,7 @@ const initialState: State = {
const AppContext = createContext<AppContextValue | undefined>(undefined);
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
// Se já há sessão guardada, não mostra loading (app aparece de imediato)
const hasStoredUser = Boolean((() => {
try {
const raw = localStorage.getItem('smart-agenda');
if (!raw) return false;
const parsed = JSON.parse(raw);
return parsed?.user != null;
} catch { return false; }
})());
const [loading, setLoading] = useState(!hasStoredUser);
const [loading, setLoading] = useState(true);
const [state, setState] = useState<State>(() => {
const stored = storage.get<Partial<State> | null>('smart-agenda', null);
const safeStored = stored && typeof stored === 'object' ? stored : {};
@@ -86,7 +74,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
...safeStored,
user: safeUser,
users: safeUsers,
shops: [], // Start empty, will be populated by refreshShops
shops: [],
appointments: safeAppointments,
orders: safeOrders,
cart: safeCart,
@@ -95,30 +83,41 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
});
const refreshShops = async () => {
const { data: shopsData, error: shopsError } = await supabase.from('shops').select('*');
if (shopsError) return;
try {
const { data: shopsData, error: shopsError } = await supabase.from('shops').select('*');
if (shopsError || !shopsData) return;
const { data: servicesData, error: servicesError } = await supabase.from('services').select('*');
if (servicesError) return;
const { data: servicesData } = await supabase.from('services').select('*');
const fetchedShops = shopsData.map((shop) => ({
...shop,
services: servicesData.filter((s) => s.shop_id === shop.id),
products: [],
barbers: [],
}));
const fetchedShops: BarberShop[] = shopsData.map((shop) => ({
id: shop.id,
name: shop.name,
address: shop.address ?? '',
rating: shop.rating ?? 0,
imageUrl: shop.image_url ?? shop.imageUrl ?? undefined,
services: (servicesData ?? [])
.filter((s) => s.shop_id === shop.id)
.map((s) => ({
id: s.id,
name: s.name,
price: s.price ?? 0,
duration: s.duration ?? 30,
barberIds: s.barber_ids ?? [],
})),
products: [],
barbers: [],
}));
setState((s) => ({ ...s, shops: fetchedShops }));
setState((s) => ({ ...s, shops: fetchedShops }));
} catch (err) {
console.error('refreshShops error:', err);
}
};
useEffect(() => {
storage.set('smart-agenda', state);
}, [state]);
/**
* Hook de Inicialização Master — sequencial para evitar race condition.
* Ordem garantida: autenticar utilizador → carregar shops da BD → libertar UI.
*/
useEffect(() => {
let mounted = true;
@@ -126,10 +125,6 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
setState((s) => ({ ...s, user: undefined }));
};
/**
* Sincroniza o Perfil do Utilizador a partir do Supabase Database ('profiles').
* Cria automaticamente a shop se a Role for 'barbearia'.
*/
const applyProfile = async (userId: string, email?: string | null) => {
const { data, error } = await supabase
.from('profiles')
@@ -147,6 +142,9 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const displayName = data.name?.trim() || (email ? email.split('@')[0] : 'Utilizador');
const shopId = role === 'barbearia' ? userId : undefined;
let needsInsert = false;
let shopNameToInsert = '';
setState((s) => {
const nextUser: User = {
id: userId,
@@ -162,92 +160,66 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
: [...s.users, nextUser];
let shops = s.shops;
if (data.shop_name) {
const shopIdFromOwner = userId;
const exists = shops.some((shop) => shop.id === shopIdFromOwner);
if (data.shop_name && shopId) {
const exists = shops.some((shop) => shop.id === shopId);
if (!exists) {
const shopName = data.shop_name.trim();
const shop: BarberShop = {
id: shopIdFromOwner,
name: shopName,
shopNameToInsert = data.shop_name.trim();
needsInsert = true;
const newShop: BarberShop = {
id: shopId,
name: shopNameToInsert,
address: 'Endereço a definir',
rating: 0,
barbers: [],
services: [],
products: [],
};
shops = [...shops, shop];
// 🔹 INSERT NA BD SUPABASE
void supabase.from('shops').insert([
{
id: shopIdFromOwner,
name: shopName,
address: 'Endereço a definir',
rating: 0,
}
]).then(({ error: insertError }) => {
if (insertError && insertError.code !== '23505') {
console.error('Erro ao sincronizar shop nova na BD:', insertError);
}
});
shops = [...shops, newShop];
}
}
return {
...s,
user: nextUser,
users,
shops,
};
return { ...s, user: nextUser, users, shops };
});
};
/**
* Boot sequencial: autenticar primeiro, depois carregar shops.
* Isso garante que quando o Dashboard renderiza, a shop já está no estado.
*/
const boot = async () => {
try {
const { data } = await supabase.auth.getSession();
if (!mounted) return;
const session = data.session;
if (session?.user) {
// 1. Aplicar perfil (adiciona shop ao estado local se necessário)
await applyProfile(session.user.id, session.user.email);
// 2. Recarregar todas as shops da BD (garante sync completo)
if (mounted) await refreshShops();
} else {
clearUser();
// Mesmo sem user, carrega shops para o Explore page
if (mounted) await refreshShops();
// Inserir na BD se necessário (fora do setState)
if (needsInsert && shopId) {
const { error: insertErr } = await supabase.from('shops').insert([{
id: shopId,
name: shopNameToInsert,
address: 'Endereço a definir',
rating: 0,
}]);
if (insertErr && insertErr.code !== '23505') {
console.error('Erro ao inserir shop:', insertErr);
}
} catch (err) {
console.error('Erro durante o boot:', err);
} finally {
if (mounted) setLoading(false);
}
};
void boot();
/**
* Listener de eventos Auth (login/logout em tempo real).
*/
const { data: sub } = supabase.auth.onAuthStateChange(async (_event, session) => {
// Usa onAuthStateChange que dispara INITIAL_SESSION automaticamente
// Evita duplicação com getSession() separado
const { data: sub } = supabase.auth.onAuthStateChange(async (event, session) => {
if (!mounted) return;
if (session?.user) {
await applyProfile(session.user.id, session.user.email);
if (mounted) await refreshShops();
} else {
clearUser();
if (mounted) await refreshShops();
}
// Liberta o loading após o primeiro evento
if (mounted) setLoading(false);
});
// Fallback: se o onAuthStateChange não disparar em 5s, liberta o loading
const fallback = setTimeout(() => {
if (mounted) setLoading(false);
}, 5000);
return () => {
mounted = false;
clearTimeout(fallback);
sub.subscription.unsubscribe();
};
}, []);
@@ -292,21 +264,12 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
products: [],
};
const user: User = { ...payload, id: userId, role: 'barbearia', shopId };
setState((s) => ({
...s,
user,
users: [...s.users, user],
shops: [...s.shops, shop],
}));
setState((s) => ({ ...s, user, users: [...s.users, user], shops: [...s.shops, shop] }));
return true;
}
const user: User = { ...payload, id: nanoid(), role: 'cliente' };
setState((s) => ({
...s,
user,
users: [...s.users, user],
}));
setState((s) => ({ ...s, user, users: [...s.users, user] }));
return true;
};
@@ -337,13 +300,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
);
if (exists) return null;
const appointment: Appointment = {
...input,
id: nanoid(),
status: 'pendente',
total: svc.price,
};
const appointment: Appointment = { ...input, id: nanoid(), status: 'pendente', total: svc.price };
setState((s) => ({ ...s, appointments: [...s.appointments, appointment] }));
return appointment;
};
@@ -369,15 +326,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
return sum + price * item.qty;
}, 0);
return {
id: nanoid(),
shopId,
customerId,
items,
total,
status: 'pendente',
createdAt: new Date().toISOString(),
};
return { id: nanoid(), shopId, customerId, items, total, status: 'pendente', createdAt: new Date().toISOString() };
});
setState((s) => ({ ...s, orders: [...s.orders, ...newOrders], cart: [] }));
@@ -385,17 +334,11 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
};
const updateAppointmentStatus: AppContextValue['updateAppointmentStatus'] = (id, status) => {
setState((s) => ({
...s,
appointments: s.appointments.map((a) => (a.id === id ? { ...a, status } : a)),
}));
setState((s) => ({ ...s, appointments: s.appointments.map((a) => (a.id === id ? { ...a, status } : a)) }));
};
const updateOrderStatus: AppContextValue['updateOrderStatus'] = (id, status) => {
setState((s) => ({
...s,
orders: s.orders.map((o) => (o.id === id ? { ...o, status } : o)),
}));
setState((s) => ({ ...s, orders: s.orders.map((o) => (o.id === id ? { ...o, status } : o)) }));
};
const addService: AppContextValue['addService'] = (shopId, service) => {
@@ -477,16 +420,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
};
const updateShopDetails: AppContextValue['updateShopDetails'] = async (shopId, payload) => {
const { error } = await supabase
.from('shops')
.update(payload)
.eq('id', shopId);
if (error) {
console.error('Failed to update shop details:', error);
return;
}
const { error } = await supabase.from('shops').update(payload).eq('id', shopId);
if (error) { console.error('updateShopDetails error:', error); return; }
setState((s) => ({
...s,
shops: s.shops.map((shop) => (shop.id === shopId ? { ...shop, ...payload } : shop)),
@@ -520,13 +455,20 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
refreshShops,
};
// Loading Shield — mostra spinner enquanto autentica pela primeira vez
if (loading) return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<div style={{ width: 40, height: 40, border: '4px solid #e2e8f0', borderTopColor: '#6366f1', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: '#f8fafc' }}>
<div style={{ textAlign: 'center' }}>
<div style={{
width: 48, height: 48, border: '4px solid #e2e8f0', borderTopColor: '#6366f1',
borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto 16px'
}} />
<p style={{ color: '#64748b', fontSize: 14 }}>A carregar...</p>
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
};