diff --git a/web/src/components/ShopCard.tsx b/web/src/components/ShopCard.tsx index 40bc38f..6bbeeed 100644 --- a/web/src/components/ShopCard.tsx +++ b/web/src/components/ShopCard.tsx @@ -3,19 +3,17 @@ import { Star, MapPin, Scissors, Heart, Calendar, Users } from 'lucide-react'; import { BarberShop } from '../types'; import { useApp } from '../context/AppContext'; -// Paleta de gradientes para barbearias sem foto const gradients = [ + 'from-emerald-600 to-teal-700', 'from-violet-600 to-indigo-700', - 'from-amber-500 to-orange-600', - 'from-emerald-500 to-teal-700', - 'from-rose-500 to-pink-700', - 'from-sky-500 to-blue-700', 'from-slate-600 to-slate-800', + 'from-sky-600 to-blue-700', + 'from-rose-600 to-pink-700', + 'from-teal-500 to-cyan-700', ]; function shopGradient(name: string) { - const idx = name.charCodeAt(0) % gradients.length; - return gradients[idx]; + return gradients[name.charCodeAt(0) % gradients.length]; } export const ShopCard = ({ shop }: { shop: BarberShop }) => { @@ -24,77 +22,67 @@ export const ShopCard = ({ shop }: { shop: BarberShop }) => { const initials = shop.name.slice(0, 2).toUpperCase(); return ( -
- {/* Top accent strip + avatar */} -
+
+ {/* Header */} +
{/* Avatar */}
-
+
{shop.imageUrl ? ( {shop.name} ) : ( - {initials} + {initials} )}
- {/* Rating pill */} -
- + {/* Rating */} +
+ {shop.rating ? shop.rating.toFixed(1) : '—'}
- {/* Info */} + {/* Name + address */}
-
-

+
+

{shop.name}

- {/* Favorite */}
- -
- -

{shop.address || 'Endereço não disponível'}

+
+ +

{shop.address || 'Endereço não disponível'}

