feat(mobile): alinhar cliente e barbearia ao web com tema, dashboard e reservas completos.

Paridade visual âmbar/slate, explore com ShopCard, painel barbearia com todas as tabs, reservas com fecho/lista de espera, perfil e detalhes da loja enriquecidos, e AppContext com users e notificações.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 17:02:03 +01:00
parent 2fb540d9a2
commit 6a22a234db
15 changed files with 1657 additions and 823 deletions

176
src/components/ShopCard.tsx Normal file
View File

@@ -0,0 +1,176 @@
import React from 'react';
import { Image, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { BarberShop } from '../types';
import { colors, radius, shadows } from '../lib/theme';
type Props = {
shop: BarberShop;
isFavorite?: boolean;
onPress: () => void;
onToggleFavorite?: () => void;
};
export const ShopCard = ({ shop, isFavorite, onPress, onToggleFavorite }: Props) => (
<TouchableOpacity style={styles.card} activeOpacity={0.92} onPress={onPress}>
<View style={styles.imageWrap}>
{shop.imageUrl ? (
<Image source={{ uri: shop.imageUrl }} style={styles.image} />
) : (
<View style={styles.imageFallback}>
<Ionicons name="cut-outline" size={36} color={colors.brandAccent} />
</View>
)}
<View style={styles.imageOverlay} />
<View style={styles.ratingBadge}>
<Ionicons name="star" size={12} color={colors.primary} />
<Text style={styles.ratingText}>{(shop.rating || 0).toFixed(1)}</Text>
</View>
{onToggleFavorite && (
<TouchableOpacity
style={styles.favBtn}
activeOpacity={0.8}
onPress={(e) => {
e.stopPropagation?.();
onToggleFavorite();
}}
>
<Ionicons
name={isFavorite ? 'heart' : 'heart-outline'}
size={16}
color={isFavorite ? colors.danger : colors.textInverse}
/>
</TouchableOpacity>
)}
</View>
<View style={styles.body}>
<Text style={styles.name} numberOfLines={1}>{shop.name}</Text>
<View style={styles.addressRow}>
<Ionicons name="location-outline" size={14} color={colors.primary} />
<Text style={styles.address} numberOfLines={1}>
{shop.address && shop.address !== 'Endereço a definir' ? shop.address : 'Endereço Indisponível'}
</Text>
</View>
<View style={styles.footer}>
<Text style={styles.barbersLabel}>+{(shop.barbers || []).length} Barbeiros</Text>
<View style={styles.cta}>
<Text style={styles.ctaText}>Reservar</Text>
</View>
</View>
</View>
</TouchableOpacity>
);
const styles = StyleSheet.create({
card: {
backgroundColor: colors.surface,
borderRadius: radius.xl,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border,
...shadows.card,
},
imageWrap: {
height: 150,
position: 'relative',
},
image: {
width: '100%',
height: '100%',
},
imageFallback: {
width: '100%',
height: '100%',
backgroundColor: colors.surfaceDark,
alignItems: 'center',
justifyContent: 'center',
},
imageOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(15,23,42,0.25)',
},
ratingBadge: {
position: 'absolute',
top: 12,
right: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: 'rgba(15,23,42,0.9)',
borderRadius: radius.pill,
paddingHorizontal: 10,
paddingVertical: 4,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
},
ratingText: {
color: colors.textInverse,
fontSize: 11,
fontWeight: '800',
},
favBtn: {
position: 'absolute',
top: 12,
left: 12,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(15,23,42,0.55)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.12)',
},
body: {
padding: 16,
gap: 8,
},
name: {
fontSize: 18,
fontWeight: '900',
color: colors.text,
letterSpacing: -0.3,
},
addressRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
address: {
flex: 1,
color: colors.textMuted,
fontSize: 13,
fontWeight: '500',
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 4,
gap: 8,
},
barbersLabel: {
flex: 1,
fontSize: 10,
fontWeight: '800',
color: colors.textSubtle,
textTransform: 'uppercase',
letterSpacing: 0.8,
},
cta: {
paddingHorizontal: 14,
paddingVertical: 8,
backgroundColor: colors.surfaceDark,
borderRadius: radius.md,
minHeight: 36,
alignItems: 'center',
justifyContent: 'center',
},
ctaText: {
color: colors.brandAccent,
fontSize: 12,
fontWeight: '800',
},
});

View File

