feat: Refactor authentication to leverage AppContext, removing redundant API calls and simplifying loading state.
This commit is contained in:
@@ -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}</>
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user