diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx index 8f95ec0..d52dde6 100644 --- a/src/components/ui/Badge.tsx +++ b/src/components/ui/Badge.tsx @@ -3,7 +3,7 @@ import { View, Text, StyleSheet, ViewStyle } from 'react-native'; type Props = { children: React.ReactNode; - color?: 'amber' | 'slate' | 'green' | 'red' | 'blue'; + color?: 'amber' | 'slate' | 'green' | 'red' | 'blue' | 'indigo'; variant?: 'solid' | 'soft'; style?: ViewStyle; }; @@ -15,6 +15,7 @@ const colorMap = { green: { bg: '#10b981', text: '#fff' }, red: { bg: '#ef4444', text: '#fff' }, blue: { bg: '#3b82f6', text: '#fff' }, + indigo: { bg: '#6366f1', text: '#fff' }, }, soft: { amber: { bg: '#fef3c7', text: '#92400e' }, @@ -22,10 +23,11 @@ const colorMap = { green: { bg: '#d1fae5', text: '#065f46' }, red: { bg: '#fee2e2', text: '#991b1b' }, blue: { bg: '#dbeafe', text: '#1e40af' }, + indigo: { bg: '#e0e7ff', text: '#4338ca' }, }, }; -export const Badge = ({ children, color = 'amber', variant = 'soft', style }: Props) => { +export const Badge = ({ children, color = 'indigo', variant = 'soft', style }: Props) => { const colors = colorMap[variant][color]; return ( diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 49457b7..bbc6359 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -36,7 +36,7 @@ export const Button = ({ children, onPress, variant = 'solid', size = 'md', disa activeOpacity={0.7} > {loading ? ( - + ) : ( {children} )} @@ -52,12 +52,12 @@ const styles = StyleSheet.create({ flexDirection: 'row', }, solid: { - backgroundColor: '#f59e0b', + backgroundColor: '#6366f1', }, outline: { backgroundColor: 'transparent', borderWidth: 2, - borderColor: '#f59e0b', + borderColor: '#6366f1', }, ghost: { backgroundColor: 'transparent', @@ -84,10 +84,10 @@ const styles = StyleSheet.create({ color: '#fff', }, text_outline: { - color: '#f59e0b', + color: '#6366f1', }, text_ghost: { - color: '#f59e0b', + color: '#6366f1', }, textSize_sm: { fontSize: 12, diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index 513dd86..dd9953b 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -7,23 +7,34 @@ import React, { createContext, useContext, useEffect, useMemo, useState } from 'react'; import { Appointment, Barber, BarberShop, CartItem, Order, Product, Service, User } from '../types'; import { supabase } from '../lib/supabase'; +import { nanoid } from 'nanoid'; -// Tipo interno determinando as propriedades globais partilhadas (Estados e Funções) type State = { user?: User; shops: BarberShop[]; cart: CartItem[]; appointments: Appointment[]; + orders: Order[]; }; type AppContextValue = State & { + login: (email: string, password: string) => boolean; logout: () => void; + register: (payload: any) => boolean; addToCart: (item: CartItem) => void; clearCart: () => void; createAppointment: (input: Omit) => Promise; + updateAppointmentStatus: (id: string, status: Appointment['status']) => Promise; + updateOrderStatus: (id: string, status: Order['status']) => Promise; addService: (shopId: string, service: Omit) => Promise; updateService: (shopId: string, service: Service) => Promise; deleteService: (shopId: string, serviceId: string) => Promise; + addProduct: (shopId: string, product: Omit) => Promise; + updateProduct: (shopId: string, product: Product) => Promise; + deleteProduct: (shopId: string, productId: string) => Promise; + addBarber: (shopId: string, barber: Omit) => Promise; + updateBarber: (shopId: string, barber: Barber) => Promise; + deleteBarber: (shopId: string, barberId: string) => Promise; refreshShops: () => Promise; }; @@ -32,135 +43,96 @@ const AppContext = createContext(undefined); export const AppProvider = ({ children }: { children: React.ReactNode }) => { const [shops, setShops] = useState([]); const [appointments, setAppointments] = useState([]); + const [orders, setOrders] = useState([]); const [cart, setCart] = useState([]); const [user, setUser] = useState(undefined); const [loading, setLoading] = useState(true); - /** - * Hook executado no carregamento (mount) inicial. - * Valida através de `supabase.auth.getUser()` se existe um token de sessão válido - * (identificando o utilizador sem necessidade de o cliente refazer login ativamente). - */ useEffect(() => { const loadUser = async () => { - // Pedido restrito à API de autenticação do Supabase const { data } = await supabase.auth.getUser(); if (data.user) { - let shopId: string | undefined = undefined; - - // Vai buscar o shop_id mapeado na tabela profiles const { data: prof } = await supabase .from('profiles') - .select('shop_id') + .select('shop_id, role, name') .eq('id', data.user.id) .single(); - shopId = prof?.shop_id || undefined; - setUser({ id: data.user.id, + name: prof?.name || data.user.email?.split('@')[0] || 'Utilizador', email: data.user.email || '', - role: 'barbearia', // assumido estaticamente na V1, deve vir de profiles - shopId + role: (prof?.role as any) || 'cliente', + shopId: prof?.shop_id || undefined, } as User); } }; loadUser(); }, []); - /** - * Consulta mestra (Query) - Refresca todo o ecossistema de dados das barbearias. - * Faz 2 queries (`supabase.from('shops').select('*')` e `services`) e depois - * executa um JOIN manual via Javascript para injetar os serviços dentro - * dos objetos da barbearia respetiva. - */ const refreshShops = async () => { - console.log("A buscar shops..."); + try { + const { data: shopsData } = await supabase.from('shops').select('*'); + const { data: servicesData } = await supabase.from('services').select('*'); + const { data: barbersData } = await supabase.from('barbers').select('*'); + const { data: productsData } = await supabase.from('products').select('*'); + const { data: appointmentsData } = await supabase.from('appointments').select('*'); + const { data: ordersData } = await supabase.from('orders').select('*'); - // Query 1: Obtém a listagem completa (tabela 'shops') - const { data: shopsData, error: shopsError } = await supabase - .from('shops') - .select('*'); + if (shopsData) { + const merged: BarberShop[] = shopsData.map((shop: any) => ({ + ...shop, + 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, + barberIds: s.barber_ids || [], + })), + products: (productsData || []).filter((p: any) => p.shop_id === shop.id), + barbers: (barbersData || []).filter((b: any) => b.shop_id === shop.id).map((b: any) => ({ + id: b.id, + name: b.name, + specialties: b.specialties || [], + schedule: b.schedule || [], + })), + })); + setShops(merged); + } - if (shopsError) { - console.error("Erro ao buscar shops:", shopsError); - return; + if (appointmentsData) { + setAppointments( + appointmentsData.map((a: any) => ({ + id: a.id, + shopId: a.shop_id, + serviceId: a.service_id, + barberId: a.barber_id, + customerId: a.customer_id, + date: a.date, + status: a.status as Appointment['status'], + total: a.total, + })) + ); + } + + if (ordersData) { + setOrders( + ordersData.map((o: any) => ({ + id: o.id, + shopId: o.shop_id, + customerId: o.customer_id, + items: o.items, + total: o.total, + status: o.status as Order['status'], + createdAt: o.created_at, + })) + ); + } + } catch (err) { + console.error('Error refreshing shops:', err); } - - // Query 2: Obtém a listagem associada globalmente (tabela 'services') - const { data: servicesData, error: servicesError } = await supabase - .from('services') - .select('*'); - - if (servicesError) { - console.error("Erro ao buscar services:", servicesError); - return; - } - - // Query 3: Obtém a listagem global de Barbeiros (tabela 'barbers') - const { data: barbersData, error: barbersError } = await supabase - .from('barbers') - .select('*'); - - if (barbersError) { - console.error("Erro ao buscar barbers:", barbersError); - return; - } - - // Query 4: Obtém a listagem global de Produtos (tabela 'products') - const { data: productsData, error: productsError } = await supabase - .from('products') - .select('*'); - - if (productsError) { - console.error("Erro ao buscar products:", productsError); - return; - } - - // Query 5: Obtém a listagem global de Marcações (tabela 'appointments') - const { data: appointmentsData, error: appointmentsError } = await supabase - .from('appointments') - .select('*'); - - if (appointmentsError) { - console.error("Erro ao buscar appointments:", appointmentsError); - return; - } - - if (appointmentsData) { - setAppointments( - appointmentsData.map((a: any) => ({ - id: a.id, - shopId: a.shop_id, - serviceId: a.service_id, - barberId: a.barber_id, - customerId: a.customer_id, - date: a.date, - status: a.status as Appointment['status'], - total: a.total, - })) - ); - } - - // Associar serviços, barbeiros e produtos às respetivas shops, simulando um INNER JOIN nativo do SQL - const shopsWithServices = shopsData.map((shop) => ({ - ...shop, - // Relaciona a 'foreign key' (shop_id) com o resgistro primário (shop.id) - services: servicesData.filter((s) => s.shop_id === shop.id), - products: productsData.filter((p) => p.shop_id === shop.id), - barbers: barbersData.filter((b) => b.shop_id === shop.id), - })); - - console.log("Shops carregadas:", shopsWithServices); - - setShops(shopsWithServices); }; - /** - * Hook de Inicialização Master. - * Aciona a função de preenchimento do Contexto assincronamente e liberta - * a interface UI da view de Loading (`setLoading(false)`). - */ useEffect(() => { const init = async () => { await refreshShops(); @@ -169,150 +141,136 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { init(); }, []); - /** - * Encerra a sessão JWT ativa com o Supabase Auth. - * Limpa integralmente a interface do User local (estado React vazio). - */ + const login = (email: string, password: string) => { + return true; + }; + const logout = async () => { await supabase.auth.signOut(); setUser(undefined); }; - // Funções elementares do fluxo transacional não persistido (Estado do Carrinho transitório/local) + const register = (payload: any) => { + const id = nanoid(); + const newUser: User = { ...payload, id }; + setUser(newUser); + return true; + }; + const addToCart = (item: CartItem) => { - setCart((prev) => [...prev, item]); + setCart((prev: CartItem[]) => [...prev, item]); }; const clearCart = () => setCart([]); - // 🔹 CRUD SERVICES (SUPABASE REAL) - - /** - * Executa um INSERT na BD (via API REST gerada) protegendo interações com a tabela estrita 'services'. - * @param {string} shopId - A foreign key relacionando o estabelecimento. - * @param {Omit} service - O DTO (Data Transfer Object) sem a primary key autonumerável. - */ const addService = async (shopId: string, service: Omit) => { - // Insere os campos exatos formatados estritamente na query Supabase - const { error } = await supabase.from('services').insert([ - { - shop_id: shopId, - name: service.name, - price: service.price, - duration: service.duration, - }, - ]); - - if (error) { - console.error("Erro ao adicionar serviço:", error); - return; - } - - // Para manter integridade reativa pura, força refetch dos dados pós-mutação + await supabase.from('services').insert([{ shop_id: shopId, ...service }]); await refreshShops(); }; - /** - * Executa um UPDATE num tuplo específico filtrando analiticamente pela primary key `(eq('id', service.id))`. - */ const updateService = async (shopId: string, service: Service) => { - const { error } = await supabase - .from('services') - .update({ - name: service.name, - price: service.price, - duration: service.duration, - }) - .eq('id', service.id); // Identificador vital do update - - if (error) { - console.error("Erro ao atualizar serviço:", error); - return; - } - + const { id, ...data } = service; + await supabase.from('services').update(data).eq('id', id); await refreshShops(); }; - /** - * Executa uma instrução SQL DELETE remota rígida, baseada no ID unívoco do tuplo. - */ const deleteService = async (shopId: string, serviceId: string) => { - const { error } = await supabase - .from('services') - .delete() - .eq('id', serviceId); - - if (error) { - console.error("Erro ao apagar serviço:", error); - return; - } - + await supabase.from('services').delete().eq('id', serviceId); await refreshShops(); }; - const createAppointment: AppContextValue['createAppointment'] = async (input) => { - const shop = shops.find((s) => s.id === input.shopId); - if (!shop) return null; - const svc = shop.services.find((s) => s.id === input.serviceId); - if (!svc) return null; - - const { data: newRow, error } = await supabase.from('appointments').insert([ - { - shop_id: input.shopId, - service_id: input.serviceId, - barber_id: input.barberId, - customer_id: input.customerId, - date: input.date, - status: 'pendente', - total: svc.price, - } - ]).select().single(); - - if (error || !newRow) { - console.error("Erro ao criar marcação na BD:", error); - return null; - } - + const addProduct = async (shopId: string, product: Omit) => { + await supabase.from('products').insert([{ shop_id: shopId, ...product }]); + await refreshShops(); + }; + + const updateProduct = async (shopId: string, product: Product) => { + const { id, ...data } = product; + await supabase.from('products').update(data).eq('id', id); + await refreshShops(); + }; + + const deleteProduct = async (shopId: string, productId: string) => { + await supabase.from('products').delete().eq('id', productId); + await refreshShops(); + }; + + const addBarber = async (shopId: string, barber: Omit) => { + await supabase.from('barbers').insert([{ shop_id: shopId, ...barber }]); + await refreshShops(); + }; + + const updateBarber = async (shopId: string, barber: Barber) => { + const { id, ...data } = barber; + await supabase.from('barbers').update(data).eq('id', id); + await refreshShops(); + }; + + const deleteBarber = async (shopId: string, barberId: string) => { + await supabase.from('barbers').delete().eq('id', barberId); + await refreshShops(); + }; + + const createAppointment = async (input: Omit) => { + const svc = shops.flatMap(s => s.services).find(s => s.id === input.serviceId); + const total = svc ? svc.price : 0; + const { data } = await supabase.from('appointments').insert([{ + shop_id: input.shopId, + service_id: input.serviceId, + barber_id: input.barberId, + customer_id: input.customerId, + date: input.date, + status: 'pendente', + total + }]).select().single(); + await refreshShops(); + return data as any as Appointment; + }; + + const updateAppointmentStatus = async (id: string, status: Appointment['status']) => { + await supabase.from('appointments').update({ status }).eq('id', id); + await refreshShops(); + }; + + const updateOrderStatus = async (id: string, status: Order['status']) => { + await supabase.from('orders').update({ status }).eq('id', id); await refreshShops(); - - return { - id: newRow.id, - shopId: newRow.shop_id, - serviceId: newRow.service_id, - barberId: newRow.barber_id, - customerId: newRow.customer_id, - date: newRow.date, - status: newRow.status as Appointment['status'], - total: newRow.total, - }; }; - // Empacotamento em objeto estabilizado memoizado face renderizações espúrias (React Context Pattern) const value: AppContextValue = useMemo( () => ({ user, shops, cart, appointments, + orders, + login, logout, + register, addToCart, clearCart, createAppointment, + updateAppointmentStatus, + updateOrderStatus, addService, updateService, deleteService, + addProduct, + updateProduct, + deleteProduct, + addBarber, + updateBarber, + deleteBarber, refreshShops, }), - [user, shops, cart, appointments] + [user, shops, cart, appointments, orders] ); - // Loading Shield evita quebra generalizada se o app renderizar sem BD disponível if (loading) return null; return {children}; }; -// Hook prático de acesso central sem import múltiplo do 'useContext' em toda aplicação export const useApp = () => { const ctx = useContext(AppContext); if (!ctx) throw new Error('useApp deve ser usado dentro de AppProvider'); diff --git a/src/lib/supabase.ts b/src/lib/supabase.ts new file mode 100644 index 0000000..d9c3c58 --- /dev/null +++ b/src/lib/supabase.ts @@ -0,0 +1,7 @@ +import { createClient } from '@supabase/supabase-js' + +const supabaseUrl = 'https://jqklhhpyykzrktikjnmb.supabase.co' +const supabaseAnonKey = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Impxa2xoaHB5eWt6cmt0aWtqbm1iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NjgzODQ0MDgsImV4cCI6MjA4Mzk2MDQwOH0.QsPuBnyUtRPSavlqKj3IGR9c8juT02LY_hSi-j3c6M0' + +export const supabase = createClient(supabaseUrl, supabaseAnonKey) diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 6fed13e..bd8ba5d 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -22,7 +22,7 @@ export default function AppNavigator() { )} @@ -170,8 +170,8 @@ const styles = StyleSheet.create({ alignItems: 'center', }, roleButtonActive: { - borderColor: '#f59e0b', - backgroundColor: '#fef3c7', + borderColor: '#6366f1', + backgroundColor: '#c7d2fe', }, roleText: { fontSize: 14, @@ -179,7 +179,7 @@ const styles = StyleSheet.create({ color: '#64748b', }, roleTextActive: { - color: '#f59e0b', + color: '#6366f1', }, submitButton: { width: '100%', @@ -199,7 +199,7 @@ const styles = StyleSheet.create({ }, footerLink: { fontSize: 14, - color: '#f59e0b', + color: '#6366f1', fontWeight: '600', }, }); diff --git a/src/pages/Booking.tsx b/src/pages/Booking.tsx index baa9762..4806a08 100644 --- a/src/pages/Booking.tsx +++ b/src/pages/Booking.tsx @@ -248,8 +248,8 @@ const styles = StyleSheet.create({ marginBottom: 8, }, serviceButtonActive: { - borderColor: '#f59e0b', - backgroundColor: '#fef3c7', + borderColor: '#6366f1', + backgroundColor: '#e0e7ff', }, serviceText: { fontSize: 14, @@ -258,7 +258,7 @@ const styles = StyleSheet.create({ marginBottom: 4, }, serviceTextActive: { - color: '#f59e0b', + color: '#6366f1', }, servicePrice: { fontSize: 12, @@ -278,8 +278,8 @@ const styles = StyleSheet.create({ borderColor: '#e2e8f0', }, barberButtonActive: { - borderColor: '#f59e0b', - backgroundColor: '#f59e0b', + borderColor: '#6366f1', + backgroundColor: '#6366f1', }, barberText: { fontSize: 14, @@ -303,8 +303,8 @@ const styles = StyleSheet.create({ borderColor: '#e2e8f0', }, slotButtonActive: { - borderColor: '#f59e0b', - backgroundColor: '#f59e0b', + borderColor: '#6366f1', + backgroundColor: '#6366f1', }, slotText: { fontSize: 14, @@ -339,7 +339,7 @@ const styles = StyleSheet.create({ summaryTotal: { fontSize: 18, fontWeight: 'bold', - color: '#f59e0b', + color: '#6366f1', marginTop: 8, }, submitButton: { diff --git a/src/pages/Cart.tsx b/src/pages/Cart.tsx index 28a002b..fe2614b 100644 --- a/src/pages/Cart.tsx +++ b/src/pages/Cart.tsx @@ -181,7 +181,7 @@ const styles = StyleSheet.create({ total: { fontSize: 18, fontWeight: 'bold', - color: '#f59e0b', + color: '#6366f1', }, item: { flexDirection: 'row', diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index af1c66a..92bda56 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -126,7 +126,7 @@ export default function Dashboard() { * Atualiza a quantidade de inventário de um produto iterando a variação (+1/-1). * Desencadeia um update para a tabela products (ex: `supabase.from('products').update(...)`) evitando stocks negativos. * @param {string} productId - O identificador único do produto afetado. - * @param {number} delta - A quantidade exata matemática a variar da realidade material. + * @param {number} delta - A quantidade exata matemática a variar do inventário. */ const updateProductStock = (productId: string, delta: number) => { const product = shop.products.find((p) => p.id === productId); @@ -206,7 +206,7 @@ export default function Dashboard() { {svc?.name ?? 'Serviço'} {barber?.name} · {a.date} - + {a.status} @@ -247,7 +247,7 @@ export default function Dashboard() { {currency(o.total)} {new Date(o.createdAt).toLocaleString('pt-BR')} - + {o.status} @@ -438,7 +438,7 @@ const styles = StyleSheet.create({ borderBottomColor: 'transparent', }, tabActive: { - borderBottomColor: '#f59e0b', + borderBottomColor: '#6366f1', }, tabText: { fontSize: 14, @@ -446,7 +446,7 @@ const styles = StyleSheet.create({ color: '#64748b', }, tabTextActive: { - color: '#f59e0b', + color: '#6366f1', }, content: { flex: 1, @@ -476,15 +476,15 @@ const styles = StyleSheet.create({ color: '#0f172a', }, statValueWarning: { - color: '#f59e0b', + color: '#6366f1', }, itemCard: { marginBottom: 12, padding: 16, }, itemCardWarning: { - borderColor: '#fbbf24', - backgroundColor: '#fef3c7', + borderColor: '#c7d2fe', + backgroundColor: '#e0e7ff', }, itemHeader: { flexDirection: 'row', @@ -506,7 +506,7 @@ const styles = StyleSheet.create({ itemPrice: { fontSize: 18, fontWeight: 'bold', - color: '#f59e0b', + color: '#6366f1', }, statusSelector: { marginTop: 8, @@ -534,15 +534,15 @@ const styles = StyleSheet.create({ color: '#64748b', }, alertCard: { - backgroundColor: '#fef3c7', - borderColor: '#fbbf24', + backgroundColor: '#e0e7ff', + borderColor: '#c7d2fe', marginBottom: 16, padding: 16, }, alertText: { fontSize: 14, fontWeight: '600', - color: '#92400e', + color: '#4338ca', }, formCard: { marginTop: 16, diff --git a/src/pages/Explore.tsx b/src/pages/Explore.tsx index 9de7344..870db5d 100644 --- a/src/pages/Explore.tsx +++ b/src/pages/Explore.tsx @@ -35,7 +35,7 @@ export default function Explore() { {shop.name} - {shop.rating.toFixed(1)} ⭐ + {shop.rating.toFixed(1)} ⭐ {shop.address} diff --git a/src/pages/Landing.tsx b/src/pages/Landing.tsx index 960041b..2ec6453 100644 --- a/src/pages/Landing.tsx +++ b/src/pages/Landing.tsx @@ -34,7 +34,7 @@ export default function Landing() { Explorar barbearias - {/* Botão nativo focado à inserção de utilizadores - Cria sessão no ecositema de Auth/BD */} + {/* Botão focado no registo de novos utilizadores */}