From 0fb8ffb12a5406021cddb6a5484c8e90c1a6da30 Mon Sep 17 00:00:00 2001 From: 230417 <230417@epvc.pt> Date: Thu, 23 Apr 2026 10:39:13 +0100 Subject: [PATCH] refactor: migrate core page layouts to use SafeAreaView and add utility notification scripts and Stepper component --- RELATORIO_PROJETO.md | 11 +- simulate_notification.mjs | 44 ++ src/components/ui/Stepper.tsx | 157 ++++++++ src/pages/AuthLogin.tsx | 105 ++--- src/pages/AuthRegister.tsx | 161 ++++---- src/pages/Booking.tsx | 736 +++++++++++++++++++++------------- src/pages/Cart.tsx | 150 ++++--- src/pages/Dashboard.tsx | 119 ++---- src/pages/Explore.tsx | 5 +- src/pages/Landing.tsx | 100 ++--- src/pages/Profile.tsx | 174 ++++---- src/pages/ShopDetails.tsx | 169 ++++---- test_notif.mjs | 36 ++ verify_notifications.mjs | 65 +++ 14 files changed, 1246 insertions(+), 786 deletions(-) create mode 100644 simulate_notification.mjs create mode 100644 src/components/ui/Stepper.tsx create mode 100644 test_notif.mjs create mode 100644 verify_notifications.mjs diff --git a/RELATORIO_PROJETO.md b/RELATORIO_PROJETO.md index 1583490..8cd42c7 100644 --- a/RELATORIO_PROJETO.md +++ b/RELATORIO_PROJETO.md @@ -277,6 +277,11 @@ Página inicial desenvolvida com: - Mudança de BRL (Reais) para EUR (Euros) - Formatação atualizada em toda a aplicação +6. **Melhoria do Fluxo de Agendamento Mobile** + - Refatoração para sistema multi-etapas (Wizard). + - Implementação de seletor de data horizontal. + - Interface premium com Stepper e feedback visual. + --- ## 📝 Arquivos de Documentação @@ -370,11 +375,7 @@ Documentadas em `web/OPCOES_CORES.md`: ## 🔍 Próximos Passos Sugeridos -1. **Melhorar Fluxo de Agendamento** - - Separar etapas (serviço → barbeiro → data/hora) - - Melhorar UX do processo - -2. **Validações** +1. **Validações** - Validação de formulários - Mensagens de erro mais claras - Validação de horários disponíveis diff --git a/simulate_notification.mjs b/simulate_notification.mjs new file mode 100644 index 0000000..9270b24 --- /dev/null +++ b/simulate_notification.mjs @@ -0,0 +1,44 @@ +import { createClient } from '@supabase/supabase-js'; + +const supabaseUrl = 'https://jqklhhpyykzrktikjnmb.supabase.co'; +const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Impxa2xoaHB5eWt6cmt0aWtqbm1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjgzODQ0MDgsImV4cCI6MjA4Mzk2MDQwOH0.QsPuBnyUtRPSavlqKj3IGR9c8juT02LY_hSi-j3c6M0'; + +const supabase = createClient(supabaseUrl, supabaseAnonKey); + +const TEST_USER_ID = '74441cc1-1d93-4ed7-9857-41499c947844'; +const DUMMY_TOKEN = 'ExponentPushToken[TEST_TOKEN_1234567890]'; + +async function simulate() { + console.log(`1. Atribuindo token dummy ao utilizador ${TEST_USER_ID}...`); + const { error: err1 } = await supabase + .from('profiles') + .update({ fcm_token: DUMMY_TOKEN }) + .eq('id', TEST_USER_ID); + + if (err1) { + console.error("Erro ao atualizar token (pode ser RLS):", err1); + // Se falhar, tentamos inserir um novo perfil (caso não exista e o anon permita) + // Mas o mais provável é que já exista se houver agendamentos. + } else { + console.log("Token atualizado com sucesso."); + } + + console.log(`2. Inserindo notificação para disparar Webhook...`); + const { data: notif, error: err2 } = await supabase + .from('notifications') + .insert([{ + user_id: TEST_USER_ID, + message: 'Teste de Notificação Automático (Antigravity)', + read: false + }]) + .select(); + + if (err2) { + console.error("Erro ao inserir notificação:", err2); + } else { + console.log("Notificação inserida com sucesso:", notif); + console.log("Se o Webhook estiver ativo, a Edge Function 'send-push-notification' foi disparada agora."); + } +} + +simulate(); diff --git a/src/components/ui/Stepper.tsx b/src/components/ui/Stepper.tsx new file mode 100644 index 0000000..580c005 --- /dev/null +++ b/src/components/ui/Stepper.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; + +type Step = { + id: number; + label: string; +}; + +type Props = { + steps: Step[]; + currentStep: number; +}; + +export const Stepper = ({ steps, currentStep }: Props) => { + return ( + + + {/* Background Line */} + + + {/* Progress Line */} + + + {steps.map((step) => { + const isActive = step.id === currentStep; + const isCompleted = step.id < currentStep; + + return ( + + + + {isCompleted ? '✓' : step.id} + + + + {step.label} + + + ); + })} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingVertical: 20, + backgroundColor: '#fff', + borderRadius: 16, + marginBottom: 16, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 2, + }, + stepsWrapper: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + paddingHorizontal: 20, + position: 'relative', + }, + stepContainer: { + alignItems: 'center', + width: 60, // Fixed width for each step container to keep spacing even + zIndex: 1, + }, + circle: { + width: 30, + height: 30, + borderRadius: 15, + backgroundColor: '#fff', + borderWidth: 2, + borderColor: '#e2e8f0', + alignItems: 'center', + justifyContent: 'center', + marginBottom: 6, + }, + circleActive: { + borderColor: '#6366f1', + backgroundColor: '#6366f1', + }, + circleCompleted: { + borderColor: '#6366f1', + backgroundColor: '#6366f1', + }, + stepNumber: { + fontSize: 12, + fontWeight: 'bold', + color: '#94a3b8', + }, + stepNumberActive: { + color: '#fff', + }, + stepNumberCompleted: { + color: '#fff', + }, + label: { + fontSize: 9, + fontWeight: 'bold', + color: '#94a3b8', + textTransform: 'uppercase', + textAlign: 'center', + }, + labelActive: { + color: '#6366f1', + }, + labelCompleted: { + color: '#6366f1', + }, + lineBackground: { + position: 'absolute', + top: 15, // Half of circle height (30/2) + left: 40, // Center of first circle (20 horizontal padding + 30 step container width / 2) -> wait + // Let's use a more robust calculation + left: 50, // approx center of first circle (20 padding + 60/2) + right: 50, // approx center of last circle + height: 2, + backgroundColor: '#e2e8f0', + zIndex: 0, + }, + lineProgress: { + position: 'absolute', + top: 15, + left: 50, + // Width is controlled by style prop + height: 2, + backgroundColor: '#6366f1', + zIndex: 0, + maxWidth: '75%', // approx distance between first and last circle centers + }, +}); diff --git a/src/pages/AuthLogin.tsx b/src/pages/AuthLogin.tsx index 5f87ec4..f64b694 100644 --- a/src/pages/AuthLogin.tsx +++ b/src/pages/AuthLogin.tsx @@ -5,6 +5,7 @@ */ import React, { useState } from 'react'; import { View, Text, StyleSheet, ScrollView, Alert } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { useApp } from '../context/AppContext'; import { Button } from '../components/ui/Button'; @@ -40,62 +41,64 @@ export default function AuthLogin() { return ( // Componente estrutural que permite "scroll" vertical do conteúdo na vista - - {/* O componente Card encapsula de forma visual os inputs de login */} - - Bem-vindo - Aceda à sua conta + + + {/* O componente Card encapsula de forma visual os inputs de login */} + + Bem-vindo + Aceda à sua conta - {/* Bloco temporário para dados demo */} - - 💡 Conta demo: - Cliente: cliente@demo.com / 123 - Barbearia: barber@demo.com / 123 - + {/* Bloco temporário para dados demo */} + + 💡 Conta demo: + Cliente: cliente@demo.com / 123 + Barbearia: barber@demo.com / 123 + - {/* Input controlado pelo estadoReact "email"; atualiza à medida que se digita */} - { - setEmail(text); - setError(''); - }} - keyboardType="email-address" - autoCapitalize="none" - placeholder="seu@email.com" - /> + {/* Input controlado pelo estadoReact "email"; atualiza à medida que se digita */} + { + setEmail(text); + setError(''); + }} + keyboardType="email-address" + autoCapitalize="none" + placeholder="seu@email.com" + /> - {/* Input controlado para a password; secureTextEntry oculta os caracteres */} - { - setPassword(text); - setError(''); - }} - secureTextEntry - placeholder="••••••••" - error={error} - /> + {/* Input controlado para a password; secureTextEntry oculta os caracteres */} + { + setPassword(text); + setError(''); + }} + secureTextEntry + placeholder="••••••••" + error={error} + /> - {/* Botão de ação que dispara a função handleSubmit para processar as credenciais */} - + {/* Botão de ação que dispara a função handleSubmit para processar as credenciais */} + - {/* Estrutura de rodapé para navegação alternativa (redirecionar para o ecrã de registo) */} - - Não tem conta? - navigation.navigate('Register' as never)} - > - Criar Conta - - - - + {/* Estrutura de rodapé para navegação alternativa (redirecionar para o ecrã de registo) */} + + Não tem conta? + navigation.navigate('Register' as never)} + > + Criar Conta + + + + + ); } diff --git a/src/pages/AuthRegister.tsx b/src/pages/AuthRegister.tsx index 721d74d..325503d 100644 --- a/src/pages/AuthRegister.tsx +++ b/src/pages/AuthRegister.tsx @@ -5,6 +5,7 @@ */ import React, { useState } from 'react'; import { View, Text, StyleSheet, ScrollView, Alert, TouchableOpacity } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { useApp } from '../context/AppContext'; import { Button } from '../components/ui/Button'; @@ -44,91 +45,93 @@ export default function AuthRegister() { return ( // Componente estrutural que assegura scroll de conteúdo para ecrãs menores - - {/* O Card agrupa a interface de registo */} - - Criar conta - Escolha o tipo de acesso + + + {/* O Card agrupa a interface de registo */} + + Criar conta + Escolha o tipo de acesso - {/* Grupo de botões de seleção de papel. O CSS ativo altera com base no estado `role`. */} - - setRole('cliente')} - > - - Cliente - - - setRole('barbearia')} - > - - Barbearia - - - + {/* Grupo de botões de seleção de papel. O CSS ativo altera com base no estado `role`. */} + + setRole('cliente')} + > + + Cliente + + + setRole('barbearia')} + > + + Barbearia + + + - {/* Input simples para o nome, que atualiza a variável React de estado "name" */} - - - {/* Input específico de email - autoCapitalize=none evita letras maiúsculas automáticas */} - { - setEmail(text); - setError(''); - }} - keyboardType="email-address" - autoCapitalize="none" - placeholder="seu@email.com" - error={error} - /> - - {/* Input para password mascarada com secureTextEntry para esconder os carateres */} - - - {/* Renderização condicional do JSX: o campo shopName só é renderizado - se o papel na base de dados (e estado) for 'barbearia' */} - {role === 'barbearia' && ( + {/* Input simples para o nome, que atualiza a variável React de estado "name" */} - )} - {/* Botão de chamada à ação principal que aciona a submissão dos dados (handleSubmit) */} - + {/* Input específico de email - autoCapitalize=none evita letras maiúsculas automáticas */} + { + setEmail(text); + setError(''); + }} + keyboardType="email-address" + autoCapitalize="none" + placeholder="seu@email.com" + error={error} + /> - {/* Seção rodapé simples com reencaminhamento de navegação para a página de Login */} - - Já tem conta? - navigation.navigate('Login' as never)} - > - Entrar - - - - + {/* Input para password mascarada com secureTextEntry para esconder os carateres */} + + + {/* Renderização condicional do JSX: o campo shopName só é renderizado + se o papel na base de dados (e estado) for 'barbearia' */} + {role === 'barbearia' && ( + + )} + + {/* Botão de chamada à ação principal que aciona a submissão dos dados (handleSubmit) */} + + + {/* Seção rodapé simples com reencaminhamento de navegação para a página de Login */} + + Já tem conta? + navigation.navigate('Login' as never)} + > + Entrar + + + + + ); } diff --git a/src/pages/Booking.tsx b/src/pages/Booking.tsx index 72f67c2..e9a3365 100644 --- a/src/pages/Booking.tsx +++ b/src/pages/Booking.tsx @@ -1,37 +1,42 @@ /** * @file Booking.tsx - * @description Página de agendamento de serviços. Permite ao utilizador (cliente) - * interagir visualmente com as disponibilidades, horários e serviços de uma barbearia - * consumindo as informações do estado/BD da aplicação. + * @description Página de agendamento reformulada para um fluxo multi-etapas (Wizard). + * Melhora a UX separando seleções e introduzindo seletor de data aprimorado. */ -import React, { useState, useMemo } from 'react'; -import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Alert } from 'react-native'; +import React, { useState, useMemo, useEffect } from 'react'; +import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Alert, FlatList } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useRoute, useNavigation } from '@react-navigation/native'; import { useApp } from '../context/AppContext'; import { Card } from '../components/ui/Card'; import { Button } from '../components/ui/Button'; -import { Input } from '../components/ui/Input'; import { Badge } from '../components/ui/Badge'; +import { Stepper } from '../components/ui/Stepper'; import { currency } from '../lib/format'; export default function Booking() { const route = useRoute(); const navigation = useNavigation(); - // Recebe o ID da barbearia a partir dos parâmetros de navegação 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, 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]); - // Gestão de estados para armazenar as seleções temporárias do cliente no formulário + // Gestão de Steps + const [step, setStep] = useState(1); + const steps = [ + { id: 1, label: 'Serviço' }, + { id: 2, label: 'Barbeiro' }, + { id: 3, label: 'Horário' }, + { id: 4, label: 'Confirmação' }, + ]; + + // Estados de Seleção const [serviceId, setService] = useState(''); const [barberId, setBarber] = useState(''); - const [date, setDate] = useState(''); + const [date, setDate] = useState(new Date().toISOString().split('T')[0]); const [slot, setSlot] = useState(''); - const [reminderMinutes, setReminderMinutes] = useState(1440); // 24h por padrão + const [reminderMinutes, setReminderMinutes] = useState(60); // 1h por padrão const reminderOptions = [ { label: '10 min', value: 10 }, @@ -40,24 +45,33 @@ export default function Booking() { { label: '24 horas', value: 1440 }, ]; - // Fallback visual caso ocorra um erro a obter o ID requisitado if (!shop) { return ( - + Barbearia não encontrada - + ); } - // Prepara as entidades interligadas baseadas na escolha para uso no interface (renderizar nome/preço) const selectedService = shop.services.find((s) => s.id === serviceId); const selectedBarber = shop.barbers.find((b) => b.id === barberId); - /** - * Função utilitária local para gerar slots de horário padrão (09:00 - 18:00). - * Auxilia no preenchimento visual caso não haja regras específicas na BD. - * @returns {string[]} Lista de horários padrão. - */ + // Geração de datas (próximos 14 dias) + const availableDates = useMemo(() => { + const dates = []; + const today = new Date(); + for (let i = 0; i < 14; i++) { + const d = new Date(); + d.setDate(today.getDate() + i); + dates.push({ + full: d.toISOString().split('T')[0], + day: d.getDate(), + weekday: d.toLocaleDateString('pt-PT', { weekday: 'short' }).replace('.', ''), + }); + } + return dates; + }, []); + const generateDefaultSlots = (): string[] => { const slots: string[] = []; for (let hour = 9; hour <= 18; hour++) { @@ -66,13 +80,6 @@ export default function Booking() { return slots; }; - /** - * Este hook processa de forma otimizada os horários disponíveis ao cruzar informações. - * Lógica do negócio e tipagem de dados: - * 1. Consulta as horas operacionais do 'barbeiro' a trabalhar nesse dia. - * 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 processedSlots = useMemo(() => { if (!selectedBarber || !date) return []; const specificSchedule = selectedBarber.schedule.find((s) => s.day === date); @@ -86,180 +93,230 @@ export default function Booking() { apt.status !== 'cancelado' && apt.date.startsWith(date) ) - .map((apt) => { - const parts = apt.date.split(' '); - return parts.length > 1 ? parts[1] : ''; - }) + .map((apt) => apt.date.split(' ')[1]) .filter(Boolean); 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 }; + const isSelected = slot === time; + return { time, isBooked, isSelected }; }); - }, [selectedBarber, date, barberId, appointments, user, waitlists]); + }, [selectedBarber, date, barberId, appointments, slot]); - // Booleano derivável auxiliar, controla o bloqueio ou liberação do botão submit - const canSubmit = serviceId && barberId && date && slot; + const canNext = () => { + if (step === 1) return !!serviceId; + if (step === 2) return !!barberId; + if (step === 3) return !!date && !!slot; + return true; + }; - /** - * Submissão do agendamento. - * Desencadeia o pedido assíncrono à API para materializar o DTO na tabela 'appointments'. - * Verifica se o token de Auth está válido (`!user`). - */ const submit = async () => { if (!user) { - // Impede requisições anónimas, delegando a sessão para o sistema de autenticação Alert.alert('Login necessário', 'Faça login para agendar'); navigation.navigate('Login' as never); return; } - if (!canSubmit) return; - // Cria o agendamento fornecendo as 'Foreign Keys' vitais (shopId, serviceId, etc...) const appt = await createAppointment({ shopId: shop.id, serviceId, barberId, - customerId: user.id, // Auth User UID + customerId: user.id, date: `${date} ${slot}`, reminderMinutes }); if (appt) { - Alert.alert('Sucesso', 'Agendamento criado com sucesso!'); + Alert.alert('Sucesso', 'O seu agendamento foi confirmado. Receberá um alerta no telemóvel conforme configurado.'); navigation.navigate('Profile' as never); } else { - Alert.alert('Erro', 'Horário indisponível'); + Alert.alert('Erro', 'Ocorreu um problema ao criar o agendamento.'); + } + }; + + const renderStepContent = () => { + switch (step) { + case 1: + return ( + + O que vamos fazer hoje? + + {shop.services.map((s) => ( + setService(s.id)} + > + + {s.name} + {s.duration} min + + {currency(s.price)} + + ))} + + + ); + case 2: + return ( + + Com quem prefere? + + {shop.barbers.map((b) => ( + setBarber(b.id)} + > + + {b.name.charAt(0).toUpperCase()} + + + {b.name} + {b.specialties.join(', ')} + + {barberId === b.id && } + + ))} + + + ); + case 3: + return ( + + Quando? + + {/* Seletor de Data Horizontal */} + Escolha o dia + + {availableDates.map((d) => ( + { setDate(d.full); setSlot(''); }} + > + {d.weekday} + {d.day} + + ))} + + + Horários Disponíveis + + {processedSlots.length > 0 ? ( + processedSlots.map((s) => ( + setSlot(s.time)} + > + + {s.time} + + + )) + ) : ( + Sem horários para este barbeiro neste dia. + )} + + + {processedSlots.length > 0 && !processedSlots.some(s => !s.isBooked) && ( + + Esgotado! Queres ser avisado se alguém cancelar? + + + )} + + ); + case 4: + return ( + + Tudo certo? + + + + Serviço + {selectedService?.name} + + + Profissional + {selectedBarber?.name} + + + Data e Hora + {date} às {slot} + + + + Total + {currency(selectedService?.price || 0)} + + + + + Configurar Alerta Push + Quando queres receber o lembrete no telemóvel? + + {reminderOptions.map(opt => ( + setReminderMinutes(opt.value)} + > + + {opt.label} + + + ))} + + + + ); + default: + return null; } }; return ( - // Área de conteúdo com scroll adaptativa - - Agendar em {shop.name} + + + + + {renderStepContent()} + - - 1. Seleção de Serviço - {/* Renderiza um botão (bloco flexível) por cada serviço (ex: Corte, Barba) vindos do mapeamento DB */} - - {shop.services.map((s) => ( - setService(s.id)} - > - {s.name} - {currency(s.price)} - - ))} - - - 2. Barbeiro - {/* Renderiza os profissionais, normalmente provindo dum JOIN na base de dados (tabela barbeiros + barbearia) */} - - {shop.barbers.map((b) => ( - setBarber(b.id)} - > - - {b.name} - - - ))} - - - 3. Data de Preferência - {/* Componente simples de input que deverá mapear para a inserção final do timestamp Postgres */} - - - 4. Horário - - {processedSlots.some(s => !s.isBooked) ? ( - processedSlots.filter(s => !s.isBooked).map((s) => ( - setSlot(s.time)} - > - {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 - )} - - - 5. Receber Lembrete - - {reminderOptions.map((opt) => ( - setReminderMinutes(opt.value)} - > - - {opt.label} antes - - - ))} - - - {/* Quadro resumo: Apenas mostrado se o estado interno conter todas as variáveis relacionais */} - {canSubmit && selectedService && ( - - Resumo do Agendamento - Serviço: {selectedService.name} - Barbeiro: {selectedBarber?.name} - Data: {date} às {slot} - Total: {currency(selectedService.price)} - + + {step > 1 && ( + )} - - {/* Botão para concretizar o INSERT na base de dados com as validações pré-acionadas */} - - - + + ); } @@ -268,163 +325,304 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#f8fafc', }, - content: { + mainScroll: { + flex: 1, + }, + scrollContent: { padding: 16, + paddingBottom: 40, }, - title: { - fontSize: 24, + stepContent: { + width: '100%', + }, + stepTitle: { + fontSize: 22, fontWeight: 'bold', color: '#0f172a', - marginBottom: 16, + marginBottom: 20, + textAlign: 'center', }, - card: { - padding: 20, - }, - sectionTitle: { - fontSize: 16, - fontWeight: 'bold', - color: '#0f172a', - marginTop: 16, + subTitle: { + fontSize: 14, + fontWeight: '700', + color: '#64748b', + textTransform: 'uppercase', + letterSpacing: 1, + marginTop: 10, marginBottom: 12, }, - grid: { + // Step 1 - Serviços + serviceGrid: { + gap: 12, + }, + serviceCard: { + backgroundColor: '#fff', + borderRadius: 16, + padding: 20, flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - marginBottom: 16, - }, - serviceButton: { - flex: 1, - minWidth: '45%', - padding: 16, - borderRadius: 8, + justifyContent: 'space-between', + alignItems: 'center', borderWidth: 2, - borderColor: '#e2e8f0', - marginBottom: 8, + borderColor: 'transparent', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.05, + shadowRadius: 4, + elevation: 2, }, - serviceButtonActive: { + serviceCardActive: { borderColor: '#6366f1', - backgroundColor: '#e0e7ff', + backgroundColor: '#f5f3ff', }, - serviceText: { - fontSize: 14, - fontWeight: '600', - color: '#0f172a', - marginBottom: 4, + serviceInfo: { + flex: 1, }, - serviceTextActive: { + serviceName: { + fontSize: 16, + fontWeight: '700', + color: '#1e293b', + }, + serviceNameActive: { color: '#6366f1', }, - servicePrice: { + serviceDuration: { fontSize: 12, - color: '#64748b', + color: '#94a3b8', + marginTop: 4, }, - barberContainer: { + servicePrice: { + fontSize: 16, + fontWeight: '800', + color: '#0f172a', + }, + servicePriceActive: { + color: '#6366f1', + }, + // Step 2 - Barbeiros + barberList: { + gap: 12, + }, + barberItem: { + backgroundColor: '#fff', + borderRadius: 16, + padding: 16, flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - marginBottom: 16, - }, - barberButton: { - paddingHorizontal: 16, - paddingVertical: 10, - borderRadius: 20, + alignItems: 'center', borderWidth: 2, - borderColor: '#e2e8f0', + borderColor: 'transparent', }, - barberButtonActive: { + barberItemActive: { + borderColor: '#6366f1', + backgroundColor: '#f5f3ff', + }, + avatarPlaceholder: { + width: 50, + height: 50, + borderRadius: 25, + backgroundColor: '#f1f5f9', + alignItems: 'center', + justifyContent: 'center', + marginRight: 16, + }, + avatarActive: { + backgroundColor: '#6366f1', + }, + avatarText: { + fontSize: 20, + fontWeight: 'bold', + color: '#94a3b8', + }, + barberDetails: { + flex: 1, + }, + barberName: { + fontSize: 16, + fontWeight: '700', + color: '#1e293b', + }, + barberNameActive: { + color: '#6366f1', + }, + barberSpecialty: { + fontSize: 12, + color: '#94a3b8', + }, + // Step 3 - Data e Hora + dateScroll: { + marginBottom: 20, + paddingBottom: 4, + }, + dateButton: { + width: 60, + height: 80, + backgroundColor: '#fff', + borderRadius: 16, + marginRight: 8, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderColor: 'transparent', + }, + dateButtonActive: { borderColor: '#6366f1', backgroundColor: '#6366f1', }, - barberText: { - fontSize: 14, - fontWeight: '600', - color: '#64748b', + dayName: { + fontSize: 10, + textTransform: 'uppercase', + color: '#94a3b8', + fontWeight: 'bold', }, - barberTextActive: { + dayNameActive: { + color: '#fff', + opacity: 0.8, + }, + dayNum: { + fontSize: 20, + fontWeight: '800', + color: '#1e293b', + marginTop: 4, + }, + dayNumActive: { color: '#fff', }, - slotsContainer: { + slotsGrid: { flexDirection: 'row', flexWrap: 'wrap', - gap: 8, - marginBottom: 16, + gap: 10, }, slotButton: { - paddingHorizontal: 16, - paddingVertical: 10, - borderRadius: 8, - borderWidth: 2, + width: '31%', + height: 50, + backgroundColor: '#fff', + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, borderColor: '#e2e8f0', }, - slotButtonActive: { - borderColor: '#6366f1', - backgroundColor: '#6366f1', + slotActive: { + backgroundColor: '#1e293b', + borderColor: '#1e293b', + }, + slotBooked: { + backgroundColor: '#f1f5f9', + borderColor: '#f1f5f9', + opacity: 0.5, }, slotText: { fontSize: 14, - fontWeight: '600', - color: '#64748b', + fontWeight: '700', + color: '#475569', }, slotTextActive: { color: '#fff', }, - noSlots: { + slotTextBooked: { + textDecorationLine: 'line-through', + color: '#94a3b8', + }, + emptyText: { fontSize: 14, color: '#94a3b8', fontStyle: 'italic', + textAlign: 'center', + width: '100%', + padding: 20, }, - reminderContainer: { + waitlistCard: { + marginTop: 30, + padding: 20, + backgroundColor: '#fff', + borderRadius: 20, + borderWidth: 1, + borderColor: '#fee2e2', + alignItems: 'center', + }, + waitlistText: { + fontSize: 14, + color: '#b91c1c', + fontWeight: '600', + textAlign: 'center', + marginBottom: 16, + }, + // Step 4 - Resumo + summaryCard: { + padding: 20, + marginTop: 0, + }, + summaryRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 12, + }, + summaryLabel: { + fontSize: 14, + color: '#64748b', + }, + summaryValue: { + fontSize: 14, + fontWeight: '700', + color: '#0f172a', + textAlign: 'right', + flex: 1, + marginLeft: 10, + }, + divider: { + height: 1, + backgroundColor: '#e2e8f0', + marginVertical: 12, + }, + summaryTotal: { + fontSize: 22, + fontWeight: '900', + color: '#6366f1', + }, + notificationSettings: { + marginTop: 24, + }, + notifHelp: { + fontSize: 13, + color: '#64748b', + marginBottom: 16, + }, + reminderOptions: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, - marginBottom: 16, }, - reminderButton: { + notifButton: { paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 8, + paddingVertical: 10, + borderRadius: 12, + backgroundColor: '#fff', borderWidth: 1, borderColor: '#e2e8f0', }, - reminderButtonActive: { + notifButtonActive: { + backgroundColor: '#6366f1', borderColor: '#6366f1', - backgroundColor: '#e0e7ff', }, - reminderText: { + notifText: { fontSize: 12, + fontWeight: 'bold', color: '#64748b', }, - reminderTextActive: { - color: '#6366f1', - fontWeight: 'bold', + notifTextActive: { + color: '#fff', }, - summary: { - backgroundColor: '#f1f5f9', + // Footer + footer: { padding: 16, - borderRadius: 8, - marginBottom: 16, + paddingBottom: 24, + backgroundColor: '#fff', + flexDirection: 'row', + gap: 12, + borderTopWidth: 1, + borderTopColor: '#e2e8f0', }, - summaryTitle: { - fontSize: 16, - fontWeight: 'bold', - color: '#0f172a', - marginBottom: 8, - }, - summaryText: { - fontSize: 14, - color: '#64748b', - marginBottom: 4, - }, - summaryTotal: { - fontSize: 18, - fontWeight: 'bold', - color: '#6366f1', - marginTop: 8, - }, - submitButton: { - width: '100%', - marginTop: 16, + footerButton: { + flex: 1, }, }); diff --git a/src/pages/Cart.tsx b/src/pages/Cart.tsx index ec18e77..5fd5b43 100644 --- a/src/pages/Cart.tsx +++ b/src/pages/Cart.tsx @@ -1,10 +1,6 @@ -/** - * @file Cart.tsx - * @description Página do carrinho de compras. Permite visualizar e finalizar pedidos - * de serviços (e possivelmente de produtos vinculados) acumulados para diferentes barbearias. - */ import React from 'react'; import { View, Text, StyleSheet, ScrollView, Alert } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { useApp } from '../context/AppContext'; import { Card } from '../components/ui/Card'; @@ -20,11 +16,11 @@ export default function Cart() { // Renderiza um estado/view vazia intercetiva, caso o array "cart" esteja vazio if (!cart.length) { return ( - + Sua Seleção está Deserta - + ); } @@ -62,80 +58,82 @@ export default function Cart() { return ( // A página permite visibilidade escalonada num conteúdo flexível (ScrollView) - - Minha Seleção + + + Minha Seleção - {/* Renderiza dinamicamente 1 Card de Checkout por Loja agrupada no objeto `grouped` */} - {Object.entries(grouped).map(([shopId, items]) => { - // Mapeia o mock do objeto de loja baseado na primmary key `shopId` - const shop = shops.find((s) => s.id === shopId); + {/* Renderiza dinamicamente 1 Card de Checkout por Loja agrupada no objeto `grouped` */} + {Object.entries(grouped).map(([shopId, items]) => { + // Mapeia o mock do objeto de loja baseado na primmary key `shopId` + const shop = shops.find((s) => s.id === shopId); - // Agregador quantitativo do array reduzindo o total financeiro calculado pelos preços da BD local - const total = items.reduce((sum, i) => { - const price = - i.type === 'service' - ? shop?.services.find((s) => s.id === i.refId)?.price ?? 0 - : shop?.products.find((p) => p.id === i.refId)?.price ?? 0; - return sum + price * i.qty; - }, 0); + // Agregador quantitativo do array reduzindo o total financeiro calculado pelos preços da BD local + const total = items.reduce((sum, i) => { + const price = + i.type === 'service' + ? shop?.services.find((s) => s.id === i.refId)?.price ?? 0 + : shop?.products.find((p) => p.id === i.refId)?.price ?? 0; + return sum + price * i.qty; + }, 0); - return ( - // Engloba os items duma só loja - - - - {/* Consome o nome e morada do registo principal (Profile > Shop) na UI */} - {shop?.name ?? 'Barbearia'} - {shop?.address} - - {/* Apresenta o custo transformado visualmente (ex: R$ / €) */} - {currency(total)} - - - {/* Listagem linha a linha dos items (relacionados por foreign key 'refId') */} - {items.map((i) => { - // JOIN via frontend para resgatar o nome natural referenciado no menu original da Lojas - const ref = - i.type === 'service' - ? shop?.services.find((s) => s.id === i.refId) - : shop?.products.find((p) => p.id === i.refId); - return ( - - - {/* Condicionamento estruturado na UI, mostra Serviço vs Produto perante a tipagem DB iterada */} - {i.type === 'service' ? 'Serviço: ' : 'Produto: '} - {ref?.name ?? 'Item'} x{i.qty} - - - {/* Elimina de forma independente o registo não guardado da persistência AppContext/State */} - + return ( + // Engloba os items duma só loja + + + + {/* Consome o nome e morada do registo principal (Profile > Shop) na UI */} + {shop?.name ?? 'Barbearia'} + {shop?.address} - ); - })} + {/* Apresenta o custo transformado visualmente (ex: R$ / €) */} + {currency(total)} + - {/* Renderização condicional no React para encaminhar fluxo para login se anónimo */} - {user ? ( - - ) : ( - - )} - - ); - })} - + {/* Listagem linha a linha dos items (relacionados por foreign key 'refId') */} + {items.map((i) => { + // JOIN via frontend para resgatar o nome natural referenciado no menu original da Lojas + const ref = + i.type === 'service' + ? shop?.services.find((s) => s.id === i.refId) + : shop?.products.find((p) => p.id === i.refId); + return ( + + + {/* Condicionamento estruturado na UI, mostra Serviço vs Produto perante a tipagem DB iterada */} + {i.type === 'service' ? 'Serviço: ' : 'Produto: '} + {ref?.name ?? 'Item'} x{i.qty} + + + {/* Elimina de forma independente o registo não guardado da persistência AppContext/State */} + + + ); + })} + + {/* Renderização condicional no React para encaminhar fluxo para login se anónimo */} + {user ? ( + + ) : ( + + )} + + ); + })} + + ); } diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index beed00e..1f7baa8 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -6,6 +6,7 @@ */ import React, { useState } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Alert } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { useApp } from '../context/AppContext'; import { Card } from '../components/ui/Card'; @@ -52,18 +53,9 @@ export default function Dashboard() { // Segurança de Bloqueio - Validação estrita do role do utilziador no componente if (!user || user.role !== 'barbearia' || !shop) { return ( - - A carregar dados da barbearia... - - ); - } - - // Fallback no caso da loja ainda não estar populada no join do utilizador - if (!shop) { - return ( - - Barbearia não encontrada - + + A carregar dados da barbearia... + ); } @@ -78,25 +70,15 @@ export default function Dashboard() { const totalRevenue = shopOrders.reduce((s, o) => s + o.total, 0); const lowStock = shop.products.filter((p) => p.stock <= 3); - /** - * Adiciona um novo serviço disponível na barbearia. - * Invoca "addService", efetuando em backend uma lógica de `supabase.from('services').insert(...)` - * relacionando com o `shop.id`. - */ const addNewService = () => { if (!svcName.trim()) return; addService(shop.id, { name: svcName, price: Number(svcPrice) || 0, duration: Number(svcDuration) || 30, barberIds: [] }); - // Limpeza formulário setSvcName(''); setSvcPrice('50'); setSvcDuration('30'); Alert.alert('Sucesso', 'Serviço adicionado'); }; - /** - * Adiciona um novo produto de venda. - * Interliga e armazena informando a Foreign Key da loja `shop.id`. - */ const addNewProduct = () => { if (!prodName.trim()) return; addProduct(shop.id, { name: prodName, price: Number(prodPrice) || 0, stock: Number(prodStock) || 0 }); @@ -106,11 +88,6 @@ export default function Dashboard() { Alert.alert('Sucesso', 'Produto adicionado'); }; - /** - * Adiciona um membro (barbeiro) à gestão da equipa. - * Modifica a base de dados (ex: `supabase.from('barbers').insert({...})`) - * inicializando com estrutura base de interface. - */ const addNewBarber = () => { if (!barberName.trim()) return; addBarber(shop.id, { @@ -122,12 +99,6 @@ export default function Dashboard() { Alert.alert('Sucesso', 'Profissional adicionado'); }; - /** - * Atualiza a quantidade de inventário de um produto iterando a variação (+1/-1). - * Desencadeia um update para a tabela products (ex: `supabase.from('products').update(...)`) evitando stocks negativos. - * @param {string} productId - O identificador único do produto afetado. - * @param {number} delta - A quantidade exata matemática a variar do inventário. - */ const updateProductStock = (productId: string, delta: number) => { const product = shop.products.find((p) => p.id === productId); if (!product) return; @@ -138,14 +109,14 @@ export default function Dashboard() { const tabs = [ { id: 'overview', label: 'Estatísticas' }, { id: 'appointments', label: 'Reservas' }, - { id: 'orders', label: 'Pedidos Boutique' }, + { id: 'orders', label: 'Pedidos' }, { id: 'services', label: 'Serviços' }, { id: 'products', label: 'Produtos' }, - { id: 'barbers', label: 'Profissionais' }, + { id: 'barbers', label: 'Equipa' }, ]; return ( - + {shop.name} )} - + ); } @@ -475,6 +443,7 @@ const styles = StyleSheet.create({ }, contentInner: { padding: 16, + paddingBottom: 32, }, statsGrid: { flexDirection: 'row', @@ -596,13 +565,14 @@ const styles = StyleSheet.create({ marginBottom: 8, }, searchInput: { - backgroundColor: '#0d0d0d', - borderColor: 'rgba(255,255,255,0.05)', - height: 56, - borderRadius: 16, - paddingHorizontal: 20, - color: '#fff', - fontSize: 16, + backgroundColor: '#fff', + borderColor: '#e2e8f0', + height: 50, + borderRadius: 12, + paddingHorizontal: 16, + color: '#0f172a', + fontSize: 14, + borderWidth: 1, }, barberList: { gap: 12, @@ -610,50 +580,37 @@ const styles = StyleSheet.create({ barberCard: { flexDirection: 'row', alignItems: 'center', - padding: 16, - backgroundColor: '#0d0d0d', - borderRadius: 24, - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.05)', - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.2, - shadowRadius: 8, - elevation: 4, + padding: 12, }, barberAvatar: { - width: 60, - height: 60, - borderRadius: 18, - backgroundColor: '#1e293b', + width: 50, + height: 50, + borderRadius: 25, + backgroundColor: '#f1f5f9', alignItems: 'center', justifyContent: 'center', - marginRight: 16, + marginRight: 12, borderWidth: 1, - borderColor: 'rgba(255,255,255,0.1)', + borderColor: '#e2e8f0', }, barberInfo: { flex: 1, }, barberNameText: { - fontSize: 18, + fontSize: 16, fontWeight: 'bold', - color: '#fff', - letterSpacing: -0.5, + color: '#0f172a', + }, + barberSpecialtyText: { + fontSize: 12, + color: '#64748b', }, deleteBarberBtn: { padding: 8, - backgroundColor: 'rgba(239, 68, 68, 0.1)', - borderRadius: 12, }, emptyResults: { - padding: 40, + padding: 24, alignItems: 'center', - backgroundColor: '#0d0d0d', - borderRadius: 24, - borderStyle: 'dashed', - borderWidth: 1, - borderColor: 'rgba(255,255,255,0.1)', }, }); diff --git a/src/pages/Explore.tsx b/src/pages/Explore.tsx index 665d046..e815e73 100644 --- a/src/pages/Explore.tsx +++ b/src/pages/Explore.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; import { View, Text, StyleSheet, ScrollView, FlatList } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useApp } from '../context/AppContext'; @@ -22,7 +23,7 @@ export default function Explore() { return ( // Componente raiz do ecã de exploração - + Barbearias {/* FlatList é o componente nativo otimizado para renderizar grandes arrays de dados provenientes da BD */} @@ -68,7 +69,7 @@ export default function Explore() { )} /> - + ); } diff --git a/src/pages/Landing.tsx b/src/pages/Landing.tsx index 416206f..3f53ed5 100644 --- a/src/pages/Landing.tsx +++ b/src/pages/Landing.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { Button } from '../components/ui/Button'; import { Card } from '../components/ui/Card'; @@ -13,58 +14,59 @@ export default function Landing() { const navigation = useNavigation(); return ( - // Interface principal agrupando o "hero" e "features" - - {/* Hero section: elemento apelativo para captar valor (exibição de marca) */} - - Smart Agenda - - Agendamento e Gestão de Barbearias. - - - A sua solução completa para o dia-a-dia da barbearia. - - - + + + {/* Hero section: elemento apelativo para captar valor (exibição de marca) */} + + Smart Agenda + + Agendamento e Gestão de Barbearias. + + + A sua solução completa para o dia-a-dia da barbearia. + + + - + + - - - - Reservas Rápidas - - Selecione o seu barbeiro e o horário ideal em poucos segundos. - - - - Produtos - - Produtos de cuidado masculino selecionados para si. - - - - Gestão de Barbearia - - Controlo total sobre o faturamento e operação da sua barbearia. - - - - + + + Reservas Rápidas + + Selecione o seu barbeiro e o horário ideal em poucos segundos. + + + + Produtos + + Produtos de cuidado masculino selecionados para si. + + + + Gestão de Barbearia + + Controlo total sobre o faturamento e operação da sua barbearia. + + + + + ); } diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 6b6d235..ed1e7bb 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -1,10 +1,6 @@ -/** - * @file Profile.tsx - * @description Página do perfil de utilizador logado. Exibe os dados do cliente - * ou barbearia, bem como o histórico de agendamentos e pedidos vinculados à conta. - */ import React from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; import { useApp } from '../context/AppContext'; import { Card } from '../components/ui/Card'; @@ -28,9 +24,9 @@ export default function Profile() { // Guarda/Bloqueio protetor para forçar navegação ou alertar utilizadores anónimos if (!user) { return ( - + Faça login para ver o perfil - + ); } @@ -43,91 +39,93 @@ export default function Profile() { return ( // Contentor com ScrollView adaptável (evita cortes em ecrãs pequenos) - - {/* Bloco de identificação do Utilizador validado pela API (Supabase Auth) */} - - Olá, {user.name} - {user.email} + + + {/* Bloco de identificação do Utilizador validado pela API (Supabase Auth) */} + + Olá, {user.name} + {user.email} - {/* Distanciamento visual e lógica dos tipos de perfil 'role' presentes na BD */} - - {user.role === 'cliente' ? 'Cliente' : 'Barbearia'} - + {/* Distanciamento visual e lógica dos tipos de perfil 'role' presentes na BD */} + + {user.role === 'cliente' ? 'Cliente' : 'Barbearia'} + - {/* Limpa a sessão ativa e tokens memorizados de Login */} - - - - {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 ? ( - myAppointments.map((a) => { - // Resolve a associação relacional (a.shopId) obtendo os detalhes da barbearia - const shop = shops.find((s) => s.id === a.shopId); - return ( - - - {/* Nome exibido pós JOIN de array em memória */} - {shop?.name} - - {/* O status (persistido na BD) influencia a cor devolvida ao Badge */} - {a.status} - - {a.date} - {currency(a.total)} - - ); - }) - ) : ( - - Nenhum agendamento ainda + {/* Limpa a sessão ativa e tokens memorizados de Login */} + - )} - As Minhas Compras - {/* Renderiza o histórico de compras de retalho/produtos usando idêntica lógica */} - {myOrders.length > 0 ? ( - myOrders.map((o) => { - const shop = shops.find((s) => s.id === o.shopId); - return ( - - - {shop?.name} - {o.status} - - - {/* Formatação Timestamp temporal da BD (createdAt) para modo visual PT */} - {new Date(o.createdAt).toLocaleString('pt-BR')} - - {currency(o.total)} - - ); - }) - ) : ( - - Nenhum pedido ainda - - )} - + {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 ? ( + myAppointments.map((a) => { + // Resolve a associação relacional (a.shopId) obtendo os detalhes da barbearia + const shop = shops.find((s) => s.id === a.shopId); + return ( + + + {/* Nome exibido pós JOIN de array em memória */} + {shop?.name} + + {/* O status (persistido na BD) influencia a cor devolvida ao Badge */} + {a.status} + + {a.date} + {currency(a.total)} + + ); + }) + ) : ( + + Nenhum agendamento ainda + + )} + + As Minhas Compras + {/* Renderiza o histórico de compras de retalho/produtos usando idêntica lógica */} + {myOrders.length > 0 ? ( + myOrders.map((o) => { + const shop = shops.find((s) => s.id === o.shopId); + return ( + + + {shop?.name} + {o.status} + + + {/* Formatação Timestamp temporal da BD (createdAt) para modo visual PT */} + {new Date(o.createdAt).toLocaleString('pt-BR')} + + {currency(o.total)} + + ); + }) + ) : ( + + Nenhum pedido ainda + + )} + + ); } diff --git a/src/pages/ShopDetails.tsx b/src/pages/ShopDetails.tsx index b5b0a50..ec6741b 100644 --- a/src/pages/ShopDetails.tsx +++ b/src/pages/ShopDetails.tsx @@ -1,11 +1,6 @@ -/** - * @file ShopDetails.tsx - * @description Página de detalhes de uma barbearia específica. - * Mostra a listagem de catálogos dividida por abas (Serviços / Produtos) e - * permite adicionar itens ao carrinho de compras ou transitar para Agendamento. - */ import React, { useState, useMemo } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useRoute, useNavigation, RouteProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useApp } from '../context/AppContext'; @@ -33,95 +28,97 @@ export default function ShopDetails() { // Fallback visual de navegação inválida para o caso da barbearia não constar if (!shop) { return ( - + Barbearia não encontrada - + ); } return ( // Contentor em Scroll adaptável horizontal/vertical em smartphones reduzidos - - {/* Cabeçalho superior: Informações imutáveis populadas pelos profiles preenchidos */} - - {shop.name} - {shop.address} - {/* Call to action de elevado destaque que incia form Booking */} - - - - {/* Controladores de abas visuais iterando o estado (setTab) */} - - setTab('servicos')} - > - Menu de Serviços - - setTab('produtos')} - > - Boutique - - - - {/* Renderização Condicionada pela Tab ativa */} - {tab === 'servicos' ? ( - // Apresenta o catálogo relacionado com 'serviços' da tabela/coleção na BD da referida loja - - {shop.services.map((service) => ( - - - {service.name} - {currency(service.price)} - - Duração: {service.duration} min - - {/* Função addToCart despacha dados para Context agregador permitindo checkout posterior */} - - - ))} + + + {/* Cabeçalho superior: Informações imutáveis populadas pelos profiles preenchidos */} + + {shop.name} + {shop.address} + {/* Call to action de elevado destaque que incia form Booking */} + - ) : ( - // Retorno divergente: inventário global visivelmente iterado em componentes - - {shop.products.map((product) => ( - - - {product.name} - {currency(product.price)} - - Stock: {product.stock} unidades - {/* Alerta de urgência de reposição assente numa regra simples de negócios matemática */} - {product.stock <= 3 && Últimas unidades} - - {/* Botão em React é afetado logicamente face à impossibilidade material de encomenda */} - - - ))} + {/* Controladores de abas visuais iterando o estado (setTab) */} + + setTab('servicos')} + > + Menu de Serviços + + setTab('produtos')} + > + Boutique + - )} - + + {/* Renderização Condicionada pela Tab ativa */} + {tab === 'servicos' ? ( + // Apresenta o catálogo relacionado com 'serviços' da tabela/coleção na BD da referida loja + + {shop.services.map((service) => ( + + + {service.name} + {currency(service.price)} + + Duração: {service.duration} min + + {/* Função addToCart despacha dados para Context agregador permitindo checkout posterior */} + + + ))} + + ) : ( + // Retorno divergente: inventário global visivelmente iterado em componentes + + {shop.products.map((product) => ( + + + {product.name} + {currency(product.price)} + + Stock: {product.stock} unidades + + {/* Alerta de urgência de reposição assente numa regra simples de negócios matemática */} + {product.stock <= 3 && Últimas unidades} + + {/* Botão em React é afetado logicamente face à impossibilidade material de encomenda */} + + + ))} + + )} + + ); } diff --git a/test_notif.mjs b/test_notif.mjs new file mode 100644 index 0000000..2c5d6c6 --- /dev/null +++ b/test_notif.mjs @@ -0,0 +1,36 @@ +import { createClient } from '@supabase/supabase-js'; + +const supabaseUrl = 'https://jqklhhpyykzrktikjnmb.supabase.co'; +const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Impxa2xoaHB5eWt6cmt0aWtqbm1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjgzODQ0MDgsImV4cCI6MjA4Mzk2MDQwOH0.QsPuBnyUtRPSavlqKj3IGR9c8juT02LY_hSi-j3c6M0'; + +const supabase = createClient(supabaseUrl, supabaseAnonKey); + +async function testNotif() { + console.log("A obter um utilizador da base de dados..."); + const { data: users, error: err1 } = await supabase.from('profiles').select('id, fcm_token').limit(1); + + if (err1 || !users || users.length === 0) { + console.error("Erro ao obter utilizador:", err1); + return; + } + + const userId = users[0].id; + console.log(`Utilizador selecionado: ${userId}`); + console.log(`Token FCM atual: ${users[0].fcm_token || "Nenhum"}`); + + console.log("A inserir notificação de teste para despontar o Webhook..."); + const { data, error } = await supabase.from('notifications').insert([{ + user_id: userId, + message: 'Teste automático de Push Notification - Smart Agenda!', + read: false + }]).select(); + + if (error) { + console.error("Falha ao inserir notificação:", error.message); + } else { + console.log("Notificação inserida com sucesso no Supabase! O webhook deve encadear a invocação da Edge Function."); + console.log(data); + } +} + +testNotif(); diff --git a/verify_notifications.mjs b/verify_notifications.mjs new file mode 100644 index 0000000..e8d9138 --- /dev/null +++ b/verify_notifications.mjs @@ -0,0 +1,65 @@ +import { createClient } from '@supabase/supabase-js'; + +const supabaseUrl = 'https://jqklhhpyykzrktikjnmb.supabase.co'; +const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Impxa2xoaHB5eWt6cmt0aWtqbm1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjgzODQ0MDgsImV4cCI6MjA4Mzk2MDQwOH0.QsPuBnyUtRPSavlqKj3IGR9c8juT02LY_hSi-j3c6M0'; + +const supabase = createClient(supabaseUrl, supabaseAnonKey); + +async function verifyNotifications() { + console.log("--- INICIANDO VERIFICAÇÃO DE SISTEMA DE NOTIFICAÇÕES ---"); + + // 1. Verificar se existem tokens FCM registados + const { data: usersWithTokens, error: err1 } = await supabase + .from('profiles') + .select('id, name, fcm_token') + .not('fcm_token', 'is', null); + + if (err1) { + console.error("Erro ao buscar perfis:", err1); + } else { + console.log(`Tokens encontrados: ${usersWithTokens.length}`); + usersWithTokens.forEach(u => { + console.log(` - Utilizador: ${u.name} | Token: ${u.fcm_token ? 'PRESENTE (' + u.fcm_token.substring(0, 20) + '...)' : 'AUSENTE'}`); + }); + } + + // 2. Verificar agendamentos pendentes com lembrete ativado + const { data: appts, error: err2 } = await supabase + .from('appointments') + .select('id, date, status, reminder_minutes, reminder_sent') + .eq('status', 'pendente') + .eq('reminder_sent', false); + + if (err2) { + console.error("Erro ao buscar agendamentos:", err2); + } else { + console.log(`Agendamentos pendentes para lembrete: ${appts.length}`); + const now = new Date(); + appts.forEach(a => { + const apptDate = new Date(a.date.replace(' ', 'T')); + const diffMin = (apptDate.getTime() - now.getTime()) / (1000 * 60); + const status = diffMin <= (a.reminder_minutes || 1440) ? "PROXIMO (Deve disparar)" : "FUTURO"; + console.log(` - ID: ${a.id} | Data: ${a.date} | Faltam: ${Math.round(diffMin)}min | Config: ${a.reminder_minutes}min | Estado: ${status}`); + }); + } + + // 3. Verificar se a tabela de notificações está a receber dados + const { data: recentNotifs, error: err3 } = await supabase + .from('notifications') + .select('*') + .order('created_at', { ascending: false }) + .limit(5); + + if (err3) { + console.error("Erro ao buscar log de notificações:", err3); + } else { + console.log(`Últimas 5 notificações registadas na BD:`); + recentNotifs.forEach(n => { + console.log(` - [${n.created_at}] Para User: ${n.user_id} | MSG: ${n.message} | Lida: ${n.read}`); + }); + } + + console.log("--- FIM DA VERIFICAÇÃO ---"); +} + +verifyNotifications();