- -
- - - {(shop.services || []).length} serviços - - - - {(shop.barbers || []).length} barbeiros - +
+ {(shop.services || []).length} serviços + {(shop.barbers || []).length} barb.
- {/* Separator */} + {/* Divider */}
{/* Actions */}
Ver detalhes - + Agendar
diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 5e2f832..d415670 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -1,5 +1,5 @@ import { Link, useNavigate, useLocation } from 'react-router-dom' -import { ShoppingCart, User, LogOut, Menu, X, Scissors, Search } from 'lucide-react' +import { ShoppingCart, User, LogOut, Menu, X, Scissors } from 'lucide-react' import { useApp } from '../../context/AppContext' import { useState } from 'react' @@ -7,54 +7,40 @@ export const Header = () => { const { user, cart, logout } = useApp() const navigate = useNavigate() const location = useLocation() - const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + const [open, setOpen] = useState(false) - const handleLogout = () => { - logout() - navigate('/') - setMobileMenuOpen(false) - } + const handleLogout = () => { logout(); navigate('/'); setOpen(false) } - const navLink = (to: string) => - `flex items-center gap-1.5 text-sm font-medium transition-colors px-3 py-1.5 rounded-lg ${location.pathname === to - ? 'bg-amber-50 text-amber-700' - : 'text-slate-600 hover:text-amber-700 hover:bg-amber-50' - }` + const isActive = (path: string) => location.pathname === path return ( -
-
+
+
{/* Logo */} - setMobileMenuOpen(false)} - className="flex items-center gap-2 group" - > -
- + setOpen(false)} className="flex items-center gap-2"> +
+
- SmartAgenda + SmartAgenda - {/* Desktop Navigation */} + {/* Desktop nav */} - {/* Mobile right area */} + {/* Mobile */}
{user?.role !== 'barbearia' && ( - - + + {cart.length > 0 && ( - + {cart.length} )} )} -
- {/* Mobile Menu */} - {mobileMenuOpen && ( -
- +
)}
diff --git a/web/src/index.css b/web/src/index.css index daada6f..48ce529 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,4 +1,4 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,400;0,14..32,500;0,14..32,600;0,14..32,700;0,14..32,800;0,14..32,900;1,14..32,400&display=swap'); @tailwind base; @tailwind components; @@ -14,16 +14,14 @@ } body { - font-family: 'Inter', system-ui, -apple-system, sans-serif; + font-family: 'Inter', system-ui, sans-serif; @apply bg-slate-50 text-slate-900 antialiased; - font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11'; } a { @apply text-inherit no-underline; } - /* Scrollbar styling */ ::-webkit-scrollbar { @apply w-1.5 h-1.5; } @@ -35,19 +33,4 @@ ::-webkit-scrollbar-thumb { @apply bg-slate-300 rounded-full hover:bg-slate-400; } -} - -@layer utilities { - .text-balance { - text-wrap: balance; - } - - /* Smooth fade-in animation for modals */ - @keyframes fadeIn { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } - } - .animate-fadeIn { - animation: fadeIn 0.25s ease-out forwards; - } -} +} \ No newline at end of file diff --git a/web/src/pages/AuthLogin.tsx b/web/src/pages/AuthLogin.tsx index 3a0c254..1eea857 100644 --- a/web/src/pages/AuthLogin.tsx +++ b/web/src/pages/AuthLogin.tsx @@ -1,120 +1,106 @@ -/** - * @file AuthLogin.tsx - * @description Página de Autenticação (Login) para a versão Web da aplicação. - * Permite aos utilizadores (clientes e barbearias) entrarem na sua conta - * através de credenciais de email e palavra-passe, ligando-se ao fluxo de - * sessões do Supabase. - */ import { FormEvent, useEffect, useState } from 'react' import { useNavigate, Link } from 'react-router-dom' -import { Input } from '../components/ui/input' -import { Button } from '../components/ui/button' -import { Card } from '../components/ui/card' -import { LogIn } from 'lucide-react' +import { LogIn, Scissors, Eye, EyeOff } from 'lucide-react' import { supabase } from '../lib/supabase' import { useApp } from '../context/AppContext' export default function AuthLogin() { const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const [showPw, setShowPw] = useState(false) const [error, setError] = useState('') const [loading, setLoading] = useState(false) - - // Utilização do hook do react-router-dom para navegação entre páginas web const navigate = useNavigate() const { user } = useApp() - /** - * Hook executado na montagem inicial do componente. - * Interage diretamente com o AppContext para verificar - * de forma reativa se o utilizador já está logado. - * Se os dados do utilizador confirmarem autenticação ativa, redireciona o fluxo consoante a role. - */ useEffect(() => { - if (user) { - navigate(user.role === 'barbearia' ? '/painel' : '/explorar', { replace: true }) - } + if (user) navigate(user.role === 'barbearia' ? '/painel' : '/explorar', { replace: true }) }, [user, navigate]) - /** - * Manipula a submissão do formulário na view para validar as credenciais. - * @param {FormEvent} e - Evento padrão de submissão form para prevenir comportamento `submit` comum. - */ const handleLogin = async (e: FormEvent) => { e.preventDefault() setError('') setLoading(true) - try { - // Comunicação via API REST do Supabase para Login - // A biblioteca gera pedido POST com as credenciais (Email e JSON PW) para endpoint /token - const { data, error } = await supabase.auth.signInWithPassword({ - email, - password, - }) + const { data, error } = await supabase.auth.signInWithPassword({ email, password }) if (error) throw error - - // Sucesso na verificação origina redirecionamento baseado na rule (metadados guardados no Auth) const role = data.user?.user_metadata?.role - if (role) { - navigate(role === 'barbearia' ? '/painel' : '/explorar', { replace: true }) - } - } catch (e: any) { - setError('Credenciais inválidas ou email não confirmado') + if (role) navigate(role === 'barbearia' ? '/painel' : '/explorar', { replace: true }) + } catch { + setError('Email ou palavra-passe incorretos.') setLoading(false) } } return ( -
- -
-
- +
+
+ {/* Logo top */} +
+
+
-

Entrar

-

Aceda à sua conta

+

Bem-vindo de volta

+

Aceda à sua conta SmartAgenda

- {error && ( -
- {error} +
+ {error && ( +
+ {error} +
+ )} + +
+
+ + setEmail(e.target.value)} + placeholder="seu@email.com" + required + className="w-full px-3.5 py-2.5 rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent placeholder:text-slate-400" + /> +
+ +
+ +
+ setPassword(e.target.value)} + placeholder="••••••••" + required + className="w-full px-3.5 py-2.5 pr-10 rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent placeholder:text-slate-400" + /> + +
+
+ + +
+ +
+

+ Não tem conta?{' '} + + Criar conta grátis + +

- )} - -
- setEmail(e.target.value)} - placeholder="seu@email.com" - required - /> - - setPassword(e.target.value)} - placeholder="••••••••" - required - /> - - -
- -
-

- Não tem conta?{' '} - - Criar conta - -