@@ -11,15 +11,15 @@ type Props = {
const colorMap = {
solid: {
amber: { bg: colors.accent, text: colors.textInverse },
slate: { bg: colors.textSoft, text: colors.textInverse },
amber: { bg: colors.primary, text: colors.textInverse },
slate: { bg: colors.surfaceDark, text: colors.textInverse },
green: { bg: colors.success, text: colors.textInverse },
red: { bg: colors.danger, text: colors.textInverse },
blue: { bg: colors.primary, text: colors.textInverse },
indigo: { bg: colors.primary, text: colors.textInverse },
},
soft: {
amber: { bg: colors.accentSoft, text: '#7a4310' },
amber: { bg: colors.primarySoft, text: colors.primaryDark },
slate: { bg: colors.backgroundAlt, text: colors.textSoft },
green: { bg: colors.successSoft, text: '#166534' },
red: { bg: colors.dangerSoft, text: '#991b1b' },
@@ -29,11 +29,11 @@ const colorMap = {
};
export const Badge = ({ children, color = 'indigo', variant = 'soft', style }: Props) => {
const colors = colorMap[variant][color];
const palette = colorMap[variant][color];
return (
<View style={[styles.badge, { backgroundColor: colors.bg }, style]}>
<Text style={[styles.text, { color: colors.text }]}>{children}</Text>
<View style={[styles.badge, { backgroundColor: palette.bg }, style]}>
<Text style={[styles.text, { color: palette.text }]}>{children}</Text>
</View>
);
};
@@ -50,4 +50,3 @@ const styles = StyleSheet.create({
fontWeight: '600',
},
});

View File

@@ -5,7 +5,7 @@ import { colors, radius, shadows } from '../../lib/theme';
type Props = {
children: React.ReactNode;
onPress?: () => void;
variant?: 'solid' | 'outline' | 'ghost';
variant?: 'solid' | 'outline' | 'ghost' | 'dark';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
@@ -29,6 +29,11 @@ export const Button = ({ children, onPress, variant = 'solid', size = 'md', disa
textStyle,
];
const spinnerColor =
variant === 'solid' ? colors.textInverse :
variant === 'dark' ? colors.brandAccent :
colors.primaryDark;
return (
<TouchableOpacity
style={buttonStyle}
@@ -37,7 +42,7 @@ export const Button = ({ children, onPress, variant = 'solid', size = 'md', disa
activeOpacity={0.7}
>
{loading ? (
<ActivityIndicator size="small" color={variant === 'solid' ? colors.textInverse : colors.primary} />
<ActivityIndicator size="small" color={spinnerColor} />
) : (
<Text style={textStyles}>{children}</Text>
)}
@@ -57,10 +62,14 @@ const styles = StyleSheet.create({
backgroundColor: colors.primary,
...shadows.soft,
},
dark: {
backgroundColor: colors.surfaceDark,
...shadows.soft,
},
outline: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: colors.borderStrong,
backgroundColor: colors.surface,
borderWidth: 2,
borderColor: colors.primary,
},
ghost: {
backgroundColor: 'transparent',
@@ -86,6 +95,9 @@ const styles = StyleSheet.create({
text_solid: {
color: colors.textInverse,
},
text_dark: {
color: colors.brandAccent,
},
text_outline: {
color: colors.primaryDark,
},

View File

@@ -14,7 +14,7 @@ export const Card = ({ children, style }: Props) => {
const styles = StyleSheet.create({
card: {
backgroundColor: colors.surface,
borderRadius: radius.sm,
borderRadius: radius.lg,
padding: 16,
borderWidth: 1,
borderColor: colors.border,

View File

@@ -14,6 +14,7 @@ import { Platform, Alert } from 'react-native';
type State = {
user?: User;
users: User[];
shops: BarberShop[];
shopsReady: boolean;
cart: CartItem[];
@@ -64,6 +65,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const [waitlists, setWaitlists] = useState<WaitlistEntry[]>([]);
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const [user, setUser] = useState<User | undefined>(undefined);
const [users, setUsers] = useState<User[]>([]);
const [shopsReady, setShopsReady] = useState(false);
const [loading, setLoading] = useState(true);
@@ -166,11 +168,22 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
};
useEffect(() => {
const loadUser = async () => {
const { data } = await supabase.auth.getUser();
if (data.user) await applySupabaseUser(data.user);
let mounted = true;
const { data: sub } = supabase.auth.onAuthStateChange((_event, session) => {
if (!mounted) return;
if (session?.user) {
applySupabaseUser(session.user).then(() => refreshShops());
} else {
setUser(undefined);
}
setLoading(false);
});
return () => {
mounted = false;
sub.subscription.unsubscribe();
};
loadUser();
}, []);
const refreshShops = async () => {
@@ -185,6 +198,20 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
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');
const { data: profilesData } = await supabase.from('profiles').select('id, name, role, shop_id');
if (profilesData) {
setUsers(
profilesData.map((p: any) => ({
id: p.id,
name: p.name || 'Utilizador',
email: '',
password: '',
role: p.role === 'barbearia' ? 'barbearia' : 'cliente',
shopId: p.shop_id || undefined,
}))
);
}
if (shopsData) {
const merged: BarberShop[] = shopsData.map((shop: any) => {
@@ -203,6 +230,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
paymentMethods: shop.payment_methods || undefined,
socialNetworks: shop.social_networks || undefined,
contacts: shop.contacts || undefined,
ownerId: shop.owner_id || shop.ownerId || undefined,
services: (servicesData || []).filter((s: any) => s.shop_id === shop.id).map((s: any) => ({
id: s.id,
name: s.name,
@@ -395,6 +423,14 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
createdAt: data.created_at,
};
const shopOwnerId = shop?.ownerId || shop?.id;
if (shopOwnerId) {
const clientName = user?.name || 'Um cliente';
await supabase.from('notifications').insert([
{ user_id: shopOwnerId, message: `${clientName} fez um novo pedido de produtos!` },
]);
}
await refreshShops();
setCart((prev) => prev.filter((i) => i.shopId !== shopId));
return newOrder;
@@ -562,20 +598,51 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
};
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;
const { data } = await supabase.from('appointments').insert([{
shop_id: input.shopId,
const shopRef = shops.find((s) => s.id === input.shopId);
if (!shopRef) return null;
const svc = shopRef.services.find((s) => s.id === input.serviceId);
if (!svc) return null;
const exists = appointments.find(
(ap) => ap.barberId === input.barberId && ap.date === input.date && ap.status !== 'cancelado'
);
if (exists) return null;
const total = svc.price;
const { data, error } = await supabase.from('appointments').insert([{
shop_id: input.shopId,
service_id: input.serviceId,
barber_id: input.barberId,
customer_id: input.customerId,
date: input.date,
status: 'pendente',
total,
reminder_minutes: input.reminderMinutes
reminder_minutes: input.reminderMinutes,
}]).select().single();
if (error || !data) {
console.error('Erro ao criar marcação:', error);
return null;
}
const shopOwnerId = shopRef.ownerId || shopRef.id;
const clientName = user?.name || 'Um cliente';
await supabase.from('notifications').insert([
{ user_id: shopOwnerId, message: `${clientName} solicitou um novo agendamento para o dia ${input.date}!` },
]);
await refreshShops();
return data as any as Appointment;
return {
id: data.id,
shopId: data.shop_id,
serviceId: data.service_id,
barberId: data.barber_id,
customerId: data.customer_id,
date: data.date,
status: data.status as Appointment['status'],
total: data.total,
reminderMinutes: data.reminder_minutes,
};
};
const updateAppointmentStatus = async (id: string, status: Appointment['status']) => {
@@ -655,6 +722,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const value: AppContextValue = useMemo(
() => ({
user,
users,
shops,
shopsReady,
cart,
@@ -690,7 +758,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
markNotificationRead,
refreshShops,
}),
[user, shops, shopsReady, cart, favorites, appointments, orders, waitlists, notifications]
[user, users, shops, shopsReady, cart, favorites, appointments, orders, waitlists, notifications]
);
if (loading) return null;

View File

@@ -1,42 +1,47 @@
import { Platform } from 'react-native';
/** Paleta alinhada com web/src/index.css e tailwind (indigo → amber) */
export const colors = {
background: '#eef3ef',
backgroundAlt: '#e5ebe7',
background: '#f8fafc',
backgroundAlt: '#f1f5f9',
surface: '#ffffff',
surfaceMuted: '#f7faf8',
surfaceDark: '#14211d',
surfaceDarkMuted: '#20312b',
text: '#17211d',
textSoft: '#35443e',
textMuted: '#64736d',
textSubtle: '#87948f',
textInverse: '#f8fbf8',
border: '#dce5df',
borderStrong: '#c8d5ce',
surfaceMuted: '#f1f5f9',
surfaceDark: '#0f172a',
surfaceDarkMuted: '#1e293b',
slate950: '#020617',
text: '#0f172a',
textSoft: '#334155',
textMuted: '#64748b',
textSubtle: '#94a3b8',
textInverse: '#ffffff',
border: '#e2e8f0',
borderStrong: '#cbd5e1',
borderDark: 'rgba(255,255,255,0.12)',
primary: '#0f766e',
primaryDark: '#0b4f49',
primarySoft: '#d9f1ed',
accent: '#c87918',
accentSoft: '#fff1d1',
primary: '#f59e0b',
primaryDark: '#b45309',
primaryLight: '#fcd34d',
primarySoft: '#fef3c7',
brandAccent: '#fbbf24',
accent: '#f59e0b',
accentSoft: '#fef3c7',
success: '#16a34a',
successSoft: '#dcfce7',
danger: '#dc2626',
dangerSoft: '#fee2e2',
danger: '#e11d48',
dangerSoft: '#fff1f2',
warning: '#d97706',
warningSoft: '#fef3c7',
star: '#f2a51a',
overlay: 'rgba(12,20,17,0.56)',
placeholder: '#8a9791',
star: '#f59e0b',
overlay: 'rgba(15,23,42,0.56)',
placeholder: '#94a3b8',
};
export const radius = {
xs: 6,
sm: 8,
md: 10,
lg: 12,
xl: 16,
md: 12,
lg: 16,
xl: 24,
xxl: 32,
pill: 999,
};
@@ -46,7 +51,7 @@ export const spacing = {
const cardShadow = Platform.select({
ios: {
shadowColor: '#102018',
shadowColor: '#0f172a',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.08,
shadowRadius: 18,
@@ -55,7 +60,7 @@ const cardShadow = Platform.select({
elevation: 3,
},
default: {
shadowColor: '#102018',
shadowColor: '#0f172a',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.08,
shadowRadius: 18,
@@ -65,7 +70,7 @@ const cardShadow = Platform.select({
const softShadow = Platform.select({
ios: {
shadowColor: '#102018',
shadowColor: '#0f172a',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.06,
shadowRadius: 12,
@@ -74,7 +79,7 @@ const softShadow = Platform.select({
elevation: 2,
},
default: {
shadowColor: '#102018',
shadowColor: '#0f172a',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.06,
shadowRadius: 12,

View File

@@ -24,7 +24,7 @@ const TabIcon = ({ name, focused }: { name: any; focused: boolean }) => (
<Ionicons
name={focused ? name : `${name}-outline`}
size={20}
color={focused ? colors.textInverse : '#9cad9f'}
color={focused ? colors.brandAccent : colors.textSubtle}
/>
</View>
);
@@ -67,8 +67,8 @@ function ClientTabs() {
screenOptions={{
headerShown: false,
tabBarStyle: tabStyles.bar,
tabBarActiveTintColor: colors.textInverse,
tabBarInactiveTintColor: '#9cad9f',
tabBarActiveTintColor: colors.brandAccent,
tabBarInactiveTintColor: colors.textSubtle,
tabBarLabelStyle: tabStyles.label,
}}
>
@@ -128,8 +128,8 @@ function BarbeariaTabs() {
screenOptions={{
headerShown: false,
tabBarStyle: tabStyles.bar,
tabBarActiveTintColor: colors.textInverse,
tabBarInactiveTintColor: '#9cad9f',
tabBarActiveTintColor: colors.brandAccent,
tabBarInactiveTintColor: colors.textSubtle,
tabBarLabelStyle: tabStyles.label,
}}
>
@@ -216,10 +216,10 @@ const iconStyles = StyleSheet.create({
justifyContent: 'center',
},
wrapActive: {
backgroundColor: colors.primary,
backgroundColor: colors.surfaceDarkMuted,
},
emoji: {
fontSize: 18,
color: '#9cad9f',
color: colors.textSubtle,
},
});

View File

@@ -76,7 +76,7 @@ export default function AuthLogin() {
placeholder="••••••••"
/>
<Button onPress={handleSubmit} style={styles.submitButton} loading={loading}>
<Button variant="dark" onPress={handleSubmit} style={styles.submitButton} loading={loading}>
Entrar na Conta
</Button>
@@ -110,11 +110,10 @@ const styles = StyleSheet.create({
logoRow: {
alignItems: 'center',
marginBottom: 28,
backgroundColor: colors.surface,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surfaceDark,
borderRadius: radius.lg,
paddingVertical: 18,
paddingHorizontal: 24,
...shadows.card,
},
logoImage: {
@@ -151,7 +150,6 @@ const styles = StyleSheet.create({
submitButton: {
width: '100%',
marginTop: 4,
backgroundColor: colors.primary,
paddingVertical: 16,
},
guestButton: {

View File

@@ -107,7 +107,7 @@ export default function AuthRegister() {
/>
)}
<Button onPress={handleSubmit} style={styles.submitButton} loading={loading}>
<Button variant="dark" onPress={handleSubmit} style={styles.submitButton} loading={loading}>
Criar minha conta
</Button>

View File

@@ -12,7 +12,7 @@ export default function Booking() {
const route = useRoute();
const navigation = useNavigation();
const { shopId, serviceId: initialServiceId } = route.params as { shopId: string; serviceId?: string };
const { shops, createAppointment, user, appointments, joinWaitlist } = useApp();
const { shops, shopsReady, createAppointment, user, appointments, waitlists, joinWaitlist } = useApp();
const shop = useMemo(() => shops.find((s) => s.id === shopId), [shops, shopId]);
@@ -42,15 +42,69 @@ export default function Booking() {
return dates;
}, []);
const generateDefaultSlots = (): string[] => {
const slots: string[] = [];
for (let hour = 9; hour <= 18; hour += 1) {
slots.push(`${hour.toString().padStart(2, '0')}:00`);
if (hour < 18) slots.push(`${hour.toString().padStart(2, '0')}:30`);
}
return slots;
};
const isShopClosedOnDate = useMemo(() => {
if (!date || !shop) return false;
const dateObj = new Date(`${date}T00:00:00`);
const ptDays = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado'];
const dayName = ptDays[dateObj.getDay()];
const exception = shop.schedule?.find((s) => s.isException && s.date === date);
if (exception) return Boolean(exception.closed);
const defaultDay = shop.schedule?.find((s) => !s.isException && (s.day === dayName || s.day.startsWith(dayName.slice(0, 3))));
if (defaultDay) return Boolean(defaultDay.closed);
return dayName === 'Domingo';
}, [shop?.schedule, date]);
const processedSlots = useMemo(() => {
if (!selectedBarber || !date) return [];
const slots = ["09:00", "09:30", "10:00", "10:30", "11:00", "11:30", "12:00", "14:00", "14:30", "15:00", "15:30", "16:00", "16:30", "17:00", "17:30", "18:00"];
const booked = appointments
.filter(a => a.barberId === barberId && a.status !== 'cancelado' && a.date.startsWith(date))
.map(a => a.date.split(' ')[1]);
return slots.map(t => ({ time: t, isBooked: booked.includes(t) }));
}, [selectedBarber, date, barberId, appointments]);
if (!selectedBarber || !date || isShopClosedOnDate) return [];
const specificSchedule = selectedBarber.schedule?.find((s) => s.day === date);
const slots =
specificSchedule && specificSchedule.slots.length > 0
? [...specificSchedule.slots]
: generateDefaultSlots();
const bookedSlots = appointments
.filter((apt) => apt.barberId === barberId && apt.status !== 'cancelado' && apt.date.startsWith(date))
.map((apt) => apt.date.split(' ')[1])
.filter(Boolean);
const todayStr = new Date().toISOString().split('T')[0];
const isToday = date === todayStr;
const now = new Date();
return slots.map((time) => {
let isPast = false;
if (isToday) {
const [h, m] = time.split(':').map(Number);
if (h < now.getHours() || (h === now.getHours() && m <= now.getMinutes())) {
isPast = true;
}
}
const isBooked = bookedSlots.includes(time) || isPast;
const waitlistedByMe = user
? waitlists.some(
(w) =>
w.barberId === barberId &&
w.date.startsWith(date) &&
w.customerId === user.id &&
w.status === 'pending'
)
: false;
return { time, isBooked, waitlistedByMe };
});
}, [selectedBarber, date, barberId, appointments, user, waitlists, isShopClosedOnDate]);
const submit = async () => {
if (!user) { navigation.navigate('Login' as never); return; }
@@ -65,9 +119,19 @@ export default function Booking() {
if (appt) {
Alert.alert('Sucesso', 'O seu agendamento foi confirmado.');
navigation.navigate('ExploreTab' as never);
} else {
Alert.alert('Erro', 'Não foi possível criar a marcação. O horário pode já estar ocupado.');
}
};
if (!shopsReady && !shop) {
return (
<SafeAreaView style={[styles.container, styles.centered]}>
<Text style={styles.loadingText}>A preparar reserva...</Text>
</SafeAreaView>
);
}
if (!shop) return null;
return (
@@ -171,20 +235,51 @@ export default function Booking() {
</ScrollView>
<Text style={[styles.stepLabel, { marginTop: 20 }]}>Horários disponíveis</Text>
<View style={styles.slotsGrid}>
{processedSlots.map(s => (
<TouchableOpacity
key={s.time}
disabled={s.isBooked}
onPress={() => setSlot(s.time)}
style={[styles.slotItem, slot === s.time && styles.slotActive, s.isBooked && styles.slotBooked]}
>
<Text style={[styles.slotText, slot === s.time && styles.slotTextActive, s.isBooked && styles.slotTextBooked]}>
{s.time}
</Text>
</TouchableOpacity>
))}
</View>
{isShopClosedOnDate ? (
<View style={styles.closedBox}>
<Text style={styles.closedTitle}>O estabelecimento está fechado neste dia.</Text>
<Text style={styles.closedHint}>Selecione outra data.</Text>
</View>
) : processedSlots.some((s) => !s.isBooked) ? (
<View style={styles.slotsGrid}>
{processedSlots.filter((s) => !s.isBooked).map((s) => (
<TouchableOpacity
key={s.time}
onPress={() => setSlot(s.time)}
style={[styles.slotItem, slot === s.time && styles.slotActive]}
>
<Text style={[styles.slotText, slot === s.time && styles.slotTextActive]}>{s.time}</Text>
</TouchableOpacity>
))}
</View>
) : processedSlots.length > 0 ? (
<View style={styles.closedBox}>
<Text style={styles.closedTitle}>Todos os horários estão preenchidos.</Text>
{processedSlots.some((s) => s.waitlistedByMe) ? (
<Text style={styles.closedHint}> estás na lista de espera deste dia.</Text>
) : (
<Button
size="sm"
variant="dark"
onPress={async () => {
if (!user) {
navigation.navigate('Login' as never);
return;
}
const ok = await joinWaitlist(shop.id, serviceId, barberId, `${date} ${processedSlots[0]?.time || '09:00'}`);
Alert.alert(
ok ? 'Lista de espera' : 'Erro',
ok ? 'Entraste na lista de espera! Serás notificado se houver desistências.' : 'Não foi possível entrar na lista de espera.'
);
}}
>
Entrar na Lista de Espera
</Button>
)}
</View>
) : (
<Text style={styles.closedHint}>Sem horários para este dia.</Text>
)}
{slot !== '' && (
<View style={styles.footerAction}>
@@ -231,6 +326,33 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: colors.background,
},
centered: {
alignItems: 'center',
justifyContent: 'center',
},
loadingText: {
color: colors.textMuted,
fontWeight: '700',
},
closedBox: {
backgroundColor: colors.dangerSoft,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: '#fecaca',
padding: 20,
gap: 10,
alignItems: 'center',
},
closedTitle: {
color: colors.danger,
fontWeight: '800',
textAlign: 'center',
},
closedHint: {
color: colors.textMuted,
fontSize: 13,
textAlign: 'center',
},
header: {
flexDirection: 'row',
alignItems: 'center',

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
import React, { useMemo, useState } from 'react';
import { Image, ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
import { ScrollView, StyleSheet, Text, TextInput, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { useApp } from '../context/AppContext';
import { ShopCard } from '../components/ShopCard';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { RootStackParamList } from '../navigation/types';
@@ -12,7 +13,7 @@ import { Ionicons } from '@expo/vector-icons';
export default function Explore() {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const { shops, shopsReady, user, toggleFavorite, isFavorite } = useApp();
const { shops, shopsReady, toggleFavorite, isFavorite } = useApp();
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<'todas' | 'top' | 'servicos'>('todas');
@@ -34,49 +35,32 @@ export default function Explore() {
});
}, [shops, query, filter]);
const featuredShops = filtered.slice(0, 3);
const goToProfile = () => {
if (!user) {
navigation.navigate('Login');
return;
}
navigation.getParent()?.navigate('ProfileTab' as never);
};
return (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled" showsVerticalScrollIndicator={false}>
{/* Modern Header Section */}
<View style={styles.header}>
<View style={styles.headerCopy}>
<Text style={styles.greeting}>
{user ? `Olá, ${user.name.split(' ')[0]} 👋` : 'Bem-vindo 👋'}
</Text>
<Text style={styles.headline}>Encontra um espaço</Text>
<View style={styles.hero}>
<View style={styles.kicker}>
<Ionicons name="star" size={10} color={colors.primaryDark} />
<Text style={styles.kickerText}>As Nossas Barbearias</Text>
</View>
<TouchableOpacity
style={styles.avatarButton}
activeOpacity={0.85}
onPress={goToProfile}
>
<View style={styles.avatarGradient}>
<Text style={styles.avatarText}>
{user ? user.name.charAt(0).toUpperCase() : 'S'}
</Text>
</View>
</TouchableOpacity>
<Text style={styles.title}>
Ver <Text style={styles.titleAccent}>Barbearias</Text>
</Text>
<Text style={styles.subtitle}>
Descubra barbearias exclusivas e reserve o seu próximo corte em segundos.
</Text>
<Text style={styles.countLabel}>
<Text style={styles.countValue}>{filtered.length}</Text> Espaços Disponíveis
</Text>
</View>
{/* Clean Integrated Search Box */}
<View style={styles.searchSection}>
<View style={styles.searchBox}>
<Ionicons name="search-outline" size={20} color={colors.placeholder} />
<Card style={styles.searchCard}>
<View style={styles.searchRow}>
<Ionicons name="search-outline" size={20} color={colors.textSubtle} />
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Pesquisar nome, endereço ou serviço..."
placeholder="Pesquisar por nome ou endereço..."
placeholderTextColor={colors.placeholder}
style={styles.searchInput}
/>
@@ -87,7 +71,6 @@ export default function Explore() {
)}
</View>
{/* Quick Filters Scroll */}
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filtersScroll}>
{[
['todas', 'Melhor avaliação'],
@@ -104,7 +87,7 @@ export default function Explore() {
</TouchableOpacity>
))}
</ScrollView>
</View>
</Card>
{!shopsReady ? (
<View style={styles.emptyState}>
@@ -121,155 +104,17 @@ export default function Explore() {
</Button>
</View>
) : (
<>
{/* Featured Section */}
{featuredShops.length > 0 && (
<View style={styles.featuredSection}>
<View style={styles.sectionHeaderRow}>
<Text style={styles.sectionTitle}>Em destaque</Text>
<Text style={styles.countBadge}>{filtered.length} espaços</Text>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.featuredList}>
{featuredShops.map((shop) => {
const isFav = isFavorite(shop.id);
return (
<TouchableOpacity
key={shop.id}
style={styles.featuredCard}
activeOpacity={0.9}
onPress={() => navigation.navigate('ShopDetails', { shopId: shop.id })}
>
<View style={styles.featuredImageWrapper}>
{shop.imageUrl ? (
<Image source={{ uri: shop.imageUrl }} style={styles.featuredImage} />
) : (
<View style={styles.featuredFallback}>
<Ionicons name="storefront-outline" size={40} color={colors.primary} style={{ opacity: 0.6 }} />
</View>
)}
{/* Rating Badge Overlay */}
<View style={styles.ratingBadgeOverlay}>
<Ionicons name="star" size={12} color={colors.star} />
<Text style={styles.ratingBadgeText}>{(shop.rating || 0).toFixed(1)}</Text>
</View>
{/* Floating Favorite Heart Button */}
<TouchableOpacity
style={styles.favoriteBtnFloating}
activeOpacity={0.7}
onPress={(e) => {
e.stopPropagation();
toggleFavorite(shop.id);
}}
>
<Ionicons
name={isFav ? "heart" : "heart-outline"}
size={18}
color={isFav ? colors.danger : colors.textInverse}
/>
</TouchableOpacity>
</View>
<View style={styles.featuredCardBody}>
<Text style={styles.featuredShopName} numberOfLines={1}>{shop.name}</Text>
<View style={styles.infoRowInline}>
<Ionicons name="location-outline" size={13} color={colors.textMuted} />
<Text style={styles.addressText} numberOfLines={1}>
{shop.address && shop.address !== 'Endereço a definir' ? shop.address : 'Morada por definir'}
</Text>
</View>
<View style={styles.cardFooterRow}>
<Text style={styles.featuredStatsText}>{(shop.services || []).length} serv. {(shop.barbers || []).length} barb.</Text>
<View style={styles.goBtn}>
<Ionicons name="arrow-forward" size={14} color={colors.textInverse} />
</View>
</View>
</View>
</TouchableOpacity>
);
})}
</ScrollView>
</View>
)}
{/* All Spaces Section */}
<View style={styles.allSpacesSection}>
<View style={styles.sectionHeaderRow}>
<Text style={styles.sectionTitle}>Todos os espaços</Text>
<Text style={styles.countBadge}>{filtered.length}</Text>
</View>
<View style={styles.allSpacesList}>
{filtered.map((shop) => {
const isFav = isFavorite(shop.id);
return (
<TouchableOpacity
key={shop.id}
style={styles.shopCard}
activeOpacity={0.9}
onPress={() => navigation.navigate('ShopDetails', { shopId: shop.id })}
>
<View style={styles.shopImageWrapper}>
{shop.imageUrl ? (
<Image source={{ uri: shop.imageUrl }} style={styles.shopImage} />
) : (
<View style={styles.shopImageFallback}>
<Ionicons name="storefront-outline" size={24} color={colors.primary} style={{ opacity: 0.5 }} />
</View>
)}
{/* Floating Favorite Button on list image */}
<TouchableOpacity
style={styles.favoriteBtnListFloating}
activeOpacity={0.7}
onPress={(e) => {
e.stopPropagation();
toggleFavorite(shop.id);
}}
>
<Ionicons
name={isFav ? "heart" : "heart-outline"}
size={14}
color={isFav ? colors.danger : colors.textInverse}
/>
</TouchableOpacity>
</View>
<View style={styles.shopBody}>
<View style={styles.shopNameRow}>
<Text style={styles.shopName} numberOfLines={1}>{shop.name}</Text>
<View style={styles.ratingBadgeSmall}>
<Ionicons name="star" size={11} color={colors.star} />
<Text style={styles.ratingTextSmall}>{(shop.rating || 0).toFixed(1)}</Text>
</View>
</View>
<View style={styles.infoRowInline}>
<Ionicons name="location-outline" size={13} color={colors.textMuted} />
<Text style={styles.addressText} numberOfLines={1}>
{shop.address && shop.address !== 'Endereço a definir' ? shop.address : 'Morada por definir'}
</Text>
</View>
<View style={styles.shopStatsRow}>
<View style={styles.statTag}>
<Ionicons name="cut-outline" size={10} color={colors.primary} />
<Text style={styles.statTagText}>{(shop.services || []).length} serv.</Text>
</View>
<View style={styles.statTag}>
<Ionicons name="people-outline" size={10} color={colors.primary} />
<Text style={styles.statTagText}>{(shop.barbers || []).length} barb.</Text>
</View>
</View>
</View>
<View style={styles.chevronIconContainer}>
<Ionicons name="chevron-forward" size={18} color={colors.primary} />
</View>
</TouchableOpacity>
);
})}
</View>
</View>
</>
<View style={styles.grid}>
{filtered.map((shop) => (
<ShopCard
key={shop.id}
shop={shop}
isFavorite={isFavorite(shop.id)}
onToggleFavorite={() => toggleFavorite(shop.id)}
onPress={() => navigation.navigate('ShopDetails', { shopId: shop.id })}
/>
))}
</View>
)}
</ScrollView>
</SafeAreaView>
@@ -283,461 +128,105 @@ const styles = StyleSheet.create({
},
content: {
padding: 20,
gap: 22,
gap: 20,
paddingBottom: 40,
},
header: {
hero: {
gap: 8,
},
kicker: {
alignSelf: 'flex-start',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 8,
gap: 6,
backgroundColor: colors.primarySoft,
paddingHorizontal: 12,
paddingVertical: 5,
borderRadius: radius.pill,
},
headerCopy: {
flex: 1,
kickerText: {
color: colors.primaryDark,
fontSize: 10,
fontWeight: '900',
textTransform: 'uppercase',
letterSpacing: 1.2,
},
greeting: {
title: {
color: colors.text,
fontSize: 32,
fontWeight: '900',
letterSpacing: -0.8,
textTransform: 'uppercase',
},
titleAccent: {
color: colors.primary,
},
subtitle: {
color: colors.textMuted,
fontSize: 14,
fontWeight: '600',
marginBottom: 4,
letterSpacing: 0.3,
fontWeight: '500',
lineHeight: 20,
maxWidth: 340,
},
headline: {
color: colors.text,
fontSize: 28,
fontWeight: '900',
letterSpacing: -0.5,
},
avatarButton: {
shadowColor: colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 4,
},
avatarGradient: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.surface,
},
avatarText: {
color: colors.textInverse,
fontSize: 18,
countLabel: {
marginTop: 4,
color: colors.textSubtle,
fontSize: 12,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.8,
},
searchSection: {
gap: 12,
countValue: {
color: colors.text,
},
searchBox: {
searchCard: {
padding: 8,
borderRadius: radius.xl,
borderWidth: 0,
...shadows.card,
},
searchRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.surface,
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.1)',
paddingHorizontal: 16,
gap: 10,
height: 54,
shadowColor: '#102018',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.04,
shadowRadius: 8,
elevation: 2,
paddingHorizontal: 12,
paddingVertical: 4,
},
searchInput: {
flex: 1,
height: '100%',
height: 48,
color: colors.text,
fontSize: 15,
fontSize: 16,
fontWeight: '500',
},
filtersScroll: {
gap: 8,
paddingVertical: 2,
paddingHorizontal: 8,
paddingBottom: 8,
paddingTop: 4,
},
filterChip: {
borderRadius: radius.pill,
backgroundColor: colors.surface,
paddingHorizontal: 16,
paddingVertical: 10,
backgroundColor: colors.backgroundAlt,
paddingHorizontal: 14,
paddingVertical: 9,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.08)',
shadowColor: '#102018',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.02,
shadowRadius: 4,
elevation: 1,
borderColor: colors.border,
},
filterChipActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
backgroundColor: colors.surfaceDark,
borderColor: colors.surfaceDark,
},
filterChipText: {
color: colors.textMuted,
fontSize: 13,
fontSize: 12,
fontWeight: '700',
},
filterChipTextActive: {
color: colors.textInverse,
color: colors.brandAccent,
},
categoriesSection: {
gap: 12,
marginTop: 4,
},
categoriesScroll: {
gap: 14,
paddingRight: 10,
paddingVertical: 4,
},
categoryItem: {
alignItems: 'center',
gap: 8,
width: 72,
},
categoryIconBg: {
width: 58,
height: 58,
borderRadius: 29,
backgroundColor: colors.surface,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.08)',
shadowColor: '#102018',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.05,
shadowRadius: 8,
elevation: 2,
},
categoryIconBgActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
categoryLabel: {
fontSize: 12,
fontWeight: '600',
color: colors.textMuted,
textAlign: 'center',
},
categoryLabelActive: {
color: colors.primary,
fontWeight: '800',
},
promoBanner: {
flexDirection: 'row',
backgroundColor: colors.surfaceDark,
borderRadius: radius.lg,
overflow: 'hidden',
shadowColor: colors.primaryDark,
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.12,
shadowRadius: 12,
elevation: 5,
marginTop: 6,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.04)',
},
promoContent: {
flex: 1.2,
padding: 20,
gap: 8,
},
promoBadge: {
alignSelf: 'flex-start',
backgroundColor: 'rgba(255,255,255,0.12)',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: radius.xs,
},
promoBadgeText: {
color: '#e2f4f1',
fontSize: 10,
fontWeight: '800',
letterSpacing: 1,
},
promoTitle: {
color: colors.textInverse,
fontSize: 18,
fontWeight: '900',
lineHeight: 22,
},
promoSubtitle: {
color: 'rgba(248,251,248,0.72)',
fontSize: 12,
lineHeight: 16,
fontWeight: '500',
},
promoButton: {
alignSelf: 'flex-start',
backgroundColor: colors.surface,
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: radius.sm,
marginTop: 6,
},
promoButtonText: {
color: colors.surfaceDark,
fontSize: 12,
fontWeight: '800',
},
promoVisual: {
flex: 0.8,
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
},
promoBackgroundIcon: {
position: 'absolute',
right: -10,
bottom: -10,
},
sectionHeaderRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
},
sectionTitle: {
color: colors.text,
fontSize: 18,
fontWeight: '900',
letterSpacing: -0.3,
},
countBadge: {
color: colors.primary,
backgroundColor: colors.primarySoft,
fontSize: 11,
fontWeight: '800',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: radius.pill,
textTransform: 'uppercase',
},
featuredSection: {
marginTop: 4,
},
featuredList: {
grid: {
gap: 16,
paddingRight: 10,
paddingVertical: 4,
},
featuredCard: {
width: 250,
backgroundColor: colors.surface,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
overflow: 'hidden',
shadowColor: '#102018',
shadowOffset: { width: 0, height: 6 },
shadowOpacity: 0.05,
shadowRadius: 10,
elevation: 3,
},
featuredImageWrapper: {
position: 'relative',
width: '100%',
height: 130,
},
featuredImage: {
width: '100%',
height: '100%',
},
featuredFallback: {
width: '100%',
height: '100%',
backgroundColor: colors.primarySoft,
alignItems: 'center',
justifyContent: 'center',
},
ratingBadgeOverlay: {
position: 'absolute',
bottom: 10,
left: 10,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(12,20,17,0.7)',
borderRadius: radius.pill,
paddingHorizontal: 8,
paddingVertical: 3.5,
gap: 4,
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.15)',
},
ratingBadgeText: {
color: colors.textInverse,
fontSize: 11,
fontWeight: '800',
},
favoriteBtnFloating: {
position: 'absolute',
top: 10,
right: 10,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(12,20,17,0.5)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.15)',
},
featuredCardBody: {
padding: 14,
gap: 6,
},
featuredShopName: {
fontSize: 16,
fontWeight: '800',
color: colors.text,
},
infoRowInline: {
flexDirection: 'row',
alignItems: 'center',
gap: 5,
},
addressText: {
flex: 1,
color: colors.textMuted,
fontWeight: '500',
fontSize: 13,
},
cardFooterRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: 4,
borderTopWidth: 1,
borderTopColor: 'rgba(15,118,110,0.05)',
paddingTop: 8,
},
featuredStatsText: {
color: colors.textSubtle,
fontSize: 12,
fontWeight: '600',
},
goBtn: {
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
},
allSpacesSection: {
marginTop: 4,
},
allSpacesList: {
gap: 12,
},
shopCard: {
flexDirection: 'row',
alignItems: 'center',
borderRadius: radius.lg,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
padding: 10,
gap: 12,
shadowColor: '#102018',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.03,
shadowRadius: 8,
elevation: 2,
},
shopImageWrapper: {
position: 'relative',
width: 82,
height: 82,
borderRadius: radius.md,
overflow: 'hidden',
},
shopImage: {
width: '100%',
height: '100%',
},
shopImageFallback: {
width: '100%',
height: '100%',
backgroundColor: colors.primarySoft,
alignItems: 'center',
justifyContent: 'center',
},
favoriteBtnListFloating: {
position: 'absolute',
top: 4,
right: 4,
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: 'rgba(12,20,17,0.5)',
alignItems: 'center',
justifyContent: 'center',
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.15)',
},
shopBody: {
flex: 1,
gap: 4,
},
shopNameRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
},
shopName: {
flex: 1,
fontSize: 16,
fontWeight: '800',
color: colors.text,
},
ratingBadgeSmall: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.accentSoft,
borderRadius: radius.pill,
paddingHorizontal: 7,
paddingVertical: 2,
gap: 3,
},
ratingTextSmall: {
color: '#7a4310',
fontSize: 10,
fontWeight: '800',
},
shopStatsRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
marginTop: 2,
},
statTag: {
flexDirection: 'row',
alignItems: 'center',
gap: 3,
backgroundColor: 'rgba(15,118,110,0.05)',
borderRadius: radius.xs,
paddingHorizontal: 6,
paddingVertical: 2,
},
statTagText: {
color: colors.primary,
fontSize: 10,
fontWeight: '700',
},
chevronIconContainer: {
width: 26,
height: 26,
borderRadius: 13,
backgroundColor: 'rgba(15,118,110,0.05)',
alignItems: 'center',
justifyContent: 'center',
},
emptyState: {
alignItems: 'center',

View File

@@ -294,7 +294,7 @@ const styles = StyleSheet.create({
fontWeight: '900',
},
rating: {
color: '#7a4310',
color: colors.primaryDark,
backgroundColor: colors.accentSoft,
borderRadius: radius.pill,
paddingHorizontal: 10,

View File

@@ -250,6 +250,9 @@ export default function Profile() {
<View style={styles.agendaMain}>
<Text style={styles.agendaShopPremium} numberOfLines={1}>{shop?.name || 'Barbearia'}</Text>
<Text style={styles.agendaServicePremium} numberOfLines={1}>
{shop?.services.find((s) => s.id === appt.serviceId)?.name || 'Serviço'} · {shop?.services.find((s) => s.id === appt.serviceId)?.duration || '—'} min
</Text>
<View style={styles.agendaMetaRow}>
<Ionicons name="time-outline" size={14} color={colors.textMuted} />
<Text style={styles.agendaTimePremium}>{dateParts[1]} {currency(appt.total)}</Text>
@@ -558,7 +561,7 @@ const styles = StyleSheet.create({
height: 72,
borderRadius: 36,
borderWidth: 2.5,
borderColor: 'rgba(15,118,110,0.35)',
borderColor: 'rgba(245,158,11,0.35)',
alignItems: 'center',
justifyContent: 'center',
padding: 2,
@@ -620,7 +623,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.surface,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
...shadows.soft,
},
profileStat: {
@@ -688,12 +691,12 @@ const styles = StyleSheet.create({
padding: 14,
marginRight: 12,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
gap: 6,
...shadows.soft,
},
notifUnread: {
borderColor: 'rgba(15,118,110,0.2)',
borderColor: 'rgba(245,158,11,0.2)',
backgroundColor: colors.primarySoft,
},
notifHeader: {
@@ -739,7 +742,7 @@ const styles = StyleSheet.create({
borderRadius: radius.pill,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
},
tabActive: {
backgroundColor: colors.primary,
@@ -791,7 +794,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.surface,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
...shadows.soft,
},
agendaTop: {
@@ -805,7 +808,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
minWidth: 52,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
overflow: 'hidden',
},
calendarHeader: {
@@ -873,7 +876,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.surface,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
...shadows.soft,
},
shopIcon: {
@@ -884,7 +887,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
borderWidth: 0.5,
borderColor: 'rgba(15,118,110,0.1)',
borderColor: colors.borderStrong,
},
shopIconText: {
color: colors.primary,
@@ -921,7 +924,7 @@ const styles = StyleSheet.create({
gap: 3,
},
shopRatingText: {
color: '#7a4310',
color: colors.primaryDark,
fontWeight: '800',
fontSize: 11,
},
@@ -933,7 +936,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.surface,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
...shadows.soft,
},
orderTop: {
@@ -970,7 +973,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.surface,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
...shadows.soft,
},
reviewHeader: {
@@ -1007,7 +1010,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.surface,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
...shadows.soft,
},
statCardFull: {
@@ -1017,7 +1020,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.surface,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
...shadows.soft,
},
statIconContainer: {
@@ -1071,7 +1074,7 @@ const styles = StyleSheet.create({
height: 90,
textAlignVertical: 'top',
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.08)',
borderColor: colors.borderStrong,
},
reviewActions: {
flexDirection: 'row',
@@ -1083,7 +1086,7 @@ const styles = StyleSheet.create({
marginTop: 20,
paddingTop: 20,
borderTopWidth: 1,
borderTopColor: 'rgba(15,118,110,0.08)',
borderTopColor: colors.borderStrong,
alignItems: 'center',
},
/* ── Premium Client Layout Styles ── */
@@ -1105,7 +1108,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.surface,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
gap: 16,
...shadows.soft,
},
@@ -1117,7 +1120,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.1)',
borderColor: colors.borderStrong,
},
dateMonthPremium: {
color: colors.primaryDark,
@@ -1136,6 +1139,12 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '800',
},
agendaServicePremium: {
color: colors.textMuted,
fontSize: 12,
fontWeight: '600',
marginTop: 2,
},
agendaTimePremium: {
color: colors.textMuted,
fontSize: 13,
@@ -1159,7 +1168,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.surface,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.04)',
borderColor: colors.backgroundAlt,
gap: 12,
},
emptyIconCircle: {
@@ -1183,7 +1192,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.surface,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
gap: 12,
},
shopImageMini: {
@@ -1218,7 +1227,7 @@ const styles = StyleSheet.create({
gap: 4,
},
shopRatingTextPremium: {
color: '#7a4310',
color: colors.primaryDark,
fontSize: 12,
fontWeight: '900',
},
@@ -1227,7 +1236,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.surface,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
},
orderTopPremium: {
flexDirection: 'row',

View File

@@ -9,11 +9,12 @@ import { Button } from '../components/ui/Button';
import { currency } from '../lib/format';
import { RootStackParamList } from '../navigation/types';
import { colors, radius, shadows } from '../lib/theme';
import { ShopSchedule } from '../types';
import { Ionicons } from '@expo/vector-icons';
type Tab = 'servicos' | 'barbeiros' | 'produtos' | 'detalhes';
const defaultSchedule = [
const defaultSchedule: ShopSchedule[] = [
{ 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' },
@@ -312,6 +313,52 @@ export default function ShopDetails() {
) : (
<Text style={styles.noContactText}>Contacto telefónico não disponível.</Text>
)}
{shop.contacts?.phone2 ? (
<TouchableOpacity onPress={() => Linking.openURL(`tel:${shop.contacts?.phone2}`)} style={styles.phoneLinkBox} activeOpacity={0.7}>
<Ionicons name="phone-portrait-outline" size={18} color={colors.primaryDark} />
<Text style={styles.contactLink}>{shop.contacts.phone2}</Text>
</TouchableOpacity>
) : null}
{shop.paymentMethods && shop.paymentMethods.length > 0 && (
<>
<View style={styles.divider} />
<View style={styles.detailTitleRow}>
<Ionicons name="card-outline" size={18} color={colors.primary} />
<Text style={styles.detailTitle}>Formas de Pagamento</Text>
</View>
<Text style={styles.detailBody}>{shop.paymentMethods.join(', ')}</Text>
</>
)}
{(shop.socialNetworks?.whatsapp || shop.socialNetworks?.instagram || shop.socialNetworks?.facebook) && (
<>
<View style={styles.divider} />
<View style={styles.detailTitleRow}>
<Ionicons name="globe-outline" size={18} color={colors.primary} />
<Text style={styles.detailTitle}>Redes Sociais</Text>
</View>
{shop.socialNetworks?.whatsapp ? (
<TouchableOpacity onPress={() => Linking.openURL(`https://wa.me/${shop.socialNetworks?.whatsapp}`)} style={styles.phoneLinkBox}>
<Ionicons name="logo-whatsapp" size={18} color={colors.success} />
<Text style={styles.contactLink}>WhatsApp</Text>
</TouchableOpacity>
) : null}
{shop.socialNetworks?.instagram ? (
<TouchableOpacity onPress={() => Linking.openURL(`https://instagram.com/${shop.socialNetworks?.instagram?.replace('@', '')}`)} style={styles.phoneLinkBox}>
<Ionicons name="logo-instagram" size={18} color={colors.primaryDark} />
<Text style={styles.contactLink}>Instagram</Text>
</TouchableOpacity>
) : null}
{shop.socialNetworks?.facebook ? (
<TouchableOpacity onPress={() => Linking.openURL(shop.socialNetworks!.facebook!)} style={styles.phoneLinkBox}>
<Ionicons name="logo-facebook" size={18} color={colors.primary} />
<Text style={styles.contactLink}>Facebook</Text>
</TouchableOpacity>
) : null}
</>
)}
</Card>
)}
</View>
@@ -388,8 +435,8 @@ const styles = StyleSheet.create({
padding: 20,
gap: 14,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
shadowColor: '#102018',
borderColor: colors.border,
shadowColor: colors.surfaceDark,
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.08,
shadowRadius: 16,
@@ -410,7 +457,7 @@ const styles = StyleSheet.create({
gap: 4,
},
ratingValue: {
color: '#7a4310',
color: colors.primaryDark,
fontSize: 12,
fontWeight: '900',
},
@@ -457,7 +504,7 @@ const styles = StyleSheet.create({
borderRadius: radius.md,
padding: 10,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.05)',
borderColor: colors.border,
},
quickValue: {
color: colors.text,
@@ -498,7 +545,7 @@ const styles = StyleSheet.create({
borderRadius: radius.pill,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.05)',
borderColor: colors.border,
},
tabItemActive: {
backgroundColor: colors.primary,
@@ -525,9 +572,9 @@ const styles = StyleSheet.create({
gap: 12,
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
backgroundColor: colors.surface,
shadowColor: '#102018',
shadowColor: colors.surfaceDark,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.02,
shadowRadius: 6,
@@ -576,7 +623,7 @@ const styles = StyleSheet.create({
gap: 14,
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
backgroundColor: colors.surface,
},
barberAvatar: {
@@ -587,7 +634,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.1)',
borderColor: colors.borderStrong,
},
avatarTxt: {
color: colors.primary,
@@ -621,7 +668,7 @@ const styles = StyleSheet.create({
gap: 10,
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
backgroundColor: colors.surface,
},
prodHeader: {
@@ -672,7 +719,7 @@ const styles = StyleSheet.create({
gap: 14,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(15,118,110,0.06)',
borderColor: colors.border,
backgroundColor: colors.surface,
},
detailTitleRow: {
@@ -686,6 +733,11 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '800',
},
detailBody: {
color: colors.textMuted,
fontSize: 14,
lineHeight: 20,
},
scheduleList: {
gap: 8,
},
@@ -716,7 +768,7 @@ const styles = StyleSheet.create({
},
divider: {
height: 1,
backgroundColor: 'rgba(15,118,110,0.08)',
backgroundColor: colors.borderStrong,
marginVertical: 6,
},
phoneLinkBox: {
@@ -729,7 +781,7 @@ const styles = StyleSheet.create({
paddingHorizontal: 14,
borderRadius: radius.sm,
borderWidth: 0.5,
borderColor: 'rgba(15,118,110,0.1)',
borderColor: colors.borderStrong,
},
contactLink: {
color: colors.primaryDark,