feat: Implement waitlist functionality and a notification system, including automatic user notifications for available slots after appointment cancellations.
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user