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:
@@ -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 }) => (
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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[] = [
|
||||
];
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user