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 */}
- removeFromCart(i.refId)}
- variant="ghost"
- size="sm"
- >
- Remover
-
+ 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 ? (
- handleCheckout(shopId)} style={styles.checkoutButton}>
- Finalizar Aquisição
-
- ) : (
- navigation.navigate('Login' as never)}
- style={styles.checkoutButton}
- >
- Entrar para Adquirir
-
- )}
-
- );
- })}
-
+ {/* 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 */}
+ removeFromCart(i.refId)}
+ variant="ghost"
+ size="sm"
+ >
+ Remover
+
+
+ );
+ })}
+
+ {/* Renderização condicional no React para encaminhar fluxo para login se anónimo */}
+ {user ? (
+ handleCheckout(shopId)} style={styles.checkoutButton}>
+ Finalizar Aquisição
+
+ ) : (
+ navigation.navigate('Login' as never)}
+ style={styles.checkoutButton}
+ >
+ Entrar para Adquirir
+
+ )}
+
+ );
+ })}
+
+
);
}
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}
@@ -369,29 +340,27 @@ export default function Dashboard() {
Profissionais
- {/* Barra de Pesquisa Estilo Screenshot */}
- {/* Lista de Profissionais */}
{shop.barbers
.filter(b => b.name.toLowerCase().includes(barberSearchQuery.toLowerCase()))
.map((b) => (
-
+
- {/* Placeholder ou imagem real se disponível futuramente */}
- {b.name.charAt(0)}
+ {b.name.charAt(0)}
{b.name}
+ {b.specialties.join(', ') || 'Barbeiro'}
{
@@ -402,9 +371,9 @@ export default function Dashboard() {
}}
style={styles.deleteBarberBtn}
>
- Sair
+ Remover
-
+
))}
{shop.barbers.filter(b => b.name.toLowerCase().includes(barberSearchQuery.toLowerCase())).length === 0 && (
@@ -414,18 +383,17 @@ export default function Dashboard() {
)}
- {/* Formulário de Adição */}
Adicionar profissional
- Adicionar Coração do Negócio
+ Adicionar Profissional
)}
-
+
);
}
@@ -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.
-
-
- navigation.navigate('Explore' as never)}
- style={styles.button}
- size="md"
- >
- Ver Barbearias
-
+
+
+ {/* 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.
+
+
+ navigation.navigate('Explore' as never)}
+ style={styles.button}
+ size="md"
+ >
+ Ver Barbearias
+
- navigation.navigate('Register' as never)}
- variant="outline"
- style={styles.button}
- size="md"
- >
- Criar Conta
-
+ navigation.navigate('Register' as never)}
+ variant="outline"
+ style={styles.button}
+ size="md"
+ >
+ Criar Conta
+
+
-
-
-
- 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 */}
-
- Sair
-
-
-
- {myNotifications.length > 0 && (
- <>
- Notificações
- {myNotifications.map((n) => (
-
-
- 🔔 Nova Vaga!
-
- {n.message}
- markNotificationRead(n.id)} variant="outline" style={{ backgroundColor: '#f1f5f9' }}>
- Marcar Lida
-
-
- ))}
- >
- )}
-
- 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 */}
+
+ Sair
+
- )}
- 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}
+ markNotificationRead(n.id)} variant="outline" style={{ backgroundColor: '#f1f5f9' }}>
+ Marcar Lida
+
+
+ ))}
+ >
+ )}
+
+ 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 */}
- navigation.navigate('Booking', { shopId: shop.id })}
- style={styles.bookButton}
- >
- Reservar Experiência
-
-
-
- {/* 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 */}
- addToCart({ shopId: shop.id, type: 'service', refId: service.id, qty: 1 })}
- size="sm"
- style={styles.addButton}
- >
- Adicionar à Seleção
-
-
- ))}
+
+
+ {/* 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 */}
+ navigation.navigate('Booking', { shopId: shop.id })}
+ style={styles.bookButton}
+ >
+ Reservar Experiência
+
- ) : (
- // 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 */}
- addToCart({ shopId: shop.id, type: 'product', refId: product.id, qty: 1 })}
- size="sm"
- style={styles.addButton}
- disabled={product.stock <= 0}
- >
- {product.stock > 0 ? 'Adicionar à Seleção' : 'Indisponível'}
-
-
- ))}
+ {/* 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 */}
+ addToCart({ shopId: shop.id, type: 'service', refId: service.id, qty: 1 })}
+ size="sm"
+ style={styles.addButton}
+ >
+ Adicionar à Seleção
+
+
+ ))}
+
+ ) : (
+ // 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 */}
+ addToCart({ shopId: shop.id, type: 'product', refId: product.id, qty: 1 })}
+ size="sm"
+ style={styles.addButton}
+ disabled={product.stock <= 0}
+ >
+ {product.stock > 0 ? 'Adicionar à Seleção' : 'Indisponível'}
+
+
+ ))}
+
+ )}
+
+
);
}
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();