diff --git a/web/src/context/AppContext.tsx b/web/src/context/AppContext.tsx index a1a2d3a..415630f 100644 --- a/web/src/context/AppContext.tsx +++ b/web/src/context/AppContext.tsx @@ -4,7 +4,7 @@ */ import React, { createContext, useContext, useEffect, useState } from 'react'; import { nanoid } from 'nanoid'; -import { Appointment, AppointmentStatus, Barber, BarberShop, CartItem, Order, Product, Service, User } from '../types'; +import { Appointment, AppointmentStatus, Barber, BarberShop, CartItem, Order, Product, Service, User, WaitlistEntry, AppNotification } from '../types'; import { mockUsers } from '../data/mock'; import { storage } from '../lib/storage'; import { supabase } from '../lib/supabase'; @@ -18,6 +18,8 @@ type State = { orders: Order[]; cart: CartItem[]; favorites: string[]; + waitlists: WaitlistEntry[]; + notifications: AppNotification[]; }; type AppContextValue = State & { @@ -44,6 +46,8 @@ type AppContextValue = State & { deleteBarber: (shopId: string, barberId: string) => Promise; updateShopDetails: (shopId: string, payload: Partial) => Promise; submitReview: (shopId: string, appointmentId: string, rating: number, comment: string) => Promise; + joinWaitlist: (shopId: string, serviceId: string, barberId: string, date: string) => Promise; + markNotificationRead: (id: string) => Promise; refreshShops: () => Promise; shopsReady: boolean; }; @@ -57,6 +61,8 @@ const initialState: State = { orders: [], cart: [], favorites: [], + waitlists: [], + notifications: [], }; const AppContext = createContext(undefined); @@ -83,6 +89,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { orders: safeOrders, cart: safeCart, favorites: safeFavorites, + waitlists: [], + notifications: [], }; }); @@ -99,6 +107,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { const { data: productsData } = await supabase.from('products').select('*'); const { data: appointmentsData } = await supabase.from('appointments').select('*'); const { data: ordersData } = await supabase.from('orders').select('*'); + const { data: waitlistData } = await supabase.from('waitlist').select('*'); + const { data: notificationsData } = await supabase.from('notifications').select('*'); const fetchedShops: BarberShop[] = shopsData.map((shop) => ({ id: shop.id, @@ -159,6 +169,25 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { createdAt: o.created_at, })); + const formattedWaitlists: WaitlistEntry[] = (waitlistData ?? []).map((w) => ({ + id: w.id, + shopId: w.shop_id, + serviceId: w.service_id, + barberId: w.barber_id, + customerId: w.customer_id, + date: w.date, + status: w.status as any, + createdAt: w.created_at, + })); + + const formattedNotifications: AppNotification[] = (notificationsData ?? []).map((n) => ({ + id: n.id, + userId: n.user_id, + message: n.message, + read: n.read, + createdAt: n.created_at, + })); + setState((s) => { // A BD é agora a única fonte de verdade. // Como o CRUD já insere na BD antes do refresh, a sobreposição com 'localVersion' foi @@ -182,7 +211,15 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { return true; }); - return { ...s, shops: dedupedShops, appointments: formattedAppointments, orders: formattedOrders, shopsReady: true }; + return { + ...s, + shops: dedupedShops, + appointments: formattedAppointments, + orders: formattedOrders, + waitlists: formattedWaitlists, + notifications: formattedNotifications, + shopsReady: true + }; }); } catch (err) { console.error('refreshShops error:', err); @@ -478,11 +515,29 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { }; const updateAppointmentStatus: AppContextValue['updateAppointmentStatus'] = async (id, status) => { + const apt = state.appointments.find(a => a.id === id); const { error } = await supabase.from('appointments').update({ status }).eq('id', id); if (error) { console.error("Erro ao atualizar status da marcação:", error); return; } + + if (status === 'cancelado' && apt) { + const waitingUsers = state.waitlists.filter(w => w.barberId === apt.barberId && w.date === apt.date && w.status === 'pending'); + + if (waitingUsers.length > 0) { + const notificationsToInsert = waitingUsers.map(w => ({ + user_id: w.customerId, + message: `Surgiu uma vaga no horário que pretendia a ${w.date}! Corra para fazer a reserva.` + })); + + await supabase.from('notifications').insert(notificationsToInsert); + + const waitlistIds = waitingUsers.map(w => w.id); + await supabase.from('waitlist').update({ status: 'notified' }).in('id', waitlistIds); + } + } + await refreshShops(); }; @@ -604,6 +659,30 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { })); }; + const joinWaitlist: AppContextValue['joinWaitlist'] = async (shopId, serviceId, barberId, date) => { + if (!state.user) return false; + const { error } = await supabase.from('waitlist').insert([{ + shop_id: shopId, + service_id: serviceId, + barber_id: barberId, + customer_id: state.user.id, + date, + status: 'pending' + }]); + if (error) { + console.error('Erro ao entrar na lista de espera:', error); + return false; + } + await refreshShops(); + return true; + }; + + const markNotificationRead: AppContextValue['markNotificationRead'] = async (id) => { + const { error } = await supabase.from('notifications').update({ read: true }).eq('id', id); + if (error) console.error("Erro ao marcar notificação como lida:", error); + else await refreshShops(); + }; + const submitReview = async (shopId: string, appointmentId: string, rating: number, comment: string) => { const { data: authData } = await supabase.auth.getUser(); const customerId = authData?.user?.id; @@ -649,6 +728,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { deleteBarber, updateShopDetails, submitReview, + joinWaitlist, + markNotificationRead, refreshShops, }; diff --git a/web/src/pages/Booking.tsx b/web/src/pages/Booking.tsx index b69096c..37351ff 100644 --- a/web/src/pages/Booking.tsx +++ b/web/src/pages/Booking.tsx @@ -13,7 +13,7 @@ export default function Booking() { const navigate = useNavigate(); // Extração das ferramentas vitais do Context global da aplicação - const { shops, createAppointment, user, appointments } = useApp(); + const { shops, createAppointment, user, appointments, waitlists, joinWaitlist } = useApp(); // Procura a barbearia acedida (com base no URL parameter ':id') const shop = useMemo(() => shops.find((s) => s.id === id), [shops, id]); @@ -49,7 +49,7 @@ export default function Booking() { return slots; }; - const availableSlots = useMemo(() => { + const processedSlots = useMemo(() => { if (!selectedBarber || !date) return []; const specificSchedule = selectedBarber.schedule?.find((s) => s.day === date); let slots = specificSchedule && specificSchedule.slots.length > 0 @@ -68,8 +68,12 @@ export default function Booking() { }) .filter(Boolean); - return slots.filter((slot) => !bookedSlots.includes(slot)); - }, [selectedBarber, date, barberId, appointments]); + return slots.map(time => { + const isBooked = bookedSlots.includes(time); + const waitlistedByMe = user ? waitlists.some(w => w.barberId === barberId && w.date === `${date} ${time}` && w.customerId === user.id && w.status === 'pending') : false; + return { time, isBooked, waitlistedByMe }; + }); + }, [selectedBarber, date, barberId, appointments, user, waitlists]); if (!shop) return
Barbearia não encontrada
; @@ -299,19 +303,40 @@ export default function Booking() { {/* Right Side: Slots Grid */}
- {availableSlots.length > 0 ? ( - availableSlots.map((h) => ( - + {processedSlots.length > 0 ? ( + processedSlots.map((s) => ( + s.isBooked ? ( + s.waitlistedByMe ? ( +
+ Na Espera ({s.time}) +
+ ) : ( + + ) + ) : ( + + ) )) ) : (
diff --git a/web/src/pages/Profile.tsx b/web/src/pages/Profile.tsx index 3249f93..e45df3c 100644 --- a/web/src/pages/Profile.tsx +++ b/web/src/pages/Profile.tsx @@ -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, CheckCircle2 } from 'lucide-react' +import { Calendar, ShoppingBag, User, Clock, Heart, Star, MapPin, CheckCircle2, Bell } from 'lucide-react' import { supabase } from '../lib/supabase' import { ReviewModal } from '../components/ReviewModal' @@ -29,7 +29,7 @@ const statusLabel: Record = { } export default function Profile() { - const { appointments, orders, shops, favorites, submitReview } = useApp() + const { appointments, orders, shops, favorites, submitReview, notifications, markNotificationRead } = useApp() const navigate = useNavigate() const [authEmail, setAuthEmail] = useState('') @@ -79,6 +79,11 @@ export default function Profile() { return shops.filter((s) => favorites.includes(s.id)) }, [shops, favorites]) + const myNotifications = useMemo(() => { + if (!authId) return [] + return notifications.filter((n) => n.userId === authId).sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + }, [notifications, authId]) + const handleReviewSubmit = async (rating: number, comment: string) => { if (!reviewTarget) return await submitReview(reviewTarget.shopId, reviewTarget.appointmentId, rating, comment) @@ -154,6 +159,32 @@ export default function Profile() {
+ {/* 🔔 Notificações */} + {myNotifications.filter(n => !n.read).length > 0 && ( +
+
+ +

Notificações

+
+
+ {myNotifications.filter(n => !n.read).map(n => ( +
+
+

{n.message}

+

{new Date(n.createdAt).toLocaleString('pt-PT')}

+
+ +
+ ))} +
+
+ )} + {/* ❤️ Barbearias Favoritas - Horizontal Scroll or Grid */} {favoriteShops.length > 0 && (
diff --git a/web/src/types.ts b/web/src/types.ts index e6ff74a..d11c6f3 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -21,6 +21,9 @@ 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 }; export type CartItem = { shopId: string; type: 'service' | 'product'; refId: string; qty: number }; export type Order = { id: string; shopId: string; customerId: string; items: CartItem[]; total: number; status: OrderStatus; createdAt: string }; +export type WaitlistEntry = { id: string; shopId: string; serviceId: string; barberId: string; customerId: string; date: string; status: 'pending' | 'notified' | 'resolved'; createdAt: string }; +export type AppNotification = { id: string; userId: string; message: string; read: boolean; createdAt: string }; + export type User = { id: string; name: string; email: string; password: string; role: 'cliente' | 'barbearia'; shopId?: string };