feat: Introduce waitlist functionality and in-app notifications with supporting types and dependencies.
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
* lidando com Auth, Consultas (Shops/Services) e CRUD (Criar/Ler/Atualizar/Apagar).
|
||||
*/
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { Appointment, Barber, BarberShop, CartItem, Order, Product, Service, User } from '../types';
|
||||
import { Appointment, Barber, BarberShop, CartItem, Order, Product, Service, User, WaitlistEntry, AppNotification } from '../types';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { nanoid } from 'nanoid';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
@@ -18,6 +18,8 @@ type State = {
|
||||
cart: CartItem[];
|
||||
appointments: Appointment[];
|
||||
orders: Order[];
|
||||
waitlists: WaitlistEntry[];
|
||||
notifications: AppNotification[];
|
||||
};
|
||||
|
||||
type AppContextValue = State & {
|
||||
@@ -40,6 +42,8 @@ type AppContextValue = State & {
|
||||
addBarber: (shopId: string, barber: Omit<Barber, 'id'>) => Promise<void>;
|
||||
updateBarber: (shopId: string, barber: Barber) => Promise<void>;
|
||||
deleteBarber: (shopId: string, barberId: string) => Promise<void>;
|
||||
joinWaitlist: (shopId: string, serviceId: string, barberId: string, date: string) => Promise<boolean>;
|
||||
markNotificationRead: (id: string) => Promise<void>;
|
||||
refreshShops: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -50,6 +54,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [cart, setCart] = useState<CartItem[]>([]);
|
||||
const [waitlists, setWaitlists] = useState<WaitlistEntry[]>([]);
|
||||
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||||
const [user, setUser] = useState<User | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -92,6 +98,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: waitlistsData } = await supabase.from('waitlist').select('*');
|
||||
const { data: notificationsData } = await supabase.from('notifications').select('*');
|
||||
|
||||
if (shopsData) {
|
||||
const merged: BarberShop[] = shopsData.map((shop: any) => ({
|
||||
@@ -142,6 +150,33 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (waitlistsData) {
|
||||
setWaitlists(
|
||||
waitlistsData.map((w: any) => ({
|
||||
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 WaitlistEntry['status'],
|
||||
createdAt: w.created_at,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
if (notificationsData) {
|
||||
setNotifications(
|
||||
notificationsData.map((n: any) => ({
|
||||
id: n.id,
|
||||
userId: n.user_id,
|
||||
message: n.message,
|
||||
read: n.read,
|
||||
createdAt: n.created_at,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error refreshing shops:', err);
|
||||
}
|
||||
@@ -293,7 +328,26 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
};
|
||||
|
||||
const updateAppointmentStatus = async (id: string, status: Appointment['status']) => {
|
||||
const apt = appointments.find((a) => a.id === id);
|
||||
await supabase.from('appointments').update({ status }).eq('id', id);
|
||||
|
||||
if (status === 'cancelado' && apt) {
|
||||
const waitlistDate = apt.date.split(' ')[0]; // Extract YYYY-MM-DD
|
||||
const waitingUsers = waitlists.filter(w => w.barberId === apt.barberId && w.date === waitlistDate && 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 ${waitlistDate} às ${apt.date.split(' ')[1]}! 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();
|
||||
};
|
||||
|
||||
@@ -302,6 +356,30 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
await refreshShops();
|
||||
};
|
||||
|
||||
const joinWaitlist = async (shopId: string, serviceId: string, barberId: string, date: string) => {
|
||||
if (!user) return false;
|
||||
const { error } = await supabase.from('waitlist').insert([{
|
||||
shop_id: shopId,
|
||||
service_id: serviceId,
|
||||
barber_id: barberId,
|
||||
customer_id: 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 = async (id: string) => {
|
||||
const { error } = await supabase.from('notifications').update({ read: true }).eq('id', id);
|
||||
if (error) console.error("Erro ao marcar notificação:", error);
|
||||
else await refreshShops();
|
||||
};
|
||||
|
||||
const value: AppContextValue = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
@@ -309,6 +387,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
cart,
|
||||
appointments,
|
||||
orders,
|
||||
waitlists,
|
||||
notifications,
|
||||
login,
|
||||
logout,
|
||||
register,
|
||||
@@ -328,9 +408,11 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
addBarber,
|
||||
updateBarber,
|
||||
deleteBarber,
|
||||
joinWaitlist,
|
||||
markNotificationRead,
|
||||
refreshShops,
|
||||
}),
|
||||
[user, shops, cart, appointments, orders]
|
||||
[user, shops, cart, appointments, orders, waitlists, notifications]
|
||||
);
|
||||
|
||||
if (loading) return null;
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function Booking() {
|
||||
const { shopId } = route.params as { shopId: string };
|
||||
|
||||
// Extrai as entidades e os métodos de interação com a base de dados (através do AppContext)
|
||||
const { shops, createAppointment, user, appointments } = useApp();
|
||||
const { shops, createAppointment, user, appointments, waitlists, joinWaitlist } = useApp();
|
||||
|
||||
// Encontra a barbearia correspondente tipada via query local na array 'shops' (similar a um SELECT * WHERE id = shopId)
|
||||
const shop = useMemo(() => shops.find((s) => s.id === shopId), [shops, shopId]);
|
||||
@@ -73,15 +73,13 @@ export default function Booking() {
|
||||
* 2. Cruza com as 'appointments' da base de dados (onde `status` não está cancelado).
|
||||
* 3. Subtrai os slots já ocupados para garantir a consistência das marcações.
|
||||
*/
|
||||
const availableSlots = useMemo(() => {
|
||||
const processedSlots = useMemo(() => {
|
||||
if (!selectedBarber || !date) return [];
|
||||
// Busca a array de schedule (tipo específico) baseada em JSON / relação
|
||||
const specificSchedule = selectedBarber.schedule.find((s) => s.day === date);
|
||||
let slots = specificSchedule && specificSchedule.slots.length > 0
|
||||
? [...specificSchedule.slots]
|
||||
: generateDefaultSlots();
|
||||
|
||||
// Obtém as marcações validadas (equivalente a `supabase.from('appointments').select(*).eq(...)`)
|
||||
const bookedSlots = appointments
|
||||
.filter((apt) =>
|
||||
apt.barberId === barberId &&
|
||||
@@ -89,15 +87,17 @@ export default function Booking() {
|
||||
apt.date.startsWith(date)
|
||||
)
|
||||
.map((apt) => {
|
||||
// Separa a data completa num timestamp (ex: "2023-10-10 14:00" -> "14:00")
|
||||
const parts = apt.date.split(' ');
|
||||
return parts.length > 1 ? parts[1] : '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
// Filtro devolvendo assim uma array de horários finais para o UI renderizar
|
||||
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]);
|
||||
|
||||
// Booleano derivável auxiliar, controla o bloqueio ou liberação do botão submit
|
||||
const canSubmit = serviceId && barberId && date && slot;
|
||||
@@ -181,16 +181,48 @@ export default function Booking() {
|
||||
|
||||
<Text style={styles.sectionTitle}>4. Horário</Text>
|
||||
<View style={styles.slotsContainer}>
|
||||
{availableSlots.length > 0 ? (
|
||||
availableSlots.map((h) => (
|
||||
{processedSlots.some(s => !s.isBooked) ? (
|
||||
processedSlots.filter(s => !s.isBooked).map((s) => (
|
||||
<TouchableOpacity
|
||||
key={h}
|
||||
style={[styles.slotButton, slot === h && styles.slotButtonActive]}
|
||||
onPress={() => setSlot(h)}
|
||||
key={s.time}
|
||||
style={[styles.slotButton, slot === s.time && styles.slotButtonActive]}
|
||||
onPress={() => setSlot(s.time)}
|
||||
>
|
||||
<Text style={[styles.slotText, slot === h && styles.slotTextActive]}>{h}</Text>
|
||||
<Text style={[styles.slotText, slot === s.time && styles.slotTextActive]}>{s.time}</Text>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
) : processedSlots.length > 0 ? (
|
||||
<View style={{ flex: 1, alignItems: 'center', padding: 20, backgroundColor: '#fff1f2', borderRadius: 16, borderWidth: 1, borderColor: '#fecdd3' }}>
|
||||
<Text style={{ fontSize: 14, fontWeight: 'bold', color: '#e11d48', textAlign: 'center', marginBottom: 12 }}>
|
||||
Todos os horários estão preenchidos para este dia.
|
||||
</Text>
|
||||
{user && waitlists.some(w => w.barberId === barberId && w.date === date && w.customerId === user.id && w.status === 'pending') ? (
|
||||
<View style={{ paddingVertical: 8, paddingHorizontal: 16, backgroundColor: '#fdf2f8', borderRadius: 8 }}>
|
||||
<Text style={{ color: '#db2777', fontWeight: 'bold', fontSize: 12 }}>Já estás na lista de espera deste dia!</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
onPress={async () => {
|
||||
if (!user) {
|
||||
Alert.alert('Login necessário', 'Faça login para entrar na lista de espera');
|
||||
navigation.navigate('Login' as never);
|
||||
return;
|
||||
}
|
||||
if (!serviceId) {
|
||||
Alert.alert('Atenção', 'Selecione primeiro o serviço que pretende.');
|
||||
return;
|
||||
}
|
||||
const ok = await joinWaitlist(shop.id, serviceId, barberId, date);
|
||||
if (ok) Alert.alert('Sucesso', 'Entraste na lista de espera! Serás notificado se houver desistências.');
|
||||
}}
|
||||
variant="outline"
|
||||
disabled={!serviceId}
|
||||
style={{ borderColor: serviceId ? '#e11d48' : '#cbd5e1' }}
|
||||
>
|
||||
<Text style={{ color: serviceId ? '#e11d48' : '#94a3b8', fontWeight: 'bold' }}>Entrar na Lista de Espera</Text>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.noSlots}>Selecione primeiro o mestre e a data</Text>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ const statusColor: Record<string, 'indigo' | 'green' | 'slate' | 'red'> = {
|
||||
export default function Profile() {
|
||||
const navigation = useNavigation();
|
||||
// Obtém sessão do utilizador (auth) e listas globais da BD (appointments e orders)
|
||||
const { user, appointments, orders, shops, logout } = useApp();
|
||||
const { user, appointments, orders, shops, logout, notifications, markNotificationRead } = useApp();
|
||||
|
||||
// Guarda/Bloqueio protetor para forçar navegação ou alertar utilizadores anónimos
|
||||
if (!user) {
|
||||
@@ -37,6 +37,9 @@ export default function Profile() {
|
||||
// Filtragem (equivalente a queries com cláusula WHERE customerId = ?) para obter o histórico individual
|
||||
const myAppointments = appointments.filter((a) => a.customerId === user.id);
|
||||
const myOrders = orders.filter((o) => o.customerId === user.id);
|
||||
const myNotifications = (notifications || [])
|
||||
.filter((n) => n.userId === user.id && !n.read)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
return (
|
||||
// Contentor com ScrollView adaptável (evita cortes em ecrãs pequenos)
|
||||
@@ -57,6 +60,23 @@ export default function Profile() {
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{myNotifications.length > 0 && (
|
||||
<>
|
||||
<Text style={styles.sectionTitle}>Notificações</Text>
|
||||
{myNotifications.map((n) => (
|
||||
<Card key={n.id} style={[styles.itemCard, { borderColor: '#fecdd3', borderWidth: 1 }]}>
|
||||
<View style={styles.itemHeader}>
|
||||
<Text style={[styles.itemName, { color: '#e11d48' }]}>🔔 Nova Vaga!</Text>
|
||||
</View>
|
||||
<Text style={{ fontSize: 14, color: '#334155', marginBottom: 16 }}>{n.message}</Text>
|
||||
<Button onPress={() => markNotificationRead(n.id)} variant="outline" style={{ backgroundColor: '#f1f5f9' }}>
|
||||
Marcar Lida
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={styles.sectionTitle}>As Minhas Reservas</Text>
|
||||
{/* Renderiza a lista se existirem marcações no percurso deste utilizador */}
|
||||
{myAppointments.length > 0 ? (
|
||||
|
||||
@@ -8,5 +8,7 @@ export type Appointment = { id: string; shopId: string; serviceId: string; barbe
|
||||
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 User = { id: string; name: string; email: string; password: string; role: 'cliente' | 'barbearia'; shopId?: string; fcmToken?: 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 };
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user