feat: add event creation functionality and enhance profile and shop details pages

- Implemented event creation feature with form validation in EventsCreate component.
- Updated Profile page to include navigation to create events.
- Enhanced ShopDetails page with improved layout, additional tabs for services, barbers, products, and details.
- Added loading states and error handling for asynchronous operations.
- Refactored styles for better UI consistency and responsiveness.
- Updated types to include new properties for BarberShop and added EventRow type.
This commit is contained in:
2026-05-06 11:00:50 +01:00
parent f9e5183a20
commit a065130167
13 changed files with 1713 additions and 532 deletions

View File

@@ -7,14 +7,16 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { Appointment, Barber, BarberShop, CartItem, Order, Product, Service, User, WaitlistEntry, AppNotification } from '../types';
import { supabase } from '../lib/supabase';
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
type State = {
user?: User;
shops: BarberShop[];
shopsReady: boolean;
cart: CartItem[];
favorites: string[];
appointments: Appointment[];
orders: Order[];
waitlists: WaitlistEntry[];
@@ -22,12 +24,14 @@ type State = {
};
type AppContextValue = State & {
login: (email: string, password: string) => User | undefined;
logout: () => void;
register: (payload: any) => User | undefined;
login: (email: string, password: string) => Promise<User | undefined>;
logout: () => Promise<void>;
register: (payload: any) => Promise<User | undefined>;
toggleFavorite: (shopId: string) => void;
isFavorite: (shopId: string) => boolean;
addToCart: (item: CartItem) => void;
removeFromCart: (refId: string) => void;
placeOrder: (customerId: string, shopId: string) => Order | null;
placeOrder: (customerId: string, shopId: string) => Promise<Order | null>;
clearCart: () => void;
createAppointment: (input: Omit<Appointment, 'id' | 'status' | 'total'>) => Promise<Appointment | null>;
updateAppointmentStatus: (id: string, status: Appointment['status']) => Promise<void>;
@@ -53,37 +57,47 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [orders, setOrders] = useState<Order[]>([]);
const [cart, setCart] = useState<CartItem[]>([]);
const [favorites, setFavorites] = useState<string[]>([]);
const [waitlists, setWaitlists] = useState<WaitlistEntry[]>([]);
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const [user, setUser] = useState<User | undefined>(undefined);
const [shopsReady, setShopsReady] = useState(false);
const [loading, setLoading] = useState(true);
const applySupabaseUser = async (authUser: any): Promise<User | undefined> => {
if (!authUser) return undefined;
const { data: prof } = await supabase
.from('profiles')
.select('shop_id, role, name, fcm_token')
.eq('id', authUser.id)
.single();
const nextUser: User = {
id: authUser.id,
name: prof?.name || authUser.user_metadata?.name || authUser.email?.split('@')[0] || 'Utilizador',
email: authUser.email || '',
password: '',
role: (prof?.role as any) || authUser.user_metadata?.role || 'cliente',
shopId: prof?.shop_id || undefined,
fcmToken: prof?.fcm_token || undefined,
};
setUser(nextUser);
registerForPushNotificationsAsync().then((token) => {
if (token && token !== prof?.fcm_token) {
supabase.from('profiles').update({ fcm_token: token }).eq('id', authUser.id).then();
}
});
return nextUser;
};
useEffect(() => {
const loadUser = async () => {
const { data } = await supabase.auth.getUser();
if (data.user) {
const { data: prof } = await supabase
.from('profiles')
.select('shop_id, role, name, fcm_token')
.eq('id', data.user.id)
.single();
setUser({
id: data.user.id,
name: prof?.name || data.user.email?.split('@')[0] || 'Utilizador',
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();
}
});
}
if (data.user) await applySupabaseUser(data.user);
};
loadUser();
}, []);
@@ -102,18 +116,32 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
if (shopsData) {
const merged: BarberShop[] = shopsData.map((shop: any) => ({
...shop,
id: shop.id,
name: shop.name,
address: shop.address || '',
rating: shop.rating || 0,
imageUrl: shop.image_url || shop.imageUrl || undefined,
schedule: shop.schedule || undefined,
paymentMethods: shop.payment_methods || undefined,
socialNetworks: shop.social_networks || undefined,
contacts: shop.contacts || undefined,
services: (servicesData || []).filter((s: any) => s.shop_id === shop.id).map((s: any) => ({
id: s.id,
name: s.name,
price: s.price,
duration: s.duration,
price: s.price || 0,
duration: s.duration || 30,
barberIds: s.barber_ids || [],
})),
products: (productsData || []).filter((p: any) => p.shop_id === shop.id),
products: (productsData || []).filter((p: any) => p.shop_id === shop.id).map((p: any) => ({
id: p.id,
name: p.name,
price: p.price || 0,
stock: p.stock || 0,
})),
barbers: (barbersData || []).filter((b: any) => b.shop_id === shop.id).map((b: any) => ({
id: b.id,
name: b.name,
imageUrl: b.image_url || undefined,
specialties: b.specialties || [],
schedule: b.schedule || [],
})),
@@ -176,8 +204,10 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
}))
);
}
setShopsReady(true);
} catch (err) {
console.error('Error refreshing shops:', err);
setShopsReady(true);
}
};
@@ -197,18 +227,16 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
init();
}, []);
const login = (email: string, password: string) => {
// 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 login = async (email: string, password: string) => {
const { data, error } = await supabase.auth.signInWithPassword({ email, password });
if (error || !data.user) {
console.error('Erro no login:', error);
return undefined;
}
const nextUser = await applySupabaseUser(data.user);
await refreshShops();
return nextUser;
};
const logout = async () => {
@@ -216,18 +244,43 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
setUser(undefined);
};
const register = (payload: any) => {
const id = Math.random().toString(36).substring(2, 15);
const newUser: User = { ...payload, id };
setUser(newUser);
return newUser;
const register = async (payload: any) => {
const { name, email, password, role, shopName } = payload;
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
name,
role,
shopName: role === 'barbearia' ? shopName : null,
shop_name: role === 'barbearia' ? shopName : null,
},
},
});
if (error) throw error;
if (!data.user) throw new Error('Erro ao criar conta');
if (!data.session) return undefined;
const nextUser = await applySupabaseUser(data.user);
await refreshShops();
return nextUser;
};
const toggleFavorite = (shopId: string) => {
setFavorites((prev) =>
prev.includes(shopId) ? prev.filter((id) => id !== shopId) : [...prev, shopId]
);
};
const isFavorite = (shopId: string) => favorites.includes(shopId);
const removeFromCart = (refId: string) => {
setCart((prev) => prev.filter((i) => i.refId !== refId));
};
const placeOrder = (customerId: string, shopId: string) => {
const placeOrder = async (customerId: string, shopId: string) => {
const shopItems = cart.filter((i) => i.shopId === shopId);
if (!shopItems.length) return null;
@@ -240,23 +293,42 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
return sum + price * i.qty;
}, 0);
const newOrder: Order = {
id: Math.random().toString(36).substring(2, 15),
shopId,
customerId,
const { data, error } = await supabase.from('orders').insert([{
shop_id: shopId,
customer_id: customerId,
items: shopItems,
total,
status: 'pendente',
createdAt: new Date().toISOString(),
}]).select().single();
if (error || !data) {
console.error('Erro ao criar pedido:', error);
return null;
}
const newOrder: Order = {
id: data.id,
shopId: data.shop_id,
customerId: data.customer_id,
items: data.items,
total: data.total,
status: data.status as Order['status'],
createdAt: data.created_at,
};
setOrders((prev) => [...prev, newOrder]);
await refreshShops();
setCart((prev) => prev.filter((i) => i.shopId !== shopId));
return newOrder;
};
const addToCart = (item: CartItem) => {
setCart((prev: CartItem[]) => [...prev, item]);
setCart((prev: CartItem[]) => {
const next = [...prev];
const existing = next.find((i) => i.shopId === item.shopId && i.type === item.type && i.refId === item.refId);
if (existing) existing.qty += item.qty;
else next.push(item);
return next;
});
};
const clearCart = () => setCart([]);
@@ -294,13 +366,23 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
};
const addBarber = async (shopId: string, barber: Omit<Barber, 'id'>) => {
await supabase.from('barbers').insert([{ shop_id: shopId, ...barber }]);
await supabase.from('barbers').insert([{
shop_id: shopId,
name: barber.name,
specialties: barber.specialties,
schedule: barber.schedule,
image_url: barber.imageUrl,
}]);
await refreshShops();
};
const updateBarber = async (shopId: string, barber: Barber) => {
const { id, ...data } = barber;
await supabase.from('barbers').update(data).eq('id', id);
await supabase.from('barbers').update({
name: barber.name,
specialties: barber.specialties,
schedule: barber.schedule,
image_url: barber.imageUrl,
}).eq('id', barber.id);
await refreshShops();
};
@@ -383,7 +465,9 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
() => ({
user,
shops,
shopsReady,
cart,
favorites,
appointments,
orders,
waitlists,
@@ -391,6 +475,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
login,
logout,
register,
toggleFavorite,
isFavorite,
addToCart,
removeFromCart,
placeOrder,
@@ -411,7 +497,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
markNotificationRead,
refreshShops,
}),
[user, shops, cart, appointments, orders, waitlists, notifications]
[user, shops, shopsReady, cart, favorites, appointments, orders, waitlists, notifications]
);
if (loading) return null;
@@ -427,6 +513,13 @@ export const useApp = () => {
async function registerForPushNotificationsAsync() {
let token;
if (Platform.OS === 'android' && Constants.appOwnership === 'expo') {
console.log('Push remoto Android requer development build; ignorado no Expo Go.');
return;
}
const Notifications = await import('expo-notifications');
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
@@ -455,4 +548,4 @@ async function registerForPushNotificationsAsync() {
}
return token;
}
}

