feat: Introduce waitlist functionality and in-app notifications with supporting types and dependencies.
This commit is contained in:
1
index.ts
1
index.ts
@@ -1,3 +1,4 @@
|
||||
import 'react-native-get-random-values';
|
||||
import { registerRootComponent } from 'expo';
|
||||
import App from './App';
|
||||
|
||||
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
Já 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>
|
||||
|
||||
Reference in New Issue
Block a user