first commit
This commit is contained in:
162
web/OPCOES_CORES.md
Normal file
162
web/OPCOES_CORES.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# Opções de Cores para a Landing Page
|
||||
|
||||
Este documento lista diferentes paletas de cores que podem ser aplicadas à aplicação.
|
||||
|
||||
## 🎨 Paleta Atual: Azul/Indigo
|
||||
**Status:** ✅ Aplicada
|
||||
|
||||
- **Primária:** Indigo 500-600
|
||||
- **Secundária:** Blue 600-700
|
||||
- **Background:** Blue 50/30
|
||||
- **Estilo:** Profissional, moderno, confiável
|
||||
|
||||
**Cores principais:**
|
||||
- Hero: `from-indigo-600 via-blue-600 to-indigo-700`
|
||||
- Botões: `from-indigo-500 to-blue-600`
|
||||
- Hover: `indigo-50`, `indigo-600`
|
||||
|
||||
---
|
||||
|
||||
## 🟡 Opção 1: Âmbar/Amarelo (Original)
|
||||
**Status:** Disponível
|
||||
|
||||
- **Primária:** Amber 500-600
|
||||
- **Secundária:** Amber 600-700
|
||||
- **Background:** Amber 50/30
|
||||
- **Estilo:** Quente, acolhedor, energético
|
||||
|
||||
**Cores principais:**
|
||||
- Hero: `from-amber-500 via-amber-600 to-amber-700`
|
||||
- Botões: `from-amber-500 to-amber-600`
|
||||
- Hover: `amber-50`, `amber-600`
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Opção 2: Verde/Emerald
|
||||
**Status:** Disponível
|
||||
|
||||
- **Primária:** Emerald 500-600
|
||||
- **Secundária:** Green 600-700
|
||||
- **Background:** Emerald 50/30
|
||||
- **Estilo:** Natural, fresco, crescimento
|
||||
|
||||
**Cores principais:**
|
||||
- Hero: `from-emerald-500 via-emerald-600 to-emerald-700`
|
||||
- Botões: `from-emerald-500 to-emerald-600`
|
||||
- Hover: `emerald-50`, `emerald-600`
|
||||
|
||||
**Substituições necessárias:**
|
||||
- `indigo` → `emerald`
|
||||
- `blue` → `green`
|
||||
- `indigo-50` → `emerald-50`
|
||||
|
||||
---
|
||||
|
||||
## 🟣 Opção 3: Roxo/Violet
|
||||
**Status:** Disponível
|
||||
|
||||
- **Primária:** Purple 500-600
|
||||
- **Secundária:** Violet 600-700
|
||||
- **Background:** Purple 50/30
|
||||
- **Estilo:** Criativo, luxuoso, inovador
|
||||
|
||||
**Cores principais:**
|
||||
- Hero: `from-purple-500 via-purple-600 to-purple-700`
|
||||
- Botões: `from-purple-500 to-purple-600`
|
||||
- Hover: `purple-50`, `purple-600`
|
||||
|
||||
**Substituições necessárias:**
|
||||
- `indigo` → `purple`
|
||||
- `blue` → `violet`
|
||||
- `indigo-50` → `purple-50`
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Opção 4: Vermelho/Rose
|
||||
**Status:** Disponível
|
||||
|
||||
- **Primária:** Rose 500-600
|
||||
- **Secundária:** Red 600-700
|
||||
- **Background:** Rose 50/30
|
||||
- **Estilo:** Vibrante, apaixonado, dinâmico
|
||||
|
||||
**Cores principais:**
|
||||
- Hero: `from-rose-500 via-rose-600 to-rose-700`
|
||||
- Botões: `from-rose-500 to-rose-600`
|
||||
- Hover: `rose-50`, `rose-600`
|
||||
|
||||
**Substituições necessárias:**
|
||||
- `indigo` → `rose`
|
||||
- `blue` → `red`
|
||||
- `indigo-50` → `rose-50`
|
||||
|
||||
---
|
||||
|
||||
## 🔵 Opção 5: Ciano/Sky
|
||||
**Status:** Disponível
|
||||
|
||||
- **Primária:** Sky 500-600
|
||||
- **Secundária:** Cyan 600-700
|
||||
- **Background:** Sky 50/30
|
||||
- **Estilo:** Fresco, moderno, tecnológico
|
||||
|
||||
**Cores principais:**
|
||||
- Hero: `from-sky-500 via-sky-600 to-sky-700`
|
||||
- Botões: `from-sky-500 to-sky-600`
|
||||
- Hover: `sky-50`, `sky-600`
|
||||
|
||||
**Substituições necessárias:**
|
||||
- `indigo` → `sky`
|
||||
- `blue` → `cyan`
|
||||
- `indigo-50` → `sky-50`
|
||||
|
||||
---
|
||||
|
||||
## 🟠 Opção 6: Laranja/Orange
|
||||
**Status:** Disponível
|
||||
|
||||
- **Primária:** Orange 500-600
|
||||
- **Secundária:** Amber 600-700
|
||||
- **Background:** Orange 50/30
|
||||
- **Estilo:** Energético, entusiasta, criativo
|
||||
|
||||
**Cores principais:**
|
||||
- Hero: `from-orange-500 via-orange-600 to-orange-700`
|
||||
- Botões: `from-orange-500 to-orange-600`
|
||||
- Hover: `orange-50`, `orange-600`
|
||||
|
||||
**Substituições necessárias:**
|
||||
- `indigo` → `orange`
|
||||
- `blue` → `amber`
|
||||
- `indigo-50` → `orange-50`
|
||||
|
||||
---
|
||||
|
||||
## 📝 Como Aplicar uma Nova Paleta
|
||||
|
||||
Para aplicar uma nova paleta, você precisa substituir as cores nos seguintes arquivos:
|
||||
|
||||
1. **`src/pages/Landing.tsx`** - Hero section, seções, CTAs
|
||||
2. **`src/components/ui/button.tsx`** - Variantes de botões
|
||||
3. **`src/components/layout/Header.tsx`** - Navegação e links
|
||||
4. **`src/components/ShopCard.tsx`** - Cards de barbearias
|
||||
5. **`src/index.css`** - Background do body
|
||||
6. **`src/components/ui/card.tsx`** - Hover states
|
||||
|
||||
### Buscar e Substituir:
|
||||
- `indigo-500` → `[nova-cor]-500`
|
||||
- `indigo-600` → `[nova-cor]-600`
|
||||
- `indigo-50` → `[nova-cor]-50`
|
||||
- `blue-600` → `[cor-secundaria]-600`
|
||||
- `blue-50` → `[cor-secundaria]-50`
|
||||
|
||||
---
|
||||
|
||||
## 💡 Recomendações
|
||||
|
||||
- **Barbearias/Beleza:** Âmbar ou Laranja (quente, acolhedor)
|
||||
- **Tecnologia/Profissional:** Azul/Indigo (confiável, moderno)
|
||||
- **Sustentabilidade:** Verde/Emerald (natural, fresco)
|
||||
- **Luxo/Criatividade:** Roxo/Violet (inovador, premium)
|
||||
- **Energia/Dinamismo:** Vermelho/Rose (vibrante, apaixonado)
|
||||
|
||||
31
web/README.md
Normal file
31
web/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
## Smart Agenda (Web, mobile-first)
|
||||
|
||||
Aplicação React + TypeScript + Vite + Tailwind com React Router v6, Context API, localStorage, Recharts e lucide-react.
|
||||
|
||||
### Requisitos
|
||||
- Node 18+ e npm
|
||||
|
||||
### Instalação e execução
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
O Vite arrancará por defeito em `http://localhost:5173`.
|
||||
|
||||
### Scripts úteis
|
||||
- `npm run dev` — modo desenvolvimento
|
||||
- `npm run build` — build de produção
|
||||
- `npm run preview` — servir o build localmente
|
||||
- `npm run lint` — verificação TypeScript
|
||||
|
||||
### Credenciais demo
|
||||
- Cliente: `cliente@demo.com` / `123`
|
||||
- Barbearia: `barber@demo.com` / `123`
|
||||
|
||||
### Notas de implementação
|
||||
- Estado global via Context API com persistência em `localStorage`.
|
||||
- Dados mock de barbearias/serviços/produtos já incluídos; registo de barbearia cria barbearia nova automaticamente.
|
||||
- UI mobile-first em Tailwind com paleta âmbar + slate e componentes reutilizáveis (botões, cards, badges, tabs, modais base).
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
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';
|
||||
import { useApp } from '../context/AppContext';
|
||||
|
||||
export const CartPanel = () => {
|
||||
const { cart, shops, removeFromCart, placeOrder, user } = useAppStore();
|
||||
const { cart, shops, removeFromCart, placeOrder, user } = useApp();
|
||||
if (!cart.length) return <Card className="p-4">Carrinho vazio</Card>;
|
||||
|
||||
const grouped = cart.reduce<Record<string, typeof cart>>((acc, item) => {
|
||||
@@ -16,7 +16,7 @@ export const CartPanel = () => {
|
||||
|
||||
const handleCheckout = (shopId: string) => {
|
||||
if (!user) return;
|
||||
placeOrder(user.id);
|
||||
placeOrder(user.id, shopId);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -74,3 +74,5 @@ export const CartPanel = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { Card } from './ui/card';
|
||||
import { currency } from '../lib/format';
|
||||
import { useMemo } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { BarChart, Bar, CartesianGrid, XAxis, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
|
||||
export const DashboardCards = () => {
|
||||
const { orders, appointments, shops, user } = useAppStore();
|
||||
type Props = { periodFilter?: (date: Date) => boolean };
|
||||
|
||||
export const DashboardCards = ({ periodFilter }: Props) => {
|
||||
const { orders, appointments, user } = useApp();
|
||||
const shopId = user?.shopId;
|
||||
|
||||
const filteredOrders = useMemo(
|
||||
() => (shopId ? orders.filter((o) => o.shopId === shopId) : orders),
|
||||
[orders, shopId]
|
||||
() =>
|
||||
(shopId ? orders.filter((o) => o.shopId === shopId) : orders).filter((o) =>
|
||||
periodFilter ? periodFilter(new Date(o.createdAt)) : true
|
||||
),
|
||||
[orders, shopId, periodFilter]
|
||||
);
|
||||
const filteredAppts = useMemo(
|
||||
() => (shopId ? appointments.filter((a) => a.shopId === shopId) : appointments),
|
||||
[appointments, shopId]
|
||||
() =>
|
||||
(shopId ? appointments.filter((a) => a.shopId === shopId) : appointments).filter((a) =>
|
||||
periodFilter ? periodFilter(new Date(a.date.replace(' ', 'T'))) : true
|
||||
),
|
||||
[appointments, shopId, periodFilter]
|
||||
);
|
||||
|
||||
const total = filteredOrders.reduce((s, o) => s + o.total, 0);
|
||||
@@ -65,3 +73,5 @@ export const DashboardCards = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { currency } from '../lib/format';
|
||||
import { Product } from '../types';
|
||||
import { Package, AlertCircle } from 'lucide-react';
|
||||
|
||||
export const ProductList = ({
|
||||
products,
|
||||
@@ -10,25 +12,43 @@ export const ProductList = ({
|
||||
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 className="grid md:grid-cols-2 gap-4">
|
||||
{products.map((p) => {
|
||||
const lowStock = p.stock <= 3;
|
||||
return (
|
||||
<Card key={p.id} hover className={`p-5 flex flex-col gap-3 ${lowStock ? 'border-amber-300 bg-amber-50/30' : ''}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-bold text-slate-900 text-lg">{p.name}</h3>
|
||||
{lowStock && (
|
||||
<Badge color="amber" variant="solid" className="text-[10px] px-1.5 py-0">
|
||||
<AlertCircle size={10} className="mr-1" />
|
||||
Stock baixo
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<Package size={14} />
|
||||
<span className={lowStock ? 'font-semibold text-amber-700' : ''}>
|
||||
{p.stock} {p.stock === 1 ? 'unidade' : 'unidades'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-amber-600">{currency(p.price)}</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
{onAdd && (
|
||||
<Button onClick={() => onAdd(p.id)} disabled={p.stock <= 0} size="sm" className="w-full" variant={lowStock ? 'solid' : 'solid'}>
|
||||
{p.stock > 0 ? 'Adicionar ao carrinho' : 'Sem stock'}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { currency } from '../lib/format';
|
||||
import { Service } from '../types';
|
||||
import { Clock } from 'lucide-react';
|
||||
|
||||
export const ServiceList = ({
|
||||
services,
|
||||
@@ -10,18 +11,23 @@ export const ServiceList = ({
|
||||
services: Service[];
|
||||
onSelect?: (id: string) => void;
|
||||
}) => (
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{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>
|
||||
<Card key={s.id} hover className="p-5 flex flex-col gap-3 group">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-slate-900 text-lg mb-1 group-hover:text-amber-700 transition-colors">{s.name}</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<Clock size={14} />
|
||||
<span>{s.duration} minutos</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-amber-600">{currency(s.price)}</div>
|
||||
</div>
|
||||
{onSelect && (
|
||||
<Button onClick={() => onSelect(s.id)} size="sm" className="w-full">
|
||||
Adicionar ao carrinho
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Star, MapPin } from 'lucide-react';
|
||||
import { Star, MapPin, Scissors } 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>
|
||||
<Card hover className="p-6 space-y-4 group">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-gradient-to-br from-indigo-500 to-blue-600 rounded-lg text-white shadow-sm">
|
||||
<Scissors size={18} />
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-slate-900 group-hover:text-indigo-700 transition-colors">{shop.name}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600">
|
||||
<span className="flex items-center gap-1 font-medium">
|
||||
<MapPin size={14} />
|
||||
{shop.address}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-indigo-600 font-semibold">
|
||||
<Star size={14} className="fill-indigo-500 text-indigo-500" />
|
||||
{shop.rating.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-500 pt-1">
|
||||
<span>{shop.services.length} serviços</span>
|
||||
<span>•</span>
|
||||
<span>{shop.barbers.length} barbeiros</span>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<div className="flex gap-2 pt-2 border-t border-slate-100">
|
||||
<Button asChild variant="outline" size="sm" className="flex-1">
|
||||
<Link to={`/barbearia/${shop.id}`}>Ver detalhes</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Button asChild size="sm" className="flex-1">
|
||||
<Link to={`/agendar/${shop.id}`}>Agendar</Link>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -30,3 +44,5 @@ export const ShopCard = ({ shop }: { shop: BarberShop }) => (
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,46 +1,138 @@
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { MapPin, ShoppingCart, User } from 'lucide-react';
|
||||
import { MapPin, ShoppingCart, User, LogOut, Menu, X } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { useAppStore } from '../../store/useAppStore';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { useState } from 'react';
|
||||
|
||||
export const Header = () => {
|
||||
const { user, cart } = useAppStore();
|
||||
const { user, cart, logout } = useApp();
|
||||
const navigate = useNavigate();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/');
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
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">
|
||||
<header className="sticky top-0 z-30 bg-white/80 backdrop-blur-md border-b border-slate-200/60 shadow-sm">
|
||||
<div className="mx-auto flex h-16 max-w-5xl items-center justify-between px-4">
|
||||
<Link to="/" className="text-xl font-bold bg-gradient-to-r from-indigo-600 to-blue-700 bg-clip-text text-transparent hover:from-indigo-700 hover:to-blue-800 transition-all">
|
||||
Smart Agenda
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/explorar" className="flex items-center gap-1 text-sm text-slate-700">
|
||||
|
||||
{/* 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} />
|
||||
Barbearias
|
||||
<span>Barbearias</span>
|
||||
</Link>
|
||||
<Link to="/carrinho" className="relative text-slate-700">
|
||||
<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-2 -top-2 rounded-full bg-amber-500 px-1 text-[11px] font-semibold text-white">
|
||||
<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>
|
||||
{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>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => navigate(user.role === 'barbearia' ? '/painel' : '/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"
|
||||
>
|
||||
<User size={16} />
|
||||
<span className="max-w-[120px] truncate">{user.name}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 text-slate-600 hover:text-rose-600 hover:bg-rose-50 rounded-lg transition-colors"
|
||||
title="Sair"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<Button asChild variant="outline">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to="/login">Entrar</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="md:hidden p-2 text-slate-700 hover:text-amber-600 hover:bg-amber-50 rounded-lg transition-colors"
|
||||
>
|
||||
{mobileMenuOpen ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{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>
|
||||
<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>
|
||||
{user ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
navigate(user.role === 'barbearia' ? '/painel' : '/perfil');
|
||||
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"
|
||||
>
|
||||
<User size={16} />
|
||||
{user.name}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-2 text-sm font-medium text-rose-600 hover:bg-rose-50 transition-colors px-3 py-2 rounded-lg text-left"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
Sair
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Button asChild variant="solid" size="sm" className="w-full">
|
||||
<Link to="/login" onClick={() => setMobileMenuOpen(false)}>Entrar</Link>
|
||||
</Button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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) => (
|
||||
<span className={cn('px-2 py-1 text-xs rounded-full font-semibold', colorMap[color], className)}>{children}</span>
|
||||
export const Badge = ({ children, color = 'amber', variant = 'soft', className }: Props) => (
|
||||
<span className={cn('inline-flex items-center px-2.5 py-1 text-xs rounded-full font-semibold transition-colors', colorMap[variant][color], className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,18 +2,25 @@ import { cn } from '../../lib/cn';
|
||||
import React from 'react';
|
||||
|
||||
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
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,
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
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>
|
||||
export const Card = ({ children, className = '', hover = false }: { children: React.ReactNode; className?: string; hover?: boolean }) => (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white rounded-xl shadow-sm border border-slate-200/60 transition-all duration-200',
|
||||
hover && 'hover:shadow-md hover:border-indigo-200/80',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
import { cn } from '../../lib/cn';
|
||||
import React from 'react';
|
||||
|
||||
type Props = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
type Props = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
label?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const Input = ({ className, label, error, ...props }: Props) => {
|
||||
const input = (
|
||||
<input
|
||||
className={cn(
|
||||
'w-full rounded-lg border transition-all duration-200 bg-white px-4 py-2.5 text-sm text-slate-900',
|
||||
'placeholder:text-slate-400',
|
||||
error
|
||||
? 'border-rose-300 focus:border-rose-500 focus:ring-2 focus:ring-rose-500/30'
|
||||
: 'border-slate-300 focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/30',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (label || error) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{label && <label className="block text-sm font-medium text-slate-700">{label}</label>}
|
||||
{input}
|
||||
{error && <p className="text-xs text-rose-600">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
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">
|
||||
<div className="flex gap-1 border-b border-slate-200 overflow-x-auto">
|
||||
{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'
|
||||
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;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
302
web/src/context/AppContext.tsx
Normal file
302
web/src/context/AppContext.tsx
Normal file
@@ -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<User, 'id' | 'shopId'> & { shopName?: string }) => boolean;
|
||||
addToCart: (item: CartItem) => void;
|
||||
removeFromCart: (refId: string) => void;
|
||||
clearCart: () => void;
|
||||
createAppointment: (input: Omit<Appointment, 'id' | 'status' | 'total'>) => 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<Service, 'id'>) => void;
|
||||
updateService: (shopId: string, service: Service) => void;
|
||||
deleteService: (shopId: string, serviceId: string) => void;
|
||||
addProduct: (shopId: string, product: Omit<Product, 'id'>) => void;
|
||||
updateProduct: (shopId: string, product: Product) => void;
|
||||
deleteProduct: (shopId: string, productId: string) => void;
|
||||
addBarber: (shopId: string, barber: Omit<Barber, 'id'>) => 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<AppContextValue | undefined>(undefined);
|
||||
|
||||
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [state, setState] = useState<State>(() => 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<Record<string, CartItem[]>>((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 <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
};
|
||||
|
||||
export const useApp = () => {
|
||||
const ctx = useContext(AppContext);
|
||||
if (!ctx) throw new Error('useApp deve ser usado dentro de AppProvider');
|
||||
return ctx;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<AppProvider>
|
||||
<App />
|
||||
</AppProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<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 className="max-w-md mx-auto py-8">
|
||||
<Card className="p-8 space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="inline-flex p-3 bg-gradient-to-br from-amber-500 to-amber-600 rounded-xl text-white shadow-lg mb-2">
|
||||
<LogIn size={24} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Bem-vindo de volta</h1>
|
||||
<p className="text-sm text-slate-600">Entre na sua conta para continuar</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">
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-xs text-amber-800">
|
||||
<p className="font-semibold mb-1">💡 Conta demo:</p>
|
||||
<p>Cliente: cliente@demo.com / 123</p>
|
||||
<p>Barbearia: barber@demo.com / 123</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
required
|
||||
error={error ? undefined : undefined}
|
||||
placeholder="seu@email.com"
|
||||
/>
|
||||
<Input
|
||||
label="Senha"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
required
|
||||
error={error}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<Button type="submit" className="w-full" size="lg">
|
||||
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>
|
||||
|
||||
<div className="text-center pt-4 border-t border-slate-200">
|
||||
<p className="text-sm text-slate-600">
|
||||
Não tem conta?{' '}
|
||||
<Link to="/registo" className="text-amber-700 font-semibold hover:text-amber-800 transition-colors">
|
||||
Criar conta
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
@@ -53,3 +92,5 @@ export default function AuthLogin() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<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 className="max-w-md mx-auto py-8">
|
||||
<Card className="p-8 space-y-6">
|
||||
<div className="text-center space-y-2">
|
||||
<div className="inline-flex p-3 bg-gradient-to-br from-amber-500 to-amber-600 rounded-xl text-white shadow-lg mb-2">
|
||||
<UserPlus size={24} />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold 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 />
|
||||
|
||||
<form className="space-y-5" onSubmit={onSubmit}>
|
||||
{/* Role Selection */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-slate-700">Tipo de conta</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(['cliente', 'barbearia'] as const).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRole(r);
|
||||
setError('');
|
||||
}}
|
||||
className={`p-4 rounded-xl border-2 transition-all ${
|
||||
role === r
|
||||
? 'border-amber-500 bg-gradient-to-br from-amber-50 to-amber-100/50 shadow-md'
|
||||
: 'border-slate-200 hover:border-amber-300 hover:bg-amber-50/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{r === 'cliente' ? (
|
||||
<User size={20} className={role === r ? 'text-amber-600' : 'text-slate-400'} />
|
||||
) : (
|
||||
<Scissors size={20} className={role === r ? 'text-amber-600' : 'text-slate-400'} />
|
||||
)}
|
||||
<span className={`text-sm font-semibold ${role === r ? 'text-amber-700' : 'text-slate-600'}`}>
|
||||
{r === 'cliente' ? 'Cliente' : 'Barbearia'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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">
|
||||
|
||||
<Input
|
||||
label="Nome completo"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
required
|
||||
placeholder="João Silva"
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
required
|
||||
placeholder="seu@email.com"
|
||||
error={error}
|
||||
/>
|
||||
<Input
|
||||
label="Senha"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
required
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{role === 'barbearia' && (
|
||||
<Input
|
||||
label="Nome da barbearia"
|
||||
value={shopName}
|
||||
onChange={(e) => setShopName(e.target.value)}
|
||||
placeholder="Barbearia XPTO"
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<Button type="submit" className="w-full" size="lg">
|
||||
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>
|
||||
|
||||
<div className="text-center pt-4 border-t border-slate-200">
|
||||
<p className="text-sm text-slate-600">
|
||||
Já tem conta?{' '}
|
||||
<Link to="/login" className="text-amber-700 font-semibold hover:text-amber-800 transition-colors">
|
||||
Entrar
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
@@ -64,3 +139,5 @@ export default function AuthRegister() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,90 +1,294 @@
|
||||
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';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { Calendar, Clock, Scissors, User, CheckCircle2 } from 'lucide-react';
|
||||
import { currency } from '../lib/format';
|
||||
|
||||
export default function Booking() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { shops, createAppointment, user } = useAppStore();
|
||||
const { shops, createAppointment, user, appointments } = useApp();
|
||||
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>;
|
||||
if (!shop) return <div className="text-center py-12 text-slate-600">Barbearia não encontrada</div>;
|
||||
|
||||
const selectedService = shop.services.find((s) => s.id === serviceId);
|
||||
const selectedBarber = shop.barbers.find((b) => b.id === barberId);
|
||||
const availableSlots = selectedBarber?.schedule.find((s) => s.day === date)?.slots ?? [];
|
||||
|
||||
// Função para gerar horários padrão se não houver horários específicos para a data
|
||||
const generateDefaultSlots = (): string[] => {
|
||||
const slots: string[] = [];
|
||||
// Horário de trabalho padrão: 09:00 às 18:00, de hora em hora
|
||||
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
|
||||
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]
|
||||
: generateDefaultSlots();
|
||||
|
||||
// Filtrar horários já ocupados
|
||||
const bookedSlots = appointments
|
||||
.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")
|
||||
const parts = apt.date.split(' ');
|
||||
return parts.length > 1 ? parts[1] : '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return slots.filter((slot) => !bookedSlots.includes(slot));
|
||||
}, [selectedBarber, date, barberId, appointments]);
|
||||
|
||||
const canSubmit = serviceId && barberId && date && slot;
|
||||
|
||||
const submit = () => {
|
||||
if (!user) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
if (!serviceId || !barberId || !date || !slot) return;
|
||||
if (!canSubmit) 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');
|
||||
if (appt) {
|
||||
navigate('/perfil');
|
||||
} else {
|
||||
alert('Horário indisponível');
|
||||
}
|
||||
};
|
||||
|
||||
// Determinar qual etapa mostrar
|
||||
const currentStep = !serviceId ? 1 : !barberId ? 2 : 3;
|
||||
|
||||
const steps = [
|
||||
{ id: 1, label: 'Serviço', icon: Scissors, completed: !!serviceId, current: currentStep === 1 },
|
||||
{ id: 2, label: 'Barbeiro', icon: User, completed: !!barberId, current: currentStep === 2 },
|
||||
{ id: 3, label: 'Data & Hora', icon: Calendar, completed: !!date && !!slot, current: currentStep === 3 },
|
||||
];
|
||||
|
||||
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="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-2">Agendar em {shop.name}</h1>
|
||||
<p className="text-sm text-slate-600">{shop.address}</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="flex items-center justify-between max-w-2xl">
|
||||
{steps.map((step, idx) => (
|
||||
<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
|
||||
? 'bg-gradient-to-br from-indigo-500 to-blue-600 border-indigo-600 text-white shadow-md'
|
||||
: step.current
|
||||
? 'bg-gradient-to-br from-indigo-100 to-blue-100 border-indigo-500 text-indigo-600'
|
||||
: 'bg-white border-slate-300 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold text-slate-900">{s.name}</div>
|
||||
<div className="text-sm text-slate-600">R$ {s.price}</div>
|
||||
</button>
|
||||
))}
|
||||
{step.completed ? <CheckCircle2 size={18} /> : <step.icon size={18} />}
|
||||
</div>
|
||||
<span className={`text-xs mt-2 font-medium ${
|
||||
step.completed ? 'text-indigo-700' : step.current ? 'text-indigo-600 font-semibold' : 'text-slate-500'
|
||||
}`}>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{idx < steps.length - 1 && (
|
||||
<div className={`h-0.5 flex-1 mx-2 ${step.completed ? 'bg-indigo-500' : 'bg-slate-200'}`} />
|
||||
)}
|
||||
</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) => (
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="p-6 space-y-6">
|
||||
{/* Step 1: Service - Só aparece se não tiver serviço selecionado */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Scissors size={18} className="text-indigo-600" />
|
||||
<h3 className="text-base font-bold text-slate-900">1. Escolha o serviço</h3>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-3">
|
||||
{shop.services.map((s) => (
|
||||
<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'}`}
|
||||
key={s.id}
|
||||
onClick={() => setService(s.id)}
|
||||
className={`p-4 rounded-xl border-2 text-left transition-all ${
|
||||
serviceId === s.id
|
||||
? 'border-indigo-500 bg-gradient-to-br from-indigo-50 to-blue-100/50 shadow-md scale-[1.02]'
|
||||
: 'border-slate-200 hover:border-indigo-300 hover:bg-indigo-50/50'
|
||||
}`}
|
||||
>
|
||||
{h}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="font-bold text-slate-900">{s.name}</div>
|
||||
<div className="text-sm font-bold text-indigo-600">{currency(s.price)}</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">Duração: {s.duration} min</div>
|
||||
</button>
|
||||
))}
|
||||
{!availableSlots.length && <p className="text-sm text-slate-500">Escolha data e barbeiro.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={submit}>Confirmar agendamento</Button>
|
||||
)}
|
||||
|
||||
{/* Step 2: Barber - Só aparece se tiver serviço mas não barbeiro */}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setService('')}
|
||||
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium flex items-center gap-1"
|
||||
>
|
||||
← Voltar
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<User size={18} className="text-indigo-600" />
|
||||
<h3 className="text-base font-bold text-slate-900">2. Escolha o barbeiro</h3>
|
||||
</div>
|
||||
{selectedService && (
|
||||
<div className="mb-4 p-3 bg-indigo-50 rounded-lg border border-indigo-200">
|
||||
<div className="text-sm text-slate-600">Serviço selecionado:</div>
|
||||
<div className="font-semibold text-slate-900">{selectedService.name} - {currency(selectedService.price)}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{shop.barbers.map((b) => (
|
||||
<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
|
||||
? 'border-indigo-500 bg-gradient-to-r from-indigo-500 to-blue-600 text-white shadow-md'
|
||||
: 'border-slate-200 text-slate-700 hover:border-indigo-300 hover:bg-indigo-50'
|
||||
}`}
|
||||
>
|
||||
{b.name}
|
||||
{b.specialties.length > 0 && (
|
||||
<span className="ml-2 text-xs opacity-80">· {b.specialties[0]}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Date & Time - Só aparece se tiver serviço e barbeiro */}
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setBarber('')}
|
||||
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium flex items-center gap-1"
|
||||
>
|
||||
← Voltar
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Calendar size={18} className="text-indigo-600" />
|
||||
<h3 className="text-base font-bold text-slate-900">3. Escolha a data e horário</h3>
|
||||
</div>
|
||||
|
||||
{selectedService && selectedBarber && (
|
||||
<div className="mb-4 p-3 bg-indigo-50 rounded-lg border border-indigo-200 space-y-1">
|
||||
<div className="text-sm text-slate-600">Serviço: <span className="font-semibold text-slate-900">{selectedService.name}</span></div>
|
||||
<div className="text-sm text-slate-600">Barbeiro: <span className="font-semibold text-slate-900">{selectedBarber.name}</span></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={18} className="text-indigo-600" />
|
||||
<h4 className="text-sm font-bold text-slate-900">Data</h4>
|
||||
</div>
|
||||
<Input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={18} className="text-indigo-600" />
|
||||
<h4 className="text-sm font-bold text-slate-900">Horário</h4>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{!date ? (
|
||||
<p className="text-sm text-slate-500 py-2">Escolha primeiro a data.</p>
|
||||
) : availableSlots.length > 0 ? (
|
||||
availableSlots.map((h) => (
|
||||
<button
|
||||
key={h}
|
||||
onClick={() => setSlot(h)}
|
||||
className={`px-4 py-2 rounded-lg border-2 text-sm font-medium transition-all ${
|
||||
slot === h
|
||||
? 'border-indigo-500 bg-gradient-to-r from-indigo-500 to-blue-600 text-white shadow-md'
|
||||
: 'border-slate-200 text-slate-700 hover:border-indigo-300 hover:bg-indigo-50'
|
||||
}`}
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-indigo-600 py-2 font-medium">Nenhum horário disponível para esta data.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{canSubmit && selectedService && (
|
||||
<div className="pt-4 border-t border-slate-200 space-y-3">
|
||||
<h4 className="text-sm font-bold text-slate-900">Resumo do agendamento</h4>
|
||||
<div className="bg-slate-50 rounded-lg p-4 space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Serviço:</span>
|
||||
<span className="font-semibold text-slate-900">{selectedService.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Barbeiro:</span>
|
||||
<span className="font-semibold text-slate-900">{selectedBarber?.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Data e hora:</span>
|
||||
<span className="font-semibold text-slate-900">
|
||||
{new Date(date).toLocaleDateString('pt-BR')} às {slot}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t border-slate-200">
|
||||
<span className="font-bold text-slate-900">Total:</span>
|
||||
<span className="font-bold text-lg text-indigo-600">{currency(selectedService.price)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={submit} disabled={!canSubmit} size="lg" className="w-full">
|
||||
{user ? 'Confirmar agendamento' : 'Entrar para agendar'}
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
@@ -92,3 +296,5 @@ export default function Booking() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,139 +1,640 @@
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { useMemo, useState } from 'react';
|
||||
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 { Tabs } from '../components/ui/tabs';
|
||||
import { currency } from '../lib/format';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { Product } from '../types';
|
||||
import { BarChart, Bar, CartesianGrid, ResponsiveContainer, Tooltip, XAxis } from 'recharts';
|
||||
import {
|
||||
BarChart3,
|
||||
Calendar,
|
||||
ShoppingBag,
|
||||
Scissors,
|
||||
Package,
|
||||
Users,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
Plus,
|
||||
Trash2,
|
||||
Minus,
|
||||
Plus as PlusIcon,
|
||||
History,
|
||||
} from 'lucide-react';
|
||||
|
||||
const periods: Record<string, (date: Date) => boolean> = {
|
||||
hoje: (d) => {
|
||||
const now = new Date();
|
||||
return d.toDateString() === now.toDateString();
|
||||
},
|
||||
semana: (d) => {
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
return diff <= 7 * 24 * 60 * 60 * 1000;
|
||||
},
|
||||
mes: (d) => {
|
||||
const now = new Date();
|
||||
return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear();
|
||||
},
|
||||
total: () => true,
|
||||
};
|
||||
|
||||
const parseDate = (value: string) => new Date(value.replace(' ', 'T'));
|
||||
|
||||
type TabId = 'overview' | 'appointments' | 'history' | 'orders' | 'services' | 'products' | 'barbers';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, shops, appointments, orders, updateAppointmentStatus, updateOrderStatus, addService: addServiceStore } = useAppStore();
|
||||
const {
|
||||
user,
|
||||
shops,
|
||||
appointments,
|
||||
orders,
|
||||
updateAppointmentStatus,
|
||||
updateOrderStatus,
|
||||
addService,
|
||||
addProduct,
|
||||
addBarber,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
deleteService,
|
||||
deleteBarber,
|
||||
} = useApp();
|
||||
const shop = shops.find((s) => s.id === user?.shopId);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview');
|
||||
const [period, setPeriod] = useState<keyof typeof periods>('semana');
|
||||
|
||||
// Form states
|
||||
const [svcName, setSvcName] = useState('');
|
||||
const [svcPrice, setSvcPrice] = useState<number>(50);
|
||||
const [svcDuration, setSvcDuration] = useState<number>(30);
|
||||
|
||||
const [prodName, setProdName] = useState('');
|
||||
const [prodPrice, setProdPrice] = useState<number>(30);
|
||||
const [prodStock, setProdStock] = useState<number>(10);
|
||||
|
||||
const [barberName, setBarberName] = useState('');
|
||||
const [barberSpecs, setBarberSpecs] = useState('');
|
||||
|
||||
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 periodMatch = periods[period];
|
||||
const allShopAppointments = appointments.filter((a) => a.shopId === shop.id && periodMatch(parseDate(a.date)));
|
||||
// Agendamentos ativos (não concluídos)
|
||||
const shopAppointments = allShopAppointments.filter((a) => a.status !== 'concluido');
|
||||
// Agendamentos concluídos (histórico)
|
||||
const completedAppointments = allShopAppointments.filter((a) => a.status === 'concluido');
|
||||
// Pedidos apenas com produtos (não serviços)
|
||||
const shopOrders = orders.filter(
|
||||
(o) => o.shopId === shop.id && periodMatch(new Date(o.createdAt)) && o.items.some((item) => item.type === 'product')
|
||||
);
|
||||
|
||||
const addService = () => {
|
||||
addServiceStore(shop.id, { id: nanoid(), name: svcName || 'Novo Serviço', price: Number(svcPrice) || 0, duration: 30, barberIds: [] });
|
||||
const totalRevenue = shopOrders.reduce((s, o) => s + o.total, 0);
|
||||
const pendingAppts = shopAppointments.filter((a) => a.status === 'pendente').length;
|
||||
const confirmedAppts = shopAppointments.filter((a) => a.status === 'confirmado').length;
|
||||
const lowStock = shop.products.filter((p) => p.stock <= 3);
|
||||
|
||||
const comparisonData = useMemo(() => {
|
||||
const totals = shopOrders.reduce(
|
||||
(acc, order) => {
|
||||
order.items.forEach((item) => {
|
||||
if (item.type === 'service') {
|
||||
acc.services += item.qty;
|
||||
} else {
|
||||
acc.products += item.qty;
|
||||
}
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
{ services: 0, products: 0 }
|
||||
);
|
||||
return [
|
||||
{ name: 'Serviços', value: totals.services },
|
||||
{ name: 'Produtos', value: totals.products },
|
||||
];
|
||||
}, [shopOrders]);
|
||||
|
||||
const topServices = useMemo(() => {
|
||||
const map = new Map<string, { name: string; qty: number }>();
|
||||
shopOrders.forEach((o) =>
|
||||
o.items
|
||||
.filter((i) => i.type === 'service')
|
||||
.forEach((i) => {
|
||||
const svc = shop.services.find((s) => s.id === i.refId);
|
||||
if (!svc) return;
|
||||
const prev = map.get(i.refId)?.qty ?? 0;
|
||||
map.set(i.refId, { name: svc.name, qty: prev + i.qty });
|
||||
})
|
||||
);
|
||||
return Array.from(map.values())
|
||||
.sort((a, b) => b.qty - a.qty)
|
||||
.slice(0, 5);
|
||||
}, [shopOrders, shop.services]);
|
||||
|
||||
const topProducts = useMemo(() => {
|
||||
const map = new Map<string, { name: string; qty: number }>();
|
||||
shopOrders.forEach((o) =>
|
||||
o.items
|
||||
.filter((i) => i.type === 'product')
|
||||
.forEach((i) => {
|
||||
const prod = shop.products.find((p) => p.id === i.refId);
|
||||
if (!prod) return;
|
||||
const prev = map.get(i.refId)?.qty ?? 0;
|
||||
map.set(i.refId, { name: prod.name, qty: prev + i.qty });
|
||||
})
|
||||
);
|
||||
return Array.from(map.values())
|
||||
.sort((a, b) => b.qty - a.qty)
|
||||
.slice(0, 5);
|
||||
}, [shopOrders, shop.products]);
|
||||
|
||||
const updateProductStock = (product: Product, delta: number) => {
|
||||
const next = { ...product, stock: Math.max(0, product.stock + delta) };
|
||||
updateProduct(shop.id, next);
|
||||
};
|
||||
|
||||
const addNewService = () => {
|
||||
if (!svcName.trim()) return;
|
||||
addService(shop.id, { name: svcName, price: Number(svcPrice) || 0, duration: Number(svcDuration) || 30, barberIds: [] });
|
||||
setSvcName('');
|
||||
setSvcPrice(50);
|
||||
setSvcDuration(30);
|
||||
};
|
||||
|
||||
const addNewProduct = () => {
|
||||
if (!prodName.trim()) return;
|
||||
addProduct(shop.id, { name: prodName, price: Number(prodPrice) || 0, stock: Number(prodStock) || 0 });
|
||||
setProdName('');
|
||||
setProdPrice(30);
|
||||
setProdStock(10);
|
||||
};
|
||||
|
||||
const addNewBarber = () => {
|
||||
if (!barberName.trim()) return;
|
||||
addBarber(shop.id, {
|
||||
name: barberName,
|
||||
specialties: barberSpecs.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
schedule: [],
|
||||
});
|
||||
setBarberName('');
|
||||
setBarberSpecs('');
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview' as TabId, label: 'Visão Geral', icon: BarChart3 },
|
||||
{ id: 'appointments' as TabId, label: 'Agendamentos', icon: Calendar },
|
||||
{ id: 'history' as TabId, label: 'Histórico', icon: History },
|
||||
{ id: 'orders' as TabId, label: 'Pedidos', icon: ShoppingBag },
|
||||
{ id: 'services' as TabId, label: 'Serviços', icon: Scissors },
|
||||
{ id: 'products' as TabId, label: 'Produtos', icon: Package },
|
||||
{ id: 'barbers' as TabId, label: 'Barbeiros', icon: Users },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-slate-900">Painel da {shop.name}</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-900">{shop.name}</h1>
|
||||
<p className="text-sm text-slate-600">{shop.address}</p>
|
||||
</div>
|
||||
<Badge color="amber">Role: barbearia</Badge>
|
||||
<div className="flex items-center gap-2">
|
||||
{(['hoje', 'semana', 'mes', 'total'] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPeriod(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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DashboardCards />
|
||||
{/* Tabs */}
|
||||
<Tabs tabs={tabs.map((t) => ({ id: t.id, label: t.label }))} active={activeTab} onChange={(v) => setActiveTab(v as TabId)} />
|
||||
|
||||
<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>
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<Card className="p-5 bg-gradient-to-br from-amber-50 to-white">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="p-2 bg-amber-500 rounded-lg text-white">
|
||||
<TrendingUp size={20} />
|
||||
</div>
|
||||
<Badge color="amber" variant="soft">Período</Badge>
|
||||
</div>
|
||||
))}
|
||||
{!shopAppointments.length && <p className="text-sm text-slate-600">Sem agendamentos.</p>}
|
||||
<p className="text-sm text-slate-600 mb-1">Faturamento</p>
|
||||
<p className="text-2xl font-bold text-amber-700">{currency(totalRevenue)}</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="p-2 bg-blue-500 rounded-lg text-white">
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
<Badge color="amber" variant="soft">{pendingAppts}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-1">Pendentes</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{pendingAppts}</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="p-2 bg-emerald-500 rounded-lg text-white">
|
||||
<Calendar size={20} />
|
||||
</div>
|
||||
<Badge color="green" variant="soft">{confirmedAppts}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-1">Confirmados</p>
|
||||
<p className="text-2xl font-bold text-slate-900">{confirmedAppts}</p>
|
||||
</Card>
|
||||
|
||||
<Card className={`p-5 ${lowStock.length > 0 ? 'bg-amber-50 border-amber-200' : ''}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className={`p-2 rounded-lg text-white ${lowStock.length > 0 ? 'bg-amber-500' : 'bg-slate-500'}`}>
|
||||
<AlertTriangle size={20} />
|
||||
</div>
|
||||
{lowStock.length > 0 && <Badge color="amber" variant="solid">{lowStock.length}</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-1">Stock baixo</p>
|
||||
<p className={`text-2xl font-bold ${lowStock.length > 0 ? 'text-amber-700' : 'text-slate-900'}`}>{lowStock.length}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-bold text-slate-900 mb-4">Serviços vs Produtos</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={comparisonData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" fill="#f59e0b" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="p-5">
|
||||
<h3 className="text-base font-bold text-slate-900 mb-3">Top 5 Serviços</h3>
|
||||
<div className="space-y-2">
|
||||
{topServices.length > 0 ? (
|
||||
topServices.map((s, idx) => (
|
||||
<div key={s.name} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-400 font-bold">#{idx + 1}</span>
|
||||
<span className="text-slate-700">{s.name}</span>
|
||||
</div>
|
||||
<Badge color="amber">{s.qty} vendas</Badge>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">Sem vendas no período</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-5">
|
||||
<h3 className="text-base font-bold text-slate-900 mb-3">Top 5 Produtos</h3>
|
||||
<div className="space-y-2">
|
||||
{topProducts.length > 0 ? (
|
||||
topProducts.map((p, idx) => (
|
||||
<div key={p.name} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-400 font-bold">#{idx + 1}</span>
|
||||
<span className="text-slate-700">{p.name}</span>
|
||||
</div>
|
||||
<Badge color="amber">{p.qty} vendas</Badge>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">Sem vendas no período</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'appointments' && (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-slate-900">Agendamentos</h2>
|
||||
<Badge color="slate" variant="soft">{shopAppointments.length} no período</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{shopAppointments.length > 0 ? (
|
||||
shopAppointments.map((a) => {
|
||||
const svc = shop.services.find((s) => s.id === a.serviceId);
|
||||
const barber = shop.barbers.find((b) => b.id === a.barberId);
|
||||
return (
|
||||
<div key={a.id} className="flex items-center justify-between p-4 border border-slate-200 rounded-lg hover:border-amber-300 transition-colors">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-bold text-slate-900">{svc?.name ?? 'Serviço'}</p>
|
||||
<Badge color={a.status === 'pendente' ? 'amber' : a.status === 'confirmado' ? 'green' : a.status === 'concluido' ? 'green' : 'red'}>
|
||||
{a.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">{barber?.name ?? 'Barbeiro'} · {a.date}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{currency(a.total)}</p>
|
||||
</div>
|
||||
<select
|
||||
className="text-sm border border-slate-300 rounded-lg px-3 py-2 focus:border-amber-500 focus:ring-2 focus:ring-amber-500/30"
|
||||
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 === 'pendente' ? 'Pendente' : s === 'confirmado' ? 'Confirmado' : s === 'concluido' ? 'Concluído' : 'Cancelado'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Calendar size={48} className="mx-auto text-slate-300 mb-3" />
|
||||
<p className="text-slate-600 font-medium">Nenhum agendamento no período</p>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
{activeTab === 'history' && (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-slate-900">Histórico de Agendamentos</h2>
|
||||
<Badge color="slate" variant="soft">{completedAppointments.length} concluídos</Badge>
|
||||
</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 className="space-y-3">
|
||||
{completedAppointments.length > 0 ? (
|
||||
completedAppointments.map((a) => {
|
||||
const svc = shop.services.find((s) => s.id === a.serviceId);
|
||||
const barber = shop.barbers.find((b) => b.id === a.barberId);
|
||||
return (
|
||||
<div key={a.id} className="flex items-center justify-between p-4 border border-slate-200 rounded-lg bg-slate-50/50">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-bold text-slate-900">{svc?.name ?? 'Serviço'}</p>
|
||||
<Badge color="green" variant="soft">Concluído</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">{barber?.name ?? 'Barbeiro'} · {a.date}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{currency(a.total)}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<History size={48} className="mx-auto text-slate-300 mb-3" />
|
||||
<p className="text-slate-600 font-medium">Nenhum agendamento concluído no período</p>
|
||||
<p className="text-sm text-slate-500 mt-1">Os agendamentos concluídos aparecerão aqui</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'orders' && (
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-slate-900">Pedidos de Produtos</h2>
|
||||
<Badge color="slate" variant="soft">{shopOrders.length} no período</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{shopOrders.length > 0 ? (
|
||||
shopOrders.map((o) => {
|
||||
const productItems = o.items.filter((i) => i.type === 'product');
|
||||
const productTotal = productItems.reduce((sum, item) => {
|
||||
const prod = shop.products.find((p) => p.id === item.refId);
|
||||
return sum + (prod?.price ?? 0) * item.qty;
|
||||
}, 0);
|
||||
return (
|
||||
<div key={o.id} className="p-4 border border-slate-200 rounded-lg hover:border-amber-300 transition-colors">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-bold text-amber-700">{currency(productTotal)}</p>
|
||||
<Badge color={o.status === 'pendente' ? 'amber' : o.status === 'confirmado' ? 'green' : o.status === 'concluido' ? 'green' : 'red'}>
|
||||
{o.status === 'pendente' ? 'Pendente' : o.status === 'confirmado' ? 'Confirmado' : o.status === 'concluido' ? 'Concluído' : 'Cancelado'}
|
||||
</Badge>
|
||||
</div>
|
||||
<select
|
||||
className="text-sm border border-slate-300 rounded-lg px-3 py-2 focus:border-amber-500 focus:ring-2 focus:ring-amber-500/30"
|
||||
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 === 'pendente' ? 'Pendente' : s === 'confirmado' ? 'Confirmado' : s === 'concluido' ? 'Concluído' : 'Cancelado'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 mb-2">{new Date(o.createdAt).toLocaleString('pt-BR')}</p>
|
||||
<div className="space-y-1">
|
||||
{productItems.map((item) => {
|
||||
const prod = shop.products.find((p) => p.id === item.refId);
|
||||
return (
|
||||
<div key={item.refId} className="flex items-center justify-between text-sm bg-slate-50 rounded px-2 py-1">
|
||||
<span className="text-slate-700">{prod?.name ?? 'Produto'} x{item.qty}</span>
|
||||
<span className="text-amber-600 font-semibold">{currency((prod?.price ?? 0) * item.qty)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<ShoppingBag size={48} className="mx-auto text-slate-300 mb-3" />
|
||||
<p className="text-slate-600 font-medium">Nenhum pedido de produtos no período</p>
|
||||
<p className="text-sm text-slate-500 mt-1">Apenas pedidos com produtos aparecem aqui</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'services' && (
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-slate-900">Serviços</h2>
|
||||
<Badge color="slate" variant="soft">{shop.services.length} serviços</Badge>
|
||||
</div>
|
||||
<div className="space-y-3 mb-6">
|
||||
{shop.services.map((s) => (
|
||||
<div key={s.id} className="flex items-center justify-between p-4 border border-slate-200 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<p className="font-bold text-slate-900">{s.name}</p>
|
||||
<p className="text-sm text-slate-600">Duração: {s.duration} min</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-lg font-bold text-amber-600">{currency(s.price)}</span>
|
||||
<Button variant="danger" size="sm" onClick={() => deleteService(shop.id, s.id)}>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</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 className="border-t border-slate-200 pt-4">
|
||||
<h3 className="text-base font-semibold text-slate-900 mb-3">Adicionar novo serviço</h3>
|
||||
<div className="grid md:grid-cols-4 gap-3">
|
||||
<Input label="Nome" placeholder="Ex: Corte Fade" value={svcName} onChange={(e) => setSvcName(e.target.value)} />
|
||||
<Input label="Preço" type="number" placeholder="50" value={svcPrice} onChange={(e) => setSvcPrice(Number(e.target.value))} />
|
||||
<Input label="Duração (min)" type="number" placeholder="30" value={svcDuration} onChange={(e) => setSvcDuration(Number(e.target.value))} />
|
||||
<div className="flex items-end">
|
||||
<Button onClick={addNewService} className="w-full">
|
||||
<PlusIcon size={16} className="mr-2" />
|
||||
Adicionar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!shopOrders.length && <p className="text-sm text-slate-600">Sem pedidos.</p>}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{activeTab === 'products' && (
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-slate-900">Produtos</h2>
|
||||
<Badge color="slate" variant="soft">{shop.products.length} produtos</Badge>
|
||||
</div>
|
||||
{lowStock.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="text-sm font-semibold text-amber-800 flex items-center gap-2">
|
||||
<AlertTriangle size={16} />
|
||||
Atenção: {lowStock.length} {lowStock.length === 1 ? 'produto com stock baixo' : 'produtos com stock baixo'}
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
<div className="space-y-3 mb-6">
|
||||
{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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-bold text-slate-900">{p.name}</p>
|
||||
{p.stock <= 3 && <Badge color="amber" variant="solid">Stock baixo</Badge>}
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">Stock: {p.stock} unidades</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-lg font-bold text-amber-600">{currency(p.price)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => updateProductStock(p, -1)}>
|
||||
<Minus size={14} />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => updateProductStock(p, 1)}>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => deleteProduct(shop.id, p.id)}>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<h3 className="text-base font-semibold text-slate-900 mb-3">Adicionar novo produto</h3>
|
||||
<div className="grid md:grid-cols-4 gap-3">
|
||||
<Input label="Nome" placeholder="Ex: Pomada" value={prodName} onChange={(e) => setProdName(e.target.value)} />
|
||||
<Input label="Preço" type="number" placeholder="30" value={prodPrice} onChange={(e) => setProdPrice(Number(e.target.value))} />
|
||||
<Input label="Stock inicial" type="number" placeholder="10" value={prodStock} onChange={(e) => setProdStock(Number(e.target.value))} />
|
||||
<div className="flex items-end">
|
||||
<Button onClick={addNewProduct} className="w-full">
|
||||
<PlusIcon size={16} className="mr-2" />
|
||||
Adicionar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{activeTab === 'barbers' && (
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-slate-900">Barbeiros</h2>
|
||||
<Badge color="slate" variant="soft">{shop.barbers.length} barbeiros</Badge>
|
||||
</div>
|
||||
<div className="space-y-3 mb-6">
|
||||
{shop.barbers.map((b) => (
|
||||
<div key={b.id} className="p-4 border border-slate-200 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="font-bold text-slate-900 text-lg">{b.name}</p>
|
||||
<Button variant="danger" size="sm" onClick={() => deleteBarber(shop.id, b.id)}>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-slate-600">
|
||||
<span className="font-medium">Especialidades:</span> {b.specialties.length > 0 ? b.specialties.join(', ') : 'Nenhuma'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{shop.barbers.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Users size={48} className="mx-auto text-slate-300 mb-3" />
|
||||
<p className="text-slate-600 font-medium">Nenhum barbeiro registado</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-slate-200 pt-4">
|
||||
<h3 className="text-base font-semibold text-slate-900 mb-3">Adicionar novo barbeiro</h3>
|
||||
<div className="grid md:grid-cols-3 gap-3">
|
||||
<Input
|
||||
label="Nome"
|
||||
placeholder="Ex: João Silva"
|
||||
value={barberName}
|
||||
onChange={(e) => setBarberName(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Especialidades"
|
||||
placeholder="Fade, Navalha, Barba"
|
||||
value={barberSpecs}
|
||||
onChange={(e) => setBarberSpecs(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-end">
|
||||
<Button onClick={addNewBarber} className="w-full">
|
||||
<PlusIcon size={16} className="mr-2" />
|
||||
Adicionar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">CRUD simplificado; ajuste de stock pode ser adicionado.</p>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { ShopCard } from '../components/ShopCard';
|
||||
import { useApp } from '../context/AppContext';
|
||||
|
||||
export default function Explore() {
|
||||
const shops = useAppStore((s) => s.shops);
|
||||
const { shops } = useApp();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -18,3 +18,5 @@ export default function Explore() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,35 +1,325 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
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,
|
||||
Users, Clock, Shield, TrendingUp, CheckCircle2,
|
||||
ArrowRight, Star, Quote, Scissors, MapPin,
|
||||
Zap, Smartphone, Globe
|
||||
} from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { mockShops } from '../data/mock';
|
||||
|
||||
export default function Landing() {
|
||||
const { user } = useApp();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const target = user.role === 'barbearia' ? '/painel' : '/explorar';
|
||||
navigate(target, { replace: true });
|
||||
}, [user, navigate]);
|
||||
|
||||
const featuredShops = mockShops.slice(0, 3);
|
||||
|
||||
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>
|
||||
<div className="space-y-16 md:space-y-24 pb-12">
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-indigo-600 via-blue-600 to-indigo-700 text-white px-6 py-16 md:px-12 md:py-24 shadow-2xl">
|
||||
<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.
|
||||
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">
|
||||
Explorar barbearias
|
||||
<ArrowRight size={18} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="bg-white text-amber-700 border-white">
|
||||
<Link to="/registo">Criar conta</Link>
|
||||
<Button asChild variant="outline" size="lg" className="bg-white/10 backdrop-blur-sm text-white border-white/30 hover:bg-white/20 text-base px-8 py-4">
|
||||
<Link to="/registo">Criar conta grátis</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-6 pt-8 border-t border-white/20">
|
||||
<div>
|
||||
<div className="text-3xl md:text-4xl font-bold">500+</div>
|
||||
<div className="text-sm text-blue-100/80 mt-1">Barbearias</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl md:text-4xl font-bold">10k+</div>
|
||||
<div className="text-sm text-blue-100/80 mt-1">Agendamentos</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-3xl md:text-4xl font-bold">4.8</div>
|
||||
<div className="text-sm text-blue-100/80 mt-1">Avaliação média</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* Features Grid */}
|
||||
<section>
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-slate-900 mb-4">
|
||||
Tudo que você precisa
|
||||
</h2>
|
||||
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it Works */}
|
||||
<section className="bg-gradient-to-br from-slate-50 to-blue-50/30 rounded-2xl p-8 md:p-12">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-slate-900 mb-4">
|
||||
Como funciona
|
||||
</h2>
|
||||
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
|
||||
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.' },
|
||||
{ step: '2', title: 'Agende', desc: 'Escolha o serviço, barbeiro e horário que melhor se adequa à sua agenda.' },
|
||||
{ step: '3', title: 'Aproveite', desc: 'Compareça no horário agendado e aproveite um serviço de qualidade.' },
|
||||
].map((item) => (
|
||||
<div key={item.step} className="text-center space-y-4">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gradient-to-br from-indigo-500 to-blue-600 text-white text-2xl font-bold shadow-lg">
|
||||
{item.step}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-slate-900">{item.title}</h3>
|
||||
<p className="text-slate-600">{item.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured Shops */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-slate-900 mb-2">
|
||||
Barbearias em destaque
|
||||
</h2>
|
||||
<p className="text-slate-600">
|
||||
Conheça algumas das melhores barbearias da plataforma
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
<Button asChild variant="ghost" className="hidden md:flex">
|
||||
<Link to="/explorar" className="flex items-center gap-2">
|
||||
Ver todas
|
||||
<ArrowRight size={16} />
|
||||
</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>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benefits */}
|
||||
<section className="grid md:grid-cols-2 gap-8">
|
||||
<Card className="p-8 md:p-10 space-y-6">
|
||||
<div className="inline-flex p-3 rounded-xl bg-gradient-to-br from-indigo-500 to-blue-600 text-white shadow-lg">
|
||||
<Smartphone size={28} />
|
||||
</div>
|
||||
<h3 className="text-2xl md:text-3xl font-bold text-slate-900">
|
||||
Mobile-First
|
||||
</h3>
|
||||
<p className="text-slate-600 leading-relaxed">
|
||||
Interface otimizada para dispositivos móveis. Agende de qualquer lugar,
|
||||
a qualquer hora. Experiência fluida e responsiva.
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
{['Design responsivo', 'Carregamento rápido', 'Interface intuitiva'].map((item) => (
|
||||
<li key={item} className="flex items-center gap-2 text-slate-700">
|
||||
<CheckCircle2 size={18} className="text-indigo-600 flex-shrink-0" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card className="p-8 md:p-10 space-y-6">
|
||||
<div className="inline-flex p-3 rounded-xl bg-gradient-to-br from-purple-500 to-purple-600 text-white shadow-lg">
|
||||
<TrendingUp size={28} />
|
||||
</div>
|
||||
<h3 className="text-2xl md:text-3xl font-bold text-slate-900">
|
||||
Aumente sua Receita
|
||||
</h3>
|
||||
<p className="text-slate-600 leading-relaxed">
|
||||
Ferramentas poderosas para gerenciar seu negócio. Análises detalhadas,
|
||||
gestão de estoque e muito mais.
|
||||
</p>
|
||||
<ul className="space-y-3">
|
||||
{['Análises em tempo real', 'Gestão de estoque', 'Relatórios detalhados'].map((item) => (
|
||||
<li key={item} className="flex items-center gap-2 text-slate-700">
|
||||
<CheckCircle2 size={18} className="text-purple-600 flex-shrink-0" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Testimonials */}
|
||||
<section>
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-slate-900 mb-4">
|
||||
O que nossos clientes dizem
|
||||
</h2>
|
||||
<p className="text-lg text-slate-600">
|
||||
Depoimentos reais de quem usa a plataforma
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{[
|
||||
{
|
||||
name: 'João Silva',
|
||||
role: 'Cliente',
|
||||
text: 'Facilita muito agendar meu corte. Interface simples e rápida. Recomendo!',
|
||||
rating: 5
|
||||
},
|
||||
{
|
||||
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',
|
||||
role: 'Cliente',
|
||||
text: 'Nunca mais perco horário. As notificações são muito úteis.',
|
||||
rating: 5
|
||||
},
|
||||
].map((testimonial) => (
|
||||
<Card key={testimonial.name} className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-1">
|
||||
{[...Array(testimonial.rating)].map((_, i) => (
|
||||
<Star key={i} size={16} className="fill-indigo-500 text-indigo-500" />
|
||||
))}
|
||||
</div>
|
||||
<Quote className="text-indigo-500/50" size={24} />
|
||||
<p className="text-slate-700 leading-relaxed">{testimonial.text}</p>
|
||||
<div className="pt-2 border-t border-slate-100">
|
||||
<div className="font-semibold text-slate-900">{testimonial.name}</div>
|
||||
<div className="text-sm text-slate-500">{testimonial.role}</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Final */}
|
||||
<section className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white px-6 py-16 md:px-12 md:py-20 shadow-2xl">
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-indigo-500/10 rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-0 left-0 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl"></div>
|
||||
|
||||
<div className="relative text-center space-y-8 max-w-3xl mx-auto">
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-balance">
|
||||
Pronto para começar?
|
||||
</h2>
|
||||
<p className="text-xl text-slate-300 max-w-2xl mx-auto">
|
||||
Junte-se a centenas de barbearias que já estão usando a Smart Agenda
|
||||
para revolucionar seus negócios.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4 pt-4">
|
||||
<Button asChild size="lg" className="text-base px-8 py-4 bg-white text-slate-900 hover:bg-slate-100">
|
||||
<Link to="/registo" className="flex items-center gap-2">
|
||||
Criar conta grátis
|
||||
<ArrowRight size={18} />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg" className="text-base px-8 py-4 border-white/30 text-white hover:bg-white/10">
|
||||
<Link to="/explorar">Explorar agora</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
@@ -37,3 +327,5 @@ export default function Landing() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import { currency } from '../lib/format';
|
||||
import { useApp } from '../context/AppContext';
|
||||
import { Calendar, ShoppingBag, User, Clock } from 'lucide-react';
|
||||
|
||||
const statusColor: Record<string, 'amber' | 'green' | 'slate' | 'red'> = {
|
||||
pendente: 'amber',
|
||||
@@ -10,59 +11,138 @@ const statusColor: Record<string, 'amber' | 'green' | 'slate' | 'red'> = {
|
||||
cancelado: 'red',
|
||||
};
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
pendente: 'Pendente',
|
||||
confirmado: 'Confirmado',
|
||||
concluido: 'Concluído',
|
||||
cancelado: 'Cancelado',
|
||||
};
|
||||
|
||||
export default function Profile() {
|
||||
const { user, appointments, orders, shops } = useAppStore();
|
||||
if (!user) return <div>Faça login para ver o perfil.</div>;
|
||||
const { user, appointments, orders, shops } = useApp();
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-600">Faça login para ver o perfil.</p>
|
||||
</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 className="space-y-8">
|
||||
{/* Profile Header */}
|
||||
<Card className="p-6 bg-gradient-to-br from-amber-50 to-white">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-4 bg-gradient-to-br from-amber-500 to-amber-600 rounded-xl text-white shadow-lg">
|
||||
<User size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Olá, {user.name}</h1>
|
||||
<p className="text-sm text-slate-600">{user.email}</p>
|
||||
<Badge color="amber" variant="soft" className="mt-2">
|
||||
{user.role === 'cliente' ? 'Cliente' : 'Barbearia'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Appointments Section */}
|
||||
<section className="space-y-4">
|
||||
<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>
|
||||
</div>
|
||||
{!myAppointments.length ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Calendar size={48} className="mx-auto text-slate-300 mb-3" />
|
||||
<p className="text-slate-600 font-medium">Nenhum agendamento ainda</p>
|
||||
<p className="text-sm text-slate-500 mt-1">Explore barbearias e agende seu primeiro serviço!</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{myAppointments.map((a) => {
|
||||
const shop = shops.find((s) => s.id === a.shopId);
|
||||
const service = shop?.services.find((s) => s.id === a.serviceId);
|
||||
return (
|
||||
<Card key={a.id} hover className="p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold text-slate-900">{shop?.name}</h3>
|
||||
<Badge color={statusColor[a.status]} variant="soft">
|
||||
{statusLabel[a.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
{service && (
|
||||
<p className="text-sm text-slate-600 flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
{service.name} · {service.duration} min
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-500">{a.date}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold text-amber-600">{currency(a.total)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
{/* Orders Section */}
|
||||
<section className="space-y-4">
|
||||
<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>
|
||||
</div>
|
||||
{!myOrders.length ? (
|
||||
<Card className="p-8 text-center">
|
||||
<ShoppingBag size={48} className="mx-auto text-slate-300 mb-3" />
|
||||
<p className="text-slate-600 font-medium">Nenhum pedido ainda</p>
|
||||
<p className="text-sm text-slate-500 mt-1">Adicione produtos ao carrinho e finalize seu primeiro pedido!</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{myOrders.map((o) => {
|
||||
const shop = shops.find((s) => s.id === o.shopId);
|
||||
return (
|
||||
<Card key={o.id} hover className="p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold text-slate-900">{shop?.name}</h3>
|
||||
<Badge color={statusColor[o.status]} variant="soft">
|
||||
{statusLabel[o.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{new Date(o.createdAt).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-slate-600">{o.items.length} {o.items.length === 1 ? 'item' : 'itens'}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-lg font-bold text-amber-600">{currency(o.total)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
@@ -70,3 +150,5 @@ export default function Profile() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
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';
|
||||
import { useApp } from '../context/AppContext';
|
||||
|
||||
export default function ShopDetails() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const shops = useAppStore((s) => s.shops);
|
||||
const addToCart = useAppStore((s) => s.addToCart);
|
||||
const { shops, addToCart } = useApp();
|
||||
const shop = useMemo(() => shops.find((s) => s.id === id), [shops, id]);
|
||||
const [tab, setTab] = useState<'servicos' | 'produtos'>('servicos');
|
||||
|
||||
@@ -48,3 +47,5 @@ export default function ShopDetails() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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' }
|
||||
)
|
||||
);
|
||||
|
||||
@@ -3,7 +3,11 @@ import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: { port: 5173 },
|
||||
server: {
|
||||
port: 5173,
|
||||
host: '0.0.0.0', // Permite acesso de outras interfaces
|
||||
strictPort: false, // Tenta outra porta se 5173 estiver ocupada
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user