refactor: migrate core page layouts to use SafeAreaView and add utility notification scripts and Stepper component
This commit is contained in:
157
src/components/ui/Stepper.tsx
Normal file
157
src/components/ui/Stepper.tsx
Normal 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
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}>Já 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}>Já tem conta? </Text>
|
||||
<Text
|
||||
style={styles.footerLink}
|
||||
onPress={() => navigation.navigate('Login' as never)}
|
||||
>
|
||||
Entrar
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }}>Já estás na lista de espera deste dia!</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
onPress={async () => {
|
||||
if (!user) {
|
||||
Alert.alert('Login necessário', 'Faça login para entrar na lista de espera');
|
||||
navigation.navigate('Login' as never);
|
||||
return;
|
||||
}
|
||||
if (!serviceId) {
|
||||
Alert.alert('Atenção', 'Selecione primeiro o serviço que pretende.');
|
||||
return;
|
||||
}
|
||||
const ok = await joinWaitlist(shop.id, serviceId, barberId, date);
|
||||
if (ok) Alert.alert('Sucesso', 'Entraste na lista de espera! Serás notificado se houver desistências.');
|
||||
}}
|
||||
variant="outline"
|
||||
disabled={!serviceId}
|
||||
style={{ borderColor: serviceId ? '#e11d48' : '#cbd5e1' }}
|
||||
>
|
||||
<Text style={{ color: serviceId ? '#e11d48' : '#94a3b8', fontWeight: 'bold' }}>Entrar na Lista de Espera</Text>
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.noSlots}>Selecione primeiro o mestre e a data</Text>
|
||||
)}
|
||||
</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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user