eventscreate
This commit is contained in:
@@ -6,7 +6,7 @@ import { supabase } from '../../lib/supabase'
|
||||
import { signOut } from '../../lib/auth'
|
||||
|
||||
export const Header = () => {
|
||||
const { cart } = useApp()
|
||||
const { cart, user } = useApp()
|
||||
const navigate = useNavigate()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
@@ -51,28 +51,41 @@ export const Header = () => {
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center gap-4">
|
||||
<Link
|
||||
to="/explorar"
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-slate-700 hover:text-indigo-600 transition-colors px-3 py-1.5 rounded-lg hover:bg-indigo-50"
|
||||
>
|
||||
<MapPin size={16} />
|
||||
<span>Barbearias</span>
|
||||
</Link>
|
||||
{user?.role !== 'barbearia' && (
|
||||
<>
|
||||
<Link
|
||||
to="/explorar"
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-slate-700 hover:text-indigo-600 transition-colors px-3 py-1.5 rounded-lg hover:bg-indigo-50"
|
||||
>
|
||||
<MapPin size={16} />
|
||||
<span>Barbearias</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/carrinho"
|
||||
className="relative text-slate-700 hover:text-indigo-600 transition-colors p-2 rounded-lg hover:bg-indigo-50"
|
||||
>
|
||||
<ShoppingCart size={18} />
|
||||
{cart.length > 0 && (
|
||||
<span className="absolute -right-1 -top-1 rounded-full bg-gradient-to-r from-indigo-500 to-blue-600 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-sm min-w-[18px] text-center">
|
||||
{cart.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
to="/carrinho"
|
||||
className="relative text-slate-700 hover:text-indigo-600 transition-colors p-2 rounded-lg hover:bg-indigo-50"
|
||||
>
|
||||
<ShoppingCart size={18} />
|
||||
{cart.length > 0 && (
|
||||
<span className="absolute -right-1 -top-1 rounded-full bg-gradient-to-r from-indigo-500 to-blue-600 px-1.5 py-0.5 text-[10px] font-bold text-white shadow-sm min-w-[18px] text-center">
|
||||
{cart.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAuthed ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{user?.role === 'barbearia' && (
|
||||
<button
|
||||
onClick={() => navigate('/painel')}
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-slate-700 hover:text-indigo-600 transition-colors px-3 py-1.5 rounded-lg hover:bg-indigo-50"
|
||||
type="button"
|
||||
>
|
||||
<span>Painel</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate('/perfil')}
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-slate-700 hover:text-indigo-600 transition-colors px-3 py-1.5 rounded-lg hover:bg-indigo-50"
|
||||
@@ -115,31 +128,47 @@ export const Header = () => {
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-slate-200/60 bg-white/95 backdrop-blur-md animate-in slide-in-from-top">
|
||||
<nav className="px-4 py-3 space-y-2">
|
||||
<Link
|
||||
to="/explorar"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-2 text-sm font-medium text-slate-700 hover:text-amber-600 transition-colors px-3 py-2 rounded-lg hover:bg-amber-50"
|
||||
>
|
||||
<MapPin size={16} />
|
||||
Barbearias
|
||||
</Link>
|
||||
{user?.role !== 'barbearia' && (
|
||||
<>
|
||||
<Link
|
||||
to="/explorar"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-2 text-sm font-medium text-slate-700 hover:text-amber-600 transition-colors px-3 py-2 rounded-lg hover:bg-amber-50"
|
||||
>
|
||||
<MapPin size={16} />
|
||||
Barbearias
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/carrinho"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-2 text-sm font-medium text-slate-700 hover:text-amber-600 transition-colors px-3 py-2 rounded-lg hover:bg-amber-50"
|
||||
>
|
||||
<ShoppingCart size={16} />
|
||||
Carrinho
|
||||
{cart.length > 0 && (
|
||||
<span className="ml-auto rounded-full bg-amber-500 px-2 py-0.5 text-[10px] font-bold text-white">
|
||||
{cart.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
<Link
|
||||
to="/carrinho"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="flex items-center gap-2 text-sm font-medium text-slate-700 hover:text-amber-600 transition-colors px-3 py-2 rounded-lg hover:bg-amber-50"
|
||||
>
|
||||
<ShoppingCart size={16} />
|
||||
Carrinho
|
||||
{cart.length > 0 && (
|
||||
<span className="ml-auto rounded-full bg-amber-500 px-2 py-0.5 text-[10px] font-bold text-white">
|
||||
{cart.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAuthed ? (
|
||||
<>
|
||||
{user?.role === 'barbearia' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/painel')
|
||||
setMobileMenuOpen(false)
|
||||
}}
|
||||
className="w-full flex items-center gap-2 text-sm font-medium text-slate-700 hover:text-amber-600 transition-colors px-3 py-2 rounded-lg hover:bg-amber-50 text-left"
|
||||
type="button"
|
||||
>
|
||||
Painel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate('/perfil')
|
||||
|
||||
@@ -3,6 +3,7 @@ import { nanoid } from 'nanoid';
|
||||
import { Appointment, Barber, BarberShop, CartItem, Order, Product, Service, User } from '../types';
|
||||
import { mockShops, mockUsers } from '../data/mock';
|
||||
import { storage } from '../lib/storage';
|
||||
import { supabase } from '../lib/supabase';
|
||||
|
||||
type State = {
|
||||
user?: User;
|
||||
@@ -64,6 +65,98 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
storage.set('smart-agenda', state);
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const clearUser = () => {
|
||||
setState((s) => ({ ...s, user: undefined }));
|
||||
};
|
||||
|
||||
const applyProfile = async (userId: string, email?: string | null) => {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('id,name,role,shop_name')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
if (!mounted) return;
|
||||
if (error || !data) {
|
||||
clearUser();
|
||||
return;
|
||||
}
|
||||
|
||||
const role = data.role === 'barbearia' ? 'barbearia' : 'cliente';
|
||||
const displayName = data.name?.trim() || (email ? email.split('@')[0] : 'Utilizador');
|
||||
const shopId = role === 'barbearia' ? userId : undefined;
|
||||
|
||||
setState((s) => {
|
||||
const nextUser: User = {
|
||||
id: userId,
|
||||
name: displayName,
|
||||
email: email ?? '',
|
||||
password: '',
|
||||
role,
|
||||
shopId,
|
||||
};
|
||||
|
||||
const users = s.users.some((u) => u.id === userId)
|
||||
? s.users.map((u) => (u.id === userId ? { ...u, ...nextUser } : u))
|
||||
: [...s.users, nextUser];
|
||||
|
||||
let shops = s.shops;
|
||||
if (role === 'barbearia' && shopId) {
|
||||
const exists = shops.some((shop) => shop.id === shopId);
|
||||
if (!exists) {
|
||||
const shopName = data.shop_name?.trim() || `Barbearia ${displayName}`;
|
||||
const shop: BarberShop = {
|
||||
id: shopId,
|
||||
name: shopName,
|
||||
address: 'Endereço a definir',
|
||||
rating: 0,
|
||||
barbers: [],
|
||||
services: [],
|
||||
products: [],
|
||||
};
|
||||
shops = [...shops, shop];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...s,
|
||||
user: nextUser,
|
||||
users,
|
||||
shops,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const boot = async () => {
|
||||
const { data } = await supabase.auth.getSession();
|
||||
if (!mounted) return;
|
||||
const session = data.session;
|
||||
if (session?.user) {
|
||||
await applyProfile(session.user.id, session.user.email);
|
||||
} else {
|
||||
clearUser();
|
||||
}
|
||||
};
|
||||
|
||||
void boot();
|
||||
|
||||
const { data: sub } = supabase.auth.onAuthStateChange((_event, session) => {
|
||||
if (session?.user) {
|
||||
void applyProfile(session.user.id, session.user.email);
|
||||
} else {
|
||||
clearUser();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
sub.subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = (email: string, password: string) => {
|
||||
const found = state.users.find((u) => u.email === email && u.password === password);
|
||||
if (found) {
|
||||
|
||||
@@ -9,18 +9,42 @@ export type EventRow = {
|
||||
user_id: string
|
||||
}
|
||||
|
||||
// LISTAR eventos do utilizador logado
|
||||
// LISTAR eventos (RLS deve garantir que só vês os teus)
|
||||
export async function listEvents() {
|
||||
const user = await getUser()
|
||||
if (!user) {
|
||||
throw new Error('Utilizador não autenticado')
|
||||
}
|
||||
if (!user) throw new Error('Utilizador não autenticado')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('events')
|
||||
.select('*')
|
||||
.select('id,title,description,date,user_id')
|
||||
.order('date', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return (data ?? []) as EventRow[]
|
||||
}
|
||||
|
||||
// CRIAR evento
|
||||
export async function createEvent(input: {
|
||||
title: string
|
||||
description?: string
|
||||
date: string
|
||||
}) {
|
||||
const user = await getUser()
|
||||
if (!user) throw new Error('Utilizador não autenticado')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('events')
|
||||
.insert({
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
date: input.date,
|
||||
// user_id:
|
||||
// - Se a tua coluna user_id tem DEFAULT auth.uid(), NÃO metas aqui.
|
||||
// - Se não tem default, então mete: user_id: user.id
|
||||
})
|
||||
.select('id,title,description,date,user_id')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data as EventRow
|
||||
}
|
||||
|
||||
74
web/src/pages/EventsCreate.tsx
Normal file
74
web/src/pages/EventsCreate.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { FormEvent, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card } from '../components/ui/card'
|
||||
import { Input } from '../components/ui/input'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { createEvent } from '../lib/events'
|
||||
|
||||
export default function EventsCreate() {
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [date, setDate] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function onSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!title.trim()) return setError('Título obrigatório')
|
||||
if (!date) return setError('Data obrigatória')
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
await createEvent({ title, description, date })
|
||||
navigate('/perfil', { replace: true })
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? 'Erro ao guardar evento')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto py-8">
|
||||
<Card className="p-6 space-y-4">
|
||||
<h1 className="text-xl font-bold text-slate-900">Criar evento</h1>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<Input
|
||||
label="Título"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Ex: Corte às 15h"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Descrição"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Opcional"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Data"
|
||||
type="datetime-local"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
|
||||
<Button className="w-full" disabled={loading}>
|
||||
{loading ? 'A guardar...' : 'Guardar'}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { Card } from '../components/ui/card'
|
||||
import { Badge } from '../components/ui/badge'
|
||||
import { currency } from '../lib/format'
|
||||
@@ -23,6 +24,7 @@ const statusLabel: Record<string, string> = {
|
||||
|
||||
export default function Profile() {
|
||||
const { appointments, orders, shops } = useApp()
|
||||
const location = useLocation()
|
||||
|
||||
// ✅ Utilizador Supabase
|
||||
const [authEmail, setAuthEmail] = useState<string>('')
|
||||
@@ -56,22 +58,33 @@ export default function Profile() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function loadEvents() {
|
||||
try {
|
||||
setLoadingEvents(true)
|
||||
setEventsError('')
|
||||
const data = await listEvents()
|
||||
setEvents(data)
|
||||
} catch (e: any) {
|
||||
setEventsError(e?.message || 'Erro ao carregar eventos')
|
||||
} finally {
|
||||
setLoadingEvents(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Carrega eventos ao entrar no perfil
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
try {
|
||||
setEventsError('')
|
||||
const data = await listEvents()
|
||||
setEvents(data)
|
||||
} catch (e: any) {
|
||||
setEventsError(e?.message || 'Erro ao carregar eventos')
|
||||
} finally {
|
||||
setLoadingEvents(false)
|
||||
}
|
||||
})()
|
||||
loadEvents()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Se quiseres filtrar por utilizador Supabase, fica assim.
|
||||
// (Só vai funcionar quando os teus appointments/orders tiverem customerId = authId)
|
||||
// ✅ Recarrega eventos sempre que muda a rota (ex.: voltas de /eventos/novo para /perfil)
|
||||
useEffect(() => {
|
||||
if (location.pathname === '/perfil') {
|
||||
loadEvents()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname])
|
||||
|
||||
const myAppointments = useMemo(() => {
|
||||
if (!authId) return []
|
||||
return appointments.filter((a) => a.customerId === authId)
|
||||
@@ -82,10 +95,6 @@ export default function Profile() {
|
||||
return orders.filter((o) => o.customerId === authId)
|
||||
}, [orders, authId])
|
||||
|
||||
// Para já, se ainda não tens customerId igual ao authId, podes mostrar tudo:
|
||||
// const myAppointments = appointments
|
||||
// const myOrders = orders
|
||||
|
||||
if (loadingAuth) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
@@ -94,7 +103,6 @@ export default function Profile() {
|
||||
)
|
||||
}
|
||||
|
||||
// Se não houver user Supabase aqui, em teoria o RequireAuth já devia redirecionar.
|
||||
if (!authId) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
@@ -128,9 +136,17 @@ export default function Profile() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={20} className="text-amber-600" />
|
||||
<h2 className="text-xl font-bold text-slate-900">Eventos (Supabase)</h2>
|
||||
|
||||
<Badge color="slate" variant="soft">
|
||||
{loadingEvents ? '…' : events.length}
|
||||
</Badge>
|
||||
|
||||
<Link
|
||||
to="/eventos/novo"
|
||||
className="ml-auto text-sm font-semibold text-amber-700 hover:text-amber-800 transition-colors"
|
||||
>
|
||||
+ Criar evento
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loadingEvents ? (
|
||||
@@ -154,12 +170,8 @@ export default function Profile() {
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<h3 className="font-bold text-slate-900">{ev.title}</h3>
|
||||
{ev.description && (
|
||||
<p className="text-sm text-slate-600">{ev.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-500">
|
||||
{new Date(ev.date).toLocaleString()}
|
||||
</p>
|
||||
{ev.description && <p className="text-sm text-slate-600">{ev.description}</p>}
|
||||
<p className="text-xs text-slate-500">{new Date(ev.date).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -173,7 +185,9 @@ export default function Profile() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={20} className="text-amber-600" />
|
||||
<h2 className="text-xl font-bold text-slate-900">Agendamentos</h2>
|
||||
<Badge color="slate" variant="soft">{myAppointments.length}</Badge>
|
||||
<Badge color="slate" variant="soft">
|
||||
{myAppointments.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{!myAppointments.length ? (
|
||||
@@ -221,7 +235,9 @@ export default function Profile() {
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingBag size={20} className="text-amber-600" />
|
||||
<h2 className="text-xl font-bold text-slate-900">Pedidos</h2>
|
||||
<Badge color="slate" variant="soft">{myOrders.length}</Badge>
|
||||
<Badge color="slate" variant="soft">
|
||||
{myOrders.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{!myOrders.length ? (
|
||||
|
||||
@@ -11,6 +11,7 @@ import Booking from './pages/Booking'
|
||||
import Cart from './pages/Cart'
|
||||
import Profile from './pages/Profile'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import EventsCreate from './pages/EventsCreate'
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -55,6 +56,14 @@ export const router = createBrowserRouter([
|
||||
</RequireAuth>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/eventos/novo',
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<EventsCreate />
|
||||
</RequireAuth>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user