From 1c68449e0576de6a9cc15e164656ebfb12e7b3ed Mon Sep 17 00:00:00 2001 From: 230417 <230417@epvc.pt> Date: Wed, 18 Mar 2026 10:12:30 +0000 Subject: [PATCH] feat: Introduce waitlist functionality and in-app notifications with supporting types and dependencies. --- index.ts | 1 + package-lock.json | 32 +++++++++++++ package.json | 1 + src/context/AppContext.tsx | 86 +++++++++++++++++++++++++++++++++- src/pages/Booking.tsx | 60 ++++++++++++++++++------ src/pages/Profile.tsx | 22 ++++++++- src/types.ts | 2 + web/src/context/AppContext.tsx | 5 +- web/src/pages/Booking.tsx | 59 ++++++++++++----------- 9 files changed, 222 insertions(+), 46 deletions(-) diff --git a/index.ts b/index.ts index 5fd059f..2dab631 100644 --- a/index.ts +++ b/index.ts @@ -1,3 +1,4 @@ +import 'react-native-get-random-values'; import { registerRootComponent } from 'expo'; import App from './App'; diff --git a/package-lock.json b/package-lock.json index 7268191..eec3255 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/package.json b/package.json index 32911de..2e83b84 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index aca679d..e68de58 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -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) => Promise; updateBarber: (shopId: string, barber: Barber) => Promise; deleteBarber: (shopId: string, barberId: string) => Promise; + joinWaitlist: (shopId: string, serviceId: string, barberId: string, date: string) => Promise; + markNotificationRead: (id: string) => Promise; refreshShops: () => Promise; }; @@ -50,6 +54,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { const [appointments, setAppointments] = useState([]); const [orders, setOrders] = useState([]); const [cart, setCart] = useState([]); + const [waitlists, setWaitlists] = useState([]); + const [notifications, setNotifications] = useState([]); const [user, setUser] = useState(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; diff --git a/src/pages/Booking.tsx b/src/pages/Booking.tsx index ca50bfe..8d69815 100644 --- a/src/pages/Booking.tsx +++ b/src/pages/Booking.tsx @@ -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() { 4. Horário - {availableSlots.length > 0 ? ( - availableSlots.map((h) => ( + {processedSlots.some(s => !s.isBooked) ? ( + processedSlots.filter(s => !s.isBooked).map((s) => ( setSlot(h)} + key={s.time} + style={[styles.slotButton, slot === s.time && styles.slotButtonActive]} + onPress={() => setSlot(s.time)} > - {h} + {s.time} )) + ) : processedSlots.length > 0 ? ( + + + Todos os horários estão preenchidos para este dia. + + {user && waitlists.some(w => w.barberId === barberId && w.date === date && w.customerId === user.id && w.status === 'pending') ? ( + + Já estás na lista de espera deste dia! + + ) : ( + + )} + ) : ( Selecione primeiro o mestre e a data )} diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index d064e87..6b6d235 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -23,7 +23,7 @@ const statusColor: Record = { 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() { + {myNotifications.length > 0 && ( + <> + Notificações + {myNotifications.map((n) => ( + + + 🔔 Nova Vaga! + + {n.message} + + + ))} + + )} + As Minhas Reservas {/* Renderiza a lista se existirem marcações no percurso deste utilizador */} {myAppointments.length > 0 ? ( diff --git a/src/types.ts b/src/types.ts index 46d64db..c953b4e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 }; diff --git a/web/src/context/AppContext.tsx b/web/src/context/AppContext.tsx index 415630f..e33f8d6 100644 --- a/web/src/context/AppContext.tsx +++ b/web/src/context/AppContext.tsx @@ -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); diff --git a/web/src/pages/Booking.tsx b/web/src/pages/Booking.tsx index 37351ff..b879a93 100644 --- a/web/src/pages/Booking.tsx +++ b/web/src/pages/Booking.tsx @@ -302,29 +302,10 @@ export default function Booking() { {/* Right Side: Slots Grid */}
-
- {processedSlots.length > 0 ? ( - processedSlots.map((s) => ( - s.isBooked ? ( - s.waitlistedByMe ? ( -
- Na Espera ({s.time}) -
- ) : ( - - ) - ) : ( +
+ {processedSlots.some(s => !s.isBooked) ? ( +
+ {processedSlots.filter(s => !s.isBooked).map((s) => ( - ) - )) + ))} +
+ ) : processedSlots.length > 0 ? ( +
+

+ Todos os horários estão preenchidos para este dia. +

+ {user && waitlists.some(w => w.barberId === barberId && w.date === date && w.customerId === user.id && w.status === 'pending') ? ( +
+ Já estás na lista de espera deste dia! +
+ ) : ( + + )} +
) : ( -
-

Sem disponibilidade para este dia

+
+

Selecione primeiro o mestre e a data

)}