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:
23
package-lock.json
generated
23
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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 há 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',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user