refactor: migrate core page layouts to use SafeAreaView and add utility notification scripts and Stepper component

This commit is contained in:
2026-04-23 10:39:13 +01:00
parent 5d16602ceb
commit 0fb8ffb12a
14 changed files with 1246 additions and 786 deletions

View File

@@ -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 (
<View style={styles.container}>
<View style={styles.stepsWrapper}>
{/* Background Line */}
<View style={styles.lineBackground} />
{/* Progress Line */}
<View
style={[
styles.lineProgress,
{ width: `${((currentStep - 1) / (steps.length - 1)) * 100}%` }
]}
/>
{steps.map((step) => {
const isActive = step.id === currentStep;
const isCompleted = step.id < currentStep;
return (
<View key={step.id} style={styles.stepContainer}>
<View
style={[
styles.circle,
isActive && styles.circleActive,
isCompleted && styles.circleCompleted,
]}
>
<Text
style={[
styles.stepNumber,
isActive && styles.stepNumberActive,
isCompleted && styles.stepNumberCompleted,
]}
>
{isCompleted ? '✓' : step.id}
</Text>
</View>
<Text
style={[
styles.label,
isActive && styles.labelActive,
isCompleted && styles.labelCompleted,
]}
>
{step.label}
</Text>
</View>
);
})}
</View>
</View>
);
};
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
},
});

View File

