mudanças design
This commit is contained in:
@@ -1,62 +1,91 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Star, MapPin, Scissors } from 'lucide-react';
|
||||
import { Star, MapPin, Scissors, Heart, Calendar } from 'lucide-react';
|
||||
import { BarberShop } from '../types';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { useApp } from '../context/AppContext';
|
||||
|
||||
export const ShopCard = ({ shop, compact = false }: { shop: BarberShop; compact?: boolean }) => {
|
||||
const { toggleFavorite, isFavorite } = useApp();
|
||||
const favorite = isFavorite(shop.id);
|
||||
|
||||
export const ShopCard = ({ shop }: { shop: BarberShop }) => {
|
||||
return (
|
||||
<Card hover className="p-4 sm:p-5 flex flex-col w-full group">
|
||||
<div className="flex gap-4">
|
||||
{/* Avatar Circular com Badge de Rating */}
|
||||
<div className="relative shrink-0 mt-1">
|
||||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full border-2 border-slate-100 overflow-hidden bg-white flex items-center justify-center shadow-sm">
|
||||
{shop.imageUrl ? (
|
||||
<img src={shop.imageUrl} alt={shop.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="text-slate-400 font-black text-center leading-none flex flex-col items-center justify-center h-full w-full bg-slate-50">
|
||||
<Scissors size={24} className="text-slate-400 mb-1" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Rating Badge - Posicionado em cima à direita como na imagem base */}
|
||||
<div className="absolute -top-1 -right-2 bg-slate-800 border border-slate-700 px-2 py-0.5 rounded-full flex items-center gap-[2px] shadow-sm z-10">
|
||||
<Star size={11} className="fill-amber-400 text-amber-400" />
|
||||
<span className="text-white text-[11px] font-semibold tracking-wide">
|
||||
{shop.rating ? shop.rating.toFixed(1) : '0.0'}
|
||||
</span>
|
||||
<div className="group relative flex flex-col bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-lg border border-slate-100 hover:border-amber-200 transition-all duration-300">
|
||||
{/* Cover image */}
|
||||
<div className="relative h-40 bg-gradient-to-br from-slate-800 to-slate-900 overflow-hidden flex-shrink-0">
|
||||
{shop.imageUrl ? (
|
||||
<img
|
||||
src={shop.imageUrl}
|
||||
alt={shop.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center">
|
||||
<Scissors size={36} className="text-slate-600 mb-2" />
|
||||
<p className="text-slate-600 text-xs font-medium">Sem foto de capa</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Gradient overlay */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||
|
||||
{/* Rating badge */}
|
||||
<div className="absolute top-3 left-3 flex items-center gap-1 bg-black/70 backdrop-blur-sm px-2.5 py-1 rounded-full">
|
||||
<Star size={11} className="fill-amber-400 text-amber-400" />
|
||||
<span className="text-white text-xs font-bold">
|
||||
{shop.rating ? shop.rating.toFixed(1) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Informações da Barbearia */}
|
||||
<div className="flex flex-col flex-1 py-1">
|
||||
<h2 className="text-slate-900 text-base md:text-lg font-bold uppercase tracking-wide truncate mb-1.5 group-hover:text-amber-600 transition-colors">
|
||||
{/* Favorite button */}
|
||||
<button
|
||||
onClick={(e) => { e.preventDefault(); toggleFavorite(shop.id); }}
|
||||
className={`absolute top-3 right-3 p-1.5 rounded-full backdrop-blur-sm transition-all ${favorite
|
||||
? 'bg-rose-500 text-white shadow-md scale-110'
|
||||
: 'bg-black/40 text-white hover:bg-rose-500'
|
||||
}`}
|
||||
>
|
||||
<Heart size={14} className={favorite ? 'fill-white' : ''} />
|
||||
</button>
|
||||
|
||||
{/* Shop name on image */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||
<h2 className="text-white font-bold text-base leading-tight truncate drop-shadow-md">
|
||||
{shop.name}
|
||||
</h2>
|
||||
<div className="flex items-start gap-1.5 text-slate-500 mb-2">
|
||||
<MapPin size={16} className="shrink-0 mt-0.5 text-amber-600" />
|
||||
<p className="text-sm leading-snug line-clamp-2 pr-1">
|
||||
{shop.address || 'Endereço Indisponível'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500 mt-auto font-medium">
|
||||
<span>{(shop.services || []).length} serviços</span>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span>{(shop.barbers || []).length} barbeiros</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Botões de Ação na base */}
|
||||
<div className="flex gap-2 pt-4 mt-4 border-t border-slate-100">
|
||||
<Button asChild variant="outline" size="sm" className="flex-1">
|
||||
<Link to={`/barbearia/${shop.id}`}>Ver detalhes</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" className="flex-1 bg-amber-600 hover:bg-amber-700 border-0">
|
||||
<Link to={`/agendar/${shop.id}`}>Agendar</Link>
|
||||
</Button>
|
||||
{/* Info section */}
|
||||
<div className="p-4 flex flex-col gap-3 flex-1">
|
||||
<div className="flex items-start gap-1.5 text-slate-500">
|
||||
<MapPin size={13} className="shrink-0 mt-0.5 text-amber-500" />
|
||||
<p className="text-xs leading-snug line-clamp-1">{shop.address || 'Endereço não disponível'}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500 font-medium">
|
||||
<span className="flex items-center gap-1">
|
||||
<Scissors size={11} className="text-slate-400" />
|
||||
{(shop.services || []).length} serviços
|
||||
</span>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span>{(shop.barbers || []).length} barbeiros</span>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 mt-auto pt-1">
|
||||
<Link
|
||||
to={`/barbearia/${shop.id}`}
|
||||
className="flex-1 text-center text-xs font-semibold py-2 px-3 rounded-xl border border-slate-200 text-slate-700 hover:border-amber-300 hover:text-amber-700 hover:bg-amber-50 transition-all"
|
||||
>
|
||||
Ver detalhes
|
||||
</Link>
|
||||
<Link
|
||||
to={`/agendar/${shop.id}`}
|
||||
className="flex-1 text-center text-xs font-semibold py-2 px-3 rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 text-white hover:from-amber-600 hover:to-orange-600 transition-all flex items-center justify-center gap-1.5 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Calendar size={12} />
|
||||
Agendar
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { MapPin, ShoppingCart, User, LogOut, Menu, X } from 'lucide-react'
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { ShoppingCart, User, LogOut, Menu, X, Scissors, Search } from 'lucide-react'
|
||||
import { useApp } from '../../context/AppContext'
|
||||
import { useState } from 'react'
|
||||
|
||||
export const Header = () => {
|
||||
const { user, cart, logout } = useApp()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
const handleLogout = () => {
|
||||
@@ -14,36 +15,46 @@ export const Header = () => {
|
||||
setMobileMenuOpen(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'
|
||||
}`
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-slate-200/60 shadow-sm">
|
||||
<div className="mx-auto flex h-16 max-w-5xl items-center justify-between px-4">
|
||||
<header className="sticky top-0 z-30 bg-white/90 backdrop-blur-md border-b border-slate-100 shadow-sm">
|
||||
<div className="mx-auto flex h-15 max-w-6xl items-center justify-between px-4 py-3">
|
||||
{/* Logo */}
|
||||
<Link
|
||||
to="/"
|
||||
className="text-xl font-bold bg-gradient-to-r from-indigo-600 to-blue-700 bg-clip-text text-transparent hover:from-indigo-700 hover:to-blue-800 transition-all"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-2 group"
|
||||
>
|
||||
Smart Agenda
|
||||
<div className="w-8 h-8 bg-gradient-to-br from-amber-400 to-orange-500 rounded-lg flex items-center justify-center shadow-sm group-hover:shadow-md transition-shadow">
|
||||
<Scissors size={16} className="text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-black text-slate-900 tracking-tight">Smart<span className="text-amber-500">Agenda</span></span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-4">
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{user?.role !== 'barbearia' && (
|
||||
<>
|
||||
<Link
|
||||
to="/explorar"
|
||||
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"
|
||||
>
|
||||
<MapPin size={16} />
|
||||
<span>Barbearias</span>
|
||||
<Link to="/explorar" className={navLink('/explorar')}>
|
||||
<Search size={15} />
|
||||
Barbearias
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/carrinho"
|
||||
className="relative text-slate-700 hover:text-indigo-600 transition-colors p-2 rounded-lg hover:bg-indigo-50"
|
||||
className={`relative p-2 rounded-lg transition-colors ${location.pathname === '/carrinho'
|
||||
? 'text-amber-700 bg-amber-50'
|
||||
: 'text-slate-600 hover:text-amber-700 hover:bg-amber-50'
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart size={18} />
|
||||
{cart.length > 0 && (
|
||||
<span className="absolute -right-1 -top-1 rounded-full bg-gradient-to-r from-indigo-500 to-blue-600 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-sm min-w-[18px] text-center">
|
||||
<span className="absolute -right-1 -top-1 rounded-full bg-amber-500 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-sm min-w-[18px] text-center leading-none flex items-center justify-center">
|
||||
{cart.length}
|
||||
</span>
|
||||
)}
|
||||
@@ -52,74 +63,72 @@ export const Header = () => {
|
||||
)}
|
||||
|
||||
{user ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 ml-2 pl-2 border-l border-slate-200">
|
||||
<button
|
||||
onClick={() => navigate(user.role === 'barbearia' ? '/painel' : '/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"
|
||||
className={navLink(user.role === 'barbearia' ? '/painel' : '/perfil')}
|
||||
type="button"
|
||||
>
|
||||
<User size={16} />
|
||||
<span className="max-w-[120px] truncate">{user.name}</span>
|
||||
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-amber-400 to-amber-600 flex items-center justify-center text-white text-xs font-bold">
|
||||
{(user.name?.[0] ?? 'U').toUpperCase()}
|
||||
</div>
|
||||
<span className="max-w-[100px] truncate">{user.name}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 text-slate-600 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors"
|
||||
className="p-2 text-slate-400 hover:text-rose-500 hover:bg-rose-50 rounded-lg transition-colors"
|
||||
title="Sair"
|
||||
type="button"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<LogOut size={15} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center justify-center rounded-md border border-slate-200 bg-white px-3 py-1.5 text-sm font-medium text-slate-800 shadow-sm hover:bg-slate-50 transition-colors"
|
||||
className="ml-2 inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 text-white text-sm font-semibold shadow-sm hover:shadow-md hover:from-amber-600 hover:to-orange-600 transition-all"
|
||||
>
|
||||
Entrar
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="md:hidden p-2 text-slate-700 hover:text-amber-600 hover:bg-amber-50 rounded-lg transition-colors"
|
||||
type="button"
|
||||
>
|
||||
{mobileMenuOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
{/* Mobile right area */}
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
{user?.role !== 'barbearia' && (
|
||||
<Link to="/carrinho" className="relative p-2 text-slate-600">
|
||||
<ShoppingCart size={20} />
|
||||
{cart.length > 0 && (
|
||||
<span className="absolute -right-0.5 -top-0.5 rounded-full bg-amber-500 w-4 h-4 text-[10px] font-bold text-white flex items-center justify-center">
|
||||
{cart.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="p-2 text-slate-700 hover:text-amber-600 hover:bg-amber-50 rounded-lg transition-colors"
|
||||
type="button"
|
||||
>
|
||||
{mobileMenuOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-slate-200/60 bg-white/95 backdrop-blur-md animate-in slide-in-from-top">
|
||||
<nav className="px-4 py-3 space-y-2">
|
||||
<div className="md:hidden border-t border-slate-100 bg-white/98 backdrop-blur-md">
|
||||
<nav className="px-4 py-3 space-y-1">
|
||||
{user?.role !== 'barbearia' && (
|
||||
<>
|
||||
<Link
|
||||
to="/explorar"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="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"
|
||||
>
|
||||
<MapPin size={16} />
|
||||
Barbearias
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/carrinho"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="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"
|
||||
>
|
||||
<ShoppingCart size={16} />
|
||||
Carrinho
|
||||
{cart.length > 0 && (
|
||||
<span className="ml-auto rounded-full bg-amber-500 px-2 py-0.5 text-[10px] font-bold text-white">
|
||||
{cart.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</>
|
||||
<Link
|
||||
to="/explorar"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-2 text-sm font-medium text-slate-700 hover:text-amber-700 px-3 py-2.5 rounded-xl hover:bg-amber-50 transition-colors"
|
||||
>
|
||||
<Search size={16} />
|
||||
Barbearias
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{user ? (
|
||||
@@ -129,16 +138,18 @@ export const Header = () => {
|
||||
navigate(user.role === 'barbearia' ? '/painel' : '/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"
|
||||
className="w-full flex items-center gap-2 text-sm font-medium text-slate-700 hover:text-amber-700 px-3 py-2.5 rounded-xl hover:bg-amber-50 transition-colors text-left"
|
||||
type="button"
|
||||
>
|
||||
<User size={16} />
|
||||
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-amber-400 to-amber-600 flex items-center justify-center text-white text-xs font-bold">
|
||||
{(user.name?.[0] ?? 'U').toUpperCase()}
|
||||
</div>
|
||||
{user.name}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-2 text-sm font-medium text-rose-600 hover:bg-rose-50 transition-colors px-3 py-2 rounded-lg text-left"
|
||||
className="w-full flex items-center gap-2 text-sm font-medium text-rose-500 hover:bg-rose-50 px-3 py-2.5 rounded-xl transition-colors text-left"
|
||||
type="button"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
@@ -149,7 +160,7 @@ export const Header = () => {
|
||||
<Link
|
||||
to="/login"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="inline-flex w-full items-center justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700 transition-colors"
|
||||
className="flex items-center justify-center gap-2 py-2.5 px-3 rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 text-white text-sm font-semibold"
|
||||
>
|
||||
Entrar
|
||||
</Link>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -12,7 +14,8 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gradient-to-br from-slate-50 via-white to-blue-50/30 text-slate-900 font-sans antialiased;
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
@apply bg-slate-50 text-slate-900 antialiased;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
}
|
||||
|
||||
@@ -22,11 +25,11 @@
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
@apply w-2 h-2;
|
||||
@apply w-1.5 h-1.5;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-slate-100;
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@@ -38,9 +41,13 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,129 +1,147 @@
|
||||
/**
|
||||
* @file Explore.tsx
|
||||
* @description View de Exploração de barbearias na versão Web.
|
||||
* Consome do estado global (`useApp`) a lista consolidada das "shops" populadas
|
||||
* pela base de dados e aplica filtros do lado do cliente baseados na query, tipo e ordenação.
|
||||
*/
|
||||
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';
|
||||
import { Search, Heart, Compass, SlidersHorizontal } from 'lucide-react';
|
||||
|
||||
export default function Explore() {
|
||||
const { shops } = useApp();
|
||||
const { shops, favorites } = useApp();
|
||||
|
||||
// Estados para manter as seleções de filtragem
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<'todas' | 'top' | 'produtos' | 'barbeiros' | 'servicos'>('todas');
|
||||
const [filter, setFilter] = useState<'todas' | 'top' | 'produtos' | 'barbeiros' | 'favoritas'>('todas');
|
||||
const [sortBy, setSortBy] = useState<'relevancia' | 'avaliacao' | 'preco' | 'servicos'>('relevancia');
|
||||
|
||||
/**
|
||||
* Deriva a lista de Shops tratada a partir do conjunto mestre global.
|
||||
* Só recalcula quando os raw `shops` ou os critérios de pesquisa se alteram.
|
||||
*/
|
||||
const favoriteShops = useMemo(() => shops.filter((s) => favorites.includes(s.id)), [shops, favorites]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
|
||||
// Regra 1: Combinação livre por correspondência parcial textual (Nome/Morada)
|
||||
const matchesQuery = (name: string, address: string) =>
|
||||
!normalized || name.toLowerCase().includes(normalized) || address.toLowerCase().includes(normalized);
|
||||
|
||||
// Regra 2: Restrições de Chip
|
||||
const passesFilter = (shop: (typeof shops)[number]) => {
|
||||
if (filter === 'top') return (shop.rating || 0) >= 4.7;
|
||||
if (filter === 'favoritas') return favorites.includes(shop.id);
|
||||
if (filter === 'top') return (shop.rating || 0) >= 4;
|
||||
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;
|
||||
};
|
||||
|
||||
// Aplicação condicional com Sort
|
||||
const sorted = [...shops]
|
||||
.filter((shop) => matchesQuery(shop.name, shop.address || ''))
|
||||
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') {
|
||||
// Extrai o preço mínimo nos serviços oferecidos e compara
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
// Critério por defeito ou quebra de empate: Avaliação descendente
|
||||
if (b.rating !== a.rating) return (b.rating || 0) - (a.rating || 0);
|
||||
return (b.services || []).length - (a.services || []).length;
|
||||
});
|
||||
}, [shops, query, filter, sortBy, favorites]);
|
||||
|
||||
return sorted;
|
||||
}, [shops, query, filter, sortBy]);
|
||||
const chips: { id: typeof filter; label: string; icon?: React.ReactNode }[] = [
|
||||
{ id: 'todas', label: 'Todas' },
|
||||
{ id: 'favoritas', label: `Favoritas${favorites.length > 0 ? ` (${favorites.length})` : ''}`, icon: <Heart size={12} className="fill-current" /> },
|
||||
{ id: 'top', label: 'Top avaliadas' },
|
||||
{ id: 'produtos', label: 'Com produtos' },
|
||||
{ id: 'barbeiros', label: 'Mais barbeiros' },
|
||||
];
|
||||
|
||||
return (
|
||||
<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>
|
||||
{/* Hero Header */}
|
||||
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 via-slate-800 to-amber-900 p-6 md:p-8">
|
||||
<div className="absolute inset-0 opacity-10 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-amber-400 to-transparent" />
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Compass size={18} className="text-amber-400" />
|
||||
<span className="text-amber-400 text-xs font-semibold uppercase tracking-widest">Explorar</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">{filtered.length} resultados</div>
|
||||
<h1 className="text-2xl md:text-3xl font-black text-white mb-1">Barbearias</h1>
|
||||
<p className="text-slate-400 text-sm">Encontre a sua favorita e agende em minutos.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<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
|
||||
{/* Search & Sort bar */}
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-4 space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search size={16} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Pesquisar por nome ou endereço..."
|
||||
className="pl-11"
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent placeholder:text-slate-400"
|
||||
/>
|
||||
<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 className="flex items-center gap-2">
|
||||
<SlidersHorizontal size={15} className="text-slate-400" />
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
|
||||
className="border border-slate-200 rounded-xl px-3 py-2.5 text-sm text-slate-700 focus:outline-none focus:ring-2 focus:ring-amber-400 bg-white"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{/* Filter chips */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
key={chip.id}
|
||||
onClick={() => setFilter(chip.id)}
|
||||
className={`flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-xs font-semibold transition-all ${filter === chip.id
|
||||
? chip.id === 'favoritas'
|
||||
? 'bg-rose-500 text-white shadow-sm'
|
||||
: 'bg-amber-500 text-white shadow-sm'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{chip.icon}
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<p className="text-sm text-slate-500">
|
||||
<span className="font-semibold text-slate-900">{filtered.length}</span>{' '}
|
||||
{filtered.length === 1 ? 'barbearia encontrada' : 'barbearias encontradas'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{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="text-center py-16 bg-white rounded-2xl border border-slate-100">
|
||||
<div className="w-16 h-16 bg-slate-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
{filter === 'favoritas'
|
||||
? <Heart size={28} className="text-slate-400" />
|
||||
: <Search size={28} className="text-slate-400" />
|
||||
}
|
||||
</div>
|
||||
<p className="font-semibold text-slate-700 mb-1">
|
||||
{filter === 'favoritas' ? 'Ainda não tem favoritas' : 'Nenhuma barbearia encontrada'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-400">
|
||||
{filter === 'favoritas'
|
||||
? 'Clique no ❤️ em qualquer barbearia para a guardar aqui.'
|
||||
: 'Tente ajustar a pesquisa ou os filtros.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filtered.map((shop) => (
|
||||
<ShopCard key={shop.id} shop={shop} />
|
||||
))}
|
||||
@@ -132,5 +150,3 @@ export default function Explore() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Badge } from '../components/ui/badge'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { currency } from '../lib/format'
|
||||
import { useApp } from '../context/AppContext'
|
||||
import { Calendar, ShoppingBag, User, Clock, Heart, Star, MapPin } from 'lucide-react'
|
||||
import { Calendar, ShoppingBag, User, Clock, Heart, Star } from 'lucide-react'
|
||||
import { supabase } from '../lib/supabase'
|
||||
import { ReviewModal } from '../components/ReviewModal'
|
||||
|
||||
@@ -29,7 +29,7 @@ const statusLabel: Record<string, string> = {
|
||||
}
|
||||
|
||||
export default function Profile() {
|
||||
const { appointments, orders, shops, favorites, submitReview } = useApp()
|
||||
const { appointments, orders, shops, submitReview } = useApp()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [authEmail, setAuthEmail] = useState<string>('')
|
||||
@@ -75,9 +75,6 @@ export default function Profile() {
|
||||
return orders.filter((o) => o.customerId === authId)
|
||||
}, [orders, authId])
|
||||
|
||||
const favoriteShops = useMemo(() => {
|
||||
return shops.filter((s) => favorites.includes(s.id))
|
||||
}, [shops, favorites])
|
||||
|
||||
const handleReviewSubmit = async (rating: number, comment: string) => {
|
||||
if (!reviewTarget) return
|
||||
@@ -142,56 +139,11 @@ export default function Profile() {
|
||||
<p className="text-sm text-slate-500 truncate">{authEmail}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge color="amber" variant="soft">Cliente</Badge>
|
||||
{favoriteShops.length > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs text-rose-500 font-medium">
|
||||
<Heart size={12} className="fill-rose-500" /> {favoriteShops.length} favorita{favoriteShops.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ❤️ Barbearias Favoritas */}
|
||||
{favoriteShops.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Heart size={20} className="text-rose-500 fill-rose-500" />
|
||||
<h2 className="text-xl font-bold text-slate-900">Barbearias Favoritas</h2>
|
||||
<Badge color="red" variant="soft">{favoriteShops.length}</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{favoriteShops.map((shop) => (
|
||||
<Link key={shop.id} to={`/barbearia/${shop.id}`}>
|
||||
<Card hover className="p-4 flex items-center gap-3 group">
|
||||
{shop.imageUrl ? (
|
||||
<img src={shop.imageUrl} alt={shop.name} className="w-14 h-14 rounded-xl object-cover flex-shrink-0" />
|
||||
) : (
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-slate-100 to-slate-200 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<User size={20} className="text-slate-400" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-slate-900 truncate group-hover:text-amber-700 transition-colors">{shop.name}</p>
|
||||
{shop.address && (
|
||||
<p className="text-xs text-slate-500 flex items-center gap-1 mt-0.5 truncate">
|
||||
<MapPin size={10} /> {shop.address}
|
||||
</p>
|
||||
)}
|
||||
{shop.rating > 0 && (
|
||||
<p className="text-xs text-amber-600 flex items-center gap-1 mt-1">
|
||||
<Star size={10} className="fill-amber-400 text-amber-400" />
|
||||
{shop.rating.toFixed(1)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Agendamentos */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user