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.
This commit is contained in:
2026-05-06 11:36:11 +01:00
parent a065130167
commit 99fc0a3882
8 changed files with 809 additions and 165 deletions

23
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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<Barber, 'id'>) => Promise<void>;
updateBarber: (shopId: string, barber: Barber) => Promise<void>;
deleteBarber: (shopId: string, barberId: string) => Promise<void>;
updateShopDetails: (shopId: string, payload: Partial<BarberShop>) => Promise<void>;
submitReview: (shopId: string, appointmentId: string, rating: number, comment: string) => Promise<void>;
joinWaitlist: (shopId: string, serviceId: string, barberId: string, date: string) => Promise<boolean>;
markNotificationRead: (id: string) => Promise<void>;
refreshShops: () => Promise<void>;
@@ -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<User | undefined> => {
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<Service, 'id'>) => {
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<BarberShop>) => {
const { services, products, barbers, id, imageUrl, paymentMethods, socialNetworks, contacts, schedule, ...rest } = payload;
const updateData: Record<string, any> = { ...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<Appointment, 'id' | 'status' | 'total'>) => {
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,

View File

@@ -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 {}
}
}

View File

@@ -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({
},
});

View File

@@ -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() {
</View>
)}
{activeTab === 'history' && (
<View>
<Text style={[styles.title, { marginBottom: 12 }]}>Histórico de Agendamentos</Text>
{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 (
<Card key={a.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<View>
<Text style={styles.itemName}>{svc?.name ?? 'Serviço'}</Text>
<Text style={styles.itemDesc}>{barber?.name ?? 'Barbeiro'} · {a.date}</Text>
<Text style={styles.itemDesc}>{currency(a.total)}</Text>
</View>
<Badge color={a.status === 'concluido' ? 'green' : 'red'}>
{a.status === 'concluido' ? 'Concluído' : 'Cancelado'}
</Badge>
</View>
</Card>
);
})
) : (
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Ainda não registos concluídos ou cancelados.</Text>
</Card>
)}
</View>
)}
{activeTab === 'services' && (
<View>
{shop.services.map((s) => (
@@ -356,23 +524,32 @@ export default function Dashboard() {
.map((b) => (
<Card key={b.id} style={styles.barberCard}>
<View style={styles.barberAvatar}>
<Text style={{ color: '#6366f1', fontWeight: 'bold', fontSize: 18 }}>{b.name.charAt(0)}</Text>
{b.imageUrl ? (
<Image source={{ uri: b.imageUrl }} style={styles.barberAvatarImage} />
) : (
<Text style={{ color: '#6366f1', fontWeight: 'bold', fontSize: 18 }}>{b.name.charAt(0)}</Text>
)}
</View>
<View style={styles.barberInfo}>
<Text style={styles.barberNameText}>{b.name}</Text>
<Text style={styles.barberSpecialtyText}>{b.specialties.join(', ') || 'Barbeiro'}</Text>
</View>
<TouchableOpacity
onPress={() => {
Alert.alert('Confirmar', 'Deseja remover este profissional?', [
{ text: 'Cancelar', style: 'cancel' },
{ text: 'Remover', style: 'destructive', onPress: () => deleteBarber(shop.id, b.id) },
]);
}}
style={styles.deleteBarberBtn}
>
<Text style={{ color: '#ef4444', fontSize: 12, fontWeight: 'bold' }}>Remover</Text>
</TouchableOpacity>
<View style={styles.barberActions}>
<TouchableOpacity onPress={() => uploadBarberImage(b.id)} style={styles.photoBarberBtn} disabled={uploadingImage}>
<Text style={{ color: '#6366f1', fontSize: 12, fontWeight: 'bold' }}>Foto</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
Alert.alert('Confirmar', 'Deseja remover este profissional?', [
{ text: 'Cancelar', style: 'cancel' },
{ text: 'Remover', style: 'destructive', onPress: () => deleteBarber(shop.id, b.id) },
]);
}}
style={styles.deleteBarberBtn}
>
<Text style={{ color: '#ef4444', fontSize: 12, fontWeight: 'bold' }}>Remover</Text>
</TouchableOpacity>
</View>
</Card>
))}
@@ -392,6 +569,38 @@ export default function Dashboard() {
</Card>
</View>
)}
{activeTab === 'settings' && (
<View>
<Card style={styles.formCard}>
<Text style={styles.formTitle}>Definições da Barbearia</Text>
<Input label="Nome da Barbearia" value={editShopName} onChangeText={setEditShopName} placeholder="Nome da barbearia" />
<Input label="Morada Completa" value={editShopAddress} onChangeText={setEditShopAddress} placeholder="Rua, Número, Localidade" />
<Input label="URL da Foto de Capa" value={editImageUrl} onChangeText={setEditImageUrl} placeholder="https://..." autoCapitalize="none" />
{!!editImageUrl && <Image source={{ uri: editImageUrl }} style={styles.coverPreview} />}
<Button variant="outline" onPress={uploadCoverImage} loading={uploadingImage} style={styles.addButton}>
Carregar Foto de Capa
</Button>
<Input label="Telefone Principal" value={editPhone1} onChangeText={(text) => setEditPhone1(text.replace(/\D/g, '').slice(0, 9))} keyboardType="phone-pad" placeholder="910000000" />
<Input label="Telefone Secundário" value={editPhone2} onChangeText={(text) => setEditPhone2(text.replace(/\D/g, '').slice(0, 9))} keyboardType="phone-pad" placeholder="252000000" />
<Input label="WhatsApp" value={editWhatsapp} onChangeText={setEditWhatsapp} placeholder="https://wa.me/351910000000" autoCapitalize="none" />
<Input label="Instagram" value={editInstagram} onChangeText={setEditInstagram} placeholder="https://instagram.com/suabarbearia" autoCapitalize="none" />
<Input label="Facebook" value={editFacebook} onChangeText={setEditFacebook} placeholder="https://facebook.com/suabarbearia" autoCapitalize="none" />
<Input label="Métodos de pagamento" value={editPaymentMethods} onChangeText={setEditPaymentMethods} placeholder="Dinheiro, Cartão, MBWay" />
<Text style={styles.settingsHint}>Horário em JSON. Mantém a estrutura day/open/close/closed.</Text>
<Input
label="Horário"
value={editScheduleJson}
onChangeText={setEditScheduleJson}
multiline
style={styles.scheduleInput}
/>
<Button onPress={saveSettings} style={styles.addButton}>
Guardar Alterações
</Button>
</Card>
</View>
)}
</ScrollView>
</SafeAreaView>
);
@@ -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',
},
});

