From 99fc0a3882856bdcf724a8942a69b6ab3ca0bb4a Mon Sep 17 00:00:00 2001 From: 230417 <230417@epvc.pt> Date: Wed, 6 May 2026 11:36:11 +0100 Subject: [PATCH] feat: enhance booking flow with optional serviceId and improve dashboard settings - Updated RootStackParamList to include optional serviceId in Booking route. - Modified Booking page to initialize step and serviceId based on route parameters. - Enhanced Dashboard page with new settings tab for shop details, including image upload and JSON schedule management. - Added functionality to upload barber images and cover images. - Improved Profile page with review submission for appointments and better notification handling. - Updated ShopDetails to pass serviceId when navigating to Booking. --- package-lock.json | 23 ++ package.json | 2 + src/context/AppContext.tsx | 142 +++++++--- src/navigation/types.ts | 3 +- src/pages/Booking.tsx | 7 +- src/pages/Dashboard.tsx | 270 +++++++++++++++++-- src/pages/Profile.tsx | 525 +++++++++++++++++++++++++++++-------- src/pages/ShopDetails.tsx | 2 +- 8 files changed, 809 insertions(+), 165 deletions(-) diff --git a/package-lock.json b/package-lock.json index a783994..9dde4b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "@react-navigation/native-stack": "^6.9.17", "@supabase/supabase-js": "^2.99.1", "expo": "~54.0.33", + "expo-constants": "~18.0.13", "expo-device": "~8.0.10", + "expo-image-picker": "~17.0.11", "expo-notifications": "~0.32.16", "expo-status-bar": "~3.0.9", "nanoid": "^5.0.7", @@ -4953,6 +4955,27 @@ "react-native": "*" } }, + "node_modules/expo-image-loader": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz", + "integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "17.0.11", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.11.tgz", + "integrity": "sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~6.0.0" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", diff --git a/package.json b/package.json index c66858e..aa3a2f8 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "@react-navigation/native-stack": "^6.9.17", "@supabase/supabase-js": "^2.99.1", "expo": "~54.0.33", + "expo-constants": "~18.0.13", "expo-device": "~8.0.10", + "expo-image-picker": "~17.0.11", "expo-notifications": "~0.32.16", "expo-status-bar": "~3.0.9", "nanoid": "^5.0.7", diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx index 5d3ef4e..e2f5245 100644 --- a/src/context/AppContext.tsx +++ b/src/context/AppContext.tsx @@ -7,6 +7,7 @@ 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 { storage } from '../lib/storage'; import * as Device from 'expo-device'; import Constants from 'expo-constants'; import { Platform } from 'react-native'; @@ -45,6 +46,8 @@ type AppContextValue = State & { addBarber: (shopId: string, barber: Omit) => Promise; updateBarber: (shopId: string, barber: Barber) => Promise; deleteBarber: (shopId: string, barberId: string) => Promise; + updateShopDetails: (shopId: string, payload: Partial) => Promise; + submitReview: (shopId: string, appointmentId: string, rating: number, comment: string) => Promise; joinWaitlist: (shopId: string, serviceId: string, barberId: string, date: string) => Promise; markNotificationRead: (id: string) => Promise; refreshShops: () => Promise; @@ -64,6 +67,22 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { const [shopsReady, setShopsReady] = useState(false); const [loading, setLoading] = useState(true); + useEffect(() => { + let mounted = true; + storage.get<{ favorites?: string[]; cart?: CartItem[] }>('smart-agenda-mobile-state', {}).then((stored) => { + if (!mounted) return; + if (Array.isArray(stored.favorites)) setFavorites(stored.favorites); + if (Array.isArray(stored.cart)) setCart(stored.cart); + }); + return () => { + mounted = false; + }; + }, []); + + useEffect(() => { + storage.set('smart-agenda-mobile-state', { favorites, cart }); + }, [favorites, cart]); + const applySupabaseUser = async (authUser: any): Promise => { if (!authUser) return undefined; @@ -113,39 +132,47 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { const { data: ordersData } = await supabase.from('orders').select('*'); const { data: waitlistsData } = await supabase.from('waitlist').select('*'); const { data: notificationsData } = await supabase.from('notifications').select('*'); + const { data: reviewsData } = await supabase.from('reviews').select('shop_id, rating'); if (shopsData) { - const merged: BarberShop[] = shopsData.map((shop: any) => ({ - 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 || 0, - duration: s.duration || 30, - barberIds: s.barber_ids || [], - })), - 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 || [], - })), - })); + const merged: BarberShop[] = shopsData.map((shop: any) => { + const shopReviews = (reviewsData || []).filter((r: any) => r.shop_id === shop.id); + const avgRating = shopReviews.length + ? shopReviews.reduce((sum: number, r: any) => sum + (r.rating || 0), 0) / shopReviews.length + : shop.rating || 0; + + return { + id: shop.id, + name: shop.name, + address: shop.address || '', + rating: avgRating, + 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 || 0, + duration: s.duration || 30, + barberIds: s.barber_ids || [], + })), + 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 || [], + })), + }; + }); setShops(merged); } @@ -334,13 +361,23 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { const clearCart = () => setCart([]); const addService = async (shopId: string, service: Omit) => { - await supabase.from('services').insert([{ shop_id: shopId, ...service }]); + await supabase.from('services').insert([{ + shop_id: shopId, + name: service.name, + price: service.price, + duration: service.duration, + barber_ids: service.barberIds || [], + }]); await refreshShops(); }; const updateService = async (shopId: string, service: Service) => { - const { id, ...data } = service; - await supabase.from('services').update(data).eq('id', id); + await supabase.from('services').update({ + name: service.name, + price: service.price, + duration: service.duration, + barber_ids: service.barberIds || [], + }).eq('id', service.id); await refreshShops(); }; @@ -391,6 +428,41 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { await refreshShops(); }; + const updateShopDetails = async (shopId: string, payload: Partial) => { + const { services, products, barbers, id, imageUrl, paymentMethods, socialNetworks, contacts, schedule, ...rest } = payload; + const updateData: Record = { ...rest }; + if (imageUrl !== undefined) updateData.image_url = imageUrl; + if (paymentMethods !== undefined) updateData.payment_methods = paymentMethods; + if (socialNetworks !== undefined) updateData.social_networks = socialNetworks; + if (contacts !== undefined) updateData.contacts = contacts; + if (schedule !== undefined) updateData.schedule = schedule; + + const { error } = await supabase.from('shops').update(updateData).eq('id', shopId); + if (error) throw error; + + if (payload.name) { + await supabase.from('profiles').update({ shop_name: payload.name }).eq('shop_id', shopId); + } + + setShops((prev) => prev.map((shop) => (shop.id === shopId ? { ...shop, ...payload } : shop))); + }; + + const submitReview = async (shopId: string, appointmentId: string, rating: number, comment: string) => { + const { data } = await supabase.auth.getUser(); + const customerId = data.user?.id; + if (!customerId) throw new Error('Sessão expirada. Faz login novamente.'); + + const { error } = await supabase.from('reviews').insert([{ + shop_id: shopId, + customer_id: customerId, + appointment_id: appointmentId, + rating, + comment: comment.trim() || null, + }]); + if (error) throw error; + 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; @@ -493,6 +565,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { addBarber, updateBarber, deleteBarber, + updateShopDetails, + submitReview, joinWaitlist, markNotificationRead, refreshShops, diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 2a2c962..0d1ba1d 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -4,7 +4,7 @@ export type RootStackParamList = { Register: undefined; Explore: undefined; ShopDetails: { shopId: string }; - Booking: { shopId: string }; + Booking: { shopId: string; serviceId?: string }; Cart: undefined; Profile: undefined; Dashboard: undefined; @@ -16,4 +16,3 @@ declare global { interface RootParamList extends RootStackParamList {} } } - diff --git a/src/pages/Booking.tsx b/src/pages/Booking.tsx index ba397ac..13f5576 100644 --- a/src/pages/Booking.tsx +++ b/src/pages/Booking.tsx @@ -17,13 +17,13 @@ import { currency } from '../lib/format'; export default function Booking() { const route = useRoute(); const navigation = useNavigation(); - const { shopId } = route.params as { shopId: string }; + const { shopId, serviceId: initialServiceId } = route.params as { shopId: string; serviceId?: string }; const { shops, createAppointment, user, appointments, waitlists, joinWaitlist } = useApp(); const shop = useMemo(() => shops.find((s) => s.id === shopId), [shops, shopId]); // Gestão de Steps - const [step, setStep] = useState(1); + const [step, setStep] = useState(initialServiceId ? 2 : 1); const steps = [ { id: 1, label: 'Serviço' }, { id: 2, label: 'Barbeiro' }, @@ -32,7 +32,7 @@ export default function Booking() { ]; // Estados de Seleção - const [serviceId, setService] = useState(''); + const [serviceId, setService] = useState(initialServiceId || ''); const [barberId, setBarber] = useState(''); const [date, setDate] = useState(new Date().toISOString().split('T')[0]); const [slot, setSlot] = useState(''); @@ -626,4 +626,3 @@ const styles = StyleSheet.create({ }, }); - diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 1f7baa8..81d9093 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -4,16 +4,18 @@ * do tipo 'barbearia'. Permite a gestão integral do negócio: marcações, pedidos, * serviços prestados, produtos e equipa (barbeiros). */ -import React, { useState } from 'react'; -import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Alert } from 'react-native'; +import React, { useEffect, useState } from 'react'; +import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Alert, Image } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; +import * as ImagePicker from 'expo-image-picker'; import { useApp } from '../context/AppContext'; import { Card } from '../components/ui/Card'; import { Button } from '../components/ui/Button'; import { Input } from '../components/ui/Input'; import { Badge } from '../components/ui/Badge'; import { currency } from '../lib/format'; +import { supabase } from '../lib/supabase'; export default function Dashboard() { const navigation = useNavigation(); @@ -32,12 +34,14 @@ export default function Dashboard() { deleteProduct, deleteService, deleteBarber, + updateBarber, + updateShopDetails, logout, } = useApp(); // Garante a entidade da barbearia atual baseada na Foreign Key armazenada no utilizador logado const shop = shops.find((s) => s.id === user?.shopId); - const [activeTab, setActiveTab] = useState<'overview' | 'appointments' | 'orders' | 'services' | 'products' | 'barbers'>('overview'); + const [activeTab, setActiveTab] = useState<'overview' | 'appointments' | 'history' | 'orders' | 'services' | 'products' | 'barbers' | 'settings'>('overview'); // Estados locais dos subformulários na página para Adicionar entidades const [svcName, setSvcName] = useState(''); @@ -49,6 +53,39 @@ export default function Dashboard() { const [barberName, setBarberName] = useState(''); const [barberSpecs, setBarberSpecs] = useState(''); const [barberSearchQuery, setBarberSearchQuery] = useState(''); + const [editShopName, setEditShopName] = useState(''); + const [editShopAddress, setEditShopAddress] = useState(''); + const [editImageUrl, setEditImageUrl] = useState(''); + const [editPaymentMethods, setEditPaymentMethods] = useState(''); + const [editPhone1, setEditPhone1] = useState(''); + const [editPhone2, setEditPhone2] = useState(''); + const [editWhatsapp, setEditWhatsapp] = useState(''); + const [editInstagram, setEditInstagram] = useState(''); + const [editFacebook, setEditFacebook] = useState(''); + const [editScheduleJson, setEditScheduleJson] = useState(''); + const [uploadingImage, setUploadingImage] = useState(false); + + useEffect(() => { + if (!shop) return; + setEditShopName(shop.name || ''); + setEditShopAddress(shop.address || ''); + setEditImageUrl(shop.imageUrl || ''); + setEditPaymentMethods((shop.paymentMethods || ['Dinheiro', 'Cartão de Crédito', 'Cartão de Débito']).join(', ')); + setEditPhone1(shop.contacts?.phone1 || ''); + setEditPhone2(shop.contacts?.phone2 || ''); + setEditWhatsapp(shop.socialNetworks?.whatsapp || ''); + setEditInstagram(shop.socialNetworks?.instagram || ''); + setEditFacebook(shop.socialNetworks?.facebook || ''); + setEditScheduleJson(JSON.stringify(shop.schedule || [ + { 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 }, + ], null, 2)); + }, [shop?.id]); // Segurança de Bloqueio - Validação estrita do role do utilziador no componente if (!user || user.role !== 'barbearia' || !shop) { @@ -65,6 +102,7 @@ export default function Dashboard() { const completedAppointments = shopAppointments.filter((a) => a.status === 'concluido'); const activeAppointments = shopAppointments.filter((a) => a.status !== 'concluido'); const productOrders = shopOrders.filter((o) => o.items.some((i) => i.type === 'product')); + const historyAppointments = shopAppointments.filter((a) => a.status === 'concluido' || a.status === 'cancelado'); // Métricas agregadas globais calculadas dinamicamente const totalRevenue = shopOrders.reduce((s, o) => s + o.total, 0); @@ -106,13 +144,113 @@ export default function Dashboard() { updateProduct(shop.id, next); }; + const saveSettings = async () => { + try { + let parsedSchedule = shop.schedule; + if (editScheduleJson.trim()) { + parsedSchedule = JSON.parse(editScheduleJson); + } + + await updateShopDetails(shop.id, { + name: editShopName.trim() || shop.name, + address: editShopAddress.trim(), + imageUrl: editImageUrl.trim() || undefined, + paymentMethods: editPaymentMethods.split(',').map((item) => item.trim()).filter(Boolean), + contacts: { + phone1: editPhone1.trim(), + phone2: editPhone2.trim(), + }, + socialNetworks: { + whatsapp: editWhatsapp.trim(), + instagram: editInstagram.trim(), + facebook: editFacebook.trim(), + }, + schedule: parsedSchedule, + }); + Alert.alert('Sucesso', 'Definições guardadas.'); + } catch (e: any) { + Alert.alert('Erro', e?.message || 'Não foi possível guardar as definições. Confirma o JSON do horário.'); + } + }; + + const pickImage = async () => { + const permission = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + Alert.alert('Permissão necessária', 'Autoriza o acesso às fotos para carregar imagens.'); + return null; + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + quality: 0.85, + allowsEditing: true, + aspect: [4, 3], + }); + + if (result.canceled || !result.assets[0]) return null; + return result.assets[0]; + }; + + const uploadImage = async (asset: ImagePicker.ImagePickerAsset, folder: string) => { + const ext = asset.uri.split('.').pop()?.split('?')[0] || 'jpg'; + const filePath = `${folder}/${shop.id}-${Date.now()}.${ext}`; + const response = await fetch(asset.uri); + const blob = await response.blob(); + + const { error } = await supabase.storage + .from('shops') + .upload(filePath, blob as any, { + contentType: asset.mimeType || blob.type || 'image/jpeg', + }); + if (error) throw error; + + const { data } = supabase.storage.from('shops').getPublicUrl(filePath); + return data.publicUrl; + }; + + const uploadCoverImage = async () => { + const asset = await pickImage(); + if (!asset) return; + try { + setUploadingImage(true); + const publicUrl = await uploadImage(asset, 'covers'); + await updateShopDetails(shop.id, { imageUrl: publicUrl }); + setEditImageUrl(publicUrl); + Alert.alert('Sucesso', 'Foto de capa atualizada.'); + } catch (e: any) { + Alert.alert('Erro', e?.message || 'Não foi possível carregar a imagem.'); + } finally { + setUploadingImage(false); + } + }; + + const uploadBarberImage = async (barberId: string) => { + const barber = shop.barbers.find((item) => item.id === barberId); + if (!barber) return; + + const asset = await pickImage(); + if (!asset) return; + try { + setUploadingImage(true); + const publicUrl = await uploadImage(asset, 'barbers'); + await updateBarber(shop.id, { ...barber, imageUrl: publicUrl }); + Alert.alert('Sucesso', 'Foto do profissional atualizada.'); + } catch (e: any) { + Alert.alert('Erro', e?.message || 'Não foi possível carregar a imagem.'); + } finally { + setUploadingImage(false); + } + }; + const tabs = [ { id: 'overview', label: 'Estatísticas' }, { id: 'appointments', label: 'Reservas' }, + { id: 'history', label: 'Histórico' }, { id: 'orders', label: 'Pedidos' }, { id: 'services', label: 'Serviços' }, { id: 'products', label: 'Produtos' }, { id: 'barbers', label: 'Equipa' }, + { id: 'settings', label: 'Definições' }, ]; return ( @@ -245,6 +383,36 @@ export default function Dashboard() { )} + {activeTab === 'history' && ( + + Histórico de Agendamentos + {historyAppointments.length > 0 ? ( + historyAppointments.map((a) => { + const svc = shop.services.find((s) => s.id === a.serviceId); + const barber = shop.barbers.find((b) => b.id === a.barberId); + return ( + + + + {svc?.name ?? 'Serviço'} + {barber?.name ?? 'Barbeiro'} · {a.date} + {currency(a.total)} + + + {a.status === 'concluido' ? 'Concluído' : 'Cancelado'} + + + + ); + }) + ) : ( + + Ainda não há registos concluídos ou cancelados. + + )} + + )} + {activeTab === 'services' && ( {shop.services.map((s) => ( @@ -356,23 +524,32 @@ export default function Dashboard() { .map((b) => ( - {b.name.charAt(0)} + {b.imageUrl ? ( + + ) : ( + {b.name.charAt(0)} + )} {b.name} {b.specialties.join(', ') || 'Barbeiro'} - { - Alert.alert('Confirmar', 'Deseja remover este profissional?', [ - { text: 'Cancelar', style: 'cancel' }, - { text: 'Remover', style: 'destructive', onPress: () => deleteBarber(shop.id, b.id) }, - ]); - }} - style={styles.deleteBarberBtn} - > - Remover - + + uploadBarberImage(b.id)} style={styles.photoBarberBtn} disabled={uploadingImage}> + Foto + + { + Alert.alert('Confirmar', 'Deseja remover este profissional?', [ + { text: 'Cancelar', style: 'cancel' }, + { text: 'Remover', style: 'destructive', onPress: () => deleteBarber(shop.id, b.id) }, + ]); + }} + style={styles.deleteBarberBtn} + > + Remover + + ))} @@ -392,6 +569,38 @@ export default function Dashboard() { )} + + {activeTab === 'settings' && ( + + + Definições da Barbearia + + + + {!!editImageUrl && } + + setEditPhone1(text.replace(/\D/g, '').slice(0, 9))} keyboardType="phone-pad" placeholder="910000000" /> + setEditPhone2(text.replace(/\D/g, '').slice(0, 9))} keyboardType="phone-pad" placeholder="252000000" /> + + + + + Horário em JSON. Mantém a estrutura day/open/close/closed. + + + + + )} ); @@ -545,6 +754,17 @@ const styles = StyleSheet.create({ color: '#0f172a', marginBottom: 16, }, + settingsHint: { + color: '#64748b', + fontSize: 12, + marginBottom: 8, + }, + scheduleInput: { + minHeight: 180, + textAlignVertical: 'top', + fontSize: 12, + lineHeight: 18, + }, addButton: { width: '100%', marginTop: 8, @@ -592,6 +812,11 @@ const styles = StyleSheet.create({ marginRight: 12, borderWidth: 1, borderColor: '#e2e8f0', + overflow: 'hidden', + }, + barberAvatarImage: { + width: '100%', + height: '100%', }, barberInfo: { flex: 1, @@ -605,13 +830,24 @@ const styles = StyleSheet.create({ fontSize: 12, color: '#64748b', }, + barberActions: { + alignItems: 'flex-end', + gap: 6, + }, + photoBarberBtn: { + padding: 8, + }, deleteBarberBtn: { padding: 8, }, + coverPreview: { + width: '100%', + height: 160, + borderRadius: 12, + marginBottom: 12, + }, emptyResults: { padding: 24, alignItems: 'center', }, }); - - diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 5f57dd6..6c84793 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Alert, 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'; @@ -9,8 +9,8 @@ import { Badge } from '../components/ui/Badge'; import { Button } from '../components/ui/Button'; import { currency } from '../lib/format'; import { RootStackParamList } from '../navigation/types'; +import { supabase } from '../lib/supabase'; -// Mapeamento visual estático das strings de estado do Postgres/State para cores da UI const statusColor: Record = { pendente: 'indigo', confirmado: 'green', @@ -18,117 +18,284 @@ const statusColor: Record = { cancelado: 'red', }; +const statusLabel: Record = { + pendente: 'Pendente', + confirmado: 'Confirmado', + concluido: 'Concluído', + cancelado: 'Cancelado', +}; + +type Tab = 'favoritos' | 'agenda' | 'pedidos'; + export default function Profile() { 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(); + const { + user, + appointments, + orders, + shops, + favorites, + logout, + notifications, + markNotificationRead, + submitReview, + } = useApp(); + + const [activeTab, setActiveTab] = useState('favoritos'); + const [reviewedAppointments, setReviewedAppointments] = useState>(new Set()); + const [reviewTarget, setReviewTarget] = useState<{ appointmentId: string; shopId: string; shopName: string } | null>(null); + const [rating, setRating] = useState(0); + const [comment, setComment] = useState(''); + const [submittingReview, setSubmittingReview] = useState(false); + + useEffect(() => { + let mounted = true; + (async () => { + if (!user) return; + const { data } = await supabase + .from('reviews') + .select('appointment_id') + .eq('customer_id', user.id); + if (!mounted || !data) return; + setReviewedAppointments(new Set(data.map((row: any) => row.appointment_id).filter(Boolean))); + })(); + return () => { + mounted = false; + }; + }, [user?.id]); + + const myAppointments = useMemo( + () => (user ? appointments.filter((a) => a.customerId === user.id) : []), + [appointments, user?.id] + ); + const myOrders = useMemo( + () => (user ? orders.filter((o) => o.customerId === user.id) : []), + [orders, user?.id] + ); + const favoriteShops = useMemo( + () => shops.filter((shop) => favorites.includes(shop.id)), + [shops, favorites] + ); + const myNotifications = useMemo( + () => + user + ? notifications + .filter((n) => n.userId === user.id) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + : [], + [notifications, user?.id] + ); - // Guarda/Bloqueio protetor para forçar navegação ou alertar utilizadores anónimos if (!user) { return ( - Faça login para ver o perfil + + Sessão expirada + Faz login para aceder ao teu perfil. + + ); } - // Filtragem (equivalente a queries com cláusula WHERE customerId = ?) para obter o histórico individual - const myAppointments = appointments.filter((a) => a.customerId === user.id); - const myOrders = orders.filter((o) => o.customerId === user.id); - const myNotifications = (notifications || []) - .filter((n) => n.userId === user.id && !n.read) - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + const resetReview = () => { + setReviewTarget(null); + setRating(0); + setComment(''); + }; + + const handleReviewSubmit = async () => { + if (!reviewTarget || rating === 0) return; + try { + setSubmittingReview(true); + await submitReview(reviewTarget.shopId, reviewTarget.appointmentId, rating, comment); + setReviewedAppointments((prev) => new Set([...prev, reviewTarget.appointmentId])); + resetReview(); + Alert.alert('Obrigado', 'A tua avaliação foi enviada.'); + } catch (e: any) { + Alert.alert('Erro', e?.message || 'Erro ao enviar avaliação.'); + } finally { + setSubmittingReview(false); + } + }; return ( - // Contentor com ScrollView adaptável (evita cortes em ecrãs pequenos) - - {/* Bloco de identificação do Utilizador validado pela API (Supabase Auth) */} + - Olá, {user.name} - {user.email} - - {/* Distanciamento visual e lógica dos tipos de perfil 'role' presentes na BD */} - - {user.role === 'cliente' ? 'Cliente' : 'Barbearia'} - - - {/* Limpa a sessão ativa e tokens memorizados de Login */} - + + {user.name.charAt(0).toUpperCase()} + + + {user.name} + {user.email} + + {user.role === 'cliente' ? 'Cliente' : 'Barbearia'} + + - + + + + {myNotifications.length > 0 && ( - <> + Notificações - {myNotifications.map((n) => ( - - - 🔔 Nova Vaga! - - {n.message} - + {myNotifications.map((notification) => ( + + {notification.read ? 'Notificação' : 'Nova vaga'} + {notification.message} + {!notification.read && ( + + )} ))} - + )} - As Minhas Reservas - {/* Renderiza a lista se existirem marcações no percurso deste utilizador */} - {myAppointments.length > 0 ? ( - myAppointments.map((a) => { - // Resolve a associação relacional (a.shopId) obtendo os detalhes da barbearia - const shop = shops.find((s) => s.id === a.shopId); - return ( - - - {/* Nome exibido pós JOIN de array em memória */} - {shop?.name} - - {/* O status (persistido na BD) influencia a cor devolvida ao Badge */} - {a.status} - - {a.date} - {currency(a.total)} - - ); - }) - ) : ( - - Nenhum agendamento ainda + {reviewTarget && ( + + Avaliar atendimento + {reviewTarget.shopName} + + {[1, 2, 3, 4, 5].map((star) => ( + setRating(star)} style={styles.starButton}> + + + ))} + + + + + + )} - As Minhas Compras - {/* Renderiza o histórico de compras de retalho/produtos usando idêntica lógica */} - {myOrders.length > 0 ? ( - myOrders.map((o) => { - const shop = shops.find((s) => s.id === o.shopId); - return ( - - - {shop?.name} - {o.status} - - - {/* Formatação Timestamp temporal da BD (createdAt) para modo visual PT */} - {new Date(o.createdAt).toLocaleString('pt-BR')} - - {currency(o.total)} + + {[ + ['favoritos', 'Favoritos', favoriteShops.length], + ['agenda', 'Agenda', myAppointments.length], + ['pedidos', 'Pedidos', myOrders.length], + ].map(([id, label, count]) => ( + setActiveTab(id as Tab)} + > + {label} + {count} + + ))} + + + {activeTab === 'favoritos' && ( + + Cofre de Favoritos + {favoriteShops.length ? favoriteShops.map((shop) => ( + navigation.navigate('ShopDetails', { shopId: shop.id })}> + + + {shop.name} + {shop.rating.toFixed(1)} + + {shop.address} + + + )) : ( + + Nenhuma barbearia favorita ainda. - ); - }) - ) : ( - - Nenhum pedido ainda - + )} + + )} + + {activeTab === 'agenda' && ( + + Minha Agenda + {myAppointments.length ? myAppointments.map((appointment) => { + const shop = shops.find((s) => s.id === appointment.shopId); + const service = shop?.services.find((s) => s.id === appointment.serviceId); + const canReview = appointment.status === 'concluido' && !reviewedAppointments.has(appointment.id); + + return ( + + + {shop?.name || 'Barbearia'} + {statusLabel[appointment.status]} + + {appointment.date} + {!!service && {service.name} · {service.duration} min} + {currency(appointment.total)} + {canReview ? ( + + ) : appointment.status === 'concluido' ? ( + Avaliado + ) : null} + + ); + }) : ( + + Sem agendamentos futuros. + + )} + + )} + + {activeTab === 'pedidos' && ( + + Meus Pedidos + {myOrders.length ? myOrders.map((order) => { + const shop = shops.find((s) => s.id === order.shopId); + return ( + + + {shop?.name || 'Barbearia'} + {statusLabel[order.status]} + + {new Date(order.createdAt).toLocaleString('pt-PT')} + {order.items.length} item(s) + {currency(order.total)} + + ); + }) : ( + + Ainda não compraste produtos. + + )} + )} @@ -142,66 +309,165 @@ const styles = StyleSheet.create({ }, content: { padding: 16, + gap: 16, + }, + centerState: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 24, + gap: 12, + }, + centerTitle: { + fontSize: 24, + fontWeight: '900', + color: '#0f172a', + textAlign: 'center', + }, + centerText: { + color: '#64748b', + fontSize: 14, + lineHeight: 20, + fontWeight: '500', }, profileCard: { - marginBottom: 24, + backgroundColor: '#020617', + borderRadius: 28, padding: 20, + flexDirection: 'row', + alignItems: 'center', + gap: 16, + }, + avatar: { + width: 72, + height: 72, + borderRadius: 20, + backgroundColor: 'rgba(255,255,255,0.1)', + alignItems: 'center', + justifyContent: 'center', + }, + avatarText: { + color: '#818cf8', + fontSize: 28, + fontWeight: '900', + }, + profileInfo: { + flex: 1, }, profileName: { fontSize: 24, - fontWeight: 'bold', - color: '#0f172a', - marginBottom: 4, + fontWeight: '900', + color: '#fff', }, profileEmail: { - fontSize: 14, - color: '#64748b', - marginBottom: 12, + fontSize: 13, + color: '#94a3b8', + marginTop: 4, }, roleBadge: { alignSelf: 'flex-start', - marginBottom: 16, + marginTop: 10, }, - logoutButton: { - width: '100%', + quickActions: { + flexDirection: 'row', + gap: 12, }, - eventButton: { - width: '100%', - marginBottom: 20, + darkButton: { + flex: 1, backgroundColor: '#0f172a', }, + outlineButton: { + flex: 1, + }, sectionTitle: { fontSize: 20, - fontWeight: 'bold', + fontWeight: '900', color: '#0f172a', marginBottom: 12, - marginTop: 8, }, itemCard: { marginBottom: 12, padding: 16, }, + notificationUnread: { + borderColor: '#fecdd3', + borderWidth: 1, + }, + notificationTitle: { + color: '#e11d48', + fontSize: 15, + fontWeight: '900', + marginBottom: 8, + }, itemHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', + gap: 12, marginBottom: 8, }, itemName: { fontSize: 16, - fontWeight: 'bold', + fontWeight: '900', color: '#0f172a', flex: 1, }, itemDate: { fontSize: 14, color: '#64748b', - marginBottom: 4, + marginBottom: 5, + fontWeight: '500', }, itemTotal: { - fontSize: 16, - fontWeight: 'bold', + fontSize: 18, + fontWeight: '900', color: '#6366f1', + marginTop: 4, + }, + smallAction: { + marginTop: 12, + alignSelf: 'flex-start', + }, + tabs: { + flexDirection: 'row', + backgroundColor: '#fff', + borderRadius: 20, + padding: 6, + gap: 6, + }, + tab: { + flex: 1, + borderRadius: 16, + paddingVertical: 11, + alignItems: 'center', + }, + tabActive: { + backgroundColor: '#0f172a', + }, + tabText: { + color: '#64748b', + fontSize: 11, + fontWeight: '900', + textTransform: 'uppercase', + }, + tabTextActive: { + color: '#818cf8', + }, + tabCount: { + color: '#94a3b8', + fontSize: 10, + fontWeight: '900', + marginTop: 2, + }, + ratingPill: { + color: '#fff', + backgroundColor: '#0f172a', + borderRadius: 999, + overflow: 'hidden', + paddingHorizontal: 10, + paddingVertical: 4, + fontSize: 12, + fontWeight: '900', }, emptyCard: { padding: 32, @@ -210,6 +476,51 @@ const styles = StyleSheet.create({ emptyText: { fontSize: 14, color: '#64748b', + textAlign: 'center', + fontWeight: '600', + }, + reviewCard: { + padding: 18, + }, + stars: { + flexDirection: 'row', + justifyContent: 'center', + gap: 6, + marginVertical: 12, + }, + starButton: { + padding: 3, + }, + starText: { + color: '#cbd5e1', + fontSize: 34, + }, + starActive: { + color: '#818cf8', + }, + commentInput: { + minHeight: 86, + borderWidth: 1, + borderColor: '#e2e8f0', + borderRadius: 14, + padding: 12, + color: '#0f172a', + textAlignVertical: 'top', + fontWeight: '500', + }, + reviewActions: { + flexDirection: 'row', + gap: 10, + marginTop: 12, + }, + reviewButton: { + flex: 1, + }, + reviewedText: { + color: '#16a34a', + fontSize: 12, + fontWeight: '900', + textTransform: 'uppercase', + marginTop: 10, }, }); - diff --git a/src/pages/ShopDetails.tsx b/src/pages/ShopDetails.tsx index 6a28db8..7a3a545 100644 --- a/src/pages/ShopDetails.tsx +++ b/src/pages/ShopDetails.tsx @@ -64,7 +64,7 @@ export default function ShopDetails() { navigation.navigate('Login'); return; } - navigation.navigate('Booking', { shopId: shop.id }); + navigation.navigate('Booking', { shopId: shop.id, serviceId }); }; const addProduct = (productId: string) => {