From 42b1eb6459a273f797a154ce5398d193a9fca55e Mon Sep 17 00:00:00 2001 From: 230417 <230417@epvc.pt> Date: Tue, 27 Jan 2026 16:45:08 +0000 Subject: [PATCH] Enhance ShopCard and ShopDetails components with image support and favorite functionality; update AppContext for favorites management; improve Explore page with filtering and sorting options; refine AuthLogin and AuthRegister forms for better user experience; update types for BarberShop to include imageUrl. --- web/src/components/ShopCard.tsx | 13 ++- web/src/components/layout/Header.tsx | 46 ++++++--- web/src/components/layout/Shell.tsx | 25 +++-- web/src/context/AppContext.tsx | 29 +++++- web/src/data/mock.ts | 5 +- web/src/pages/AuthLogin.tsx | 74 +++++++-------- web/src/pages/AuthRegister.tsx | 136 +++++++++++++++++---------- web/src/pages/Explore.tsx | 114 ++++++++++++++++++++-- web/src/pages/Landing.tsx | 10 -- web/src/pages/Profile.tsx | 83 ++++++++++++---- web/src/pages/ShopDetails.tsx | 71 ++++++++++++-- web/src/types.ts | 3 +- 12 files changed, 446 insertions(+), 163 deletions(-) diff --git a/web/src/components/ShopCard.tsx b/web/src/components/ShopCard.tsx index c8667d0..fa0ed30 100644 --- a/web/src/components/ShopCard.tsx +++ b/web/src/components/ShopCard.tsx @@ -6,6 +6,18 @@ import { Button } from './ui/button'; export const ShopCard = ({ shop }: { shop: BarberShop }) => ( +
+ {shop.imageUrl ? ( + {`Foto + ) : ( +
+ )} +
@@ -45,4 +57,3 @@ export const ShopCard = ({ shop }: { shop: BarberShop }) => ( - diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 2b0972d..5456ee0 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -1,17 +1,41 @@ import { Link, useNavigate } from 'react-router-dom' import { MapPin, ShoppingCart, User, LogOut, Menu, X } from 'lucide-react' import { useApp } from '../../context/AppContext' -import { useState } from 'react' +import { useEffect, useState } from 'react' +import { supabase } from '../../lib/supabase' +import { signOut } from '../../lib/auth' export const Header = () => { - const { user, cart, logout } = useApp() + const { cart } = useApp() const navigate = useNavigate() const [mobileMenuOpen, setMobileMenuOpen] = useState(false) - const handleLogout = () => { - logout() - navigate('/') + // ✅ sessão Supabase (fonte única) + const [isAuthed, setIsAuthed] = useState(false) + + useEffect(() => { + let mounted = true + + ;(async () => { + const { data } = await supabase.auth.getSession() + if (!mounted) return + setIsAuthed(!!data.session) + })() + + const { data: sub } = supabase.auth.onAuthStateChange((_event, session) => { + setIsAuthed(!!session) + }) + + return () => { + mounted = false + sub.subscription.unsubscribe() + } + }, []) + + const handleLogout = async () => { + await signOut() setMobileMenuOpen(false) + navigate('/login', { replace: true }) } return ( @@ -47,15 +71,15 @@ export const Header = () => { )} - {user ? ( + {isAuthed ? (
-
-

💡 Conta demo:

-

Cliente: cliente@demo.com / 123

-

Barbearia: barber@demo.com / 123

-
+ {error && ( +
+ {error} +
+ )}
{ - setEmail(e.target.value) - setError('') - }} - required + onChange={(e) => setEmail(e.target.value)} placeholder="seu@email.com" + required /> { - setPassword(e.target.value) - setError('') - }} - required - error={error} + onChange={(e) => setPassword(e.target.value)} placeholder="••••••••" + required /> -

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

diff --git a/web/src/pages/AuthRegister.tsx b/web/src/pages/AuthRegister.tsx index dabb667..e9bb813 100644 --- a/web/src/pages/AuthRegister.tsx +++ b/web/src/pages/AuthRegister.tsx @@ -1,36 +1,69 @@ -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 { useApp } from '../context/AppContext'; -import { UserPlus, User, Scissors } from 'lucide-react'; +import { FormEvent, 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 { UserPlus, User, Scissors } from 'lucide-react' +import { supabase } from '../lib/supabase' export default function AuthRegister() { - const [name, setName] = useState(''); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [role, setRole] = useState<'cliente' | 'barbearia'>('cliente'); - const [shopName, setShopName] = useState(''); - const [error, setError] = useState(''); - const { register, user } = useApp(); - const navigate = useNavigate(); + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [role, setRole] = useState<'cliente' | 'barbearia'>('cliente') + const [shopName, setShopName] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) - useEffect(() => { - if (!user) return; - const target = user.role === 'barbearia' ? '/painel' : '/explorar'; - navigate(target, { replace: true }); - }, [user, navigate]); + const navigate = useNavigate() - const onSubmit = (e: FormEvent) => { - e.preventDefault(); - const ok = register({ name, email, password, role, shopName }); - if (!ok) setError('Email já registado'); - else { - const target = role === 'barbearia' ? '/painel' : '/explorar'; - navigate(target); + const onSubmit = async (e: FormEvent) => { + e.preventDefault() + setError('') + + if (!name.trim()) return setError('Preencha o nome completo') + if (!email.trim()) return setError('Preencha o email') + if (!password.trim()) return setError('Preencha a senha') + if (role === 'barbearia' && !shopName.trim()) return setError('Informe o nome da barbearia') + + setLoading(true) + + try { + // 1) Criar conta no Supabase Auth + const { data, error: signUpError } = await supabase.auth.signUp({ + email, + password, + }) + if (signUpError) throw signUpError + + const userId = data.user?.id + if (!userId) { + // Em alguns casos o user pode exigir confirmação por email + // Mesmo assim, mostramos uma mensagem útil. + throw new Error('Conta criada, mas sessão não iniciada. Verifica o email para confirmar a conta.') + } + + // 2) Criar perfil na tabela public.profiles + const { error: profileError } = await supabase.from('profiles').insert({ + id: userId, + name, + role, + shop_name: role === 'barbearia' ? shopName : null, + }) + if (profileError) throw profileError + + // 3) Redirecionar + navigate('/explorar', { replace: true }) + } catch (e: any) { + // Mensagens comuns + const msg = + e?.message || + (typeof e === 'string' ? e : 'Erro ao criar conta') + setError(msg) + } finally { + setLoading(false) } - }; + } return (
@@ -43,6 +76,12 @@ export default function AuthRegister() {

Escolha o tipo de acesso

+ {error && ( +
+ {error} +
+ )} +
{/* Role Selection */}
@@ -53,8 +92,8 @@ export default function AuthRegister() { key={r} type="button" onClick={() => { - setRole(r); - setError(''); + setRole(r) + setError('') }} className={`p-4 rounded-xl border-2 transition-all ${ role === r @@ -81,46 +120,48 @@ export default function AuthRegister() { label="Nome completo" value={name} onChange={(e) => { - setName(e.target.value); - setError(''); + setName(e.target.value) + setError('') }} - required placeholder="João Silva" /> + { - setEmail(e.target.value); - setError(''); + setEmail(e.target.value) + setError('') }} - required placeholder="seu@email.com" - error={error} /> + { - setPassword(e.target.value); - setError(''); + setPassword(e.target.value) + setError('') }} - required placeholder="••••••••" /> + {role === 'barbearia' && ( setShopName(e.target.value)} + onChange={(e) => { + setShopName(e.target.value) + setError('') + }} placeholder="Barbearia XPTO" - required /> )} - @@ -134,10 +175,5 @@ export default function AuthRegister() {
- ); + ) } - - - - - diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index 31e1db9..38e5052 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -1,22 +1,118 @@ +import { useMemo, useState } from 'react'; import { ShopCard } from '../components/ShopCard'; +import { Card } from '../components/ui/card'; +import { Chip } from '../components/ui/chip'; +import { Input } from '../components/ui/input'; import { useApp } from '../context/AppContext'; +import { Search } from 'lucide-react'; export default function Explore() { const { shops } = useApp(); + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState<'todas' | 'top' | 'produtos' | 'barbeiros' | 'servicos'>('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 === 'top') return shop.rating >= 4.7; + if (filter === 'produtos') return shop.products.length > 0; + if (filter === 'barbeiros') return shop.barbers.length >= 2; + if (filter === 'servicos') return shop.services.length >= 2; + return true; + }; + + const sorted = [...shops] + .filter((shop) => matchesQuery(shop.name, shop.address)) + .filter(passesFilter) + .sort((a, b) => { + if (sortBy === 'avaliacao') return b.rating - a.rating; + if (sortBy === 'servicos') return b.services.length - a.services.length; + if (sortBy === 'preco') { + const aMin = Math.min(...a.services.map((s) => s.price)); + const bMin = Math.min(...b.services.map((s) => s.price)); + return aMin - bMin; + } + if (b.rating !== a.rating) return b.rating - a.rating; + return b.services.length - a.services.length; + }); + + return sorted; + }, [shops, query, filter, sortBy]); return ( -
-

Explorar barbearias

-
- {shops.map((shop) => ( - - ))} -
+
+
+

Explorar

+
+
+

Barbearias

+

Escolha a sua favorita e agende em minutos.

+
+
{filtered.length} resultados
+
+
+ + +
+
+ setQuery(e.target.value)} + placeholder="Pesquisar por nome ou endereço..." + className="pl-11" + /> + +
+ +
+ +
+ setFilter('todas')}> + Todas + + setFilter('top')}> + Top avaliadas + + setFilter('produtos')}> + Com produtos + + setFilter('barbeiros')}> + Mais barbeiros + + setFilter('servicos')}> + Mais serviços + +
+
+ + {filtered.length === 0 ? ( + +

Nenhuma barbearia encontrada

+

Tente ajustar a pesquisa ou limpar os filtros.

+
+ ) : ( +
+ {filtered.map((shop) => ( + + ))} +
+ )}
); } - - diff --git a/web/src/pages/Landing.tsx b/web/src/pages/Landing.tsx index 874478a..7ae5644 100644 --- a/web/src/pages/Landing.tsx +++ b/web/src/pages/Landing.tsx @@ -8,20 +8,11 @@ import { ArrowRight, Star, Quote, Scissors, MapPin, Zap, Smartphone, Globe } from 'lucide-react'; -import { useEffect } from 'react'; import { useApp } from '../context/AppContext'; import { mockShops } from '../data/mock'; export default function Landing() { const { user } = useApp(); - const navigate = useNavigate(); - - useEffect(() => { - if (!user) return; - const target = user.role === 'barbearia' ? '/painel' : '/explorar'; - navigate(target, { replace: true }); - }, [user, navigate]); - const featuredShops = mockShops.slice(0, 3); return ( @@ -328,4 +319,3 @@ export default function Landing() { - diff --git a/web/src/pages/Profile.tsx b/web/src/pages/Profile.tsx index 57934a9..6e6b10a 100644 --- a/web/src/pages/Profile.tsx +++ b/web/src/pages/Profile.tsx @@ -1,10 +1,11 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Card } from '../components/ui/card' import { Badge } from '../components/ui/badge' import { currency } from '../lib/format' import { useApp } from '../context/AppContext' import { Calendar, ShoppingBag, User, Clock } from 'lucide-react' import { listEvents, type EventRow } from '../lib/events' +import { supabase } from '../lib/supabase' const statusColor: Record = { pendente: 'amber', @@ -21,13 +22,40 @@ const statusLabel: Record = { } export default function Profile() { - const { user, appointments, orders, shops } = useApp() + const { appointments, orders, shops } = useApp() - // ✅ Supabase events + // ✅ Utilizador Supabase + const [authEmail, setAuthEmail] = useState('') + const [authId, setAuthId] = useState('') + const [loadingAuth, setLoadingAuth] = useState(true) + + // ✅ Eventos Supabase const [events, setEvents] = useState([]) const [loadingEvents, setLoadingEvents] = useState(true) const [eventsError, setEventsError] = useState('') + useEffect(() => { + let mounted = true + + ;(async () => { + const { data, error } = await supabase.auth.getUser() + if (!mounted) return + + if (error || !data.user) { + setAuthEmail('') + setAuthId('') + } else { + setAuthEmail(data.user.email ?? '') + setAuthId(data.user.id) + } + setLoadingAuth(false) + })() + + return () => { + mounted = false + } + }, []) + useEffect(() => { ;(async () => { try { @@ -35,23 +63,47 @@ export default function Profile() { const data = await listEvents() setEvents(data) } catch (e: any) { - setEventsError(e.message || 'Erro ao carregar eventos') + setEventsError(e?.message || 'Erro ao carregar eventos') } finally { setLoadingEvents(false) } })() }, []) - if (!user) { + // Se quiseres filtrar por utilizador Supabase, fica assim. + // (Só vai funcionar quando os teus appointments/orders tiverem customerId = authId) + const myAppointments = useMemo(() => { + if (!authId) return [] + return appointments.filter((a) => a.customerId === authId) + }, [appointments, authId]) + + const myOrders = useMemo(() => { + if (!authId) return [] + return orders.filter((o) => o.customerId === authId) + }, [orders, authId]) + + // Para já, se ainda não tens customerId igual ao authId, podes mostrar tudo: + // const myAppointments = appointments + // const myOrders = orders + + if (loadingAuth) { return (
-

Faça login para ver o perfil.

+

A carregar perfil...

) } - const myAppointments = appointments.filter((a) => a.customerId === user.id) - const myOrders = orders.filter((o) => o.customerId === user.id) + // Se não houver user Supabase aqui, em teoria o RequireAuth já devia redirecionar. + if (!authId) { + return ( +
+

Sessão não encontrada. Faz login novamente.

+
+ ) + } + + const displayName = authEmail ? authEmail.split('@')[0] : 'Utilizador' return (
@@ -62,10 +114,10 @@ export default function Profile() {
-

Olá, {user.name}

-

{user.email}

+

Olá, {displayName}

+

{authEmail}

- {user.role === 'cliente' ? 'Cliente' : 'Barbearia'} + Utilizador
@@ -88,17 +140,12 @@ export default function Profile() { ) : eventsError ? (

{eventsError}

-

- Confirma que estás logado e que as policies RLS estão corretas. -

) : events.length === 0 ? (

Nenhum evento ainda

-

- Cria um evento para aparecer aqui. -

+

Cria um evento para aparecer aqui.

) : (
@@ -128,6 +175,7 @@ export default function Profile() {

Agendamentos

{myAppointments.length}
+ {!myAppointments.length ? ( @@ -175,6 +223,7 @@ export default function Profile() {

Pedidos

{myOrders.length}
+ {!myOrders.length ? ( diff --git a/web/src/pages/ShopDetails.tsx b/web/src/pages/ShopDetails.tsx index 8cf89ac..0a5a29f 100644 --- a/web/src/pages/ShopDetails.tsx +++ b/web/src/pages/ShopDetails.tsx @@ -1,25 +1,77 @@ -import { useParams, Link } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; import { useMemo, useState } from 'react'; import { Tabs } from '../components/ui/tabs'; import { ServiceList } from '../components/ServiceList'; import { ProductList } from '../components/ProductList'; import { Button } from '../components/ui/button'; import { useApp } from '../context/AppContext'; +import { Dialog } from '../components/ui/dialog'; +import { Heart, MapPin, Maximize2, Star } from 'lucide-react'; export default function ShopDetails() { const { id } = useParams<{ id: string }>(); - const { shops, addToCart } = useApp(); + const { shops, addToCart, toggleFavorite, isFavorite } = useApp(); const shop = useMemo(() => shops.find((s) => s.id === id), [shops, id]); const [tab, setTab] = useState<'servicos' | 'produtos'>('servicos'); + const [imageOpen, setImageOpen] = useState(false); if (!shop) return
Barbearia não encontrada.
; + const mapUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent( + `${shop.name} ${shop.address}` + )}`; return (
+
+ {shop.imageUrl ? ( + {`Foto + ) : ( +
+ )} +
+
+ + + + + +
+
+
+ + {shop.rating.toFixed(1)} +
+

{shop.name}

+

{shop.address}

+
+
+
-
-

{shop.name}

-

{shop.address}

+
+ {shop.services.length} serviços · {shop.barbers.length} barbeiros
); } @@ -48,4 +108,3 @@ export default function ShopDetails() { - diff --git a/web/src/types.ts b/web/src/types.ts index fecd1c7..30fd4a2 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1,7 +1,7 @@ export type Barber = { id: string; name: string; specialties: string[]; schedule: { day: string; slots: string[] }[] }; export type Service = { id: string; name: string; price: number; duration: number; barberIds: string[] }; export type Product = { id: string; name: string; price: number; stock: number }; -export type BarberShop = { id: string; name: string; address: string; rating: number; services: Service[]; products: Product[]; barbers: Barber[] }; +export type BarberShop = { id: string; name: string; address: string; rating: number; services: Service[]; products: Product[]; barbers: Barber[]; imageUrl?: string }; export type AppointmentStatus = 'pendente' | 'confirmado' | 'concluido' | 'cancelado'; export type OrderStatus = 'pendente' | 'confirmado' | 'concluido' | 'cancelado'; export type Appointment = { id: string; shopId: string; serviceId: string; barberId: string; customerId: string; date: string; status: AppointmentStatus; total: number }; @@ -10,4 +10,3 @@ export type Order = { id: string; shopId: string; customerId: string; items: Car export type User = { id: string; name: string; email: string; password: string; role: 'cliente' | 'barbearia'; shopId?: string }; -