View File

@@ -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<string, 'indigo' | 'green' | 'slate' | 'red'> = {
pendente: 'indigo',
confirmado: 'green',
@@ -18,117 +18,284 @@ const statusColor: Record<string, 'indigo' | 'green' | 'slate' | 'red'> = {
cancelado: 'red',
};
const statusLabel: Record<string, string> = {
pendente: 'Pendente',
confirmado: 'Confirmado',
concluido: 'Concluído',
cancelado: 'Cancelado',
};
type Tab = 'favoritos' | 'agenda' | 'pedidos';
export default function Profile() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
// Obtém sessão do utilizador (auth) e listas globais da BD (appointments e orders)
const { user, appointments, orders, shops, logout, notifications, markNotificationRead } = useApp();
const {
user,
appointments,
orders,
shops,
favorites,
logout,
notifications,
markNotificationRead,
submitReview,
} = useApp();
const [activeTab, setActiveTab] = useState<Tab>('favoritos');
const [reviewedAppointments, setReviewedAppointments] = useState<Set<string>>(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 (
<SafeAreaView style={styles.container}>
<Text>Faça login para ver o perfil</Text>
<View style={styles.centerState}>
<Text style={styles.centerTitle}>Sessão expirada</Text>
<Text style={styles.centerText}>Faz login para aceder ao teu perfil.</Text>
<Button style={styles.darkButton} onPress={() => navigation.replace('Login')}>
Iniciar Sessão
</Button>
</View>
</SafeAreaView>
);
}
// 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)
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content}>
{/* Bloco de identificação do Utilizador validado pela API (Supabase Auth) */}
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
<Card style={styles.profileCard}>
<Text style={styles.profileName}>Olá, {user.name}</Text>
<Text style={styles.profileEmail}>{user.email}</Text>
{/* Distanciamento visual e lógica dos tipos de perfil 'role' presentes na BD */}
<Badge color="indigo" style={styles.roleBadge}>
{user.role === 'cliente' ? 'Cliente' : 'Barbearia'}
</Badge>
{/* Limpa a sessão ativa e tokens memorizados de Login */}
<Button onPress={logout} variant="outline" style={styles.logoutButton}>
Sair
</Button>
<View style={styles.avatar}>
<Text style={styles.avatarText}>{user.name.charAt(0).toUpperCase()}</Text>
</View>
<View style={styles.profileInfo}>
<Text style={styles.profileName} numberOfLines={1}>{user.name}</Text>
<Text style={styles.profileEmail} numberOfLines={1}>{user.email}</Text>
<Badge color="indigo" style={styles.roleBadge}>
{user.role === 'cliente' ? 'Cliente' : 'Barbearia'}
</Badge>
</View>
</Card>
<Button onPress={() => navigation.navigate('EventsCreate')} style={styles.eventButton}>
Criar evento
</Button>
<View style={styles.quickActions}>
<Button onPress={() => navigation.navigate('EventsCreate')} style={styles.darkButton}>
Criar evento
</Button>
<Button onPress={logout} variant="outline" style={styles.outlineButton}>
Sair
</Button>
</View>
{myNotifications.length > 0 && (
<>
<View>
<Text style={styles.sectionTitle}>Notificações</Text>
{myNotifications.map((n) => (
<Card key={n.id} style={[styles.itemCard, { borderColor: '#fecdd3', borderWidth: 1 }]}>
<View style={styles.itemHeader}>
<Text style={[styles.itemName, { color: '#e11d48' }]}>🔔 Nova Vaga!</Text>
</View>
<Text style={{ fontSize: 14, color: '#334155', marginBottom: 16 }}>{n.message}</Text>
<Button onPress={() => markNotificationRead(n.id)} variant="outline" style={{ backgroundColor: '#f1f5f9' }}>
Marcar Lida
</Button>
{myNotifications.map((notification) => (
<Card key={notification.id} style={[styles.itemCard, !notification.read && styles.notificationUnread]}>
<Text style={styles.notificationTitle}>{notification.read ? 'Notificação' : 'Nova vaga'}</Text>
<Text style={styles.itemDate}>{notification.message}</Text>
{!notification.read && (
<Button onPress={() => markNotificationRead(notification.id)} variant="outline" style={styles.smallAction}>
Marcar lida
</Button>
)}
</Card>
))}
</>
</View>
)}
<Text style={styles.sectionTitle}>As Minhas Reservas</Text>
{/* 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 (
<Card key={a.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
{/* Nome exibido pós JOIN de array em memória */}
<Text style={styles.itemName}>{shop?.name}</Text>
{/* O status (persistido na BD) influencia a cor devolvida ao Badge */}
<Badge color={statusColor[a.status]}>{a.status}</Badge>
</View>
<Text style={styles.itemDate}>{a.date}</Text>
<Text style={styles.itemTotal}>{currency(a.total)}</Text>
</Card>
);
})
) : (
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Nenhum agendamento ainda</Text>
{reviewTarget && (
<Card style={styles.reviewCard}>
<Text style={styles.sectionTitle}>Avaliar atendimento</Text>
<Text style={styles.centerText}>{reviewTarget.shopName}</Text>
<View style={styles.stars}>
{[1, 2, 3, 4, 5].map((star) => (
<TouchableOpacity key={star} onPress={() => setRating(star)} style={styles.starButton}>
<Text style={[styles.starText, star <= rating && styles.starActive]}></Text>
</TouchableOpacity>
))}
</View>
<TextInput
value={comment}
onChangeText={setComment}
placeholder="Comentário opcional"
placeholderTextColor="#94a3b8"
multiline
style={styles.commentInput}
/>
<View style={styles.reviewActions}>
<Button variant="outline" style={styles.reviewButton} onPress={resetReview}>
Cancelar
</Button>
<Button style={styles.reviewButton} onPress={handleReviewSubmit} disabled={rating === 0} loading={submittingReview}>
Enviar
</Button>
</View>
</Card>
)}
<Text style={styles.sectionTitle}>As Minhas Compras</Text>
{/* 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 (
<Card key={o.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<Text style={styles.itemName}>{shop?.name}</Text>
<Badge color={statusColor[o.status]}>{o.status}</Badge>
</View>
<Text style={styles.itemDate}>
{/* Formatação Timestamp temporal da BD (createdAt) para modo visual PT */}
{new Date(o.createdAt).toLocaleString('pt-BR')}
</Text>
<Text style={styles.itemTotal}>{currency(o.total)}</Text>
<View style={styles.tabs}>
{[
['favoritos', 'Favoritos', favoriteShops.length],
['agenda', 'Agenda', myAppointments.length],
['pedidos', 'Pedidos', myOrders.length],
].map(([id, label, count]) => (
<TouchableOpacity
key={String(id)}
style={[styles.tab, activeTab === id && styles.tabActive]}
onPress={() => setActiveTab(id as Tab)}
>
<Text style={[styles.tabText, activeTab === id && styles.tabTextActive]}>{label}</Text>
<Text style={[styles.tabCount, activeTab === id && styles.tabTextActive]}>{count}</Text>
</TouchableOpacity>
))}
</View>
{activeTab === 'favoritos' && (
<View>
<Text style={styles.sectionTitle}>Cofre de Favoritos</Text>
{favoriteShops.length ? favoriteShops.map((shop) => (
<TouchableOpacity key={shop.id} onPress={() => navigation.navigate('ShopDetails', { shopId: shop.id })}>
<Card style={styles.itemCard}>
<View style={styles.itemHeader}>
<Text style={styles.itemName}>{shop.name}</Text>
<Text style={styles.ratingPill}>{shop.rating.toFixed(1)}</Text>
</View>
<Text style={styles.itemDate}>{shop.address}</Text>
</Card>
</TouchableOpacity>
)) : (
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Nenhuma barbearia favorita ainda.</Text>
</Card>
);
})
) : (
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Nenhum pedido ainda</Text>
</Card>
)}
</View>
)}
{activeTab === 'agenda' && (
<View>
<Text style={styles.sectionTitle}>Minha Agenda</Text>
{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 (
<Card key={appointment.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<Text style={styles.itemName}>{shop?.name || 'Barbearia'}</Text>
<Badge color={statusColor[appointment.status]}>{statusLabel[appointment.status]}</Badge>
</View>
<Text style={styles.itemDate}>{appointment.date}</Text>
{!!service && <Text style={styles.itemDate}>{service.name} · {service.duration} min</Text>}
<Text style={styles.itemTotal}>{currency(appointment.total)}</Text>
{canReview ? (
<Button
style={styles.smallAction}
onPress={() => {
setReviewTarget({
appointmentId: appointment.id,
shopId: appointment.shopId,
shopName: shop?.name || 'Barbearia',
});
}}
>
Avaliar agora
</Button>
) : appointment.status === 'concluido' ? (
<Text style={styles.reviewedText}>Avaliado</Text>
) : null}
</Card>
);
}) : (
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Sem agendamentos futuros.</Text>
</Card>
)}
</View>
)}
{activeTab === 'pedidos' && (
<View>
<Text style={styles.sectionTitle}>Meus Pedidos</Text>
{myOrders.length ? myOrders.map((order) => {
const shop = shops.find((s) => s.id === order.shopId);
return (
<Card key={order.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<Text style={styles.itemName}>{shop?.name || 'Barbearia'}</Text>
<Badge color={statusColor[order.status]}>{statusLabel[order.status]}</Badge>
</View>
<Text style={styles.itemDate}>{new Date(order.createdAt).toLocaleString('pt-PT')}</Text>
<Text style={styles.itemDate}>{order.items.length} item(s)</Text>
<Text style={styles.itemTotal}>{currency(order.total)}</Text>
</Card>
);
}) : (
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Ainda não compraste produtos.</Text>
</Card>
)}
</View>
)}
</ScrollView>
</SafeAreaView>
@@ -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,
},
});

View File

@@ -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) => {