feat: implement calendar exceptions, realtime updates, and notifications
This commit is contained in:
@@ -50,6 +50,8 @@ type AppContextValue = State & {
|
||||
markNotificationRead: (id: string) => Promise<void>;
|
||||
refreshShops: () => Promise<void>;
|
||||
shopsReady: boolean;
|
||||
toasts: { id: string; message: string; type: 'info' | 'success' }[];
|
||||
removeToast: (id: string) => void;
|
||||
};
|
||||
|
||||
const initialState: State = {
|
||||
@@ -69,6 +71,37 @@ const AppContext = createContext<AppContextValue | undefined>(undefined);
|
||||
|
||||
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [toasts, setToasts] = useState<{ id: string; message: string; type: 'info' | 'success' }[]>([]);
|
||||
|
||||
const addToast = (message: string, type: 'info' | 'success' = 'info') => {
|
||||
const id = nanoid();
|
||||
setToasts((prev) => [...prev, { id, message, type }]);
|
||||
|
||||
try {
|
||||
const audio = new Audio('https://assets.mixkit.co/active_storage/sfx/2869/2869-120.wav');
|
||||
audio.volume = 0.4;
|
||||
audio.play().catch(() => {});
|
||||
} catch (e) {}
|
||||
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification('Smart Agenda', { body: message });
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 6000);
|
||||
};
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
Notification.requestPermission();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [state, setState] = useState<State>(() => {
|
||||
const stored = storage.get<Partial<State> | null>('smart-agenda', null);
|
||||
const safeStored = stored && typeof stored === 'object' ? stored : {};
|
||||
@@ -110,6 +143,16 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const { data: waitlistData } = await supabase.from('waitlist').select('*');
|
||||
const { data: notificationsData } = await supabase.from('notifications').select('*');
|
||||
const { data: reviewsData } = await supabase.from('reviews').select('shop_id, rating');
|
||||
const { data: profilesData } = await supabase.from('profiles').select('id, name, role, shop_id');
|
||||
|
||||
const formattedUsers: User[] = (profilesData ?? []).map((u) => ({
|
||||
id: u.id,
|
||||
name: u.name || 'Cliente',
|
||||
email: '',
|
||||
password: '',
|
||||
role: (u.role === 'barbearia' ? 'barbearia' : 'cliente') as 'cliente' | 'barbearia',
|
||||
shopId: u.shop_id || undefined,
|
||||
}));
|
||||
|
||||
const fetchedShops: BarberShop[] = shopsData.map((shop) => {
|
||||
const shopReviews = (reviewsData ?? []).filter(r => r.shop_id === shop.id);
|
||||
@@ -127,7 +170,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
paymentMethods: shop.payment_methods,
|
||||
socialNetworks: shop.social_networks,
|
||||
contacts: shop.contacts,
|
||||
services: (servicesData ?? [])
|
||||
ownerId: shop.owner_id || shop.ownerId || undefined,
|
||||
services: (servicesData ?? [])
|
||||
.filter((s) => s.shop_id === shop.id)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
@@ -219,8 +263,16 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return true;
|
||||
});
|
||||
|
||||
const mergedUsers = [...formattedUsers];
|
||||
s.users.forEach((localUser) => {
|
||||
if (!mergedUsers.some((u) => u.id === localUser.id)) {
|
||||
mergedUsers.push(localUser);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...s,
|
||||
users: mergedUsers,
|
||||
shops: dedupedShops,
|
||||
appointments: formattedAppointments,
|
||||
orders: formattedOrders,
|
||||
@@ -354,6 +406,47 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const channel = supabase
|
||||
.channel('db-changes')
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'appointments' },
|
||||
() => {
|
||||
refreshShops();
|
||||
}
|
||||
)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'orders' },
|
||||
() => {
|
||||
refreshShops();
|
||||
}
|
||||
);
|
||||
|
||||
if (state.user?.id) {
|
||||
channel.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'notifications',
|
||||
filter: `user_id=eq.${state.user.id}`,
|
||||
},
|
||||
(payload) => {
|
||||
addToast(payload.new.message, 'info');
|
||||
refreshShops();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
channel.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [state.user?.id]);
|
||||
|
||||
const login = (email: string, password: string) => {
|
||||
const found = state.users.find((u) => u.email === email && u.password === password);
|
||||
if (found) {
|
||||
@@ -450,6 +543,15 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shopOwnerId = shop.ownerId || shop.id;
|
||||
const clientName = state.user?.name || 'Um cliente';
|
||||
await supabase.from('notifications').insert([
|
||||
{
|
||||
user_id: shopOwnerId,
|
||||
message: `${clientName} solicitou um novo agendamento para o dia ${input.date}!`
|
||||
}
|
||||
]);
|
||||
|
||||
await refreshShops();
|
||||
|
||||
return {
|
||||
@@ -500,6 +602,18 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shop = state.shops.find((s) => s.id === shopId);
|
||||
if (shop) {
|
||||
const shopOwnerId = shop.ownerId || shop.id;
|
||||
const clientName = state.user?.name || 'Um cliente';
|
||||
await supabase.from('notifications').insert([
|
||||
{
|
||||
user_id: shopOwnerId,
|
||||
message: `${clientName} fez um novo pedido de produto no valor de €${total.toFixed(2)}!`
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
shopId: data.shop_id,
|
||||
@@ -740,6 +854,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
joinWaitlist,
|
||||
markNotificationRead,
|
||||
refreshShops,
|
||||
toasts,
|
||||
removeToast,
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
|
||||
@@ -46,8 +46,29 @@ export default function Booking() {
|
||||
return slots;
|
||||
};
|
||||
|
||||
const isShopClosedOnDate = useMemo(() => {
|
||||
if (!date || !shop) return false;
|
||||
const dateObj = new Date(date + 'T00:00:00');
|
||||
const dayOfWeekIndex = dateObj.getDay();
|
||||
const ptDays = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado'];
|
||||
const dayName = ptDays[dayOfWeekIndex];
|
||||
|
||||
const exception = shop.schedule?.find(s => s.isException && s.date === date);
|
||||
if (exception) {
|
||||
return exception.closed;
|
||||
}
|
||||
|
||||
const defaultDay = shop.schedule?.find(s => s.day === dayName && !s.isException);
|
||||
if (defaultDay) {
|
||||
return defaultDay.closed;
|
||||
}
|
||||
|
||||
if (dayName === 'Domingo') return true;
|
||||
return false;
|
||||
}, [shop?.schedule, date]);
|
||||
|
||||
const processedSlots = useMemo(() => {
|
||||
if (!selectedBarber || !date) return [];
|
||||
if (!selectedBarber || !date || isShopClosedOnDate) return [];
|
||||
const specificSchedule = selectedBarber.schedule?.find((s) => s.day === date);
|
||||
let slots = specificSchedule && specificSchedule.slots.length > 0
|
||||
? [...specificSchedule.slots]
|
||||
@@ -119,7 +140,7 @@ export default function Booking() {
|
||||
const nextStep = () => setStep((s) => Math.min(s + 1, 4));
|
||||
const prevStep = () => setStep((s) => Math.max(s - 1, 1));
|
||||
|
||||
const canSubmit = serviceId && barberId && date && slot;
|
||||
const canSubmit = serviceId && barberId && date && slot && !isShopClosedOnDate;
|
||||
|
||||
const submit = async () => {
|
||||
if (!user) {
|
||||
@@ -355,7 +376,16 @@ export default function Booking() {
|
||||
{/* Right Side: Slots Grid */}
|
||||
<div className="flex-1 space-y-6">
|
||||
<div className="space-y-6">
|
||||
{processedSlots.some(s => !s.isBooked) ? (
|
||||
{isShopClosedOnDate ? (
|
||||
<div className="py-12 px-6 text-center bg-rose-50 rounded-[2rem] border border-rose-100 flex flex-col items-center gap-4 animate-in fade-in">
|
||||
<p className="text-sm text-rose-600 font-black uppercase tracking-widest italic">
|
||||
O estabelecimento está fechado neste dia.
|
||||
</p>
|
||||
<p className="text-xs text-rose-500 font-medium">
|
||||
Por favor, volte atrás e selecione outra data.
|
||||
</p>
|
||||
</div>
|
||||
) : processedSlots.some(s => !s.isBooked) ? (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||
{processedSlots.filter(s => !s.isBooked).map((s) => (
|
||||
<button
|
||||
|
||||
@@ -124,8 +124,13 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
deleteBarber,
|
||||
updateBarber,
|
||||
updateShopDetails,
|
||||
toasts,
|
||||
removeToast,
|
||||
} = useApp();
|
||||
|
||||
const [exceptionDate, setExceptionDate] = useState('');
|
||||
const [exceptionClosed, setExceptionClosed] = useState(true);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview');
|
||||
const [period, setPeriod] = useState<keyof typeof periods>('semana');
|
||||
const [currentWeek, setCurrentWeek] = useState(new Date());
|
||||
@@ -1356,52 +1361,146 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
<div className="pt-4 border-t border-slate-200">
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-4">Horário de Atendimento</h3>
|
||||
<div className="space-y-3">
|
||||
{editSchedule.map((slot, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3">
|
||||
<div className="w-32">
|
||||
<span className={`text-sm font-medium ${slot.closed ? 'text-slate-400' : 'text-slate-700'}`}>{slot.day}</span>
|
||||
{editSchedule.filter(slot => !slot.isException).map((slot) => {
|
||||
const idx = editSchedule.findIndex(s => s.day === slot.day && !s.isException);
|
||||
return (
|
||||
<div key={slot.day} className="flex items-center gap-3">
|
||||
<div className="w-32">
|
||||
<span className={`text-sm font-medium ${slot.closed ? 'text-slate-400' : 'text-slate-700'}`}>{slot.day}</span>
|
||||
</div>
|
||||
{!slot.closed ? (
|
||||
<>
|
||||
<Input
|
||||
type="time"
|
||||
value={slot.open}
|
||||
onChange={(e) => {
|
||||
const newSchedule = [...editSchedule];
|
||||
if (idx !== -1) {
|
||||
newSchedule[idx].open = e.target.value;
|
||||
setEditSchedule(newSchedule);
|
||||
}
|
||||
}}
|
||||
className="w-28"
|
||||
/>
|
||||
<span className="text-slate-400">-</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={slot.close}
|
||||
onChange={(e) => {
|
||||
const newSchedule = [...editSchedule];
|
||||
if (idx !== -1) {
|
||||
newSchedule[idx].close = e.target.value;
|
||||
setEditSchedule(newSchedule);
|
||||
}
|
||||
}}
|
||||
className="w-28"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 text-sm text-slate-400 italic">Fechado</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
const newSchedule = [...editSchedule];
|
||||
if (idx !== -1) {
|
||||
newSchedule[idx].closed = !newSchedule[idx].closed;
|
||||
setEditSchedule(newSchedule);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{slot.closed ? 'Abrir' : 'Fechar'}
|
||||
</Button>
|
||||
</div>
|
||||
{!slot.closed ? (
|
||||
<>
|
||||
<Input
|
||||
type="time"
|
||||
value={slot.open}
|
||||
onChange={(e) => {
|
||||
const newSchedule = [...editSchedule];
|
||||
newSchedule[idx].open = e.target.value;
|
||||
setEditSchedule(newSchedule);
|
||||
}}
|
||||
className="w-28"
|
||||
/>
|
||||
<span className="text-slate-400">-</span>
|
||||
<Input
|
||||
type="time"
|
||||
value={slot.close}
|
||||
onChange={(e) => {
|
||||
const newSchedule = [...editSchedule];
|
||||
newSchedule[idx].close = e.target.value;
|
||||
setEditSchedule(newSchedule);
|
||||
}}
|
||||
className="w-28"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 text-sm text-slate-400 italic">Fechado</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
const newSchedule = [...editSchedule];
|
||||
newSchedule[idx].closed = !newSchedule[idx].closed;
|
||||
setEditSchedule(newSchedule);
|
||||
}}
|
||||
>
|
||||
{slot.closed ? 'Abrir' : 'Fechar'}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-slate-200">
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-2">Exceções de Horário (Datas Específicas)</h3>
|
||||
<p className="text-xs text-slate-500 mb-4">
|
||||
Defina datas especiais em que a barbearia estará especificamente fechada (ex: Natal) ou aberta num dia normalmente fechado.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 items-end bg-slate-50 p-4 rounded-xl border border-slate-200">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 mb-1.5">Data</label>
|
||||
<input
|
||||
type="date"
|
||||
value={exceptionDate}
|
||||
onChange={(e) => setExceptionDate(e.target.value)}
|
||||
className="w-full h-10 border border-slate-300 rounded-lg px-3 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-500 mb-1.5">Estado</label>
|
||||
<select
|
||||
value={exceptionClosed ? 'closed' : 'open'}
|
||||
onChange={(e) => setExceptionClosed(e.target.value === 'closed')}
|
||||
className="w-full h-10 border border-slate-300 rounded-lg px-3 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30"
|
||||
>
|
||||
<option value="closed">Fechado</option>
|
||||
<option value="open">Aberto</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!exceptionDate) {
|
||||
alert('Selecione uma data para a exceção.');
|
||||
return;
|
||||
}
|
||||
if (editSchedule.some(s => s.isException && s.date === exceptionDate)) {
|
||||
alert('Já existe uma exceção configurada para esta data.');
|
||||
return;
|
||||
}
|
||||
const newException: ShopSchedule = {
|
||||
day: 'Exceção',
|
||||
open: '09:00',
|
||||
close: '19:30',
|
||||
closed: exceptionClosed,
|
||||
date: exceptionDate,
|
||||
isException: true
|
||||
};
|
||||
setEditSchedule([...editSchedule, newException]);
|
||||
setExceptionDate('');
|
||||
}}
|
||||
className="h-10 bg-indigo-600 hover:bg-indigo-700 text-white"
|
||||
>
|
||||
Adicionar Exceção
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{editSchedule.filter(s => s.isException).length > 0 ? (
|
||||
editSchedule.filter(s => s.isException).map((slot) => (
|
||||
<div key={slot.date} className="flex items-center justify-between p-3 bg-white border border-slate-200 rounded-lg text-sm shadow-sm">
|
||||
<div>
|
||||
<span className="font-bold text-slate-700">
|
||||
{new Date(slot.date! + 'T00:00:00').toLocaleDateString('pt-PT', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
<span className={`ml-2 px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider ${slot.closed ? 'bg-rose-100 text-rose-700' : 'bg-emerald-100 text-emerald-700'}`}>
|
||||
{slot.closed ? 'Fechado' : 'Aberto'}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-rose-600 hover:bg-rose-50 border-rose-200 hover:border-rose-300"
|
||||
onClick={() => {
|
||||
setEditSchedule(editSchedule.filter(s => s.date !== slot.date));
|
||||
}}
|
||||
>
|
||||
Remover
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 italic">Nenhuma exceção configurada.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1437,6 +1536,25 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast Notifications Overlay */}
|
||||
{toasts && toasts.length > 0 && (
|
||||
<div className="fixed bottom-5 right-5 z-50 flex flex-col gap-2 max-w-sm w-full animate-in fade-in duration-300">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
onClick={() => removeToast(t.id)}
|
||||
className="p-4 bg-slate-900 text-white border border-indigo-500/30 rounded-xl shadow-2xl flex items-center gap-3 cursor-pointer hover:bg-slate-800 transition-all transform hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<div className="w-2.5 h-2.5 bg-indigo-50 rounded-full shrink-0 animate-pulse" />
|
||||
<div className="flex-1 text-xs font-semibold leading-relaxed">{t.message}</div>
|
||||
<button className="text-slate-400 hover:text-white text-xs font-bold px-1.5 py-0.5 rounded bg-white/10">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export type Barber = { id: string; name: string; imageUrl?: string; specialties: string[]; schedule: { day: string; slots: string[] }[] };
|
||||
export type Service = { id: string; name: string; price: number; duration: number; barberIds: string[] };
|
||||
export type Product = { id: string; name: string; price: number; stock: number };
|
||||
export type ShopSchedule = { day: string; open: string; close: string; closed?: boolean };
|
||||
export type ShopSchedule = { day: string; open: string; close: string; closed?: boolean; date?: string; isException?: boolean };
|
||||
export type BarberShop = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -15,6 +15,7 @@ export type BarberShop = {
|
||||
paymentMethods?: string[];
|
||||
socialNetworks?: { whatsapp?: string; instagram?: string; facebook?: string };
|
||||
contacts?: { phone1?: string; phone2?: string };
|
||||
ownerId?: string;
|
||||
};
|
||||
export type AppointmentStatus = 'pendente' | 'confirmado' | 'concluido' | 'cancelado';
|
||||
export type OrderStatus = 'pendente' | 'confirmado' | 'concluido' | 'cancelado';
|
||||
|
||||
Reference in New Issue
Block a user