feat: Implement waitlist functionality and a notification system, including automatic user notifications for available slots after appointment cancellations.

This commit is contained in:
2026-03-18 09:29:42 +00:00
parent 77825ffaa1
commit 77b81e9be6
4 changed files with 161 additions and 21 deletions

View File

@@ -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<void>;
updateShopDetails: (shopId: string, payload: Partial<BarberShop>) => Promise<void>;
submitReview: (shopId: string, appointmentId: string, rating: number, comment: string) => Promise<void>;
joinWaitlist: (shopId: string, serviceId: string, barberId: string, date: string) => Promise<boolean>;
markNotificationRead: (id: string) => Promise<void>;
refreshShops: () => Promise<void>;
shopsReady: boolean;
};
@@ -57,6 +61,8 @@ const initialState: State = {
orders: [],
cart: [],
favorites: [],
waitlists: [],
notifications: [],
};
const AppContext = createContext<AppContextValue | undefined>(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,
};

View File

@@ -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 <div className="text-center py-24 text-slate-500 font-black uppercase tracking-widest italic">Barbearia não encontrada</div>;
@@ -299,19 +303,40 @@ export default function Booking() {
{/* Right Side: Slots Grid */}
<div className="flex-1 space-y-6">
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{availableSlots.length > 0 ? (
availableSlots.map((h) => (
<button
key={h}
onClick={() => setSlot(h)}
className={`h-14 rounded-2xl border-2 text-sm font-black tracking-widest transition-all duration-300 ${
slot === h
? 'border-slate-900 bg-slate-900 text-indigo-400 shadow-xl scale-105 z-10'
: 'border-slate-50 bg-slate-50 text-slate-600 hover:border-indigo-200 hover:bg-indigo-50'
}`}
>
{h}
</button>
{processedSlots.length > 0 ? (
processedSlots.map((s) => (
s.isBooked ? (
s.waitlistedByMe ? (
<div key={s.time} className="h-14 rounded-2xl border-2 border-indigo-200 bg-indigo-50 flex items-center justify-center text-xs font-bold text-indigo-700 opacity-80 cursor-not-allowed">
Na Espera ({s.time})
</div>
) : (
<button
key={s.time}
onClick={async () => {
if (!user) { navigate('/login'); return; }
const ok = await joinWaitlist(shop.id, serviceId, barberId, `${date} ${s.time}`);
if (ok) alert('Adicionado à lista de espera! Receberá notificação se vagar.');
}}
className="h-14 rounded-2xl border-2 border-slate-200 bg-slate-100 text-xs font-bold text-slate-600 hover:bg-slate-200 hover:text-slate-800 transition-all flex flex-col items-center justify-center leading-tight shadow-inner"
>
<span className="text-[9px] uppercase font-black tracking-widest opacity-80">Esgotado</span>
<span className="text-[10px] uppercase font-semibold">Lista Espera</span>
</button>
)
) : (
<button
key={s.time}
onClick={() => setSlot(s.time)}
className={`h-14 rounded-2xl border-2 text-sm font-black tracking-widest transition-all duration-300 ${
slot === s.time
? 'border-slate-900 bg-slate-900 text-indigo-400 shadow-xl scale-105 z-10'
: 'border-slate-50 bg-slate-50 text-slate-600 hover:border-indigo-200 hover:bg-indigo-50'
}`}
>
{s.time}
</button>
)
))
) : (
<div className="col-span-full py-12 text-center bg-rose-50 rounded-[2rem] border border-rose-100">

View File

@@ -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<string, string> = {
}
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<string>('')
@@ -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() {
</div>
</section>
{/* 🔔 Notificações */}
{myNotifications.filter(n => !n.read).length > 0 && (
<section className="space-y-4">
<div className="flex items-center gap-2 px-2 text-rose-600">
<Bell size={18} className="fill-rose-100" />
<h2 className="text-sm font-black uppercase tracking-[0.2em]">Notificações</h2>
</div>
<div className="grid gap-3">
{myNotifications.filter(n => !n.read).map(n => (
<div key={n.id} className="p-4 bg-white border border-rose-100 shadow-md rounded-2xl flex items-center justify-between gap-4">
<div className="flex-1">
<p className="text-slate-800 text-sm font-semibold">{n.message}</p>
<p className="text-xs text-slate-400 mt-1">{new Date(n.createdAt).toLocaleString('pt-PT')}</p>
</div>
<button
onClick={() => markNotificationRead(n.id)}
className="px-3 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 font-bold text-[10px] uppercase tracking-widest rounded-lg transition-colors"
>
Marcar Lida
</button>
</div>
))}
</div>
</section>
)}
{/* ❤️ Barbearias Favoritas - Horizontal Scroll or Grid */}
{favoriteShops.length > 0 && (
<section className="space-y-6">

View File

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