@@ -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
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* O componente Card encapsula de forma visual os inputs de login */}
<Card style={styles.card}>
<Text style={styles.title}>Bem-vindo</Text>
<Text style={styles.subtitle}>Aceda à sua conta</Text>
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
{/* O componente Card encapsula de forma visual os inputs de login */}
<Card style={styles.card}>
<Text style={styles.title}>Bem-vindo</Text>
<Text style={styles.subtitle}>Aceda à sua conta</Text>
{/* Bloco temporário para dados demo */}
<View style={styles.demoBox}>
<Text style={styles.demoTitle}>💡 Conta demo:</Text>
<Text style={styles.demoText}>Cliente: cliente@demo.com / 123</Text>
<Text style={styles.demoText}>Barbearia: barber@demo.com / 123</Text>
</View>
{/* Bloco temporário para dados demo */}
<View style={styles.demoBox}>
<Text style={styles.demoTitle}>💡 Conta demo:</Text>
<Text style={styles.demoText}>Cliente: cliente@demo.com / 123</Text>
<Text style={styles.demoText}>Barbearia: barber@demo.com / 123</Text>
</View>
{/* Input controlado pelo estadoReact "email"; atualiza à medida que se digita */}
<Input
label="Email"
value={email}
onChangeText={(text) => {
setEmail(text);
setError('');
}}
keyboardType="email-address"
autoCapitalize="none"
placeholder="seu@email.com"
/>
{/* Input controlado pelo estadoReact "email"; atualiza à medida que se digita */}
<Input
label="Email"
value={email}
onChangeText={(text) => {
setEmail(text);
setError('');
}}
keyboardType="email-address"
autoCapitalize="none"
placeholder="seu@email.com"
/>
{/* Input controlado para a password; secureTextEntry oculta os caracteres */}
<Input
label="Senha"
value={password}
onChangeText={(text) => {
setPassword(text);
setError('');
}}
secureTextEntry
placeholder="••••••••"
error={error}
/>
{/* Input controlado para a password; secureTextEntry oculta os caracteres */}
<Input
label="Senha"
value={password}
onChangeText={(text) => {
setPassword(text);
setError('');
}}
secureTextEntry
placeholder="••••••••"
error={error}
/>
{/* Botão de ação que dispara a função handleSubmit para processar as credenciais */}
<Button onPress={handleSubmit} style={styles.submitButton} size="md">
Entrar
</Button>
{/* Botão de ação que dispara a função handleSubmit para processar as credenciais */}
<Button onPress={handleSubmit} style={styles.submitButton} size="md">
Entrar
</Button>
{/* Estrutura de rodapé para navegação alternativa (redirecionar para o ecrã de registo) */}
<View style={styles.footer}>
<Text style={styles.footerText}>Não tem conta? </Text>
<Text
style={styles.footerLink}
onPress={() => navigation.navigate('Register' as never)}
>
Criar Conta
</Text>
</View>
</Card>
</ScrollView>
{/* Estrutura de rodapé para navegação alternativa (redirecionar para o ecrã de registo) */}
<View style={styles.footer}>
<Text style={styles.footerText}>Não tem conta? </Text>
<Text
style={styles.footerLink}
onPress={() => navigation.navigate('Register' as never)}
>
Criar Conta
</Text>
</View>
</Card>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -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
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* O Card agrupa a interface de registo */}
<Card style={styles.card}>
<Text style={styles.title}>Criar conta</Text>
<Text style={styles.subtitle}>Escolha o tipo de acesso</Text>
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
{/* O Card agrupa a interface de registo */}
<Card style={styles.card}>
<Text style={styles.title}>Criar conta</Text>
<Text style={styles.subtitle}>Escolha o tipo de acesso</Text>
{/* Grupo de botões de seleção de papel. O CSS ativo altera com base no estado `role`. */}
<View style={styles.roleContainer}>
<TouchableOpacity
style={[styles.roleButton, role === 'cliente' && styles.roleButtonActive]}
onPress={() => setRole('cliente')}
>
<Text style={[styles.roleText, role === 'cliente' && styles.roleTextActive]}>
Cliente
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.roleButton, role === 'barbearia' && styles.roleButtonActive]}
onPress={() => setRole('barbearia')}
>
<Text style={[styles.roleText, role === 'barbearia' && styles.roleTextActive]}>
Barbearia
</Text>
</TouchableOpacity>
</View>
{/* Grupo de botões de seleção de papel. O CSS ativo altera com base no estado `role`. */}
<View style={styles.roleContainer}>
<TouchableOpacity
style={[styles.roleButton, role === 'cliente' && styles.roleButtonActive]}
onPress={() => setRole('cliente')}
>
<Text style={[styles.roleText, role === 'cliente' && styles.roleTextActive]}>
Cliente
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.roleButton, role === 'barbearia' && styles.roleButtonActive]}
onPress={() => setRole('barbearia')}
>
<Text style={[styles.roleText, role === 'barbearia' && styles.roleTextActive]}>
Barbearia
</Text>
</TouchableOpacity>
</View>
{/* Input simples para o nome, que atualiza a variável React de estado "name" */}
<Input
label="Nome completo"
value={name}
onChangeText={setName}
placeholder="João Silva"
/>
{/* Input específico de email - autoCapitalize=none evita letras maiúsculas automáticas */}
<Input
label="Email"
value={email}
onChangeText={(text) => {
setEmail(text);
setError('');
}}
keyboardType="email-address"
autoCapitalize="none"
placeholder="seu@email.com"
error={error}
/>
{/* Input para password mascarada com secureTextEntry para esconder os carateres */}
<Input
label="Senha"
value={password}
onChangeText={setPassword}
secureTextEntry
placeholder="••••••••"
/>
{/* 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" */}
<Input
label="Nome da barbearia"
value={shopName}
onChangeText={setShopName}
placeholder="Ex: Minha Barbearia"
label="Nome completo"
value={name}
onChangeText={setName}
placeholder="João Silva"
/>
)}
{/* Botão de chamada à ação principal que aciona a submissão dos dados (handleSubmit) */}
<Button onPress={handleSubmit} style={styles.submitButton} size="md">
Criar conta
</Button>
{/* Input específico de email - autoCapitalize=none evita letras maiúsculas automáticas */}
<Input
label="Email"
value={email}
onChangeText={(text) => {
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 */}
<View style={styles.footer}>
<Text style={styles.footerText}> tem conta? </Text>
<Text
style={styles.footerLink}
onPress={() => navigation.navigate('Login' as never)}
>
Entrar
</Text>
</View>
</Card>
</ScrollView>
{/* Input para password mascarada com secureTextEntry para esconder os carateres */}
<Input
label="Senha"
value={password}
onChangeText={setPassword}
secureTextEntry
placeholder="••••••••"
/>
{/* Renderização condicional do JSX: o campo shopName só é renderizado
se o papel na base de dados (e estado) for 'barbearia' */}
{role === 'barbearia' && (
<Input
label="Nome da barbearia"
value={shopName}
onChangeText={setShopName}
placeholder="Ex: Minha Barbearia"
/>
)}
{/* Botão de chamada à ação principal que aciona a submissão dos dados (handleSubmit) */}
<Button onPress={handleSubmit} style={styles.submitButton} size="md">
Criar conta
</Button>
{/* Seção rodapé simples com reencaminhamento de navegação para a página de Login */}
<View style={styles.footer}>
<Text style={styles.footerText}> tem conta? </Text>
<Text
style={styles.footerLink}
onPress={() => navigation.navigate('Login' as never)}
>
Entrar
</Text>
</View>
</Card>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -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 (
<View style={styles.container}>
<SafeAreaView style={styles.container}>
<Text>Barbearia não encontrada</Text>
</View>
</SafeAreaView>
);
}
// 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 (
<View style={styles.stepContent}>
<Text style={styles.stepTitle}>O que vamos fazer hoje?</Text>
<View style={styles.serviceGrid}>
{shop.services.map((s) => (
<TouchableOpacity
key={s.id}
style={[styles.serviceCard, serviceId === s.id && styles.serviceCardActive]}
onPress={() => setService(s.id)}
>
<View style={styles.serviceInfo}>
<Text style={[styles.serviceName, serviceId === s.id && styles.serviceNameActive]}>{s.name}</Text>
<Text style={styles.serviceDuration}>{s.duration} min</Text>
</View>
<Text style={[styles.servicePrice, serviceId === s.id && styles.servicePriceActive]}>{currency(s.price)}</Text>
</TouchableOpacity>
))}
</View>
</View>
);
case 2:
return (
<View style={styles.stepContent}>
<Text style={styles.stepTitle}>Com quem prefere?</Text>
<View style={styles.barberList}>
{shop.barbers.map((b) => (
<TouchableOpacity
key={b.id}
style={[styles.barberItem, barberId === b.id && styles.barberItemActive]}
onPress={() => setBarber(b.id)}
>
<View style={[styles.avatarPlaceholder, barberId === b.id && styles.avatarActive]}>
<Text style={styles.avatarText}>{b.name.charAt(0).toUpperCase()}</Text>
</View>
<View style={styles.barberDetails}>
<Text style={[styles.barberName, barberId === b.id && styles.barberNameActive]}>{b.name}</Text>
<Text style={styles.barberSpecialty}>{b.specialties.join(', ')}</Text>
</View>
{barberId === b.id && <Badge color="indigo"></Badge>}
</TouchableOpacity>
))}
</View>
</View>
);
case 3:
return (
<View style={styles.stepContent}>
<Text style={styles.stepTitle}>Quando?</Text>
{/* Seletor de Data Horizontal */}
<Text style={styles.subTitle}>Escolha o dia</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.dateScroll}>
{availableDates.map((d) => (
<TouchableOpacity
key={d.full}
style={[styles.dateButton, date === d.full && styles.dateButtonActive]}
onPress={() => { setDate(d.full); setSlot(''); }}
>
<Text style={[styles.dayName, date === d.full && styles.dayNameActive]}>{d.weekday}</Text>
<Text style={[styles.dayNum, date === d.full && styles.dayNumActive]}>{d.day}</Text>
</TouchableOpacity>
))}
</ScrollView>
<Text style={styles.subTitle}>Horários Disponíveis</Text>
<View style={styles.slotsGrid}>
{processedSlots.length > 0 ? (
processedSlots.map((s) => (
<TouchableOpacity
key={s.time}
disabled={s.isBooked}
style={[
styles.slotButton,
s.isSelected && styles.slotActive,
s.isBooked && styles.slotBooked
]}
onPress={() => setSlot(s.time)}
>
<Text style={[
styles.slotText,
s.isSelected && styles.slotTextActive,
s.isBooked && styles.slotTextBooked
]}>
{s.time}
</Text>
</TouchableOpacity>
))
) : (
<Text style={styles.emptyText}>Sem horários para este barbeiro neste dia.</Text>
)}
</View>
{processedSlots.length > 0 && !processedSlots.some(s => !s.isBooked) && (
<View style={styles.waitlistCard}>
<Text style={styles.waitlistText}>Esgotado! Queres ser avisado se alguém cancelar?</Text>
<Button
variant="outline"
onPress={async () => {
const ok = await joinWaitlist(shop.id, serviceId, barberId, date);
if (ok) Alert.alert('Lista de Espera', 'Estás na lista! Avisamos-te por push se abrir vaga.');
}}
>
Entrar na Lista de Espera
</Button>
</View>
)}
</View>
);
case 4:
return (
<View style={styles.stepContent}>
<Text style={styles.stepTitle}>Tudo certo?</Text>
<Card style={styles.summaryCard}>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Serviço</Text>
<Text style={styles.summaryValue}>{selectedService?.name}</Text>
</View>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Profissional</Text>
<Text style={styles.summaryValue}>{selectedBarber?.name}</Text>
</View>
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Data e Hora</Text>
<Text style={styles.summaryValue}>{date} às {slot}</Text>
</View>
<View style={styles.divider} />
<View style={styles.summaryRow}>
<Text style={styles.summaryLabel}>Total</Text>
<Text style={styles.summaryTotal}>{currency(selectedService?.price || 0)}</Text>
</View>
</Card>
<View style={styles.notificationSettings}>
<Text style={styles.subTitle}>Configurar Alerta Push</Text>
<Text style={styles.notifHelp}>Quando queres receber o lembrete no telemóvel?</Text>
<View style={styles.reminderOptions}>
{reminderOptions.map(opt => (
<TouchableOpacity
key={opt.value}
style={[styles.notifButton, reminderMinutes === opt.value && styles.notifButtonActive]}
onPress={() => setReminderMinutes(opt.value)}
>
<Text style={[styles.notifText, reminderMinutes === opt.value && styles.notifTextActive]}>
{opt.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</View>
);
default:
return null;
}
};
return (
// Área de conteúdo com scroll adaptativa
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>Agendar em {shop.name}</Text>
<SafeAreaView style={styles.container}>
<Stepper steps={steps} currentStep={step} />
<ScrollView style={styles.mainScroll} contentContainerStyle={styles.scrollContent}>
{renderStepContent()}
</ScrollView>
<Card style={styles.card}>
<Text style={styles.sectionTitle}>1. Seleção de Serviço</Text>
{/* Renderiza um botão (bloco flexível) por cada serviço (ex: Corte, Barba) vindos do mapeamento DB */}
<View style={styles.grid}>
{shop.services.map((s) => (
<TouchableOpacity
key={s.id}
style={[styles.serviceButton, serviceId === s.id && styles.serviceButtonActive]}
onPress={() => setService(s.id)}
>
<Text style={[styles.serviceText, serviceId === s.id && styles.serviceTextActive]}>{s.name}</Text>
<Text style={styles.servicePrice}>{currency(s.price)}</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.sectionTitle}>2. Barbeiro</Text>
{/* Renderiza os profissionais, normalmente provindo dum JOIN na base de dados (tabela barbeiros + barbearia) */}
<View style={styles.barberContainer}>
{shop.barbers.map((b) => (
<TouchableOpacity
key={b.id}
style={[styles.barberButton, barberId === b.id && styles.barberButtonActive]}
onPress={() => setBarber(b.id)}
>
<Text style={[styles.barberText, barberId === b.id && styles.barberTextActive]}>
{b.name}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.sectionTitle}>3. Data de Preferência</Text>
{/* Componente simples de input que deverá mapear para a inserção final do timestamp Postgres */}
<Input
value={date}
onChangeText={setDate}
placeholder="YYYY-MM-DD"
/>
<Text style={styles.sectionTitle}>4. Horário</Text>
<View style={styles.slotsContainer}>
{processedSlots.some(s => !s.isBooked) ? (
processedSlots.filter(s => !s.isBooked).map((s) => (
<TouchableOpacity
key={s.time}
style={[styles.slotButton, slot === s.time && styles.slotButtonActive]}
onPress={() => setSlot(s.time)}
>
<Text style={[styles.slotText, slot === s.time && styles.slotTextActive]}>{s.time}</Text>
</TouchableOpacity>
))
) : processedSlots.length > 0 ? (
<View style={{ flex: 1, alignItems: 'center', padding: 20, backgroundColor: '#fff1f2', borderRadius: 16, borderWidth: 1, borderColor: '#fecdd3' }}>
<Text style={{ fontSize: 14, fontWeight: 'bold', color: '#e11d48', textAlign: 'center', marginBottom: 12 }}>
Todos os horários estão preenchidos para este dia.
</Text>
{user && waitlists.some(w => w.barberId === barberId && w.date === date && w.customerId === user.id && w.status === 'pending') ? (
<View style={{ paddingVertical: 8, paddingHorizontal: 16, backgroundColor: '#fdf2f8', borderRadius: 8 }}>
<Text style={{ color: '#db2777', fontWeight: 'bold', fontSize: 12 }}> estás na lista de espera deste dia!</Text>
</View>
) : (
<Button
onPress={async () => {
if (!user) {
Alert.alert('Login necessário', 'Faça login para entrar na lista de espera');
navigation.navigate('Login' as never);
return;
}
if (!serviceId) {
Alert.alert('Atenção', 'Selecione primeiro o serviço que pretende.');
return;
}
const ok = await joinWaitlist(shop.id, serviceId, barberId, date);
if (ok) Alert.alert('Sucesso', 'Entraste na lista de espera! Serás notificado se houver desistências.');
}}
variant="outline"
disabled={!serviceId}
style={{ borderColor: serviceId ? '#e11d48' : '#cbd5e1' }}
>
<Text style={{ color: serviceId ? '#e11d48' : '#94a3b8', fontWeight: 'bold' }}>Entrar na Lista de Espera</Text>
</Button>
)}
</View>
) : (
<Text style={styles.noSlots}>Selecione primeiro o mestre e a data</Text>
)}
</View>
<Text style={styles.sectionTitle}>5. Receber Lembrete</Text>
<View style={styles.reminderContainer}>
{reminderOptions.map((opt) => (
<TouchableOpacity
key={opt.value}
style={[styles.reminderButton, reminderMinutes === opt.value && styles.reminderButtonActive]}
onPress={() => setReminderMinutes(opt.value)}
>
<Text style={[styles.reminderText, reminderMinutes === opt.value && styles.reminderTextActive]}>
{opt.label} antes
</Text>
</TouchableOpacity>
))}
</View>
{/* Quadro resumo: Apenas mostrado se o estado interno conter todas as variáveis relacionais */}
{canSubmit && selectedService && (
<View style={styles.summary}>
<Text style={styles.summaryTitle}>Resumo do Agendamento</Text>
<Text style={styles.summaryText}>Serviço: {selectedService.name}</Text>
<Text style={styles.summaryText}>Barbeiro: {selectedBarber?.name}</Text>
<Text style={styles.summaryText}>Data: {date} às {slot}</Text>
<Text style={styles.summaryTotal}>Total: {currency(selectedService.price)}</Text>
</View>
<View style={styles.footer}>
{step > 1 && (
<Button variant="ghost" onPress={() => setStep(s => s - 1)} style={styles.footerButton}>
Voltar
</Button>
)}
{/* Botão para concretizar o INSERT na base de dados com as validações pré-acionadas */}
<Button onPress={submit} disabled={!canSubmit} style={styles.submitButton} size="md">
{user ? 'Confirmar Reserva' : 'Entrar para Reservar'}
<Button
disabled={!canNext()}
onPress={step === 4 ? submit : () => setStep(s => s + 1)}
style={[styles.footerButton, { flex: 2 }]}
>
{step === 4 ? (user ? 'Confirmar Agendamento' : 'Login para Confirmar') : 'Próximo'}
</Button>
</Card>
</ScrollView>
</View>
</SafeAreaView>
);
}
@@ -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,
},
});

View File

@@ -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 (
<View style={styles.container}>
<SafeAreaView style={styles.container}>
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Sua Seleção está Deserta</Text>
</Card>
</View>
</SafeAreaView>
);
}
@@ -62,80 +58,82 @@ export default function Cart() {
return (
// A página permite visibilidade escalonada num conteúdo flexível (ScrollView)
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>Minha Seleção</Text>
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
<Text style={styles.title}>Minha Seleção</Text>
{/* 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
<Card key={shopId} style={styles.shopCard}>
<View style={styles.shopHeader}>
<View>
{/* Consome o nome e morada do registo principal (Profile > Shop) na UI */}
<Text style={styles.shopName}>{shop?.name ?? 'Barbearia'}</Text>
<Text style={styles.shopAddress}>{shop?.address}</Text>
</View>
{/* Apresenta o custo transformado visualmente (ex: R$ / €) */}
<Text style={styles.total}>{currency(total)}</Text>
</View>
{/* 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 (
<View key={i.refId} style={styles.item}>
<Text style={styles.itemText}>
{/* 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}
</Text>
{/* Elimina de forma independente o registo não guardado da persistência AppContext/State */}
<Button
onPress={() => removeFromCart(i.refId)}
variant="ghost"
size="sm"
>
Remover
</Button>
return (
// Engloba os items duma só loja
<Card key={shopId} style={styles.shopCard}>
<View style={styles.shopHeader}>
<View>
{/* Consome o nome e morada do registo principal (Profile > Shop) na UI */}
<Text style={styles.shopName}>{shop?.name ?? 'Barbearia'}</Text>
<Text style={styles.shopAddress}>{shop?.address}</Text>
</View>
);
})}
{/* Apresenta o custo transformado visualmente (ex: R$ / €) */}
<Text style={styles.total}>{currency(total)}</Text>
</View>
{/* Renderização condicional no React para encaminhar fluxo para login se anónimo */}
{user ? (
<Button onPress={() => handleCheckout(shopId)} style={styles.checkoutButton}>
Finalizar Aquisição
</Button>
) : (
<Button
onPress={() => navigation.navigate('Login' as never)}
style={styles.checkoutButton}
>
Entrar para Adquirir
</Button>
)}
</Card>
);
})}
</ScrollView>
{/* 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 (
<View key={i.refId} style={styles.item}>
<Text style={styles.itemText}>
{/* 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}
</Text>
{/* Elimina de forma independente o registo não guardado da persistência AppContext/State */}
<Button
onPress={() => removeFromCart(i.refId)}
variant="ghost"
size="sm"
>
Remover
</Button>
</View>
);
})}
{/* Renderização condicional no React para encaminhar fluxo para login se anónimo */}
{user ? (
<Button onPress={() => handleCheckout(shopId)} style={styles.checkoutButton}>
Finalizar Aquisição
</Button>
) : (
<Button
onPress={() => navigation.navigate('Login' as never)}
style={styles.checkoutButton}
>
Entrar para Adquirir
</Button>
)}
</Card>
);
})}
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -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 (
<View style={[styles.container, { justifyContent: 'center', alignItems: 'center' }]}>
<Text style={{ color: '#fff', fontSize: 18 }}>A carregar dados da barbearia...</Text>
</View>
);
}
// Fallback no caso da loja ainda não estar populada no join do utilizador
if (!shop) {
return (
<View style={styles.container}>
<Text>Barbearia não encontrada</Text>
</View>
<SafeAreaView style={[styles.container, { justifyContent: 'center', alignItems: 'center' }]}>
<Text style={{ color: '#0f172a', fontSize: 18 }}>A carregar dados da barbearia...</Text>
</SafeAreaView>
);
}
@@ -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 (
<View style={styles.container}>
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>{shop.name}</Text>
<Button onPress={logout} variant="ghost" size="sm">
@@ -369,29 +340,27 @@ export default function Dashboard() {
<View style={{ gap: 20 }}>
<Text style={[styles.title, { marginBottom: 0 }]}>Profissionais</Text>
{/* Barra de Pesquisa Estilo Screenshot */}
<View style={styles.searchContainer}>
<Input
placeholder="Pesquisar"
value={barberSearchQuery}
onChangeText={setBarberSearchQuery}
style={styles.searchInput}
placeholderTextColor="#64748b"
placeholderTextColor="#94a3b8"
/>
</View>
{/* Lista de Profissionais */}
<View style={styles.barberList}>
{shop.barbers
.filter(b => b.name.toLowerCase().includes(barberSearchQuery.toLowerCase()))
.map((b) => (
<View key={b.id} style={styles.barberCard}>
<Card key={b.id} style={styles.barberCard}>
<View style={styles.barberAvatar}>
{/* Placeholder ou imagem real se disponível futuramente */}
<Text style={{ color: '#6366f1', fontWeight: 'bold' }}>{b.name.charAt(0)}</Text>
<Text style={{ color: '#6366f1', fontWeight: 'bold', fontSize: 18 }}>{b.name.charAt(0)}</Text>
</View>
<View style={styles.barberInfo}>
<Text style={styles.barberNameText}>{b.name}</Text>
<Text style={styles.barberSpecialtyText}>{b.specialties.join(', ') || 'Barbeiro'}</Text>
</View>
<TouchableOpacity
onPress={() => {
@@ -402,9 +371,9 @@ export default function Dashboard() {
}}
style={styles.deleteBarberBtn}
>
<Text style={{ color: '#ef4444', fontSize: 12 }}>Sair</Text>
<Text style={{ color: '#ef4444', fontSize: 12, fontWeight: 'bold' }}>Remover</Text>
</TouchableOpacity>
</View>
</Card>
))}
{shop.barbers.filter(b => b.name.toLowerCase().includes(barberSearchQuery.toLowerCase())).length === 0 && (
@@ -414,18 +383,17 @@ export default function Dashboard() {
)}
</View>
{/* Formulário de Adição */}
<Card style={styles.formCard}>
<Text style={styles.formTitle}>Adicionar profissional</Text>
<Input label="Nome" value={barberName} onChangeText={setBarberName} placeholder="Ex: João Silva" />
<Button onPress={addNewBarber} style={styles.addButton}>
Adicionar Coração do Negócio
Adicionar Profissional
</Button>
</Card>
</View>
)}
</ScrollView>
</View>
</SafeAreaView>
);
}
@@ -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)',
},
});

