teste
This commit is contained in:
18
web/index.html
Normal file
18
web/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Smart Agenda</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
3145
web/package-lock.json
generated
Normal file
3145
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
web/package.json
Normal file
37
web/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "smart-agenda-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"classnames": "^2.5.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.473.0",
|
||||
"nanoid": "^5.0.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"recharts": "^2.12.7",
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
9
web/postcss.config.cjs
Normal file
9
web/postcss.config.cjs
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
|
||||
9
web/src/App.tsx
Normal file
9
web/src/App.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { router } from './routes';
|
||||
|
||||
const App = () => <RouterProvider router={router} />;
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
|
||||
76
web/src/components/CartPanel.tsx
Normal file
76
web/src/components/CartPanel.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { currency } from '../lib/format';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export const CartPanel = () => {
|
||||
const { cart, shops, removeFromCart, placeOrder, user } = useAppStore();
|
||||
if (!cart.length) return <Card className="p-4">Carrinho vazio</Card>;
|
||||
|
||||
const grouped = cart.reduce<Record<string, typeof cart>>((acc, item) => {
|
||||
acc[item.shopId] = acc[item.shopId] || [];
|
||||
acc[item.shopId].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const handleCheckout = (shopId: string) => {
|
||||
if (!user) return;
|
||||
placeOrder(user.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(grouped).map(([shopId, items]) => {
|
||||
const shop = shops.find((s) => s.id === shopId);
|
||||
const total = items.reduce((sum, i) => {
|
||||
const price =
|
||||
i.type === 'service'
|
||||
? shop?.services.find((s) => s.id === i.refId)?.price ?? 0
|
||||
: shop?.products.find((p) => p.id === i.refId)?.price ?? 0;
|
||||
return sum + price * i.qty;
|
||||
}, 0);
|
||||
return (
|
||||
<Card key={shopId} className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{shop?.name ?? 'Barbearia'}</p>
|
||||
<p className="text-xs text-slate-500">{shop?.address}</p>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-amber-700">{currency(total)}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{items.map((i) => {
|
||||
const ref =
|
||||
i.type === 'service'
|
||||
? shop?.services.find((s) => s.id === i.refId)
|
||||
: shop?.products.find((p) => p.id === i.refId);
|
||||
return (
|
||||
<div key={i.refId} className="flex items-center justify-between text-sm">
|
||||
<span>
|
||||
{i.type === 'service' ? 'Serviço: ' : 'Produto: '}
|
||||
{ref?.name ?? 'Item'} x{i.qty}
|
||||
</span>
|
||||
<button className="text-amber-700 text-xs" onClick={() => removeFromCart(i.refId)}>
|
||||
Remover
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{user ? (
|
||||
<Button onClick={() => handleCheckout(shopId)}>Finalizar pedido</Button>
|
||||
) : (
|
||||
<Button asChild>
|
||||
<Link to="/login">Entrar para finalizar</Link>
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
67
web/src/components/DashboardCards.tsx
Normal file
67
web/src/components/DashboardCards.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Card } from './ui/card';
|
||||
import { currency } from '../lib/format';
|
||||
import { useMemo } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { BarChart, Bar, CartesianGrid, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
|
||||
export const DashboardCards = () => {
|
||||
const { orders, appointments, shops, user } = useAppStore();
|
||||
const shopId = user?.shopId;
|
||||
|
||||
const filteredOrders = useMemo(
|
||||
() => (shopId ? orders.filter((o) => o.shopId === shopId) : orders),
|
||||
[orders, shopId]
|
||||
);
|
||||
const filteredAppts = useMemo(
|
||||
() => (shopId ? appointments.filter((a) => a.shopId === shopId) : appointments),
|
||||
[appointments, shopId]
|
||||
);
|
||||
|
||||
const total = filteredOrders.reduce((s, o) => s + o.total, 0);
|
||||
const pending = filteredAppts.filter((a) => a.status === 'pendente').length;
|
||||
const confirmed = filteredAppts.filter((a) => a.status === 'confirmado').length;
|
||||
|
||||
const chartData = filteredOrders.reduce<Record<string, number>>((acc, o) => {
|
||||
const month = new Date(o.createdAt).toLocaleString('pt-BR', { month: 'short' });
|
||||
acc[month] = (acc[month] || 0) + o.total;
|
||||
return acc;
|
||||
}, {});
|
||||
const dataset = Object.entries(chartData).map(([name, value]) => ({ name, value }));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<Card className="p-4">
|
||||
<div className="text-sm text-slate-600">Faturamento</div>
|
||||
<div className="text-xl font-semibold text-amber-700">{currency(total)}</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="text-sm text-slate-600">Agendamentos pendentes</div>
|
||||
<div className="text-xl font-semibold text-slate-900">{pending}</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="text-sm text-slate-600">Confirmados</div>
|
||||
<div className="text-xl font-semibold text-slate-900">{confirmed}</div>
|
||||
</Card>
|
||||
</div>
|
||||
<Card className="p-4 h-72">
|
||||
<h3 className="text-sm font-semibold text-slate-900 mb-3">Faturamento por mês</h3>
|
||||
{dataset.length ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={dataset}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" fill="#f59e0b" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">Sem dados ainda.</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
34
web/src/components/ProductList.tsx
Normal file
34
web/src/components/ProductList.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { currency } from '../lib/format';
|
||||
import { Product } from '../types';
|
||||
|
||||
export const ProductList = ({
|
||||
products,
|
||||
onAdd,
|
||||
}: {
|
||||
products: Product[];
|
||||
onAdd?: (id: string) => void;
|
||||
}) => (
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
{products.map((p) => (
|
||||
<Card key={p.id} className="p-4 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold text-slate-900">{p.name}</div>
|
||||
<div className="text-sm text-amber-600">{currency(p.price)}</div>
|
||||
</div>
|
||||
<div className="text-sm text-slate-600">Stock: {p.stock}</div>
|
||||
{onAdd && (
|
||||
<div className="pt-2">
|
||||
<Button onClick={() => onAdd(p.id)} disabled={p.stock <= 0}>
|
||||
{p.stock > 0 ? 'Adicionar' : 'Sem stock'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
|
||||
30
web/src/components/ServiceList.tsx
Normal file
30
web/src/components/ServiceList.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { currency } from '../lib/format';
|
||||
import { Service } from '../types';
|
||||
|
||||
export const ServiceList = ({
|
||||
services,
|
||||
onSelect,
|
||||
}: {
|
||||
services: Service[];
|
||||
onSelect?: (id: string) => void;
|
||||
}) => (
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
{services.map((s) => (
|
||||
<Card key={s.id} className="p-4 flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="font-semibold text-slate-900">{s.name}</div>
|
||||
<div className="text-sm text-amber-600">{currency(s.price)}</div>
|
||||
</div>
|
||||
<div className="text-sm text-slate-600">Duração: {s.duration} min</div>
|
||||
{onSelect && (
|
||||
<div className="pt-2">
|
||||
<Button onClick={() => onSelect(s.id)}>Selecionar</Button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
32
web/src/components/ShopCard.tsx
Normal file
32
web/src/components/ShopCard.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Star, MapPin } from 'lucide-react';
|
||||
import { BarberShop } from '../types';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export const ShopCard = ({ shop }: { shop: BarberShop }) => (
|
||||
<Card className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-slate-900">{shop.name}</h2>
|
||||
<span className="flex items-center gap-1 text-amber-600 text-sm">
|
||||
<Star size={14} />
|
||||
{shop.rating}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 flex items-center gap-1">
|
||||
<MapPin size={14} />
|
||||
{shop.address}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild>
|
||||
<Link to={`/barbearia/${shop.id}`}>Ver detalhes</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`/agendar/${shop.id}`}>Agendar</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
|
||||
|
||||
46
web/src/components/layout/Header.tsx
Normal file
46
web/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { MapPin, ShoppingCart, User } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { useAppStore } from '../../store/useAppStore';
|
||||
|
||||
export const Header = () => {
|
||||
const { user, cart } = useAppStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 bg-white/95 backdrop-blur border-b border-slate-200">
|
||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
|
||||
<Link to="/" className="text-lg font-bold text-amber-600">
|
||||
Smart Agenda
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/explorar" className="flex items-center gap-1 text-sm text-slate-700">
|
||||
<MapPin size={16} />
|
||||
Barbearias
|
||||
</Link>
|
||||
<Link to="/carrinho" className="relative text-slate-700">
|
||||
<ShoppingCart size={18} />
|
||||
{cart.length > 0 && (
|
||||
<span className="absolute -right-2 -top-2 rounded-full bg-amber-500 px-1 text-[11px] font-semibold text-white">
|
||||
{cart.length}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
{user ? (
|
||||
<button onClick={() => navigate(user.role === 'barbearia' ? '/painel' : '/perfil')} className="flex items-center gap-1 text-sm text-slate-700">
|
||||
<User size={16} />
|
||||
{user.name}
|
||||
</button>
|
||||
) : (
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/login">Entrar</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
14
web/src/components/layout/Shell.tsx
Normal file
14
web/src/components/layout/Shell.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Header } from './Header';
|
||||
|
||||
export const Shell = () => (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Header />
|
||||
<main className="mx-auto max-w-5xl px-4 py-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
|
||||
17
web/src/components/ui/badge.tsx
Normal file
17
web/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
type Props = { children: React.ReactNode; color?: 'amber' | 'slate' | 'green' | 'red'; className?: string };
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
export const Badge = ({ children, color = 'amber', className }: Props) => (
|
||||
<span className={cn('px-2 py-1 text-xs rounded-full font-semibold', colorMap[color], className)}>{children}</span>
|
||||
);
|
||||
|
||||
|
||||
|
||||
25
web/src/components/ui/button.tsx
Normal file
25
web/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { cn } from '../../lib/cn';
|
||||
import React from 'react';
|
||||
|
||||
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: 'solid' | 'outline' | 'ghost';
|
||||
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';
|
||||
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',
|
||||
};
|
||||
const cls = cn(base, variants[variant], className);
|
||||
if (asChild && React.isValidElement(props.children)) {
|
||||
return React.cloneElement(props.children, {
|
||||
...props,
|
||||
className: cn(cls, (props.children as any).props?.className),
|
||||
});
|
||||
}
|
||||
return <button className={cls} {...props} />;
|
||||
};
|
||||
|
||||
8
web/src/components/ui/card.tsx
Normal file
8
web/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
export const Card = ({ children, className = '' }: { children: React.ReactNode; className?: string }) => (
|
||||
<div className={cn('bg-white rounded-xl shadow-sm border border-slate-200', className)}>{children}</div>
|
||||
);
|
||||
|
||||
|
||||
|
||||
16
web/src/components/ui/chip.tsx
Normal file
16
web/src/components/ui/chip.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
export const Chip = ({ children, active, onClick }: { children: React.ReactNode; active?: boolean; onClick?: () => void }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-full border text-sm transition',
|
||||
active ? 'border-amber-500 bg-amber-50 text-amber-700' : 'border-slate-200 text-slate-700 hover:bg-slate-100'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
|
||||
|
||||
29
web/src/components/ui/dialog.tsx
Normal file
29
web/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
export const Dialog = ({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 px-4">
|
||||
<div className="w-full max-w-lg rounded-xl bg-white shadow-lg border border-slate-200">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
|
||||
<h3 className="text-base font-semibold text-slate-900">{title}</h3>
|
||||
<button onClick={onClose} className="text-slate-500 text-sm hover:text-slate-700">
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
17
web/src/components/ui/input.tsx
Normal file
17
web/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { cn } from '../../lib/cn';
|
||||
import React from 'react';
|
||||
|
||||
type Props = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const Input = ({ className, ...props }: Props) => (
|
||||
<input
|
||||
className={cn(
|
||||
'w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/30',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
|
||||
20
web/src/components/ui/tabs.tsx
Normal file
20
web/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
type Tab = { id: string; label: string };
|
||||
|
||||
export const Tabs = ({ tabs, active, onChange }: { tabs: Tab[]; active: string; onChange: (id: string) => void }) => (
|
||||
<div className="flex gap-2 border-b border-slate-200">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
|
||||
41
web/src/data/mock.ts
Normal file
41
web/src/data/mock.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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' },
|
||||
];
|
||||
|
||||
export const mockShops: BarberShop[] = [
|
||||
{
|
||||
id: 's1',
|
||||
name: 'Barbearia Central',
|
||||
address: 'Rua Principal, 123',
|
||||
rating: 4.7,
|
||||
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: 's2',
|
||||
name: 'Barbearia Bairro',
|
||||
address: 'Av. Verde, 45',
|
||||
rating: 4.5,
|
||||
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 }],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
|
||||
18
web/src/index.css
Normal file
18
web/src/index.css
Normal file
@@ -0,0 +1,18 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-slate-50 text-slate-900 font-sans;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-inherit no-underline;
|
||||
}
|
||||
|
||||
|
||||
|
||||
6
web/src/lib/cn.ts
Normal file
6
web/src/lib/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import clsx, { ClassValue } from 'classnames';
|
||||
|
||||
export const cn = (...inputs: ClassValue[]) => clsx(inputs);
|
||||
|
||||
|
||||
|
||||
4
web/src/lib/format.ts
Normal file
4
web/src/lib/format.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const currency = (v: number) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
|
||||
|
||||
|
||||
10
web/src/lib/palette.ts
Normal file
10
web/src/lib/palette.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export const brand = {
|
||||
primary: 'amber-500',
|
||||
primaryDark: 'amber-600',
|
||||
bg: 'slate-50',
|
||||
card: 'white',
|
||||
text: 'slate-900',
|
||||
};
|
||||
|
||||
|
||||
|
||||
18
web/src/lib/storage.ts
Normal file
18
web/src/lib/storage.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const storage = {
|
||||
get<T>(key: string, fallback: T): T {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch (err) {
|
||||
console.error('storage parse error', err);
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
set<T>(key: string, value: T) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
|
||||
13
web/src/main.tsx
Normal file
13
web/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
||||
|
||||
55
web/src/pages/AuthLogin.tsx
Normal file
55
web/src/pages/AuthLogin.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { FormEvent, 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';
|
||||
|
||||
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 navigate = useNavigate();
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const ok = login(email, password);
|
||||
if (!ok) {
|
||||
setError('Credenciais inválidas');
|
||||
} else {
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card className="p-6 space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Entrar</h1>
|
||||
<p className="text-sm text-slate-600">Use o demo: cliente@demo.com / 123</p>
|
||||
</div>
|
||||
<form className="space-y-3" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Email</label>
|
||||
<Input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Senha</label>
|
||||
<Input value={password} onChange={(e) => setPassword(e.target.value)} type="password" required />
|
||||
</div>
|
||||
{error && <p className="text-sm text-rose-600">{error}</p>}
|
||||
<Button type="submit" className="w-full">
|
||||
Entrar
|
||||
</Button>
|
||||
</form>
|
||||
<p className="text-sm text-slate-600">
|
||||
Não tem conta? <Link to="/registo" className="text-amber-700 font-semibold">Registar</Link>
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
66
web/src/pages/AuthRegister.tsx
Normal file
66
web/src/pages/AuthRegister.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { FormEvent, 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';
|
||||
|
||||
export default function AuthRegister() {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [role, setRole] = useState<'cliente' | 'barbearia'>('cliente');
|
||||
const [error, setError] = useState('');
|
||||
const register = useAppStore((s) => s.register);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const ok = register({ name, email, password, role });
|
||||
if (!ok) setError('Email já registado');
|
||||
else navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto">
|
||||
<Card className="p-6 space-y-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Criar conta</h1>
|
||||
<p className="text-sm text-slate-600">Escolha o tipo de acesso.</p>
|
||||
</div>
|
||||
<form className="space-y-3" onSubmit={onSubmit}>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Nome</label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Email</label>
|
||||
<Input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700">Senha</label>
|
||||
<Input value={password} onChange={(e) => setPassword(e.target.value)} type="password" required />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{(['cliente', 'barbearia'] as const).map((r) => (
|
||||
<label key={r} className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="radio" name="role" value={r} checked={role === r} onChange={() => setRole(r)} />
|
||||
{r === 'cliente' ? 'Cliente' : 'Barbearia'}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{error && <p className="text-sm text-rose-600">{error}</p>}
|
||||
<Button type="submit" className="w-full">
|
||||
Criar conta
|
||||
</Button>
|
||||
</form>
|
||||
<p className="text-sm text-slate-600">
|
||||
Já tem conta? <Link to="/login" className="text-amber-700 font-semibold">Entrar</Link>
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
94
web/src/pages/Booking.tsx
Normal file
94
web/src/pages/Booking.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { Button } from '../components/ui/button';
|
||||
|
||||
export default function Booking() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { shops, createAppointment, user } = useAppStore();
|
||||
const shop = useMemo(() => shops.find((s) => s.id === id), [shops, id]);
|
||||
const [serviceId, setService] = useState('');
|
||||
const [barberId, setBarber] = useState('');
|
||||
const [date, setDate] = useState('');
|
||||
const [slot, setSlot] = useState('');
|
||||
|
||||
if (!shop) return <div>Barbearia não encontrada</div>;
|
||||
|
||||
const selectedBarber = shop.barbers.find((b) => b.id === barberId);
|
||||
const availableSlots = selectedBarber?.schedule.find((s) => s.day === date)?.slots ?? [];
|
||||
|
||||
const submit = () => {
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
if (!serviceId || !barberId || !date || !slot) return;
|
||||
const appt = createAppointment({ shopId: shop.id, serviceId, barberId, customerId: user.id, date: `${date} ${slot}` });
|
||||
if (appt) navigate('/perfil');
|
||||
else alert('Horário indisponível');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold text-slate-900">Agendar em {shop.name}</h1>
|
||||
<Card className="p-4 space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-700 mb-2">1. Serviço</p>
|
||||
<div className="grid md:grid-cols-2 gap-2">
|
||||
{shop.services.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setService(s.id)}
|
||||
className={`p-3 rounded-md border text-left ${serviceId === s.id ? 'border-amber-500 bg-amber-50' : 'border-slate-200'}`}
|
||||
>
|
||||
<div className="font-semibold text-slate-900">{s.name}</div>
|
||||
<div className="text-sm text-slate-600">R$ {s.price}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-700 mb-2">2. Barbeiro</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{shop.barbers.map((b) => (
|
||||
<button
|
||||
key={b.id}
|
||||
onClick={() => setBarber(b.id)}
|
||||
className={`px-3 py-2 rounded-full border text-sm ${barberId === b.id ? 'border-amber-500 bg-amber-50' : 'border-slate-200'}`}
|
||||
>
|
||||
{b.name} · {b.specialties.join(', ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-700 mb-1">3. Data</p>
|
||||
<input type="date" className="w-full border rounded-md px-3 py-2" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-700 mb-1">4. Horário</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{availableSlots.map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setSlot(h)}
|
||||
className={`px-3 py-2 rounded-md border text-sm ${slot === h ? 'border-amber-500 bg-amber-50' : 'border-slate-200'}`}
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))}
|
||||
{!availableSlots.length && <p className="text-sm text-slate-500">Escolha data e barbeiro.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={submit}>Confirmar agendamento</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
13
web/src/pages/Cart.tsx
Normal file
13
web/src/pages/Cart.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
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>
|
||||
<CartPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
139
web/src/pages/Dashboard.tsx
Normal file
139
web/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { DashboardCards } from '../components/DashboardCards';
|
||||
import { currency } from '../lib/format';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, shops, appointments, orders, updateAppointmentStatus, updateOrderStatus, addService: addServiceStore } = useAppStore();
|
||||
const shop = shops.find((s) => s.id === user?.shopId);
|
||||
const [svcName, setSvcName] = useState('');
|
||||
const [svcPrice, setSvcPrice] = useState<number>(50);
|
||||
|
||||
if (!user || user.role !== 'barbearia') return <div>Área exclusiva para barbearias.</div>;
|
||||
if (!shop) return <div>Barbearia não encontrada.</div>;
|
||||
|
||||
const shopAppointments = appointments.filter((a) => a.shopId === shop.id);
|
||||
const shopOrders = orders.filter((o) => o.shopId === shop.id);
|
||||
|
||||
const addService = () => {
|
||||
addServiceStore(shop.id, { id: nanoid(), name: svcName || 'Novo Serviço', price: Number(svcPrice) || 0, duration: 30, barberIds: [] });
|
||||
setSvcName('');
|
||||
setSvcPrice(50);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Painel da {shop.name}</h1>
|
||||
<p className="text-sm text-slate-600">{shop.address}</p>
|
||||
</div>
|
||||
<Badge color="amber">Role: barbearia</Badge>
|
||||
</div>
|
||||
|
||||
<DashboardCards />
|
||||
|
||||
<section className="grid md:grid-cols-2 gap-4">
|
||||
<Card className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-slate-900">Agendamentos</h3>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-80 overflow-auto">
|
||||
{shopAppointments.map((a) => (
|
||||
<div key={a.id} className="flex items-center justify-between rounded-md border border-slate-200 px-3 py-2">
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-slate-900">{a.date}</p>
|
||||
<p className="text-xs text-slate-600">Serviço: {a.serviceId}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>{a.status}</Badge>
|
||||
<select
|
||||
className="text-xs border border-slate-300 rounded-md px-2 py-1"
|
||||
value={a.status}
|
||||
onChange={(e) => updateAppointmentStatus(a.id, e.target.value as any)}
|
||||
>
|
||||
{['pendente', 'confirmado', 'concluido', 'cancelado'].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!shopAppointments.length && <p className="text-sm text-slate-600">Sem agendamentos.</p>}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-slate-900">Pedidos</h3>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-80 overflow-auto">
|
||||
{shopOrders.map((o) => (
|
||||
<div key={o.id} className="flex items-center justify-between rounded-md border border-slate-200 px-3 py-2">
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-slate-900">{currency(o.total)}</p>
|
||||
<p className="text-xs text-slate-600">{new Date(o.createdAt).toLocaleString('pt-BR')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge>{o.status}</Badge>
|
||||
<select
|
||||
className="text-xs border border-slate-300 rounded-md px-2 py-1"
|
||||
value={o.status}
|
||||
onChange={(e) => updateOrderStatus(o.id, e.target.value as any)}
|
||||
>
|
||||
{['pendente', 'confirmado', 'concluido', 'cancelado'].map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!shopOrders.length && <p className="text-sm text-slate-600">Sem pedidos.</p>}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid md:grid-cols-2 gap-4">
|
||||
<Card className="p-4 space-y-3">
|
||||
<h3 className="text-base font-semibold text-slate-900">Serviços</h3>
|
||||
<div className="space-y-2">
|
||||
{shop.services.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between text-sm border border-slate-200 rounded-md px-3 py-2">
|
||||
<span>{s.name}</span>
|
||||
<span className="text-amber-700 font-semibold">{currency(s.price)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder="Nome do serviço" value={svcName} onChange={(e) => setSvcName(e.target.value)} />
|
||||
<Input type="number" value={svcPrice} onChange={(e) => setSvcPrice(Number(e.target.value))} />
|
||||
<Button onClick={addService}>Adicionar</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 space-y-3">
|
||||
<h3 className="text-base font-semibold text-slate-900">Produtos (stock)</h3>
|
||||
<div className="space-y-2">
|
||||
{shop.products.map((p) => (
|
||||
<div key={p.id} className="flex items-center justify-between text-sm border border-slate-200 rounded-md px-3 py-2">
|
||||
<span>{p.name}</span>
|
||||
<span className="text-slate-700">Stock: {p.stock}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">CRUD simplificado; ajuste de stock pode ser adicionado.</p>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
20
web/src/pages/Explore.tsx
Normal file
20
web/src/pages/Explore.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { ShopCard } from '../components/ShopCard';
|
||||
|
||||
export default function Explore() {
|
||||
const shops = useAppStore((s) => s.shops);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold text-slate-900">Explorar barbearias</h1>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{shops.map((shop) => (
|
||||
<ShopCard key={shop.id} shop={shop} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
39
web/src/pages/Landing.tsx
Normal file
39
web/src/pages/Landing.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from '../components/ui/button';
|
||||
|
||||
export default function Landing() {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<section className="rounded-2xl bg-gradient-to-r from-amber-500 to-amber-600 text-white px-6 py-10 shadow-lg">
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<p className="text-sm uppercase tracking-wide font-semibold">Smart Agenda</p>
|
||||
<h1 className="text-3xl font-bold leading-tight">Agendamentos, produtos e gestão em um único lugar.</h1>
|
||||
<p className="text-lg text-amber-50">Experiência mobile-first para clientes e painel completo para barbearias.</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button asChild>
|
||||
<Link to="/explorar">Explorar barbearias</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="bg-white text-amber-700 border-white">
|
||||
<Link to="/registo">Criar conta</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="grid md:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ title: 'Agendamentos', desc: 'Escolha serviço, barbeiro, data e horário com validação de slots.' },
|
||||
{ title: 'Carrinho', desc: 'Produtos e serviços agrupados por barbearia, pagamento rápido.' },
|
||||
{ title: 'Painel', desc: 'Faturamento, agendamentos, pedidos, barbearia no controle.' },
|
||||
].map((c) => (
|
||||
<div key={c.title} className="rounded-xl bg-white border border-slate-200 p-4 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-slate-900">{c.title}</h3>
|
||||
<p className="text-sm text-slate-600">{c.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
72
web/src/pages/Profile.tsx
Normal file
72
web/src/pages/Profile.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { currency } from '../lib/format';
|
||||
|
||||
const statusColor: Record<string, 'amber' | 'green' | 'slate' | 'red'> = {
|
||||
pendente: 'amber',
|
||||
confirmado: 'green',
|
||||
concluido: 'green',
|
||||
cancelado: 'red',
|
||||
};
|
||||
|
||||
export default function Profile() {
|
||||
const { user, appointments, orders, shops } = useAppStore();
|
||||
if (!user) return <div>Faça login para ver o perfil.</div>;
|
||||
|
||||
const myAppointments = appointments.filter((a) => a.customerId === user.id);
|
||||
const myOrders = orders.filter((o) => o.customerId === user.id);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Olá, {user.name}</h1>
|
||||
<p className="text-sm text-slate-600">{user.email}</p>
|
||||
</div>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Agendamentos</h2>
|
||||
{!myAppointments.length && <Card className="p-4 text-sm text-slate-600">Sem agendamentos.</Card>}
|
||||
<div className="space-y-2">
|
||||
{myAppointments.map((a) => {
|
||||
const shop = shops.find((s) => s.id === a.shopId);
|
||||
return (
|
||||
<Card key={a.id} className="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{shop?.name}</p>
|
||||
<p className="text-xs text-slate-600">{a.date}</p>
|
||||
</div>
|
||||
<Badge color={statusColor[a.status]}>{a.status}</Badge>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900">Pedidos</h2>
|
||||
{!myOrders.length && <Card className="p-4 text-sm text-slate-600">Sem pedidos.</Card>}
|
||||
<div className="space-y-2">
|
||||
{myOrders.map((o) => {
|
||||
const shop = shops.find((s) => s.id === o.shopId);
|
||||
return (
|
||||
<Card key={o.id} className="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{shop?.name}</p>
|
||||
<p className="text-xs text-slate-600">{new Date(o.createdAt).toLocaleString('pt-BR')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-semibold text-slate-900">{currency(o.total)}</span>
|
||||
<Badge color={statusColor[o.status]}>{o.status}</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
50
web/src/pages/ShopDetails.tsx
Normal file
50
web/src/pages/ShopDetails.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { Tabs } from '../components/ui/tabs';
|
||||
import { ServiceList } from '../components/ServiceList';
|
||||
import { ProductList } from '../components/ProductList';
|
||||
import { Button } from '../components/ui/button';
|
||||
|
||||
export default function ShopDetails() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const shops = useAppStore((s) => s.shops);
|
||||
const addToCart = useAppStore((s) => s.addToCart);
|
||||
const shop = useMemo(() => shops.find((s) => s.id === id), [shops, id]);
|
||||
const [tab, setTab] = useState<'servicos' | 'produtos'>('servicos');
|
||||
|
||||
if (!shop) return <div>Barbearia não encontrada.</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">{shop.name}</h1>
|
||||
<p className="text-sm text-slate-600">{shop.address}</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link to={`/agendar/${shop.id}`}>Agendar</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'servicos', label: 'Serviços' },
|
||||
{ id: 'produtos', label: 'Produtos' },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={(v) => setTab(v as typeof tab)}
|
||||
/>
|
||||
{tab === 'servicos' ? (
|
||||
<ServiceList
|
||||
services={shop.services}
|
||||
onSelect={(sid) => addToCart({ shopId: shop.id, type: 'service', refId: sid, qty: 1 })}
|
||||
/>
|
||||
) : (
|
||||
<ProductList products={shop.products} onAdd={(pid) => addToCart({ shopId: shop.id, type: 'product', refId: pid, qty: 1 })} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
31
web/src/routes.tsx
Normal file
31
web/src/routes.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createBrowserRouter } from 'react-router-dom';
|
||||
import { Shell } from './components/layout/Shell';
|
||||
import Landing from './pages/Landing';
|
||||
import AuthLogin from './pages/AuthLogin';
|
||||
import AuthRegister from './pages/AuthRegister';
|
||||
import Explore from './pages/Explore';
|
||||
import ShopDetails from './pages/ShopDetails';
|
||||
import Booking from './pages/Booking';
|
||||
import Cart from './pages/Cart';
|
||||
import Profile from './pages/Profile';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
element: <Shell />,
|
||||
children: [
|
||||
{ path: '/', element: <Landing /> },
|
||||
{ path: '/login', element: <AuthLogin /> },
|
||||
{ path: '/registo', element: <AuthRegister /> },
|
||||
{ path: '/explorar', element: <Explore /> },
|
||||
{ path: '/barbearia/:id', element: <ShopDetails /> },
|
||||
{ path: '/agendar/:id', element: <Booking /> },
|
||||
{ path: '/carrinho', element: <Cart /> },
|
||||
{ path: '/perfil', element: <Profile /> },
|
||||
{ path: '/painel', element: <Dashboard /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
|
||||
118
web/src/store/useAppStore.ts
Normal file
118
web/src/store/useAppStore.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { Appointment, BarberShop, CartItem, Order, User } from '../types';
|
||||
import { mockShops, mockUsers } from '../data/mock';
|
||||
|
||||
type State = {
|
||||
user?: User;
|
||||
shops: BarberShop[];
|
||||
appointments: Appointment[];
|
||||
orders: Order[];
|
||||
cart: CartItem[];
|
||||
};
|
||||
|
||||
type Actions = {
|
||||
login: (email: string, password: string) => boolean;
|
||||
register: (input: Omit<User, 'id'>) => boolean;
|
||||
addToCart: (item: CartItem) => void;
|
||||
removeFromCart: (refId: string) => void;
|
||||
clearCart: () => void;
|
||||
createAppointment: (a: Omit<Appointment, 'id' | 'status' | 'total'>) => Appointment | null;
|
||||
placeOrder: (customerId: string) => Order | null;
|
||||
updateAppointmentStatus: (id: string, status: Appointment['status']) => void;
|
||||
updateOrderStatus: (id: string, status: Order['status']) => void;
|
||||
addService: (shopId: string, service: BarberShop['services'][number]) => void;
|
||||
};
|
||||
|
||||
export const useAppStore = create<State & Actions>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
user: undefined,
|
||||
shops: mockShops,
|
||||
appointments: [],
|
||||
orders: [],
|
||||
cart: [],
|
||||
login: (email, password) => {
|
||||
const found = mockUsers.find((u) => u.email === email && u.password === password);
|
||||
if (found) {
|
||||
set({ user: found });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
addService: (shopId, service) => {
|
||||
set({
|
||||
shops: get().shops.map((s) => (s.id === shopId ? { ...s, services: [...s.services, service] } : s)),
|
||||
});
|
||||
},
|
||||
register: (input) => {
|
||||
const exists = mockUsers.some((u) => u.email === input.email);
|
||||
if (exists) return false;
|
||||
const nu: User = { ...input, id: nanoid() };
|
||||
mockUsers.push(nu);
|
||||
set({ user: nu });
|
||||
return true;
|
||||
},
|
||||
addToCart: (item) => {
|
||||
const cart = get().cart.slice();
|
||||
const idx = cart.findIndex((c) => c.refId === item.refId && c.type === item.type);
|
||||
if (idx >= 0) cart[idx].qty += item.qty;
|
||||
else cart.push(item);
|
||||
set({ cart });
|
||||
},
|
||||
removeFromCart: (refId) => set({ cart: get().cart.filter((c) => c.refId !== refId) }),
|
||||
clearCart: () => set({ cart: [] }),
|
||||
createAppointment: (a) => {
|
||||
const shop = get().shops.find((s) => s.id === a.shopId);
|
||||
if (!shop) return null;
|
||||
const svc = shop.services.find((s) => s.id === a.serviceId);
|
||||
if (!svc) return null;
|
||||
const clash = get().appointments.find(
|
||||
(ap) => ap.barberId === a.barberId && ap.date === a.date && ap.status !== 'cancelado'
|
||||
);
|
||||
if (clash) return null;
|
||||
const appointment: Appointment = {
|
||||
...a,
|
||||
id: nanoid(),
|
||||
status: 'pendente',
|
||||
total: svc.price,
|
||||
};
|
||||
set({ appointments: [...get().appointments, appointment] });
|
||||
return appointment;
|
||||
},
|
||||
placeOrder: (customerId) => {
|
||||
const cart = get().cart;
|
||||
if (!cart.length) return null;
|
||||
const total = cart.reduce((sum, item) => {
|
||||
const shop = get().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);
|
||||
const order: Order = {
|
||||
id: nanoid(),
|
||||
shopId: cart[0].shopId,
|
||||
customerId,
|
||||
items: cart,
|
||||
total,
|
||||
status: 'pendente',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
set({ orders: [...get().orders, order], cart: [] });
|
||||
return order;
|
||||
},
|
||||
updateAppointmentStatus: (id, status) => {
|
||||
set({ appointments: get().appointments.map((a) => (a.id === id ? { ...a, status } : a)) });
|
||||
},
|
||||
updateOrderStatus: (id, status) => {
|
||||
set({ orders: get().orders.map((o) => (o.id === id ? { ...o, status } : o)) });
|
||||
},
|
||||
}),
|
||||
{ name: 'smart-agenda' }
|
||||
)
|
||||
);
|
||||
|
||||
13
web/src/types.ts
Normal file
13
web/src/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export type Barber = { id: string; name: string; specialties: string[]; schedule: { day: string; slots: string[] }[] };
|
||||
export type Service = { id: string; name: string; price: number; duration: number; barberIds: string[] };
|
||||
export type Product = { id: string; name: string; price: number; stock: number };
|
||||
export type BarberShop = { id: string; name: string; address: string; rating: number; services: Service[]; products: Product[]; barbers: Barber[] };
|
||||
export type AppointmentStatus = 'pendente' | 'confirmado' | 'concluido' | 'cancelado';
|
||||
export type OrderStatus = 'pendente' | 'confirmado' | 'concluido' | 'cancelado';
|
||||
export type Appointment = { id: string; shopId: string; serviceId: string; barberId: string; customerId: string; date: string; status: AppointmentStatus; total: number };
|
||||
export type CartItem = { shopId: string; type: 'service' | 'product'; refId: string; qty: number };
|
||||
export type Order = { id: string; shopId: string; customerId: string; items: CartItem[]; total: number; status: OrderStatus; createdAt: string };
|
||||
export type User = { id: string; name: string; email: string; password: string; role: 'cliente' | 'barbearia'; shopId?: string };
|
||||
|
||||
|
||||
|
||||
4
web/src/vite-env.d.ts
vendored
Normal file
4
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
|
||||
|
||||
21
web/tailwind.config.cjs
Normal file
21
web/tailwind.config.cjs
Normal file
@@ -0,0 +1,21 @@
|
||||
const colors = require('tailwindcss/colors');
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
amber: colors.amber,
|
||||
slate: colors.slate,
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
|
||||
|
||||
22
web/tsconfig.json
Normal file
22
web/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": []
|
||||
}
|
||||
|
||||
|
||||
|
||||
10
web/vite.config.ts
Normal file
10
web/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 5173 },
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user