feat: Implement initial application structure, core pages, UI components, and Supabase backend integration.

This commit is contained in:
Rodrigo Lopes dos Santos
2026-03-16 01:30:28 +00:00
parent 8ece90a37e
commit 0270a6cbdf
49 changed files with 2122 additions and 797 deletions

View File

@@ -36,7 +36,7 @@ export const Button = ({ children, onPress, variant = 'solid', size = 'md', disa
activeOpacity={0.7}
>
{loading ? (
<ActivityIndicator color={variant === 'solid' ? '#fff' : '#6366f1'} />
<ActivityIndicator size={20} color={variant === 'solid' ? '#fff' : '#6366f1'} />
) : (
<Text style={textStyles}>{children}</Text>
)}

View File

@@ -8,6 +8,9 @@ import React, { createContext, useContext, useEffect, useMemo, useState } from '
import { Appointment, Barber, BarberShop, CartItem, Order, Product, Service, User } from '../types';
import { supabase } from '../lib/supabase';
import { nanoid } from 'nanoid';
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import { Platform } from 'react-native';
type State = {
user?: User;
@@ -18,10 +21,12 @@ type State = {
};
type AppContextValue = State & {
login: (email: string, password: string) => boolean;
login: (email: string, password: string) => User | undefined;
logout: () => void;
register: (payload: any) => boolean;
register: (payload: any) => User | undefined;
addToCart: (item: CartItem) => void;
removeFromCart: (refId: string) => void;
placeOrder: (customerId: string, shopId: string) => Order | null;
clearCart: () => void;
createAppointment: (input: Omit<Appointment, 'id' | 'status' | 'total'>) => Promise<Appointment | null>;
updateAppointmentStatus: (id: string, status: Appointment['status']) => Promise<void>;
@@ -54,7 +59,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
if (data.user) {
const { data: prof } = await supabase
.from('profiles')
.select('shop_id, role, name')
.select('shop_id, role, name, fcm_token')
.eq('id', data.user.id)
.single();
@@ -64,13 +69,22 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
email: data.user.email || '',
role: (prof?.role as any) || 'cliente',
shopId: prof?.shop_id || undefined,
fcmToken: prof?.fcm_token || undefined,
} as User);
// Regista token FCM se ainda não existir ou tiver mudado
registerForPushNotificationsAsync().then(token => {
if (token && token !== prof?.fcm_token) {
supabase.from('profiles').update({ fcm_token: token }).eq('id', data.user.id).then();
}
});
}
};
loadUser();
}, []);
const refreshShops = async () => {
console.log('AppContext: refreshShops iniciado');
try {
const { data: shopsData } = await supabase.from('shops').select('*');
const { data: servicesData } = await supabase.from('services').select('*');
@@ -134,15 +148,33 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
};
useEffect(() => {
console.log('AppContext: Iniciando carregamento...');
const init = async () => {
await refreshShops();
setLoading(false);
try {
await refreshShops();
console.log('AppContext: Lojas carregadas com sucesso.');
} catch (e) {
console.error('AppContext: Erro no init:', e);
} finally {
setLoading(false);
console.log('AppContext: setLoading(false)');
}
};
init();
}, []);
const login = (email: string, password: string) => {
return true;
// Provisório para demo
const u: User = {
id: email === 'barber@demo.com' ? 'demo-barber' : 'demo-cliente',
name: email === 'barber@demo.com' ? 'Barbeiro Chefe' : 'Utilizador Demo',
email,
password, // Adicionado para satisfazer o tipo
role: email === 'barber@demo.com' ? 'barbearia' : 'cliente',
shopId: email === 'barber@demo.com' ? shops[0]?.id : undefined
};
setUser(u);
return u;
};
const logout = async () => {
@@ -154,7 +186,39 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const id = nanoid();
const newUser: User = { ...payload, id };
setUser(newUser);
return true;
return newUser;
};
const removeFromCart = (refId: string) => {
setCart((prev) => prev.filter((i) => i.refId !== refId));
};
const placeOrder = (customerId: string, shopId: string) => {
const shopItems = cart.filter((i) => i.shopId === shopId);
if (!shopItems.length) return null;
const shop = shops.find((s) => s.id === shopId);
const total = shopItems.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);
const newOrder: Order = {
id: nanoid(),
shopId,
customerId,
items: shopItems,
total,
status: 'pendente',
createdAt: new Date().toISOString(),
};
setOrders((prev) => [...prev, newOrder]);
setCart((prev) => prev.filter((i) => i.shopId !== shopId));
return newOrder;
};
const addToCart = (item: CartItem) => {
@@ -221,7 +285,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
customer_id: input.customerId,
date: input.date,
status: 'pendente',
total
total,
reminder_minutes: input.reminderMinutes
}]).select().single();
await refreshShops();
return data as any as Appointment;
@@ -248,6 +313,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
logout,
register,
addToCart,
removeFromCart,
placeOrder,
clearCart,
createAppointment,
updateAppointmentStatus,
@@ -275,4 +342,36 @@ export const useApp = () => {
const ctx = useContext(AppContext);
if (!ctx) throw new Error('useApp deve ser usado dentro de AppProvider');
return ctx;
};
};
async function registerForPushNotificationsAsync() {
let token;
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#FF231F7C',
});
}
if (Device.isDevice) {
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
console.log('Falha ao obter token push!');
return;
}
token = (await Notifications.getExpoPushTokenAsync({
projectId: 'b018a5db-c940-4364-81ee-596ced75cae3',
})).data;
} else {
console.log('Necessário dispositivo físico para notificações push');
}
return token;
}

