This commit is contained in:
2026-03-12 10:40:43 +00:00
parent 7a95de4ba6
commit 56d5c11b87
3 changed files with 350 additions and 121 deletions

View File

@@ -0,0 +1,112 @@
/**
* @file ReviewModal.tsx
* @description Modal de avaliação pós-atendimento com estrelas interativas.
*/
import { useState } from 'react'
import { Star, X, Send } from 'lucide-react'
import { Button } from './ui/button'
interface ReviewModalProps {
shopName: string
appointmentId: string
onSubmit: (rating: number, comment: string) => Promise<void>
onClose: () => void
}
export function ReviewModal({ shopName, appointmentId, onSubmit, onClose }: ReviewModalProps) {
const [rating, setRating] = useState(0)
const [hovered, setHovered] = useState(0)
const [comment, setComment] = useState('')
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const handleSubmit = async () => {
if (rating === 0) return
setSubmitting(true)
try {
await onSubmit(rating, comment)
setSubmitted(true)
setTimeout(onClose, 1800)
} catch {
alert('Erro ao enviar avaliação. Tente novamente.')
} finally {
setSubmitting(false)
}
}
const labels = ['', 'Muito mau', 'Mau', 'Razoável', 'Bom', 'Excelente!']
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-sm p-6 relative animate-in fade-in slide-in-from-bottom-4 duration-300">
{/* Close */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-1 rounded-full text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
>
<X size={18} />
</button>
{submitted ? (
<div className="text-center py-4">
<div className="text-5xl mb-3">🎉</div>
<h3 className="text-xl font-bold text-slate-900">Obrigado pela avaliação!</h3>
<p className="text-sm text-slate-500 mt-1">O seu feedback ajuda outros clientes.</p>
</div>
) : (
<>
<div className="text-center mb-6">
<div className="w-14 h-14 bg-gradient-to-br from-amber-400 to-amber-600 rounded-2xl flex items-center justify-center mx-auto mb-3 shadow-md">
<Star size={28} className="text-white fill-white" />
</div>
<h3 className="text-xl font-bold text-slate-900">Avaliar atendimento</h3>
<p className="text-sm text-slate-500 mt-1">{shopName}</p>
</div>
{/* Stars */}
<div className="flex justify-center gap-2 mb-2">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
onClick={() => setRating(star)}
onMouseEnter={() => setHovered(star)}
onMouseLeave={() => setHovered(0)}
className="transition-transform hover:scale-125 active:scale-110"
>
<Star
size={36}
className={`transition-colors ${star <= (hovered || rating)
? 'text-amber-400 fill-amber-400'
: 'text-slate-300'
}`}
/>
</button>
))}
</div>
<p className="text-center text-sm font-medium text-amber-600 mb-5 h-5">
{labels[hovered || rating]}
</p>
{/* Comment */}
<textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Quer deixar um comentário? (opcional)"
rows={3}
className="w-full border border-slate-200 rounded-xl p-3 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent mb-4 placeholder:text-slate-400"
/>
<Button
onClick={handleSubmit}
disabled={rating === 0 || submitting}
className="w-full flex items-center justify-center gap-2"
>
<Send size={16} />
{submitting ? 'A enviar...' : 'Enviar Avaliação'}
</Button>
</>
)}
</div>
</div>
)
}

View File

@@ -43,6 +43,7 @@ type AppContextValue = State & {
updateBarber: (shopId: string, barber: Barber) => Promise<void>;
deleteBarber: (shopId: string, barberId: string) => Promise<void>;
updateShopDetails: (shopId: string, payload: Partial<BarberShop>) => Promise<void>;
submitReview: (shopId: string, appointmentId: string, rating: number, comment: string) => Promise<void>;
refreshShops: () => Promise<void>;
shopsReady: boolean;
};
@@ -597,6 +598,26 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
}));
};
const submitReview = async (shopId: string, appointmentId: string, rating: number, comment: string) => {
const { data: authData } = await supabase.auth.getUser();
const customerId = authData?.user?.id;
if (!customerId) { alert('Sess\u00e3o expirada. Faz login novamente.'); return; }
const { error } = await supabase.from('reviews').insert([{
shop_id: shopId,
customer_id: customerId,
appointment_id: appointmentId,
rating,
comment: comment.trim() || null,
}]);
if (error) {
console.error('Erro submitReview:', error);
throw new Error(error.message);
}
await refreshShops();
};
const value: AppContextValue = {
...state,
login,
@@ -621,6 +642,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
updateBarber,
deleteBarber,
updateShopDetails,
submitReview,
refreshShops,
};

