feat: Implement initial application structure, core pages, UI components, and Supabase backend integration.
This commit is contained in:
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["denoland.vscode-deno"]
|
||||
}
|
||||
24
.vscode/settings.json
vendored
Normal file
24
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"deno.enablePaths": [
|
||||
"supabase/functions"
|
||||
],
|
||||
"deno.lint": true,
|
||||
"deno.unstable": [
|
||||
"bare-node-builtins",
|
||||
"byonm",
|
||||
"sloppy-imports",
|
||||
"unsafe-proto",
|
||||
"webgpu",
|
||||
"broadcast-channel",
|
||||
"worker-options",
|
||||
"cron",
|
||||
"kv",
|
||||
"ffi",
|
||||
"fs",
|
||||
"http",
|
||||
"net"
|
||||
],
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "denoland.vscode-deno"
|
||||
}
|
||||
}
|
||||
6
app.json
6
app.json
@@ -6,7 +6,6 @@
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
@@ -32,8 +31,9 @@
|
||||
},
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "your-project-id-here"
|
||||
"projectId": "b018a5db-c940-4364-81ee-596ced75cae3"
|
||||
}
|
||||
}
|
||||
},
|
||||
"owner": "rodrigo_santos08"
|
||||
}
|
||||
}
|
||||
|
||||
6
babel.config.js
Normal file
6
babel.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = function(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
1602
package-lock.json
generated
1602
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,14 @@
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^6.5.11",
|
||||
"@react-navigation/native": "^6.1.9",
|
||||
"@react-navigation/native-stack": "^6.9.17",
|
||||
"expo": "~54.0.27",
|
||||
"@supabase/supabase-js": "^2.99.1",
|
||||
"expo": "~54.0.33",
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-notifications": "~0.32.16",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"nanoid": "^5.0.7",
|
||||
"react": "19.1.0",
|
||||
@@ -26,6 +29,7 @@
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"@types/react-native": "^0.73.0",
|
||||
"babel-preset-expo": "~54.0.10",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
|
||||
@@ -36,7 +36,7 @@ export const Button = ({ children, onPress, variant = 'solid', size = 'md', disa
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={variant === 'solid' ? '#fff' : '#6366f1'} />
|
||||
<ActivityIndicator size={20} color={variant === 'solid' ? '#fff' : '#6366f1'} />
|
||||
) : (
|
||||
<Text style={textStyles}>{children}</Text>
|
||||
)}
|
||||
|
||||
@@ -8,6 +8,9 @@ import React, { createContext, useContext, useEffect, useMemo, useState } from '
|
||||
import { Appointment, Barber, BarberShop, CartItem, Order, Product, Service, User } from '../types';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { nanoid } from 'nanoid';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import * as Device from 'expo-device';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
type State = {
|
||||
user?: User;
|
||||
@@ -18,10 +21,12 @@ type State = {
|
||||
};
|
||||
|
||||
type AppContextValue = State & {
|
||||
login: (email: string, password: string) => boolean;
|
||||
login: (email: string, password: string) => User | undefined;
|
||||
logout: () => void;
|
||||
register: (payload: any) => boolean;
|
||||
register: (payload: any) => User | undefined;
|
||||
addToCart: (item: CartItem) => void;
|
||||
removeFromCart: (refId: string) => void;
|
||||
placeOrder: (customerId: string, shopId: string) => Order | null;
|
||||
clearCart: () => void;
|
||||
createAppointment: (input: Omit<Appointment, 'id' | 'status' | 'total'>) => Promise<Appointment | null>;
|
||||
updateAppointmentStatus: (id: string, status: Appointment['status']) => Promise<void>;
|
||||
@@ -54,7 +59,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
if (data.user) {
|
||||
const { data: prof } = await supabase
|
||||
.from('profiles')
|
||||
.select('shop_id, role, name')
|
||||
.select('shop_id, role, name, fcm_token')
|
||||
.eq('id', data.user.id)
|
||||
.single();
|
||||
|
||||
@@ -64,13 +69,22 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
email: data.user.email || '',
|
||||
role: (prof?.role as any) || 'cliente',
|
||||
shopId: prof?.shop_id || undefined,
|
||||
fcmToken: prof?.fcm_token || undefined,
|
||||
} as User);
|
||||
|
||||
// Regista token FCM se ainda não existir ou tiver mudado
|
||||
registerForPushNotificationsAsync().then(token => {
|
||||
if (token && token !== prof?.fcm_token) {
|
||||
supabase.from('profiles').update({ fcm_token: token }).eq('id', data.user.id).then();
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
loadUser();
|
||||
}, []);
|
||||
|
||||
const refreshShops = async () => {
|
||||
console.log('AppContext: refreshShops iniciado');
|
||||
try {
|
||||
const { data: shopsData } = await supabase.from('shops').select('*');
|
||||
const { data: servicesData } = await supabase.from('services').select('*');
|
||||
@@ -134,15 +148,33 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log('AppContext: Iniciando carregamento...');
|
||||
const init = async () => {
|
||||
await refreshShops();
|
||||
setLoading(false);
|
||||
try {
|
||||
await refreshShops();
|
||||
console.log('AppContext: Lojas carregadas com sucesso.');
|
||||
} catch (e) {
|
||||
console.error('AppContext: Erro no init:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
console.log('AppContext: setLoading(false)');
|
||||
}
|
||||
};
|
||||
init();
|
||||
}, []);
|
||||
|
||||
const login = (email: string, password: string) => {
|
||||
return true;
|
||||
// Provisório para demo
|
||||
const u: User = {
|
||||
id: email === 'barber@demo.com' ? 'demo-barber' : 'demo-cliente',
|
||||
name: email === 'barber@demo.com' ? 'Barbeiro Chefe' : 'Utilizador Demo',
|
||||
email,
|
||||
password, // Adicionado para satisfazer o tipo
|
||||
role: email === 'barber@demo.com' ? 'barbearia' : 'cliente',
|
||||
shopId: email === 'barber@demo.com' ? shops[0]?.id : undefined
|
||||
};
|
||||
setUser(u);
|
||||
return u;
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
@@ -154,7 +186,39 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const id = nanoid();
|
||||
const newUser: User = { ...payload, id };
|
||||
setUser(newUser);
|
||||
return true;
|
||||
return newUser;
|
||||
};
|
||||
|
||||
const removeFromCart = (refId: string) => {
|
||||
setCart((prev) => prev.filter((i) => i.refId !== refId));
|
||||
};
|
||||
|
||||
const placeOrder = (customerId: string, shopId: string) => {
|
||||
const shopItems = cart.filter((i) => i.shopId === shopId);
|
||||
if (!shopItems.length) return null;
|
||||
|
||||
const shop = shops.find((s) => s.id === shopId);
|
||||
const total = shopItems.reduce((sum, i) => {
|
||||
const price =
|
||||
i.type === 'service'
|
||||
? shop?.services.find((s) => s.id === i.refId)?.price ?? 0
|
||||
: shop?.products.find((p) => p.id === i.refId)?.price ?? 0;
|
||||
return sum + price * i.qty;
|
||||
}, 0);
|
||||
|
||||
const newOrder: Order = {
|
||||
id: nanoid(),
|
||||
shopId,
|
||||
customerId,
|
||||
items: shopItems,
|
||||
total,
|
||||
status: 'pendente',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setOrders((prev) => [...prev, newOrder]);
|
||||
setCart((prev) => prev.filter((i) => i.shopId !== shopId));
|
||||
return newOrder;
|
||||
};
|
||||
|
||||
const addToCart = (item: CartItem) => {
|
||||
@@ -221,7 +285,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
customer_id: input.customerId,
|
||||
date: input.date,
|
||||
status: 'pendente',
|
||||
total
|
||||
total,
|
||||
reminder_minutes: input.reminderMinutes
|
||||
}]).select().single();
|
||||
await refreshShops();
|
||||
return data as any as Appointment;
|
||||
@@ -248,6 +313,8 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
logout,
|
||||
register,
|
||||
addToCart,
|
||||
removeFromCart,
|
||||
placeOrder,
|
||||
clearCart,
|
||||
createAppointment,
|
||||
updateAppointmentStatus,
|
||||
@@ -275,4 +342,36 @@ export const useApp = () => {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) throw new Error('useApp deve ser usado dentro de AppProvider');
|
||||
return ctx;
|
||||
};
|
||||
};
|
||||
|
||||
async function registerForPushNotificationsAsync() {
|
||||
let token;
|
||||
if (Platform.OS === 'android') {
|
||||
await Notifications.setNotificationChannelAsync('default', {
|
||||
name: 'default',
|
||||
importance: Notifications.AndroidImportance.MAX,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
lightColor: '#FF231F7C',
|
||||
});
|
||||
}
|
||||
|
||||
if (Device.isDevice) {
|
||||
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
||||
let finalStatus = existingStatus;
|
||||
if (existingStatus !== 'granted') {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
finalStatus = status;
|
||||
}
|
||||
if (finalStatus !== 'granted') {
|
||||
console.log('Falha ao obter token push!');
|
||||
return;
|
||||
}
|
||||
token = (await Notifications.getExpoPushTokenAsync({
|
||||
projectId: 'b018a5db-c940-4364-81ee-596ced75cae3',
|
||||
})).data;
|
||||
} else {
|
||||
console.log('Necessário dispositivo físico para notificações push');
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
@@ -43,8 +43,8 @@ export default function AuthLogin() {
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
{/* O componente Card encapsula de forma visual os inputs de login */}
|
||||
<Card style={styles.card}>
|
||||
<Text style={styles.title}>Bem-vindo de volta</Text>
|
||||
<Text style={styles.subtitle}>Entre na sua conta para continuar</Text>
|
||||
<Text style={styles.title}>Bem-vindo</Text>
|
||||
<Text style={styles.subtitle}>Aceda à sua conta</Text>
|
||||
|
||||
{/* Bloco temporário para dados demo */}
|
||||
<View style={styles.demoBox}>
|
||||
@@ -91,7 +91,7 @@ export default function AuthLogin() {
|
||||
style={styles.footerLink}
|
||||
onPress={() => navigation.navigate('Register' as never)}
|
||||
>
|
||||
Criar conta
|
||||
Criar Conta
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
@@ -126,9 +126,9 @@ const styles = StyleSheet.create({
|
||||
textAlign: 'center',
|
||||
},
|
||||
demoBox: {
|
||||
backgroundColor: '#fef3c7',
|
||||
backgroundColor: '#e0e7ff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#fbbf24',
|
||||
borderColor: '#6366f1',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
marginBottom: 20,
|
||||
@@ -136,12 +136,12 @@ const styles = StyleSheet.create({
|
||||
demoTitle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#92400e',
|
||||
color: '#4338ca',
|
||||
marginBottom: 4,
|
||||
},
|
||||
demoText: {
|
||||
fontSize: 11,
|
||||
color: '#92400e',
|
||||
color: '#4338ca',
|
||||
},
|
||||
submitButton: {
|
||||
width: '100%',
|
||||
|
||||
@@ -31,6 +31,14 @@ export default function Booking() {
|
||||
const [barberId, setBarber] = useState('');
|
||||
const [date, setDate] = useState('');
|
||||
const [slot, setSlot] = useState('');
|
||||
const [reminderMinutes, setReminderMinutes] = useState(1440); // 24h por padrão
|
||||
|
||||
const reminderOptions = [
|
||||
{ label: '10 min', value: 10 },
|
||||
{ label: '30 min', value: 30 },
|
||||
{ label: '1 hora', value: 60 },
|
||||
{ label: '24 horas', value: 1440 },
|
||||
];
|
||||
|
||||
// Fallback visual caso ocorra um erro a obter o ID requisitado
|
||||
if (!shop) {
|
||||
@@ -99,7 +107,7 @@ export default function Booking() {
|
||||
* Desencadeia o pedido assíncrono à API para materializar o DTO na tabela 'appointments'.
|
||||
* Verifica se o token de Auth está válido (`!user`).
|
||||
*/
|
||||
const submit = () => {
|
||||
const submit = async () => {
|
||||
if (!user) {
|
||||
// Impede requisições anónimas, delegando a sessão para o sistema de autenticação
|
||||
Alert.alert('Login necessário', 'Faça login para agendar');
|
||||
@@ -109,12 +117,13 @@ export default function Booking() {
|
||||
if (!canSubmit) return;
|
||||
|
||||
// Cria o agendamento fornecendo as 'Foreign Keys' vitais (shopId, serviceId, etc...)
|
||||
const appt = createAppointment({
|
||||
const appt = await createAppointment({
|
||||
shopId: shop.id,
|
||||
serviceId,
|
||||
barberId,
|
||||
customerId: user.id, // Auth User UID
|
||||
date: `${date} ${slot}`
|
||||
date: `${date} ${slot}`,
|
||||
reminderMinutes
|
||||
});
|
||||
|
||||
if (appt) {
|
||||
@@ -131,7 +140,7 @@ export default function Booking() {
|
||||
<Text style={styles.title}>Agendar em {shop.name}</Text>
|
||||
|
||||
<Card style={styles.card}>
|
||||
<Text style={styles.sectionTitle}>1. Escolha o serviço</Text>
|
||||
<Text style={styles.sectionTitle}>1. Seleção de Serviço</Text>
|
||||
{/* Renderiza um botão (bloco flexível) por cada serviço (ex: Corte, Barba) vindos do mapeamento DB */}
|
||||
<View style={styles.grid}>
|
||||
{shop.services.map((s) => (
|
||||
@@ -146,7 +155,7 @@ export default function Booking() {
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>2. Escolha o barbeiro</Text>
|
||||
<Text style={styles.sectionTitle}>2. Barbeiro</Text>
|
||||
{/* Renderiza os profissionais, normalmente provindo dum JOIN na base de dados (tabela barbeiros + barbearia) */}
|
||||
<View style={styles.barberContainer}>
|
||||
{shop.barbers.map((b) => (
|
||||
@@ -162,7 +171,7 @@ export default function Booking() {
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>3. Escolha a data</Text>
|
||||
<Text style={styles.sectionTitle}>3. Data de Preferência</Text>
|
||||
{/* Componente simples de input que deverá mapear para a inserção final do timestamp Postgres */}
|
||||
<Input
|
||||
value={date}
|
||||
@@ -170,8 +179,7 @@ export default function Booking() {
|
||||
placeholder="YYYY-MM-DD"
|
||||
/>
|
||||
|
||||
<Text style={styles.sectionTitle}>4. Escolha o horário</Text>
|
||||
{/* Lista mapeada e computada: Apenas slots `available` (que passaram pela query preventiva de duplicação) */}
|
||||
<Text style={styles.sectionTitle}>4. Horário</Text>
|
||||
<View style={styles.slotsContainer}>
|
||||
{availableSlots.length > 0 ? (
|
||||
availableSlots.map((h) => (
|
||||
@@ -184,14 +192,29 @@ export default function Booking() {
|
||||
</TouchableOpacity>
|
||||
))
|
||||
) : (
|
||||
<Text style={styles.noSlots}>Escolha primeiro o barbeiro e a data</Text>
|
||||
<Text style={styles.noSlots}>Selecione primeiro o mestre e a data</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>5. Receber Lembrete</Text>
|
||||
<View style={styles.reminderContainer}>
|
||||
{reminderOptions.map((opt) => (
|
||||
<TouchableOpacity
|
||||
key={opt.value}
|
||||
style={[styles.reminderButton, reminderMinutes === opt.value && styles.reminderButtonActive]}
|
||||
onPress={() => setReminderMinutes(opt.value)}
|
||||
>
|
||||
<Text style={[styles.reminderText, reminderMinutes === opt.value && styles.reminderTextActive]}>
|
||||
{opt.label} antes
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Quadro resumo: Apenas mostrado se o estado interno conter todas as variáveis relacionais */}
|
||||
{canSubmit && selectedService && (
|
||||
<View style={styles.summary}>
|
||||
<Text style={styles.summaryTitle}>Resumo</Text>
|
||||
<Text style={styles.summaryTitle}>Resumo do Agendamento</Text>
|
||||
<Text style={styles.summaryText}>Serviço: {selectedService.name}</Text>
|
||||
<Text style={styles.summaryText}>Barbeiro: {selectedBarber?.name}</Text>
|
||||
<Text style={styles.summaryText}>Data: {date} às {slot}</Text>
|
||||
@@ -201,7 +224,7 @@ export default function Booking() {
|
||||
|
||||
{/* Botão para concretizar o INSERT na base de dados com as validações pré-acionadas */}
|
||||
<Button onPress={submit} disabled={!canSubmit} style={styles.submitButton} size="lg">
|
||||
{user ? 'Confirmar agendamento' : 'Entrar para agendar'}
|
||||
{user ? 'Confirmar Reserva' : 'Entrar para Reservar'}
|
||||
</Button>
|
||||
</Card>
|
||||
</ScrollView>
|
||||
@@ -319,6 +342,31 @@ const styles = StyleSheet.create({
|
||||
color: '#94a3b8',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
reminderContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
reminderButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e2e8f0',
|
||||
},
|
||||
reminderButtonActive: {
|
||||
borderColor: '#6366f1',
|
||||
backgroundColor: '#e0e7ff',
|
||||
},
|
||||
reminderText: {
|
||||
fontSize: 12,
|
||||
color: '#64748b',
|
||||
},
|
||||
reminderTextActive: {
|
||||
color: '#6366f1',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
summary: {
|
||||
backgroundColor: '#f1f5f9',
|
||||
padding: 16,
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function Cart() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Card style={styles.emptyCard}>
|
||||
<Text style={styles.emptyText}>Carrinho vazio</Text>
|
||||
<Text style={styles.emptyText}>Sua Seleção está Deserta</Text>
|
||||
</Card>
|
||||
</View>
|
||||
);
|
||||
@@ -49,7 +49,7 @@ export default function Cart() {
|
||||
const handleCheckout = (shopId: string) => {
|
||||
// Verificamos de forma segura pelo objeto user se o authState (sessão Supabase) existe
|
||||
if (!user) {
|
||||
Alert.alert('Login necessário', 'Faça login para finalizar o pedido');
|
||||
Alert.alert('Sessão Necessária', 'Inicie sessão para confirmar o seu pedido');
|
||||
navigation.navigate('Login' as never);
|
||||
return;
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export default function Cart() {
|
||||
return (
|
||||
// A página permite visibilidade escalonada num conteúdo flexível (ScrollView)
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.title}>Carrinho</Text>
|
||||
<Text style={styles.title}>Minha Seleção</Text>
|
||||
|
||||
{/* Renderiza dinamicamente 1 Card de Checkout por Loja agrupada no objeto `grouped` */}
|
||||
{Object.entries(grouped).map(([shopId, items]) => {
|
||||
@@ -122,14 +122,14 @@ export default function Cart() {
|
||||
{/* Renderização condicional no React para encaminhar fluxo para login se anónimo */}
|
||||
{user ? (
|
||||
<Button onPress={() => handleCheckout(shopId)} style={styles.checkoutButton}>
|
||||
Finalizar pedido
|
||||
Finalizar Aquisição
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onPress={() => navigation.navigate('Login' as never)}
|
||||
style={styles.checkoutButton}
|
||||
>
|
||||
Entrar para finalizar
|
||||
Entrar para Adquirir
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -136,12 +136,12 @@ export default function Dashboard() {
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Visão Geral' },
|
||||
{ id: 'appointments', label: 'Agendamentos' },
|
||||
{ id: 'orders', label: 'Pedidos' },
|
||||
{ id: 'overview', label: 'Estatísticas' },
|
||||
{ id: 'appointments', label: 'Reservas' },
|
||||
{ id: 'orders', label: 'Pedidos Boutique' },
|
||||
{ id: 'services', label: 'Serviços' },
|
||||
{ id: 'products', label: 'Produtos' },
|
||||
{ id: 'barbers', label: 'Barbeiros' },
|
||||
{ id: 'barbers', label: 'Equipa' },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -172,7 +172,7 @@ export default function Dashboard() {
|
||||
<View>
|
||||
<View style={styles.statsGrid}>
|
||||
<Card style={styles.statCard}>
|
||||
<Text style={styles.statLabel}>Faturamento</Text>
|
||||
<Text style={styles.statLabel}>Receita Total</Text>
|
||||
<Text style={styles.statValue}>{currency(totalRevenue)}</Text>
|
||||
</Card>
|
||||
<Card style={styles.statCard}>
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function Explore() {
|
||||
return (
|
||||
// Componente raiz do ecã de exploração
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Explorar barbearias</Text>
|
||||
<Text style={styles.title}>Barbearias</Text>
|
||||
|
||||
{/* FlatList é o componente nativo otimizado para renderizar grandes arrays de dados provenientes da BD */}
|
||||
<FlatList
|
||||
@@ -54,7 +54,7 @@ export default function Explore() {
|
||||
variant="outline"
|
||||
style={styles.button}
|
||||
>
|
||||
Ver detalhes
|
||||
Ver Barbearia
|
||||
</Button>
|
||||
|
||||
{/* Redirecionamento direto com foreign key injetada para a view de Agendamentos */}
|
||||
@@ -62,7 +62,7 @@ export default function Explore() {
|
||||
onPress={() => navigation.navigate('Booking', { shopId: shop.id })}
|
||||
style={styles.button}
|
||||
>
|
||||
Agendar
|
||||
Reservar
|
||||
</Button>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
@@ -19,51 +19,48 @@ export default function Landing() {
|
||||
<View style={styles.hero}>
|
||||
<Text style={styles.heroTitle}>Smart Agenda</Text>
|
||||
<Text style={styles.heroSubtitle}>
|
||||
Agendamentos, produtos e gestão em um único lugar.
|
||||
Agendamento e Gestão de Barbearias.
|
||||
</Text>
|
||||
<Text style={styles.heroDesc}>
|
||||
Experiência mobile-first para clientes e painel completo para barbearias.
|
||||
A sua solução completa para o dia-a-dia da barbearia.
|
||||
</Text>
|
||||
<View style={styles.buttons}>
|
||||
{/* Este fluxo permite utilizadores visitarem dados públicos da plataforma via Explore */}
|
||||
<Button
|
||||
onPress={() => navigation.navigate('Explore' as never)}
|
||||
style={styles.button}
|
||||
size="lg"
|
||||
>
|
||||
Explorar barbearias
|
||||
Ver Barbearias
|
||||
</Button>
|
||||
|
||||
{/* Botão focado no registo de novos utilizadores */}
|
||||
<Button
|
||||
onPress={() => navigation.navigate('Register' as never)}
|
||||
variant="outline"
|
||||
style={styles.button}
|
||||
size="lg"
|
||||
>
|
||||
Criar conta
|
||||
Criar Conta
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.features}>
|
||||
{/* Componentes estáticos descritivos sobre as features que mapeiam para funcionalidades da BD */}
|
||||
<Card style={styles.featureCard}>
|
||||
<Text style={styles.featureTitle}>Agendamentos</Text>
|
||||
<Text style={styles.featureTitle}>Reservas Rápidas</Text>
|
||||
<Text style={styles.featureDesc}>
|
||||
Escolha serviço, barbeiro, data e horário com validação de slots.
|
||||
Selecione o seu barbeiro e o horário ideal em poucos segundos.
|
||||
</Text>
|
||||
</Card>
|
||||
<Card style={styles.featureCard}>
|
||||
<Text style={styles.featureTitle}>Carrinho</Text>
|
||||
<Text style={styles.featureTitle}>Produtos</Text>
|
||||
<Text style={styles.featureDesc}>
|
||||
Produtos e serviços agrupados por barbearia, pagamento rápido.
|
||||
Produtos de cuidado masculino selecionados para si.
|
||||
</Text>
|
||||
</Card>
|
||||
<Card style={styles.featureCard}>
|
||||
<Text style={styles.featureTitle}>Painel</Text>
|
||||
<Text style={styles.featureTitle}>Gestão de Barbearia</Text>
|
||||
<Text style={styles.featureDesc}>
|
||||
Faturamento, agendamentos, pedidos, barbearia no controle.
|
||||
Controlo total sobre o faturamento e operação da sua barbearia.
|
||||
</Text>
|
||||
</Card>
|
||||
</View>
|
||||
|
||||
@@ -13,8 +13,8 @@ import { Button } from '../components/ui/Button';
|
||||
import { currency } from '../lib/format';
|
||||
|
||||
// Mapeamento visual estático das strings de estado do Postgres/State para cores da UI
|
||||
const statusColor: Record<string, 'amber' | 'green' | 'slate' | 'red'> = {
|
||||
pendente: 'amber',
|
||||
const statusColor: Record<string, 'indigo' | 'green' | 'slate' | 'red'> = {
|
||||
pendente: 'indigo',
|
||||
confirmado: 'green',
|
||||
concluido: 'green',
|
||||
cancelado: 'red',
|
||||
@@ -47,7 +47,7 @@ export default function Profile() {
|
||||
<Text style={styles.profileEmail}>{user.email}</Text>
|
||||
|
||||
{/* Distanciamento visual e lógica dos tipos de perfil 'role' presentes na BD */}
|
||||
<Badge color="amber" style={styles.roleBadge}>
|
||||
<Badge color="indigo" style={styles.roleBadge}>
|
||||
{user.role === 'cliente' ? 'Cliente' : 'Barbearia'}
|
||||
</Badge>
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function Profile() {
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Text style={styles.sectionTitle}>Agendamentos</Text>
|
||||
<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) => {
|
||||
@@ -83,7 +83,7 @@ export default function Profile() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Text style={styles.sectionTitle}>Pedidos</Text>
|
||||
<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) => {
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function ShopDetails() {
|
||||
onPress={() => navigation.navigate('Booking', { shopId: shop.id })}
|
||||
style={styles.bookButton}
|
||||
>
|
||||
Agendar
|
||||
Reservar Experiência
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
@@ -61,13 +61,13 @@ export default function ShopDetails() {
|
||||
style={[styles.tab, tab === 'servicos' && styles.tabActive]}
|
||||
onPress={() => setTab('servicos')}
|
||||
>
|
||||
<Text style={[styles.tabText, tab === 'servicos' && styles.tabTextActive]}>Serviços</Text>
|
||||
<Text style={[styles.tabText, tab === 'servicos' && styles.tabTextActive]}>Menu de Serviços</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, tab === 'produtos' && styles.tabActive]}
|
||||
onPress={() => setTab('produtos')}
|
||||
>
|
||||
<Text style={[styles.tabText, tab === 'produtos' && styles.tabTextActive]}>Produtos</Text>
|
||||
<Text style={[styles.tabText, tab === 'produtos' && styles.tabTextActive]}>Boutique</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function ShopDetails() {
|
||||
size="sm"
|
||||
style={styles.addButton}
|
||||
>
|
||||
Adicionar ao carrinho
|
||||
Adicionar à Seleção
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
@@ -106,7 +106,7 @@ export default function ShopDetails() {
|
||||
<Text style={styles.itemDesc}>Stock: {product.stock} unidades</Text>
|
||||
|
||||
{/* Alerta de urgência de reposição assente numa regra simples de negócios matemática */}
|
||||
{product.stock <= 3 && <Badge color="amber" style={styles.stockBadge}>Stock baixo</Badge>}
|
||||
{product.stock <= 3 && <Badge color="indigo" style={styles.stockBadge}>Últimas unidades</Badge>}
|
||||
|
||||
{/* Botão em React é afetado logicamente face à impossibilidade material de encomenda */}
|
||||
<Button
|
||||
@@ -115,7 +115,7 @@ export default function ShopDetails() {
|
||||
style={styles.addButton}
|
||||
disabled={product.stock <= 0}
|
||||
>
|
||||
{product.stock > 0 ? 'Adicionar ao carrinho' : 'Sem stock'}
|
||||
{product.stock > 0 ? 'Adicionar à Seleção' : 'Indisponível'}
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
@@ -165,7 +165,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
tabActive: {
|
||||
borderColor: '#6366f1',
|
||||
backgroundColor: '#fef3c7',
|
||||
backgroundColor: '#e0e7ff',
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 14,
|
||||
|
||||
@@ -4,9 +4,9 @@ export type Product = { id: string; name: string; price: number; stock: number }
|
||||
export type BarberShop = { id: string; name: string; address: string; rating: number; services: Service[]; products: Product[]; barbers: Barber[] };
|
||||
export type AppointmentStatus = 'pendente' | 'confirmado' | 'concluido' | 'cancelado';
|
||||
export type OrderStatus = 'pendente' | 'confirmado' | 'concluido' | 'cancelado';
|
||||
export type Appointment = { id: string; shopId: string; serviceId: string; barberId: string; customerId: string; date: string; status: AppointmentStatus; total: number };
|
||||
export type Appointment = { id: string; shopId: string; serviceId: string; barberId: string; customerId: string; date: string; status: AppointmentStatus; total: number; reminderMinutes?: number };
|
||||
export type CartItem = { shopId: string; type: 'service' | 'product'; refId: string; qty: number };
|
||||
export type Order = { id: string; shopId: string; customerId: string; items: CartItem[]; total: number; status: OrderStatus; createdAt: string };
|
||||
export type User = { id: string; name: string; email: string; password: string; role: 'cliente' | 'barbearia'; shopId?: string };
|
||||
export type User = { id: string; name: string; email: string; password: string; role: 'cliente' | 'barbearia'; shopId?: string; fcmToken?: string };
|
||||
|
||||
|
||||
|
||||
8
supabase/.gitignore
vendored
Normal file
8
supabase/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
|
||||
# dotenvx
|
||||
.env.keys
|
||||
.env.local
|
||||
.env.*.local
|
||||
399
supabase/config.toml
Normal file
399
supabase/config.toml
Normal file
@@ -0,0 +1,399 @@
|
||||
# For detailed configuration reference documentation, visit:
|
||||
# https://supabase.com/docs/guides/local-development/cli/config
|
||||
# A string used to distinguish different Supabase projects on the same host. Defaults to the
|
||||
# working directory name when running `supabase init`.
|
||||
project_id = "smartagenda_pap"
|
||||
|
||||
[api]
|
||||
enabled = true
|
||||
# Port to use for the API URL.
|
||||
port = 54321
|
||||
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
|
||||
# endpoints. `public` and `graphql_public` schemas are included by default.
|
||||
schemas = ["public", "graphql_public"]
|
||||
# Extra schemas to add to the search_path of every request.
|
||||
extra_search_path = ["public", "extensions"]
|
||||
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
|
||||
# for accidental or malicious requests.
|
||||
max_rows = 1000
|
||||
|
||||
[api.tls]
|
||||
# Enable HTTPS endpoints locally using a self-signed certificate.
|
||||
enabled = false
|
||||
# Paths to self-signed certificate pair.
|
||||
# cert_path = "../certs/my-cert.pem"
|
||||
# key_path = "../certs/my-key.pem"
|
||||
|
||||
[db]
|
||||
# Port to use for the local database URL.
|
||||
port = 54322
|
||||
# Port used by db diff command to initialize the shadow database.
|
||||
shadow_port = 54320
|
||||
# Maximum amount of time to wait for health check when starting the local database.
|
||||
health_timeout = "2m"
|
||||
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
|
||||
# server_version;` on the remote database to check.
|
||||
major_version = 17
|
||||
|
||||
[db.pooler]
|
||||
enabled = false
|
||||
# Port to use for the local connection pooler.
|
||||
port = 54329
|
||||
# Specifies when a server connection can be reused by other clients.
|
||||
# Configure one of the supported pooler modes: `transaction`, `session`.
|
||||
pool_mode = "transaction"
|
||||
# How many server connections to allow per user/database pair.
|
||||
default_pool_size = 20
|
||||
# Maximum number of client connections allowed.
|
||||
max_client_conn = 100
|
||||
|
||||
# [db.vault]
|
||||
# secret_key = "env(SECRET_VALUE)"
|
||||
|
||||
[db.migrations]
|
||||
# If disabled, migrations will be skipped during a db push or reset.
|
||||
enabled = true
|
||||
# Specifies an ordered list of schema files that describe your database.
|
||||
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
|
||||
schema_paths = []
|
||||
|
||||
[db.seed]
|
||||
# If enabled, seeds the database after migrations during a db reset.
|
||||
enabled = true
|
||||
# Specifies an ordered list of seed files to load during db reset.
|
||||
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
|
||||
sql_paths = ["./seed.sql"]
|
||||
|
||||
[db.network_restrictions]
|
||||
# Enable management of network restrictions.
|
||||
enabled = false
|
||||
# List of IPv4 CIDR blocks allowed to connect to the database.
|
||||
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
|
||||
allowed_cidrs = ["0.0.0.0/0"]
|
||||
# List of IPv6 CIDR blocks allowed to connect to the database.
|
||||
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
|
||||
allowed_cidrs_v6 = ["::/0"]
|
||||
|
||||
# Uncomment to reject non-secure connections to the database.
|
||||
# [db.ssl_enforcement]
|
||||
# enabled = true
|
||||
|
||||
[realtime]
|
||||
enabled = true
|
||||
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
|
||||
# ip_version = "IPv6"
|
||||
# The maximum length in bytes of HTTP request headers. (default: 4096)
|
||||
# max_header_length = 4096
|
||||
|
||||
[studio]
|
||||
enabled = true
|
||||
# Port to use for Supabase Studio.
|
||||
port = 54323
|
||||
# External URL of the API server that frontend connects to.
|
||||
api_url = "http://127.0.0.1"
|
||||
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
|
||||
openai_api_key = "env(OPENAI_API_KEY)"
|
||||
|
||||
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
|
||||
# are monitored, and you can view the emails that would have been sent from the web interface.
|
||||
[inbucket]
|
||||
enabled = true
|
||||
# Port to use for the email testing server web interface.
|
||||
port = 54324
|
||||
# Uncomment to expose additional ports for testing user applications that send emails.
|
||||
# smtp_port = 54325
|
||||
# pop3_port = 54326
|
||||
# admin_email = "admin@email.com"
|
||||
# sender_name = "Admin"
|
||||
|
||||
[storage]
|
||||
enabled = true
|
||||
# The maximum file size allowed (e.g. "5MB", "500KB").
|
||||
file_size_limit = "50MiB"
|
||||
|
||||
# Uncomment to configure local storage buckets
|
||||
# [storage.buckets.images]
|
||||
# public = false
|
||||
# file_size_limit = "50MiB"
|
||||
# allowed_mime_types = ["image/png", "image/jpeg"]
|
||||
# objects_path = "./images"
|
||||
|
||||
# Allow connections via S3 compatible clients
|
||||
[storage.s3_protocol]
|
||||
enabled = true
|
||||
|
||||
# Image transformation API is available to Supabase Pro plan.
|
||||
# [storage.image_transformation]
|
||||
# enabled = true
|
||||
|
||||
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
|
||||
# This feature is only available on the hosted platform.
|
||||
[storage.analytics]
|
||||
enabled = false
|
||||
max_namespaces = 5
|
||||
max_tables = 10
|
||||
max_catalogs = 2
|
||||
|
||||
# Analytics Buckets is available to Supabase Pro plan.
|
||||
# [storage.analytics.buckets.my-warehouse]
|
||||
|
||||
# Store vector embeddings in S3 for large and durable datasets
|
||||
# This feature is only available on the hosted platform.
|
||||
[storage.vector]
|
||||
enabled = false
|
||||
max_buckets = 10
|
||||
max_indexes = 5
|
||||
|
||||
# Vector Buckets is available to Supabase Pro plan.
|
||||
# [storage.vector.buckets.documents-openai]
|
||||
|
||||
[auth]
|
||||
enabled = true
|
||||
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
|
||||
# in emails.
|
||||
site_url = "http://127.0.0.1:3000"
|
||||
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
|
||||
additional_redirect_urls = ["https://127.0.0.1:3000"]
|
||||
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
|
||||
jwt_expiry = 3600
|
||||
# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:<port>/auth/v1).
|
||||
# jwt_issuer = ""
|
||||
# Path to JWT signing key. DO NOT commit your signing keys file to git.
|
||||
# signing_keys_path = "./signing_keys.json"
|
||||
# If disabled, the refresh token will never expire.
|
||||
enable_refresh_token_rotation = true
|
||||
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
|
||||
# Requires enable_refresh_token_rotation = true.
|
||||
refresh_token_reuse_interval = 10
|
||||
# Allow/disallow new user signups to your project.
|
||||
enable_signup = true
|
||||
# Allow/disallow anonymous sign-ins to your project.
|
||||
enable_anonymous_sign_ins = false
|
||||
# Allow/disallow testing manual linking of accounts
|
||||
enable_manual_linking = false
|
||||
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
|
||||
minimum_password_length = 6
|
||||
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
|
||||
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
|
||||
password_requirements = ""
|
||||
|
||||
[auth.rate_limit]
|
||||
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
|
||||
email_sent = 2
|
||||
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
|
||||
sms_sent = 30
|
||||
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
|
||||
anonymous_users = 30
|
||||
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
|
||||
token_refresh = 150
|
||||
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
|
||||
sign_in_sign_ups = 30
|
||||
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
|
||||
token_verifications = 30
|
||||
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
|
||||
web3 = 30
|
||||
|
||||
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
|
||||
# [auth.captcha]
|
||||
# enabled = true
|
||||
# provider = "hcaptcha"
|
||||
# secret = ""
|
||||
|
||||
[auth.email]
|
||||
# Allow/disallow new user signups via email to your project.
|
||||
enable_signup = true
|
||||
# If enabled, a user will be required to confirm any email change on both the old, and new email
|
||||
# addresses. If disabled, only the new email is required to confirm.
|
||||
double_confirm_changes = true
|
||||
# If enabled, users need to confirm their email address before signing in.
|
||||
enable_confirmations = false
|
||||
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
|
||||
secure_password_change = false
|
||||
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
|
||||
max_frequency = "1s"
|
||||
# Number of characters used in the email OTP.
|
||||
otp_length = 6
|
||||
# Number of seconds before the email OTP expires (defaults to 1 hour).
|
||||
otp_expiry = 3600
|
||||
|
||||
# Use a production-ready SMTP server
|
||||
# [auth.email.smtp]
|
||||
# enabled = true
|
||||
# host = "smtp.sendgrid.net"
|
||||
# port = 587
|
||||
# user = "apikey"
|
||||
# pass = "env(SENDGRID_API_KEY)"
|
||||
# admin_email = "admin@email.com"
|
||||
# sender_name = "Admin"
|
||||
|
||||
# Uncomment to customize email template
|
||||
# [auth.email.template.invite]
|
||||
# subject = "You have been invited"
|
||||
# content_path = "./supabase/templates/invite.html"
|
||||
|
||||
# Uncomment to customize notification email template
|
||||
# [auth.email.notification.password_changed]
|
||||
# enabled = true
|
||||
# subject = "Your password has been changed"
|
||||
# content_path = "./templates/password_changed_notification.html"
|
||||
|
||||
[auth.sms]
|
||||
# Allow/disallow new user signups via SMS to your project.
|
||||
enable_signup = false
|
||||
# If enabled, users need to confirm their phone number before signing in.
|
||||
enable_confirmations = false
|
||||
# Template for sending OTP to users
|
||||
template = "Your code is {{ .Code }}"
|
||||
# Controls the minimum amount of time that must pass before sending another sms otp.
|
||||
max_frequency = "5s"
|
||||
|
||||
# Use pre-defined map of phone number to OTP for testing.
|
||||
# [auth.sms.test_otp]
|
||||
# 4152127777 = "123456"
|
||||
|
||||
# Configure logged in session timeouts.
|
||||
# [auth.sessions]
|
||||
# Force log out after the specified duration.
|
||||
# timebox = "24h"
|
||||
# Force log out if the user has been inactive longer than the specified duration.
|
||||
# inactivity_timeout = "8h"
|
||||
|
||||
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
|
||||
# [auth.hook.before_user_created]
|
||||
# enabled = true
|
||||
# uri = "pg-functions://postgres/auth/before-user-created-hook"
|
||||
|
||||
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
|
||||
# [auth.hook.custom_access_token]
|
||||
# enabled = true
|
||||
# uri = "pg-functions://<database>/<schema>/<hook_name>"
|
||||
|
||||
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
|
||||
[auth.sms.twilio]
|
||||
enabled = false
|
||||
account_sid = ""
|
||||
message_service_sid = ""
|
||||
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
|
||||
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
|
||||
|
||||
# Multi-factor-authentication is available to Supabase Pro plan.
|
||||
[auth.mfa]
|
||||
# Control how many MFA factors can be enrolled at once per user.
|
||||
max_enrolled_factors = 10
|
||||
|
||||
# Control MFA via App Authenticator (TOTP)
|
||||
[auth.mfa.totp]
|
||||
enroll_enabled = false
|
||||
verify_enabled = false
|
||||
|
||||
# Configure MFA via Phone Messaging
|
||||
[auth.mfa.phone]
|
||||
enroll_enabled = false
|
||||
verify_enabled = false
|
||||
otp_length = 6
|
||||
template = "Your code is {{ .Code }}"
|
||||
max_frequency = "5s"
|
||||
|
||||
# Configure MFA via WebAuthn
|
||||
# [auth.mfa.web_authn]
|
||||
# enroll_enabled = true
|
||||
# verify_enabled = true
|
||||
|
||||
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
|
||||
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
|
||||
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
|
||||
[auth.external.apple]
|
||||
enabled = false
|
||||
client_id = ""
|
||||
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
|
||||
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
|
||||
# Overrides the default auth redirectUrl.
|
||||
redirect_uri = ""
|
||||
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
|
||||
# or any other third-party OIDC providers.
|
||||
url = ""
|
||||
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
|
||||
skip_nonce_check = false
|
||||
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
|
||||
email_optional = false
|
||||
|
||||
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
|
||||
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
|
||||
[auth.web3.solana]
|
||||
enabled = false
|
||||
|
||||
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.firebase]
|
||||
enabled = false
|
||||
# project_id = "my-firebase-project"
|
||||
|
||||
# Use Auth0 as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.auth0]
|
||||
enabled = false
|
||||
# tenant = "my-auth0-tenant"
|
||||
# tenant_region = "us"
|
||||
|
||||
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.aws_cognito]
|
||||
enabled = false
|
||||
# user_pool_id = "my-user-pool-id"
|
||||
# user_pool_region = "us-east-1"
|
||||
|
||||
# Use Clerk as a third-party provider alongside Supabase Auth.
|
||||
[auth.third_party.clerk]
|
||||
enabled = false
|
||||
# Obtain from https://clerk.com/setup/supabase
|
||||
# domain = "example.clerk.accounts.dev"
|
||||
|
||||
# OAuth server configuration
|
||||
[auth.oauth_server]
|
||||
# Enable OAuth server functionality
|
||||
enabled = false
|
||||
# Path for OAuth consent flow UI
|
||||
authorization_url_path = "/oauth/consent"
|
||||
# Allow dynamic client registration
|
||||
allow_dynamic_registration = false
|
||||
|
||||
[edge_runtime]
|
||||
enabled = true
|
||||
# Supported request policies: `oneshot`, `per_worker`.
|
||||
# `per_worker` (default) — enables hot reload during local development.
|
||||
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
|
||||
policy = "per_worker"
|
||||
# Port to attach the Chrome inspector for debugging edge functions.
|
||||
inspector_port = 8083
|
||||
# The Deno major version to use.
|
||||
deno_version = 2
|
||||
|
||||
# [edge_runtime.secrets]
|
||||
# secret_key = "env(SECRET_VALUE)"
|
||||
|
||||
[analytics]
|
||||
enabled = true
|
||||
port = 54327
|
||||
# Configure one of the supported backends: `postgres`, `bigquery`.
|
||||
backend = "postgres"
|
||||
|
||||
# Experimental features may be deprecated any time
|
||||
[experimental]
|
||||
# Configures Postgres storage engine to use OrioleDB (S3)
|
||||
orioledb_version = ""
|
||||
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
|
||||
s3_host = "env(S3_HOST)"
|
||||
# Configures S3 bucket region, eg. us-east-1
|
||||
s3_region = "env(S3_REGION)"
|
||||
# Configures AWS_ACCESS_KEY_ID for S3 bucket
|
||||
s3_access_key = "env(S3_ACCESS_KEY)"
|
||||
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
|
||||
s3_secret_key = "env(S3_SECRET_KEY)"
|
||||
|
||||
[functions.send-reminder]
|
||||
enabled = true
|
||||
verify_jwt = true
|
||||
import_map = "./functions/send-reminder/deno.json"
|
||||
# Uncomment to specify a custom file path to the entrypoint.
|
||||
# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx
|
||||
entrypoint = "./functions/send-reminder/index.ts"
|
||||
# Specifies static files to be bundled with the function. Supports glob patterns.
|
||||
# For example, if you want to serve static HTML pages in your function:
|
||||
# static_files = [ "./functions/send-reminder/*.html" ]
|
||||
3
supabase/functions/send-reminder/.npmrc
Normal file
3
supabase/functions/send-reminder/.npmrc
Normal file
@@ -0,0 +1,3 @@
|
||||
# Configuration for private npm package dependencies
|
||||
# For more information on using private registries with Edge Functions, see:
|
||||
# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries
|
||||
5
supabase/functions/send-reminder/deno.json
Normal file
5
supabase/functions/send-reminder/deno.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"imports": {
|
||||
"@supabase/functions-js": "jsr:@supabase/functions-js@^2"
|
||||
}
|
||||
}
|
||||
71
supabase/functions/send-reminder/index.ts
Normal file
71
supabase/functions/send-reminder/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
// URL da API de Push do Expo (não precisa de chaves de servidor para o básico)
|
||||
const EXPO_PUSH_URL = 'https://exp.host/--/api/v2/push/send';
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
// Criar o cliente Supabase com as variáveis de ambiente do sistema
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
|
||||
)
|
||||
|
||||
try {
|
||||
// 1. Procurar agendamentos pendentes com tokens válidos
|
||||
// Nota: O campo 'fcm_token' na BD irá guardar o 'ExponentPushToken'
|
||||
const { data: appts, error } = await supabase
|
||||
.from('appointments')
|
||||
.select('*, profiles(fcm_token)')
|
||||
.eq('status', 'pendente')
|
||||
.not('profiles.fcm_token', 'is', null);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const notifications = [];
|
||||
|
||||
for (const appt of appts) {
|
||||
const expoToken = appt.profiles?.fcm_token;
|
||||
|
||||
// Verifica se é um token válido do Expo
|
||||
if (expoToken && expoToken.startsWith('ExponentPushToken')) {
|
||||
notifications.push({
|
||||
to: expoToken,
|
||||
sound: 'default',
|
||||
title: 'Lembrete Smart Agenda',
|
||||
body: `Olá! Não se esqueça do seu agendamento em breve.`,
|
||||
data: { appointmentId: appt.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Enviando ${notifications.length} notificações via Expo...`);
|
||||
|
||||
if (notifications.length > 0) {
|
||||
// 2. Enviar notificações em lote (Batch) via Expo
|
||||
const response = await fetch(EXPO_PUSH_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
},
|
||||
body: JSON.stringify(notifications),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Resultado do Expo:', result);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true, sent: notifications.length }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Erro na função send-reminder:', err);
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
"strict": true,
|
||||
"jsx": "react-native",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "node"
|
||||
}
|
||||
}
|
||||
|
||||
9
web/package-lock.json
generated
9
web/package-lock.json
generated
@@ -75,7 +75,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -1362,7 +1361,6 @@
|
||||
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1530,7 +1528,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -2129,7 +2126,6 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -2397,7 +2393,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -2603,7 +2598,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -2616,7 +2610,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -3030,7 +3023,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -3159,7 +3151,6 @@
|
||||
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
|
||||
@@ -7,20 +7,20 @@ export const BarberList = ({ barbers }: { barbers: Barber[] }) => (
|
||||
{barbers.map((b) => (
|
||||
<Card key={b.id} hover className="p-5 flex flex-col items-center text-center gap-4 group">
|
||||
<div className="relative">
|
||||
<div className="w-full aspect-square rounded-2xl overflow-hidden border-4 border-slate-50 bg-slate-100 flex items-center justify-center shadow-sm group-hover:border-amber-100 transition-colors">
|
||||
<div className="w-full aspect-square rounded-2xl overflow-hidden border-4 border-slate-50 bg-slate-100 flex items-center justify-center shadow-sm group-hover:border-indigo-100 transition-colors">
|
||||
{b.imageUrl ? (
|
||||
<img src={b.imageUrl} alt={b.name} className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" />
|
||||
) : (
|
||||
<User size={40} className="text-slate-300" />
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute -bottom-1 -right-1 w-8 h-8 bg-amber-600 rounded-full flex items-center justify-center text-white border-2 border-white shadow-sm">
|
||||
<div className="absolute -bottom-1 -right-1 w-8 h-8 bg-indigo-600 rounded-full flex items-center justify-center text-white border-2 border-white shadow-sm">
|
||||
<Scissors size={14} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-900 text-lg group-hover:text-amber-700 transition-colors">{b.name}</h3>
|
||||
<h3 className="font-bold text-slate-900 text-lg group-hover:text-indigo-700 transition-colors">{b.name}</h3>
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-1">
|
||||
{b.specialties.map((spec, i) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded text-[10px] font-bold uppercase tracking-wider">
|
||||
|
||||
@@ -219,7 +219,7 @@ export const CalendarWeekView = ({
|
||||
const service = shop.services.find((s) => s.id === apt.serviceId);
|
||||
const barber = shop.barbers.find((b) => b.id === apt.barberId);
|
||||
const statusColors = {
|
||||
pendente: 'bg-amber-100 border-amber-300 text-amber-800',
|
||||
pendente: 'bg-indigo-100 border-indigo-300 text-indigo-800',
|
||||
confirmado: 'bg-green-100 border-green-300 text-green-800',
|
||||
concluido: 'bg-blue-100 border-blue-300 text-blue-800',
|
||||
cancelado: 'bg-red-100 border-red-300 text-red-800',
|
||||
@@ -252,7 +252,7 @@ export const CalendarWeekView = ({
|
||||
{/* Legenda */}
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-l-2 bg-amber-100 border-amber-300"></div>
|
||||
<div className="w-4 h-4 rounded border-l-2 bg-indigo-100 border-indigo-300"></div>
|
||||
<span>Pendente</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -16,10 +16,10 @@ export const CartPanel = () => {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-2xl font-black text-slate-900 uppercase italic tracking-tighter">O seu carrinho está vazio</h3>
|
||||
<p className="text-slate-500 font-medium italic">Explore os melhores produtos e serviços de luxo.</p>
|
||||
<p className="text-slate-500 font-medium italic">Explore os melhores produtos e serviços disponíveis.</p>
|
||||
</div>
|
||||
<Button asChild className="h-14 px-8 bg-slate-900 hover:bg-slate-800 text-amber-500 font-black rounded-2xl uppercase tracking-widest text-xs italic">
|
||||
<Link to="/explorar">Começar a Explorar</Link>
|
||||
<Button asChild className="h-14 px-8 bg-slate-900 hover:bg-slate-800 text-indigo-400 font-black rounded-2xl uppercase tracking-widest text-xs italic">
|
||||
<Link to="/explorar">Ver Barbearias</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -60,7 +60,7 @@ export const CartPanel = () => {
|
||||
return (
|
||||
<div key={shopId} className="space-y-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<ShoppingBag size={14} className="text-amber-600" />
|
||||
<ShoppingBag size={14} className="text-indigo-600" />
|
||||
<h3 className="text-[10px] font-black uppercase tracking-[0.3em] text-slate-400">Origem: <span className="text-slate-900">{shop?.name ?? 'Barbearia'}</span></h3>
|
||||
</div>
|
||||
|
||||
@@ -105,7 +105,7 @@ export const CartPanel = () => {
|
||||
{user ? (
|
||||
<Button
|
||||
onClick={() => handleCheckout(shopId)}
|
||||
className="h-14 px-8 bg-slate-900 hover:bg-slate-800 text-amber-500 font-black rounded-2xl uppercase tracking-widest text-xs italic shadow-lg active:scale-95 transition-all"
|
||||
className="h-14 px-8 bg-slate-900 hover:bg-slate-800 text-indigo-400 font-black rounded-2xl uppercase tracking-widest text-xs italic shadow-lg active:scale-95 transition-all"
|
||||
>
|
||||
Finalizar Compra
|
||||
<ArrowRight size={14} className="ml-2" />
|
||||
|
||||
@@ -41,7 +41,7 @@ export const DashboardCards = ({ periodFilter }: Props) => {
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<Card className="p-4">
|
||||
<div className="text-sm text-slate-600">Faturamento</div>
|
||||
<div className="text-xl font-semibold text-amber-700">{currency(total)}</div>
|
||||
<div className="text-xl font-semibold text-indigo-700">{currency(total)}</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="text-sm text-slate-600">Agendamentos pendentes</div>
|
||||
|
||||
@@ -16,13 +16,13 @@ export const ProductList = ({
|
||||
{products.map((p) => {
|
||||
const lowStock = p.stock <= 3;
|
||||
return (
|
||||
<Card key={p.id} className="relative overflow-hidden border-none glass-card rounded-[2rem] premium-shadow group hover:shadow-2xl transition-all duration-300 flex flex-col">
|
||||
<div className="aspect-square bg-slate-50 flex items-center justify-center p-8 group-hover:bg-amber-50 transition-colors">
|
||||
<Package size={48} className="text-slate-200 group-hover:text-amber-200 transition-all group-hover:scale-110 duration-500" />
|
||||
<Card key={p.id} className="relative overflow-hidden border-none glass-card rounded-[2rem] group hover:shadow-2xl transition-all duration-300 flex flex-col">
|
||||
<div className="aspect-square bg-slate-50 flex items-center justify-center p-8 group-hover:bg-indigo-50 transition-colors">
|
||||
<Package size={48} className="text-slate-200 group-hover:text-indigo-200 transition-all group-hover:scale-110 duration-500" />
|
||||
|
||||
{lowStock && (
|
||||
<div className="absolute top-4 left-4">
|
||||
<Badge color="amber" variant="solid" className="text-[9px] px-2 py-0.5 font-black uppercase tracking-widest shadow-lg animate-pulse">
|
||||
<Badge color="indigo" variant="solid" className="text-[9px] px-2 py-0.5 font-black uppercase tracking-widest shadow-lg animate-pulse">
|
||||
Últimas Unidades
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -31,7 +31,7 @@ export const ProductList = ({
|
||||
|
||||
<div className="p-5 flex-1 flex flex-col gap-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-black text-slate-900 text-base tracking-tight leading-tight group-hover:text-amber-600 transition-colors uppercase italic truncate">{p.name}</h3>
|
||||
<h3 className="font-black text-slate-900 text-base tracking-tight leading-tight group-hover:text-indigo-600 transition-colors uppercase italic truncate">{p.name}</h3>
|
||||
<div className="text-xs font-bold text-slate-400 uppercase tracking-widest">
|
||||
{p.stock} em stock
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@ export const ProductList = ({
|
||||
<Button
|
||||
onClick={() => onAdd(p.id)}
|
||||
disabled={p.stock <= 0}
|
||||
className="w-full h-10 bg-slate-900 hover:bg-slate-800 text-amber-500 font-black rounded-xl shadow-lg shadow-slate-200 transition-all active:scale-95 uppercase tracking-widest text-[10px]"
|
||||
className="w-full h-10 bg-slate-900 hover:bg-slate-800 text-indigo-400 font-black rounded-xl shadow-lg shadow-slate-200 transition-all active:scale-95 uppercase tracking-widest text-[10px]"
|
||||
>
|
||||
{p.stock > 0 ? 'Adicionar' : 'Esgotado'}
|
||||
</Button>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function ReviewModal({ shopName, appointmentId, onSubmit, onClose }: Revi
|
||||
) : (
|
||||
<>
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-amber-400 to-amber-600 rounded-2xl flex items-center justify-center mx-auto mb-3 shadow-md">
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-indigo-400 to-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-3 shadow-md">
|
||||
<Star size={28} className="text-white fill-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-900">Avaliar atendimento</h3>
|
||||
@@ -76,14 +76,14 @@ export function ReviewModal({ shopName, appointmentId, onSubmit, onClose }: Revi
|
||||
<Star
|
||||
size={36}
|
||||
className={`transition-colors ${star <= (hovered || rating)
|
||||
? 'text-amber-400 fill-amber-400'
|
||||
? 'text-indigo-400 fill-indigo-400'
|
||||
: 'text-slate-300'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-center text-sm font-medium text-amber-600 mb-5 h-5">
|
||||
<p className="text-center text-sm font-medium text-indigo-600 mb-5 h-5">
|
||||
{labels[hovered || rating]}
|
||||
</p>
|
||||
|
||||
@@ -93,7 +93,7 @@ export function ReviewModal({ shopName, appointmentId, onSubmit, onClose }: Revi
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Quer deixar um comentário? (opcional)"
|
||||
rows={3}
|
||||
className="w-full border border-slate-200 rounded-xl p-3 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-amber-400 focus:border-transparent mb-4 placeholder:text-slate-400"
|
||||
className="w-full border border-slate-200 rounded-xl p-3 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:border-transparent mb-4 placeholder:text-slate-400"
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -13,10 +13,10 @@ export const ServiceList = ({
|
||||
}) => (
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{services.map((s) => (
|
||||
<Card key={s.id} className="p-6 border-none glass-card rounded-[2rem] premium-shadow group hover:shadow-2xl transition-all duration-300">
|
||||
<Card key={s.id} className="p-6 border-none glass-card rounded-[2rem] group hover:shadow-2xl transition-all duration-300">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<h3 className="font-black text-slate-900 text-xl tracking-tight leading-tight group-hover:text-amber-600 transition-colors uppercase italic">{s.name}</h3>
|
||||
<h3 className="font-black text-slate-900 text-xl tracking-tight leading-tight group-hover:text-indigo-600 transition-colors uppercase italic">{s.name}</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 bg-slate-100 rounded-lg text-xs font-bold text-slate-500 uppercase">
|
||||
<Clock size={12} />
|
||||
@@ -26,7 +26,7 @@ export const ServiceList = ({
|
||||
<span className="text-xs font-black text-slate-400 uppercase tracking-widest italic">Corte Profissional</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-black text-slate-900 tracking-tighter bg-amber-50 px-3 py-1 rounded-xl border border-amber-100">
|
||||
<div className="text-2xl font-black text-slate-900 tracking-tighter bg-indigo-50 px-3 py-1 rounded-xl border border-indigo-100">
|
||||
{currency(s.price)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -36,7 +36,7 @@ export const ServiceList = ({
|
||||
<p className="text-xs font-medium text-slate-400 max-w-[150px]">Lugar disponível hoje</p>
|
||||
<Button
|
||||
onClick={() => onSelect(s.id)}
|
||||
className="flex-1 h-11 bg-slate-900 hover:bg-slate-800 text-amber-500 font-black rounded-xl shadow-lg shadow-slate-200 transition-all active:scale-95 uppercase tracking-widest text-xs"
|
||||
className="flex-1 h-11 bg-slate-900 hover:bg-slate-800 text-indigo-400 font-black rounded-xl shadow-lg shadow-slate-200 transition-all active:scale-95 uppercase tracking-widest text-xs"
|
||||
>
|
||||
Reservar Agora
|
||||
</Button>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from './ui/button';
|
||||
|
||||
export const ShopCard = ({ shop }: { shop: BarberShop }) => {
|
||||
return (
|
||||
<Card className="overflow-hidden border-none glass-card rounded-[2rem] premium-shadow group hover:shadow-2xl hover:shadow-slate-200/60 transition-all duration-500">
|
||||
<Card className="overflow-hidden border-none glass-card rounded-[2.5rem] group hover:shadow-2xl hover:shadow-slate-200/60 transition-all duration-500">
|
||||
<div className="relative h-44 overflow-hidden">
|
||||
{shop.imageUrl ? (
|
||||
<img
|
||||
@@ -15,7 +15,7 @@ export const ShopCard = ({ shop }: { shop: BarberShop }) => {
|
||||
className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-slate-900 flex items-center justify-center text-amber-500">
|
||||
<div className="w-full h-full bg-slate-900 flex items-center justify-center text-indigo-400">
|
||||
<Scissors size={40} />
|
||||
</div>
|
||||
)}
|
||||
@@ -23,7 +23,7 @@ export const ShopCard = ({ shop }: { shop: BarberShop }) => {
|
||||
|
||||
{/* Rating Badge */}
|
||||
<div className="absolute top-4 right-4 bg-slate-900/90 backdrop-blur-md border border-white/10 px-3 py-1 rounded-full flex items-center gap-1.5 shadow-xl">
|
||||
<Star size={14} className="fill-amber-500 text-amber-500" />
|
||||
<Star size={14} className="fill-indigo-500 text-indigo-500" />
|
||||
<span className="text-white text-xs font-black tracking-wider">
|
||||
{shop.rating ? shop.rating.toFixed(1) : '0.0'}
|
||||
</span>
|
||||
@@ -32,11 +32,11 @@ export const ShopCard = ({ shop }: { shop: BarberShop }) => {
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-slate-900 text-xl font-black tracking-tight group-hover:text-amber-600 transition-colors truncate">
|
||||
<h2 className="text-slate-900 text-xl font-black tracking-tight group-hover:text-indigo-600 transition-colors truncate">
|
||||
{shop.name}
|
||||
</h2>
|
||||
<div className="flex items-center gap-1.5 text-slate-500 mt-1">
|
||||
<MapPin size={14} className="text-amber-600" />
|
||||
<MapPin size={14} className="text-indigo-600" />
|
||||
<p className="text-sm font-medium line-clamp-1">
|
||||
{shop.address || 'Endereço Indisponível'}
|
||||
</p>
|
||||
@@ -57,7 +57,7 @@ export const ShopCard = ({ shop }: { shop: BarberShop }) => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button asChild className="rounded-xl bg-slate-900 hover:bg-slate-800 text-amber-500 font-bold px-5 h-10 shadow-lg shadow-slate-200 transition-all active:scale-95">
|
||||
<Button asChild className="rounded-xl bg-slate-900 hover:bg-slate-800 text-indigo-400 font-bold px-5 h-10 shadow-lg shadow-slate-200 transition-all active:scale-95">
|
||||
<Link to={`/barbearia/${shop.id}`}>Reservar</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -22,10 +22,10 @@ export const Header = () => {
|
||||
className="text-2xl font-black tracking-tighter text-slate-900 group flex items-center gap-2"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-900 flex items-center justify-center text-amber-500 shadow-lg shadow-slate-200">
|
||||
<div className="w-8 h-8 rounded-lg bg-slate-900 flex items-center justify-center text-indigo-400 shadow-lg shadow-slate-200">
|
||||
<User size={18} fill="currentColor" />
|
||||
</div>
|
||||
<span className="group-hover:text-amber-600 transition-colors">Smart Agenda</span>
|
||||
<span className="group-hover:text-indigo-600 transition-colors uppercase italic font-black">Smart Agenda</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
@@ -36,8 +36,8 @@ export const Header = () => {
|
||||
to="/explorar"
|
||||
className="text-sm font-bold text-slate-600 hover:text-slate-900 transition-all flex items-center gap-2"
|
||||
>
|
||||
<MapPin size={16} className="text-amber-600" />
|
||||
<span>Explorar</span>
|
||||
<MapPin size={16} className="text-indigo-600" />
|
||||
<span>Descobrir Barbearias</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
@@ -46,7 +46,7 @@ export const Header = () => {
|
||||
>
|
||||
<ShoppingCart size={20} />
|
||||
{cart.length > 0 && (
|
||||
<span className="absolute -right-1 -top-1 rounded-full bg-slate-900 px-1.5 py-0.5 text-[10px] font-black text-amber-500 shadow-md min-w-[18px] text-center">
|
||||
<span className="absolute -right-1 -top-1 rounded-full bg-slate-900 px-1.5 py-0.5 text-[10px] font-black text-indigo-400 shadow-md min-w-[18px] text-center">
|
||||
{cart.length}
|
||||
</span>
|
||||
)}
|
||||
@@ -63,7 +63,7 @@ export const Header = () => {
|
||||
className="flex items-center gap-3 bg-slate-50 hover:bg-slate-100 border border-slate-200/60 pl-3 pr-4 py-1.5 rounded-full transition-all group"
|
||||
type="button"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-full bg-slate-900 flex items-center justify-center text-amber-500 shadow-sm">
|
||||
<div className="w-7 h-7 rounded-full bg-slate-900 flex items-center justify-center text-indigo-400 shadow-sm">
|
||||
<User size={14} fill="currentColor" />
|
||||
</div>
|
||||
<span className="text-sm font-bold text-slate-700 group-hover:text-slate-900 max-w-[120px] truncate">{user.name}</span>
|
||||
@@ -87,8 +87,8 @@ export const Header = () => {
|
||||
Login
|
||||
</Link>
|
||||
<Link
|
||||
to="/registro"
|
||||
className="inline-flex items-center justify-center rounded-xl bg-slate-900 px-5 py-2 text-sm font-bold text-amber-500 shadow-lg shadow-slate-200 hover:bg-slate-800 transition-all"
|
||||
to="/registo"
|
||||
className="inline-flex items-center justify-center rounded-xl bg-slate-900 px-5 py-2 text-sm font-bold text-indigo-400 shadow-lg shadow-slate-200 hover:bg-slate-800 transition-all font-black uppercase italic tracking-widest"
|
||||
>
|
||||
Criar Conta
|
||||
</Link>
|
||||
@@ -115,21 +115,21 @@ export const Header = () => {
|
||||
<Link
|
||||
to="/explorar"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-3 text-base font-bold text-slate-700 hover:text-amber-600 p-3 rounded-2xl bg-slate-50 transition-all"
|
||||
className="flex items-center gap-3 text-base font-black text-slate-700 hover:text-indigo-600 p-3 rounded-2xl bg-slate-50 transition-all uppercase italic"
|
||||
>
|
||||
<MapPin size={18} className="text-amber-600" />
|
||||
Barbearias
|
||||
<MapPin size={18} className="text-indigo-600" />
|
||||
Descobrir Barbearias
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/carrinho"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-3 text-base font-bold text-slate-700 hover:text-amber-600 p-3 rounded-2xl bg-slate-50 transition-all"
|
||||
className="flex items-center gap-3 text-base font-black text-slate-700 hover:text-indigo-600 p-3 rounded-2xl bg-slate-50 transition-all uppercase italic"
|
||||
>
|
||||
<ShoppingCart size={18} className="text-amber-600" />
|
||||
<ShoppingCart size={18} className="text-indigo-600" />
|
||||
Meu Carrinho
|
||||
{cart.length > 0 && (
|
||||
<span className="ml-auto rounded-full bg-slate-900 px-2 py-0.5 text-[10px] font-black text-amber-500">
|
||||
<span className="ml-auto rounded-full bg-slate-900 px-2 py-0.5 text-[10px] font-black text-indigo-400">
|
||||
{cart.length}
|
||||
</span>
|
||||
)}
|
||||
@@ -172,9 +172,9 @@ export const Header = () => {
|
||||
Entrar
|
||||
</Link>
|
||||
<Link
|
||||
to="/registro"
|
||||
to="/registo"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center justify-center rounded-2xl bg-slate-900 py-3 text-sm font-bold text-amber-500 shadow-lg shadow-slate-200"
|
||||
className="flex items-center justify-center rounded-2xl bg-slate-900 py-3 text-sm font-bold text-indigo-400 shadow-lg shadow-slate-200 font-black uppercase italic tracking-widest"
|
||||
>
|
||||
Criar Conta
|
||||
</Link>
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
type Props = { children: React.ReactNode; color?: 'amber' | 'slate' | 'green' | 'red' | 'blue'; className?: string; variant?: 'solid' | 'soft' };
|
||||
type Props = { children: React.ReactNode; color?: 'slate' | 'green' | 'red' | 'blue' | 'indigo'; className?: string; variant?: 'solid' | 'soft' };
|
||||
|
||||
const colorMap = {
|
||||
solid: {
|
||||
amber: 'bg-amber-500 text-white',
|
||||
slate: 'bg-slate-600 text-white',
|
||||
green: 'bg-emerald-500 text-white',
|
||||
red: 'bg-rose-500 text-white',
|
||||
slate: 'bg-slate-500 text-white',
|
||||
green: 'bg-green-500 text-white',
|
||||
red: 'bg-red-500 text-white',
|
||||
blue: 'bg-blue-500 text-white',
|
||||
indigo: 'bg-indigo-500 text-white',
|
||||
},
|
||||
soft: {
|
||||
amber: 'bg-amber-50 text-amber-700 border border-amber-200/60',
|
||||
slate: 'bg-slate-50 text-slate-700 border border-slate-200/60',
|
||||
green: 'bg-emerald-50 text-emerald-700 border border-emerald-200/60',
|
||||
red: 'bg-rose-50 text-rose-700 border border-rose-200/60',
|
||||
green: 'bg-green-50 text-green-700 border border-green-200/60',
|
||||
red: 'bg-red-50 text-red-700 border border-red-200/60',
|
||||
blue: 'bg-blue-50 text-blue-700 border border-blue-200/60',
|
||||
indigo: 'bg-indigo-50 text-indigo-700 border border-indigo-200/60',
|
||||
},
|
||||
};
|
||||
|
||||
export const Badge = ({ children, color = 'amber', variant = 'soft', className }: Props) => (
|
||||
export const Badge = ({ children, color = 'indigo', variant = 'soft', className }: Props) => (
|
||||
<span className={cn('inline-flex items-center px-2.5 py-1 text-xs rounded-full font-semibold transition-colors', colorMap[variant][color], className)}>
|
||||
{children}
|
||||
</span>
|
||||
|
||||
@@ -5,7 +5,7 @@ export const Chip = ({ children, active, onClick, className }: { children: React
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-full border text-sm transition font-medium',
|
||||
active ? 'border-amber-500 bg-amber-50 text-amber-700' : 'border-slate-200 text-slate-700 hover:bg-slate-100',
|
||||
active ? 'border-indigo-500 bg-indigo-50 text-indigo-700' : 'border-slate-200 text-slate-700 hover:bg-slate-100',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -14,7 +14,7 @@ export const Input = ({ className, label, error, ...props }: Props) => {
|
||||
'placeholder:text-slate-400',
|
||||
error
|
||||
? 'border-rose-300 focus:border-rose-500 focus:ring-2 focus:ring-rose-500/30'
|
||||
: 'border-slate-300 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/30',
|
||||
: 'border-slate-300 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -11,13 +11,13 @@ export const Tabs = ({ tabs, active, onChange, className }: { tabs: Tab[]; activ
|
||||
className={cn(
|
||||
"px-6 py-2.5 text-sm font-black uppercase tracking-widest transition-all rounded-xl whitespace-nowrap",
|
||||
active === t.id
|
||||
? "bg-slate-900 text-amber-500 shadow-xl"
|
||||
? "bg-slate-900 text-indigo-400 shadow-xl"
|
||||
: "text-slate-500 hover:text-slate-900 hover:bg-white/50"
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
{t.badge && (
|
||||
<span className="ml-2 inline-flex items-center justify-center w-5 h-5 text-[10px] font-black text-white bg-slate-900 rounded-full border border-amber-500/50">
|
||||
<span className="ml-2 inline-flex items-center justify-center w-5 h-5 text-[10px] font-black text-white bg-slate-900 rounded-full border border-indigo-500/50">
|
||||
{t.badge}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -16,63 +16,30 @@ export const mockShops: BarberShop[] = [
|
||||
imageUrl:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='480'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='%23111827'/%3E%3Cstop offset='100%25' stop-color='%232563eb'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='800' height='480' fill='url(%23g)'/%3E%3Ccircle cx='640' cy='120' r='120' fill='%23ffffff22'/%3E%3Crect x='80' y='320' width='300' height='12' fill='%23ffffff66'/%3E%3Crect x='80' y='350' width='220' height='10' fill='%23ffffff55'/%3E%3C/text%3E%3C/svg%3E",
|
||||
barbers: [
|
||||
{ id: 'b1', name: 'João', specialties: ['Fade', 'Navalha'], schedule: [{ day: '2025-01-01', slots: ['09:00', '10:00', '11:00'] }] },
|
||||
{ id: 'b2', name: 'Carlos', specialties: ['Barba', 'Clássico'], schedule: [{ day: '2025-01-01', slots: ['14:00', '15:00'] }] },
|
||||
{ id: 'b1', name: 'João', specialties: ['Fade', 'Navalha'], schedule: [{ day: '2026-03-15', slots: ['09:00', '10:00', '11:00'] }] },
|
||||
{ id: 'b2', name: 'Carlos', specialties: ['Barba', 'Clássico'], schedule: [{ day: '2026-03-15', slots: ['14:00', '15:00'] }] },
|
||||
],
|
||||
services: [
|
||||
{ id: 'sv1', name: 'Corte Fade', price: 60, duration: 45, barberIds: ['b1'] },
|
||||
{ id: 'sv2', name: 'Barba Completa', price: 40, duration: 30, barberIds: ['b2'] },
|
||||
{ id: 'sv1', name: 'Corte', price: 15, duration: 45, barberIds: ['b1'] },
|
||||
{ id: 'sv2', name: 'Barba', price: 10, duration: 30, barberIds: ['b2'] },
|
||||
],
|
||||
products: [
|
||||
{ id: 'p1', name: 'Pomada Matte', price: 35, stock: 8 },
|
||||
{ id: 'p2', name: 'Óleo para Barba', price: 45, stock: 5 },
|
||||
{ id: 'p1', name: 'Pomada Matte', price: 12, stock: 8 },
|
||||
{ id: 'p2', name: 'Óleo para Barba', price: 15, stock: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 's2',
|
||||
name: 'Barbearia Bairro',
|
||||
address: 'Av. Verde, 45',
|
||||
name: 'Barbearia do Bairro',
|
||||
address: 'Av. Principal, 45',
|
||||
rating: 4.5,
|
||||
imageUrl:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='480'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='%230f172a'/%3E%3Cstop offset='100%25' stop-color='%230ea5e9'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='800' height='480' fill='url(%23g)'/%3E%3Crect x='60' y='90' width='260' height='180' rx='24' fill='%23ffffff1f'/%3E%3Crect x='380' y='260' width='300' height='12' fill='%23ffffff66'/%3E%3Crect x='380' y='290' width='180' height='10' fill='%23ffffff55'/%3E%3C/svg%3E",
|
||||
barbers: [
|
||||
{ id: 'b3', name: 'Miguel', specialties: ['Clássico', 'Fade'], schedule: [{ day: '2025-01-01', slots: ['09:30', '10:30'] }] },
|
||||
{ id: 'b3', name: 'Miguel', specialties: ['Clássico', 'Fade'], schedule: [{ day: '2026-03-15', slots: ['09:30', '10:30'] }] },
|
||||
],
|
||||
services: [{ id: 'sv3', name: 'Corte Clássico', price: 50, duration: 40, barberIds: ['b3'] }],
|
||||
products: [{ id: 'p3', name: 'Shampoo Masculino', price: 30, stock: 10 }],
|
||||
},
|
||||
{
|
||||
id: 's3',
|
||||
name: 'Barbearia Central',
|
||||
address: 'Rua Principal, 123',
|
||||
rating: 4.7,
|
||||
imageUrl:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='480'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='%23111827'/%3E%3Cstop offset='100%25' stop-color='%232563eb'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='800' height='480' fill='url(%23g)'/%3E%3Ccircle cx='640' cy='120' r='120' fill='%23ffffff22'/%3E%3Crect x='80' y='320' width='300' height='12' fill='%23ffffff66'/%3E%3Crect x='80' y='350' width='220' height='10' fill='%23ffffff55'/%3E%3C/text%3E%3C/svg%3E",
|
||||
barbers: [
|
||||
{ id: 'b1', name: 'João', specialties: ['Fade', 'Navalha'], schedule: [{ day: '2025-01-01', slots: ['09:00', '10:00', '11:00'] }] },
|
||||
{ id: 'b2', name: 'Carlos', specialties: ['Barba', 'Clássico'], schedule: [{ day: '2025-01-01', slots: ['14:00', '15:00'] }] },
|
||||
],
|
||||
services: [
|
||||
{ id: 'sv1', name: 'Corte Fade', price: 60, duration: 45, barberIds: ['b1'] },
|
||||
{ id: 'sv2', name: 'Barba Completa', price: 40, duration: 30, barberIds: ['b2'] },
|
||||
],
|
||||
products: [
|
||||
{ id: 'p1', name: 'Pomada Matte', price: 35, stock: 8 },
|
||||
{ id: 'p2', name: 'Óleo para Barba', price: 45, stock: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 's4',
|
||||
name: 'Barbearia Bairro',
|
||||
address: 'Av. Verde, 45',
|
||||
rating: 4.5,
|
||||
imageUrl:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='480'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0%25' stop-color='%230f172a'/%3E%3Cstop offset='100%25' stop-color='%230ea5e9'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='800' height='480' fill='url(%23g)'/%3E%3Crect x='60' y='90' width='260' height='180' rx='24' fill='%23ffffff1f'/%3E%3Crect x='380' y='260' width='300' height='12' fill='%23ffffff66'/%3E%3Crect x='380' y='290' width='180' height='10' fill='%23ffffff55'/%3E%3C/svg%3E",
|
||||
barbers: [
|
||||
{ id: 'b3', name: 'Miguel', specialties: ['Clássico', 'Fade'], schedule: [{ day: '2025-01-01', slots: ['09:30', '10:30'] }] },
|
||||
],
|
||||
services: [{ id: 'sv3', name: 'Corte Clássico', price: 50, duration: 40, barberIds: ['b3'] }],
|
||||
products: [{ id: 'p3', name: 'Shampoo Masculino', price: 30, stock: 10 }],
|
||||
services: [{ id: 'sv3', name: 'Corte Clássico', price: 12, duration: 40, barberIds: ['b3'] }],
|
||||
products: [{ id: 'p3', name: 'Shampoo', price: 8, stock: 10 }],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--brand-gold: #d97706;
|
||||
--brand-gold-light: #fbbf24;
|
||||
--brand-primary: #4f46e5;
|
||||
--brand-primary-light: #818cf8;
|
||||
--brand-primary-dark: #3730a3;
|
||||
--obsidian: #0f172a;
|
||||
--obsidian-light: #1e293b;
|
||||
--slate-950: #020617;
|
||||
@@ -19,7 +20,7 @@
|
||||
body {
|
||||
@apply bg-[#f8fafc] text-slate-900 font-sans antialiased;
|
||||
background-image:
|
||||
radial-gradient(at 0% 0%, rgba(217, 119, 6, 0.03) 0px, transparent 50%),
|
||||
radial-gradient(at 0% 0%, rgba(79, 70, 229, 0.03) 0px, transparent 50%),
|
||||
radial-gradient(at 100% 0%, rgba(15, 23, 42, 0.03) 0px, transparent 50%);
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
min-height: 100vh;
|
||||
@@ -39,7 +40,7 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-slate-300 rounded-full hover:bg-slate-400;
|
||||
@apply bg-indigo-200 rounded-full hover:bg-indigo-300;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,12 +53,9 @@
|
||||
@apply bg-white/80 backdrop-blur-md border border-white/20 shadow-xl shadow-slate-200/50;
|
||||
}
|
||||
|
||||
.premium-shadow {
|
||||
box-shadow: 0 10px 40px -10px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.gold-gradient {
|
||||
@apply bg-gradient-to-br from-amber-500 via-amber-600 to-amber-700;
|
||||
.indigo-gradient {
|
||||
@apply bg-gradient-to-br from-indigo-500 via-indigo-600 to-indigo-700;
|
||||
}
|
||||
|
||||
.obsidian-gradient {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const brand = {
|
||||
primary: 'amber-500',
|
||||
primaryDark: 'amber-600',
|
||||
primary: 'indigo-600',
|
||||
primaryDark: 'indigo-700',
|
||||
bg: 'slate-50',
|
||||
card: 'white',
|
||||
text: 'slate-900',
|
||||
|
||||
@@ -67,9 +67,9 @@ export default function AuthLogin() {
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh] flex items-center justify-center px-6 py-12">
|
||||
<Card className="w-full max-w-[440px] p-10 space-y-8 glass-card border-none rounded-[2rem] premium-shadow animate-in fade-in zoom-in duration-500">
|
||||
<Card className="w-full max-w-[440px] p-10 space-y-8 glass-card border-none rounded-[2rem] animate-in fade-in zoom-in duration-500">
|
||||
<div className="text-center space-y-4">
|
||||
<Link to="/" className="inline-flex p-4 bg-slate-900 rounded-2xl text-amber-500 shadow-xl mb-2 hover:scale-105 transition-transform">
|
||||
<Link to="/" className="inline-flex p-4 bg-slate-900 rounded-2xl text-indigo-400 shadow-xl mb-2 hover:scale-105 transition-transform">
|
||||
<LogIn size={32} />
|
||||
</Link>
|
||||
<div className="space-y-1">
|
||||
@@ -93,7 +93,7 @@ export default function AuthLogin() {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="exemplo@email.com"
|
||||
required
|
||||
className="rounded-xl border-slate-200/60 focus:ring-amber-500/20"
|
||||
className="rounded-xl border-slate-200/60 focus:ring-indigo-500/20"
|
||||
/>
|
||||
|
||||
<Input
|
||||
@@ -103,14 +103,14 @@ export default function AuthLogin() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="rounded-xl border-slate-200/60 focus:ring-amber-500/20"
|
||||
className="rounded-xl border-slate-200/60 focus:ring-indigo-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full h-12 bg-slate-900 hover:bg-slate-800 text-amber-500 font-bold rounded-xl shadow-lg shadow-slate-200 transition-all active:scale-[0.98]" disabled={loading}>
|
||||
<Button type="submit" className="w-full h-12 bg-slate-900 hover:bg-slate-800 text-indigo-400 font-bold rounded-xl shadow-lg shadow-slate-200 transition-all active:scale-[0.98]" disabled={loading}>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="w-4 h-4 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span>A entrar...</span>
|
||||
</div>
|
||||
) : 'Entrar na Conta'}
|
||||
@@ -120,7 +120,7 @@ export default function AuthLogin() {
|
||||
<div className="text-center pt-6 border-t border-slate-100">
|
||||
<p className="text-sm text-slate-500 font-medium">
|
||||
Ainda não tem conta?{' '}
|
||||
<Link to="/registo" className="text-amber-600 font-bold hover:text-amber-700 underline-offset-4 hover:underline transition-all">
|
||||
<Link to="/registo" className="text-indigo-600 font-bold hover:text-indigo-700 underline-offset-4 hover:underline transition-all">
|
||||
Criar conta grátis
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -109,9 +109,9 @@ export default function AuthRegister() {
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh] flex items-center justify-center px-6 py-12">
|
||||
<Card className="w-full max-w-[500px] p-10 space-y-8 glass-card border-none rounded-[2rem] premium-shadow animate-in fade-in zoom-in duration-500">
|
||||
<Card className="w-full max-w-[500px] p-10 space-y-8 glass-card border-none rounded-[2rem] animate-in fade-in zoom-in duration-500">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="inline-flex p-4 bg-slate-900 rounded-2xl text-amber-500 shadow-xl mb-2">
|
||||
<div className="inline-flex p-4 bg-slate-900 rounded-2xl text-indigo-400 shadow-xl mb-2">
|
||||
<UserPlus size={32} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -142,15 +142,15 @@ export default function AuthRegister() {
|
||||
setError('')
|
||||
}}
|
||||
className={`p-4 rounded-2xl border-2 transition-all group ${role === r
|
||||
? 'border-slate-900 bg-slate-900 text-amber-500 shadow-lg'
|
||||
? 'border-slate-900 bg-slate-900 text-indigo-400 shadow-lg'
|
||||
: 'border-slate-100 bg-slate-50/50 hover:border-slate-200 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{r === 'cliente' ? (
|
||||
<User size={20} className={role === r ? 'text-amber-500' : 'text-slate-400 group-hover:text-slate-600'} />
|
||||
<User size={20} className={role === r ? 'text-indigo-400' : 'text-slate-400 group-hover:text-slate-600'} />
|
||||
) : (
|
||||
<Scissors size={20} className={role === r ? 'text-amber-500' : 'text-slate-400 group-hover:text-slate-600'} />
|
||||
<Scissors size={20} className={role === r ? 'text-indigo-400' : 'text-slate-400 group-hover:text-slate-600'} />
|
||||
)}
|
||||
<span className="text-sm font-bold uppercase tracking-tight">
|
||||
{r === 'cliente' ? 'Cliente' : 'Barbearia'}
|
||||
@@ -168,7 +168,7 @@ export default function AuthRegister() {
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Ex: João Silva"
|
||||
required
|
||||
className="rounded-xl border-slate-200 focus:ring-amber-500/20"
|
||||
className="rounded-xl border-slate-200 focus:ring-indigo-500/20"
|
||||
/>
|
||||
|
||||
<Input
|
||||
@@ -178,7 +178,7 @@ export default function AuthRegister() {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="exemplo@email.com"
|
||||
required
|
||||
className="rounded-xl border-slate-200 focus:ring-amber-500/20"
|
||||
className="rounded-xl border-slate-200 focus:ring-indigo-500/20"
|
||||
/>
|
||||
|
||||
<Input
|
||||
@@ -188,7 +188,7 @@ export default function AuthRegister() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
className="rounded-xl border-slate-200 focus:ring-amber-500/20"
|
||||
className="rounded-xl border-slate-200 focus:ring-indigo-500/20"
|
||||
/>
|
||||
|
||||
{role === 'barbearia' && (
|
||||
@@ -203,10 +203,10 @@ export default function AuthRegister() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full h-12 bg-slate-900 hover:bg-slate-800 text-amber-500 font-bold rounded-xl shadow-lg shadow-slate-200 transition-all active:scale-[0.98]" disabled={loading}>
|
||||
<Button type="submit" className="w-full h-12 bg-slate-900 hover:bg-slate-800 text-indigo-400 font-bold rounded-xl shadow-lg shadow-slate-200 transition-all active:scale-[0.98]" disabled={loading}>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="w-4 h-4 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span>A processar...</span>
|
||||
</div>
|
||||
) : 'Criar minha conta'}
|
||||
@@ -216,7 +216,7 @@ export default function AuthRegister() {
|
||||
<div className="text-center pt-6 border-t border-slate-100">
|
||||
<p className="text-sm text-slate-500 font-medium">
|
||||
Já tem uma conta?{' '}
|
||||
<Link to="/login" className="text-amber-600 font-bold hover:text-amber-700 underline-offset-4 hover:underline transition-all">
|
||||
<Link to="/login" className="text-indigo-600 font-bold hover:text-indigo-700 underline-offset-4 hover:underline transition-all">
|
||||
Fazer Login
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -99,16 +99,16 @@ export default function Booking() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-10 py-4 pb-20">
|
||||
<header className="space-y-4 text-center">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-amber-100 text-amber-700 text-[10px] font-black uppercase tracking-widest">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-indigo-100 text-indigo-700 text-[10px] font-black uppercase tracking-widest">
|
||||
<Calendar size={10} fill="currentColor" />
|
||||
<span>Reserva Exclusiva</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-4xl md:text-5xl font-black text-slate-900 tracking-tighter uppercase italic">
|
||||
Agendar em <span className="text-amber-600 block md:inline">{shop.name}</span>
|
||||
Reservar em <span className="text-indigo-600 block md:inline">{shop.name}</span>
|
||||
</h1>
|
||||
<div className="flex items-center justify-center gap-2 text-slate-500 font-medium">
|
||||
<MapPin size={14} className="text-amber-600" />
|
||||
<MapPin size={14} className="text-indigo-600" />
|
||||
<p className="text-sm">{shop.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,9 +127,9 @@ export default function Booking() {
|
||||
disabled={!s.completed && s.id > step}
|
||||
className={`w-12 h-12 rounded-2xl flex items-center justify-center border-2 transition-all duration-500 scale-100 active:scale-90 ${
|
||||
s.active
|
||||
? 'bg-slate-900 border-slate-900 text-amber-500 shadow-2xl shadow-slate-300 -translate-y-2'
|
||||
? 'bg-slate-900 border-slate-900 text-indigo-400 shadow-2xl shadow-slate-300 -translate-y-2'
|
||||
: s.completed
|
||||
? 'bg-amber-500 border-amber-500 text-white'
|
||||
? 'bg-indigo-600 border-indigo-600 text-white'
|
||||
: 'bg-white border-slate-200 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
@@ -162,8 +162,8 @@ export default function Booking() {
|
||||
{step === 1 && (
|
||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="space-y-2 text-center md:text-left">
|
||||
<h3 className="text-3xl font-black text-slate-900 tracking-tighter uppercase italic">1. Selecione o <span className="text-amber-600">Serviço</span></h3>
|
||||
<p className="text-slate-500 font-medium italic">O primeiro passo para a sua transformação de elite.</p>
|
||||
<h3 className="text-3xl font-black text-slate-900 tracking-tighter uppercase italic">1. Selecione o <span className="text-indigo-600">Serviço</span></h3>
|
||||
<p className="text-slate-500 font-medium italic">O primeiro passo para o seu agendamento.</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{shop.services.map((s) => (
|
||||
@@ -176,12 +176,12 @@ export default function Booking() {
|
||||
className={`group p-6 rounded-[2rem] border-2 text-left transition-all duration-300 flex flex-col gap-4 ${
|
||||
serviceId === s.id
|
||||
? 'border-slate-900 bg-slate-900 text-white shadow-2xl translate-y-[-4px]'
|
||||
: 'border-slate-50 bg-slate-50 hover:border-amber-200 hover:bg-amber-50/50'
|
||||
: 'border-slate-50 bg-slate-50 hover:border-indigo-200 hover:bg-indigo-50/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className={`font-black text-xl tracking-tight uppercase italic ${serviceId === s.id ? 'text-amber-500' : 'text-slate-900 group-hover:text-amber-600'}`}>
|
||||
<div className={`font-black text-xl tracking-tight uppercase italic ${serviceId === s.id ? 'text-indigo-500' : 'text-slate-900 group-hover:text-indigo-600'}`}>
|
||||
{s.name}
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 text-xs font-bold uppercase tracking-widest ${serviceId === s.id ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
@@ -202,7 +202,7 @@ export default function Booking() {
|
||||
{step === 2 && (
|
||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="space-y-2 text-center md:text-left">
|
||||
<h3 className="text-3xl font-black text-slate-900 tracking-tighter uppercase italic">2. Escolha o <span className="text-amber-600">Mestre</span></h3>
|
||||
<h3 className="text-3xl font-black text-slate-900 tracking-tighter uppercase italic">2. Escolha o <span className="text-indigo-600">Mestre</span></h3>
|
||||
<p className="text-slate-500 font-medium italic">Selecione o artista que cuidará do seu visual.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-6">
|
||||
@@ -216,10 +216,10 @@ export default function Booking() {
|
||||
className={`group p-6 rounded-[2.5rem] border-2 text-center transition-all duration-300 flex flex-col items-center gap-5 ${
|
||||
barberId === b.id
|
||||
? 'border-slate-900 bg-slate-900 text-white shadow-2xl translate-y-[-4px]'
|
||||
: 'border-slate-50 bg-slate-50 hover:border-amber-200 hover:bg-amber-50/50'
|
||||
: 'border-slate-50 bg-slate-50 hover:border-indigo-200 hover:bg-indigo-50/50'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-32 h-32 rounded-[2rem] overflow-hidden border-4 transition-all duration-500 ${barberId === b.id ? 'border-amber-500 rotate-3' : 'border-white group-hover:border-amber-100'}`}>
|
||||
<div className={`w-32 h-32 rounded-[2rem] overflow-hidden border-4 transition-all duration-500 ${barberId === b.id ? 'border-indigo-500 rotate-3' : 'border-white group-hover:border-indigo-100'}`}>
|
||||
{b.imageUrl ? (
|
||||
<img src={b.imageUrl} alt={b.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
@@ -229,9 +229,9 @@ export default function Booking() {
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className={`font-black text-lg uppercase italic tracking-tight ${barberId === b.id ? 'text-amber-500' : 'text-slate-900 group-hover:text-amber-600'}`}>{b.name}</p>
|
||||
<p className={`font-black text-lg uppercase italic tracking-tight ${barberId === b.id ? 'text-indigo-500' : 'text-slate-900 group-hover:text-indigo-600'}`}>{b.name}</p>
|
||||
<p className={`text-[10px] font-black uppercase tracking-[0.2em] ${barberId === b.id ? 'text-slate-400' : 'text-slate-400'}`}>
|
||||
{b.specialties[0] || 'Elite Barber'}
|
||||
{b.specialties[0] || 'Barbeiro'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
@@ -243,11 +243,11 @@ export default function Booking() {
|
||||
{step === 3 && (
|
||||
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="space-y-2 text-center md:text-left">
|
||||
<h3 className="text-3xl font-black text-slate-900 tracking-tighter uppercase italic">3. Defina o <span className="text-amber-600">Momento</span></h3>
|
||||
<h3 className="text-3xl font-black text-slate-900 tracking-tighter uppercase italic">3. Escolha o <span className="text-indigo-600">Horário</span></h3>
|
||||
<p className="text-slate-500 font-medium italic">Seu tempo é valioso. Escolha a data perfeita.</p>
|
||||
</div>
|
||||
<div className="max-w-md mx-auto relative">
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 text-amber-600 pointer-events-none z-10">
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 text-indigo-600 pointer-events-none z-10">
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
<Input
|
||||
@@ -258,7 +258,7 @@ export default function Booking() {
|
||||
if (e.target.value) setStep(4);
|
||||
}}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
className="h-16 pl-14 pr-6 bg-slate-50 border-none rounded-2xl text-lg font-black uppercase tracking-widest text-slate-900 focus:ring-2 focus:ring-amber-500/20 transition-all shadow-inner"
|
||||
className="h-16 pl-14 pr-6 bg-slate-50 border-none rounded-2xl text-lg font-black uppercase tracking-widest text-slate-900 focus:ring-2 focus:ring-indigo-500/20 transition-all shadow-inner"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -267,7 +267,7 @@ export default function Booking() {
|
||||
{step === 4 && (
|
||||
<div className="space-y-10 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="space-y-2 text-center md:text-left">
|
||||
<h3 className="text-3xl font-black text-slate-900 tracking-tighter uppercase italic">4. Escolha o <span className="text-amber-600">Horário</span></h3>
|
||||
<h3 className="text-3xl font-black text-slate-900 tracking-tighter uppercase italic">4. Escolha o <span className="text-indigo-600">Horário Privilegiado</span></h3>
|
||||
<p className="text-slate-500 font-medium italic">A pontualidade é a cortesia dos reis.</p>
|
||||
</div>
|
||||
|
||||
@@ -275,12 +275,12 @@ export default function Booking() {
|
||||
{/* Left Side: Summary Sidebar */}
|
||||
<div className="w-full md:w-80 space-y-4">
|
||||
<div className="p-6 bg-slate-900 text-white rounded-[2rem] space-y-6 shadow-xl relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-amber-500/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-indigo-500/5 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
|
||||
<div className="space-y-4 relative z-10">
|
||||
<div className="space-y-1 border-b border-white/10 pb-4">
|
||||
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Serviço</p>
|
||||
<p className="text-lg font-black uppercase italic tracking-tight">{selectedService?.name}</p>
|
||||
<p className="text-xl font-black text-amber-500 tracking-tighter">{currency(selectedService?.price || 0)}</p>
|
||||
<p className="text-xl font-black text-indigo-500 tracking-tighter">{currency(selectedService?.price || 0)}</p>
|
||||
</div>
|
||||
<div className="space-y-1 border-b border-white/10 pb-4">
|
||||
<p className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Mestre</p>
|
||||
@@ -306,8 +306,8 @@ export default function Booking() {
|
||||
onClick={() => setSlot(h)}
|
||||
className={`h-14 rounded-2xl border-2 text-sm font-black tracking-widest transition-all duration-300 ${
|
||||
slot === h
|
||||
? 'border-slate-900 bg-slate-900 text-amber-500 shadow-xl scale-105 z-10'
|
||||
: 'border-slate-50 bg-slate-50 text-slate-600 hover:border-amber-200 hover:bg-amber-50'
|
||||
? 'border-slate-900 bg-slate-900 text-indigo-400 shadow-xl scale-105 z-10'
|
||||
: 'border-slate-50 bg-slate-50 text-slate-600 hover:border-indigo-200 hover:bg-indigo-50'
|
||||
}`}
|
||||
>
|
||||
{h}
|
||||
@@ -325,9 +325,9 @@ export default function Booking() {
|
||||
<Button
|
||||
onClick={submit}
|
||||
size="lg"
|
||||
className="w-full h-16 bg-slate-900 hover:bg-slate-800 text-amber-500 font-black rounded-2xl shadow-2xl transition-all active:scale-95 uppercase tracking-[0.2em] text-sm italic"
|
||||
className="w-full h-16 bg-slate-900 hover:bg-slate-800 text-indigo-400 font-black rounded-2xl shadow-2xl transition-all active:scale-95 uppercase tracking-[0.2em] text-sm italic"
|
||||
>
|
||||
Confirmar Experiência de Elite
|
||||
Confirmar Agendamento
|
||||
</Button>
|
||||
<p className="text-center mt-4 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
Pagamento realizado após o serviço
|
||||
|
||||
@@ -367,8 +367,8 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${period === p
|
||||
? 'border-amber-500 bg-amber-50 text-amber-700 shadow-sm'
|
||||
: 'border-slate-200 text-slate-700 hover:border-amber-300 hover:bg-amber-50/50'
|
||||
? 'border-indigo-500 bg-indigo-50 text-indigo-700 shadow-sm'
|
||||
: 'border-slate-200 text-slate-700 hover:border-indigo-300 hover:bg-indigo-50/50'
|
||||
}`}
|
||||
>
|
||||
{p === 'mes' ? 'Mês' : p === 'hoje' ? 'Hoje' : p === 'semana' ? 'Semana' : 'Total'}
|
||||
@@ -550,13 +550,13 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
<h2 className="text-xl font-bold text-slate-900">Caixa de Pedidos</h2>
|
||||
<p className="text-sm text-slate-600">Aprove ou recuse os pedidos pendentes e conclua os serviços de hoje.</p>
|
||||
</div>
|
||||
<Badge color="amber" variant="soft">{pendingAppts} Novos Pedidos</Badge>
|
||||
<Badge color="indigo" variant="soft">{pendingAppts} Novos Pedidos</Badge>
|
||||
</div>
|
||||
|
||||
{/* Secção de Pedidos Pendentes */}
|
||||
<Card className="p-6 border-amber-200">
|
||||
<Card className="p-6 border-indigo-200">
|
||||
<div className="flex items-center gap-2 mb-4 pb-4 border-b border-slate-100">
|
||||
<Clock className="text-amber-500" size={20} />
|
||||
<Clock className="text-indigo-500" size={20} />
|
||||
<h3 className="font-bold text-slate-900">Aguardam Aprovação</h3>
|
||||
<Badge color="slate" variant="soft" className="ml-auto">{pendingAppts}</Badge>
|
||||
</div>
|
||||
@@ -569,7 +569,7 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
const aptDate = new Date(a.date.replace(' ', 'T'));
|
||||
|
||||
return (
|
||||
<div key={a.id} className="flex flex-col sm:flex-row sm:items-center justify-between p-4 bg-amber-50/50 border border-amber-100 rounded-lg gap-4">
|
||||
<div key={a.id} className="flex flex-col sm:flex-row sm:items-center justify-between p-4 bg-indigo-50/50 border border-indigo-100 rounded-lg gap-4">
|
||||
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Cliente</p>
|
||||
@@ -582,7 +582,7 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-1">Data / Hora</p>
|
||||
<p className="font-semibold text-amber-700">
|
||||
<p className="font-semibold text-indigo-700">
|
||||
{aptDate.toLocaleDateString('pt-PT', { day: 'numeric', month: 'short' })} às {aptDate.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -596,7 +596,7 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
Recusar
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-amber-500 hover:bg-amber-600 text-white"
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white"
|
||||
onClick={() => updateAppointmentStatus(a.id, 'confirmado')}
|
||||
>
|
||||
Aceitar
|
||||
@@ -744,16 +744,16 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
return sum + (prod?.price ?? 0) * item.qty;
|
||||
}, 0);
|
||||
return (
|
||||
<div key={o.id} className="p-4 border border-slate-200 rounded-lg hover:border-amber-300 transition-colors">
|
||||
<div key={o.id} className="p-4 border border-slate-200 rounded-lg hover:border-indigo-300 transition-colors">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-bold text-amber-700">{currency(productTotal)}</p>
|
||||
<Badge color={o.status === 'pendente' ? 'amber' : o.status === 'confirmado' ? 'green' : o.status === 'concluido' ? 'green' : 'red'}>
|
||||
<p className="font-bold text-indigo-700">{currency(productTotal)}</p>
|
||||
<Badge color={o.status === 'pendente' ? 'indigo' : o.status === 'confirmado' ? 'green' : o.status === 'concluido' ? 'green' : 'red'}>
|
||||
{o.status === 'pendente' ? 'Pendente' : o.status === 'confirmado' ? 'Confirmado' : o.status === 'concluido' ? 'Concluído' : 'Cancelado'}
|
||||
</Badge>
|
||||
</div>
|
||||
<select
|
||||
className="text-sm border border-slate-300 rounded-lg px-3 py-2 focus:border-amber-500 focus:ring-2 focus:ring-amber-500/30"
|
||||
className="text-sm border border-slate-300 rounded-lg px-3 py-2 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30"
|
||||
value={o.status}
|
||||
onChange={(e) => updateOrderStatus(o.id, e.target.value as any)}
|
||||
>
|
||||
@@ -771,7 +771,7 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
return (
|
||||
<div key={item.refId} className="flex items-center justify-between text-sm bg-slate-50 rounded px-2 py-1">
|
||||
<span className="text-slate-700">{prod?.name ?? 'Produto'} x{item.qty}</span>
|
||||
<span className="text-amber-600 font-semibold">{currency((prod?.price ?? 0) * item.qty)}</span>
|
||||
<span className="text-indigo-600 font-semibold">{currency((prod?.price ?? 0) * item.qty)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -805,7 +805,7 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
<p className="text-sm text-slate-600">Duração: {s.duration} min</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-lg font-bold text-amber-600">{currency(s.price)}</span>
|
||||
<span className="text-lg font-bold text-indigo-600">{currency(s.price)}</span>
|
||||
<Button variant="danger" size="sm" onClick={() => deleteService(shop.id, s.id)}>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
@@ -839,8 +839,8 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
<Badge color="slate" variant="soft">{shop.products.length} produtos</Badge>
|
||||
</div>
|
||||
{lowStock.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="text-sm font-semibold text-amber-800 flex items-center gap-2">
|
||||
<div className="mb-4 p-3 bg-indigo-50 border border-indigo-200 rounded-lg">
|
||||
<p className="text-sm font-semibold text-indigo-800 flex items-center gap-2">
|
||||
<AlertTriangle size={16} />
|
||||
Atenção: {lowStock.length} {lowStock.length === 1 ? 'produto com stock baixo' : 'produtos com stock baixo'}
|
||||
</p>
|
||||
@@ -850,18 +850,18 @@ function DashboardInner({ shop }: { shop: BarberShop }) {
|
||||
{shop.products.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`flex items-center justify-between p-4 border rounded-lg ${p.stock <= 3 ? 'border-amber-300 bg-amber-50' : 'border-slate-200'
|
||||
className={`flex items-center justify-between p-4 border rounded-lg ${p.stock <= 3 ? 'border-indigo-300 bg-indigo-50' : 'border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-bold text-slate-900">{p.name}</p>
|
||||
{p.stock <= 3 && <Badge color="amber" variant="solid">Stock baixo</Badge>}
|
||||
{p.stock <= 3 && <Badge color="indigo" variant="solid">Stock baixo</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">Stock: {p.stock} unidades</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-lg font-bold text-amber-600">{currency(p.price)}</span>
|
||||
<span className="text-lg font-bold text-indigo-600">{currency(p.price)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => updateProductStock(p, -1)}>
|
||||
<Minus size={14} />
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function EventsCreate() {
|
||||
<div className="max-w-xl mx-auto py-8">
|
||||
<Card className="p-8 space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-gradient-to-br from-amber-500 to-amber-600 rounded-xl text-white shadow-lg">
|
||||
<div className="p-3 bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-xl text-white shadow-lg">
|
||||
<CalendarPlus size={22} />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -57,12 +57,12 @@ export default function Explore() {
|
||||
<section className="space-y-4 text-center md:text-left">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-amber-100 text-amber-700 text-[10px] font-black uppercase tracking-widest mb-2">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-indigo-100 text-indigo-700 text-[10px] font-black uppercase tracking-widest mb-2">
|
||||
<Star size={10} fill="currentColor" />
|
||||
<span>As melhores Barbearias</span>
|
||||
<span>As Nossas Barbearias</span>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-black text-slate-900 tracking-tighter">
|
||||
Explorar <span className="text-amber-600">Espaços</span>
|
||||
<h1 className="text-4xl md:text-5xl font-black text-slate-900 tracking-tighter uppercase italic">
|
||||
Ver <span className="text-indigo-600">Barbearias</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 font-medium max-w-md">Descubra barbearias exclusivas e reserve o seu próximo corte em segundos.</p>
|
||||
</div>
|
||||
@@ -91,14 +91,14 @@ export default function Explore() {
|
||||
<Chip
|
||||
active={filter === 'todas'}
|
||||
onClick={() => setFilter('todas')}
|
||||
className={`h-11 px-6 rounded-2xl font-bold uppercase tracking-tight transition-all ${filter === 'todas' ? '!bg-slate-900 !text-amber-500 border-none shadow-lg' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`}
|
||||
className={`h-11 px-6 rounded-2xl font-bold uppercase tracking-tight transition-all ${filter === 'todas' ? '!bg-slate-900 !text-indigo-400 border-none shadow-lg' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`}
|
||||
>
|
||||
Todas
|
||||
</Chip>
|
||||
<Chip
|
||||
active={filter === 'top'}
|
||||
onClick={() => setFilter('top')}
|
||||
className={`h-11 px-6 rounded-2xl font-bold uppercase tracking-tight transition-all ${filter === 'top' ? '!bg-slate-900 !text-amber-500 border-none shadow-lg' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`}
|
||||
className={`h-11 px-6 rounded-2xl font-bold uppercase tracking-tight transition-all ${filter === 'top' ? '!bg-slate-900 !text-indigo-400 border-none shadow-lg' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'}`}
|
||||
>
|
||||
Top Avaliadas
|
||||
</Chip>
|
||||
@@ -106,7 +106,7 @@ export default function Explore() {
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
|
||||
className="h-11 rounded-2xl border-none bg-slate-100 px-4 text-sm font-bold text-slate-700 focus:ring-2 focus:ring-amber-500/20"
|
||||
className="h-11 rounded-2xl border-none bg-slate-100 px-4 text-sm font-bold text-slate-700 focus:ring-2 focus:ring-indigo-500/20"
|
||||
>
|
||||
<option value="avaliacao">Melhor avaliação</option>
|
||||
<option value="servicos">Mais serviços</option>
|
||||
@@ -117,7 +117,7 @@ export default function Explore() {
|
||||
|
||||
{!useApp().shopsReady ? (
|
||||
<div className="py-24 text-center">
|
||||
<div className="inline-block w-12 h-12 border-4 border-slate-200 border-t-amber-600 rounded-full animate-spin mb-4" />
|
||||
<div className="inline-block w-12 h-12 border-4 border-slate-200 border-t-indigo-600 rounded-full animate-spin mb-4" />
|
||||
<p className="text-slate-500 font-bold uppercase tracking-widest text-xs">A carregar espaços...</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
@@ -129,7 +129,7 @@ export default function Explore() {
|
||||
<p className="text-2xl font-black text-slate-900 tracking-tight">Nenhuma barbearia encontrada</p>
|
||||
<p className="text-slate-500 font-medium">Tente ajustar o termo de pesquisa ou os filtros ativos.</p>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => {setQuery(''); setFilter('todas');}} className="font-bold text-amber-600 hover:text-amber-700">
|
||||
<Button variant="ghost" onClick={() => {setQuery(''); setFilter('todas');}} className="font-bold text-indigo-600 hover:text-indigo-700">
|
||||
Limpar Tudo
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useApp } from '../context/AppContext';
|
||||
import { mockShops } from '../data/mock';
|
||||
|
||||
export default function Landing() {
|
||||
const { user } = useApp();
|
||||
const { user, shops } = useApp();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -28,36 +28,36 @@ export default function Landing() {
|
||||
navigate(target, { replace: true });
|
||||
}, [user, navigate]);
|
||||
|
||||
const featuredShops = mockShops.slice(0, 3);
|
||||
const featuredShops = shops.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="space-y-24 md:space-y-32 pb-24">
|
||||
{/* Hero Section - Midnight Luxury Style */}
|
||||
<section className="relative overflow-hidden rounded-[3rem] obsidian-gradient text-white px-8 py-20 md:px-16 md:py-32 shadow-[0_20px_50px_rgba(0,0,0,0.3)] border border-white/5">
|
||||
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/carbon-fibre.png')] opacity-20 pointer-events-none"></div>
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-amber-500/10 rounded-full blur-[120px] -translate-y-1/2 translate-x-1/2"></div>
|
||||
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-indigo-500/10 rounded-full blur-[120px] -translate-y-1/2 translate-x-1/2"></div>
|
||||
<div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-slate-500/10 rounded-full blur-[120px] translate-y-1/2 -translate-x-1/2"></div>
|
||||
|
||||
<div className="relative z-10 space-y-10 max-w-5xl">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-white/5 backdrop-blur-md rounded-full text-[10px] font-black uppercase tracking-[0.3em] w-fit border border-white/10 animate-fade-in text-amber-500">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-white/5 backdrop-blur-md rounded-full text-[10px] font-black uppercase tracking-[0.3em] w-fit border border-white/10 animate-fade-in text-indigo-400">
|
||||
<Sparkles size={14} className="animate-pulse" />
|
||||
<span>O Novo Standard do Cuidado Masculino</span>
|
||||
<span>A Solução Completa para a Sua Barbearia</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-6xl md:text-8xl font-black leading-[0.9] tracking-tighter text-balance uppercase italic">
|
||||
Elegância em cada <br />
|
||||
<span className="gold-gradient bg-clip-text text-transparent italic">Agendamento</span>
|
||||
Gestão Simplificada da sua <br />
|
||||
<span className="indigo-gradient bg-clip-text text-transparent italic">Barbearia</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl md:text-2xl text-slate-300 max-w-2xl leading-relaxed font-medium">
|
||||
Transforme a rotina da sua barbearia com uma experiência digital digna de um cavalheiro.
|
||||
Mobile-first, premium e inteligente.
|
||||
Organize a sua barbearia com facilidade.
|
||||
Simples, rápido e eficiente.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-6 pt-6">
|
||||
<Button asChild size="lg" className="h-16 px-10 bg-white text-slate-950 hover:bg-amber-500 hover:text-white font-black uppercase tracking-widest text-xs transition-all duration-300 rounded-2xl shadow-2xl">
|
||||
<Button asChild size="lg" className="h-16 px-10 bg-white text-slate-950 hover:bg-indigo-600 hover:text-white font-black uppercase tracking-widest text-xs transition-all duration-300 rounded-2xl shadow-2xl">
|
||||
<Link to="/explorar" className="flex items-center gap-3">
|
||||
Explorar Espaços
|
||||
Ver Barbearias
|
||||
<ArrowRight size={18} />
|
||||
</Link>
|
||||
</Button>
|
||||
@@ -70,47 +70,30 @@ export default function Landing() {
|
||||
<div className="grid grid-cols-3 gap-10 pt-12 border-t border-white/10 max-w-2xl">
|
||||
<div className="space-y-1">
|
||||
<div className="text-4xl md:text-5xl font-black tracking-tighter italic">500+</div>
|
||||
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Espaços de Luxo</div>
|
||||
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Lojas Registadas</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-4xl md:text-5xl font-black tracking-tighter italic">10K+</div>
|
||||
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Cortes Marcados</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-4xl md:text-5xl font-black tracking-tighter gold-gradient bg-clip-text text-transparent italic">4.9</div>
|
||||
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Rating de Elite</div>
|
||||
<div className="text-4xl md:text-5xl font-black tracking-tighter indigo-gradient bg-clip-text text-transparent italic">4.9</div>
|
||||
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Média de Avaliação</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hero Image Mockup (Place with a generated-like look) */}
|
||||
<section className="relative -mt-32 px-6">
|
||||
<div className="max-w-6xl mx-auto rounded-[3rem] overflow-hidden shadow-[0_50px_100px_rgba(0,0,0,0.5)] border-4 border-white/10 glass-card">
|
||||
<div className="aspect-video bg-slate-900 flex items-center justify-center relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-950 flex items-center justify-center opacity-80" />
|
||||
<div className="relative z-10 text-center space-y-6">
|
||||
<Scissors size={80} className="text-amber-500 mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-black text-white uppercase italic tracking-tighter">Smart Agenda Elite Edition</h3>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<div className="w-12 h-1 bg-amber-500 rounded-full" />
|
||||
<div className="w-12 h-1 bg-white/20 rounded-full" />
|
||||
<div className="w-12 h-1 bg-white/20 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features - Minimalist & Bold */}
|
||||
<section className="max-w-7xl mx-auto px-6">
|
||||
<div className="text-center md:text-left mb-16 flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-5xl md:text-6xl font-black text-slate-900 tracking-tighter uppercase italic pr-8 border-l-[12px] border-amber-500 pl-8">
|
||||
Ecossistema <br /> <span className="text-amber-600">Completo</span>
|
||||
<h2 className="text-5xl md:text-6xl font-black text-slate-900 tracking-tighter uppercase italic pr-8 border-l-[12px] border-indigo-600 pl-8">
|
||||
Tudo para o <br /> <span className="text-indigo-700">Seu Negócio</span>
|
||||
</h2>
|
||||
<p className="text-xl text-slate-500 font-medium max-w-xl">
|
||||
Tudo o que a sua barbearia precisa para escalar com sofisticação.
|
||||
Tudo o que precisa para gerir e fazer crescer a sua barbearia.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -119,30 +102,27 @@ export default function Landing() {
|
||||
{[
|
||||
{
|
||||
icon: Clock,
|
||||
title: 'Gestão Cirúrgica',
|
||||
desc: 'Controle de horários com precisão absoluta. Slot management inteligente e automação de reserva.',
|
||||
color: 'bg-slate-950 shadow-[0_15px_35px_rgba(0,0,0,0.15)]'
|
||||
title: 'Agenda Prática',
|
||||
desc: 'Gerencie os horários dos seus barbeiros e evite conflitos de agenda de forma automática.',
|
||||
},
|
||||
{
|
||||
icon: ShoppingBag,
|
||||
title: 'Curadoria de Produtos',
|
||||
desc: 'Venda produtos de elite diretamente no ecossistema. Gestão de stock e carrinho omnicanal.',
|
||||
color: 'bg-white border-2 border-slate-50'
|
||||
title: 'Venda de Produtos',
|
||||
desc: 'Venda pomadas, óleos e outros produtos diretamente aos seus clientes através da plataforma.',
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: 'Analytics de Luxo',
|
||||
desc: 'Relatórios detalhados de faturamento, performance de barbeiros e taxas de retenção.',
|
||||
color: 'bg-white border-2 border-slate-50'
|
||||
title: 'Controle de Ganhos',
|
||||
desc: 'Saiba exatamente quanto a barbearia está faturando e quais os serviços mais procurados.',
|
||||
},
|
||||
].map((feature) => (
|
||||
<Card key={feature.title} className={`p-10 space-y-6 rounded-[2.5rem] transition-all duration-500 hover:-translate-y-2 group ${feature.color}`}>
|
||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center shadow-inner ${feature.icon === Clock ? 'bg-amber-500 text-slate-900' : 'bg-slate-900 text-amber-500'}`}>
|
||||
<Card key={feature.title} className="p-10 space-y-6 rounded-[2.5rem] transition-all duration-500 hover:-translate-y-2 group bg-white border-2 border-slate-50 shadow-sm hover:shadow-xl">
|
||||
<div className="w-16 h-16 rounded-2xl flex items-center justify-center bg-slate-900 text-indigo-400 group-hover:bg-indigo-600 group-hover:text-white transition-colors duration-300">
|
||||
<feature.icon size={32} />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<h3 className={`text-2xl font-black tracking-tight uppercase italic ${feature.icon === Clock ? 'text-white' : 'text-slate-900'}`}>{feature.title}</h3>
|
||||
<p className={`text-sm leading-relaxed font-medium ${feature.icon === Clock ? 'text-slate-400' : 'text-slate-500'}`}>{feature.desc}</p>
|
||||
<h3 className="text-2xl font-black tracking-tight uppercase italic text-slate-900">{feature.title}</h3>
|
||||
<p className="text-sm leading-relaxed font-medium text-slate-600">{feature.desc}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
@@ -155,23 +135,23 @@ export default function Landing() {
|
||||
<div className="max-w-6xl mx-auto px-6 relative z-10">
|
||||
<div className="text-center mb-20">
|
||||
<h2 className="text-5xl md:text-6xl font-black text-white tracking-tighter uppercase italic mb-6">
|
||||
A Jornada do <span className="gold-gradient bg-clip-text text-transparent">Cavalheiro</span>
|
||||
Como <span className="indigo-gradient bg-clip-text text-transparent">Funciona</span>
|
||||
</h2>
|
||||
<div className="w-24 h-1 bg-amber-500 mx-auto rounded-full" />
|
||||
<div className="w-24 h-1 bg-indigo-600 mx-auto rounded-full" />
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-16 relative">
|
||||
{[
|
||||
{ step: '01', title: 'Descobrir', desc: 'Encontre os espaços mais exclusivos da cidade com avaliações reais.' },
|
||||
{ step: '02', title: 'Personalizar', desc: 'Escolha o seu barbeiro de confiança e o seu horário preferido.' },
|
||||
{ step: '03', title: 'Vivenciar', desc: 'Receba o tratamento de elite que você merece, sem esperas.' },
|
||||
{ step: '01', title: 'Explorar', desc: 'Encontre as barbearias mais próximas e veja as fotos e avaliações reais.' },
|
||||
{ step: '02', title: 'Agendar', desc: 'Escolha o seu barbeiro e o serviço. Marque o dia e hora que preferir.' },
|
||||
{ step: '03', title: 'Cortar', desc: 'Apareça na barbearia à hora marcada e desfrute do serviço. Sem esperas.' },
|
||||
].map((item, idx) => (
|
||||
<div key={item.step} className="text-center space-y-8 relative group">
|
||||
<div className="relative">
|
||||
<div className="text-8xl font-black text-white/5 absolute -top-12 left-1/2 -translate-x-1/2 select-none group-hover:text-amber-500/10 transition-colors duration-700">
|
||||
<div className="text-8xl font-black text-white/5 absolute -top-12 left-1/2 -translate-x-1/2 select-none group-hover:text-indigo-500/10 transition-colors duration-700">
|
||||
{item.step}
|
||||
</div>
|
||||
<div className="w-20 h-20 mx-auto rounded-full obsidian-gradient border-2 border-white/10 flex items-center justify-center text-amber-500 text-2xl font-black italic shadow-[0_0_30px_rgba(245,158,11,0.2)]">
|
||||
<div className="w-20 h-20 mx-auto rounded-full obsidian-gradient border-2 border-white/10 flex items-center justify-center text-indigo-400 text-2xl font-black italic shadow-[0_0_30px_rgba(79,70,229,0.2)]">
|
||||
{item.step}
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,9 +169,9 @@ export default function Landing() {
|
||||
<section className="max-w-7xl mx-auto px-6">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between mb-16 gap-6">
|
||||
<div className="space-y-2 text-center md:text-left">
|
||||
<h2 className="text-4xl md:text-5xl font-black text-slate-900 tracking-tighter uppercase italic">
|
||||
Clubes <span className="text-amber-600">Membros</span>
|
||||
</h2>
|
||||
<h1 className="text-4xl md:text-5xl font-black text-slate-900 tracking-tighter uppercase italic">
|
||||
Descobrir <span className="text-indigo-600">Barbearias</span>
|
||||
</h1>
|
||||
<p className="text-slate-500 font-bold uppercase tracking-[0.2em] text-xs">As melhores barbearias do país</p>
|
||||
</div>
|
||||
<Button asChild variant="ghost" className="h-14 px-8 rounded-2xl bg-slate-50 border border-slate-100 font-black text-slate-900 uppercase tracking-widest text-xs hover:bg-slate-100 transition-all">
|
||||
@@ -212,21 +192,21 @@ export default function Landing() {
|
||||
{/* Final CTA - Immersive Dark */}
|
||||
<section className="px-6">
|
||||
<div className="max-w-6xl mx-auto relative overflow-hidden rounded-[4rem] obsidian-gradient text-white px-8 py-20 md:px-20 md:py-24 shadow-2xl border border-white/5">
|
||||
<div className="absolute top-0 right-0 w-[400px] h-[400px] bg-amber-500/10 rounded-full blur-[100px]" />
|
||||
<div className="absolute top-0 right-0 w-[400px] h-[400px] bg-indigo-500/10 rounded-full blur-[100px]" />
|
||||
<div className="absolute bottom-0 left-0 w-[400px] h-[400px] bg-slate-500/10 rounded-full blur-[100px]" />
|
||||
|
||||
<div className="relative text-center space-y-12 max-w-3xl mx-auto">
|
||||
<h2 className="text-5xl md:text-7xl font-black tracking-tighter uppercase italic leading-[0.9]">
|
||||
Faça Parte <br /> do <span className="gold-gradient bg-clip-text text-transparent">Legado</span>
|
||||
Registe a <br /> sua <span className="indigo-gradient bg-clip-text text-transparent">Barbearia</span>
|
||||
</h2>
|
||||
<p className="text-xl text-slate-400 font-medium leading-relaxed">
|
||||
Centenas de profissionais já elevaram o seu negócio ao próximo nível.
|
||||
A sua barbearia merece o melhor.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-6 pt-4">
|
||||
<Button asChild size="lg" className="h-16 px-10 bg-white text-slate-950 hover:bg-amber-500 hover:text-white font-black uppercase tracking-widest text-xs rounded-2xl transition-all shadow-2xl">
|
||||
<Button asChild size="lg" className="h-16 px-10 bg-white text-slate-950 hover:bg-indigo-600 hover:text-white font-black uppercase tracking-widest text-xs rounded-2xl transition-all shadow-2xl">
|
||||
<Link to="/registo" className="flex items-center gap-3">
|
||||
Criar Conta Grátis
|
||||
Criar Conta
|
||||
<ArrowRight size={18} />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -14,8 +14,8 @@ import { Calendar, ShoppingBag, User, Clock, Heart, Star, MapPin, CheckCircle2 }
|
||||
import { supabase } from '../lib/supabase'
|
||||
import { ReviewModal } from '../components/ReviewModal'
|
||||
|
||||
const statusColor: Record<string, 'amber' | 'green' | 'slate' | 'red'> = {
|
||||
pendente: 'amber',
|
||||
const statusColor: Record<string, 'indigo' | 'green' | 'slate' | 'red'> = {
|
||||
pendente: 'indigo',
|
||||
confirmado: 'green',
|
||||
concluido: 'green',
|
||||
cancelado: 'red',
|
||||
@@ -90,7 +90,7 @@ export default function Profile() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="w-10 h-10 border-4 border-slate-200 border-t-amber-500 rounded-full animate-spin mx-auto mb-3" />
|
||||
<div className="w-10 h-10 border-4 border-slate-200 border-t-indigo-500 rounded-full animate-spin mx-auto mb-3" />
|
||||
<p className="text-slate-500 text-sm">A carregar perfil...</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,15 +100,15 @@ export default function Profile() {
|
||||
if (!authId) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-16 h-16 bg-amber-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<User size={28} className="text-amber-600" />
|
||||
<div className="w-16 h-16 bg-indigo-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
||||
<User size={28} className="text-indigo-600" />
|
||||
</div>
|
||||
<p className="text-slate-700 font-semibold mb-1">Sessão não encontrada</p>
|
||||
<p className="text-slate-500 text-sm mb-4">Faz login para ver o teu perfil.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/login', { replace: true })}
|
||||
className="px-5 py-2 bg-amber-500 text-white text-sm font-semibold rounded-xl hover:bg-amber-600 transition-colors"
|
||||
className="px-5 py-2 bg-indigo-600 text-white text-sm font-semibold rounded-xl hover:bg-slate-900 transition-colors"
|
||||
>
|
||||
Ir para login
|
||||
</button>
|
||||
@@ -131,20 +131,20 @@ export default function Profile() {
|
||||
)}
|
||||
|
||||
<div className="max-w-4xl mx-auto space-y-12 pb-20">
|
||||
{/* Profile Header - Luxury Style */}
|
||||
{/* Cabeçalho do Perfil */}
|
||||
<section className="relative overflow-hidden rounded-[3rem] obsidian-gradient text-white p-8 md:p-12 shadow-2xl border border-white/5">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-amber-500/10 rounded-full blur-[80px] -translate-y-1/2 translate-x-1/2" />
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-indigo-500/10 rounded-full blur-[80px] -translate-y-1/2 translate-x-1/2" />
|
||||
<div className="relative z-10 flex flex-col md:flex-row items-center gap-8 md:text-left text-center">
|
||||
<div className="relative group">
|
||||
<div className="absolute inset-0 bg-amber-500 blur-2xl opacity-20 group-hover:opacity-40 transition-opacity" />
|
||||
<div className="w-24 h-24 bg-white/10 backdrop-blur-xl border-2 border-white/20 rounded-[2rem] flex items-center justify-center text-amber-500 shadow-2xl relative z-10 transition-transform duration-500 hover:rotate-6">
|
||||
<div className="absolute inset-0 bg-indigo-500 blur-2xl opacity-20 group-hover:opacity-40 transition-opacity" />
|
||||
<div className="w-24 h-24 bg-white/10 backdrop-blur-xl border-2 border-white/20 rounded-[2rem] flex items-center justify-center text-indigo-400 shadow-2xl relative z-10 transition-transform duration-500 hover:rotate-6">
|
||||
<User size={48} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 bg-white/5 border border-white/10 rounded-full text-[10px] font-black uppercase tracking-[0.2em] text-amber-500">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1 bg-white/5 border border-white/10 rounded-full text-[10px] font-black uppercase tracking-[0.2em] text-indigo-400">
|
||||
<Star size={12} fill="currentColor" />
|
||||
<span>Membro de Elite</span>
|
||||
<span>Utilizador Registado</span>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-black tracking-tighter uppercase italic leading-[0.9]">
|
||||
{displayName}
|
||||
@@ -169,7 +169,7 @@ export default function Profile() {
|
||||
<Link key={shop.id} to={`/barbearia/${shop.id}`}>
|
||||
<Card className="p-2 border-none glass-card rounded-[2rem] shadow-lg shadow-slate-200/50 hover:-translate-y-1 transition-all duration-300 group">
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
<div className="w-16 h-16 rounded-2xl overflow-hidden border-2 border-slate-50 shadow-inner group-hover:border-amber-200 transition-colors">
|
||||
<div className="w-16 h-16 rounded-2xl overflow-hidden border-2 border-slate-50 shadow-inner group-hover:border-indigo-200 transition-colors">
|
||||
{shop.imageUrl ? (
|
||||
<img src={shop.imageUrl} alt={shop.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
@@ -179,10 +179,10 @@ export default function Profile() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-black text-slate-900 uppercase italic tracking-tight group-hover:text-amber-600 transition-colors truncate">{shop.name}</p>
|
||||
<p className="font-black text-slate-900 uppercase italic tracking-tight group-hover:text-indigo-600 transition-colors truncate">{shop.name}</p>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<div className="flex items-center gap-1 text-[10px] font-black text-amber-600 uppercase tracking-widest">
|
||||
<Star size={10} className="fill-amber-500" />
|
||||
<div className="flex items-center gap-1 text-[10px] font-black text-indigo-600 uppercase tracking-widest">
|
||||
<Star size={10} className="fill-indigo-500" />
|
||||
{shop.rating.toFixed(1)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-[10px] font-black text-slate-400 uppercase tracking-widest truncate">
|
||||
@@ -204,7 +204,7 @@ export default function Profile() {
|
||||
<section className="lg:col-span-3 space-y-6">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex items-center gap-2 text-slate-900">
|
||||
<Calendar size={16} className="text-amber-600" />
|
||||
<Calendar size={16} className="text-indigo-600" />
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em]">Minha Agenda</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -214,7 +214,7 @@ export default function Profile() {
|
||||
<Calendar size={64} className="mx-auto text-slate-100 mb-6" />
|
||||
<h3 className="text-xl font-black text-slate-900 uppercase italic tracking-tight">Sem Reservas</h3>
|
||||
<p className="text-slate-400 font-medium italic mt-2">Sua jornada de estilo ainda não começou.</p>
|
||||
<Button asChild className="mt-8 h-12 px-8 bg-slate-900 text-amber-500 font-black rounded-2xl uppercase tracking-widest text-[10px] italic">
|
||||
<Button asChild className="mt-8 h-12 px-8 bg-slate-900 text-indigo-400 font-black rounded-2xl uppercase tracking-widest text-[10px] italic">
|
||||
<Link to="/explorar">Agendar Agora</Link>
|
||||
</Button>
|
||||
</Card>
|
||||
@@ -246,7 +246,7 @@ export default function Profile() {
|
||||
<div className="flex items-center justify-between pt-4 border-t border-slate-50">
|
||||
{service && (
|
||||
<div className="flex items-center gap-2 text-[10px] font-black text-slate-500 uppercase tracking-widest">
|
||||
<Clock size={12} className="text-amber-600" />
|
||||
<Clock size={12} className="text-indigo-600" />
|
||||
{service.name} · {service.duration} MIN
|
||||
</div>
|
||||
)}
|
||||
@@ -254,10 +254,10 @@ export default function Profile() {
|
||||
{canReview && (
|
||||
<button
|
||||
onClick={() => setReviewTarget({ appointmentId: a.id, shopId: a.shopId, shopName: shop?.name ?? 'Barbearia' })}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-amber-500 hover:bg-slate-900 text-white hover:text-amber-500 rounded-xl transition-all duration-300 transform active:scale-95 shadow-lg shadow-amber-500/20"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-slate-900 text-white hover:text-indigo-400 rounded-xl transition-all duration-300 transform active:scale-95 shadow-lg shadow-indigo-500/20"
|
||||
>
|
||||
<Star size={12} className="fill-current" />
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">Avaliar Experiência</span>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest">Avaliar Atendimento</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -280,7 +280,7 @@ export default function Profile() {
|
||||
<section className="lg:col-span-2 space-y-6">
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex items-center gap-2 text-slate-900">
|
||||
<ShoppingBag size={16} className="text-amber-600" />
|
||||
<ShoppingBag size={16} className="text-indigo-600" />
|
||||
<h2 className="text-sm font-black uppercase tracking-[0.3em]">Pedidos</h2>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,7 +298,7 @@ export default function Profile() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-black text-slate-900 uppercase italic tracking-tight truncate max-w-[120px]">{shop?.name}</h3>
|
||||
<div className="text-lg font-black text-amber-600 tracking-tighter">{currency(o.total)}</div>
|
||||
<div className="text-lg font-black text-indigo-600 tracking-tighter">{currency(o.total)}</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-50">
|
||||
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">
|
||||
|
||||
@@ -77,12 +77,12 @@ export default function ShopDetails() {
|
||||
|
||||
<div className="absolute bottom-10 left-10 space-y-3">
|
||||
<div className="flex items-center gap-2 bg-slate-900/40 backdrop-blur-md border border-white/20 w-fit px-3 py-1 rounded-full">
|
||||
<Star size={14} className="fill-amber-500 text-amber-500" />
|
||||
<Star size={14} className="fill-indigo-500 text-indigo-500" />
|
||||
<span className="text-white text-xs font-black tracking-widest">{(shop.rating || 0).toFixed(1)} EXCELENTE</span>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-black text-white tracking-tighter">{shop.name}</h1>
|
||||
<div className="flex items-center gap-2 text-white/90">
|
||||
<MapPin size={16} className="text-amber-500" />
|
||||
<MapPin size={16} className="text-indigo-600" />
|
||||
<p className="text-base font-medium">{shop.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user