View File

@@ -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
<View style={styles.container}>
<SafeAreaView style={styles.container}>
<Text style={styles.title}>Barbearias</Text>
{/* FlatList é o componente nativo otimizado para renderizar grandes arrays de dados provenientes da BD */}
@@ -68,7 +69,7 @@ export default function Explore() {
</Card>
)}
/>
</View>
</SafeAreaView>
);
}

View File

@@ -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"
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Hero section: elemento apelativo para captar valor (exibição de marca) */}
<View style={styles.hero}>
<Text style={styles.heroTitle}>Smart Agenda</Text>
<Text style={styles.heroSubtitle}>
Agendamento e Gestão de Barbearias.
</Text>
<Text style={styles.heroDesc}>
A sua solução completa para o dia-a-dia da barbearia.
</Text>
<View style={styles.buttons}>
<Button
onPress={() => navigation.navigate('Explore' as never)}
style={styles.button}
size="md"
>
Ver Barbearias
</Button>
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
{/* Hero section: elemento apelativo para captar valor (exibição de marca) */}
<View style={styles.hero}>
<Text style={styles.heroTitle}>Smart Agenda</Text>
<Text style={styles.heroSubtitle}>
Agendamento e Gestão de Barbearias.
</Text>
<Text style={styles.heroDesc}>
A sua solução completa para o dia-a-dia da barbearia.
</Text>
<View style={styles.buttons}>
<Button
onPress={() => navigation.navigate('Explore' as never)}
style={styles.button}
size="md"
>
Ver Barbearias
</Button>
<Button
onPress={() => navigation.navigate('Register' as never)}
variant="outline"
style={styles.button}
size="md"
>
Criar Conta
</Button>
<Button
onPress={() => navigation.navigate('Register' as never)}
variant="outline"
style={styles.button}
size="md"
>
Criar Conta
</Button>
</View>
</View>
</View>
<View style={styles.features}>
<Card style={styles.featureCard}>
<Text style={styles.featureTitle}>Reservas Rápidas</Text>
<Text style={styles.featureDesc}>
Selecione o seu barbeiro e o horário ideal em poucos segundos.
</Text>
</Card>
<Card style={styles.featureCard}>
<Text style={styles.featureTitle}>Produtos</Text>
<Text style={styles.featureDesc}>
Produtos de cuidado masculino selecionados para si.
</Text>
</Card>
<Card style={styles.featureCard}>
<Text style={styles.featureTitle}>Gestão de Barbearia</Text>
<Text style={styles.featureDesc}>
Controlo total sobre o faturamento e operação da sua barbearia.
</Text>
</Card>
</View>
</ScrollView>
<View style={styles.features}>
<Card style={styles.featureCard}>
<Text style={styles.featureTitle}>Reservas Rápidas</Text>
<Text style={styles.featureDesc}>
Selecione o seu barbeiro e o horário ideal em poucos segundos.
</Text>
</Card>
<Card style={styles.featureCard}>
<Text style={styles.featureTitle}>Produtos</Text>
<Text style={styles.featureDesc}>
Produtos de cuidado masculino selecionados para si.
</Text>
</Card>
<Card style={styles.featureCard}>
<Text style={styles.featureTitle}>Gestão de Barbearia</Text>
<Text style={styles.featureDesc}>
Controlo total sobre o faturamento e operação da sua barbearia.
</Text>
</Card>
</View>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -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 (
<View style={styles.container}>
<SafeAreaView style={styles.container}>
<Text>Faça login para ver o perfil</Text>
</View>
</SafeAreaView>
);
}
@@ -43,91 +39,93 @@ export default function Profile() {
return (
// Contentor com ScrollView adaptável (evita cortes em ecrãs pequenos)
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Bloco de identificação do Utilizador validado pela API (Supabase Auth) */}
<Card style={styles.profileCard}>
<Text style={styles.profileName}>Olá, {user.name}</Text>
<Text style={styles.profileEmail}>{user.email}</Text>
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
{/* Bloco de identificação do Utilizador validado pela API (Supabase Auth) */}
<Card style={styles.profileCard}>
<Text style={styles.profileName}>Olá, {user.name}</Text>
<Text style={styles.profileEmail}>{user.email}</Text>
{/* Distanciamento visual e lógica dos tipos de perfil 'role' presentes na BD */}
<Badge color="indigo" style={styles.roleBadge}>
{user.role === 'cliente' ? 'Cliente' : 'Barbearia'}
</Badge>
{/* Distanciamento visual e lógica dos tipos de perfil 'role' presentes na BD */}
<Badge color="indigo" style={styles.roleBadge}>
{user.role === 'cliente' ? 'Cliente' : 'Barbearia'}
</Badge>
{/* Limpa a sessão ativa e tokens memorizados de Login */}
<Button onPress={logout} variant="outline" style={styles.logoutButton}>
Sair
</Button>
</Card>
{myNotifications.length > 0 && (
<>
<Text style={styles.sectionTitle}>Notificações</Text>
{myNotifications.map((n) => (
<Card key={n.id} style={[styles.itemCard, { borderColor: '#fecdd3', borderWidth: 1 }]}>
<View style={styles.itemHeader}>
<Text style={[styles.itemName, { color: '#e11d48' }]}>🔔 Nova Vaga!</Text>
</View>
<Text style={{ fontSize: 14, color: '#334155', marginBottom: 16 }}>{n.message}</Text>
<Button onPress={() => markNotificationRead(n.id)} variant="outline" style={{ backgroundColor: '#f1f5f9' }}>
Marcar Lida
</Button>
</Card>
))}
</>
)}
<Text style={styles.sectionTitle}>As Minhas Reservas</Text>
{/* Renderiza a lista se existirem marcações no percurso deste utilizador */}
{myAppointments.length > 0 ? (
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 (
<Card key={a.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
{/* Nome exibido pós JOIN de array em memória */}
<Text style={styles.itemName}>{shop?.name}</Text>
{/* O status (persistido na BD) influencia a cor devolvida ao Badge */}
<Badge color={statusColor[a.status]}>{a.status}</Badge>
</View>
<Text style={styles.itemDate}>{a.date}</Text>
<Text style={styles.itemTotal}>{currency(a.total)}</Text>
</Card>
);
})
) : (
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Nenhum agendamento ainda</Text>
{/* Limpa a sessão ativa e tokens memorizados de Login */}
<Button onPress={logout} variant="outline" style={styles.logoutButton}>
Sair
</Button>
</Card>
)}
<Text style={styles.sectionTitle}>As Minhas Compras</Text>
{/* 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 (
<Card key={o.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<Text style={styles.itemName}>{shop?.name}</Text>
<Badge color={statusColor[o.status]}>{o.status}</Badge>
</View>
<Text style={styles.itemDate}>
{/* Formatação Timestamp temporal da BD (createdAt) para modo visual PT */}
{new Date(o.createdAt).toLocaleString('pt-BR')}
</Text>
<Text style={styles.itemTotal}>{currency(o.total)}</Text>
</Card>
);
})
) : (
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Nenhum pedido ainda</Text>
</Card>
)}
</ScrollView>
{myNotifications.length > 0 && (
<>
<Text style={styles.sectionTitle}>Notificações</Text>
{myNotifications.map((n) => (
<Card key={n.id} style={[styles.itemCard, { borderColor: '#fecdd3', borderWidth: 1 }]}>
<View style={styles.itemHeader}>
<Text style={[styles.itemName, { color: '#e11d48' }]}>🔔 Nova Vaga!</Text>
</View>
<Text style={{ fontSize: 14, color: '#334155', marginBottom: 16 }}>{n.message}</Text>
<Button onPress={() => markNotificationRead(n.id)} variant="outline" style={{ backgroundColor: '#f1f5f9' }}>
Marcar Lida
</Button>
</Card>
))}
</>
)}
<Text style={styles.sectionTitle}>As Minhas Reservas</Text>
{/* Renderiza a lista se existirem marcações no percurso deste utilizador */}
{myAppointments.length > 0 ? (
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 (
<Card key={a.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
{/* Nome exibido pós JOIN de array em memória */}
<Text style={styles.itemName}>{shop?.name}</Text>
{/* O status (persistido na BD) influencia a cor devolvida ao Badge */}
<Badge color={statusColor[a.status]}>{a.status}</Badge>
</View>
<Text style={styles.itemDate}>{a.date}</Text>
<Text style={styles.itemTotal}>{currency(a.total)}</Text>
</Card>
);
})
) : (
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Nenhum agendamento ainda</Text>
</Card>
)}
<Text style={styles.sectionTitle}>As Minhas Compras</Text>
{/* 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 (
<Card key={o.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<Text style={styles.itemName}>{shop?.name}</Text>
<Badge color={statusColor[o.status]}>{o.status}</Badge>
</View>
<Text style={styles.itemDate}>
{/* Formatação Timestamp temporal da BD (createdAt) para modo visual PT */}
{new Date(o.createdAt).toLocaleString('pt-BR')}
</Text>
<Text style={styles.itemTotal}>{currency(o.total)}</Text>
</Card>
);
})
) : (
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Nenhum pedido ainda</Text>
</Card>
)}
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -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 (
<View style={styles.container}>
<SafeAreaView style={styles.container}>
<Text>Barbearia não encontrada</Text>
</View>
</SafeAreaView>
);
}
return (
// Contentor em Scroll adaptável horizontal/vertical em smartphones reduzidos
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
{/* Cabeçalho superior: Informações imutáveis populadas pelos profiles preenchidos */}
<View style={styles.header}>
<Text style={styles.title}>{shop.name}</Text>
<Text style={styles.address}>{shop.address}</Text>
{/* Call to action de elevado destaque que incia form Booking */}
<Button
onPress={() => navigation.navigate('Booking', { shopId: shop.id })}
style={styles.bookButton}
>
Reservar Experiência
</Button>
</View>
{/* Controladores de abas visuais iterando o estado (setTab) */}
<View style={styles.tabs}>
<TouchableOpacity
style={[styles.tab, tab === 'servicos' && styles.tabActive]}
onPress={() => setTab('servicos')}
>
<Text style={[styles.tabText, tab === 'servicos' && styles.tabTextActive]}>Menu de Serviços</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tab, tab === 'produtos' && styles.tabActive]}
onPress={() => setTab('produtos')}
>
<Text style={[styles.tabText, tab === 'produtos' && styles.tabTextActive]}>Boutique</Text>
</TouchableOpacity>
</View>
{/* 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
<View style={styles.list}>
{shop.services.map((service) => (
<Card key={service.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<Text style={styles.itemName}>{service.name}</Text>
<Text style={styles.itemPrice}>{currency(service.price)}</Text>
</View>
<Text style={styles.itemDesc}>Duração: {service.duration} min</Text>
{/* Função addToCart despacha dados para Context agregador permitindo checkout posterior */}
<Button
onPress={() => addToCart({ shopId: shop.id, type: 'service', refId: service.id, qty: 1 })}
size="sm"
style={styles.addButton}
>
Adicionar à Seleção
</Button>
</Card>
))}
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
{/* Cabeçalho superior: Informações imutáveis populadas pelos profiles preenchidos */}
<View style={styles.header}>
<Text style={styles.title}>{shop.name}</Text>
<Text style={styles.address}>{shop.address}</Text>
{/* Call to action de elevado destaque que incia form Booking */}
<Button
onPress={() => navigation.navigate('Booking', { shopId: shop.id })}
style={styles.bookButton}
>
Reservar Experiência
</Button>
</View>
) : (
// Retorno divergente: inventário global visivelmente iterado em componentes
<View style={styles.list}>
{shop.products.map((product) => (
<Card key={product.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<Text style={styles.itemName}>{product.name}</Text>
<Text style={styles.itemPrice}>{currency(product.price)}</Text>
</View>
<Text style={styles.itemDesc}>Stock: {product.stock} unidades</Text>
{/* Alerta de urgência de reposição assente numa regra simples de negócios matemática */}
{product.stock <= 3 && <Badge color="indigo" style={styles.stockBadge}>Últimas unidades</Badge>}
{/* Botão em React é afetado logicamente face à impossibilidade material de encomenda */}
<Button
onPress={() => 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'}
</Button>
</Card>
))}
{/* Controladores de abas visuais iterando o estado (setTab) */}
<View style={styles.tabs}>
<TouchableOpacity
style={[styles.tab, tab === 'servicos' && styles.tabActive]}
onPress={() => setTab('servicos')}
>
<Text style={[styles.tabText, tab === 'servicos' && styles.tabTextActive]}>Menu de Serviços</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tab, tab === 'produtos' && styles.tabActive]}
onPress={() => setTab('produtos')}
>
<Text style={[styles.tabText, tab === 'produtos' && styles.tabTextActive]}>Boutique</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
{/* 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
<View style={styles.list}>
{shop.services.map((service) => (
<Card key={service.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<Text style={styles.itemName}>{service.name}</Text>
<Text style={styles.itemPrice}>{currency(service.price)}</Text>
</View>
<Text style={styles.itemDesc}>Duração: {service.duration} min</Text>
{/* Função addToCart despacha dados para Context agregador permitindo checkout posterior */}
<Button
onPress={() => addToCart({ shopId: shop.id, type: 'service', refId: service.id, qty: 1 })}
size="sm"
style={styles.addButton}
>
Adicionar à Seleção
</Button>
</Card>
))}
</View>
) : (
// Retorno divergente: inventário global visivelmente iterado em componentes
<View style={styles.list}>
{shop.products.map((product) => (
<Card key={product.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<Text style={styles.itemName}>{product.name}</Text>
<Text style={styles.itemPrice}>{currency(product.price)}</Text>
</View>
<Text style={styles.itemDesc}>Stock: {product.stock} unidades</Text>
{/* Alerta de urgência de reposição assente numa regra simples de negócios matemática */}
{product.stock <= 3 && <Badge color="indigo" style={styles.stockBadge}>Últimas unidades</Badge>}
{/* Botão em React é afetado logicamente face à impossibilidade material de encomenda */}
<Button
onPress={() => 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'}
</Button>
</Card>
))}
</View>
)}
</ScrollView>
</SafeAreaView>
);
}