View File

@@ -1,16 +1,18 @@
/**
* @file Profile.tsx
* @description Página do Perfil do utilizador web. Carrega a sessão atual a partir do Supabase,
* interceta eventos customizados, agendamentos associados ao `customerId` e pedidos de produtos (Orders).
* @description Página do Perfil do utilizador web.
* Mostra agendamentos, pedidos, barbearias favoritas e permite avaliar atendimentos concluídos.
*/
import { useEffect, useMemo, useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { Card } from '../components/ui/card'
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 } from 'lucide-react'
import { Calendar, ShoppingBag, User, Clock, Heart, Star, MapPin } from 'lucide-react'
import { supabase } from '../lib/supabase'
import { ReviewModal } from '../components/ReviewModal'
const statusColor: Record<string, 'amber' | 'green' | 'slate' | 'red'> = {
pendente: 'amber',
@@ -27,15 +29,17 @@ const statusLabel: Record<string, string> = {
}
export default function Profile() {
const { appointments, orders, shops } = useApp()
const location = useLocation()
const { appointments, orders, shops, favorites, submitReview } = useApp()
const navigate = useNavigate()
// ✅ Utilizador Supabase
const [authEmail, setAuthEmail] = useState<string>('')
const [authId, setAuthId] = useState<string>('')
const [loadingAuth, setLoadingAuth] = useState(true)
// Avaliações já enviadas (para esconder o botão se já foi avaliado)
const [reviewedAppointments, setReviewedAppointments] = useState<Set<string>>(new Set())
const [reviewTarget, setReviewTarget] = useState<{ appointmentId: string; shopId: string; shopName: string } | null>(null)
useEffect(() => {
let mounted = true
; (async () => {
@@ -47,6 +51,14 @@ export default function Profile() {
} else {
setAuthEmail(data.user.email ?? '')
setAuthId(data.user.id)
// Carregar avaliações já feitas por este utilizador
const { data: reviews } = await supabase
.from('reviews')
.select('appointment_id')
.eq('customer_id', data.user.id)
if (reviews) {
setReviewedAppointments(new Set(reviews.map((r: any) => r.appointment_id).filter(Boolean)))
}
}
setLoadingAuth(false)
})()
@@ -63,22 +75,40 @@ 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
await submitReview(reviewTarget.shopId, reviewTarget.appointmentId, rating, comment)
setReviewedAppointments((prev) => new Set([...prev, reviewTarget.appointmentId]))
setReviewTarget(null)
}
if (loadingAuth) {
return (
<div className="text-center py-12">
<p className="text-slate-600">A carregar perfil...</p>
<div className="flex items-center justify-center py-20">
<div className="text-center">
<div className="w-10 h-10 border-4 border-slate-200 border-t-amber-500 rounded-full animate-spin mx-auto mb-3" />
<p className="text-slate-500 text-sm">A carregar perfil...</p>
</div>
</div>
)
}
if (!authId) {
return (
<div className="text-center py-12">
<p className="text-slate-600">Sessão não encontrada. Faz login novamente.</p>
<div className="text-center py-16">
<div className="w-16 h-16 bg-amber-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<User size={28} className="text-amber-600" />
</div>
<p className="text-slate-700 font-semibold mb-1">Sessão não encontrada</p>
<p className="text-slate-500 text-sm mb-4">Faz login para ver o teu perfil.</p>
<button
type="button"
onClick={() => navigate('/login', { replace: true })}
className="mt-4 text-sm font-semibold text-amber-700 hover:text-amber-800"
className="px-5 py-2 bg-amber-500 text-white text-sm font-semibold rounded-xl hover:bg-amber-600 transition-colors"
>
Ir para login
</button>
@@ -89,126 +119,191 @@ export default function Profile() {
const displayName = authEmail ? authEmail.split('@')[0] : 'Utilizador'
return (
<div className="space-y-8">
{/* Profile Header */}
<Card className="p-6 bg-gradient-to-br from-amber-50 to-white">
<div className="flex items-center gap-4">
<div className="p-4 bg-gradient-to-br from-amber-500 to-amber-600 rounded-xl text-white shadow-lg">
<User size={24} />
</div>
<div>
<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">
Cliente Barber
</Badge>
</div>
</div>
</Card>
<>
{/* Modal de avaliação */}
{reviewTarget && (
<ReviewModal
shopName={reviewTarget.shopName}
appointmentId={reviewTarget.appointmentId}
onSubmit={handleReviewSubmit}
onClose={() => setReviewTarget(null)}
/>
)}
{/* Appointments Section */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<Calendar size={20} className="text-amber-600" />
<h2 className="text-xl font-bold text-slate-900">Agendamentos</h2>
<Badge color="slate" variant="soft">
{myAppointments.length}
</Badge>
</div>
<div className="space-y-8">
{/* Profile Header */}
<Card className="p-6 bg-gradient-to-br from-amber-50 via-white to-orange-50 border-amber-100">
<div className="flex items-center gap-4">
<div className="w-16 h-16 bg-gradient-to-br from-amber-400 to-amber-600 rounded-2xl flex items-center justify-center text-white shadow-lg flex-shrink-0">
<User size={28} />
</div>
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-bold text-slate-900 truncate">Olá, {displayName}!</h1>
<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>
{!myAppointments.length ? (
<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 agendamento ainda</p>
<p className="text-sm text-slate-500 mt-1">Explore barbearias e agende seu primeiro serviço!</p>
</Card>
) : (
<div className="space-y-3">
{myAppointments.map((a) => {
const shop = shops.find((s) => s.id === a.shopId)
const service = shop?.services.find((s) => s.id === a.serviceId)
return (
<Card key={a.id} hover className="p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-bold text-slate-900">{shop?.name}</h3>
<Badge color={statusColor[a.status]} variant="soft">
{statusLabel[a.status]}
</Badge>
{/* ❤️ 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>
{service && (
<p className="text-sm text-slate-600 flex items-center gap-1">
<Clock size={14} />
{service.name} · {service.duration} min
)}
<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>
)}
<p className="text-xs text-slate-500">{a.date}</p>
</div>
<div className="text-right">
<p className="text-lg font-bold text-amber-600">{currency(a.total)}</p>
</div>
</div>
</Card>
)
})}
</div>
</Card>
</Link>
))}
</div>
</section>
)}
</section>
{/* Orders Section */}
<section className="space-y-4">
<div className="flex items-center gap-2">
<ShoppingBag size={20} className="text-amber-600" />
<h2 className="text-xl font-bold text-slate-900">Pedidos</h2>
<Badge color="slate" variant="soft">
{myOrders.length}
</Badge>
</div>
{/* Agendamentos */}
<section className="space-y-3">
<div className="flex items-center gap-2">
<Calendar size={20} className="text-amber-600" />
<h2 className="text-xl font-bold text-slate-900">Agendamentos</h2>
<Badge color="slate" variant="soft">{myAppointments.length}</Badge>
</div>
{!myOrders.length ? (
<Card className="p-8 text-center">
<ShoppingBag size={48} className="mx-auto text-slate-300 mb-3" />
<p className="text-slate-600 font-medium">Nenhum pedido ainda</p>
<p className="text-sm text-slate-500 mt-1">Adicione produtos ao carrinho e finalize seu primeiro pedido!</p>
</Card>
) : (
<div className="space-y-3">
{myOrders.map((o) => {
const shop = shops.find((s) => s.id === o.shopId)
return (
<Card key={o.id} hover className="p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-bold text-slate-900">{shop?.name}</h3>
<Badge color={statusColor[o.status]} variant="soft">
{statusLabel[o.status]}
</Badge>
{!myAppointments.length ? (
<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 agendamento ainda</p>
<p className="text-sm text-slate-500 mt-1">Explore barbearias e agende o seu primeiro serviço!</p>
</Card>
) : (
<div className="space-y-3">
{myAppointments.map((a) => {
const shop = shops.find((s) => s.id === a.shopId)
const service = shop?.services.find((s) => s.id === a.serviceId)
const canReview = a.status === 'concluido' && !reviewedAppointments.has(a.id)
return (
<Card key={a.id} hover className="p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-bold text-slate-900">{shop?.name}</h3>
<Badge color={statusColor[a.status]} variant="soft">
{statusLabel[a.status]}
</Badge>
</div>
{service && (
<p className="text-sm text-slate-600 flex items-center gap-1">
<Clock size={13} />
{service.name} · {service.duration} min
</p>
)}
<p className="text-xs text-slate-500">{a.date}</p>
{/* Botão de avaliar */}
{canReview && (
<button
onClick={() => setReviewTarget({ appointmentId: a.id, shopId: a.shopId, shopName: shop?.name ?? 'Barbearia' })}
className="flex items-center gap-1.5 mt-2 text-xs font-semibold text-amber-600 hover:text-amber-700 bg-amber-50 hover:bg-amber-100 px-3 py-1.5 rounded-lg transition-colors"
>
<Star size={12} className="fill-amber-400 text-amber-400" />
Avaliar atendimento
</button>
)}
{a.status === 'concluido' && reviewedAppointments.has(a.id) && (
<p className="text-xs text-green-600 flex items-center gap-1 mt-1">
Avaliado
</p>
)}
</div>
<div className="text-right flex-shrink-0">
<p className="text-lg font-bold text-amber-600">{currency(a.total)}</p>
</div>
<p className="text-xs text-slate-500">
{new Date(o.createdAt).toLocaleDateString('pt-BR', {
day: '2-digit',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
<p className="text-xs text-slate-600">
{o.items.length} {o.items.length === 1 ? 'item' : 'itens'}
</p>
</div>
<div className="text-right">
<p className="text-lg font-bold text-amber-600">{currency(o.total)}</p>
</div>
</div>
</Card>
)
})}
</Card>
)
})}
</div>
)}
</section>
{/* Pedidos */}
<section className="space-y-3">
<div className="flex items-center gap-2">
<ShoppingBag size={20} className="text-amber-600" />
<h2 className="text-xl font-bold text-slate-900">Pedidos</h2>
<Badge color="slate" variant="soft">{myOrders.length}</Badge>
</div>
)}
</section>
</div>
{!myOrders.length ? (
<Card className="p-8 text-center">
<ShoppingBag size={48} className="mx-auto text-slate-300 mb-3" />
<p className="text-slate-600 font-medium">Nenhum pedido ainda</p>
<p className="text-sm text-slate-500 mt-1">Adicione produtos ao carrinho e finalize o primeiro pedido!</p>
</Card>
) : (
<div className="space-y-3">
{myOrders.map((o) => {
const shop = shops.find((s) => s.id === o.shopId)
return (
<Card key={o.id} hover className="p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-bold text-slate-900">{shop?.name}</h3>
<Badge color={statusColor[o.status]} variant="soft">
{statusLabel[o.status]}
</Badge>
</div>
<p className="text-xs text-slate-500">
{new Date(o.createdAt).toLocaleDateString('pt-PT', {
day: '2-digit', month: 'long', year: 'numeric'
})}
</p>
<p className="text-xs text-slate-600">
{o.items.length} {o.items.length === 1 ? 'item' : 'itens'}
</p>
</div>
<div className="text-right flex-shrink-0">
<p className="text-lg font-bold text-amber-600">{currency(o.total)}</p>
</div>
</div>
</Card>
)
})}
</div>
)}
</section>
</div>
</>
)
}