feat: Introduce waitlist functionality and in-app notifications with supporting types and dependencies.

This commit is contained in:
2026-03-18 10:12:30 +00:00
parent 77b81e9be6
commit 1c68449e05
9 changed files with 222 additions and 46 deletions

View File

@@ -1,3 +1,4 @@
import 'react-native-get-random-values';
import { registerRootComponent } from 'expo';
import App from './App';

32
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-get-random-values": "^2.0.0",
"react-native-safe-area-context": "4.10.1",
"react-native-screens": "~3.31.1",
"react-native-web": "^0.21.0"
@@ -69,6 +70,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -3039,6 +3041,7 @@
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-6.1.18.tgz",
"integrity": "sha512-mIT9MiL/vMm4eirLcmw2h6h/Nm5FICtnYSdohq4vTLA2FF/6PNhByM7s8ffqoVfE5L0uAa6Xda1B7oddolUiGg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-navigation/core": "^6.4.17",
"escape-string-regexp": "^4.0.0",
@@ -3332,6 +3335,7 @@
"integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4029,6 +4033,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4800,6 +4805,7 @@
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz",
"integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.0",
"@expo/cli": "54.0.23",
@@ -4938,6 +4944,7 @@
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz",
"integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==",
"license": "MIT",
"peer": true,
"dependencies": {
"fontfaceobserver": "^2.1.0"
},
@@ -5273,6 +5280,12 @@
"integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==",
"license": "Apache-2.0"
},
"node_modules/fast-base64-decode": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz",
"integrity": "sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -8011,6 +8024,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -8030,6 +8044,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -8060,6 +8075,7 @@
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz",
"integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/create-cache-key-function": "^29.7.0",
"@react-native/assets-registry": "0.81.5",
@@ -8112,6 +8128,18 @@
}
}
},
"node_modules/react-native-get-random-values": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-2.0.0.tgz",
"integrity": "sha512-wx7/aPqsUIiWsG35D+MsUJd8ij96e3JKddklSdrdZUrheTx89gPtz3Q2yl9knBArj5u26Cl23T88ai+Q0vypdQ==",
"license": "MIT",
"dependencies": {
"fast-base64-decode": "^1.0.0"
},
"peerDependencies": {
"react-native": ">=0.81"
}
},
"node_modules/react-native-is-edge-to-edge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
@@ -8127,6 +8155,7 @@
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.10.1.tgz",
"integrity": "sha512-w8tCuowDorUkPoWPXmhqosovBr33YsukkwYCDERZFHAxIkx6qBadYxfeoaJ91nCQKjkNzGrK5qhoNOeSIcYSpA==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
@@ -8137,6 +8166,7 @@
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-3.31.1.tgz",
"integrity": "sha512-8fRW362pfZ9y4rS8KY5P3DFScrmwo/vu1RrRMMx0PNHbeC9TLq0Kw1ubD83591yz64gLNHFLTVkTJmWeWCXKtQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"react-freeze": "^1.0.0",
"warn-once": "^0.1.0"
@@ -8267,6 +8297,7 @@
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
"integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -9226,6 +9257,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},

View File

@@ -22,6 +22,7 @@
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-get-random-values": "^2.0.0",
"react-native-safe-area-context": "4.10.1",
"react-native-screens": "~3.31.1",
"react-native-web": "^0.21.0"

View File

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

View File

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

View File

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

View File

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

View File

@@ -523,12 +523,13 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
}
if (status === 'cancelado' && apt) {
const waitingUsers = state.waitlists.filter(w => w.barberId === apt.barberId && w.date === apt.date && w.status === 'pending');
const waitlistDate = apt.date.split(' ')[0]; // Extract YYYY-MM-DD
const waitingUsers = state.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 ${w.date}! Corra para fazer a reserva.`
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);

View File

@@ -302,29 +302,10 @@ 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">
{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>
)
) : (
<div className="space-y-6">
{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
key={s.time}
onClick={() => setSlot(s.time)}
@@ -336,11 +317,35 @@ export default function Booking() {
>
{s.time}
</button>
)
))
))}
</div>
) : processedSlots.length > 0 ? (
<div className="py-8 px-6 text-center bg-rose-50 rounded-[2rem] border border-rose-100 flex flex-col items-center gap-4">
<p className="text-sm text-rose-600 font-black uppercase tracking-widest italic">
Todos os horários estão preenchidos para este dia.
</p>
{user && waitlists.some(w => w.barberId === barberId && w.date === date && w.customerId === user.id && w.status === 'pending') ? (
<div className="px-4 py-2 bg-indigo-100 text-indigo-700 rounded-xl text-xs font-bold uppercase tracking-widest">
estás na lista de espera deste dia!
</div>
) : (
<Button
onClick={async () => {
if (!user) { navigate('/login'); return; }
if (!serviceId) { alert('Selecione primeiro o serviço que pretende.'); return; }
const ok = await joinWaitlist(shop.id, serviceId, barberId, date);
if (ok) alert('Entraste na lista de espera! Serás notificado se houver desistências.');
}}
disabled={!serviceId}
className={`${serviceId ? 'bg-rose-600 hover:bg-rose-700' : 'bg-slate-300 cursor-not-allowed'} text-white font-black rounded-xl uppercase tracking-widest text-[10px] italic h-10 px-6`}
>
Entrar na Lista de Espera
</Button>
)}
</div>
) : (
<div className="col-span-full py-12 text-center bg-rose-50 rounded-[2rem] border border-rose-100">
<p className="text-sm text-rose-600 font-black uppercase tracking-widest italic">Sem disponibilidade para este dia</p>
<div className="col-span-full py-12 text-center bg-indigo-50 rounded-[2rem] border border-indigo-100">
<p className="text-sm text-indigo-600 font-black uppercase tracking-widest italic">Selecione primeiro o mestre e a data</p>
</div>
)}
</div>