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:
@@ -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
31
src/lib/events.ts
Normal 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;
|
||||
}
|
||||
@@ -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() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export type RootStackParamList = {
|
||||
Cart: undefined;
|
||||
Profile: undefined;
|
||||
Dashboard: undefined;
|
||||
EventsCreate: undefined;
|
||||
};
|
||||
|
||||
declare global {
|
||||
@@ -16,4 +17,3 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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}>Já tem conta? </Text>
|
||||
<Text
|
||||
style={styles.footerLink}
|
||||
onPress={() => navigation.navigate('Login' as never)}
|
||||
>
|
||||
Entrar
|
||||
<Text style={styles.footerText}>Já 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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
160
src/pages/EventsCreate.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
19
src/types.ts
19
src/types.ts
@@ -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 };
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user