View File

@@ -43,8 +43,8 @@ export default function AuthLogin() {
<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 de volta</Text>
<Text style={styles.subtitle}>Entre na sua conta para continuar</Text>
<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}>
@@ -91,7 +91,7 @@ export default function AuthLogin() {
style={styles.footerLink}
onPress={() => navigation.navigate('Register' as never)}
>
Criar conta
Criar Conta
</Text>
</View>
</Card>
@@ -126,9 +126,9 @@ const styles = StyleSheet.create({
textAlign: 'center',
},
demoBox: {
backgroundColor: '#fef3c7',
backgroundColor: '#e0e7ff',
borderWidth: 1,
borderColor: '#fbbf24',
borderColor: '#6366f1',
borderRadius: 8,
padding: 12,
marginBottom: 20,
@@ -136,12 +136,12 @@ const styles = StyleSheet.create({
demoTitle: {
fontSize: 12,
fontWeight: '600',
color: '#92400e',
color: '#4338ca',
marginBottom: 4,
},
demoText: {
fontSize: 11,
color: '#92400e',
color: '#4338ca',
},
submitButton: {
width: '100%',

View File

@@ -31,6 +31,14 @@ export default function Booking() {
const [barberId, setBarber] = useState('');
const [date, setDate] = useState('');
const [slot, setSlot] = useState('');
const [reminderMinutes, setReminderMinutes] = useState(1440); // 24h por padrão
const reminderOptions = [
{ label: '10 min', value: 10 },
{ label: '30 min', value: 30 },
{ label: '1 hora', value: 60 },
{ label: '24 horas', value: 1440 },
];
// Fallback visual caso ocorra um erro a obter o ID requisitado
if (!shop) {
@@ -99,7 +107,7 @@ export default function Booking() {
* 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 = () => {
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');
@@ -109,12 +117,13 @@ export default function Booking() {
if (!canSubmit) return;
// Cria o agendamento fornecendo as 'Foreign Keys' vitais (shopId, serviceId, etc...)
const appt = createAppointment({
const appt = await createAppointment({
shopId: shop.id,
serviceId,
barberId,
customerId: user.id, // Auth User UID
date: `${date} ${slot}`
date: `${date} ${slot}`,
reminderMinutes
});
if (appt) {
@@ -131,7 +140,7 @@ export default function Booking() {
<Text style={styles.title}>Agendar em {shop.name}</Text>
<Card style={styles.card}>
<Text style={styles.sectionTitle}>1. Escolha o serviço</Text>
<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) => (
@@ -146,7 +155,7 @@ export default function Booking() {
))}
</View>
<Text style={styles.sectionTitle}>2. Escolha o barbeiro</Text>
<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) => (
@@ -162,7 +171,7 @@ export default function Booking() {
))}
</View>
<Text style={styles.sectionTitle}>3. Escolha a data</Text>
<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}
@@ -170,8 +179,7 @@ export default function Booking() {
placeholder="YYYY-MM-DD"
/>
<Text style={styles.sectionTitle}>4. Escolha o horário</Text>
{/* Lista mapeada e computada: Apenas slots `available` (que passaram pela query preventiva de duplicação) */}
<Text style={styles.sectionTitle}>4. Horário</Text>
<View style={styles.slotsContainer}>
{availableSlots.length > 0 ? (
availableSlots.map((h) => (
@@ -184,14 +192,29 @@ export default function Booking() {
</TouchableOpacity>
))
) : (
<Text style={styles.noSlots}>Escolha primeiro o barbeiro e a data</Text>
<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</Text>
<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>
@@ -201,7 +224,7 @@ export default function Booking() {
{/* 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="lg">
{user ? 'Confirmar agendamento' : 'Entrar para agendar'}
{user ? 'Confirmar Reserva' : 'Entrar para Reservar'}
</Button>
</Card>
</ScrollView>
@@ -319,6 +342,31 @@ const styles = StyleSheet.create({
color: '#94a3b8',
fontStyle: 'italic',
},
reminderContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginBottom: 16,
},
reminderButton: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 8,
borderWidth: 1,
borderColor: '#e2e8f0',
},
reminderButtonActive: {
borderColor: '#6366f1',
backgroundColor: '#e0e7ff',
},
reminderText: {
fontSize: 12,
color: '#64748b',
},
reminderTextActive: {
color: '#6366f1',
fontWeight: 'bold',
},
summary: {
backgroundColor: '#f1f5f9',
padding: 16,

View File

@@ -22,7 +22,7 @@ export default function Cart() {
return (
<View style={styles.container}>
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Carrinho vazio</Text>
<Text style={styles.emptyText}>Sua Seleção está Deserta</Text>
</Card>
</View>
);
@@ -49,7 +49,7 @@ export default function Cart() {
const handleCheckout = (shopId: string) => {
// Verificamos de forma segura pelo objeto user se o authState (sessão Supabase) existe
if (!user) {
Alert.alert('Login necessário', 'Faça login para finalizar o pedido');
Alert.alert('Sessão Necessária', 'Inicie sessão para confirmar o seu pedido');
navigation.navigate('Login' as never);
return;
}
@@ -63,7 +63,7 @@ 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}>Carrinho</Text>
<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]) => {
@@ -122,14 +122,14 @@ export default function Cart() {
{/* Renderização condicional no React para encaminhar fluxo para login se anónimo */}
{user ? (
<Button onPress={() => handleCheckout(shopId)} style={styles.checkoutButton}>
Finalizar pedido
Finalizar Aquisição
</Button>
) : (
<Button
onPress={() => navigation.navigate('Login' as never)}
style={styles.checkoutButton}
>
Entrar para finalizar
Entrar para Adquirir
</Button>
)}
</Card>

View File

@@ -136,12 +136,12 @@ export default function Dashboard() {
};
const tabs = [
{ id: 'overview', label: 'Visão Geral' },
{ id: 'appointments', label: 'Agendamentos' },
{ id: 'orders', label: 'Pedidos' },
{ id: 'overview', label: 'Estatísticas' },
{ id: 'appointments', label: 'Reservas' },
{ id: 'orders', label: 'Pedidos Boutique' },
{ id: 'services', label: 'Serviços' },
{ id: 'products', label: 'Produtos' },
{ id: 'barbers', label: 'Barbeiros' },
{ id: 'barbers', label: 'Equipa' },
];
return (
@@ -172,7 +172,7 @@ export default function Dashboard() {
<View>
<View style={styles.statsGrid}>
<Card style={styles.statCard}>
<Text style={styles.statLabel}>Faturamento</Text>
<Text style={styles.statLabel}>Receita Total</Text>
<Text style={styles.statValue}>{currency(totalRevenue)}</Text>
</Card>
<Card style={styles.statCard}>

View File

@@ -23,7 +23,7 @@ export default function Explore() {
return (
// Componente raiz do ecã de exploração
<View style={styles.container}>
<Text style={styles.title}>Explorar barbearias</Text>
<Text style={styles.title}>Barbearias</Text>
{/* FlatList é o componente nativo otimizado para renderizar grandes arrays de dados provenientes da BD */}
<FlatList
@@ -54,7 +54,7 @@ export default function Explore() {
variant="outline"
style={styles.button}
>
Ver detalhes
Ver Barbearia
</Button>
{/* Redirecionamento direto com foreign key injetada para a view de Agendamentos */}
@@ -62,7 +62,7 @@ export default function Explore() {
onPress={() => navigation.navigate('Booking', { shopId: shop.id })}
style={styles.button}
>
Agendar
Reservar
</Button>
</View>
</Card>

View File

@@ -19,51 +19,48 @@ export default function Landing() {
<View style={styles.hero}>
<Text style={styles.heroTitle}>Smart Agenda</Text>
<Text style={styles.heroSubtitle}>
Agendamentos, produtos e gestão em um único lugar.
Agendamento e Gestão de Barbearias.
</Text>
<Text style={styles.heroDesc}>
Experiência mobile-first para clientes e painel completo para barbearias.
A sua solução completa para o dia-a-dia da barbearia.
</Text>
<View style={styles.buttons}>
{/* Este fluxo permite utilizadores visitarem dados públicos da plataforma via Explore */}
<Button
onPress={() => navigation.navigate('Explore' as never)}
style={styles.button}
size="lg"
>
Explorar barbearias
Ver Barbearias
</Button>
{/* Botão focado no registo de novos utilizadores */}
<Button
onPress={() => navigation.navigate('Register' as never)}
variant="outline"
style={styles.button}
size="lg"
>
Criar conta
Criar Conta
</Button>
</View>
</View>
<View style={styles.features}>
{/* Componentes estáticos descritivos sobre as features que mapeiam para funcionalidades da BD */}
<Card style={styles.featureCard}>
<Text style={styles.featureTitle}>Agendamentos</Text>
<Text style={styles.featureTitle}>Reservas Rápidas</Text>
<Text style={styles.featureDesc}>
Escolha serviço, barbeiro, data e horário com validação de slots.
Selecione o seu barbeiro e o horário ideal em poucos segundos.
</Text>
</Card>
<Card style={styles.featureCard}>
<Text style={styles.featureTitle}>Carrinho</Text>
<Text style={styles.featureTitle}>Produtos</Text>
<Text style={styles.featureDesc}>
Produtos e serviços agrupados por barbearia, pagamento rápido.
Produtos de cuidado masculino selecionados para si.
</Text>
</Card>
<Card style={styles.featureCard}>
<Text style={styles.featureTitle}>Painel</Text>
<Text style={styles.featureTitle}>Gestão de Barbearia</Text>
<Text style={styles.featureDesc}>
Faturamento, agendamentos, pedidos, barbearia no controle.
Controlo total sobre o faturamento e operação da sua barbearia.
</Text>
</Card>
</View>

View File

@@ -13,8 +13,8 @@ import { Button } from '../components/ui/Button';
import { currency } from '../lib/format';
// Mapeamento visual estático das strings de estado do Postgres/State para cores da UI
const statusColor: Record<string, 'amber' | 'green' | 'slate' | 'red'> = {
pendente: 'amber',
const statusColor: Record<string, 'indigo' | 'green' | 'slate' | 'red'> = {
pendente: 'indigo',
confirmado: 'green',
concluido: 'green',
cancelado: 'red',
@@ -47,7 +47,7 @@ export default function Profile() {
<Text style={styles.profileEmail}>{user.email}</Text>
{/* Distanciamento visual e lógica dos tipos de perfil 'role' presentes na BD */}
<Badge color="amber" style={styles.roleBadge}>
<Badge color="indigo" style={styles.roleBadge}>
{user.role === 'cliente' ? 'Cliente' : 'Barbearia'}
</Badge>
@@ -57,7 +57,7 @@ export default function Profile() {
</Button>
</Card>
<Text style={styles.sectionTitle}>Agendamentos</Text>
<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) => {
@@ -83,7 +83,7 @@ export default function Profile() {
</Card>
)}
<Text style={styles.sectionTitle}>Pedidos</Text>
<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) => {

View File

@@ -51,7 +51,7 @@ export default function ShopDetails() {
onPress={() => navigation.navigate('Booking', { shopId: shop.id })}
style={styles.bookButton}
>
Agendar
Reservar Experiência
</Button>
</View>
@@ -61,13 +61,13 @@ export default function ShopDetails() {
style={[styles.tab, tab === 'servicos' && styles.tabActive]}
onPress={() => setTab('servicos')}
>
<Text style={[styles.tabText, tab === 'servicos' && styles.tabTextActive]}>Serviços</Text>
<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]}>Produtos</Text>
<Text style={[styles.tabText, tab === 'produtos' && styles.tabTextActive]}>Boutique</Text>
</TouchableOpacity>
</View>
@@ -89,7 +89,7 @@ export default function ShopDetails() {
size="sm"
style={styles.addButton}
>
Adicionar ao carrinho
Adicionar à Seleção
</Button>
</Card>
))}
@@ -106,7 +106,7 @@ export default function ShopDetails() {
<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="amber" style={styles.stockBadge}>Stock baixo</Badge>}
{product.stock <= 3 && <Badge color="indigo" style={styles.stockBadge}>Últimas unidades</Badge>}
{/* Botão em React é afetado logicamente face à impossibilidade material de encomenda */}
<Button
@@ -115,7 +115,7 @@ export default function ShopDetails() {
style={styles.addButton}
disabled={product.stock <= 0}
>
{product.stock > 0 ? 'Adicionar ao carrinho' : 'Sem stock'}
{product.stock > 0 ? 'Adicionar à Seleção' : 'Indisponível'}
</Button>
</Card>
))}
@@ -165,7 +165,7 @@ const styles = StyleSheet.create({
},
tabActive: {
borderColor: '#6366f1',
backgroundColor: '#fef3c7',
backgroundColor: '#e0e7ff',
},
tabText: {
fontSize: 14,

View File

@@ -4,9 +4,9 @@ export type Product = { id: string; name: string; price: number; stock: number }
export type BarberShop = { id: string; name: string; address: string; rating: number; services: Service[]; products: Product[]; barbers: Barber[] };
export type AppointmentStatus = 'pendente' | 'confirmado' | 'concluido' | 'cancelado';
export type OrderStatus = 'pendente' | 'confirmado' | 'concluido' | 'cancelado';
export type Appointment = { id: string; shopId: string; serviceId: string; barberId: string; customerId: string; date: string; status: AppointmentStatus; total: number };
export type Appointment = { id: string; shopId: string; serviceId: string; barberId: string; customerId: string; date: string; status: AppointmentStatus; total: number; reminderMinutes?: number };
export type CartItem = { shopId: string; type: 'service' | 'product'; refId: string; qty: number };
export type Order = { id: string; shopId: string; customerId: string; items: CartItem[]; total: number; status: OrderStatus; createdAt: string };
export type User = { id: string; name: string; email: string; password: string; role: 'cliente' | 'barbearia'; shopId?: string };
export type User = { id: string; name: string; email: string; password: string; role: 'cliente' | 'barbearia'; shopId?: string; fcmToken?: string };