feat: implement calendar exceptions, realtime updates, and notifications

This commit is contained in:
2026-06-16 14:40:54 +01:00
parent 80f06019d3
commit a78b72b96f
6 changed files with 317 additions and 51 deletions

View File

@@ -70,7 +70,7 @@ export default function ShopDetails() {
Alert.alert('Sucesso', 'Produto adicionado ao carrinho.');
};
const schedule = shop.schedule || defaultSchedule;
const schedule = (shop.schedule || defaultSchedule).filter(s => !s.isException);
const currentDayIndex = new Date().getDay() === 0 ? 6 : new Date().getDay() - 1;
const isFav = isFavorite(shop.id);

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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