31
src/lib/events.ts Normal file
View File

@@ -0,0 +1,31 @@
import { supabase } from './supabase';
export type EventRow = {
id: number;
title: string;
description: string | null;
date: string;
user_id: string;
};
export async function createEvent(input: {
title: string;
description?: string;
date: string;
}) {
const { data: auth } = await supabase.auth.getUser();
if (!auth.user) throw new Error('Utilizador não autenticado');
const { data, error } = await supabase
.from('events')
.insert({
title: input.title,
description: input.description ?? null,
date: input.date,
})
.select('id, title, description, date, user_id')
.single();
if (error) throw error;
return data as EventRow;
}

View File

@@ -11,6 +11,7 @@ import Booking from '../pages/Booking';
import Cart from '../pages/Cart';
import Profile from '../pages/Profile';
import Dashboard from '../pages/Dashboard';
import EventsCreate from '../pages/EventsCreate';
import { RootStackParamList } from './types';
const Stack = createNativeStackNavigator<RootStackParamList>();
@@ -34,13 +35,12 @@ export default function AppNavigator() {
<Stack.Screen name="Register" component={AuthRegister} options={{ title: 'Criar Conta' }} />
<Stack.Screen name="Explore" component={Explore} options={{ title: 'Explorar' }} />
<Stack.Screen name="ShopDetails" component={ShopDetails} options={{ title: 'Detalhes' }} />
<Stack.Screen name="Booking" component={Booking} options={{ title: 'Agendar' }} />
<Stack.Screen name="Cart" component={Cart} options={{ title: 'Carrinho' }} />
</>
) : user.role === 'barbearia' ? (
<>
<Stack.Screen name="Dashboard" component={Dashboard} options={{ title: 'Painel', headerShown: false }} />
<Stack.Screen name="Profile" component={Profile} options={{ title: 'Perfil' }} />
<Stack.Screen name="EventsCreate" component={EventsCreate} options={{ title: 'Criar evento' }} />
</>
) : (
<>
@@ -49,6 +49,7 @@ export default function AppNavigator() {
<Stack.Screen name="Booking" component={Booking} options={{ title: 'Agendar' }} />
<Stack.Screen name="Cart" component={Cart} options={{ title: 'Carrinho' }} />
<Stack.Screen name="Profile" component={Profile} options={{ title: 'Perfil' }} />
<Stack.Screen name="EventsCreate" component={EventsCreate} options={{ title: 'Criar evento' }} />
</>
)}
</Stack.Navigator>
@@ -56,4 +57,3 @@ export default function AppNavigator() {
);
}

View File

@@ -8,6 +8,7 @@ export type RootStackParamList = {
Cart: undefined;
Profile: undefined;
Dashboard: undefined;
EventsCreate: undefined;
};
declare global {
@@ -16,4 +17,3 @@ declare global {
}
}

View File

