-
+
+
+
Smart Agenda
-
-
+
+ {/* Desktop Navigation */}
+
+
+
+ {/* Mobile Menu Button */}
+
setMobileMenuOpen(!mobileMenuOpen)}
+ className="md:hidden p-2 text-slate-700 hover:text-amber-600 hover:bg-amber-50 rounded-lg transition-colors"
+ >
+ {mobileMenuOpen ? : }
+
+
+ {/* Mobile Menu */}
+ {mobileMenuOpen && (
+
+
+
+ )}
);
};
+
+
diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx
index f35e63f..ae346be 100644
--- a/web/src/components/ui/badge.tsx
+++ b/web/src/components/ui/badge.tsx
@@ -1,17 +1,31 @@
import { cn } from '../../lib/cn';
-type Props = { children: React.ReactNode; color?: 'amber' | 'slate' | 'green' | 'red'; className?: string };
+type Props = { children: React.ReactNode; color?: 'amber' | 'slate' | 'green' | 'red' | 'blue'; className?: string; variant?: 'solid' | 'soft' };
const colorMap = {
- amber: 'bg-amber-100 text-amber-700 border border-amber-200',
- slate: 'bg-slate-100 text-slate-700 border border-slate-200',
- green: 'bg-emerald-100 text-emerald-700 border border-emerald-200',
- red: 'bg-rose-100 text-rose-700 border border-rose-200',
+ solid: {
+ amber: 'bg-amber-500 text-white',
+ slate: 'bg-slate-600 text-white',
+ green: 'bg-emerald-500 text-white',
+ red: 'bg-rose-500 text-white',
+ blue: 'bg-blue-500 text-white',
+ },
+ soft: {
+ amber: 'bg-amber-50 text-amber-700 border border-amber-200/60',
+ slate: 'bg-slate-50 text-slate-700 border border-slate-200/60',
+ green: 'bg-emerald-50 text-emerald-700 border border-emerald-200/60',
+ red: 'bg-rose-50 text-rose-700 border border-rose-200/60',
+ blue: 'bg-blue-50 text-blue-700 border border-blue-200/60',
+ },
};
-export const Badge = ({ children, color = 'amber', className }: Props) => (
-
{children}
+export const Badge = ({ children, color = 'amber', variant = 'soft', className }: Props) => (
+
+ {children}
+
);
+
+
diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx
index 57dc09f..825868f 100644
--- a/web/src/components/ui/button.tsx
+++ b/web/src/components/ui/button.tsx
@@ -2,18 +2,25 @@ import { cn } from '../../lib/cn';
import React from 'react';
type Props = React.ButtonHTMLAttributes
& {
- variant?: 'solid' | 'outline' | 'ghost';
+ variant?: 'solid' | 'outline' | 'ghost' | 'danger';
+ size?: 'sm' | 'md' | 'lg';
asChild?: boolean;
};
-export const Button = ({ className, variant = 'solid', asChild, ...props }: Props) => {
- const base = 'rounded-md px-4 py-2 text-sm font-semibold transition focus:outline-none focus:ring-2 focus:ring-amber-500/40';
+export const Button = ({ className, variant = 'solid', size = 'md', asChild, ...props }: Props) => {
+ const base = 'inline-flex items-center justify-center font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
const variants = {
- solid: 'bg-amber-500 text-white hover:bg-amber-600',
- outline: 'border border-amber-500 text-amber-700 hover:bg-amber-50',
- ghost: 'text-amber-700 hover:bg-amber-50',
+ solid: 'bg-gradient-to-r from-indigo-500 to-blue-600 text-white hover:from-indigo-600 hover:to-blue-700 shadow-md hover:shadow-lg focus:ring-indigo-500/50 active:scale-[0.98]',
+ outline: 'border-2 border-indigo-500 text-indigo-700 bg-white hover:bg-indigo-50 hover:border-indigo-600 focus:ring-indigo-500/50 active:scale-[0.98]',
+ ghost: 'text-indigo-700 hover:bg-indigo-50 focus:ring-indigo-500/50 active:scale-[0.98]',
+ danger: 'bg-gradient-to-r from-rose-500 to-rose-600 text-white hover:from-rose-600 hover:to-rose-700 shadow-md hover:shadow-lg focus:ring-rose-500/50 active:scale-[0.98]',
};
- const cls = cn(base, variants[variant], className);
+ const sizes = {
+ sm: 'text-xs px-3 py-1.5 rounded-lg',
+ md: 'text-sm px-4 py-2.5 rounded-lg',
+ lg: 'text-base px-6 py-3 rounded-xl',
+ };
+ const cls = cn(base, variants[variant], sizes[size], className);
if (asChild && React.isValidElement(props.children)) {
return React.cloneElement(props.children, {
...props,
diff --git a/web/src/components/ui/card.tsx b/web/src/components/ui/card.tsx
index f543686..deda884 100644
--- a/web/src/components/ui/card.tsx
+++ b/web/src/components/ui/card.tsx
@@ -1,8 +1,18 @@
import { cn } from '../../lib/cn';
-export const Card = ({ children, className = '' }: { children: React.ReactNode; className?: string }) => (
- {children}
+export const Card = ({ children, className = '', hover = false }: { children: React.ReactNode; className?: string; hover?: boolean }) => (
+
+ {children}
+
);
+
+
diff --git a/web/src/components/ui/input.tsx b/web/src/components/ui/input.tsx
index ffc2745..631a405 100644
--- a/web/src/components/ui/input.tsx
+++ b/web/src/components/ui/input.tsx
@@ -1,17 +1,40 @@
import { cn } from '../../lib/cn';
import React from 'react';
-type Props = React.InputHTMLAttributes;
+type Props = React.InputHTMLAttributes & {
+ label?: string;
+ error?: string;
+};
+
+export const Input = ({ className, label, error, ...props }: Props) => {
+ const input = (
+
+ );
+
+ if (label || error) {
+ return (
+
+ {label &&
}
+ {input}
+ {error &&
{error}
}
+
+ );
+ }
+
+ return input;
+};
+
-export const Input = ({ className, ...props }: Props) => (
-
-);
diff --git a/web/src/components/ui/tabs.tsx b/web/src/components/ui/tabs.tsx
index 7c0f726..e3815d8 100644
--- a/web/src/components/ui/tabs.tsx
+++ b/web/src/components/ui/tabs.tsx
@@ -1,13 +1,15 @@
type Tab = { id: string; label: string };
export const Tabs = ({ tabs, active, onChange }: { tabs: Tab[]; active: string; onChange: (id: string) => void }) => (
-
+
{tabs.map((t) => (
onChange(t.id)}
- className={`px-3 py-2 text-sm font-semibold ${
- active === t.id ? 'text-amber-600 border-b-2 border-amber-500' : 'text-slate-600'
+ className={`px-4 py-3 text-sm font-semibold transition-all whitespace-nowrap ${
+ active === t.id
+ ? 'text-amber-600 border-b-2 border-amber-500 bg-amber-50/50'
+ : 'text-slate-600 hover:text-amber-600 hover:bg-amber-50/30'
}`}
>
{t.label}
@@ -18,3 +20,5 @@ export const Tabs = ({ tabs, active, onChange }: { tabs: Tab[]; active: string;
+
+
diff --git a/web/src/context/AppContext.tsx b/web/src/context/AppContext.tsx
new file mode 100644
index 0000000..bb5a823
--- /dev/null
+++ b/web/src/context/AppContext.tsx
@@ -0,0 +1,302 @@
+import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
+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';
+
+type State = {
+ user?: User;
+ users: User[];
+ shops: BarberShop[];
+ appointments: Appointment[];
+ orders: Order[];
+ cart: CartItem[];
+};
+
+type AppContextValue = State & {
+ login: (email: string, password: string) => boolean;
+ logout: () => void;
+ register: (payload: Omit & { shopName?: string }) => boolean;
+ addToCart: (item: CartItem) => void;
+ removeFromCart: (refId: string) => void;
+ clearCart: () => void;
+ createAppointment: (input: Omit) => Appointment | null;
+ placeOrder: (customerId: string, shopId?: string) => Order | null;
+ updateAppointmentStatus: (id: string, status: Appointment['status']) => void;
+ updateOrderStatus: (id: string, status: Order['status']) => void;
+ addService: (shopId: string, service: Omit) => void;
+ updateService: (shopId: string, service: Service) => void;
+ deleteService: (shopId: string, serviceId: string) => void;
+ addProduct: (shopId: string, product: Omit) => void;
+ updateProduct: (shopId: string, product: Product) => void;
+ deleteProduct: (shopId: string, productId: string) => void;
+ addBarber: (shopId: string, barber: Omit) => void;
+ updateBarber: (shopId: string, barber: Barber) => void;
+ deleteBarber: (shopId: string, barberId: string) => void;
+};
+
+const initialState: State = {
+ user: undefined,
+ users: mockUsers,
+ shops: mockShops,
+ appointments: [],
+ orders: [],
+ cart: [],
+};
+
+const AppContext = createContext(undefined);
+
+export const AppProvider = ({ children }: { children: React.ReactNode }) => {
+ const [state, setState] = useState(() => storage.get('smart-agenda', initialState));
+
+ useEffect(() => {
+ storage.set('smart-agenda', state);
+ }, [state]);
+
+ const login = (email: string, password: string) => {
+ const found = state.users.find((u) => u.email === email && u.password === password);
+ if (found) {
+ setState((s) => ({ ...s, user: found }));
+ return true;
+ }
+ return false;
+ };
+
+ const logout = () => setState((s) => ({ ...s, user: undefined }));
+
+ const register: AppContextValue['register'] = ({ shopName, ...payload }) => {
+ const exists = state.users.some((u) => u.email === payload.email);
+ if (exists) return false;
+
+ if (payload.role === 'barbearia') {
+ const shopId = nanoid();
+ const shop: BarberShop = {
+ id: shopId,
+ name: shopName || `Barbearia ${payload.name}`,
+ address: 'Endereço a definir',
+ rating: 0,
+ barbers: [],
+ services: [],
+ products: [],
+ };
+ const user: User = { ...payload, id: nanoid(), role: 'barbearia', shopId };
+ setState((s) => ({
+ ...s,
+ user,
+ users: [...s.users, user],
+ shops: [...s.shops, shop],
+ }));
+ return true;
+ }
+
+ const user: User = { ...payload, id: nanoid(), role: 'cliente' };
+ setState((s) => ({
+ ...s,
+ user,
+ users: [...s.users, user],
+ }));
+ return true;
+ };
+
+ const addToCart: AppContextValue['addToCart'] = (item) => {
+ setState((s) => {
+ const cart = [...s.cart];
+ const idx = cart.findIndex((c) => c.refId === item.refId && c.type === item.type && c.shopId === item.shopId);
+ if (idx >= 0) cart[idx].qty += item.qty;
+ else cart.push(item);
+ return { ...s, cart };
+ });
+ };
+
+ const removeFromCart: AppContextValue['removeFromCart'] = (refId) => {
+ setState((s) => ({ ...s, cart: s.cart.filter((c) => c.refId !== refId) }));
+ };
+
+ const clearCart = () => setState((s) => ({ ...s, cart: [] }));
+
+ const createAppointment: AppContextValue['createAppointment'] = (input) => {
+ const shop = state.shops.find((s) => s.id === input.shopId);
+ if (!shop) return null;
+ const svc = shop.services.find((s) => s.id === input.serviceId);
+ if (!svc) return null;
+
+ const exists = state.appointments.find(
+ (ap) => ap.barberId === input.barberId && ap.date === input.date && ap.status !== 'cancelado'
+ );
+ if (exists) return null;
+
+ const appointment: Appointment = {
+ ...input,
+ id: nanoid(),
+ status: 'pendente',
+ total: svc.price,
+ };
+
+ setState((s) => ({ ...s, appointments: [...s.appointments, appointment] }));
+ return appointment;
+ };
+
+ const placeOrder: AppContextValue['placeOrder'] = (customerId, onlyShopId) => {
+ if (!state.cart.length) return null;
+ const grouped = state.cart.reduce>((acc, item) => {
+ acc[item.shopId] = acc[item.shopId] || [];
+ acc[item.shopId].push(item);
+ return acc;
+ }, {});
+
+ const entries = Object.entries(grouped).filter(([shopId]) => (onlyShopId ? shopId === onlyShopId : true));
+
+ const newOrders: Order[] = entries.map(([shopId, items]) => {
+ const total = items.reduce((sum, item) => {
+ const shop = state.shops.find((s) => s.id === item.shopId);
+ if (!shop) return sum;
+ const price =
+ item.type === 'service'
+ ? shop.services.find((s) => s.id === item.refId)?.price ?? 0
+ : shop.products.find((p) => p.id === item.refId)?.price ?? 0;
+ return sum + price * item.qty;
+ }, 0);
+
+ return {
+ id: nanoid(),
+ shopId,
+ customerId,
+ items,
+ total,
+ status: 'pendente',
+ createdAt: new Date().toISOString(),
+ };
+ });
+
+ setState((s) => ({ ...s, orders: [...s.orders, ...newOrders], cart: [] }));
+ return newOrders[0] ?? null;
+ };
+
+ const updateAppointmentStatus: AppContextValue['updateAppointmentStatus'] = (id, status) => {
+ setState((s) => ({
+ ...s,
+ appointments: s.appointments.map((a) => (a.id === id ? { ...a, status } : a)),
+ }));
+ };
+
+ const updateOrderStatus: AppContextValue['updateOrderStatus'] = (id, status) => {
+ setState((s) => ({
+ ...s,
+ orders: s.orders.map((o) => (o.id === id ? { ...o, status } : o)),
+ }));
+ };
+
+ const addService: AppContextValue['addService'] = (shopId, service) => {
+ const entry: Service = { ...service, id: nanoid() };
+ setState((s) => ({
+ ...s,
+ shops: s.shops.map((shop) => (shop.id === shopId ? { ...shop, services: [...shop.services, entry] } : shop)),
+ }));
+ };
+
+ const updateService: AppContextValue['updateService'] = (shopId, service) => {
+ setState((s) => ({
+ ...s,
+ shops: s.shops.map((shop) =>
+ shop.id === shopId ? { ...shop, services: shop.services.map((sv) => (sv.id === service.id ? service : sv)) } : shop
+ ),
+ }));
+ };
+
+ const deleteService: AppContextValue['deleteService'] = (shopId, serviceId) => {
+ setState((s) => ({
+ ...s,
+ shops: s.shops.map((shop) =>
+ shop.id === shopId ? { ...shop, services: shop.services.filter((sv) => sv.id !== serviceId) } : shop
+ ),
+ }));
+ };
+
+ const addProduct: AppContextValue['addProduct'] = (shopId, product) => {
+ const entry: Product = { ...product, id: nanoid() };
+ setState((s) => ({
+ ...s,
+ shops: s.shops.map((shop) => (shop.id === shopId ? { ...shop, products: [...shop.products, entry] } : shop)),
+ }));
+ };
+
+ const updateProduct: AppContextValue['updateProduct'] = (shopId, product) => {
+ setState((s) => ({
+ ...s,
+ shops: s.shops.map((shop) =>
+ shop.id === shopId ? { ...shop, products: shop.products.map((p) => (p.id === product.id ? product : p)) } : shop
+ ),
+ }));
+ };
+
+ const deleteProduct: AppContextValue['deleteProduct'] = (shopId, productId) => {
+ setState((s) => ({
+ ...s,
+ shops: s.shops.map((shop) =>
+ shop.id === shopId ? { ...shop, products: shop.products.filter((p) => p.id !== productId) } : shop
+ ),
+ }));
+ };
+
+ const addBarber: AppContextValue['addBarber'] = (shopId, barber) => {
+ const entry: Barber = { ...barber, id: nanoid() };
+ setState((s) => ({
+ ...s,
+ shops: s.shops.map((shop) => (shop.id === shopId ? { ...shop, barbers: [...shop.barbers, entry] } : shop)),
+ }));
+ };
+
+ const updateBarber: AppContextValue['updateBarber'] = (shopId, barber) => {
+ setState((s) => ({
+ ...s,
+ shops: s.shops.map((shop) =>
+ shop.id === shopId ? { ...shop, barbers: shop.barbers.map((b) => (b.id === barber.id ? barber : b)) } : shop
+ ),
+ }));
+ };
+
+ const deleteBarber: AppContextValue['deleteBarber'] = (shopId, barberId) => {
+ setState((s) => ({
+ ...s,
+ shops: s.shops.map((shop) =>
+ shop.id === shopId ? { ...shop, barbers: shop.barbers.filter((b) => b.id !== barberId) } : shop
+ ),
+ }));
+ };
+
+ const value: AppContextValue = useMemo(
+ () => ({
+ ...state,
+ login,
+ logout,
+ register,
+ addToCart,
+ removeFromCart,
+ clearCart,
+ createAppointment,
+ placeOrder,
+ updateAppointmentStatus,
+ updateOrderStatus,
+ addService,
+ updateService,
+ deleteService,
+ addProduct,
+ updateProduct,
+ deleteProduct,
+ addBarber,
+ updateBarber,
+ deleteBarber,
+ }),
+ [state]
+ );
+
+ return {children};
+};
+
+export const useApp = () => {
+ const ctx = useContext(AppContext);
+ if (!ctx) throw new Error('useApp deve ser usado dentro de AppProvider');
+ return ctx;
+};
+
+
diff --git a/web/src/index.css b/web/src/index.css
index 9f06c4e..83b5b14 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -2,17 +2,45 @@
@tailwind components;
@tailwind utilities;
-:root {
- color-scheme: light;
+@layer base {
+ :root {
+ color-scheme: light;
+ }
+
+ * {
+ @apply border-slate-200;
+ }
+
+ body {
+ @apply bg-gradient-to-br from-slate-50 via-white to-blue-50/30 text-slate-900 font-sans antialiased;
+ font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
+ }
+
+ a {
+ @apply text-inherit no-underline;
+ }
+
+ /* Scrollbar styling */
+ ::-webkit-scrollbar {
+ @apply w-2 h-2;
+ }
+
+ ::-webkit-scrollbar-track {
+ @apply bg-slate-100;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ @apply bg-slate-300 rounded-full hover:bg-slate-400;
+ }
}
-body {
- @apply bg-slate-50 text-slate-900 font-sans;
-}
-
-a {
- @apply text-inherit no-underline;
+@layer utilities {
+ .text-balance {
+ text-wrap: balance;
+ }
}
+
+
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 827e417..440c3b2 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -2,12 +2,17 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
+import { AppProvider } from './context/AppContext';
ReactDOM.createRoot(document.getElementById('root')!).render(
-
+
+
+
);
+
+
diff --git a/web/src/pages/AuthLogin.tsx b/web/src/pages/AuthLogin.tsx
index 4dd3b6c..afb020c 100644
--- a/web/src/pages/AuthLogin.tsx
+++ b/web/src/pages/AuthLogin.tsx
@@ -1,51 +1,90 @@
-import { FormEvent, useState } from 'react';
+import { FormEvent, useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Input } from '../components/ui/input';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
-import { useAppStore } from '../store/useAppStore';
+import { useApp } from '../context/AppContext';
+import { LogIn, Mail, Lock } from 'lucide-react';
export default function AuthLogin() {
const [email, setEmail] = useState('cliente@demo.com');
const [password, setPassword] = useState('123');
const [error, setError] = useState('');
- const login = useAppStore((s) => s.login);
+ const { login, user } = useApp();
const navigate = useNavigate();
+ useEffect(() => {
+ if (!user) return;
+ const target = user.role === 'barbearia' ? '/painel' : '/explorar';
+ navigate(target, { replace: true });
+ }, [user, navigate]);
+
const onSubmit = (e: FormEvent) => {
e.preventDefault();
const ok = login(email, password);
if (!ok) {
setError('Credenciais inválidas');
} else {
- navigate('/');
+ const target = user?.role === 'barbearia' ? '/painel' : '/explorar';
+ navigate(target);
}
};
return (
-
-
-
-
Entrar
-
Use o demo: cliente@demo.com / 123
+
+
+
+
+
+
+
Bem-vindo de volta
+
Entre na sua conta para continuar
-
);
@@ -53,3 +92,5 @@ export default function AuthLogin() {
+
+
diff --git a/web/src/pages/AuthRegister.tsx b/web/src/pages/AuthRegister.tsx
index d25cc0a..dabb667 100644
--- a/web/src/pages/AuthRegister.tsx
+++ b/web/src/pages/AuthRegister.tsx
@@ -1,62 +1,137 @@
-import { FormEvent, useState } from 'react';
+import { FormEvent, useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Input } from '../components/ui/input';
import { Button } from '../components/ui/button';
import { Card } from '../components/ui/card';
-import { useAppStore } from '../store/useAppStore';
+import { useApp } from '../context/AppContext';
+import { UserPlus, User, Scissors } from 'lucide-react';
export default function AuthRegister() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState<'cliente' | 'barbearia'>('cliente');
+ const [shopName, setShopName] = useState('');
const [error, setError] = useState('');
- const register = useAppStore((s) => s.register);
+ const { register, user } = useApp();
const navigate = useNavigate();
+ useEffect(() => {
+ if (!user) return;
+ const target = user.role === 'barbearia' ? '/painel' : '/explorar';
+ navigate(target, { replace: true });
+ }, [user, navigate]);
+
const onSubmit = (e: FormEvent) => {
e.preventDefault();
- const ok = register({ name, email, password, role });
+ const ok = register({ name, email, password, role, shopName });
if (!ok) setError('Email já registado');
- else navigate('/');
+ else {
+ const target = role === 'barbearia' ? '/painel' : '/explorar';
+ navigate(target);
+ }
};
return (
-
-
-
-
Criar conta
-
Escolha o tipo de acesso.
+
+
+
+
+
+
+
Criar conta
+
Escolha o tipo de acesso
-