refactor: improve loading states and error UI across shop details, booking, and profile pages

This commit is contained in:
2026-04-28 16:02:09 +01:00
parent 2ba5f03f35
commit 95f12337d0
3 changed files with 236 additions and 170 deletions

View File

@@ -13,11 +13,37 @@ export default function Booking() {
const navigate = useNavigate();
// Extração das ferramentas vitais do Context global da aplicação
const { shops, createAppointment, user, appointments, waitlists, joinWaitlist } = useApp();
const { shops, shopsReady, createAppointment, user, appointments, waitlists, joinWaitlist } = useApp();
// Procura a barbearia acedida (com base no URL parameter ':id')
const shop = useMemo(() => shops.find((s) => s.id === id), [shops, id]);
if (!shopsReady || (shops.length === 0 && !shop)) {
return (
<div className="py-32 text-center animate-pulse">
<div className="inline-block w-10 h-10 border-4 border-slate-200 border-t-indigo-600 rounded-full animate-spin mb-4" />
<p className="text-sm font-black text-slate-500 uppercase tracking-widest italic">A preparar reserva...</p>
</div>
);
}
if (!shop) {
return (
<div className="py-32 text-center space-y-6">
<div className="w-20 h-20 bg-slate-100 rounded-[2rem] flex items-center justify-center mx-auto text-slate-300">
<Calendar size={40} />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-black text-slate-900 uppercase italic tracking-tighter">Espaço não encontrado</h2>
<p className="text-slate-500 font-medium italic">Não foi possível iniciar o agendamento para este estabelecimento.</p>
</div>
<Button asChild className="h-12 px-8 bg-slate-900 text-indigo-400 font-black rounded-2xl uppercase tracking-widest text-[10px] italic">
<Link to="/explorar">Voltar ao Explorar</Link>
</Button>
</div>
);
}
// Estados para as escolhas parciais do utilizador
const [serviceId, setService] = useState(searchParams.get('service') || '');
const [barberId, setBarber] = useState('');
@@ -75,7 +101,6 @@ export default function Booking() {
});
}, [selectedBarber, date, barberId, appointments, user, waitlists]);
if (!shop) return <div className="text-center py-24 text-slate-500 font-black uppercase tracking-widest italic">Barbearia não encontrada</div>;
const canSubmit = serviceId && barberId && date && slot;

View File

