docs: Add JSDoc and inline comments to enhance code readability and explain functionality across multiple pages and components.

This commit is contained in:
2026-02-26 10:38:13 +00:00
parent c4164e50a0
commit 5b866a161e
25 changed files with 675 additions and 174 deletions

View File

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

View File

@@ -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);

View File

@@ -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 }],
},
];

View File

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

View File

@@ -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()

View File

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

View File

@@ -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' ? (

View File

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

View File

@@ -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>
);

View File

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

View File

@@ -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('')

View File

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

View File

@@ -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 estão usando a Smart Agenda
Junte-se a centenas de barbearias que estão usando a Smart Agenda
para revolucionar seus negócios.
</p>
<div className="flex flex-wrap justify-center gap-4 pt-4">

View File

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

View File

@@ -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';