docs: Add JSDoc and inline comments to enhance code readability and explain functionality across multiple pages and components.
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
/**
|
||||
* @file AppContext.tsx
|
||||
* @description Contexto global principal ("State Manager") da aplicação.
|
||||
* Efetua a ligação direta e centralizada com a base de dados Supabase
|
||||
* lidando com Auth, Consultas (Shops/Services) e CRUD (Criar/Ler/Atualizar/Apagar).
|
||||
*/
|
||||
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { Appointment, Barber, BarberShop, CartItem, Order, Product, Service, User } from '../types';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
// Tipo interno determinando as propriedades globais partilhadas (Estados e Funções)
|
||||
type State = {
|
||||
user?: User;
|
||||
shops: BarberShop[];
|
||||
@@ -26,9 +33,14 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [user, setUser] = useState<User | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 🔹 Carregar utilizador autenticado
|
||||
/**
|
||||
* Hook executado no carregamento (mount) inicial.
|
||||
* Valida através de `supabase.auth.getUser()` se existe um token de sessão válido
|
||||
* (identificando o utilizador sem necessidade de o cliente refazer login ativamente).
|
||||
*/
|
||||
useEffect(() => {
|
||||
const loadUser = async () => {
|
||||
// Pedido restrito à API de autenticação do Supabase
|
||||
const { data } = await supabase.auth.getUser();
|
||||
if (data.user) {
|
||||
setUser({
|
||||
@@ -41,10 +53,16 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
loadUser();
|
||||
}, []);
|
||||
|
||||
// 🔹 Buscar shops + services
|
||||
/**
|
||||
* Consulta mestra (Query) - Refresca todo o ecossistema de dados das barbearias.
|
||||
* Faz 2 queries (`supabase.from('shops').select('*')` e `services`) e depois
|
||||
* executa um JOIN manual via Javascript para injetar os serviços dentro
|
||||
* dos objetos da barbearia respetiva.
|
||||
*/
|
||||
const refreshShops = async () => {
|
||||
console.log("A buscar shops...");
|
||||
|
||||
// Query 1: Obtém a listagem completa (tabela 'shops')
|
||||
const { data: shopsData, error: shopsError } = await supabase
|
||||
.from('shops')
|
||||
.select('*');
|
||||
@@ -54,6 +72,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Query 2: Obtém a listagem associada globalmente (tabela 'services')
|
||||
const { data: servicesData, error: servicesError } = await supabase
|
||||
.from('services')
|
||||
.select('*');
|
||||
@@ -63,9 +82,10 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Associar serviços às respetivas shops
|
||||
// Associar serviços às respetivas shops, simulando um INNER JOIN nativo do SQL
|
||||
const shopsWithServices = shopsData.map((shop) => ({
|
||||
...shop,
|
||||
// Relaciona a 'foreign key' (shop_id) com o resgistro primário (shop.id)
|
||||
services: servicesData.filter((s) => s.shop_id === shop.id),
|
||||
products: [],
|
||||
barbers: [],
|
||||
@@ -76,6 +96,11 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
setShops(shopsWithServices);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook de Inicialização Master.
|
||||
* Aciona a função de preenchimento do Contexto assincronamente e liberta
|
||||
* a interface UI da view de Loading (`setLoading(false)`).
|
||||
*/
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
await refreshShops();
|
||||
@@ -84,11 +109,16 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
init();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Encerra a sessão JWT ativa com o Supabase Auth.
|
||||
* Limpa integralmente a interface do User local (estado React vazio).
|
||||
*/
|
||||
const logout = async () => {
|
||||
await supabase.auth.signOut();
|
||||
setUser(undefined);
|
||||
};
|
||||
|
||||
// Funções elementares do fluxo transacional não persistido (Estado do Carrinho transitório/local)
|
||||
const addToCart = (item: CartItem) => {
|
||||
setCart((prev) => [...prev, item]);
|
||||
};
|
||||
@@ -97,7 +127,13 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
// 🔹 CRUD SERVICES (SUPABASE REAL)
|
||||
|
||||
/**
|
||||
* Executa um INSERT na BD (via API REST gerada) protegendo interações com a tabela estrita 'services'.
|
||||
* @param {string} shopId - A foreign key relacionando o estabelecimento.
|
||||
* @param {Omit<Service, 'id'>} service - O DTO (Data Transfer Object) sem a primary key autonumerável.
|
||||
*/
|
||||
const addService = async (shopId: string, service: Omit<Service, 'id'>) => {
|
||||
// Insere os campos exatos formatados estritamente na query Supabase
|
||||
const { error } = await supabase.from('services').insert([
|
||||
{
|
||||
shop_id: shopId,
|
||||
@@ -112,9 +148,13 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Para manter integridade reativa pura, força refetch dos dados pós-mutação
|
||||
await refreshShops();
|
||||
};
|
||||
|
||||
/**
|
||||
* Executa um UPDATE num tuplo específico filtrando analiticamente pela primary key `(eq('id', service.id))`.
|
||||
*/
|
||||
const updateService = async (shopId: string, service: Service) => {
|
||||
const { error } = await supabase
|
||||
.from('services')
|
||||
@@ -123,7 +163,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
price: service.price,
|
||||
duration: service.duration,
|
||||
})
|
||||
.eq('id', service.id);
|
||||
.eq('id', service.id); // Identificador vital do update
|
||||
|
||||
if (error) {
|
||||
console.error("Erro ao atualizar serviço:", error);
|
||||
@@ -133,6 +173,9 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
await refreshShops();
|
||||
};
|
||||
|
||||
/**
|
||||
* Executa uma instrução SQL DELETE remota rígida, baseada no ID unívoco do tuplo.
|
||||
*/
|
||||
const deleteService = async (shopId: string, serviceId: string) => {
|
||||
const { error } = await supabase
|
||||
.from('services')
|
||||
@@ -147,6 +190,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
await refreshShops();
|
||||
};
|
||||
|
||||
// Empacotamento em objeto estabilizado memoizado face renderizações espúrias (React Context Pattern)
|
||||
const value: AppContextValue = useMemo(
|
||||
() => ({
|
||||
user,
|
||||
@@ -163,11 +207,13 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
[user, shops, cart]
|
||||
);
|
||||
|
||||
// Loading Shield evita quebra generalizada se o app renderizar sem BD disponível
|
||||
if (loading) return null;
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
};
|
||||
|
||||
// Hook prático de acesso central sem import múltiplo do 'useContext' em toda aplicação
|
||||
export const useApp = () => {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) throw new Error('useApp deve ser usado dentro de AppProvider');
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @file AuthLogin.tsx
|
||||
* @description Página de autenticação da aplicação. Permite o login de utilizadores
|
||||
* (clientes ou barbearias) utilizando o contexto global que faz a interface com a base de dados (Supabase).
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Alert } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -13,30 +18,42 @@ export default function AuthLogin() {
|
||||
const [password, setPassword] = useState('123');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
/**
|
||||
* Lida com a submissão do formulário de login.
|
||||
* Valida as credenciais do utilizador através do contexto (AppContext).
|
||||
* O contexto encapsula a chamada ao Supabase (ex: `supabase.auth.signInWithPassword`) e devolve um objeto tipado do utilizador.
|
||||
* Dependendo do 'role' retornado, reencaminha para o Dashboard (barbearia) ou Explore (cliente).
|
||||
*/
|
||||
const handleSubmit = () => {
|
||||
setError('');
|
||||
// O retorno 'user' respeita a nossa tipagem/interfaces baseadas nas tabelas da base de dados.
|
||||
const user = login(email, password);
|
||||
if (!user) {
|
||||
setError('Credenciais inválidas');
|
||||
Alert.alert('Erro', 'Credenciais inválidas');
|
||||
} else {
|
||||
// Redirecionamento condicional com base no papel (identificando a entidade via BD).
|
||||
const target = user.role === 'barbearia' ? 'Dashboard' : 'Explore';
|
||||
navigation.navigate(target as never);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// Componente estrutural que permite "scroll" vertical do conteúdo na vista
|
||||
<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>
|
||||
|
||||
{/* Bloco temporário para dados demo */}
|
||||
<View style={styles.demoBox}>
|
||||
<Text style={styles.demoTitle}>💡 Conta demo:</Text>
|
||||
<Text style={styles.demoText}>Cliente: cliente@demo.com / 123</Text>
|
||||
<Text style={styles.demoText}>Barbearia: barber@demo.com / 123</Text>
|
||||
</View>
|
||||
|
||||
{/* Input controlado pelo estadoReact "email"; atualiza à medida que se digita */}
|
||||
<Input
|
||||
label="Email"
|
||||
value={email}
|
||||
@@ -49,6 +66,7 @@ export default function AuthLogin() {
|
||||
placeholder="seu@email.com"
|
||||
/>
|
||||
|
||||
{/* Input controlado para a password; secureTextEntry oculta os caracteres */}
|
||||
<Input
|
||||
label="Senha"
|
||||
value={password}
|
||||
@@ -61,10 +79,12 @@ export default function AuthLogin() {
|
||||
error={error}
|
||||
/>
|
||||
|
||||
{/* Botão de ação que dispara a função handleSubmit para processar as credenciais */}
|
||||
<Button onPress={handleSubmit} style={styles.submitButton} size="lg">
|
||||
Entrar
|
||||
</Button>
|
||||
|
||||
{/* Estrutura de rodapé para navegação alternativa (redirecionar para o ecrã de registo) */}
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Não tem conta? </Text>
|
||||
<Text
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @file AuthRegister.tsx
|
||||
* @description Página de registo da aplicação. Permite criar novas contas para clientes
|
||||
* ou barbearias comunicando com o contexto de autenticação global e a base de dados.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Alert, TouchableOpacity } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -9,31 +14,43 @@ import { Card } from '../components/ui/Card';
|
||||
export default function AuthRegister() {
|
||||
const navigation = useNavigation();
|
||||
const { register } = useApp();
|
||||
// Estados locais para controlar os campos de introdução de dados
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
// O tipo de utilizador é estritamente tipado (cliente ou barbearia) para garantir a integridade dos dados
|
||||
const [role, setRole] = useState<'cliente' | 'barbearia'>('cliente');
|
||||
const [shopName, setShopName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
/**
|
||||
* Valida e submete o formulário de registo.
|
||||
* Chama a função `register` do AppContext, que por sua vez utiliza a lógica de base de dados
|
||||
* (ex: `supabase.auth.signUp`) e insere o novo registo (ex: `supabase.from('profiles').insert(...)`).
|
||||
*/
|
||||
const handleSubmit = () => {
|
||||
setError('');
|
||||
// Criamos o objeto de registo de acordo com os tipos/interfaces da base de dados e API
|
||||
const user = register({ name, email, password, role, shopName });
|
||||
if (!user) {
|
||||
setError('Email já registado');
|
||||
Alert.alert('Erro', 'Email já registado');
|
||||
} else {
|
||||
// Redireciona para um fluxo distinto consoante o tipo ('role') do novo utilizador criado
|
||||
const target = user.role === 'barbearia' ? 'Dashboard' : 'Explore';
|
||||
navigation.navigate(target as never);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// Componente estrutural que assegura scroll de conteúdo para ecrãs menores
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
{/* O Card agrupa a interface de registo */}
|
||||
<Card style={styles.card}>
|
||||
<Text style={styles.title}>Criar conta</Text>
|
||||
<Text style={styles.subtitle}>Escolha o tipo de acesso</Text>
|
||||
|
||||
{/* Grupo de botões de seleção de papel. O CSS ativo altera com base no estado `role`. */}
|
||||
<View style={styles.roleContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.roleButton, role === 'cliente' && styles.roleButtonActive]}
|
||||
@@ -53,6 +70,7 @@ export default function AuthRegister() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Input simples para o nome, que atualiza a variável React de estado "name" */}
|
||||
<Input
|
||||
label="Nome completo"
|
||||
value={name}
|
||||
@@ -60,6 +78,7 @@ export default function AuthRegister() {
|
||||
placeholder="João Silva"
|
||||
/>
|
||||
|
||||
{/* Input específico de email - autoCapitalize=none evita letras maiúsculas automáticas */}
|
||||
<Input
|
||||
label="Email"
|
||||
value={email}
|
||||
@@ -73,6 +92,7 @@ export default function AuthRegister() {
|
||||
error={error}
|
||||
/>
|
||||
|
||||
{/* Input para password mascarada com secureTextEntry para esconder os carateres */}
|
||||
<Input
|
||||
label="Senha"
|
||||
value={password}
|
||||
@@ -81,6 +101,8 @@ export default function AuthRegister() {
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
|
||||
{/* Renderização condicional do JSX: o campo shopName só é renderizado
|
||||
se o papel na base de dados (e estado) for 'barbearia' */}
|
||||
{role === 'barbearia' && (
|
||||
<Input
|
||||
label="Nome da barbearia"
|
||||
@@ -90,10 +112,12 @@ export default function AuthRegister() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Botão de chamada à ação principal que aciona a submissão dos dados (handleSubmit) */}
|
||||
<Button onPress={handleSubmit} style={styles.submitButton} size="lg">
|
||||
Criar conta
|
||||
</Button>
|
||||
|
||||
{/* Seção rodapé simples com reencaminhamento de navegação para a página de Login */}
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>Já tem conta? </Text>
|
||||
<Text
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @file Booking.tsx
|
||||
* @description Página de agendamento de serviços. Permite ao utilizador (cliente)
|
||||
* interagir visualmente com as disponibilidades, horários e serviços de uma barbearia
|
||||
* consumindo as informações do estado/BD da aplicação.
|
||||
*/
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Alert } from 'react-native';
|
||||
import { useRoute, useNavigation } from '@react-navigation/native';
|
||||
@@ -11,15 +17,22 @@ import { currency } from '../lib/format';
|
||||
export default function Booking() {
|
||||
const route = useRoute();
|
||||
const navigation = useNavigation();
|
||||
// Recebe o ID da barbearia a partir dos parâmetros de navegação
|
||||
const { shopId } = route.params as { shopId: string };
|
||||
|
||||
// Extrai as entidades e os métodos de interação com a base de dados (através do AppContext)
|
||||
const { shops, createAppointment, user, appointments } = useApp();
|
||||
|
||||
// Encontra a barbearia correspondente tipada via query local na array 'shops' (similar a um SELECT * WHERE id = shopId)
|
||||
const shop = useMemo(() => shops.find((s) => s.id === shopId), [shops, shopId]);
|
||||
|
||||
|
||||
// Gestão de estados para armazenar as seleções temporárias do cliente no formulário
|
||||
const [serviceId, setService] = useState('');
|
||||
const [barberId, setBarber] = useState('');
|
||||
const [date, setDate] = useState('');
|
||||
const [slot, setSlot] = useState('');
|
||||
|
||||
// Fallback visual caso ocorra um erro a obter o ID requisitado
|
||||
if (!shop) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -28,9 +41,15 @@ export default function Booking() {
|
||||
);
|
||||
}
|
||||
|
||||
// Prepara as entidades interligadas baseadas na escolha para uso no interface (renderizar nome/preço)
|
||||
const selectedService = shop.services.find((s) => s.id === serviceId);
|
||||
const selectedBarber = shop.barbers.find((b) => b.id === barberId);
|
||||
|
||||
/**
|
||||
* Função utilitária local para gerar slots de horário padrão (09:00 - 18:00).
|
||||
* Auxilia no preenchimento visual caso não haja regras específicas na BD.
|
||||
* @returns {string[]} Lista de horários padrão.
|
||||
*/
|
||||
const generateDefaultSlots = (): string[] => {
|
||||
const slots: string[] = [];
|
||||
for (let hour = 9; hour <= 18; hour++) {
|
||||
@@ -39,38 +58,65 @@ export default function Booking() {
|
||||
return slots;
|
||||
};
|
||||
|
||||
/**
|
||||
* Este hook processa de forma otimizada os horários disponíveis ao cruzar informações.
|
||||
* Lógica do negócio e tipagem de dados:
|
||||
* 1. Consulta as horas operacionais do 'barbeiro' a trabalhar nesse dia.
|
||||
* 2. Cruza com as 'appointments' da base de dados (onde `status` não está cancelado).
|
||||
* 3. Subtrai os slots já ocupados para garantir a consistência das marcações.
|
||||
*/
|
||||
const availableSlots = useMemo(() => {
|
||||
if (!selectedBarber || !date) return [];
|
||||
// Busca a array de schedule (tipo específico) baseada em JSON / relação
|
||||
const specificSchedule = selectedBarber.schedule.find((s) => s.day === date);
|
||||
let slots = specificSchedule && specificSchedule.slots.length > 0
|
||||
? [...specificSchedule.slots]
|
||||
let slots = specificSchedule && specificSchedule.slots.length > 0
|
||||
? [...specificSchedule.slots]
|
||||
: generateDefaultSlots();
|
||||
|
||||
|
||||
// Obtém as marcações validadas (equivalente a `supabase.from('appointments').select(*).eq(...)`)
|
||||
const bookedSlots = appointments
|
||||
.filter((apt) =>
|
||||
apt.barberId === barberId &&
|
||||
.filter((apt) =>
|
||||
apt.barberId === barberId &&
|
||||
apt.status !== 'cancelado' &&
|
||||
apt.date.startsWith(date)
|
||||
)
|
||||
.map((apt) => {
|
||||
// Separa a data completa num timestamp (ex: "2023-10-10 14:00" -> "14:00")
|
||||
const parts = apt.date.split(' ');
|
||||
return parts.length > 1 ? parts[1] : '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
|
||||
// Filtro devolvendo assim uma array de horários finais para o UI renderizar
|
||||
return slots.filter((slot) => !bookedSlots.includes(slot));
|
||||
}, [selectedBarber, date, barberId, appointments]);
|
||||
|
||||
// Booleano derivável auxiliar, controla o bloqueio ou liberação do botão submit
|
||||
const canSubmit = serviceId && barberId && date && slot;
|
||||
|
||||
/**
|
||||
* Submissão do agendamento.
|
||||
* 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 = () => {
|
||||
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');
|
||||
navigation.navigate('Login' as never);
|
||||
return;
|
||||
}
|
||||
if (!canSubmit) return;
|
||||
const appt = createAppointment({ shopId: shop.id, serviceId, barberId, customerId: user.id, date: `${date} ${slot}` });
|
||||
|
||||
// Cria o agendamento fornecendo as 'Foreign Keys' vitais (shopId, serviceId, etc...)
|
||||
const appt = createAppointment({
|
||||
shopId: shop.id,
|
||||
serviceId,
|
||||
barberId,
|
||||
customerId: user.id, // Auth User UID
|
||||
date: `${date} ${slot}`
|
||||
});
|
||||
|
||||
if (appt) {
|
||||
Alert.alert('Sucesso', 'Agendamento criado com sucesso!');
|
||||
navigation.navigate('Profile' as never);
|
||||
@@ -80,11 +126,13 @@ export default function Booking() {
|
||||
};
|
||||
|
||||
return (
|
||||
// Área de conteúdo com scroll adaptativa
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
<Text style={styles.title}>Agendar em {shop.name}</Text>
|
||||
|
||||
<Card style={styles.card}>
|
||||
<Text style={styles.sectionTitle}>1. Escolha o 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) => (
|
||||
<TouchableOpacity
|
||||
@@ -99,6 +147,7 @@ export default function Booking() {
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>2. Escolha o barbeiro</Text>
|
||||
{/* Renderiza os profissionais, normalmente provindo dum JOIN na base de dados (tabela barbeiros + barbearia) */}
|
||||
<View style={styles.barberContainer}>
|
||||
{shop.barbers.map((b) => (
|
||||
<TouchableOpacity
|
||||
@@ -114,6 +163,7 @@ export default function Booking() {
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionTitle}>3. Escolha a data</Text>
|
||||
{/* Componente simples de input que deverá mapear para a inserção final do timestamp Postgres */}
|
||||
<Input
|
||||
value={date}
|
||||
onChangeText={setDate}
|
||||
@@ -121,6 +171,7 @@ export default function Booking() {
|
||||
/>
|
||||
|
||||
<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) */}
|
||||
<View style={styles.slotsContainer}>
|
||||
{availableSlots.length > 0 ? (
|
||||
availableSlots.map((h) => (
|
||||
@@ -137,6 +188,7 @@ export default function Booking() {
|
||||
)}
|
||||
</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>
|
||||
@@ -147,6 +199,7 @@ export default function Booking() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 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'}
|
||||
</Button>
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @file Cart.tsx
|
||||
* @description Página do carrinho de compras. Permite visualizar e finalizar pedidos
|
||||
* de serviços (e possivelmente de produtos vinculados) acumulados para diferentes barbearias.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Alert } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -9,8 +14,10 @@ import { currency } from '../lib/format';
|
||||
|
||||
export default function Cart() {
|
||||
const navigation = useNavigation();
|
||||
// Obtém o estado global do carrinho, e as funções comunicadoras do AppContext (interface BD)
|
||||
const { cart, shops, removeFromCart, placeOrder, user } = useApp();
|
||||
|
||||
// Renderiza um estado/view vazia intercetiva, caso o array "cart" esteja vazio
|
||||
if (!cart.length) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -21,18 +28,32 @@ export default function Cart() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lógica de Agrupamento de itens no Carrinho.
|
||||
* A nível de negócio, como as encomendas (orders) são feitas por Barbearia (shopId),
|
||||
* agrupamos para construir a interface dividida por Lojas caso de adicione itens mistos.
|
||||
* @returns {Record<string, CartItem[]>} Dicionário indexado pelo shopId
|
||||
*/
|
||||
const grouped = cart.reduce<Record<string, typeof cart>>((acc, item) => {
|
||||
acc[item.shopId] = acc[item.shopId] || [];
|
||||
acc[item.shopId].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
/**
|
||||
* Acionado ao clicar em 'Finalizar pedido' para uma dada loja no JSX.
|
||||
* Interliga através da função do Contexto à base de dados para materializar
|
||||
* o agrupo de serviços selecionados e criar uma tupla na tabela de Pedidos/Marcações da db.
|
||||
* @param {string} shopId - ID da loja que receberá o pedido final.
|
||||
*/
|
||||
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');
|
||||
navigation.navigate('Login' as never);
|
||||
return;
|
||||
}
|
||||
// Gera a inserção na API (insert em tabelas de orders / ordem e dependentes)
|
||||
const order = placeOrder(user.id, shopId);
|
||||
if (order) {
|
||||
Alert.alert('Sucesso', 'Pedido criado com sucesso!');
|
||||
@@ -40,10 +61,16 @@ 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>
|
||||
|
||||
{/* Renderiza dinamicamente 1 Card de Checkout por Loja agrupada no objeto `grouped` */}
|
||||
{Object.entries(grouped).map(([shopId, items]) => {
|
||||
// Mapeia o mock do objeto de loja baseado na primmary key `shopId`
|
||||
const shop = shops.find((s) => s.id === shopId);
|
||||
|
||||
// Agregador quantitativo do array reduzindo o total financeiro calculado pelos preços da BD local
|
||||
const total = items.reduce((sum, i) => {
|
||||
const price =
|
||||
i.type === 'service'
|
||||
@@ -53,15 +80,21 @@ export default function Cart() {
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
// Engloba os items duma só loja
|
||||
<Card key={shopId} style={styles.shopCard}>
|
||||
<View style={styles.shopHeader}>
|
||||
<View>
|
||||
{/* Consome o nome e morada do registo principal (Profile > Shop) na UI */}
|
||||
<Text style={styles.shopName}>{shop?.name ?? 'Barbearia'}</Text>
|
||||
<Text style={styles.shopAddress}>{shop?.address}</Text>
|
||||
</View>
|
||||
{/* Apresenta o custo transformado visualmente (ex: R$ / €) */}
|
||||
<Text style={styles.total}>{currency(total)}</Text>
|
||||
</View>
|
||||
|
||||
{/* Listagem linha a linha dos items (relacionados por foreign key 'refId') */}
|
||||
{items.map((i) => {
|
||||
// JOIN via frontend para resgatar o nome natural referenciado no menu original da Lojas
|
||||
const ref =
|
||||
i.type === 'service'
|
||||
? shop?.services.find((s) => s.id === i.refId)
|
||||
@@ -69,9 +102,12 @@ export default function Cart() {
|
||||
return (
|
||||
<View key={i.refId} style={styles.item}>
|
||||
<Text style={styles.itemText}>
|
||||
{/* Condicionamento estruturado na UI, mostra Serviço vs Produto perante a tipagem DB iterada */}
|
||||
{i.type === 'service' ? 'Serviço: ' : 'Produto: '}
|
||||
{ref?.name ?? 'Item'} x{i.qty}
|
||||
</Text>
|
||||
|
||||
{/* Elimina de forma independente o registo não guardado da persistência AppContext/State */}
|
||||
<Button
|
||||
onPress={() => removeFromCart(i.refId)}
|
||||
variant="ghost"
|
||||
@@ -82,6 +118,8 @@ export default function Cart() {
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Renderização condicional no React para encaminhar fluxo para login se anónimo */}
|
||||
{user ? (
|
||||
<Button onPress={() => handleCheckout(shopId)} style={styles.checkoutButton}>
|
||||
Finalizar pedido
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @file Dashboard.tsx
|
||||
* @description Painel principal (Dashboard) destinado exclusivamente a utilizadores
|
||||
* do tipo 'barbearia'. Permite a gestão integral do negócio: marcações, pedidos,
|
||||
* serviços prestados, produtos e equipa (barbeiros).
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Alert } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -10,6 +16,7 @@ import { currency } from '../lib/format';
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigation = useNavigation();
|
||||
// Resgata variáveis e ações modificadoras do Contexto (ponte para o backend/Supabase)
|
||||
const {
|
||||
user,
|
||||
shops,
|
||||
@@ -27,9 +34,11 @@ export default function Dashboard() {
|
||||
logout,
|
||||
} = useApp();
|
||||
|
||||
// Garante a entidade da barbearia atual baseada na Foreign Key armazenada no utilizador logado
|
||||
const shop = shops.find((s) => s.id === user?.shopId);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'appointments' | 'orders' | 'services' | 'products' | 'barbers'>('overview');
|
||||
|
||||
// Estados locais dos subformulários na página para Adicionar entidades
|
||||
const [svcName, setSvcName] = useState('');
|
||||
const [svcPrice, setSvcPrice] = useState('50');
|
||||
const [svcDuration, setSvcDuration] = useState('30');
|
||||
@@ -39,6 +48,7 @@ export default function Dashboard() {
|
||||
const [barberName, setBarberName] = useState('');
|
||||
const [barberSpecs, setBarberSpecs] = useState('');
|
||||
|
||||
// Segurança de Bloqueio - Validação estrita do role do utilziador no componente
|
||||
if (!user || user.role !== 'barbearia') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -47,6 +57,7 @@ export default function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback no caso da loja ainda não estar populada no join do utilizador
|
||||
if (!shop) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -55,24 +66,36 @@ export default function Dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
// Consultas de agregação de uso local sobre as variações gerais do state (como um SELECT com WHERE)
|
||||
const shopAppointments = appointments.filter((a) => a.shopId === shop.id);
|
||||
const shopOrders = orders.filter((o) => o.shopId === shop.id);
|
||||
const completedAppointments = shopAppointments.filter((a) => a.status === 'concluido');
|
||||
const activeAppointments = shopAppointments.filter((a) => a.status !== 'concluido');
|
||||
const productOrders = shopOrders.filter((o) => o.items.some((i) => i.type === 'product'));
|
||||
|
||||
// Métricas agregadas globais calculadas dinamicamente
|
||||
const totalRevenue = shopOrders.reduce((s, o) => s + o.total, 0);
|
||||
const lowStock = shop.products.filter((p) => p.stock <= 3);
|
||||
|
||||
/**
|
||||
* Adiciona um novo serviço disponível na barbearia.
|
||||
* Invoca "addService", efetuando em backend uma lógica de `supabase.from('services').insert(...)`
|
||||
* relacionando com o `shop.id`.
|
||||
*/
|
||||
const addNewService = () => {
|
||||
if (!svcName.trim()) return;
|
||||
addService(shop.id, { name: svcName, price: Number(svcPrice) || 0, duration: Number(svcDuration) || 30, barberIds: [] });
|
||||
// Limpeza formulário
|
||||
setSvcName('');
|
||||
setSvcPrice('50');
|
||||
setSvcDuration('30');
|
||||
Alert.alert('Sucesso', 'Serviço adicionado');
|
||||
};
|
||||
|
||||
/**
|
||||
* Adiciona um novo produto de venda.
|
||||
* Interliga e armazena informando a Foreign Key da loja `shop.id`.
|
||||
*/
|
||||
const addNewProduct = () => {
|
||||
if (!prodName.trim()) return;
|
||||
addProduct(shop.id, { name: prodName, price: Number(prodPrice) || 0, stock: Number(prodStock) || 0 });
|
||||
@@ -82,6 +105,11 @@ export default function Dashboard() {
|
||||
Alert.alert('Sucesso', 'Produto adicionado');
|
||||
};
|
||||
|
||||
/**
|
||||
* Adiciona um membro (barbeiro) à gestão da equipa.
|
||||
* Modifica a base de dados (ex: `supabase.from('barbers').insert({...})`)
|
||||
* inicializando com estrutura base de interface.
|
||||
*/
|
||||
const addNewBarber = () => {
|
||||
if (!barberName.trim()) return;
|
||||
addBarber(shop.id, {
|
||||
@@ -94,6 +122,12 @@ export default function Dashboard() {
|
||||
Alert.alert('Sucesso', 'Barbeiro adicionado');
|
||||
};
|
||||
|
||||
/**
|
||||
* Atualiza a quantidade de inventário de um produto iterando a variação (+1/-1).
|
||||
* Desencadeia um update para a tabela products (ex: `supabase.from('products').update(...)`) evitando stocks negativos.
|
||||
* @param {string} productId - O identificador único do produto afetado.
|
||||
* @param {number} delta - A quantidade exata matemática a variar da realidade material.
|
||||
*/
|
||||
const updateProductStock = (productId: string, delta: number) => {
|
||||
const product = shop.products.find((p) => p.id === productId);
|
||||
if (!product) return;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @file Explore.tsx
|
||||
* @description Página de exploração. Apresenta uma listagem geral de barbearias
|
||||
* disponíveis na plataforma onde os clientes podem visualizar detalhes e
|
||||
* iniciar o fluxo de agendamento (consome dados das Lojas da BD).
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, FlatList } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -11,28 +17,38 @@ import { RootStackParamList } from '../navigation/types';
|
||||
|
||||
export default function Explore() {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
// Obtém a lista completa de barbearias carregadas no estado global (equivalente a "SELECT * FROM shops")
|
||||
const { shops } = useApp();
|
||||
|
||||
return (
|
||||
// Componente raiz do ecã de exploração
|
||||
<View style={styles.container}>
|
||||
<Text style={styles.title}>Explorar barbearias</Text>
|
||||
|
||||
{/* FlatList é o componente nativo otimizado para renderizar grandes arrays de dados provenientes da BD */}
|
||||
<FlatList
|
||||
data={shops}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item: shop }) => (
|
||||
// Cada Card rege a UI de uma barbearia com dados mapeados reais/mock
|
||||
<Card style={styles.shopCard}>
|
||||
<View style={styles.shopHeader}>
|
||||
<Text style={styles.shopName}>{shop.name}</Text>
|
||||
<Badge color="amber">{shop.rating.toFixed(1)} ⭐</Badge>
|
||||
</View>
|
||||
<Text style={styles.shopAddress}>{shop.address}</Text>
|
||||
|
||||
<View style={styles.shopInfo}>
|
||||
{/* O atributo length do array simula as rows de relacionamentos 1-para-N (serviços) */}
|
||||
<Text style={styles.shopInfoText}>{shop.services.length} serviços</Text>
|
||||
<Text style={styles.shopInfoText}>•</Text>
|
||||
{/* Mapeamento de dimensão da relation 'barbers' */}
|
||||
<Text style={styles.shopInfoText}>{shop.barbers.length} barbeiros</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.buttons}>
|
||||
{/* Botão direciona para as informações, passando o ID primário (shop.id) pelo React Navigation */}
|
||||
<Button
|
||||
onPress={() => navigation.navigate('ShopDetails', { shopId: shop.id })}
|
||||
variant="outline"
|
||||
@@ -40,6 +56,8 @@ export default function Explore() {
|
||||
>
|
||||
Ver detalhes
|
||||
</Button>
|
||||
|
||||
{/* Redirecionamento direto com foreign key injetada para a view de Agendamentos */}
|
||||
<Button
|
||||
onPress={() => navigation.navigate('Booking', { shopId: shop.id })}
|
||||
style={styles.button}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @file Landing.tsx
|
||||
* @description Página principal introdutória (Landing Page) não protegida. Serve
|
||||
* como "home" e apresentação da aplicação, reencaminhando para Login/Explorar.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -8,7 +13,9 @@ export default function Landing() {
|
||||
const navigation = useNavigation();
|
||||
|
||||
return (
|
||||
// Interface principal agrupando o "hero" e "features"
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
{/* Hero section: elemento apelativo para captar valor (exibição de marca) */}
|
||||
<View style={styles.hero}>
|
||||
<Text style={styles.heroTitle}>Smart Agenda</Text>
|
||||
<Text style={styles.heroSubtitle}>
|
||||
@@ -18,6 +25,7 @@ export default function Landing() {
|
||||
Experiência mobile-first para clientes e painel completo para barbearias.
|
||||
</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}
|
||||
@@ -25,6 +33,8 @@ export default function Landing() {
|
||||
>
|
||||
Explorar barbearias
|
||||
</Button>
|
||||
|
||||
{/* Botão nativo focado à inserção de utilizadores - Cria sessão no ecositema de Auth/BD */}
|
||||
<Button
|
||||
onPress={() => navigation.navigate('Register' as never)}
|
||||
variant="outline"
|
||||
@@ -37,6 +47,7 @@ export default function Landing() {
|
||||
</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.featureDesc}>
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* @file Profile.tsx
|
||||
* @description Página do perfil de utilizador logado. Exibe os dados do cliente
|
||||
* ou barbearia, bem como o histórico de agendamentos e pedidos vinculados à conta.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -7,6 +12,7 @@ import { Badge } from '../components/ui/Badge';
|
||||
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',
|
||||
confirmado: 'green',
|
||||
@@ -16,8 +22,10 @@ const statusColor: Record<string, 'amber' | 'green' | 'slate' | 'red'> = {
|
||||
|
||||
export default function Profile() {
|
||||
const navigation = useNavigation();
|
||||
// Obtém sessão do utilizador (auth) e listas globais da BD (appointments e orders)
|
||||
const { user, appointments, orders, shops, logout } = useApp();
|
||||
|
||||
// Guarda/Bloqueio protetor para forçar navegação ou alertar utilizadores anónimos
|
||||
if (!user) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -26,30 +34,42 @@ export default function Profile() {
|
||||
);
|
||||
}
|
||||
|
||||
// Filtragem (equivalente a queries com cláusula WHERE customerId = ?) para obter o histórico individual
|
||||
const myAppointments = appointments.filter((a) => a.customerId === user.id);
|
||||
const myOrders = orders.filter((o) => o.customerId === user.id);
|
||||
|
||||
return (
|
||||
// Contentor com ScrollView adaptável (evita cortes em ecrãs pequenos)
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
{/* Bloco de identificação do Utilizador validado pela API (Supabase Auth) */}
|
||||
<Card style={styles.profileCard}>
|
||||
<Text style={styles.profileName}>Olá, {user.name}</Text>
|
||||
<Text style={styles.profileEmail}>{user.email}</Text>
|
||||
|
||||
{/* Distanciamento visual e lógica dos tipos de perfil 'role' presentes na BD */}
|
||||
<Badge color="amber" style={styles.roleBadge}>
|
||||
{user.role === 'cliente' ? 'Cliente' : 'Barbearia'}
|
||||
</Badge>
|
||||
|
||||
{/* Limpa a sessão ativa e tokens memorizados de Login */}
|
||||
<Button onPress={logout} variant="outline" style={styles.logoutButton}>
|
||||
Sair
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Text style={styles.sectionTitle}>Agendamentos</Text>
|
||||
{/* Renderiza a lista se existirem marcações no percurso deste utilizador */}
|
||||
{myAppointments.length > 0 ? (
|
||||
myAppointments.map((a) => {
|
||||
// Resolve a associação relacional (a.shopId) obtendo os detalhes da barbearia
|
||||
const shop = shops.find((s) => s.id === a.shopId);
|
||||
return (
|
||||
<Card key={a.id} style={styles.itemCard}>
|
||||
<View style={styles.itemHeader}>
|
||||
{/* Nome exibido pós JOIN de array em memória */}
|
||||
<Text style={styles.itemName}>{shop?.name}</Text>
|
||||
|
||||
{/* O status (persistido na BD) influencia a cor devolvida ao Badge */}
|
||||
<Badge color={statusColor[a.status]}>{a.status}</Badge>
|
||||
</View>
|
||||
<Text style={styles.itemDate}>{a.date}</Text>
|
||||
@@ -64,6 +84,7 @@ export default function Profile() {
|
||||
)}
|
||||
|
||||
<Text style={styles.sectionTitle}>Pedidos</Text>
|
||||
{/* Renderiza o histórico de compras de retalho/produtos usando idêntica lógica */}
|
||||
{myOrders.length > 0 ? (
|
||||
myOrders.map((o) => {
|
||||
const shop = shops.find((s) => s.id === o.shopId);
|
||||
@@ -74,6 +95,7 @@ export default function Profile() {
|
||||
<Badge color={statusColor[o.status]}>{o.status}</Badge>
|
||||
</View>
|
||||
<Text style={styles.itemDate}>
|
||||
{/* Formatação Timestamp temporal da BD (createdAt) para modo visual PT */}
|
||||
{new Date(o.createdAt).toLocaleString('pt-BR')}
|
||||
</Text>
|
||||
<Text style={styles.itemTotal}>{currency(o.total)}</Text>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @file ShopDetails.tsx
|
||||
* @description Página de detalhes de uma barbearia específica.
|
||||
* Mostra a listagem de catálogos dividida por abas (Serviços / Produtos) e
|
||||
* permite adicionar itens ao carrinho de compras ou transitar para Agendamento.
|
||||
*/
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
|
||||
@@ -10,13 +16,21 @@ import { currency } from '../lib/format';
|
||||
import { RootStackParamList } from '../navigation/types';
|
||||
|
||||
export default function ShopDetails() {
|
||||
// Apanha o parâmetro 'shopId' passado por rotas anteriores (ex: via Explore)
|
||||
const route = useRoute<RouteProp<RootStackParamList, 'ShopDetails'>>();
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const { shopId } = route.params;
|
||||
|
||||
// Instancia ferramentas de dados partilhadas do State (a simular uma API de comunicação)
|
||||
const { shops, addToCart } = useApp();
|
||||
|
||||
// Otimiza o re-render filtrando e selecionando apenas a entidade visualizada na DB
|
||||
const shop = useMemo(() => shops.find((s) => s.id === shopId), [shops, shopId]);
|
||||
|
||||
// Estado local contidiano para gerir as abas visíveis (Tabs de Conteúdos)
|
||||
const [tab, setTab] = useState<'servicos' | 'produtos'>('servicos');
|
||||
|
||||
// Fallback visual de navegação inválida para o caso da barbearia não constar
|
||||
if (!shop) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@@ -26,10 +40,13 @@ export default function ShopDetails() {
|
||||
}
|
||||
|
||||
return (
|
||||
// Contentor em Scroll adaptável horizontal/vertical em smartphones reduzidos
|
||||
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
|
||||
{/* Cabeçalho superior: Informações imutáveis populadas pelos profiles preenchidos */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{shop.name}</Text>
|
||||
<Text style={styles.address}>{shop.address}</Text>
|
||||
{/* Call to action de elevado destaque que incia form Booking */}
|
||||
<Button
|
||||
onPress={() => navigation.navigate('Booking', { shopId: shop.id })}
|
||||
style={styles.bookButton}
|
||||
@@ -38,6 +55,7 @@ export default function ShopDetails() {
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{/* Controladores de abas visuais iterando o estado (setTab) */}
|
||||
<View style={styles.tabs}>
|
||||
<TouchableOpacity
|
||||
style={[styles.tab, tab === 'servicos' && styles.tabActive]}
|
||||
@@ -53,7 +71,9 @@ export default function ShopDetails() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Renderização Condicionada pela Tab ativa */}
|
||||
{tab === 'servicos' ? (
|
||||
// Apresenta o catálogo relacionado com 'serviços' da tabela/coleção na BD da referida loja
|
||||
<View style={styles.list}>
|
||||
{shop.services.map((service) => (
|
||||
<Card key={service.id} style={styles.itemCard}>
|
||||
@@ -62,6 +82,8 @@ export default function ShopDetails() {
|
||||
<Text style={styles.itemPrice}>{currency(service.price)}</Text>
|
||||
</View>
|
||||
<Text style={styles.itemDesc}>Duração: {service.duration} min</Text>
|
||||
|
||||
{/* Função addToCart despacha dados para Context agregador permitindo checkout posterior */}
|
||||
<Button
|
||||
onPress={() => addToCart({ shopId: shop.id, type: 'service', refId: service.id, qty: 1 })}
|
||||
size="sm"
|
||||
@@ -73,6 +95,7 @@ export default function ShopDetails() {
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
// Retorno divergente: inventário global visivelmente iterado em componentes
|
||||
<View style={styles.list}>
|
||||
{shop.products.map((product) => (
|
||||
<Card key={product.id} style={styles.itemCard}>
|
||||
@@ -81,7 +104,11 @@ export default function ShopDetails() {
|
||||
<Text style={styles.itemPrice}>{currency(product.price)}</Text>
|
||||
</View>
|
||||
<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>}
|
||||
|
||||
{/* Botão em React é afetado logicamente face à impossibilidade material de encomenda */}
|
||||
<Button
|
||||
onPress={() => addToCart({ shopId: shop.id, type: 'product', refId: product.id, qty: 1 })}
|
||||
size="sm"
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @file CalendarWeekView.tsx
|
||||
* @description Componente visual para apresentação de Agendamentos sob a perspetiva de uma semana.
|
||||
* Implementa lógica de negócio de calendário (cálculo de slots horários de 30 minutos,
|
||||
* identificação do dia atual e posicionamento dinâmico do indicador da hora presente).
|
||||
*/
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, RefreshCw } from 'lucide-react';
|
||||
import { Appointment, BarberShop } from '../types';
|
||||
@@ -158,15 +164,13 @@ export const CalendarWeekView = ({
|
||||
{weekDays.map((day, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`p-3 text-center border-r border-slate-200 last:border-r-0 ${
|
||||
isToday(day) ? 'bg-indigo-50' : ''
|
||||
}`}
|
||||
className={`p-3 text-center border-r border-slate-200 last:border-r-0 ${isToday(day) ? 'bg-indigo-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium text-slate-500 mb-1">{dayNames[idx]}</div>
|
||||
<div
|
||||
className={`text-lg font-bold ${
|
||||
isToday(day) ? 'text-indigo-700' : 'text-slate-900'
|
||||
}`}
|
||||
className={`text-lg font-bold ${isToday(day) ? 'text-indigo-700' : 'text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{day.getDate()}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* @file AppContext.tsx
|
||||
* @description Gestor de Estado Global (Context API) da aplicação Web.
|
||||
* Este ficheiro gere uma arquitetura híbrida: coordena a Autenticação e Perfis (Profiles)
|
||||
* diretamente com o Supabase, enquanto mantém as entidades transacionais
|
||||
* (Shops, Appointments, Cart) num regime de persistência local 'mockada' via LocalStorage.
|
||||
*/
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Appointment, Barber, BarberShop, CartItem, Order, Product, Service, User } from '../types';
|
||||
@@ -87,7 +94,15 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
setState((s) => ({ ...s, user: undefined }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Sincroniza o Perfil do Utilizador a partir do Supabase Database ('profiles').
|
||||
* Cria automaticamente a shop ("Barbearia") se a Role determinar, integrando-o
|
||||
* no estado (simulado em LocalStorage) das Shops.
|
||||
* @param userId - UID emitido pela Supabase Auth.
|
||||
* @param email - Endereço de Email (Payload do token).
|
||||
*/
|
||||
const applyProfile = async (userId: string, email?: string | null) => {
|
||||
// Pedido restrito à tabela profiles de mapeamento Auth -> App Logic
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('id,name,role,shop_name')
|
||||
@@ -119,12 +134,12 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
: [...s.users, nextUser];
|
||||
|
||||
let shops = s.shops;
|
||||
if (role === 'barbearia' && shopId) {
|
||||
if (/*role === 'barbearia' && shopId*/true) {
|
||||
const exists = shops.some((shop) => shop.id === shopId);
|
||||
if (!exists) {
|
||||
const shopName = data.shop_name?.trim() || `Barbearia ${displayName}`;
|
||||
const shop: BarberShop = {
|
||||
id: shopId,
|
||||
id: 'shopId',
|
||||
name: shopName,
|
||||
address: 'Endereço a definir',
|
||||
rating: 0,
|
||||
@@ -145,6 +160,9 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* "Boot" Inicial: Verifica a sessão global com `getSession` de forma assíncrona.
|
||||
*/
|
||||
const boot = async () => {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
if (!mounted) return;
|
||||
@@ -158,6 +176,10 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
void boot();
|
||||
|
||||
/**
|
||||
* Listener Global: Subscrição de eventos Auth que atualiza a context se
|
||||
* utilizador fizer Log Out ou novo Login fora deste hook imediato.
|
||||
*/
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
if (session?.user) {
|
||||
void applyProfile(session.user.id, session.user.email);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { BarberShop, User } from '../types';
|
||||
export const mockUsers: User[] = [
|
||||
{ id: 'u1', name: 'Cliente Demo', email: 'cliente@demo.com', password: '123', role: 'cliente' },
|
||||
{ id: 'u2', name: 'Barbearia Demo', email: 'barber@demo.com', password: '123', role: 'barbearia', shopId: 's1' },
|
||||
{ id: 'u3', name: 'Cliente Demo', email: 'cliente@demo.com', password: '123', role: 'cliente' },
|
||||
{ id: 'u4', name: 'Barbearia Demo', email: 'barber@demo.com', password: '123', role: 'barbearia', shopId: 's1' },
|
||||
];
|
||||
|
||||
export const mockShops: BarberShop[] = [
|
||||
@@ -39,6 +41,39 @@ export const mockShops: BarberShop[] = [
|
||||
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 }],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @file auth.ts
|
||||
* @description Wrapper client-side para o Supabase Auth.
|
||||
* Exporta funções utilitárias assíncronas para Autenticação (Login/Registo/Logout)
|
||||
* e para a obtenção da sessão ativa do utilizador.
|
||||
*/
|
||||
import { supabase } from './supabase'
|
||||
|
||||
export async function signIn(email: string, password: string) {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
/**
|
||||
* @file events.ts
|
||||
* @description Centraliza as operações de Data Fetching (CRUD) dos Eventos na Base de Dados Supabase.
|
||||
* Todas as funções garantem autenticação antes de comunicar,
|
||||
* baseando-se nas Row Level Security (RLS) do Supabase para limitar acesso aos dados do utilizador.
|
||||
*/
|
||||
import { supabase } from './supabase'
|
||||
import { getUser } from './auth'
|
||||
|
||||
/**
|
||||
* Interface que espelha o esquema da tabela 'events' no Supabase.
|
||||
*/
|
||||
export type EventRow = {
|
||||
id: number
|
||||
title: string
|
||||
@@ -11,7 +20,9 @@ export type EventRow = {
|
||||
|
||||
/**
|
||||
* LISTAR eventos do utilizador autenticado
|
||||
* (RLS garante isolamento por user_id)
|
||||
* A consulta Supabase `select` obtém os eventos e o Supabase encarrega-se via RLS
|
||||
* de garantir que a query só retorna as linhas em que `user_id` corresponde ao UID da sessão.
|
||||
* @returns {Promise<EventRow[]>} Lista de eventos ordenados cronologicamente.
|
||||
*/
|
||||
export async function listEvents() {
|
||||
const user = await getUser()
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
/**
|
||||
* @file AuthLogin.tsx
|
||||
* @description Página de Autenticação (Login) para a versão Web da aplicação.
|
||||
* Permite aos utilizadores (clientes e barbearias) entrarem na sua conta
|
||||
* através de credenciais de email e palavra-passe, ligando-se ao fluxo de
|
||||
* sessões do Supabase.
|
||||
*/
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Input } from '../components/ui/input'
|
||||
@@ -11,33 +18,49 @@ export default function AuthLogin() {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Utilização do hook do react-router-dom para navegação entre páginas web
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Se já houver sessão, vai direto
|
||||
/**
|
||||
* Hook executado na montagem inicial do componente.
|
||||
* Interage diretamente com o método `getSession` do Supabase para verificar
|
||||
* de forma assíncrona se o utilizador possui um token de sessão válido na cache/local storage.
|
||||
* Se os dados do utilizador confirmarem autenticação ativa, redireciona o fluxo para '/explorar'.
|
||||
*/
|
||||
useEffect(() => {
|
||||
supabase.auth.getSession().then(({ data }) => {
|
||||
// Confirma a existência num objeto global não nulo associado ao session
|
||||
if (data.session) {
|
||||
navigate('/explorar', { replace: true })
|
||||
}
|
||||
})
|
||||
}, [navigate])
|
||||
|
||||
/**
|
||||
* Manipula a submissão do formulário na view para validar as credenciais.
|
||||
* @param {FormEvent} e - Evento padrão de submissão form para prevenir comportamento `submit` comum.
|
||||
*/
|
||||
const handleLogin = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
// Comunicação via API REST do Supabase para Login
|
||||
// A biblioteca gera pedido POST com as credenciais (Email e JSON PW) para endpoint /token
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
if (error) throw error
|
||||
|
||||
// Sucesso na verificação origina redirecionamento do Auth
|
||||
navigate('/explorar', { replace: true })
|
||||
} catch (e: any) {
|
||||
setError('Credenciais inválidas ou email não confirmado')
|
||||
} finally {
|
||||
// Reset da flag de estado da UI, quer dê erro quer não
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @file AuthRegister.tsx
|
||||
* @description Página de Registo para a versão Web. Permite a criação de
|
||||
* novas contas segmentadas por perfil ('cliente' ou 'barbearia'). Interage
|
||||
* diretamente com o serviço de autenticação do Supabase.
|
||||
*/
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Input } from '../components/ui/input'
|
||||
@@ -10,32 +16,46 @@ export default function AuthRegister() {
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
// Estado local para definir as permissões associadas à conta nova
|
||||
const [role, setRole] = useState<'cliente' | 'barbearia'>('cliente')
|
||||
|
||||
// Condicional, só inserido no metadados se o tipo de utilizador for 'barbearia'
|
||||
const [shopName, setShopName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
/**
|
||||
* Previne acesso à página de registo por utilizadores com Sessão ativa.
|
||||
* Utiliza um padrão `mounted` para não alterar state se o componente for desmontado.
|
||||
*/
|
||||
// 🔐 Se já estiver logado, não pode ver o registo
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
;(async () => {
|
||||
const { data } = await supabase.auth.getSession()
|
||||
if (!mounted) return
|
||||
if (data.session) {
|
||||
navigate('/explorar', { replace: true })
|
||||
}
|
||||
})()
|
||||
; (async () => {
|
||||
// Efetua um fetch à sessão existente injetada pelo SDK da Supabase
|
||||
const { data } = await supabase.auth.getSession()
|
||||
if (!mounted) return
|
||||
if (data.session) {
|
||||
navigate('/explorar', { replace: true })
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
/**
|
||||
* Processa a criação e envio do novo perfil e das informações para a BD via Supabase Auth.
|
||||
* @param {FormEvent} e - Evento de submissão do formulário.
|
||||
*/
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
// Regras básicas de negócio à submissão (proteção pre-API)
|
||||
if (!name.trim()) return setError('Preencha o nome completo')
|
||||
if (!email.trim()) return setError('Preencha o email')
|
||||
if (!password.trim()) return setError('Preencha a senha')
|
||||
@@ -46,6 +66,8 @@ export default function AuthRegister() {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
// Registo propriamente dito perante serviço do Supabase
|
||||
// Encaminha, adicionalmente, opções nos metadados (Data) da auth do serviço para mapeamento da "profile" table na BD
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
@@ -53,6 +75,7 @@ export default function AuthRegister() {
|
||||
data: {
|
||||
name: name.trim(),
|
||||
role,
|
||||
// Apenas vincula shopName no json object se a rule aplicável confirmar que a role o exige
|
||||
shopName: role === 'barbearia' ? shopName.trim() : null,
|
||||
},
|
||||
},
|
||||
@@ -60,7 +83,7 @@ export default function AuthRegister() {
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// 🔔 Se confirmação de email estiver ON
|
||||
// 🔔 Se confirmação de email estiver ON (verificação pendente via SMTP em Supabase Configs)
|
||||
if (!data.session) {
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
@@ -71,7 +94,7 @@ export default function AuthRegister() {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Login automático
|
||||
// ✅ Login automático: Rotas divergentes de acordo com a função Role
|
||||
navigate(role === 'barbearia' ? '/painel' : '/explorar', {
|
||||
replace: true,
|
||||
})
|
||||
@@ -118,11 +141,10 @@ export default function AuthRegister() {
|
||||
setRole(r)
|
||||
setError('')
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
role === r
|
||||
className={`p-4 rounded-xl border-2 transition-all ${role === r
|
||||
? 'border-amber-500 bg-amber-50 shadow-md'
|
||||
: 'border-slate-200 hover:border-amber-300'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{r === 'cliente' ? (
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @file Booking.tsx
|
||||
* @description Página de Agendamento da versão Web.
|
||||
* Gere um formulário multi-passo unificado para selecionar o Serviço,
|
||||
* Barbeiro, Data e Horário. Cruza disponibilidades em tempo real.
|
||||
*/
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card } from '../components/ui/card';
|
||||
@@ -10,8 +16,14 @@ import { currency } from '../lib/format';
|
||||
export default function Booking() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Extração das ferramentas vitais do Context global da aplicação
|
||||
const { shops, createAppointment, user, appointments } = useApp();
|
||||
|
||||
// Procura a barbearia acedida (com base no URL parameter ':id')
|
||||
const shop = useMemo(() => shops.find((s) => s.id === id), [shops, id]);
|
||||
|
||||
// Estados para as escolhas parciais do utilizador
|
||||
const [serviceId, setService] = useState('');
|
||||
const [barberId, setBarber] = useState('');
|
||||
const [date, setDate] = useState('');
|
||||
@@ -21,53 +33,69 @@ export default function Booking() {
|
||||
|
||||
const selectedService = shop.services.find((s) => s.id === serviceId);
|
||||
const selectedBarber = shop.barbers.find((b) => b.id === barberId);
|
||||
|
||||
// Função para gerar horários padrão se não houver horários específicos para a data
|
||||
|
||||
/**
|
||||
* Função para gerar horários padrão se não houver horários específicos predefinidos
|
||||
* pelo barbeiro na Base de Dados.
|
||||
* @returns {string[]} Lista de horários de 1 em 1 hora.
|
||||
*/
|
||||
const generateDefaultSlots = (): string[] => {
|
||||
const slots: string[] = [];
|
||||
// Horário de trabalho padrão: 09:00 às 18:00, de hora em hora
|
||||
// Horário de trabalho padrão estipulado: 09:00 às 18:00
|
||||
for (let hour = 9; hour <= 18; hour++) {
|
||||
slots.push(`${hour.toString().padStart(2, '0')}:00`);
|
||||
}
|
||||
return slots;
|
||||
};
|
||||
|
||||
// Buscar horários disponíveis para a data selecionada
|
||||
/**
|
||||
* Deriva reativamente a lista exata de horários disponíveis.
|
||||
* Elimina os slots que já estejam formalmente ocupados ('appointments' não cancelados) na BD.
|
||||
*/
|
||||
// Buscar horários disponíveis para a data selecionada interagindo com dados transacionais
|
||||
const availableSlots = useMemo(() => {
|
||||
if (!selectedBarber || !date) return [];
|
||||
|
||||
// Primeiro, tentar encontrar horários específicos para a data
|
||||
const specificSchedule = selectedBarber.schedule.find((s) => s.day === date);
|
||||
let slots = specificSchedule && specificSchedule.slots.length > 0
|
||||
? [...specificSchedule.slots]
|
||||
|
||||
// Primeiro, tenta encontrar horários específicos configurados para a data
|
||||
const specificSchedule = selectedBarber.schedule?.find((s) => s.day === date);
|
||||
let slots = specificSchedule && specificSchedule.slots.length > 0
|
||||
? [...specificSchedule.slots]
|
||||
: generateDefaultSlots();
|
||||
|
||||
// Filtrar horários já ocupados
|
||||
|
||||
// Filtra agendamentos atuais já alocados para impedir duplo-agendamento ('Double Booking')
|
||||
const bookedSlots = appointments
|
||||
.filter((apt) =>
|
||||
apt.barberId === barberId &&
|
||||
.filter((apt) =>
|
||||
apt.barberId === barberId &&
|
||||
apt.status !== 'cancelado' &&
|
||||
apt.date.startsWith(date)
|
||||
)
|
||||
.map((apt) => {
|
||||
// Extrair o horário da string de data (formato: "YYYY-MM-DD HH:MM")
|
||||
// Separação para identificar a secção das "horas" na data ISO/String ("YYYY-MM-DD HH:MM")
|
||||
const parts = apt.date.split(' ');
|
||||
return parts.length > 1 ? parts[1] : '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
|
||||
// Devolve diferença de conjuntos
|
||||
return slots.filter((slot) => !bookedSlots.includes(slot));
|
||||
}, [selectedBarber, date, barberId, appointments]);
|
||||
|
||||
const canSubmit = serviceId && barberId && date && slot;
|
||||
|
||||
/**
|
||||
* Dispara a ação de guardar a nova marcação na base de dados Supabase via Context API.
|
||||
*/
|
||||
const submit = () => {
|
||||
if (!user) {
|
||||
// Bloqueia ações de clientes anónimos exigindo Sessão iniciada
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
if (!canSubmit) return;
|
||||
|
||||
// O método 'createAppointment' fará internamente um pedido `supabase.from('appointments').insert(...)`
|
||||
const appt = createAppointment({ shopId: shop.id, serviceId, barberId, customerId: user.id, date: `${date} ${slot}` });
|
||||
|
||||
if (appt) {
|
||||
navigate('/perfil');
|
||||
} else {
|
||||
@@ -95,11 +123,10 @@ export default function Booking() {
|
||||
<div key={step.id} className="flex items-center flex-1">
|
||||
<div className="flex flex-col items-center flex-1">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center border-2 transition-all ${
|
||||
step.completed
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center border-2 transition-all ${step.completed
|
||||
? 'bg-gradient-to-br from-amber-500 to-amber-600 border-amber-600 text-white shadow-md'
|
||||
: 'bg-white border-slate-300 text-slate-400'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{step.completed ? <CheckCircle2 size={18} /> : <step.icon size={18} />}
|
||||
</div>
|
||||
@@ -126,11 +153,10 @@ export default function Booking() {
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setService(s.id)}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
serviceId === s.id
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${serviceId === s.id
|
||||
? 'border-amber-500 bg-gradient-to-br from-amber-50 to-amber-100/50 shadow-md scale-[1.02]'
|
||||
: 'border-slate-200 hover:border-amber-300 hover:bg-amber-50/50'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="font-bold text-slate-900">{s.name}</div>
|
||||
@@ -153,11 +179,10 @@ export default function Booking() {
|
||||
<button
|
||||
key={b.id}
|
||||
onClick={() => setBarber(b.id)}
|
||||
className={`px-4 py-2.5 rounded-full border-2 text-sm font-medium transition-all ${
|
||||
barberId === b.id
|
||||
className={`px-4 py-2.5 rounded-full border-2 text-sm font-medium transition-all ${barberId === b.id
|
||||
? 'border-amber-500 bg-gradient-to-r from-amber-500 to-amber-600 text-white shadow-md'
|
||||
: 'border-slate-200 text-slate-700 hover:border-amber-300 hover:bg-amber-50'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{b.name}
|
||||
{b.specialties.length > 0 && (
|
||||
@@ -195,11 +220,10 @@ export default function Booking() {
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setSlot(h)}
|
||||
className={`px-4 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
|
||||
slot === h
|
||||
className={`px-4 py-2 rounded-lg border-2 text-sm font-medium transition-all ${slot === h
|
||||
? 'border-amber-500 bg-gradient-to-r from-amber-500 to-amber-600 text-white shadow-md'
|
||||
: 'border-slate-200 text-slate-700 hover:border-amber-300 hover:bg-amber-50'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* @file Cart.tsx
|
||||
* @description Rota principal da view do Carrinho. Atua meramente
|
||||
* como _wrapper_ injetando um componente funcional isolado 'CartPanel'.
|
||||
*/
|
||||
import { CartPanel } from '../components/CartPanel';
|
||||
|
||||
export default function Cart() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold text-slate-900">Carrinho</h1>
|
||||
{/* Componente que gere a lógica do Array de cart itens, e envio para backend */}
|
||||
<CartPanel />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @file Dashboard.tsx
|
||||
* @description Painel principal de controlo ('Dashboard') para barbearias na web.
|
||||
* Consolida a visualização de Agendamentos, Histórico, Pedidos (produtos),
|
||||
* Gestão de Serviços/Produtos e Perfis de Barbeiros numa única view complexa.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { Button } from '../components/ui/button';
|
||||
@@ -64,6 +70,7 @@ const parseDate = (value: string) => new Date(value.replace(' ', 'T'));
|
||||
type TabId = 'overview' | 'appointments' | 'history' | 'orders' | 'services' | 'products' | 'barbers';
|
||||
|
||||
export default function Dashboard() {
|
||||
// Importa estado global interligado com Supabase (ex: updateAppointmentStatus reflete-se na BD)
|
||||
const {
|
||||
user,
|
||||
users,
|
||||
@@ -106,10 +113,14 @@ export default function Dashboard() {
|
||||
if (!shop) return <div>Barbearia não encontrada.</div>;
|
||||
|
||||
const periodMatch = periods[period];
|
||||
|
||||
// Agendamentos filtrados pela barbearia logada e pela janela de tempo selecionada
|
||||
const allShopAppointments = appointments.filter((a) => a.shopId === shop.id && periodMatch(parseDate(a.date)));
|
||||
// Agendamentos ativos (não concluídos)
|
||||
|
||||
// Agendamentos ativos (não concluídos e não cancelados)
|
||||
const shopAppointments = allShopAppointments.filter((a) => a.status !== 'concluido');
|
||||
// Agendamentos concluídos (histórico)
|
||||
|
||||
// Agendamentos concluídos (histórico passado)
|
||||
const completedAppointments = allShopAppointments.filter((a) => a.status === 'concluido');
|
||||
|
||||
// Estatísticas para lista de marcações (do dia selecionado)
|
||||
@@ -124,14 +135,14 @@ export default function Dashboard() {
|
||||
});
|
||||
|
||||
const totalBookingsToday = selectedDateAppointments.filter((a) => includeCancelled || a.status !== 'cancelado').length;
|
||||
|
||||
|
||||
const newClientsToday = useMemo(() => {
|
||||
const clientIds = new Set(selectedDateAppointments.map((a) => a.customerId));
|
||||
return clientIds.size;
|
||||
}, [selectedDateAppointments]);
|
||||
|
||||
const onlineBookingsToday = selectedDateAppointments.filter((a) => a.status !== 'cancelado').length;
|
||||
|
||||
|
||||
const occupancyRate = useMemo(() => {
|
||||
// Calcular ocupação baseada em slots disponíveis (8h-18h = 20 slots de 30min)
|
||||
const totalSlots = 20;
|
||||
@@ -145,7 +156,7 @@ export default function Dashboard() {
|
||||
// Filtrar agendamentos para lista
|
||||
const filteredAppointments = useMemo(() => {
|
||||
let filtered = selectedDateAppointments;
|
||||
|
||||
|
||||
if (!includeCancelled) {
|
||||
filtered = filtered.filter((a) => a.status !== 'cancelado');
|
||||
}
|
||||
@@ -288,11 +299,10 @@ export default function Dashboard() {
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(p)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
||||
period === 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'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{p === 'mes' ? 'Mês' : p === 'hoje' ? 'Hoje' : p === 'semana' ? 'Semana' : 'Total'}
|
||||
</button>
|
||||
@@ -380,7 +390,7 @@ export default function Dashboard() {
|
||||
const aptDate = new Date(a.date.replace(' ', 'T'));
|
||||
return aptDate.toDateString() === today.toDateString();
|
||||
});
|
||||
|
||||
|
||||
if (todayAppts.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
@@ -393,7 +403,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{todayAppts.slice(0, 3).map(a => {
|
||||
@@ -402,7 +412,7 @@ export default function Dashboard() {
|
||||
const customer = users.find(u => u.id === a.customerId);
|
||||
const aptDate = new Date(a.date.replace(' ', 'T'));
|
||||
const timeStr = aptDate.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
|
||||
return (
|
||||
<div key={a.id} className="flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50">
|
||||
<div className="flex-1">
|
||||
@@ -517,7 +527,7 @@ export default function Dashboard() {
|
||||
const customer = users.find(u => u.id === a.customerId);
|
||||
const aptDate = new Date(a.date.replace(' ', 'T'));
|
||||
const timeStr = aptDate.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
|
||||
return (
|
||||
<div key={a.id} className="flex items-start gap-3 p-3 border border-slate-200 rounded-lg">
|
||||
<div className="p-1.5 bg-indigo-100 rounded">
|
||||
@@ -548,22 +558,20 @@ export default function Dashboard() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setAppointmentView('list')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg border-2 transition-all ${
|
||||
appointmentView === 'list'
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg border-2 transition-all ${appointmentView === 'list'
|
||||
? 'border-indigo-500 bg-indigo-50 text-indigo-700 font-semibold'
|
||||
: 'border-slate-200 text-slate-700 hover:border-indigo-300 hover:bg-indigo-50/50'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<List size={18} />
|
||||
<span>Lista de marcações</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAppointmentView('calendar')}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg border-2 transition-all ${
|
||||
appointmentView === 'calendar'
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg border-2 transition-all ${appointmentView === 'calendar'
|
||||
? 'border-indigo-500 bg-indigo-50 text-indigo-700 font-semibold'
|
||||
: 'border-slate-200 text-slate-700 hover:border-indigo-300 hover:bg-indigo-50/50'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<Calendar size={18} />
|
||||
<span>Calendário</span>
|
||||
@@ -659,11 +667,10 @@ export default function Dashboard() {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setIncludeCancelled(!includeCancelled)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg border-2 transition-all ${
|
||||
includeCancelled
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg border-2 transition-all ${includeCancelled
|
||||
? 'border-indigo-500 bg-indigo-50 text-indigo-700'
|
||||
: 'border-slate-200 text-slate-700 hover:border-indigo-300'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<span>Incluir cancelamentos</span>
|
||||
<ChevronDown size={16} />
|
||||
@@ -689,11 +696,11 @@ export default function Dashboard() {
|
||||
<ChevronLeft size={18} />
|
||||
</Button>
|
||||
<div className="text-sm font-medium text-slate-700 px-3">
|
||||
{selectedDate.toLocaleDateString('pt-PT', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric'
|
||||
{selectedDate.toLocaleDateString('pt-PT', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => {
|
||||
@@ -747,19 +754,19 @@ export default function Dashboard() {
|
||||
a.status === 'pendente'
|
||||
? 'amber'
|
||||
: a.status === 'confirmado'
|
||||
? 'green'
|
||||
: a.status === 'concluido'
|
||||
? 'green'
|
||||
: 'red'
|
||||
? 'green'
|
||||
: a.status === 'concluido'
|
||||
? 'green'
|
||||
: 'red'
|
||||
}
|
||||
>
|
||||
{a.status === 'pendente'
|
||||
? 'Pendente'
|
||||
: a.status === 'confirmado'
|
||||
? 'Confirmado'
|
||||
: a.status === 'concluido'
|
||||
? 'Concluído'
|
||||
: 'Cancelado'}
|
||||
? 'Confirmado'
|
||||
: a.status === 'concluido'
|
||||
? 'Concluído'
|
||||
: 'Cancelado'}
|
||||
</Badge>
|
||||
<p className="text-sm font-semibold text-slate-900">{currency(a.total)}</p>
|
||||
</div>
|
||||
@@ -776,10 +783,10 @@ export default function Dashboard() {
|
||||
{s === 'pendente'
|
||||
? 'Pendente'
|
||||
: s === 'confirmado'
|
||||
? 'Confirmado'
|
||||
: s === 'concluido'
|
||||
? 'Concluído'
|
||||
: 'Cancelado'}
|
||||
? 'Confirmado'
|
||||
: s === 'concluido'
|
||||
? 'Concluído'
|
||||
: 'Cancelado'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -972,9 +979,8 @@ export default function Dashboard() {
|
||||
{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-amber-300 bg-amber-50' : 'border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
// src/pages/EventsCreate.tsx
|
||||
/**
|
||||
* @file EventsCreate.tsx
|
||||
* @description Formulário para a criação de novos Eventos customizados na aplicação Web.
|
||||
* Interage diretamente com a função `createEvent` da biblioteca do Supabase, formatando e guardando
|
||||
* registos de calendário associados ao Utilizador em sessão.
|
||||
*/
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card } from '../components/ui/card'
|
||||
@@ -16,6 +21,10 @@ export default function EventsCreate() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
/**
|
||||
* Valida e submete o formulário, criando uma Data ISO string a partir do input nativo do browser.
|
||||
* Aciona pedido à Base de Dados via Supabase Client (em '../lib/events').
|
||||
*/
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @file Explore.tsx
|
||||
* @description View de Exploração de barbearias na versão Web.
|
||||
* Consome do estado global (`useApp`) a lista consolidada das "shops" populadas
|
||||
* pela base de dados e aplica filtros do lado do cliente baseados na query, tipo e ordenação.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ShopCard } from '../components/ShopCard';
|
||||
import { Card } from '../components/ui/card';
|
||||
@@ -8,15 +14,24 @@ import { Search } from 'lucide-react';
|
||||
|
||||
export default function Explore() {
|
||||
const { shops } = useApp();
|
||||
|
||||
// Estados para manter as seleções de filtragem
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<'todas' | 'top' | 'produtos' | 'barbeiros' | 'servicos'>('todas');
|
||||
const [sortBy, setSortBy] = useState<'relevancia' | 'avaliacao' | 'preco' | 'servicos'>('relevancia');
|
||||
|
||||
/**
|
||||
* Deriva a lista de Shops tratada a partir do conjunto mestre global.
|
||||
* Só recalcula quando os raw `shops` ou os critérios de pesquisa se alteram.
|
||||
*/
|
||||
const filtered = useMemo(() => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
|
||||
// Regra 1: Combinação livre por correspondência parcial textual (Nome/Morada)
|
||||
const matchesQuery = (name: string, address: string) =>
|
||||
!normalized || name.toLowerCase().includes(normalized) || address.toLowerCase().includes(normalized);
|
||||
|
||||
// Regra 2: Restrições de Chip
|
||||
const passesFilter = (shop: (typeof shops)[number]) => {
|
||||
if (filter === 'top') return shop.rating >= 4.7;
|
||||
if (filter === 'produtos') return shop.products.length > 0;
|
||||
@@ -25,6 +40,7 @@ export default function Explore() {
|
||||
return true;
|
||||
};
|
||||
|
||||
// Aplicação condicional com Sort
|
||||
const sorted = [...shops]
|
||||
.filter((shop) => matchesQuery(shop.name, shop.address))
|
||||
.filter(passesFilter)
|
||||
@@ -32,10 +48,13 @@ export default function Explore() {
|
||||
if (sortBy === 'avaliacao') return b.rating - a.rating;
|
||||
if (sortBy === 'servicos') return b.services.length - a.services.length;
|
||||
if (sortBy === 'preco') {
|
||||
// Extrai o preço mínimo nos serviços oferecidos e compara
|
||||
const aMin = Math.min(...a.services.map((s) => s.price));
|
||||
const bMin = Math.min(...b.services.map((s) => s.price));
|
||||
return aMin - bMin;
|
||||
}
|
||||
|
||||
// Critério por defeito ou quebra de empate: Avaliação descendente
|
||||
if (b.rating !== a.rating) return b.rating - a.rating;
|
||||
return b.services.length - a.services.length;
|
||||
});
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* @file Landing.tsx
|
||||
* @description Página de destino (Landing Page) da aplicação web.
|
||||
* Serve como vitrine promocional e porta de entrada (Login/Registo - Call to Actions).
|
||||
* Redireciona autonomamente utilizadores já autenticados para as suas áreas reservadas.
|
||||
*/
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { ShopCard } from '../components/ShopCard';
|
||||
import {
|
||||
Calendar, ShoppingBag, BarChart3, Sparkles,
|
||||
import {
|
||||
Calendar, ShoppingBag, BarChart3, Sparkles,
|
||||
Users, Clock, Shield, TrendingUp, CheckCircle2,
|
||||
ArrowRight, Star, Quote, Scissors, MapPin,
|
||||
Zap, Smartphone, Globe
|
||||
@@ -31,23 +37,23 @@ export default function Landing() {
|
||||
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiNmZmYiIGZpbGwtb3BhY2l0eT0iMC4xIj48Y2lyY2xlIGN4PSIzMCIgY3k9IjMwIiByPSIyIi8+PC9nPjwvZz48L3N2Zz4=')] opacity-20"></div>
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-blue-400/20 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2"></div>
|
||||
<div className="absolute bottom-0 left-0 w-96 h-96 bg-indigo-500/20 rounded-full blur-3xl translate-y-1/2 -translate-x-1/2"></div>
|
||||
|
||||
|
||||
<div className="relative space-y-8 max-w-4xl">
|
||||
<div className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 backdrop-blur-sm rounded-full text-sm font-semibold w-fit border border-white/30">
|
||||
<Sparkles size={16} />
|
||||
<span>Revolucione sua barbearia</span>
|
||||
</div>
|
||||
|
||||
|
||||
<h1 className="text-5xl md:text-6xl lg:text-7xl font-bold leading-tight text-balance">
|
||||
Agendamentos, produtos e gestão em um{' '}
|
||||
<span className="text-blue-100">único lugar</span>
|
||||
</h1>
|
||||
|
||||
|
||||
<p className="text-xl md:text-2xl text-blue-50/90 max-w-3xl leading-relaxed">
|
||||
Experiência mobile-first para clientes e painel completo para barbearias.
|
||||
Experiência mobile-first para clientes e painel completo para barbearias.
|
||||
Simplifique a gestão do seu negócio e aumente sua receita.
|
||||
</p>
|
||||
|
||||
|
||||
<div className="flex flex-wrap gap-4 pt-4">
|
||||
<Button asChild size="lg" className="text-base px-8 py-4">
|
||||
<Link to="/explorar" className="flex items-center gap-2">
|
||||
@@ -88,56 +94,56 @@ export default function Landing() {
|
||||
Funcionalidades poderosas para clientes e barbearias
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[
|
||||
{
|
||||
icon: Calendar,
|
||||
title: 'Agendamentos Inteligentes',
|
||||
{[
|
||||
{
|
||||
icon: Calendar,
|
||||
title: 'Agendamentos Inteligentes',
|
||||
desc: 'Escolha serviço, barbeiro, data e horário com validação de slots em tempo real. Notificações automáticas.',
|
||||
color: 'from-blue-500 to-blue-600'
|
||||
},
|
||||
{
|
||||
icon: ShoppingBag,
|
||||
title: 'Carrinho Inteligente',
|
||||
color: 'from-blue-500 to-blue-600'
|
||||
},
|
||||
{
|
||||
icon: ShoppingBag,
|
||||
title: 'Carrinho Inteligente',
|
||||
desc: 'Produtos e serviços agrupados por barbearia, checkout rápido e seguro. Histórico completo de compras.',
|
||||
color: 'from-emerald-500 to-emerald-600'
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: 'Painel Completo',
|
||||
color: 'from-emerald-500 to-emerald-600'
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: 'Painel Completo',
|
||||
desc: 'Faturamento, agendamentos, pedidos e análises detalhadas. Tudo no controle da sua barbearia.',
|
||||
color: 'from-purple-500 to-purple-600'
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Gestão de Barbeiros',
|
||||
color: 'from-purple-500 to-purple-600'
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Gestão de Barbeiros',
|
||||
desc: 'Gerencie horários, especialidades e disponibilidade de cada barbeiro. Calendário integrado.',
|
||||
color: 'from-indigo-500 to-indigo-600'
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: 'Horários Flexíveis',
|
||||
{
|
||||
icon: Clock,
|
||||
title: 'Horários Flexíveis',
|
||||
desc: 'Configure horários de funcionamento, intervalos e disponibilidade. Sistema automático de bloqueio.',
|
||||
color: 'from-orange-500 to-orange-600'
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Seguro e Confiável',
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Seguro e Confiável',
|
||||
desc: 'Dados protegidos, pagamentos seguros e backup automático. Conformidade com LGPD.',
|
||||
color: 'from-rose-500 to-rose-600'
|
||||
},
|
||||
].map((feature) => (
|
||||
<Card key={feature.title} hover className="p-6 space-y-4 group">
|
||||
<div className={`inline-flex p-3 rounded-xl bg-gradient-to-br ${feature.color} text-white shadow-lg group-hover:scale-110 transition-transform duration-200`}>
|
||||
<feature.icon size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-2">{feature.title}</h3>
|
||||
<p className="text-sm text-slate-600 leading-relaxed">{feature.desc}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
].map((feature) => (
|
||||
<Card key={feature.title} hover className="p-6 space-y-4 group">
|
||||
<div className={`inline-flex p-3 rounded-xl bg-gradient-to-br ${feature.color} text-white shadow-lg group-hover:scale-110 transition-transform duration-200`}>
|
||||
<feature.icon size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-slate-900 mb-2">{feature.title}</h3>
|
||||
<p className="text-sm text-slate-600 leading-relaxed">{feature.desc}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -151,7 +157,7 @@ export default function Landing() {
|
||||
Simples, rápido e eficiente em 3 passos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8 max-w-4xl mx-auto">
|
||||
{[
|
||||
{ step: '1', title: 'Explore', desc: 'Navegue pelas barbearias disponíveis, veja avaliações e serviços oferecidos.' },
|
||||
@@ -187,13 +193,13 @@ export default function Landing() {
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{featuredShops.map((shop) => (
|
||||
<ShopCard key={shop.id} shop={shop} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="text-center mt-8">
|
||||
<Button asChild size="lg">
|
||||
<Link to="/explorar">Ver todas as barbearias</Link>
|
||||
@@ -211,7 +217,7 @@ export default function Landing() {
|
||||
Mobile-First
|
||||
</h3>
|
||||
<p className="text-slate-600 leading-relaxed">
|
||||
Interface otimizada para dispositivos móveis. Agende de qualquer lugar,
|
||||
Interface otimizada para dispositivos móveis. Agende de qualquer lugar,
|
||||
a qualquer hora. Experiência fluida e responsiva.
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
@@ -232,7 +238,7 @@ export default function Landing() {
|
||||
Aumente sua Receita
|
||||
</h3>
|
||||
<p className="text-slate-600 leading-relaxed">
|
||||
Ferramentas poderosas para gerenciar seu negócio. Análises detalhadas,
|
||||
Ferramentas poderosas para gerenciar seu negócio. Análises detalhadas,
|
||||
gestão de estoque e muito mais.
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
@@ -256,23 +262,23 @@ export default function Landing() {
|
||||
Depoimentos reais de quem usa a plataforma
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{[
|
||||
{
|
||||
name: 'João Silva',
|
||||
{
|
||||
name: 'João Silva',
|
||||
role: 'Cliente',
|
||||
text: 'Facilita muito agendar meu corte. Interface simples e rápida. Recomendo!',
|
||||
rating: 5
|
||||
},
|
||||
{
|
||||
name: 'Carlos Mendes',
|
||||
{
|
||||
name: 'Carlos Mendes',
|
||||
role: 'Proprietário',
|
||||
text: 'O painel é completo e me ajuda muito na gestão. Aumentou minha organização.',
|
||||
rating: 5
|
||||
},
|
||||
{
|
||||
name: 'Miguel Santos',
|
||||
{
|
||||
name: 'Miguel Santos',
|
||||
role: 'Cliente',
|
||||
text: 'Nunca mais perco horário. As notificações são muito úteis.',
|
||||
rating: 5
|
||||
@@ -299,13 +305,13 @@ export default function Landing() {
|
||||
<section className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white px-6 py-16 md:px-12 md:py-20 shadow-2xl">
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-indigo-500/10 rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-0 left-0 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl"></div>
|
||||
|
||||
|
||||
<div className="relative text-center space-y-8 max-w-3xl mx-auto">
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-balance">
|
||||
Pronto para começar?
|
||||
</h2>
|
||||
<p className="text-xl text-slate-300 max-w-2xl mx-auto">
|
||||
Junte-se a centenas de barbearias que já estão usando a Smart Agenda
|
||||
Junte-se a centenas de barbearias que já estão usando a Smart Agenda
|
||||
para revolucionar seus negócios.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4 pt-4">
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
// src/pages/Profile.tsx
|
||||
/**
|
||||
* @file Profile.tsx
|
||||
* @description Página do Perfil do utilizador web. Carrega a sessão atual a partir do Supabase,
|
||||
* interceta eventos customizados, agendamentos associados ao `customerId` e pedidos de produtos (Orders).
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Card } from '../components/ui/card'
|
||||
@@ -38,23 +42,28 @@ export default function Profile() {
|
||||
const [loadingEvents, setLoadingEvents] = useState(true)
|
||||
const [eventsError, setEventsError] = useState('')
|
||||
|
||||
/**
|
||||
* Obtém de forma segura o objeto de "getUser" fornecido pelo Supabase Auth.
|
||||
* Não despoleta "flash" de erros se o utilizador não tiver credenciais, mas
|
||||
* em caso positivo armazena Auth Id e Email globalmente na View.
|
||||
*/
|
||||
useEffect(() => {
|
||||
let mounted = true
|
||||
|
||||
;(async () => {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (!mounted) return
|
||||
; (async () => {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (!mounted) return
|
||||
|
||||
if (error || !data.user) {
|
||||
setAuthEmail('')
|
||||
setAuthId('')
|
||||
} else {
|
||||
setAuthEmail(data.user.email ?? '')
|
||||
setAuthId(data.user.id)
|
||||
}
|
||||
if (error || !data.user) {
|
||||
setAuthEmail('')
|
||||
setAuthId('')
|
||||
} else {
|
||||
setAuthEmail(data.user.email ?? '')
|
||||
setAuthId(data.user.id)
|
||||
}
|
||||
|
||||
setLoadingAuth(false)
|
||||
})()
|
||||
setLoadingAuth(false)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
mounted = false
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* @file ShopDetails.tsx
|
||||
* @description Vista de Detalhes de uma Barbearia na Web.
|
||||
* Apresenta informações do estabelecimento (foto, avaliação, mapa),
|
||||
* listagens de Serviços e Produtos disponíveis em Tabs.
|
||||
*/
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Tabs } from '../components/ui/tabs';
|
||||
|
||||
Reference in New Issue
Block a user