@@ -91,6 +91,8 @@ export default function Profile() {
setReviewTarget(null)
}
const [activeTab, setActiveTab] = useState<'favoritos' | 'agenda' | 'pedidos'>('favoritos')
if (loadingAuth) {
return (
<div className="flex items-center justify-center py-20">
@@ -135,27 +137,27 @@ export default function Profile() {
/>
)}
<div className="max-w-4xl mx-auto space-y-8 sm:space-y-12 pb-12 sm:pb-20">
<div className="max-w-4xl mx-auto space-y-6 sm:space-y-12 pb-12 sm:pb-20">
{/* Cabeçalho do Perfil */}
<section className="relative overflow-hidden rounded-2xl sm:rounded-[3rem] obsidian-gradient text-white p-4 sm:p-8 md:p-12 shadow-2xl border border-white/5">
<div className="absolute top-0 right-0 w-64 h-64 bg-indigo-500/10 rounded-full blur-[80px] -translate-y-1/2 translate-x-1/2" />
<div className="relative z-10 flex flex-col md:flex-row items-center gap-8 md:text-left text-center">
<div className="relative z-10 flex flex-col md:flex-row items-center gap-6 md:gap-8 md:text-left text-center">
<div className="relative group">
<div className="absolute inset-0 bg-indigo-500 blur-2xl opacity-20 group-hover:opacity-40 transition-opacity" />
<div className="w-16 h-16 sm:w-24 sm:h-24 bg-white/10 backdrop-blur-xl border-2 border-white/20 rounded-2xl sm:rounded-[2rem] flex items-center justify-center text-indigo-400 shadow-2xl relative z-10 transition-transform duration-500 hover:rotate-6">
<User size={36} className="sm:hidden" />
<div className="w-16 h-16 sm:w-24 sm:h-24 bg-white/10 backdrop-blur-xl border-2 border-white/20 rounded-xl sm:rounded-[2rem] flex items-center justify-center text-indigo-400 shadow-2xl relative z-10 transition-transform duration-500 hover:rotate-6">
<User size={30} className="sm:hidden" />
<User size={48} className="hidden sm:block" />
</div>
</div>
<div className="space-y-3">
<div className="inline-flex items-center gap-2 px-3 py-1 bg-white/5 border border-white/10 rounded-full text-[10px] font-black uppercase tracking-[0.2em] text-indigo-400">
<Star size={12} fill="currentColor" />
<div className="space-y-2 sm:space-y-3">
<div className="inline-flex items-center gap-2 px-3 py-1 bg-white/5 border border-white/10 rounded-full text-[9px] sm:text-[10px] font-black uppercase tracking-[0.2em] text-indigo-400">
<Star size={10} fill="currentColor" />
<span>Utilizador Registado</span>
</div>
<h1 className="text-2xl sm:text-4xl md:text-5xl font-black tracking-tighter uppercase italic leading-[0.9]">
<h1 className="text-xl sm:text-4xl md:text-5xl font-black tracking-tighter uppercase italic leading-[0.9]">
{displayName}
</h1>
<p className="text-slate-400 font-medium italic">{authEmail}</p>
<p className="text-sm sm:text-lg text-slate-400 font-medium italic">{authEmail}</p>
</div>
</div>
</section>
@@ -186,167 +188,191 @@ export default function Profile() {
</section>
)}
{/* ❤️ Barbearias Favoritas - Horizontal Scroll or Grid */}
{favoriteShops.length > 0 && (
<section className="space-y-6">
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-2 text-slate-900">
<Heart size={16} className="text-rose-500 fill-rose-500" />
<h2 className="text-sm font-black uppercase tracking-[0.3em]">Cofre de Favoritos</h2>
</div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{favoriteShops.length} Espaços</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{favoriteShops.map((shop) => (
<Link key={shop.id} to={`/barbearia/${shop.id}`}>
<Card className="p-2 border-none glass-card rounded-[2rem] shadow-lg shadow-slate-200/50 hover:-translate-y-1 transition-all duration-300 group">
<div className="flex items-center gap-4 p-4">
<div className="w-16 h-16 rounded-2xl overflow-hidden border-2 border-slate-50 shadow-inner group-hover:border-indigo-200 transition-colors">
{shop.imageUrl ? (
<img src={shop.imageUrl} alt={shop.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-slate-100 flex items-center justify-center text-slate-300">
<User size={24} />
{/* Navegação por Tabs no Perfil */}
<div className="w-full max-w-[100vw] overflow-hidden px-1 sm:px-0">
<div className="flex gap-1.5 p-1 bg-white/50 backdrop-blur-md rounded-xl sm:rounded-2xl border border-white/20 shadow-sm overflow-x-auto scrollbar-hide">
{[
{ id: 'favoritos', label: 'Favoritos', icon: Heart, count: favoriteShops.length },
{ id: 'agenda', label: 'Minha Agenda', icon: Calendar, count: myAppointments.length },
{ id: 'pedidos', label: 'Pedidos', icon: ShoppingBag, count: myOrders.length },
].map((t) => (
<button
key={t.id}
onClick={() => setActiveTab(t.id as any)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg sm:rounded-xl whitespace-nowrap transition-all shrink-0 ${
activeTab === t.id
? 'bg-slate-900 text-indigo-400 shadow-lg scale-[1.02]'
: 'text-slate-500 hover:bg-slate-100'
}`}
>
<t.icon size={14} className={activeTab === t.id ? 'fill-current' : ''} />
<span className="text-[10px] sm:text-xs font-black uppercase tracking-widest">{t.label}</span>
{t.count > 0 && (
<span className={`ml-1 flex items-center justify-center min-w-[18px] h-[18px] rounded-full text-[9px] font-bold border ${
activeTab === t.id ? 'bg-indigo-500 text-white border-indigo-400' : 'bg-slate-100 text-slate-500 border-slate-200'
}`}>
{t.count}
</span>
)}
</button>
))}
</div>
</div>
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
{activeTab === 'favoritos' && (
<section className="space-y-6">
{!favoriteShops.length ? (
<Card className="p-12 sm:p-16 text-center border-none glass-card rounded-[2.5rem] sm:rounded-[3rem] shadow-xl">
<Heart size={48} className="mx-auto text-slate-100 mb-6" />
<h3 className="text-lg sm:text-xl font-black text-slate-900 uppercase italic tracking-tight">Sem Favoritos</h3>
<p className="text-slate-400 font-medium italic mt-2 text-sm">Ainda não escolheste os teus espaços preferidos.</p>
<Button asChild className="mt-8 h-10 sm:h-12 px-8 bg-slate-900 text-indigo-400 font-black rounded-xl sm:rounded-2xl uppercase tracking-widest text-[10px] italic">
<Link to="/explorar">Explorar Barbearias</Link>
</Button>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
{favoriteShops.map((shop) => (
<Link key={shop.id} to={`/barbearia/${shop.id}`}>
<Card className="p-1.5 sm:p-2 border-none glass-card rounded-2xl sm:rounded-[2rem] shadow-lg shadow-slate-200/50 hover:-translate-y-1 transition-all duration-300 group">
<div className="flex items-center gap-3 sm:gap-4 p-3 sm:p-4">
<div className="w-14 h-14 sm:w-16 sm:h-16 rounded-xl sm:rounded-2xl overflow-hidden border-2 border-slate-50 shadow-inner group-hover:border-indigo-200 transition-colors">
{shop.imageUrl ? (
<img src={shop.imageUrl} alt={shop.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-slate-100 flex items-center justify-center text-slate-300">
<User size={24} />
</div>
)}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<p className="font-black text-slate-900 uppercase italic tracking-tight group-hover:text-indigo-600 transition-colors truncate">{shop.name}</p>
<div className="flex items-center gap-3 mt-1">
<div className="flex items-center gap-1 text-[10px] font-black text-indigo-600 uppercase tracking-widest">
<Star size={10} className="fill-indigo-500" />
{shop.rating.toFixed(1)}
</div>
<div className="flex items-center gap-1 text-[10px] font-black text-slate-400 uppercase tracking-widest truncate">
<MapPin size={10} />
{shop.address.split(',')[0]}
</div>
</div>
</div>
</div>
</Card>
</Link>
))}
</div>
</section>
)}
<div className="grid lg:grid-cols-5 gap-10">
{/* Main Column: Appointments */}
<section className="lg:col-span-3 space-y-6">
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-2 text-slate-900">
<Calendar size={16} className="text-indigo-600" />
<h2 className="text-sm font-black uppercase tracking-[0.3em]">Minha Agenda</h2>
</div>
</div>
{!myAppointments.length ? (
<Card className="p-16 text-center border-none glass-card rounded-[3rem] shadow-xl">
<Calendar size={64} className="mx-auto text-slate-100 mb-6" />
<h3 className="text-xl font-black text-slate-900 uppercase italic tracking-tight">Sem Reservas</h3>
<p className="text-slate-400 font-medium italic mt-2">Sua jornada de estilo ainda não começou.</p>
<Button asChild className="mt-8 h-12 px-8 bg-slate-900 text-indigo-400 font-black rounded-2xl uppercase tracking-widest text-[10px] italic">
<Link to="/explorar">Agendar Agora</Link>
</Button>
</Card>
) : (
<div className="space-y-4">
{myAppointments.map((a) => {
const shop = shops.find((s) => s.id === a.shopId)
const service = shop?.services.find((s) => s.id === a.serviceId)
const canReview = a.status === 'concluido' && !reviewedAppointments.has(a.id)
return (
<Card key={a.id} className="p-1.5 sm:p-2 border-none glass-card rounded-2xl sm:rounded-[2.5rem] shadow-lg shadow-slate-200/50">
<div className="p-4 sm:p-6 space-y-3 sm:space-y-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<div className="flex items-center gap-3">
<h3 className="font-black text-xl text-slate-900 uppercase italic tracking-tighter">{shop?.name}</h3>
<div className={`px-2 py-0.5 rounded-full text-[8px] font-black uppercase tracking-widest bg-${statusColor[a.status]}-100 text-${statusColor[a.status]}-700`}>
{statusLabel[a.status]}
</div>
<div className="flex-1 min-w-0">
<p className="font-black text-sm sm:text-base text-slate-900 uppercase italic tracking-tight group-hover:text-indigo-600 transition-colors truncate">{shop.name}</p>
<div className="flex items-center gap-3 mt-1">
<div className="flex items-center gap-1 text-[9px] sm:text-[10px] font-black text-indigo-600 uppercase tracking-widest">
<Star size={10} className="fill-indigo-500" />
{shop.rating.toFixed(1)}
</div>
<div className="flex items-center gap-1 text-[9px] sm:text-[10px] font-black text-slate-400 uppercase tracking-widest truncate">
<MapPin size={10} />
{shop.address.split(',')[0]}
</div>
<p className="text-xs font-black text-slate-400 uppercase tracking-[0.2em]">{a.date}</p>
</div>
<div className="text-2xl font-black text-slate-900 tracking-tighter italic">
{currency(a.total)}
</div>
</div>
<div className="flex items-center justify-between pt-4 border-t border-slate-50">
{service && (
<div className="flex items-center gap-2 text-[10px] font-black text-slate-500 uppercase tracking-widest">
<Clock size={12} className="text-indigo-600" />
{service.name} · {service.duration} MIN
</div>
)}
{canReview && (
<button
onClick={() => setReviewTarget({ appointmentId: a.id, shopId: a.shopId, shopName: shop?.name ?? 'Barbearia' })}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-slate-900 text-white hover:text-indigo-400 rounded-xl transition-all duration-300 transform active:scale-95 shadow-lg shadow-indigo-500/20"
>
<Star size={12} className="fill-current" />
<span className="text-[10px] font-black uppercase tracking-widest">Avaliar Atendimento</span>
</button>
)}
{a.status === 'concluido' && reviewedAppointments.has(a.id) && (
<div className="flex items-center gap-1 text-[10px] font-black text-green-600 uppercase tracking-widest">
<CheckCircle2 size={12} />
Avaliado
</div>
)}
</div>
</div>
</Card>
)
})}
</div>
)}
</section>
{/* Side Column: Orders */}
<section className="lg:col-span-2 space-y-6">
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-2 text-slate-900">
<ShoppingBag size={16} className="text-indigo-600" />
<h2 className="text-sm font-black uppercase tracking-[0.3em]">Pedidos</h2>
</div>
</div>
{!myOrders.length ? (
<Card className="p-12 text-center border-none glass-card rounded-[3rem] shadow-lg">
<p className="text-slate-400 font-medium italic">Sem encomendas efetuadas.</p>
</Card>
) : (
<div className="space-y-4">
{myOrders.map((o) => {
const shop = shops.find((s) => s.id === o.shopId)
return (
<Card key={o.id} className="p-4 border-none glass-card rounded-[2rem] shadow shadow-slate-200/50">
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-black text-slate-900 uppercase italic tracking-tight truncate max-w-[120px]">{shop?.name}</h3>
<div className="text-lg font-black text-indigo-600 tracking-tighter">{currency(o.total)}</div>
</div>
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-50">
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">
{o.items.length} {o.items.length === 1 ? 'Item' : 'Itens'}
</Card>
</Link>
))}
</div>
)}
</section>
)}
{activeTab === 'agenda' && (
<section className="space-y-6">
{!myAppointments.length ? (
<Card className="p-16 text-center border-none glass-card rounded-[2.5rem] sm:rounded-[3rem] shadow-xl">
<Calendar size={48} className="mx-auto text-slate-100 mb-6" />
<h3 className="text-lg sm:text-xl font-black text-slate-900 uppercase italic tracking-tight">Sem Reservas</h3>
<p className="text-slate-400 font-medium italic mt-2 text-sm">Sua jornada de estilo ainda não começou.</p>
<Button asChild className="mt-8 h-10 sm:h-12 px-8 bg-slate-900 text-indigo-400 font-black rounded-xl sm:rounded-2xl uppercase tracking-widest text-[10px] italic">
<Link to="/explorar">Agendar Agora</Link>
</Button>
</Card>
) : (
<div className="grid gap-4 sm:gap-6">
{myAppointments.map((a) => {
const shop = shops.find((s) => s.id === a.shopId)
const service = shop?.services.find((s) => s.id === a.serviceId)
const canReview = a.status === 'concluido' && !reviewedAppointments.has(a.id)
return (
<Card key={a.id} className="p-1 sm:p-1.5 border-none glass-card rounded-2xl sm:rounded-[2.5rem] shadow-lg shadow-slate-200/50">
<div className="p-4 sm:p-6 space-y-3 sm:space-y-4">
<div className="flex items-start justify-between">
<div className="space-y-1">
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-3">
<h3 className="font-black text-lg sm:text-xl text-slate-900 uppercase italic tracking-tighter truncate max-w-[200px]">{shop?.name}</h3>
<div className={`w-fit px-2 py-0.5 rounded-full text-[8px] font-black uppercase tracking-widest bg-${statusColor[a.status]}-100 text-${statusColor[a.status]}-700`}>
{statusLabel[a.status]}
</div>
</div>
<p className="text-[10px] sm:text-xs font-black text-slate-400 uppercase tracking-[0.2em]">{a.date}</p>
</div>
<div className="text-xl sm:text-2xl font-black text-slate-900 tracking-tighter italic">
{currency(a.total)}
</div>
</div>
<div className={`px-2 py-0.5 rounded-full text-[7px] font-black uppercase tracking-widest bg-${statusColor[o.status]}-100 text-${statusColor[o.status]}-700`}>
{statusLabel[o.status]}
<div className="flex flex-col sm:flex-row sm:items-center justify-between pt-4 border-t border-slate-50 gap-3">
{service && (
<div className="flex items-center gap-2 text-[9px] sm:text-[10px] font-black text-slate-500 uppercase tracking-widest">
<Clock size={12} className="text-indigo-600" />
{service.name} · {service.duration} MIN
</div>
)}
{canReview && (
<button
onClick={() => setReviewTarget({ appointmentId: a.id, shopId: a.shopId, shopName: shop?.name ?? 'Barbearia' })}
className="flex items-center justify-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-slate-900 text-white hover:text-indigo-400 rounded-lg sm:rounded-xl transition-all duration-300 transform active:scale-95 shadow-lg shadow-indigo-500/20"
>
<Star size={12} className="fill-current" />
<span className="text-[9px] sm:text-[10px] font-black uppercase tracking-widest">Avaliar Atendimento</span>
</button>
)}
{a.status === 'concluido' && reviewedAppointments.has(a.id) && (
<div className="flex items-center gap-1 text-[9px] sm:text-[10px] font-black text-green-600 uppercase tracking-widest">
<CheckCircle2 size={12} />
Avaliado
</div>
)}
</div>
</div>
</div>
</Card>
)
})}
</div>
)}
</section>
</Card>
)
})}
</div>
)}
</section>
)}
{activeTab === 'pedidos' && (
<section className="space-y-6">
{!myOrders.length ? (
<Card className="p-12 sm:p-16 text-center border-none glass-card rounded-[2.5rem] sm:rounded-[3rem] shadow-xl">
<ShoppingBag size={48} className="mx-auto text-slate-100 mb-6" />
<p className="text-slate-400 font-medium italic text-sm">Sem encomendas efetuadas.</p>
</Card>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-6">
{myOrders.map((o) => {
const shop = shops.find((s) => s.id === o.shopId)
return (
<Card key={o.id} className="p-1 sm:p-1.5 border-none glass-card rounded-2xl sm:rounded-[2rem] shadow-lg shadow-slate-200/50">
<div className="p-4 sm:p-5 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-black text-sm sm:text-base text-slate-900 uppercase italic tracking-tight truncate max-w-[150px]">{shop?.name}</h3>
<div className="text-base sm:text-lg font-black text-indigo-600 tracking-tighter">{currency(o.total)}</div>
</div>
<p className="text-[9px] text-slate-400 font-medium">{new Date(o.createdAt).toLocaleString('pt-PT')}</p>
<div className="flex items-center justify-between pt-2 border-t border-slate-50">
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">
{o.items.length} {o.items.length === 1 ? 'Item' : 'Itens'}
</div>
<div className={`px-2 py-0.5 rounded-full text-[7px] font-black uppercase tracking-widest bg-${statusColor[o.status]}-100 text-${statusColor[o.status]}-700`}>
{statusLabel[o.status]}
</div>
</div>
</div>
</Card>
)
})}
</div>
)}
</section>
)}
</div>
</div>
</>

View File

@@ -23,16 +23,31 @@ export default function ShopDetails() {
const [tab, setTab] = useState<'servicos' | 'produtos' | 'barbeiros' | 'detalhes'>('servicos');
const [imageOpen, setImageOpen] = useState(false);
if (!shopsReady) {
if (!shopsReady || (shops.length === 0 && !shop)) {
return (
<div className="py-20 text-center">
<div className="inline-block w-8 h-8 border-4 border-slate-200 border-t-indigo-600 rounded-full animate-spin mb-2" />
<p className="text-sm text-slate-500">A carregar detalhes...</p>
<div className="py-20 text-center animate-pulse">
<div className="inline-block w-10 h-10 border-4 border-slate-200 border-t-indigo-600 rounded-full animate-spin mb-4" />
<p className="text-sm font-black text-slate-500 uppercase tracking-widest italic">A carregar detalhes...</p>
</div>
);
}
if (!shop) return <div>Barbearia não encontrada.</div>;
if (!shop) {
return (
<div className="py-32 text-center space-y-6">
<div className="w-20 h-20 bg-slate-100 rounded-[2rem] flex items-center justify-center mx-auto text-slate-300">
<Star size={40} />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-black text-slate-900 uppercase italic tracking-tighter">Barbearia não encontrada</h2>
<p className="text-slate-500 font-medium italic">O espaço que procura pode ter sido removido ou o link está incorreto.</p>
</div>
<Button asChild className="h-12 px-8 bg-slate-900 text-indigo-400 font-black rounded-2xl uppercase tracking-widest text-[10px] italic">
<Link to="/explorar">Explorar outros espaços</Link>
</Button>
</div>
);
}
const mapUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(
`${shop.name} ${shop.address}`
)}`;