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.

This commit is contained in:
2026-01-27 16:45:08 +00:00
parent 2d1f09154f
commit 42b1eb6459
12 changed files with 446 additions and 163 deletions

View File

@@ -6,6 +6,18 @@ import { Button } from './ui/button';
export const ShopCard = ({ shop }: { shop: BarberShop }) => (
<Card hover className="p-6 space-y-4 group">
<div className="relative overflow-hidden rounded-xl border border-slate-100 bg-slate-100">
{shop.imageUrl ? (
<img
src={shop.imageUrl}
alt={`Foto de ${shop.name}`}
className="h-36 w-full object-cover"
loading="lazy"
/>
) : (
<div className="h-36 w-full bg-gradient-to-br from-slate-900 via-slate-700 to-slate-500" />
)}
</div>
<div className="flex items-start justify-between">
<div className="space-y-2 flex-1">
<div className="flex items-center gap-2">
@@ -45,4 +57,3 @@ export const ShopCard = ({ shop }: { shop: BarberShop }) => (

View File

@@ -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 = () => {
)}
</Link>
{user ? (
{isAuthed ? (
<div className="flex items-center gap-2">
<button
onClick={() => navigate(user.role === 'barbearia' ? '/painel' : '/perfil')}
onClick={() => navigate('/perfil')}
className="flex items-center gap-1.5 text-sm font-medium text-slate-700 hover:text-indigo-600 transition-colors px-3 py-1.5 rounded-lg hover:bg-indigo-50"
type="button"
>
<User size={16} />
<span className="max-w-[120px] truncate">{user.name}</span>
<span className="max-w-[120px] truncate">Perfil</span>
</button>
<button
@@ -114,18 +138,18 @@ export const Header = () => {
)}
</Link>
{user ? (
{isAuthed ? (
<>
<button
onClick={() => {
navigate(user.role === 'barbearia' ? '/painel' : '/perfil')
navigate('/perfil')
setMobileMenuOpen(false)
}}
className="w-full flex items-center gap-2 text-sm font-medium text-slate-700 hover:text-amber-600 transition-colors px-3 py-2 rounded-lg hover:bg-amber-50 text-left"
type="button"
>
<User size={16} />
{user.name}
Perfil
</button>
<button

View File

@@ -1,14 +1,13 @@
import { Outlet } from 'react-router-dom';
import { Header } from './Header';
export const Shell = () => (
<div className="min-h-screen bg-slate-50">
<Header />
<main className="mx-auto max-w-5xl px-4 py-6">
<Outlet />
</main>
</div>
);
import { Outlet } from 'react-router-dom'
import { Header } from './Header'
export function Shell() {
return (
<>
<Header />
<main className="mx-auto max-w-5xl px-4 py-6">
<Outlet />
</main>
</>
)
}

View File

@@ -11,12 +11,15 @@ type State = {
appointments: Appointment[];
orders: Order[];
cart: CartItem[];
favorites: string[];
};
type AppContextValue = State & {
login: (email: string, password: string) => boolean;
logout: () => void;
register: (payload: Omit<User, 'id' | 'shopId'> & { shopName?: string }) => boolean;
toggleFavorite: (shopId: string) => void;
isFavorite: (shopId: string) => boolean;
addToCart: (item: CartItem) => void;
removeFromCart: (refId: string) => void;
clearCart: () => void;
@@ -42,12 +45,20 @@ const initialState: State = {
appointments: [],
orders: [],
cart: [],
favorites: [],
};
const AppContext = createContext<AppContextValue | undefined>(undefined);
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const [state, setState] = useState<State>(() => storage.get('smart-agenda', initialState));
const [state, setState] = useState<State>(() => {
const stored = storage.get('smart-agenda', initialState) as State;
return {
...initialState,
...stored,
favorites: stored?.favorites ?? [],
};
});
useEffect(() => {
storage.set('smart-agenda', state);
@@ -64,6 +75,18 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const logout = () => setState((s) => ({ ...s, user: undefined }));
const toggleFavorite = (shopId: string) => {
setState((s) => {
const exists = s.favorites.includes(shopId);
return {
...s,
favorites: exists ? s.favorites.filter((id) => id !== shopId) : [...s.favorites, shopId],
};
});
};
const isFavorite = (shopId: string) => (state.favorites ?? []).includes(shopId);
const register: AppContextValue['register'] = ({ shopName, ...payload }) => {
const exists = state.users.some((u) => u.email === payload.email);
if (exists) return false;
@@ -269,6 +292,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
...state,
login,
logout,
toggleFavorite,
isFavorite,
register,
addToCart,
removeFromCart,
@@ -298,5 +323,3 @@ export const useApp = () => {
if (!ctx) throw new Error('useApp deve ser usado dentro de AppProvider');
return ctx;
};

View File

@@ -11,6 +11,8 @@ export const mockShops: BarberShop[] = [
name: 'Barbearia Central',
address: 'Rua Principal, 123',
rating: 4.7,
imageUrl:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='480'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='%23111827'/%3E%3Cstop offset='100%25' stop-color='%232563eb'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='800' height='480' fill='url(%23g)'/%3E%3Ccircle cx='640' cy='120' r='120' fill='%23ffffff22'/%3E%3Crect x='80' y='320' width='300' height='12' fill='%23ffffff66'/%3E%3Crect x='80' y='350' width='220' height='10' fill='%23ffffff55'/%3E%3C/text%3E%3C/svg%3E",
barbers: [
{ id: 'b1', name: 'João', specialties: ['Fade', 'Navalha'], schedule: [{ day: '2025-01-01', slots: ['09:00', '10:00', '11:00'] }] },
{ id: 'b2', name: 'Carlos', specialties: ['Barba', 'Clássico'], schedule: [{ day: '2025-01-01', slots: ['14:00', '15:00'] }] },
@@ -29,6 +31,8 @@ export const mockShops: BarberShop[] = [
name: 'Barbearia Bairro',
address: 'Av. Verde, 45',
rating: 4.5,
imageUrl:
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='480'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='%230f172a'/%3E%3Cstop offset='100%25' stop-color='%230ea5e9'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='800' height='480' fill='url(%23g)'/%3E%3Crect x='60' y='90' width='260' height='180' rx='24' fill='%23ffffff1f'/%3E%3Crect x='380' y='260' width='300' height='12' fill='%23ffffff66'/%3E%3Crect x='380' y='290' width='180' height='10' fill='%23ffffff55'/%3E%3C/svg%3E",
barbers: [
{ id: 'b3', name: 'Miguel', specialties: ['Clássico', 'Fade'], schedule: [{ day: '2025-01-01', slots: ['09:30', '10:30'] }] },
],
@@ -38,4 +42,3 @@ export const mockShops: BarberShop[] = [
];

View File

@@ -4,33 +4,41 @@ import { Input } from '../components/ui/input'
import { Button } from '../components/ui/button'
import { Card } from '../components/ui/card'
import { LogIn } from 'lucide-react'
import { signIn, getUser } from '../lib/auth'
import { supabase } from '../lib/supabase'
export default function AuthLogin() {
const [email, setEmail] = useState('cliente@demo.com')
const [password, setPassword] = useState('123')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
// Se já estiver logado, redireciona
// Se já houver sessão, vai direto
useEffect(() => {
;(async () => {
const user = await getUser()
if (user) {
supabase.auth.getSession().then(({ data }) => {
if (data.session) {
navigate('/explorar', { replace: true })
}
})()
})
}, [navigate])
async function handleLogin(e: FormEvent) {
const handleLogin = async (e: FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
await signIn(email, password)
const { error } = await supabase.auth.signInWithPassword({
email,
password,
})
if (error) throw error
navigate('/explorar', { replace: true })
} catch {
setError('Credenciais inválidas')
} catch (e: any) {
setError('Credenciais inválidas ou email não confirmado')
} finally {
setLoading(false)
}
}
@@ -41,58 +49,44 @@ export default function AuthLogin() {
<div className="inline-flex p-3 bg-gradient-to-br from-amber-500 to-amber-600 rounded-xl text-white shadow-lg mb-2">
<LogIn size={24} />
</div>
<h1 className="text-2xl font-bold text-slate-900">
Bem-vindo de volta
</h1>
<p className="text-sm text-slate-600">
Entre na sua conta para continuar
</p>
<h1 className="text-2xl font-bold text-slate-900">Entrar</h1>
<p className="text-sm text-slate-600">Aceda à sua conta</p>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-xs text-amber-800">
<p className="font-semibold mb-1">💡 Conta demo:</p>
<p>Cliente: cliente@demo.com / 123</p>
<p>Barbearia: barber@demo.com / 123</p>
</div>
{error && (
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{error}
</div>
)}
<form className="space-y-4" onSubmit={handleLogin}>
<Input
label="Email"
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value)
setError('')
}}
required
onChange={(e) => setEmail(e.target.value)}
placeholder="seu@email.com"
required
/>
<Input
label="Senha"
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value)
setError('')
}}
required
error={error}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
/>
<Button type="submit" className="w-full" size="lg">
Entrar
<Button type="submit" className="w-full" size="lg" disabled={loading}>
{loading ? 'A entrar...' : 'Entrar'}
</Button>
</form>
<div className="text-center pt-4 border-t border-slate-200">
<p className="text-sm text-slate-600">
Não tem conta?{' '}
<Link
to="/registo"
className="text-amber-700 font-semibold hover:text-amber-800 transition-colors"
>
<Link to="/registo" className="text-amber-700 font-semibold hover:text-amber-800 transition-colors">
Criar conta
</Link>
</p>

View File

@@ -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 (
<div className="max-w-md mx-auto py-8">
@@ -43,6 +76,12 @@ export default function AuthRegister() {
<p className="text-sm text-slate-600">Escolha o tipo de acesso</p>
</div>
{error && (
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{error}
</div>
)}
<form className="space-y-5" onSubmit={onSubmit}>
{/* Role Selection */}
<div className="space-y-2">
@@ -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"
/>
<Input
label="Email"
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError('');
setEmail(e.target.value)
setError('')
}}
required
placeholder="seu@email.com"
error={error}
/>
<Input
label="Senha"
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError('');
setPassword(e.target.value)
setError('')
}}
required
placeholder="••••••••"
/>
{role === 'barbearia' && (
<Input
label="Nome da barbearia"
value={shopName}
onChange={(e) => setShopName(e.target.value)}
onChange={(e) => {
setShopName(e.target.value)
setError('')
}}
placeholder="Barbearia XPTO"
required
/>
)}
<Button type="submit" className="w-full" size="lg">
Criar conta
<Button type="submit" className="w-full" size="lg" disabled={loading}>
{loading ? 'A criar...' : 'Criar conta'}
</Button>
</form>
@@ -134,10 +175,5 @@ export default function AuthRegister() {
</div>
</Card>
</div>
);
)
}

View File

@@ -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 (
<div className="space-y-4">
<h1 className="text-xl font-semibold text-slate-900">Explorar barbearias</h1>
<div className="grid md:grid-cols-2 gap-4">
{shops.map((shop) => (
<ShopCard key={shop.id} shop={shop} />
))}
</div>
<div className="space-y-6">
<section className="space-y-2">
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Explorar</p>
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-2xl md:text-3xl font-semibold text-slate-900">Barbearias</h1>
<p className="text-sm text-slate-600">Escolha a sua favorita e agende em minutos.</p>
</div>
<div className="text-sm text-slate-500">{filtered.length} resultados</div>
</div>
</section>
<Card className="p-4 md:p-5">
<div className="grid gap-3 md:grid-cols-[1.3fr_auto] md:items-center">
<div className="relative">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Pesquisar por nome ou endereço..."
className="pl-11"
/>
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" />
</div>
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-sm"
>
<option value="relevancia">Relevância</option>
<option value="avaliacao">Melhor avaliação</option>
<option value="preco">Menor preço</option>
<option value="servicos">Mais serviços</option>
</select>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Chip active={filter === 'todas'} onClick={() => setFilter('todas')}>
Todas
</Chip>
<Chip active={filter === 'top'} onClick={() => setFilter('top')}>
Top avaliadas
</Chip>
<Chip active={filter === 'produtos'} onClick={() => setFilter('produtos')}>
Com produtos
</Chip>
<Chip active={filter === 'barbeiros'} onClick={() => setFilter('barbeiros')}>
Mais barbeiros
</Chip>
<Chip active={filter === 'servicos'} onClick={() => setFilter('servicos')}>
Mais serviços
</Chip>
</div>
</Card>
{filtered.length === 0 ? (
<Card className="p-8 text-center space-y-2">
<p className="text-lg font-semibold text-slate-900">Nenhuma barbearia encontrada</p>
<p className="text-sm text-slate-600">Tente ajustar a pesquisa ou limpar os filtros.</p>
</Card>
) : (
<div className="grid md:grid-cols-2 gap-4">
{filtered.map((shop) => (
<ShopCard key={shop.id} shop={shop} />
))}
</div>
)}
</div>
);
}

View File

@@ -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() {

View File

@@ -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<string, 'amber' | 'green' | 'slate' | 'red'> = {
pendente: 'amber',
@@ -21,13 +22,40 @@ const statusLabel: Record<string, string> = {
}
export default function Profile() {
const { user, appointments, orders, shops } = useApp()
const { appointments, orders, shops } = useApp()
// ✅ Supabase events
// ✅ Utilizador Supabase
const [authEmail, setAuthEmail] = useState<string>('')
const [authId, setAuthId] = useState<string>('')
const [loadingAuth, setLoadingAuth] = useState(true)
// ✅ Eventos Supabase
const [events, setEvents] = useState<EventRow[]>([])
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 (
<div className="text-center py-12">
<p className="text-slate-600">Faça login para ver o perfil.</p>
<p className="text-slate-600">A carregar perfil...</p>
</div>
)
}
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 (
<div className="text-center py-12">
<p className="text-slate-600">Sessão não encontrada. Faz login novamente.</p>
</div>
)
}
const displayName = authEmail ? authEmail.split('@')[0] : 'Utilizador'
return (
<div className="space-y-8">
@@ -62,10 +114,10 @@ export default function Profile() {
<User size={24} />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900">Olá, {user.name}</h1>
<p className="text-sm text-slate-600">{user.email}</p>
<h1 className="text-2xl font-bold text-slate-900">Olá, {displayName}</h1>
<p className="text-sm text-slate-600">{authEmail}</p>
<Badge color="amber" variant="soft" className="mt-2">
{user.role === 'cliente' ? 'Cliente' : 'Barbearia'}
Utilizador
</Badge>
</div>
</div>
@@ -88,17 +140,12 @@ export default function Profile() {
) : eventsError ? (
<Card className="p-8 text-center">
<p className="text-rose-600 font-medium">{eventsError}</p>
<p className="text-sm text-slate-500 mt-1">
Confirma que estás logado e que as policies RLS estão corretas.
</p>
</Card>
) : events.length === 0 ? (
<Card className="p-8 text-center">
<Calendar size={48} className="mx-auto text-slate-300 mb-3" />
<p className="text-slate-600 font-medium">Nenhum evento ainda</p>
<p className="text-sm text-slate-500 mt-1">
Cria um evento para aparecer aqui.
</p>
<p className="text-sm text-slate-500 mt-1">Cria um evento para aparecer aqui.</p>
</Card>
) : (
<div className="space-y-3">
@@ -128,6 +175,7 @@ export default function Profile() {
<h2 className="text-xl font-bold text-slate-900">Agendamentos</h2>
<Badge color="slate" variant="soft">{myAppointments.length}</Badge>
</div>
{!myAppointments.length ? (
<Card className="p-8 text-center">
<Calendar size={48} className="mx-auto text-slate-300 mb-3" />
@@ -175,6 +223,7 @@ export default function Profile() {
<h2 className="text-xl font-bold text-slate-900">Pedidos</h2>
<Badge color="slate" variant="soft">{myOrders.length}</Badge>
</div>
{!myOrders.length ? (
<Card className="p-8 text-center">
<ShoppingBag size={48} className="mx-auto text-slate-300 mb-3" />

View File

@@ -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 <div>Barbearia não encontrada.</div>;
const mapUrl = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(
`${shop.name} ${shop.address}`
)}`;
return (
<div className="space-y-4">
<div className="relative overflow-hidden rounded-2xl border border-slate-200 bg-slate-100">
{shop.imageUrl ? (
<img src={shop.imageUrl} alt={`Foto de ${shop.name}`} className="h-48 w-full object-cover md:h-64" />
) : (
<div className="h-48 w-full bg-gradient-to-br from-slate-900 via-slate-700 to-slate-500 md:h-64" />
)}
<div className="absolute inset-0 bg-gradient-to-t from-slate-900/70 via-slate-900/15 to-transparent" />
<div className="absolute right-4 top-4 flex gap-2">
<a
href={mapUrl}
target="_blank"
rel="noreferrer"
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/90 text-slate-800 shadow-md hover:bg-white"
aria-label="Abrir no Google Maps"
>
<MapPin size={18} />
</a>
<button
onClick={() => toggleFavorite(shop.id)}
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/90 text-slate-800 shadow-md hover:bg-white"
aria-label={isFavorite(shop.id) ? 'Remover dos favoritos' : 'Adicionar aos favoritos'}
type="button"
>
<Heart
size={18}
className={isFavorite(shop.id) ? 'fill-rose-500 text-rose-500' : 'text-slate-700'}
/>
</button>
<button
onClick={() => setImageOpen(true)}
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/90 text-slate-800 shadow-md hover:bg-white"
aria-label="Ampliar foto"
type="button"
>
<Maximize2 size={18} />
</button>
</div>
<div className="absolute bottom-4 left-4 space-y-1 text-white">
<div className="flex items-center gap-2 text-sm">
<Star size={14} className="fill-amber-400 text-amber-400" />
<span className="font-semibold">{shop.rating.toFixed(1)}</span>
</div>
<h1 className="text-2xl font-semibold">{shop.name}</h1>
<p className="text-sm text-white/80">{shop.address}</p>
</div>
</div>
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-slate-900">{shop.name}</h1>
<p className="text-sm text-slate-600">{shop.address}</p>
<div className="text-sm text-slate-600">
{shop.services.length} serviços · {shop.barbers.length} barbeiros
</div>
<Button asChild>
<Link to={`/agendar/${shop.id}`}>Agendar</Link>
@@ -41,6 +93,14 @@ export default function ShopDetails() {
) : (
<ProductList products={shop.products} onAdd={(pid) => addToCart({ shopId: shop.id, type: 'product', refId: pid, qty: 1 })} />
)}
<Dialog open={imageOpen} title={shop.name} onClose={() => setImageOpen(false)}>
{shop.imageUrl ? (
<img src={shop.imageUrl} alt={`Foto de ${shop.name}`} className="w-full rounded-lg object-cover" />
) : (
<div className="h-64 w-full rounded-lg bg-gradient-to-br from-slate-900 via-slate-700 to-slate-500" />
)}
</Dialog>
</div>
);
}
@@ -48,4 +108,3 @@ export default function ShopDetails() {

View File

@@ -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 };