diff --git a/src/pages/ShopDetails.tsx b/src/pages/ShopDetails.tsx index 8080fb3..ecd6959 100644 --- a/src/pages/ShopDetails.tsx +++ b/src/pages/ShopDetails.tsx @@ -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); diff --git a/src/types.ts b/src/types.ts index c20e694..b9b903b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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'; diff --git a/web/src/context/AppContext.tsx b/web/src/context/AppContext.tsx index af59b05..bdb9c70 100644 --- a/web/src/context/AppContext.tsx +++ b/web/src/context/AppContext.tsx @@ -50,6 +50,8 @@ type AppContextValue = State & { markNotificationRead: (id: string) => Promise; refreshShops: () => Promise; shopsReady: boolean; + toasts: { id: string; message: string; type: 'info' | 'success' }[]; + removeToast: (id: string) => void; }; const initialState: State = { @@ -69,6 +71,37 @@ const AppContext = createContext(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(() => { const stored = storage.get | 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) { diff --git a/web/src/pages/Booking.tsx b/web/src/pages/Booking.tsx index 99cedc5..2486073 100644 --- a/web/src/pages/Booking.tsx +++ b/web/src/pages/Booking.tsx @@ -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 */}
- {processedSlots.some(s => !s.isBooked) ? ( + {isShopClosedOnDate ? ( +
+

+ O estabelecimento está fechado neste dia. +

+

+ Por favor, volte atrás e selecione outra data. +

+
+ ) : processedSlots.some(s => !s.isBooked) ? (
{processedSlots.filter(s => !s.isBooked).map((s) => (
- {!slot.closed ? ( - <> - { - const newSchedule = [...editSchedule]; - newSchedule[idx].open = e.target.value; - setEditSchedule(newSchedule); - }} - className="w-28" - /> - - - { - const newSchedule = [...editSchedule]; - newSchedule[idx].close = e.target.value; - setEditSchedule(newSchedule); - }} - className="w-28" - /> - - ) : ( -
Fechado
- )} - + ); + })} +
+
+ +
+

Exceções de Horário (Datas Específicas)

+

+ Defina datas especiais em que a barbearia estará especificamente fechada (ex: Natal) ou aberta num dia normalmente fechado. +

+
+
+
+ + 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" + />
- ))} +
+ + +
+ +
+ +
+ {editSchedule.filter(s => s.isException).length > 0 ? ( + editSchedule.filter(s => s.isException).map((slot) => ( +
+
+ + {new Date(slot.date! + 'T00:00:00').toLocaleDateString('pt-PT', { day: 'numeric', month: 'long', year: 'numeric' })} + + + {slot.closed ? 'Fechado' : 'Aberto'} + +
+ +
+ )) + ) : ( +

Nenhuma exceção configurada.

+ )} +
@@ -1437,6 +1536,25 @@ function DashboardInner({ shop }: { shop: BarberShop }) { )} + + {/* Toast Notifications Overlay */} + {toasts && toasts.length > 0 && ( +
+ {toasts.map((t) => ( +
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]" + > +
+
{t.message}
+ +
+ ))} +
+ )}
); } diff --git a/web/src/types.ts b/web/src/types.ts index d11c6f3..9b40f2f 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -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';