@@ -1,61 +1,60 @@
/**
* @file AuthLogin.tsx
* @description Página de autenticação da aplicação. Permite o login de utilizadores
* (clientes ou barbearias) utilizando o contexto global que faz a interface com a base de dados (Supabase).
*/
import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, Alert } from 'react-native';
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } 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';
import { Button } from '../components/ui/Button';
import { Input } from '../components/ui/Input';
import { Card } from '../components/ui/Card';
import { RootStackParamList } from '../navigation/types';
export default function AuthLogin() {
const navigation = useNavigation();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { login } = useApp();
const [email, setEmail] = useState('cliente@demo.com');
const [password, setPassword] = useState('123');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
/**
* Lida com a submissão do formulário de login.
* Valida as credenciais do utilizador através do contexto (AppContext).
* O contexto encapsula a chamada ao Supabase (ex: `supabase.auth.signInWithPassword`) e devolve um objeto tipado do utilizador.
* Dependendo do 'role' retornado, reencaminha para o Dashboard (barbearia) ou Explore (cliente).
*/
const handleSubmit = () => {
const handleSubmit = async () => {
setError('');
// O retorno 'user' respeita a nossa tipagem/interfaces baseadas nas tabelas da base de dados.
const user = login(email, password);
if (!user) {
setError('Credenciais inválidas');
Alert.alert('Erro', 'Credenciais inválidas');
} else {
// Redirecionamento condicional com base no papel (identificando a entidade via BD).
const target = user.role === 'barbearia' ? 'Dashboard' : 'Explore';
navigation.navigate(target as never);
if (!email.trim() || !password.trim()) {
setError('Preenche email e palavra-passe');
return;
}
try {
setLoading(true);
const nextUser = await login(email.trim(), password);
if (!nextUser) {
setError('Credenciais inválidas ou email não confirmado');
return;
}
navigation.replace(nextUser.role === 'barbearia' ? 'Dashboard' : 'Explore');
} catch (e: any) {
const message = e?.message || 'Credenciais inválidas ou email não confirmado';
setError(message);
Alert.alert('Erro', message);
} finally {
setLoading(false);
}
};
return (
// Componente estrutural que permite "scroll" vertical do conteúdo na vista
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
{/* O componente Card encapsula de forma visual os inputs de login */}
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
<Card style={styles.card}>
<TouchableOpacity style={styles.iconBox} onPress={() => navigation.navigate('Landing')}>
<Text style={styles.iconText}>SA</Text>
</TouchableOpacity>
<Text style={styles.title}>Bem-vindo</Text>
<Text style={styles.subtitle}>Aceda à sua conta</Text>
<Text style={styles.subtitle}>Aceda à sua conta Smart Agenda</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>
{!!error && <Text style={styles.error}>{error}</Text>}
{/* Input controlado pelo estadoReact "email"; atualiza à medida que se digita */}
<Input
label="Email"
value={email}
@@ -65,12 +64,11 @@ export default function AuthLogin() {
}}
keyboardType="email-address"
autoCapitalize="none"
placeholder="seu@email.com"
placeholder="exemplo@email.com"
/>
{/* Input controlado para a password; secureTextEntry oculta os caracteres */}
<Input
label="Senha"
label="Palavra-passe"
value={password}
onChangeText={(text) => {
setPassword(text);
@@ -78,22 +76,16 @@ export default function AuthLogin() {
}}
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 onPress={handleSubmit} style={styles.submitButton} loading={loading}>
Entrar na Conta
</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 style={styles.footerText}>Ainda não tem conta? </Text>
<Text style={styles.footerLink} onPress={() => navigation.navigate('Register')}>
Criar conta grátis
</Text>
</View>
</Card>
@@ -115,43 +107,53 @@ const styles = StyleSheet.create({
card: {
padding: 24,
},
iconBox: {
width: 64,
height: 64,
borderRadius: 18,
backgroundColor: '#0f172a',
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
marginBottom: 18,
},
iconText: {
color: '#818cf8',
fontSize: 18,
fontWeight: '900',
},
title: {
fontSize: 24,
fontWeight: 'bold',
fontSize: 30,
fontWeight: '900',
color: '#0f172a',
marginBottom: 8,
textAlign: 'center',
marginBottom: 6,
},
subtitle: {
fontSize: 14,
color: '#64748b',
marginBottom: 24,
textAlign: 'center',
fontWeight: '500',
},
demoBox: {
backgroundColor: '#e0e7ff',
error: {
backgroundColor: '#fff1f2',
borderColor: '#fecdd3',
borderWidth: 1,
borderColor: '#6366f1',
borderRadius: 8,
borderRadius: 14,
color: '#be123c',
padding: 12,
marginBottom: 20,
},
demoTitle: {
fontSize: 12,
marginBottom: 16,
fontWeight: '600',
color: '#4338ca',
marginBottom: 4,
},
demoText: {
fontSize: 11,
color: '#4338ca',
},
submitButton: {
width: '100%',
marginTop: 8,
backgroundColor: '#0f172a',
},
footer: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
marginTop: 24,
paddingTop: 24,
@@ -164,8 +166,7 @@ const styles = StyleSheet.create({
},
footerLink: {
fontSize: 14,
color: '#6366f1',
fontWeight: '600',
color: '#4f46e5',
fontWeight: '800',
},
});

View File

@@ -1,86 +1,89 @@
/**
* @file AuthRegister.tsx
* @description Página de registo da aplicação. Permite criar novas contas para clientes
* ou barbearias comunicando com o contexto de autenticação global e a base de dados.
*/
import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, Alert, TouchableOpacity } from 'react-native';
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } 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';
import { Button } from '../components/ui/Button';
import { Input } from '../components/ui/Input';
import { Card } from '../components/ui/Card';
import { RootStackParamList } from '../navigation/types';
export default function AuthRegister() {
const navigation = useNavigation();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { register } = useApp();
// Estados locais para controlar os campos de introdução de dados
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
// O tipo de utilizador é estritamente tipado (cliente ou barbearia) para garantir a integridade dos dados
const [role, setRole] = useState<'cliente' | 'barbearia'>('cliente');
const [shopName, setShopName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
/**
* Valida e submete o formulário de registo.
* Chama a função `register` do AppContext, que por sua vez utiliza a lógica de base de dados
* (ex: `supabase.auth.signUp`) e insere o novo registo (ex: `supabase.from('profiles').insert(...)`).
*/
const handleSubmit = () => {
const handleSubmit = async () => {
setError('');
// Criamos o objeto de registo de acordo com os tipos/interfaces da base de dados e API
const user = register({ name, email, password, role, shopName });
if (!user) {
setError('Email já registado');
Alert.alert('Erro', 'Email já registado');
} else {
// Redireciona para um fluxo distinto consoante o tipo ('role') do novo utilizador criado
const target = user.role === 'barbearia' ? 'Dashboard' : 'Explore';
navigation.navigate(target as never);
if (!name.trim()) return setError('Preencha o nome completo');
if (!email.trim()) return setError('Preencha o email');
if (!password.trim()) return setError('Preencha a palavra-passe');
if (role === 'barbearia' && !shopName.trim()) return setError('Informe o nome da barbearia');
try {
setLoading(true);
const nextUser = await register({
name: name.trim(),
email: email.trim(),
password,
role,
shopName: shopName.trim(),
});
if (!nextUser) {
Alert.alert('Conta criada', 'Confirma o email antes de fazer login.');
navigation.replace('Login');
return;
}
navigation.replace(nextUser.role === 'barbearia' ? 'Dashboard' : 'Explore');
} catch (e: any) {
const message = e?.message || 'Erro ao criar conta';
setError(message);
Alert.alert('Erro', message);
} finally {
setLoading(false);
}
};
return (
// Componente estrutural que assegura scroll de conteúdo para ecrãs menores
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
{/* O Card agrupa a interface de registo */}
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
<Card style={styles.card}>
<Text style={styles.title}>Criar conta</Text>
<Text style={styles.subtitle}>Escolha o tipo de acesso</Text>
<View style={styles.iconBox}>
<Text style={styles.iconText}>SA</Text>
</View>
<Text style={styles.title}>Criar Conta</Text>
<Text style={styles.subtitle}>Junte-se à Smart Agenda</Text>
{/* Grupo de botões de seleção de papel. O CSS ativo altera com base no estado `role`. */}
{!!error && <Text style={styles.error}>{error}</Text>}
<Text style={styles.label}>Eu sou...</Text>
<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>
{(['cliente', 'barbearia'] as const).map((item) => (
<TouchableOpacity
key={item}
style={[styles.roleButton, role === item && styles.roleButtonActive]}
onPress={() => {
setRole(item);
setError('');
}}
>
<Text style={[styles.roleText, role === item && styles.roleTextActive]}>
{item === 'cliente' ? 'Cliente' : '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="Nome completo" value={name} onChangeText={setName} placeholder="Ex: João Silva" />
<Input
label="Email"
value={email}
@@ -90,43 +93,27 @@ export default function AuthRegister() {
}}
keyboardType="email-address"
autoCapitalize="none"
placeholder="seu@email.com"
error={error}
placeholder="exemplo@email.com"
/>
<Input label="Palavra-passe" value={password} onChangeText={setPassword} secureTextEntry placeholder="••••••••" />
{/* 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"
placeholder="Ex: Barbearia Estilo"
/>
)}
{/* 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 onPress={handleSubmit} style={styles.submitButton} loading={loading}>
Criar minha conta
</Button>
{/* Seção rodapé simples com reencaminhamento de navegação para a página de Login */}
<View style={styles.footer}>
<Text style={styles.footerText}> tem conta? </Text>
<Text
style={styles.footerLink}
onPress={() => navigation.navigate('Login' as never)}
>
Entrar
<Text style={styles.footerText}> tem uma conta? </Text>
<Text style={styles.footerLink} onPress={() => navigation.navigate('Login')}>
Fazer Login
</Text>
</View>
</Card>
@@ -142,54 +129,93 @@ const styles = StyleSheet.create({
},
content: {
padding: 16,
justifyContent: 'center',
minHeight: '100%',
},
card: {
padding: 24,
},
iconBox: {
width: 64,
height: 64,
borderRadius: 18,
backgroundColor: '#0f172a',
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
marginBottom: 18,
},
iconText: {
color: '#818cf8',
fontSize: 18,
fontWeight: '900',
},
title: {
fontSize: 24,
fontWeight: 'bold',
fontSize: 30,
fontWeight: '900',
color: '#0f172a',
marginBottom: 8,
textAlign: 'center',
marginBottom: 6,
},
subtitle: {
fontSize: 14,
color: '#64748b',
marginBottom: 24,
textAlign: 'center',
fontWeight: '500',
},
error: {
backgroundColor: '#fff1f2',
borderColor: '#fecdd3',
borderWidth: 1,
borderRadius: 14,
color: '#be123c',
padding: 12,
marginBottom: 16,
fontWeight: '600',
},
label: {
fontSize: 12,
color: '#0f172a',
fontWeight: '900',
textTransform: 'uppercase',
marginBottom: 10,
},
roleContainer: {
flexDirection: 'row',
gap: 12,
marginBottom: 20,
marginBottom: 18,
},
roleButton: {
flex: 1,
padding: 16,
borderRadius: 12,
borderRadius: 16,
borderWidth: 2,
borderColor: '#e2e8f0',
borderColor: '#f1f5f9',
backgroundColor: '#f8fafc',
alignItems: 'center',
},
roleButtonActive: {
borderColor: '#6366f1',
backgroundColor: '#c7d2fe',
borderColor: '#0f172a',
backgroundColor: '#0f172a',
},
roleText: {
fontSize: 14,
fontWeight: '600',
fontSize: 13,
fontWeight: '900',
color: '#64748b',
textTransform: 'uppercase',
},
roleTextActive: {
color: '#6366f1',
color: '#818cf8',
},
submitButton: {
width: '100%',
marginTop: 8,
backgroundColor: '#0f172a',
},
footer: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
marginTop: 24,
paddingTop: 24,
@@ -202,8 +228,7 @@ const styles = StyleSheet.create({
},
footerLink: {
fontSize: 14,
color: '#6366f1',
fontWeight: '600',
color: '#4f46e5',
fontWeight: '800',
},
});

View File

@@ -42,7 +42,7 @@ export default function Cart() {
* o agrupo de serviços selecionados e criar uma tupla na tabela de Pedidos/Marcações da db.
* @param {string} shopId - ID da loja que receberá o pedido final.
*/
const handleCheckout = (shopId: string) => {
const handleCheckout = async (shopId: string) => {
// Verificamos de forma segura pelo objeto user se o authState (sessão Supabase) existe
if (!user) {
Alert.alert('Sessão Necessária', 'Inicie sessão para confirmar o seu pedido');
@@ -50,9 +50,11 @@ export default function Cart() {
return;
}
// Gera a inserção na API (insert em tabelas de orders / ordem e dependentes)
const order = placeOrder(user.id, shopId);
const order = await placeOrder(user.id, shopId);
if (order) {
Alert.alert('Sucesso', 'Pedido criado com sucesso!');
} else {
Alert.alert('Erro', 'Não foi possível criar o pedido.');
}
};
@@ -200,4 +202,3 @@ const styles = StyleSheet.create({
},
});

160
src/pages/EventsCreate.tsx Normal file
View File

@@ -0,0 +1,160 @@
import React, { useState } from 'react';
import { Alert, ScrollView, StyleSheet, Text, View } 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 { Card } from '../components/ui/Card';
import { Input } from '../components/ui/Input';
import { Button } from '../components/ui/Button';
import { createEvent } from '../lib/events';
import { RootStackParamList } from '../navigation/types';
export default function EventsCreate() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [date, setDate] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const submit = async () => {
setError('');
if (!title.trim()) return setError('Preenche o título');
if (!date.trim()) return setError('Escolhe a data e hora');
const parsed = new Date(date.trim());
if (Number.isNaN(parsed.getTime())) {
return setError('Usa uma data válida. Ex: 2026-05-10 18:30');
}
try {
setSaving(true);
await createEvent({
title: title.trim(),
description: description.trim() || undefined,
date: parsed.toISOString(),
});
Alert.alert('Evento criado', 'O evento foi guardado no teu perfil.');
navigation.navigate('Profile');
} catch (e: any) {
setError(e?.message || 'Erro ao criar evento');
} finally {
setSaving(false);
}
};
return (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
<Card style={styles.card}>
<View style={styles.iconBox}>
<Text style={styles.iconText}>+</Text>
</View>
<Text style={styles.title}>Criar evento</Text>
<Text style={styles.subtitle}>Este evento vai aparecer no teu perfil</Text>
{!!error && <Text style={styles.error}>{error}</Text>}
<Input
label="Título"
value={title}
onChangeText={(text) => {
setTitle(text);
setError('');
}}
placeholder="Ex: Corte às 18h"
/>
<Input
label="Descrição (opcional)"
value={description}
onChangeText={(text) => {
setDescription(text);
setError('');
}}
placeholder="Notas do evento..."
/>
<Input
label="Data e hora"
value={date}
onChangeText={(text) => {
setDate(text);
setError('');
}}
placeholder="2026-05-10 18:30"
/>
<View style={styles.actions}>
<Button variant="outline" style={styles.actionButton} onPress={() => navigation.navigate('Profile')}>
Cancelar
</Button>
<Button style={styles.actionButton} onPress={submit} loading={saving}>
Criar evento
</Button>
</View>
</Card>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
content: {
padding: 16,
justifyContent: 'center',
minHeight: '100%',
},
card: {
padding: 24,
},
iconBox: {
width: 56,
height: 56,
borderRadius: 16,
backgroundColor: '#0f172a',
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'center',
marginBottom: 16,
},
iconText: {
color: '#818cf8',
fontSize: 32,
fontWeight: '900',
lineHeight: 36,
},
title: {
fontSize: 28,
fontWeight: '900',
color: '#0f172a',
textAlign: 'center',
},
subtitle: {
fontSize: 14,
color: '#64748b',
textAlign: 'center',
marginTop: 6,
marginBottom: 24,
},
error: {
backgroundColor: '#fff1f2',
borderColor: '#fecdd3',
borderWidth: 1,
borderRadius: 14,
color: '#be123c',
padding: 12,
marginBottom: 16,
fontWeight: '600',
},
actions: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
actionButton: {
flex: 1,
},
});

View File

@@ -1,74 +1,142 @@
/**
* @file Explore.tsx
* @description Página de exploração. Apresenta uma listagem geral de barbearias
* disponíveis na plataforma onde os clientes podem visualizar detalhes e
* iniciar o fluxo de agendamento (consome dados das Lojas da BD).
*/
import React from 'react';
import { View, Text, StyleSheet, ScrollView, FlatList } from 'react-native';
import React, { useMemo, useState } from 'react';
import { Image, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } 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';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { Badge } from '../components/ui/Badge';
import { currency } from '../lib/format';
import { RootStackParamList } from '../navigation/types';
export default function Explore() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
// Obtém a lista completa de barbearias carregadas no estado global (equivalente a "SELECT * FROM shops")
const { shops } = useApp();
const { shops, shopsReady, cart, user, logout } = useApp();
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<'todas' | 'top' | 'servicos'>('todas');
const filtered = useMemo(() => {
const normalized = query.trim().toLowerCase();
return [...shops]
.filter((shop) => {
if (!normalized) return true;
return shop.name.toLowerCase().includes(normalized) || (shop.address || '').toLowerCase().includes(normalized);
})
.filter((shop) => (filter === 'top' ? (shop.rating || 0) >= 4.7 : true))
.sort((a, b) => {
if (filter === 'servicos') return (b.services || []).length - (a.services || []).length;
return (b.rating || 0) - (a.rating || 0);
});
}, [shops, query, filter]);
const goToProfile = () => {
if (!user) navigation.navigate('Login');
else navigation.navigate(user.role === 'barbearia' ? 'Dashboard' : 'Profile');
};
return (
// Componente raiz do ecã de exploração
<SafeAreaView style={styles.container}>
<Text style={styles.title}>Barbearias</Text>
{/* FlatList é o componente nativo otimizado para renderizar grandes arrays de dados provenientes da BD */}
<FlatList
data={shops}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
renderItem={({ item: shop }) => (
// Cada Card rege a UI de uma barbearia com dados mapeados reais/mock
<Card style={styles.shopCard}>
<View style={styles.shopHeader}>
<Text style={styles.shopName}>{shop.name}</Text>
<Badge color="indigo">{shop.rating.toFixed(1)} </Badge>
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
<View style={styles.topBar}>
<TouchableOpacity onPress={() => navigation.navigate(user ? 'Explore' : 'Landing')} style={styles.brand}>
<View style={styles.brandIcon}>
<Text style={styles.brandIconText}>SA</Text>
</View>
<Text style={styles.shopAddress}>{shop.address}</Text>
<Text style={styles.brandText}>Smart Agenda</Text>
</TouchableOpacity>
<View style={styles.topActions}>
{user?.role !== 'barbearia' && (
<TouchableOpacity style={styles.cartButton} onPress={() => (user ? navigation.navigate('Cart') : navigation.navigate('Login'))}>
<Text style={styles.cartText}>Carrinho</Text>
{cart.length > 0 && <Text style={styles.cartBadge}>{cart.length}</Text>}
</TouchableOpacity>
)}
<TouchableOpacity style={styles.profileButton} onPress={goToProfile}>
<Text style={styles.profileText}>{user ? user.name.charAt(0).toUpperCase() : 'Entrar'}</Text>
</TouchableOpacity>
</View>
</View>
<View style={styles.shopInfo}>
{/* O atributo length do array simula as rows de relacionamentos 1-para-N (serviços) */}
<Text style={styles.shopInfoText}>{shop.services.length} serviços</Text>
<Text style={styles.shopInfoText}></Text>
{/* Mapeamento de dimensão da relation 'barbers' */}
<Text style={styles.shopInfoText}>{shop.barbers.length} barbeiros</Text>
</View>
<View style={styles.header}>
<View style={styles.kicker}>
<Text style={styles.kickerText}>As nossas barbearias</Text>
</View>
<Text style={styles.title}>Ver <Text style={styles.titleAccent}>Barbearias</Text></Text>
<Text style={styles.subtitle}>Descubra barbearias exclusivas e reserve o seu próximo corte em segundos.</Text>
{user && (
<TouchableOpacity onPress={logout} style={styles.logoutButton}>
<Text style={styles.logoutText}>Sair da conta</Text>
</TouchableOpacity>
)}
</View>
<View style={styles.buttons}>
{/* Botão direciona para as informações, passando o ID primário (shop.id) pelo React Navigation */}
<Button
onPress={() => navigation.navigate('ShopDetails', { shopId: shop.id })}
variant="outline"
style={styles.button}
<Card style={styles.searchCard}>
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Pesquisar por nome ou endereço..."
placeholderTextColor="#94a3b8"
style={styles.searchInput}
/>
<View style={styles.filters}>
{[
['todas', 'Melhor avaliação'],
['top', 'Top avaliadas'],
['servicos', 'Mais serviços'],
].map(([id, label]) => (
<TouchableOpacity
key={id}
onPress={() => setFilter(id as typeof filter)}
style={[styles.filterChip, filter === id && styles.filterChipActive]}
>
Ver Barbearia
</Button>
<Text style={[styles.filterText, filter === id && styles.filterTextActive]}>{label}</Text>
</TouchableOpacity>
))}
</View>
</Card>
{/* Redirecionamento direto com foreign key injetada para a view de Agendamentos */}
<Button
onPress={() => navigation.navigate('Booking', { shopId: shop.id })}
style={styles.button}
>
Reservar
</Button>
</View>
{!shopsReady ? (
<Card style={styles.emptyCard}>
<Text style={styles.emptyTitle}>A carregar espaços...</Text>
</Card>
) : filtered.length === 0 ? (
<Card style={styles.emptyCard}>
<Text style={styles.emptyTitle}>Nenhuma barbearia encontrada</Text>
<Text style={styles.emptyText}>Tente ajustar a pesquisa ou os filtros ativos.</Text>
<Button variant="ghost" onPress={() => { setQuery(''); setFilter('todas'); }}>
Limpar Tudo
</Button>
</Card>
) : (
<View style={styles.list}>
<Text style={styles.count}>{filtered.length} espaços disponíveis</Text>
{filtered.map((shop) => (
<Card key={shop.id} style={styles.shopCard}>
{shop.imageUrl ? (
<Image source={{ uri: shop.imageUrl }} style={styles.shopImage} />
) : (
<View style={styles.shopImageFallback}>
<Text style={styles.shopImageFallbackText}>SA</Text>
</View>
)}
<View style={styles.shopBody}>
<View style={styles.shopRow}>
<Text style={styles.shopName} numberOfLines={1}>{shop.name}</Text>
<Text style={styles.rating}>{(shop.rating || 0).toFixed(1)}</Text>
</View>
<Text style={styles.address} numberOfLines={1}>{shop.address || 'Endereço indisponível'}</Text>
<View style={styles.metaRow}>
<Text style={styles.metaText}>{(shop.services || []).length} serviços</Text>
<Text style={styles.metaText}>{(shop.barbers || []).length} barbeiros</Text>
</View>
<Button style={styles.reserveButton} onPress={() => navigation.navigate('ShopDetails', { shopId: shop.id })}>
Reservar
</Button>
</View>
</Card>
))}
</View>
)}
/>
</ScrollView>
</SafeAreaView>
);
}
@@ -77,53 +145,247 @@ const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
content: {
padding: 16,
gap: 18,
},
topBar: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
},
brand: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
flex: 1,
},
brandIcon: {
width: 34,
height: 34,
borderRadius: 10,
backgroundColor: '#4f46e5',
alignItems: 'center',
justifyContent: 'center',
},
brandIconText: {
color: '#fff',
fontSize: 12,
fontWeight: '900',
},
brandText: {
color: '#0f172a',
fontSize: 18,
fontWeight: '900',
textTransform: 'uppercase',
},
topActions: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
cartButton: {
minHeight: 36,
borderRadius: 12,
backgroundColor: '#f1f5f9',
paddingHorizontal: 10,
alignItems: 'center',
justifyContent: 'center',
},
cartText: {
color: '#334155',
fontSize: 11,
fontWeight: '900',
},
cartBadge: {
position: 'absolute',
right: -5,
top: -5,
minWidth: 18,
textAlign: 'center',
borderRadius: 999,
backgroundColor: '#0f172a',
color: '#818cf8',
fontSize: 10,
fontWeight: '900',
paddingHorizontal: 4,
},
profileButton: {
minWidth: 38,
height: 38,
borderRadius: 999,
backgroundColor: '#0f172a',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 10,
},
profileText: {
color: '#818cf8',
fontSize: 12,
fontWeight: '900',
},
header: {
gap: 8,
},
kicker: {
alignSelf: 'flex-start',
backgroundColor: '#e0e7ff',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 5,
},
kickerText: {
color: '#4338ca',
fontSize: 10,
fontWeight: '900',
textTransform: 'uppercase',
},
title: {
fontSize: 24,
fontWeight: 'bold',
fontSize: 36,
lineHeight: 38,
fontWeight: '900',
color: '#0f172a',
marginBottom: 16,
textTransform: 'uppercase',
},
titleAccent: {
color: '#4f46e5',
},
subtitle: {
color: '#64748b',
fontSize: 15,
lineHeight: 22,
fontWeight: '500',
},
logoutButton: {
alignSelf: 'flex-start',
paddingVertical: 6,
},
logoutText: {
color: '#e11d48',
fontWeight: '800',
},
searchCard: {
padding: 12,
gap: 12,
},
searchInput: {
minHeight: 48,
borderRadius: 16,
backgroundColor: '#f8fafc',
color: '#0f172a',
paddingHorizontal: 14,
fontSize: 15,
fontWeight: '600',
},
filters: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
filterChip: {
borderRadius: 999,
backgroundColor: '#f1f5f9',
paddingHorizontal: 12,
paddingVertical: 8,
},
filterChipActive: {
backgroundColor: '#0f172a',
},
filterText: {
color: '#475569',
fontSize: 11,
fontWeight: '900',
},
filterTextActive: {
color: '#818cf8',
},
list: {
gap: 16,
gap: 12,
},
count: {
color: '#64748b',
fontSize: 12,
fontWeight: '900',
textTransform: 'uppercase',
},
shopCard: {
marginBottom: 12,
padding: 0,
overflow: 'hidden',
},
shopHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
shopImage: {
width: '100%',
height: 150,
},
shopImageFallback: {
width: '100%',
height: 150,
backgroundColor: '#0f172a',
alignItems: 'center',
marginBottom: 8,
justifyContent: 'center',
},
shopImageFallbackText: {
color: '#818cf8',
fontSize: 24,
fontWeight: '900',
},
shopBody: {
padding: 16,
gap: 10,
},
shopRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
},
shopName: {
fontSize: 18,
fontWeight: 'bold',
flex: 1,
fontSize: 20,
fontWeight: '900',
color: '#0f172a',
flex: 1,
},
shopAddress: {
fontSize: 14,
color: '#64748b',
marginBottom: 8,
},
shopInfo: {
flexDirection: 'row',
gap: 8,
marginBottom: 12,
},
shopInfoText: {
rating: {
color: '#fff',
backgroundColor: '#0f172a',
borderRadius: 999,
paddingHorizontal: 9,
paddingVertical: 4,
fontSize: 12,
color: '#94a3b8',
fontWeight: '900',
},
buttons: {
address: {
color: '#64748b',
fontWeight: '500',
},
metaRow: {
flexDirection: 'row',
gap: 8,
marginTop: 8,
gap: 10,
},
button: {
flex: 1,
metaText: {
color: '#94a3b8',
fontSize: 11,
fontWeight: '900',
textTransform: 'uppercase',
},
reserveButton: {
backgroundColor: '#0f172a',
marginTop: 4,
},
emptyCard: {
padding: 28,
alignItems: 'center',
gap: 10,
},
emptyTitle: {
color: '#0f172a',
fontSize: 18,
fontWeight: '900',
textAlign: 'center',
},
emptyText: {
color: '#64748b',
textAlign: 'center',
fontWeight: '500',
},
});

View File

@@ -1,69 +1,114 @@
/**
* @file Landing.tsx
* @description Página principal introdutória (Landing Page) não protegida. Serve
* como "home" e apresentação da aplicação, reencaminhando para Login/Explorar.
*/
import React from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
import { Image, ScrollView, StyleSheet, Text, View } 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 { Button } from '../components/ui/Button';
import { Card } from '../components/ui/Card';
import { useApp } from '../context/AppContext';
import { RootStackParamList } from '../navigation/types';
export default function Landing() {
const navigation = useNavigation();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { shops } = useApp();
const featuredShops = shops.slice(0, 3);
return (
<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.
<View style={styles.kicker}>
<Text style={styles.kickerText}>A solução completa para a sua barbearia</Text>
</View>
<Text style={styles.heroTitle}>
Gestão Simplificada da sua <Text style={styles.heroAccent}>Barbearia</Text>
</Text>
<Text style={styles.heroDesc}>
A sua solução completa para o dia-a-dia da barbearia.
Organize a sua barbearia com facilidade. Simples, rápido e eficiente.
</Text>
<View style={styles.buttons}>
<Button
onPress={() => navigation.navigate('Explore' as never)}
style={styles.button}
size="md"
>
<View style={styles.heroActions}>
<Button onPress={() => navigation.navigate('Explore')} style={styles.primaryHeroButton} textStyle={styles.primaryHeroText}>
Ver Barbearias
</Button>
<Button
onPress={() => navigation.navigate('Register' as never)}
variant="outline"
style={styles.button}
size="md"
>
Criar Conta
<Button onPress={() => navigation.navigate('Register')} variant="outline" style={styles.secondaryHeroButton} textStyle={styles.secondaryHeroText}>
Parceiro Profissional
</Button>
</View>
<View style={styles.stats}>
<View style={styles.statItem}>
<Text style={styles.statNumber}>500+</Text>
<Text style={styles.statLabel}>Lojas registadas</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statNumber}>10K+</Text>
<Text style={styles.statLabel}>Cortes marcados</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statNumber, styles.statAccent]}>4.9</Text>
<Text style={styles.statLabel}>Avaliação média</Text>
</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 style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Tudo para o Seu Negócio</Text>
<Text style={styles.sectionSubtitle}>Tudo o que precisa para gerir e fazer crescer a sua barbearia.</Text>
</View>
<View style={styles.featureGrid}>
{[
['Agenda Prática', 'Gerencie horários e evite conflitos de agenda.'],
['Venda de Produtos', 'Venda produtos diretamente aos seus clientes.'],
['Controle de Ganhos', 'Acompanhe faturação e serviços mais procurados.'],
].map(([title, desc]) => (
<Card key={title} style={styles.featureCard}>
<View style={styles.featureIcon}>
<Text style={styles.featureIconText}>{title.charAt(0)}</Text>
</View>
<Text style={styles.featureTitle}>{title}</Text>
<Text style={styles.featureDesc}>{desc}</Text>
</Card>
))}
</View>
{featuredShops.length > 0 && (
<>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>Descobrir Barbearias</Text>
<Text style={styles.sectionSubtitle}>As melhores barbearias disponíveis na plataforma.</Text>
</View>
{featuredShops.map((shop) => (
<Card key={shop.id} style={styles.shopCard}>
{shop.imageUrl ? (
<Image source={{ uri: shop.imageUrl }} style={styles.shopImage} />
) : (
<View style={styles.shopImageFallback}>
<Text style={styles.shopImageFallbackText}>SA</Text>
</View>
)}
<View style={styles.shopBody}>
<View style={styles.shopTitleRow}>
<Text style={styles.shopName} numberOfLines={1}>{shop.name}</Text>
<Text style={styles.rating}>{(shop.rating || 0).toFixed(1)}</Text>
</View>
<Text style={styles.shopAddress} numberOfLines={1}>{shop.address || 'Endereço indisponível'}</Text>
<Button onPress={() => navigation.navigate('ShopDetails', { shopId: shop.id })} style={styles.darkButton}>
Reservar
</Button>
</View>
</Card>
))}
</>
)}
<View style={styles.finalCta}>
<Text style={styles.finalTitle}>Registe a sua Barbearia</Text>
<Text style={styles.finalText}>A sua barbearia merece uma experiência simples para clientes e equipa.</Text>
<Button onPress={() => navigation.navigate('Register')} style={styles.primaryHeroButton} textStyle={styles.primaryHeroText}>
Criar Conta
</Button>
</View>
</ScrollView>
</SafeAreaView>
@@ -77,51 +122,209 @@ const styles = StyleSheet.create({
},
content: {
padding: 16,
gap: 22,
},
hero: {
backgroundColor: '#6366f1',
borderRadius: 16,
backgroundColor: '#020617',
borderRadius: 28,
padding: 24,
marginBottom: 24,
overflow: 'hidden',
},
kicker: {
alignSelf: 'flex-start',
backgroundColor: 'rgba(99,102,241,0.16)',
borderColor: 'rgba(129,140,248,0.35)',
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 7,
marginBottom: 22,
},
kickerText: {
color: '#a5b4fc',
fontSize: 10,
fontWeight: '900',
textTransform: 'uppercase',
letterSpacing: 0,
},
heroTitle: {
fontSize: 28,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
fontSize: 40,
lineHeight: 42,
fontWeight: '900',
textTransform: 'uppercase',
},
heroSubtitle: {
fontSize: 20,
fontWeight: '600',
color: '#fff',
marginBottom: 8,
heroAccent: {
color: '#818cf8',
},
heroDesc: {
fontSize: 16,
color: '#e0e7ff',
marginBottom: 20,
color: '#cbd5e1',
fontSize: 17,
lineHeight: 24,
fontWeight: '500',
marginTop: 18,
},
buttons: {
heroActions: {
gap: 12,
marginTop: 26,
},
primaryHeroButton: {
backgroundColor: '#fff',
},
primaryHeroText: {
color: '#0f172a',
},
secondaryHeroButton: {
borderColor: 'rgba(255,255,255,0.24)',
backgroundColor: 'transparent',
},
secondaryHeroText: {
color: '#fff',
},
stats: {
flexDirection: 'row',
borderTopColor: 'rgba(255,255,255,0.12)',
borderTopWidth: 1,
marginTop: 28,
paddingTop: 18,
gap: 10,
},
statItem: {
flex: 1,
},
statNumber: {
color: '#fff',
fontSize: 26,
fontWeight: '900',
},
statAccent: {
color: '#818cf8',
},
statLabel: {
color: '#94a3b8',
fontSize: 9,
fontWeight: '900',
textTransform: 'uppercase',
marginTop: 4,
},
sectionHeader: {
gap: 6,
},
sectionTitle: {
color: '#0f172a',
fontSize: 30,
lineHeight: 32,
fontWeight: '900',
textTransform: 'uppercase',
},
sectionSubtitle: {
color: '#64748b',
fontSize: 15,
lineHeight: 22,
fontWeight: '500',
},
featureGrid: {
gap: 12,
},
button: {
width: '100%',
},
features: {
gap: 16,
},
featureCard: {
marginBottom: 12,
padding: 20,
},
featureIcon: {
width: 56,
height: 56,
borderRadius: 16,
backgroundColor: '#eef2ff',
alignItems: 'center',
justifyContent: 'center',
marginBottom: 14,
},
featureIconText: {
color: '#4f46e5',
fontWeight: '900',
fontSize: 22,
},
featureTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#0f172a',
fontSize: 20,
fontWeight: '900',
textTransform: 'uppercase',
marginBottom: 8,
},
featureDesc: {
fontSize: 14,
color: '#64748b',
fontSize: 14,
lineHeight: 20,
fontWeight: '500',
},
shopCard: {
padding: 0,
overflow: 'hidden',
marginBottom: 12,
},
shopImage: {
height: 150,
width: '100%',
},
shopImageFallback: {
height: 150,
backgroundColor: '#0f172a',
alignItems: 'center',
justifyContent: 'center',
},
shopImageFallbackText: {
color: '#818cf8',
fontWeight: '900',
fontSize: 24,
},
shopBody: {
padding: 16,
gap: 10,
},
shopTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
shopName: {
flex: 1,
color: '#0f172a',
fontSize: 20,
fontWeight: '900',
},
rating: {
color: '#fff',
backgroundColor: '#0f172a',
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 4,
fontSize: 12,
fontWeight: '900',
},
shopAddress: {
color: '#64748b',
fontWeight: '500',
},
darkButton: {
backgroundColor: '#0f172a',
marginTop: 6,
},
finalCta: {
backgroundColor: '#020617',
borderRadius: 28,
padding: 24,
gap: 14,
marginBottom: 16,
},
finalTitle: {
color: '#fff',
fontSize: 32,
fontWeight: '900',
textTransform: 'uppercase',
},
finalText: {
color: '#cbd5e1',
fontSize: 15,
lineHeight: 22,
fontWeight: '500',
},
});

View File

@@ -2,11 +2,13 @@ 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 { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useApp } from '../context/AppContext';
import { Card } from '../components/ui/Card';
import { Badge } from '../components/ui/Badge';
import { Button } from '../components/ui/Button';
import { currency } from '../lib/format';
import { RootStackParamList } from '../navigation/types';
// Mapeamento visual estático das strings de estado do Postgres/State para cores da UI
const statusColor: Record<string, 'indigo' | 'green' | 'slate' | 'red'> = {
@@ -17,7 +19,7 @@ const statusColor: Record<string, 'indigo' | 'green' | 'slate' | 'red'> = {
};
export default function Profile() {
const navigation = useNavigation();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
// Obtém sessão do utilizador (auth) e listas globais da BD (appointments e orders)
const { user, appointments, orders, shops, logout, notifications, markNotificationRead } = useApp();
@@ -57,6 +59,10 @@ export default function Profile() {
</Button>
</Card>
<Button onPress={() => navigation.navigate('EventsCreate')} style={styles.eventButton}>
Criar evento
</Button>
{myNotifications.length > 0 && (
<>
<Text style={styles.sectionTitle}>Notificações</Text>
@@ -159,6 +165,11 @@ const styles = StyleSheet.create({
logoutButton: {
width: '100%',
},
eventButton: {
width: '100%',
marginBottom: 20,
backgroundColor: '#0f172a',
},
sectionTitle: {
fontSize: 20,
fontWeight: 'bold',
@@ -202,4 +213,3 @@ const styles = StyleSheet.create({
},
});

View File

@@ -1,7 +1,7 @@
import React, { useState, useMemo } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
import React, { useMemo, useState } from 'react';
import { Alert, Image, Linking, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
import { RouteProp, useNavigation, useRoute } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useApp } from '../context/AppContext';
import { Card } from '../components/ui/Card';
@@ -10,113 +10,229 @@ import { Badge } from '../components/ui/Badge';
import { currency } from '../lib/format';
import { RootStackParamList } from '../navigation/types';
type Tab = 'servicos' | 'barbeiros' | 'produtos' | 'detalhes';
const defaultSchedule = [
{ day: 'Segunda-feira', open: '09:00', close: '19:30' },
{ day: 'Terça-feira', open: '09:00', close: '19:30' },
{ day: 'Quarta-feira', open: '09:00', close: '19:30' },
{ day: 'Quinta-feira', open: '09:00', close: '19:30' },
{ day: 'Sexta-feira', open: '09:00', close: '19:30' },
{ day: 'Sábado', open: '09:00', close: '19:00' },
{ day: 'Domingo', open: '', close: '', closed: true },
];
export default function ShopDetails() {
// Apanha o parâmetro 'shopId' passado por rotas anteriores (ex: via Explore)
const route = useRoute<RouteProp<RootStackParamList, 'ShopDetails'>>();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { shopId } = route.params;
// Instancia ferramentas de dados partilhadas do State (a simular uma API de comunicação)
const { shops, addToCart } = useApp();
// Otimiza o re-render filtrando e selecionando apenas a entidade visualizada na DB
const { shops, shopsReady, addToCart, user, toggleFavorite, isFavorite } = useApp();
const shop = useMemo(() => shops.find((s) => s.id === shopId), [shops, shopId]);
const [tab, setTab] = useState<Tab>('servicos');
// Estado local contidiano para gerir as abas visíveis (Tabs de Conteúdos)
const [tab, setTab] = useState<'servicos' | 'produtos'>('servicos');
// Fallback visual de navegação inválida para o caso da barbearia não constar
if (!shop) {
if (!shopsReady && !shop) {
return (
<SafeAreaView style={styles.container}>
<Text>Barbearia não encontrada</Text>
<View style={styles.centerState}>
<Text style={styles.centerTitle}>A carregar detalhes...</Text>
</View>
</SafeAreaView>
);
}
return (
// Contentor em Scroll adaptável horizontal/vertical em smartphones reduzidos
<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
if (!shop) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.centerState}>
<Text style={styles.centerTitle}>Barbearia não encontrada</Text>
<Text style={styles.centerText}>O espaço pode ter sido removido ou o link está incorreto.</Text>
<Button style={styles.reserveButton} onPress={() => navigation.navigate('Explore')}>
Explorar outros espaços
</Button>
</View>
</SafeAreaView>
);
}
{/* 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>
const openMap = () => {
const url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(`${shop.name} ${shop.address}`)}`;
Linking.openURL(url).catch(() => Alert.alert('Erro', 'Não foi possível abrir o mapa.'));
};
const reserveService = (serviceId?: string) => {
if (!user) {
navigation.navigate('Login');
return;
}
navigation.navigate('Booking', { shopId: shop.id });
};
const addProduct = (productId: string) => {
if (!user) {
navigation.navigate('Login');
return;
}
addToCart({ shopId: shop.id, type: 'product', refId: productId, qty: 1 });
Alert.alert('Adicionado', 'Produto adicionado ao carrinho.');
};
const schedule = shop.schedule || defaultSchedule;
const paymentMethods = shop.paymentMethods || ['Dinheiro', 'Cartão de Crédito', 'Cartão de Débito'];
const contacts = shop.contacts || { phone1: '252 048 754', phone2: '252 048 754' };
const currentDayIndex = new Date().getDay() === 0 ? 6 : new Date().getDay() - 1;
return (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
<View style={styles.hero}>
{shop.imageUrl ? (
<Image source={{ uri: shop.imageUrl }} style={styles.heroImage} />
) : (
<View style={styles.heroFallback}>
<Text style={styles.heroFallbackText}>SA</Text>
</View>
)}
<View style={styles.heroOverlay} />
<View style={styles.heroActions}>
<TouchableOpacity style={styles.heroAction} onPress={openMap}>
<Text style={styles.heroActionText}>Mapa</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.heroAction} onPress={() => toggleFavorite(shop.id)}>
<Text style={[styles.heroActionText, isFavorite(shop.id) && styles.favoriteActive]}>
{isFavorite(shop.id) ? 'Favorito' : 'Guardar'}
</Text>
</TouchableOpacity>
</View>
<View style={styles.heroContent}>
<View style={styles.ratingPill}>
<Text style={styles.ratingText}>{(shop.rating || 0).toFixed(1)} Excelente</Text>
</View>
<Text style={styles.title} numberOfLines={2}>{shop.name}</Text>
<Text style={styles.address} numberOfLines={2}>{shop.address}</Text>
</View>
</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}>
<View style={styles.tabShell}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.tabs}>
{[
['servicos', 'Serviços'],
['barbeiros', 'Barbeiros'],
['produtos', 'Produtos'],
['detalhes', 'Detalhes'],
].map(([id, label]) => (
<TouchableOpacity
key={id}
onPress={() => setTab(id as Tab)}
style={[styles.tab, tab === id && styles.tabActive]}
>
<Text style={[styles.tabText, tab === id && styles.tabTextActive]}>{label}</Text>
</TouchableOpacity>
))}
</ScrollView>
<Text style={styles.summary}>{shop.services.length} serviços · {shop.barbers.length} barbeiros</Text>
</View>
{tab === 'servicos' && (
<View style={styles.grid}>
{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>
<Card key={service.id} style={styles.serviceCard}>
<View style={styles.itemTop}>
<View style={styles.itemInfo}>
<Text style={styles.itemTitle}>{service.name}</Text>
<Text style={styles.itemMeta}>{service.duration} min · Lugar disponível hoje</Text>
</View>
<Text style={styles.price}>{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 style={styles.reserveButton} onPress={() => reserveService(service.id)}>
Reservar
</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>
)}
{tab === 'barbeiros' && (
<Card style={styles.panel}>
<View style={styles.barberGrid}>
{shop.barbers.length === 0 ? (
<Text style={styles.emptyText}>Esta barbearia ainda não registou barbeiros.</Text>
) : shop.barbers.map((barber) => (
<View key={barber.id} style={styles.barberItem}>
{barber.imageUrl ? (
<Image source={{ uri: barber.imageUrl }} style={styles.avatarImage} />
) : (
<View style={styles.avatar}>
<Text style={styles.avatarText}>{barber.name.charAt(0).toUpperCase()}</Text>
</View>
)}
<Text style={styles.barberName} numberOfLines={1}>{barber.name}</Text>
<Text style={styles.barberSpecialty} numberOfLines={1}>
{barber.specialties[0] || 'Especialista'}
</Text>
</View>
<Text style={styles.itemDesc}>Stock: {product.stock} unidades</Text>
))}
</View>
</Card>
)}
{/* 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>
))}
{tab === 'produtos' && (
<View style={styles.productGrid}>
{shop.products.map((product) => {
const lowStock = product.stock <= 3;
return (
<Card key={product.id} style={styles.productCard}>
<View style={styles.productIcon}>
<Text style={styles.productIconText}>P</Text>
{lowStock && <Badge color="indigo" style={styles.lowStock}>Últimas</Badge>}
</View>
<Text style={styles.productTitle} numberOfLines={1}>{product.name}</Text>
<Text style={styles.productStock}>{product.stock} em stock</Text>
<Text style={styles.productPrice}>{currency(product.price)}</Text>
<Button
size="sm"
style={styles.reserveButton}
disabled={product.stock <= 0}
onPress={() => addProduct(product.id)}
>
{product.stock > 0 ? 'Adicionar' : 'Esgotado'}
</Button>
</Card>
);
})}
</View>
)}
{tab === 'detalhes' && (
<Card style={styles.panel}>
<Text style={styles.detailTitle}>Horário de atendimento</Text>
{schedule.map((slot, index) => (
<View key={slot.day} style={styles.scheduleRow}>
<View style={styles.scheduleDay}>
<Text style={[styles.scheduleDayText, index === currentDayIndex && styles.todayText]}>{slot.day}</Text>
{index === currentDayIndex && <Text style={styles.todayBadge}>Hoje</Text>}
</View>
<Text style={styles.scheduleTime}>{slot.closed ? 'Fechado' : `${slot.open} - ${slot.close}`}</Text>
</View>
))}
<View style={styles.divider} />
<Text style={styles.detailTitle}>Formas de pagamento</Text>
<View style={styles.paymentList}>
{paymentMethods.map((method) => (
<Text key={method} style={styles.paymentChip}>{method}</Text>
))}
</View>
<View style={styles.divider} />
<Text style={styles.detailTitle}>Contacto</Text>
{[contacts.phone1, contacts.phone2].filter(Boolean).map((phone) => (
<TouchableOpacity key={phone} style={styles.phoneCard} onPress={() => Linking.openURL(`tel:${String(phone).replace(/\D/g, '')}`)}>
<Text style={styles.phoneText}>{phone}</Text>
<Text style={styles.phoneAction}>Ligar</Text>
</TouchableOpacity>
))}
</Card>
)}
</ScrollView>
</SafeAreaView>
);
@@ -129,81 +245,347 @@ const styles = StyleSheet.create({
},
content: {
padding: 16,
gap: 16,
},
header: {
marginBottom: 20,
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#0f172a',
marginBottom: 8,
},
address: {
fontSize: 14,
color: '#64748b',
marginBottom: 16,
},
bookButton: {
width: '100%',
},
tabs: {
flexDirection: 'row',
gap: 8,
marginBottom: 16,
},
tab: {
centerState: {
flex: 1,
padding: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: '#e2e8f0',
alignItems: 'center',
},
tabActive: {
borderColor: '#6366f1',
backgroundColor: '#e0e7ff',
},
tabText: {
fontSize: 14,
fontWeight: '600',
color: '#64748b',
},
tabTextActive: {
color: '#6366f1',
},
list: {
justifyContent: 'center',
padding: 24,
gap: 12,
},
itemCard: {
centerTitle: {
color: '#0f172a',
fontSize: 22,
fontWeight: '900',
textAlign: 'center',
},
centerText: {
color: '#64748b',
textAlign: 'center',
fontWeight: '500',
},
hero: {
minHeight: 340,
borderRadius: 28,
overflow: 'hidden',
backgroundColor: '#0f172a',
},
heroImage: {
...StyleSheet.absoluteFillObject,
width: '100%',
height: '100%',
},
heroFallback: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
},
heroFallbackText: {
color: '#818cf8',
fontSize: 42,
fontWeight: '900',
},
heroOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(2,6,23,0.48)',
},
heroActions: {
position: 'absolute',
right: 14,
top: 14,
flexDirection: 'row',
gap: 8,
},
heroAction: {
backgroundColor: 'rgba(255,255,255,0.92)',
borderRadius: 14,
paddingHorizontal: 12,
paddingVertical: 9,
},
heroActionText: {
color: '#0f172a',
fontSize: 11,
fontWeight: '900',
},
favoriteActive: {
color: '#e11d48',
},
heroContent: {
position: 'absolute',
bottom: 22,
left: 18,
right: 18,
gap: 8,
},
ratingPill: {
alignSelf: 'flex-start',
backgroundColor: 'rgba(15,23,42,0.52)',
borderColor: 'rgba(255,255,255,0.18)',
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: 10,
paddingVertical: 5,
},
ratingText: {
color: '#fff',
fontSize: 11,
fontWeight: '900',
textTransform: 'uppercase',
},
title: {
color: '#fff',
fontSize: 34,
lineHeight: 36,
fontWeight: '900',
},
address: {
color: 'rgba(255,255,255,0.9)',
fontSize: 15,
fontWeight: '600',
},
tabShell: {
backgroundColor: 'rgba(255,255,255,0.72)',
borderRadius: 24,
padding: 8,
gap: 8,
},
tabs: {
gap: 8,
},
tab: {
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 11,
backgroundColor: '#f1f5f9',
},
tabActive: {
backgroundColor: '#0f172a',
},
tabText: {
color: '#475569',
fontSize: 12,
fontWeight: '900',
},
tabTextActive: {
color: '#818cf8',
},
summary: {
backgroundColor: '#fff',
color: '#94a3b8',
borderRadius: 16,
padding: 11,
fontSize: 11,
fontWeight: '900',
textTransform: 'uppercase',
},
grid: {
gap: 12,
},
serviceCard: {
padding: 16,
gap: 14,
},
itemTop: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: 10,
},
itemInfo: {
flex: 1,
gap: 6,
},
itemTitle: {
color: '#0f172a',
fontSize: 18,
fontWeight: '900',
textTransform: 'uppercase',
},
itemMeta: {
color: '#64748b',
fontSize: 12,
fontWeight: '700',
},
price: {
color: '#0f172a',
backgroundColor: '#eef2ff',
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 6,
fontSize: 16,
fontWeight: '900',
},
reserveButton: {
backgroundColor: '#0f172a',
},
panel: {
padding: 18,
},
barberGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 16,
},
barberItem: {
width: '29%',
minWidth: 92,
alignItems: 'center',
gap: 6,
},
avatar: {
width: 74,
height: 74,
borderRadius: 999,
backgroundColor: '#e2e8f0',
alignItems: 'center',
justifyContent: 'center',
},
avatarImage: {
width: 74,
height: 74,
borderRadius: 999,
},
avatarText: {
color: '#64748b',
fontSize: 24,
fontWeight: '900',
},
barberName: {
color: '#0f172a',
fontWeight: '900',
textAlign: 'center',
},
barberSpecialty: {
color: '#94a3b8',
fontSize: 12,
textAlign: 'center',
fontWeight: '600',
},
productGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
},
productCard: {
width: '48%',
padding: 12,
gap: 8,
},
productIcon: {
aspectRatio: 1,
borderRadius: 18,
backgroundColor: '#f8fafc',
alignItems: 'center',
justifyContent: 'center',
},
productIconText: {
color: '#cbd5e1',
fontSize: 36,
fontWeight: '900',
},
lowStock: {
position: 'absolute',
top: 8,
left: 8,
},
productTitle: {
color: '#0f172a',
fontSize: 14,
fontWeight: '900',
textTransform: 'uppercase',
},
productStock: {
color: '#94a3b8',
fontSize: 11,
fontWeight: '900',
textTransform: 'uppercase',
},
productPrice: {
color: '#0f172a',
fontSize: 18,
fontWeight: '900',
},
emptyText: {
color: '#64748b',
fontWeight: '600',
},
detailTitle: {
color: '#0f172a',
fontSize: 19,
fontWeight: '900',
marginBottom: 12,
},
itemHeader: {
scheduleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
gap: 12,
paddingVertical: 7,
},
itemName: {
fontSize: 16,
fontWeight: 'bold',
color: '#0f172a',
scheduleDay: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
flex: 1,
},
itemPrice: {
fontSize: 16,
fontWeight: 'bold',
color: '#6366f1',
},
itemDesc: {
fontSize: 14,
scheduleDayText: {
color: '#64748b',
marginBottom: 12,
fontWeight: '700',
},
stockBadge: {
marginBottom: 8,
todayText: {
color: '#4f46e5',
fontWeight: '900',
},
addButton: {
width: '100%',
todayBadge: {
color: '#4338ca',
backgroundColor: '#e0e7ff',
borderRadius: 8,
paddingHorizontal: 7,
paddingVertical: 2,
fontSize: 10,
fontWeight: '900',
},
scheduleTime: {
color: '#334155',
fontWeight: '800',
},
divider: {
height: 1,
backgroundColor: '#e2e8f0',
marginVertical: 18,
},
paymentList: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
paymentChip: {
backgroundColor: '#fff',
borderColor: '#e2e8f0',
borderWidth: 1,
color: '#334155',
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 8,
fontWeight: '700',
},
phoneCard: {
backgroundColor: '#fff',
borderColor: '#e2e8f0',
borderWidth: 1,
borderRadius: 16,
padding: 14,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 10,
},
phoneText: {
color: '#334155',
fontSize: 16,
fontWeight: '900',
},
phoneAction: {
color: '#4f46e5',
fontWeight: '900',
},
});

View File

@@ -1,7 +1,21 @@
export type Barber = { id: string; name: string; specialties: string[]; schedule: { day: string; slots: string[] }[] };
export type Barber = { id: string; name: string; imageUrl?: string; specialties: string[]; schedule: { day: string; slots: string[] }[] };
export type Service = { id: string; name: string; price: number; duration: number; barberIds: string[] };
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 ShopSchedule = { day: string; open: string; close: string; closed?: boolean };
export type BarberShop = {
id: string;
name: string;
address: string;
rating: number;
services: Service[];
products: Product[];
barbers: Barber[];
imageUrl?: string;
schedule?: ShopSchedule[];
paymentMethods?: string[];
socialNetworks?: { whatsapp?: string; instagram?: string; facebook?: string };
contacts?: { phone1?: string; phone2?: string };
};
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; reminderMinutes?: number };
@@ -11,4 +25,3 @@ export type User = { id: string; name: string; email: string; password: string;
export type WaitlistEntry = { id: string; shopId: string; serviceId: string; barberId: string; customerId: string; date: string; status: 'pending' | 'notified' | 'resolved'; createdAt: string };
export type AppNotification = { id: string; userId: string; message: string; read: boolean; createdAt: string };