From a0651301673009180d2ac2a2f06cf68ed115c0c1 Mon Sep 17 00:00:00 2001 From: 230417 <230417@epvc.pt> Date: Wed, 6 May 2026 11:00:50 +0100 Subject: [PATCH] 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. --- src/context/AppContext.tsx | 217 ++++++++--- src/lib/events.ts | 31 ++ src/navigation/AppNavigator.tsx | 6 +- src/navigation/types.ts | 2 +- src/pages/AuthLogin.tsx | 141 +++---- src/pages/AuthRegister.tsx | 211 +++++----- src/pages/Cart.tsx | 7 +- src/pages/EventsCreate.tsx | 160 ++++++++ src/pages/Explore.tsx | 424 ++++++++++++++++---- src/pages/Landing.tsx | 343 ++++++++++++---- src/pages/Profile.tsx | 14 +- src/pages/ShopDetails.tsx | 670 +++++++++++++++++++++++++------- src/types.ts | 19 +- 13 files changed, 1713 insertions(+), 532 deletions(-) create mode 100644 src/lib/events.ts create mode 100644 src/pages/EventsCreate.tsx diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index 43f777a..5d3ef4e 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -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; + logout: () => Promise; + register: (payload: any) => Promise; + 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; clearCart: () => void; createAppointment: (input: Omit) => Promise; updateAppointmentStatus: (id: string, status: Appointment['status']) => Promise; @@ -53,37 +57,47 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { const [appointments, setAppointments] = useState([]); const [orders, setOrders] = useState([]); const [cart, setCart] = useState([]); + const [favorites, setFavorites] = useState([]); const [waitlists, setWaitlists] = useState([]); const [notifications, setNotifications] = useState([]); const [user, setUser] = useState(undefined); + const [shopsReady, setShopsReady] = useState(false); const [loading, setLoading] = useState(true); + const applySupabaseUser = async (authUser: any): Promise => { + 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) => { - 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; -} \ No newline at end of file +} diff --git a/src/lib/events.ts b/src/lib/events.ts new file mode 100644 index 0000000..e3c5ba9 --- /dev/null +++ b/src/lib/events.ts @@ -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; +} diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 7441d4e..9d44b78 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -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(); @@ -34,13 +35,12 @@ export default function AppNavigator() { - - ) : user.role === 'barbearia' ? ( <> + ) : ( <> @@ -49,6 +49,7 @@ export default function AppNavigator() { + )} @@ -56,4 +57,3 @@ export default function AppNavigator() { ); } - diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 5c7cb1e..2a2c962 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -8,6 +8,7 @@ export type RootStackParamList = { Cart: undefined; Profile: undefined; Dashboard: undefined; + EventsCreate: undefined; }; declare global { @@ -16,4 +17,3 @@ declare global { } } - diff --git a/src/pages/AuthLogin.tsx b/src/pages/AuthLogin.tsx index f64b694..a44d3f7 100644 --- a/src/pages/AuthLogin.tsx +++ b/src/pages/AuthLogin.tsx @@ -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>(); 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 - - {/* O componente Card encapsula de forma visual os inputs de login */} + + navigation.navigate('Landing')}> + SA + + Bem-vindo - Aceda à sua conta + Aceda à sua conta Smart Agenda - {/* Bloco temporário para dados demo */} - - 💡 Conta demo: - Cliente: cliente@demo.com / 123 - Barbearia: barber@demo.com / 123 - + {!!error && {error}} - {/* Input controlado pelo estadoReact "email"; atualiza à medida que se digita */} - {/* Input controlado para a password; secureTextEntry oculta os caracteres */} { 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 */} - - {/* Estrutura de rodapé para navegação alternativa (redirecionar para o ecrã de registo) */} - Não tem conta? - navigation.navigate('Register' as never)} - > - Criar Conta + Ainda não tem conta? + navigation.navigate('Register')}> + Criar conta grátis @@ -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', }, }); - diff --git a/src/pages/AuthRegister.tsx b/src/pages/AuthRegister.tsx index 325503d..714910f 100644 --- a/src/pages/AuthRegister.tsx +++ b/src/pages/AuthRegister.tsx @@ -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>(); 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 - - {/* O Card agrupa a interface de registo */} + - Criar conta - Escolha o tipo de acesso + + SA + + Criar Conta + Junte-se à Smart Agenda - {/* Grupo de botões de seleção de papel. O CSS ativo altera com base no estado `role`. */} + {!!error && {error}} + + Eu sou... - setRole('cliente')} - > - - Cliente - - - setRole('barbearia')} - > - - Barbearia - - + {(['cliente', 'barbearia'] as const).map((item) => ( + { + setRole(item); + setError(''); + }} + > + + {item === 'cliente' ? 'Cliente' : 'Barbearia'} + + + ))} - {/* Input simples para o nome, que atualiza a variável React de estado "name" */} - - - {/* Input específico de email - autoCapitalize=none evita letras maiúsculas automáticas */} + + - {/* Input para password mascarada com secureTextEntry para esconder os carateres */} - - - {/* Renderização condicional do JSX: o campo shopName só é renderizado - se o papel na base de dados (e estado) for 'barbearia' */} {role === 'barbearia' && ( )} - {/* Botão de chamada à ação principal que aciona a submissão dos dados (handleSubmit) */} - - {/* Seção rodapé simples com reencaminhamento de navegação para a página de Login */} - Já tem conta? - navigation.navigate('Login' as never)} - > - Entrar + Já tem uma conta? + navigation.navigate('Login')}> + Fazer Login @@ -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', }, }); - diff --git a/src/pages/Cart.tsx b/src/pages/Cart.tsx index 5fd5b43..1219872 100644 --- a/src/pages/Cart.tsx +++ b/src/pages/Cart.tsx @@ -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({ }, }); - diff --git a/src/pages/EventsCreate.tsx b/src/pages/EventsCreate.tsx new file mode 100644 index 0000000..7947daf --- /dev/null +++ b/src/pages/EventsCreate.tsx @@ -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>(); + 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 ( + + + + + + + + Criar evento + Este evento vai aparecer no teu perfil + + {!!error && {error}} + + { + setTitle(text); + setError(''); + }} + placeholder="Ex: Corte às 18h" + /> + { + setDescription(text); + setError(''); + }} + placeholder="Notas do evento..." + /> + { + setDate(text); + setError(''); + }} + placeholder="2026-05-10 18:30" + /> + + + + + + + + + ); +} + +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, + }, +}); diff --git a/src/pages/Explore.tsx b/src/pages/Explore.tsx index e815e73..ffdbe02 100644 --- a/src/pages/Explore.tsx +++ b/src/pages/Explore.tsx @@ -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>(); - // 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 - Barbearias - - {/* FlatList é o componente nativo otimizado para renderizar grandes arrays de dados provenientes da BD */} - item.id} - contentContainerStyle={styles.list} - renderItem={({ item: shop }) => ( - // Cada Card rege a UI de uma barbearia com dados mapeados reais/mock - - - {shop.name} - {shop.rating.toFixed(1)} ⭐ + + + navigation.navigate(user ? 'Explore' : 'Landing')} style={styles.brand}> + + SA - {shop.address} + Smart Agenda + + + {user?.role !== 'barbearia' && ( + (user ? navigation.navigate('Cart') : navigation.navigate('Login'))}> + Carrinho + {cart.length > 0 && {cart.length}} + + )} + + {user ? user.name.charAt(0).toUpperCase() : 'Entrar'} + + + - - {/* O atributo length do array simula as rows de relacionamentos 1-para-N (serviços) */} - {shop.services.length} serviços - - {/* Mapeamento de dimensão da relation 'barbers' */} - {shop.barbers.length} barbeiros - + + + As nossas barbearias + + Ver Barbearias + Descubra barbearias exclusivas e reserve o seu próximo corte em segundos. + {user && ( + + Sair da conta + + )} + - - {/* Botão direciona para as informações, passando o ID primário (shop.id) pelo React Navigation */} - + {label} + + ))} + + - {/* Redirecionamento direto com foreign key injetada para a view de Agendamentos */} - - + {!shopsReady ? ( + + A carregar espaços... + ) : filtered.length === 0 ? ( + + Nenhuma barbearia encontrada + Tente ajustar a pesquisa ou os filtros ativos. + + + ) : ( + + {filtered.length} espaços disponíveis + {filtered.map((shop) => ( + + {shop.imageUrl ? ( + + ) : ( + + SA + + )} + + + {shop.name} + {(shop.rating || 0).toFixed(1)} + + {shop.address || 'Endereço indisponível'} + + {(shop.services || []).length} serviços + {(shop.barbers || []).length} barbeiros + + + + + ))} + )} - /> + ); } @@ -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', }, }); - diff --git a/src/pages/Landing.tsx b/src/pages/Landing.tsx index 3f53ed5..b26deaf 100644 --- a/src/pages/Landing.tsx +++ b/src/pages/Landing.tsx @@ -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>(); + const { shops } = useApp(); + const featuredShops = shops.slice(0, 3); return ( - {/* Hero section: elemento apelativo para captar valor (exibição de marca) */} - Smart Agenda - - Agendamento e Gestão de Barbearias. + + A solução completa para a sua barbearia + + + Gestão Simplificada da sua Barbearia - A sua solução completa para o dia-a-dia da barbearia. + Organize a sua barbearia com facilidade. Simples, rápido e eficiente. - - - - + + + + 500+ + Lojas registadas + + + 10K+ + Cortes marcados + + + 4.9 + Avaliação média + + - - - Reservas Rápidas - - Selecione o seu barbeiro e o horário ideal em poucos segundos. - - - - Produtos - - Produtos de cuidado masculino selecionados para si. - - - - Gestão de Barbearia - - Controlo total sobre o faturamento e operação da sua barbearia. - - + + Tudo para o Seu Negócio + Tudo o que precisa para gerir e fazer crescer a sua barbearia. + + + + {[ + ['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]) => ( + + + {title.charAt(0)} + + {title} + {desc} + + ))} + + + {featuredShops.length > 0 && ( + <> + + Descobrir Barbearias + As melhores barbearias disponíveis na plataforma. + + + {featuredShops.map((shop) => ( + + {shop.imageUrl ? ( + + ) : ( + + SA + + )} + + + {shop.name} + {(shop.rating || 0).toFixed(1)} + + {shop.address || 'Endereço indisponível'} + + + + ))} + + )} + + + Registe a sua Barbearia + A sua barbearia merece uma experiência simples para clientes e equipa. + @@ -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', }, }); diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index ed1e7bb..5f57dd6 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -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 = { @@ -17,7 +19,7 @@ const statusColor: Record = { }; export default function Profile() { - const navigation = useNavigation(); + const navigation = useNavigation>(); // 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() { + + {myNotifications.length > 0 && ( <> Notificações @@ -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({ }, }); - diff --git a/src/pages/ShopDetails.tsx b/src/pages/ShopDetails.tsx index ec6741b..6a28db8 100644 --- a/src/pages/ShopDetails.tsx +++ b/src/pages/ShopDetails.tsx @@ -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>(); const navigation = useNavigation>(); 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('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 ( - Barbearia não encontrada + + A carregar detalhes... + ); } - return ( - // Contentor em Scroll adaptável horizontal/vertical em smartphones reduzidos - - - {/* Cabeçalho superior: Informações imutáveis populadas pelos profiles preenchidos */} - - {shop.name} - {shop.address} - {/* Call to action de elevado destaque que incia form Booking */} - + + ); + } - {/* Controladores de abas visuais iterando o estado (setTab) */} - - setTab('servicos')} - > - Menu de Serviços - - setTab('produtos')} - > - Boutique - + 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 ( + + + + {shop.imageUrl ? ( + + ) : ( + + SA + + )} + + + + Mapa + + toggleFavorite(shop.id)}> + + {isFavorite(shop.id) ? 'Favorito' : 'Guardar'} + + + + + + {(shop.rating || 0).toFixed(1)} Excelente + + {shop.name} + {shop.address} + - {/* 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 - + + + {[ + ['servicos', 'Serviços'], + ['barbeiros', 'Barbeiros'], + ['produtos', 'Produtos'], + ['detalhes', 'Detalhes'], + ].map(([id, label]) => ( + setTab(id as Tab)} + style={[styles.tab, tab === id && styles.tabActive]} + > + {label} + + ))} + + {shop.services.length} serviços · {shop.barbers.length} barbeiros + + + {tab === 'servicos' && ( + {shop.services.map((service) => ( - - - {service.name} - {currency(service.price)} + + + + {service.name} + {service.duration} min · Lugar disponível hoje + + {currency(service.price)} - Duração: {service.duration} min - - {/* Função addToCart despacha dados para Context agregador permitindo checkout posterior */} - ))} - ) : ( - // Retorno divergente: inventário global visivelmente iterado em componentes - - {shop.products.map((product) => ( - - - {product.name} - {currency(product.price)} + )} + + {tab === 'barbeiros' && ( + + + {shop.barbers.length === 0 ? ( + Esta barbearia ainda não registou barbeiros. + ) : shop.barbers.map((barber) => ( + + {barber.imageUrl ? ( + + ) : ( + + {barber.name.charAt(0).toUpperCase()} + + )} + {barber.name} + + {barber.specialties[0] || 'Especialista'} + - Stock: {product.stock} unidades + ))} + + + )} - {/* Alerta de urgência de reposição assente numa regra simples de negócios matemática */} - {product.stock <= 3 && Últimas unidades} - - {/* Botão em React é afetado logicamente face à impossibilidade material de encomenda */} - - - ))} + {tab === 'produtos' && ( + + {shop.products.map((product) => { + const lowStock = product.stock <= 3; + return ( + + + P + {lowStock && Últimas} + + {product.name} + {product.stock} em stock + {currency(product.price)} + + + ); + })} )} + + {tab === 'detalhes' && ( + + Horário de atendimento + {schedule.map((slot, index) => ( + + + {slot.day} + {index === currentDayIndex && Hoje} + + {slot.closed ? 'Fechado' : `${slot.open} - ${slot.close}`} + + ))} + + + Formas de pagamento + + {paymentMethods.map((method) => ( + {method} + ))} + + + + Contacto + {[contacts.phone1, contacts.phone2].filter(Boolean).map((phone) => ( + Linking.openURL(`tel:${String(phone).replace(/\D/g, '')}`)}> + {phone} + Ligar + + ))} + + )} ); @@ -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', }, }); - diff --git a/src/types.ts b/src/types.ts index c953b4e..c20e694 100644 --- a/src/types.ts +++ b/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 }; -