- +
) } diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index ace7f3b..447909a 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -1,106 +1,86 @@ -/** - * @file Explore.tsx - */ import { useMemo, useState } from 'react'; import { ShopCard } from '../components/ShopCard'; import { useApp } from '../context/AppContext'; -import { Search, Heart, Scissors, SlidersHorizontal, X } from 'lucide-react'; +import { Search, Heart, X, SlidersHorizontal } from 'lucide-react'; export default function Explore() { const { shops, favorites } = useApp(); - const [query, setQuery] = useState(''); const [filter, setFilter] = useState<'todas' | 'top' | 'produtos' | 'favoritas'>('todas'); const [sortBy, setSortBy] = useState<'relevancia' | 'avaliacao' | 'preco' | 'servicos'>('relevancia'); const filtered = useMemo(() => { - const normalized = query.trim().toLowerCase(); - const matchesQuery = (name: string, address: string) => - !normalized || name.toLowerCase().includes(normalized) || address.toLowerCase().includes(normalized); - - const passesFilter = (shop: (typeof shops)[number]) => { - if (filter === 'favoritas') return favorites.includes(shop.id); - if (filter === 'top') return (shop.rating || 0) >= 4; - if (filter === 'produtos') return (shop.products || []).length > 0; + const norm = query.trim().toLowerCase(); + const matchQ = (n: string, a: string) => !norm || n.toLowerCase().includes(norm) || a.toLowerCase().includes(norm); + const matchF = (s: (typeof shops)[0]) => { + if (filter === 'favoritas') return favorites.includes(s.id); + if (filter === 'top') return (s.rating || 0) >= 4; + if (filter === 'produtos') return (s.products || []).length > 0; return true; }; - - return [...shops] - .filter((s) => matchesQuery(s.name, s.address || '')) - .filter(passesFilter) - .sort((a, b) => { - if (sortBy === 'avaliacao') return (b.rating || 0) - (a.rating || 0); - if (sortBy === 'servicos') return (b.services || []).length - (a.services || []).length; - if (sortBy === 'preco') { - const aMin = a.services?.length ? Math.min(...a.services.map((s) => s.price)) : Infinity; - const bMin = b.services?.length ? Math.min(...b.services.map((s) => s.price)) : Infinity; - return aMin - bMin; - } - if (b.rating !== a.rating) return (b.rating || 0) - (a.rating || 0); - return (b.services || []).length - (a.services || []).length; - }); + return [...shops].filter(s => matchQ(s.name, s.address || '')).filter(matchF).sort((a, b) => { + if (sortBy === 'avaliacao') return (b.rating || 0) - (a.rating || 0); + if (sortBy === 'servicos') return (b.services || []).length - (a.services || []).length; + if (sortBy === 'preco') { + const aMin = a.services?.length ? Math.min(...a.services.map(s => s.price)) : Infinity; + const bMin = b.services?.length ? Math.min(...b.services.map(s => s.price)) : Infinity; + return aMin - bMin; + } + return (b.rating || 0) - (a.rating || 0); + }); }, [shops, query, filter, sortBy, favorites]); - const chips: { id: typeof filter; label: string }[] = [ - { id: 'todas', label: 'Todas' }, - { id: 'favoritas', label: `❤️ Favoritas${favorites.length > 0 ? ` (${favorites.length})` : ''}` }, - { id: 'top', label: '⭐ Top avaliadas' }, - { id: 'produtos', label: '🛍️ Com produtos' }, + const chips = [ + { id: 'todas' as const, label: 'Todas' }, + { id: 'favoritas' as const, label: `❤️ Favoritas${favorites.length > 0 ? ` (${favorites.length})` : ''}` }, + { id: 'top' as const, label: '⭐ Melhor avaliadas' }, + { id: 'produtos' as const, label: '🛍️ Com produtos' }, ]; return ( -
- {/* Page header */} -
-
-
- - Explorar -
-

Barbearias

-
- - {filtered.length} {filtered.length === 1 ? 'resultado' : 'resultados'} - +
+ {/* Header */} +
+

Barbearias

+

Encontre e agende na sua barbearia favorita

- {/* Search bar */} + {/* Search */}
- + setQuery(e.target.value)} - placeholder="Pesquisar por nome ou endereço..." - className="w-full pl-10 pr-10 py-3 rounded-2xl border border-slate-200 bg-white text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent placeholder:text-slate-400 shadow-sm" + onChange={e => setQuery(e.target.value)} + placeholder="Pesquisar barbearia ou endereço..." + className="w-full pl-10 pr-9 py-2.5 rounded-xl border border-slate-200 bg-white text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent placeholder:text-slate-400" /> {query && ( - )}
- {/* Filters row */} + {/* Filters */}
- {chips.map((chip) => ( + {chips.map(chip => ( ))} - -
- +
+