first commit

This commit is contained in:
2026-01-07 10:35:00 +00:00
parent 13745ac89e
commit 3c7190bca4
53 changed files with 5538 additions and 531 deletions

24
App.tsx
View File

@@ -1,20 +1,16 @@
import React from 'react';
import { StatusBar } from 'expo-status-bar';
import { StyleSheet, Text, View } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { AppProvider } from './src/context/AppContext';
import AppNavigator from './src/navigation/AppNavigator';
export default function App() {
return (
<View style={styles.container}>
<Text>Open up App.tsx to start working on your app!</Text>
<StatusBar style="auto" />
</View>
<SafeAreaProvider>
<AppProvider>
<AppNavigator />
<StatusBar style="auto" />
</AppProvider>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});

59
CONECTAR_ANDROID.md Normal file
View File

@@ -0,0 +1,59 @@
# Como Conectar Android - Guia Passo a Passo
## ✅ Passo 1: Instalar Expo Go no Android
1. Abre a Play Store no teu Android
2. Procura "Expo Go"
3. Instala a aplicação
## ✅ Passo 2: Iniciar o Servidor
Abre um terminal no VSCode e executa:
```bash
cd /Users/230417/SmartAgendaMobile
npm start
```
## ✅ Passo 3: Conectar (3 métodos)
### Método A: QR Code (Recomendado)
1. No terminal, aparece um QR code
2. Abre a app Expo Go no Android
3. Toca em "Scan QR code"
4. Escaneia o QR code do terminal
5. Aguarda o carregamento
### Método B: Link Manual
1. No terminal, aparece um link tipo: `exp://192.168.x.x:8081`
2. Abre a app Expo Go no Android
3. Toca em "Enter URL manually"
4. Cola o link completo (começa com `exp://`)
5. Toca em "Connect"
### Método C: USB (Mais Confiável)
1. Liga o Android ao computador via USB
2. Ativa Depuração USB no Android:
- Definições → Sobre o telefone
- Toca 7 vezes em "Número de compilação"
- Volta → Opções de programador → Ativa "Depuração USB"
3. No terminal do Expo, pressiona `a`
4. A aplicação abre automaticamente
## ⚠️ Problemas Comuns
### "Não consegue conectar"
- Verifica se Android e computador estão na mesma rede Wi-Fi
- Tenta o Método C (USB) que é mais confiável
- Reinicia o servidor: `npm start -- --clear`
### "Link não abre nada"
- Certifica-te que copiaste o link completo (começa com `exp://`)
- Verifica se a Expo Go está atualizada
- Tenta fechar e reabrir a Expo Go
### "Aplicação não carrega"
- Verifica se há erros no terminal
- Tenta `npm start -- --clear` para limpar cache
## 🎯 Dica Final
O método USB (Método C) é geralmente o mais confiável e rápido!

78
README.md Normal file
View File

@@ -0,0 +1,78 @@
# Smart Agenda Mobile
Aplicação mobile React Native/Expo para gestão de agendamentos de barbearias.
## 📱 Funcionalidades
- **Para Clientes:**
- Explorar barbearias
- Agendar serviços (escolher serviço, barbeiro, data e horário)
- Adicionar produtos ao carrinho
- Ver histórico de agendamentos e pedidos
- Perfil pessoal
- **Para Barbearias:**
- Dashboard completo com métricas
- Gestão de agendamentos (alterar status)
- Gestão de pedidos de produtos
- CRUD de serviços
- CRUD de produtos com controlo de stock
- CRUD de barbeiros
- Histórico de agendamentos concluídos
## 🚀 Instalação
1. Instala as dependências:
```bash
npm install
```
2. Inicia o servidor de desenvolvimento:
```bash
npm start
```
3. Escolhe a plataforma:
- Pressiona `a` para Android
- Pressiona `i` para iOS
- Pressiona `w` para Web
- Escaneia o QR code com a app Expo Go no teu telemóvel
## 📦 Dependências Principais
- Expo ~54.0.27
- React Native 0.81.5
- React Navigation (navegação)
- AsyncStorage (persistência de dados)
- Nanoid (geração de IDs)
## 🔐 Credenciais Demo
- **Cliente:** `cliente@demo.com` / `123`
- **Barbearia:** `barber@demo.com` / `123`
## 📁 Estrutura do Projeto
```
src/
├── components/ # Componentes UI reutilizáveis
├── context/ # Context API (estado global)
├── data/ # Dados mock
├── lib/ # Utilitários (format, storage)
├── navigation/ # Configuração de navegação
├── pages/ # Páginas da aplicação
└── types.ts # Definições de tipos TypeScript
```
## 🛠️ Scripts Disponíveis
- `npm start` - Inicia o servidor Expo
- `npm run android` - Abre no Android
- `npm run ios` - Abre no iOS
- `npm run web` - Abre no navegador
## 📱 Publicar na Play Store
Ver instruções no ficheiro `app.json` e documentação do Expo EAS Build.

View File

@@ -1,7 +1,7 @@
{
"expo": {
"name": "SmartAgendaMobile",
"slug": "SmartAgendaMobile",
"name": "Smart Agenda",
"slug": "smart-agenda",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
@@ -13,18 +13,27 @@
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
"supportsTablet": true,
"bundleIdentifier": "com.smartagenda.app"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.smartagenda.app",
"versionCode": 1,
"permissions": [],
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"extra": {
"eas": {
"projectId": "your-project-id-here"
}
}
}
}

View File

@@ -1,8 +1,4 @@
import { registerRootComponent } from 'expo';
import App from './App';
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
// It also ensures that whether you load the app in Expo Go or in a native build,
// the environment is set up appropriately
registerRootComponent(App);

426
package-lock.json generated
View File

@@ -8,13 +8,21 @@
"name": "smartagendamobile",
"version": "1.0.0",
"dependencies": {
"@react-native-async-storage/async-storage": "1.23.1",
"@react-navigation/bottom-tabs": "^6.5.11",
"@react-navigation/native": "^6.1.9",
"@react-navigation/native-stack": "^6.9.17",
"expo": "~54.0.27",
"expo-status-bar": "~3.0.9",
"nanoid": "^5.0.7",
"react": "19.1.0",
"react-native": "0.81.5"
"react-native": "0.81.5",
"react-native-safe-area-context": "4.10.1",
"react-native-screens": "~3.31.1"
},
"devDependencies": {
"@types/react": "~19.1.0",
"@types/react-native": "^0.73.0",
"typescript": "~5.9.2"
}
},
@@ -2716,6 +2724,18 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@react-native-async-storage/async-storage": {
"version": "1.23.1",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.23.1.tgz",
"integrity": "sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA==",
"license": "MIT",
"dependencies": {
"merge-options": "^3.0.4"
},
"peerDependencies": {
"react-native": "^0.0.0-0 || >=0.60 <1.0"
}
},
"node_modules/@react-native/assets-registry": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz",
@@ -2970,6 +2990,180 @@
"integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==",
"license": "MIT"
},
"node_modules/@react-navigation/bottom-tabs": {
"version": "6.6.1",
"resolved": "https://registry.npmjs.org/@react-navigation/bottom-tabs/-/bottom-tabs-6.6.1.tgz",
"integrity": "sha512-9oD4cypEBjPuaMiu9tevWGiQ4w/d6l3HNhcJ1IjXZ24xvYDSs0mqjUcdt8SWUolCvRrYc/DmNBLlT83bk0bHTw==",
"license": "MIT",
"dependencies": {
"@react-navigation/elements": "^1.3.31",
"color": "^4.2.3",
"warn-once": "^0.1.0"
},
"peerDependencies": {
"@react-navigation/native": "^6.0.0",
"react": "*",
"react-native": "*",
"react-native-safe-area-context": ">= 3.0.0",
"react-native-screens": ">= 3.0.0"
}
},
"node_modules/@react-navigation/core": {
"version": "6.4.17",
"resolved": "https://registry.npmjs.org/@react-navigation/core/-/core-6.4.17.tgz",
"integrity": "sha512-Nd76EpomzChWAosGqWOYE3ItayhDzIEzzZsT7PfGcRFDgW5miHV2t4MZcq9YIK4tzxZjVVpYbIynOOQQd1e0Cg==",
"license": "MIT",
"dependencies": {
"@react-navigation/routers": "^6.1.9",
"escape-string-regexp": "^4.0.0",
"nanoid": "^3.1.23",
"query-string": "^7.1.3",
"react-is": "^16.13.0",
"use-latest-callback": "^0.2.1"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/@react-navigation/core/node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@react-navigation/core/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/@react-navigation/core/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/@react-navigation/elements": {
"version": "1.3.31",
"resolved": "https://registry.npmjs.org/@react-navigation/elements/-/elements-1.3.31.tgz",
"integrity": "sha512-bUzP4Awlljx5RKEExw8WYtif8EuQni2glDaieYROKTnaxsu9kEIA515sXQgUDZU4Ob12VoL7+z70uO3qrlfXcQ==",
"license": "MIT",
"peerDependencies": {
"@react-navigation/native": "^6.0.0",
"react": "*",
"react-native": "*",
"react-native-safe-area-context": ">= 3.0.0"
}
},
"node_modules/@react-navigation/native": {
"version": "6.1.18",
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-6.1.18.tgz",
"integrity": "sha512-mIT9MiL/vMm4eirLcmw2h6h/Nm5FICtnYSdohq4vTLA2FF/6PNhByM7s8ffqoVfE5L0uAa6Xda1B7oddolUiGg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@react-navigation/core": "^6.4.17",
"escape-string-regexp": "^4.0.0",
"fast-deep-equal": "^3.1.3",
"nanoid": "^3.1.23"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/@react-navigation/native-stack": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-6.11.0.tgz",
"integrity": "sha512-U5EcUB9Q2NQspCFwYGGNJm0h6wBCOv7T30QjndmvlawLkNt7S7KWbpWyxS9XBHSIKF57RgWjfxuJNTgTstpXxw==",
"license": "MIT",
"dependencies": {
"@react-navigation/elements": "^1.3.31",
"warn-once": "^0.1.0"
},
"peerDependencies": {
"@react-navigation/native": "^6.0.0",
"react": "*",
"react-native": "*",
"react-native-safe-area-context": ">= 3.0.0",
"react-native-screens": ">= 3.0.0"
}
},
"node_modules/@react-navigation/native/node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@react-navigation/native/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/@react-navigation/routers": {
"version": "6.1.9",
"resolved": "https://registry.npmjs.org/@react-navigation/routers/-/routers-6.1.9.tgz",
"integrity": "sha512-lTM8gSFHSfkJvQkxacGM6VJtBt61ip2XO54aNfswD+KMw6eeZ4oehl7m0me3CR9hnDE4+60iAZR8sAhvCiI3NA==",
"license": "MIT",
"dependencies": {
"nanoid": "^3.1.23"
}
},
"node_modules/@react-navigation/routers/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -3088,6 +3282,17 @@
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-native": {
"version": "0.73.0",
"resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.73.0.tgz",
"integrity": "sha512-6ZRPQrYM72qYKGWidEttRe6M5DZBEV5F+MHMHqd4TTYx0tfkcdrUFGdef6CCxY0jXU7wldvd/zA/b0A/kTeJmA==",
"deprecated": "This is a stub types definition. react-native provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT",
"dependencies": {
"react-native": "*"
}
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -3914,6 +4119,19 @@
"node": ">=0.8"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@@ -3929,6 +4147,34 @@
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"license": "MIT"
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/color/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/commander": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
@@ -4094,6 +4340,15 @@
}
}
},
"node_modules/decode-uri-component": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
"integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
@@ -4299,6 +4554,7 @@
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.27.tgz",
"integrity": "sha512-50BcJs8eqGwRiMUoWwphkRGYtKFS2bBnemxLzy0lrGVA1E6F4Q7L5h3WT6w1ehEZybtOVkfJu4Z6GWo2IJcpEA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.0",
"@expo/cli": "54.0.18",
@@ -4883,6 +5139,12 @@
"integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==",
"license": "Apache-2.0"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -4910,6 +5172,15 @@
"node": ">=8"
}
},
"node_modules/filter-obj": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/finalhandler": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
@@ -5278,6 +5549,12 @@
"loose-envify": "^1.0.0"
}
},
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@@ -5326,6 +5603,15 @@
"node": ">=0.12.0"
}
},
"node_modules/is-plain-obj": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
@@ -6224,6 +6510,18 @@
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
"node_modules/merge-options": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
"integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==",
"license": "MIT",
"dependencies": {
"is-plain-obj": "^2.1.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -6713,9 +7011,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz",
"integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==",
"funding": [
{
"type": "github",
@@ -6724,10 +7022,10 @@
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
"nanoid": "bin/nanoid.js"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
"node": "^18 || >=20"
}
},
"node_modules/negotiator": {
@@ -7121,6 +7419,24 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss/node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/pretty-bytes": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
@@ -7216,6 +7532,24 @@
"qrcode-terminal": "bin/qrcode-terminal.js"
}
},
"node_modules/query-string": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
"integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==",
"license": "MIT",
"dependencies": {
"decode-uri-component": "^0.2.2",
"filter-obj": "^1.1.0",
"split-on-first": "^1.0.0",
"strict-uri-encode": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
@@ -7268,6 +7602,18 @@
"ws": "^7"
}
},
"node_modules/react-freeze": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
"integrity": "sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=17.0.0"
}
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
@@ -7341,6 +7687,32 @@
"react-native": "*"
}
},
"node_modules/react-native-safe-area-context": {
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.10.1.tgz",
"integrity": "sha512-w8tCuowDorUkPoWPXmhqosovBr33YsukkwYCDERZFHAxIkx6qBadYxfeoaJ91nCQKjkNzGrK5qhoNOeSIcYSpA==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native-screens": {
"version": "3.31.1",
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-3.31.1.tgz",
"integrity": "sha512-8fRW362pfZ9y4rS8KY5P3DFScrmwo/vu1RrRMMx0PNHbeC9TLq0Kw1ubD83591yz64gLNHFLTVkTJmWeWCXKtQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"react-freeze": "^1.0.0",
"warn-once": "^0.1.0"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/react-native/node_modules/@react-native/virtualized-lists": {
"version": "0.81.5",
"resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz",
@@ -7855,6 +8227,15 @@
"plist": "^3.0.5"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -7916,6 +8297,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split-on-first": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
@@ -7979,6 +8369,15 @@
"node": ">= 0.10.0"
}
},
"node_modules/strict-uri-encode": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
"integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -8484,6 +8883,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-latest-callback": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/use-latest-callback/-/use-latest-callback-0.2.6.tgz",
"integrity": "sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -8535,6 +8943,12 @@
"makeerror": "1.0.12"
}
},
"node_modules/warn-once": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz",
"integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==",
"license": "MIT"
},
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",

View File

@@ -11,11 +11,19 @@
"dependencies": {
"expo": "~54.0.27",
"expo-status-bar": "~3.0.9",
"@react-navigation/native": "^6.1.9",
"@react-navigation/native-stack": "^6.9.17",
"@react-navigation/bottom-tabs": "^6.5.11",
"react-native-screens": "~3.31.1",
"react-native-safe-area-context": "4.10.1",
"@react-native-async-storage/async-storage": "1.23.1",
"react": "19.1.0",
"react-native": "0.81.5"
"react-native": "0.81.5",
"nanoid": "^5.0.7"
},
"devDependencies": {
"@types/react": "~19.1.0",
"@types/react-native": "^0.73.0",
"typescript": "~5.9.2"
},
"private": true

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
type Props = {
children: React.ReactNode;
color?: 'amber' | 'slate' | 'green' | 'red' | 'blue';
variant?: 'solid' | 'soft';
style?: ViewStyle;
};
const colorMap = {
solid: {
amber: { bg: '#f59e0b', text: '#fff' },
slate: { bg: '#475569', text: '#fff' },
green: { bg: '#10b981', text: '#fff' },
red: { bg: '#ef4444', text: '#fff' },
blue: { bg: '#3b82f6', text: '#fff' },
},
soft: {
amber: { bg: '#fef3c7', text: '#92400e' },
slate: { bg: '#f1f5f9', text: '#475569' },
green: { bg: '#d1fae5', text: '#065f46' },
red: { bg: '#fee2e2', text: '#991b1b' },
blue: { bg: '#dbeafe', text: '#1e40af' },
},
};
export const Badge = ({ children, color = 'amber', variant = 'soft', style }: Props) => {
const colors = colorMap[variant][color];
return (
<View style={[styles.badge, { backgroundColor: colors.bg }, style]}>
<Text style={[styles.text, { color: colors.text }]}>{children}</Text>
</View>
);
};
const styles = StyleSheet.create({
badge: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 12,
alignSelf: 'flex-start',
},
text: {
fontSize: 12,
fontWeight: '600',
},
});

View File

@@ -0,0 +1,103 @@
import React from 'react';
import { TouchableOpacity, Text, StyleSheet, ActivityIndicator, ViewStyle, TextStyle } from 'react-native';
type Props = {
children: React.ReactNode;
onPress?: () => void;
variant?: 'solid' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
style?: ViewStyle;
textStyle?: TextStyle;
};
export const Button = ({ children, onPress, variant = 'solid', size = 'md', disabled, loading, style, textStyle }: Props) => {
const buttonStyle = [
styles.base,
styles[variant],
styles[`size_${size}`],
(disabled || loading) && styles.disabled,
style,
];
const textStyles = [
styles.text,
styles[`text_${variant}`],
styles[`textSize_${size}`],
textStyle,
];
return (
<TouchableOpacity
style={buttonStyle}
onPress={onPress}
disabled={disabled || loading}
activeOpacity={0.7}
>
{loading ? (
<ActivityIndicator color={variant === 'solid' ? '#fff' : '#f59e0b'} />
) : (
<Text style={textStyles}>{children}</Text>
)}
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
base: {
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
solid: {
backgroundColor: '#f59e0b',
},
outline: {
backgroundColor: 'transparent',
borderWidth: 2,
borderColor: '#f59e0b',
},
ghost: {
backgroundColor: 'transparent',
},
size_sm: {
paddingHorizontal: 12,
paddingVertical: 8,
},
size_md: {
paddingHorizontal: 16,
paddingVertical: 10,
},
size_lg: {
paddingHorizontal: 24,
paddingVertical: 14,
},
disabled: {
opacity: 0.5,
},
text: {
fontWeight: '600',
},
text_solid: {
color: '#fff',
},
text_outline: {
color: '#f59e0b',
},
text_ghost: {
color: '#f59e0b',
},
textSize_sm: {
fontSize: 12,
},
textSize_md: {
fontSize: 14,
},
textSize_lg: {
fontSize: 16,
},
});

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { View, StyleSheet, ViewStyle } from 'react-native';
type Props = {
children: React.ReactNode;
style?: ViewStyle;
};
export const Card = ({ children, style }: Props) => {
return <View style={[styles.card, style]}>{children}</View>;
};
const styles = StyleSheet.create({
card: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.1,
shadowRadius: 2,
elevation: 2,
borderWidth: 1,
borderColor: '#e2e8f0',
},
});

View File

@@ -0,0 +1,53 @@
import React from 'react';
import { TextInput, View, Text, StyleSheet, TextInputProps } from 'react-native';
type Props = TextInputProps & {
label?: string;
error?: string;
};
export const Input = ({ label, error, style, ...props }: Props) => {
return (
<View style={styles.container}>
{label && <Text style={styles.label}>{label}</Text>}
<TextInput
style={[styles.input, error && styles.inputError, style]}
placeholderTextColor="#94a3b8"
{...props}
/>
{error && <Text style={styles.error}>{error}</Text>}
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#334155',
marginBottom: 6,
},
input: {
borderWidth: 1,
borderColor: '#cbd5e1',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 14,
color: '#0f172a',
backgroundColor: '#fff',
},
inputError: {
borderColor: '#ef4444',
},
error: {
fontSize: 12,
color: '#ef4444',
marginTop: 4,
},
});

323
src/context/AppContext.tsx Normal file
View File

@@ -0,0 +1,323 @@
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>(initialState);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadData = async () => {
try {
const saved = await storage.get('smart-agenda', initialState);
setState(saved);
} catch (err) {
console.error('Error loading data:', err);
} finally {
setIsLoading(false);
}
};
loadData();
}, []);
useEffect(() => {
if (!isLoading) {
storage.set('smart-agenda', state);
}
}, [state, isLoading]);
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]
);
if (isLoading) {
return null; // Ou um componente de loading
}
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;
};

40
src/data/mock.ts Normal file
View File

@@ -0,0 +1,40 @@
import { BarberShop, User } from '../types';
export const mockUsers: User[] = [
{ id: 'u1', name: 'Cliente Demo', email: 'cliente@demo.com', password: '123', role: 'cliente' },
{ id: 'u2', name: 'Barbearia Demo', email: 'barber@demo.com', password: '123', role: 'barbearia', shopId: 's1' },
];
export const mockShops: BarberShop[] = [
{
id: 's1',
name: 'Barbearia Central',
address: 'Rua Principal, 123',
rating: 4.7,
barbers: [
{ id: 'b1', name: 'João', specialties: ['Fade', 'Navalha'], schedule: [{ day: '2025-01-01', slots: ['09:00', '10:00', '11:00'] }] },
{ id: 'b2', name: 'Carlos', specialties: ['Barba', 'Clássico'], schedule: [{ day: '2025-01-01', slots: ['14:00', '15:00'] }] },
],
services: [
{ id: 'sv1', name: 'Corte Fade', price: 60, duration: 45, barberIds: ['b1'] },
{ id: 'sv2', name: 'Barba Completa', price: 40, duration: 30, barberIds: ['b2'] },
],
products: [
{ id: 'p1', name: 'Pomada Matte', price: 35, stock: 8 },
{ id: 'p2', name: 'Óleo para Barba', price: 45, stock: 5 },
],
},
{
id: 's2',
name: 'Barbearia Bairro',
address: 'Av. Verde, 45',
rating: 4.5,
barbers: [
{ id: 'b3', name: 'Miguel', specialties: ['Clássico', 'Fade'], schedule: [{ day: '2025-01-01', slots: ['09:30', '10:30'] }] },
],
services: [{ id: 'sv3', name: 'Corte Clássico', price: 50, duration: 40, barberIds: ['b3'] }],
products: [{ id: 'p3', name: 'Shampoo Masculino', price: 30, stock: 10 }],
},
];

3
src/lib/format.ts Normal file
View File

@@ -0,0 +1,3 @@
export const currency = (v: number) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });

23
src/lib/storage.ts Normal file
View File

@@ -0,0 +1,23 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
export const storage = {
async get<T>(key: string, fallback: T): Promise<T> {
try {
const raw = await AsyncStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw) as T;
} catch (err) {
console.error('storage parse error', err);
return fallback;
}
},
async set<T>(key: string, value: T): Promise<void> {
try {
await AsyncStorage.setItem(key, JSON.stringify(value));
} catch (err) {
console.error('storage set error', err);
}
},
};

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useApp } from '../context/AppContext';
import Landing from '../pages/Landing';
import AuthLogin from '../pages/AuthLogin';
import AuthRegister from '../pages/AuthRegister';
import Explore from '../pages/Explore';
import ShopDetails from '../pages/ShopDetails';
import Booking from '../pages/Booking';
import Cart from '../pages/Cart';
import Profile from '../pages/Profile';
import Dashboard from '../pages/Dashboard';
import { RootStackParamList } from './types';
const Stack = createNativeStackNavigator<RootStackParamList>();
export default function AppNavigator() {
const { user } = useApp();
return (
<NavigationContainer>
<Stack.Navigator
screenOptions={{
headerStyle: { backgroundColor: '#f59e0b' },
headerTintColor: '#fff',
headerTitleStyle: { fontWeight: 'bold' },
}}
>
{!user ? (
<>
<Stack.Screen name="Landing" component={Landing} options={{ headerShown: false }} />
<Stack.Screen name="Login" component={AuthLogin} options={{ title: 'Entrar' }} />
<Stack.Screen name="Register" component={AuthRegister} options={{ title: 'Criar Conta' }} />
<Stack.Screen name="Explore" component={Explore} options={{ title: 'Explorar' }} />
<Stack.Screen name="ShopDetails" component={ShopDetails} options={{ title: 'Detalhes' }} />
<Stack.Screen name="Booking" component={Booking} options={{ title: 'Agendar' }} />
<Stack.Screen name="Cart" component={Cart} options={{ title: 'Carrinho' }} />
</>
) : user.role === 'barbearia' ? (
<>
<Stack.Screen name="Dashboard" component={Dashboard} options={{ title: 'Painel', headerShown: false }} />
<Stack.Screen name="Profile" component={Profile} options={{ title: 'Perfil' }} />
</>
) : (
<>
<Stack.Screen name="Explore" component={Explore} options={{ title: 'Explorar' }} />
<Stack.Screen name="ShopDetails" component={ShopDetails} options={{ title: 'Detalhes' }} />
<Stack.Screen name="Booking" component={Booking} options={{ title: 'Agendar' }} />
<Stack.Screen name="Cart" component={Cart} options={{ title: 'Carrinho' }} />
<Stack.Screen name="Profile" component={Profile} options={{ title: 'Perfil' }} />
</>
)}
</Stack.Navigator>
</NavigationContainer>
);
}

19
src/navigation/types.ts Normal file
View File

@@ -0,0 +1,19 @@
export type RootStackParamList = {
Landing: undefined;
Login: undefined;
Register: undefined;
Explore: undefined;
ShopDetails: { shopId: string };
Booking: { shopId: string };
Cart: undefined;
Profile: undefined;
Dashboard: undefined;
};
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}

148
src/pages/AuthLogin.tsx Normal file
View File

@@ -0,0 +1,148 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, Alert } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useApp } from '../context/AppContext';
import { Button } from '../components/ui/Button';
import { Input } from '../components/ui/Input';
import { Card } from '../components/ui/Card';
export default function AuthLogin() {
const navigation = useNavigation();
const { login } = useApp();
const [email, setEmail] = useState('cliente@demo.com');
const [password, setPassword] = useState('123');
const [error, setError] = useState('');
const handleSubmit = () => {
setError('');
const ok = login(email, password);
if (!ok) {
setError('Credenciais inválidas');
Alert.alert('Erro', 'Credenciais inválidas');
} else {
navigation.navigate('Explore' as never);
}
};
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Card style={styles.card}>
<Text style={styles.title}>Bem-vindo de volta</Text>
<Text style={styles.subtitle}>Entre na sua conta para continuar</Text>
<View style={styles.demoBox}>
<Text style={styles.demoTitle}>💡 Conta demo:</Text>
<Text style={styles.demoText}>Cliente: cliente@demo.com / 123</Text>
<Text style={styles.demoText}>Barbearia: barber@demo.com / 123</Text>
</View>
<Input
label="Email"
value={email}
onChangeText={(text) => {
setEmail(text);
setError('');
}}
keyboardType="email-address"
autoCapitalize="none"
placeholder="seu@email.com"
/>
<Input
label="Senha"
value={password}
onChangeText={(text) => {
setPassword(text);
setError('');
}}
secureTextEntry
placeholder="••••••••"
error={error}
/>
<Button onPress={handleSubmit} style={styles.submitButton} size="lg">
Entrar
</Button>
<View style={styles.footer}>
<Text style={styles.footerText}>Não tem conta? </Text>
<Text
style={styles.footerLink}
onPress={() => navigation.navigate('Register' as never)}
>
Criar conta
</Text>
</View>
</Card>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
content: {
padding: 16,
justifyContent: 'center',
minHeight: '100%',
},
card: {
padding: 24,
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#0f172a',
marginBottom: 8,
textAlign: 'center',
},
subtitle: {
fontSize: 14,
color: '#64748b',
marginBottom: 24,
textAlign: 'center',
},
demoBox: {
backgroundColor: '#fef3c7',
borderWidth: 1,
borderColor: '#fbbf24',
borderRadius: 8,
padding: 12,
marginBottom: 20,
},
demoTitle: {
fontSize: 12,
fontWeight: '600',
color: '#92400e',
marginBottom: 4,
},
demoText: {
fontSize: 11,
color: '#92400e',
},
submitButton: {
width: '100%',
marginTop: 8,
},
footer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: 24,
paddingTop: 24,
borderTopWidth: 1,
borderTopColor: '#e2e8f0',
},
footerText: {
fontSize: 14,
color: '#64748b',
},
footerLink: {
fontSize: 14,
color: '#f59e0b',
fontWeight: '600',
},
});

182
src/pages/AuthRegister.tsx Normal file
View File

@@ -0,0 +1,182 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, Alert, TouchableOpacity } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useApp } from '../context/AppContext';
import { Button } from '../components/ui/Button';
import { Input } from '../components/ui/Input';
import { Card } from '../components/ui/Card';
export default function AuthRegister() {
const navigation = useNavigation();
const { register } = useApp();
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 handleSubmit = () => {
setError('');
const ok = register({ name, email, password, role, shopName });
if (!ok) {
setError('Email já registado');
Alert.alert('Erro', 'Email já registado');
} else {
navigation.navigate('Explore' as never);
}
};
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Card style={styles.card}>
<Text style={styles.title}>Criar conta</Text>
<Text style={styles.subtitle}>Escolha o tipo de acesso</Text>
<View style={styles.roleContainer}>
<TouchableOpacity
style={[styles.roleButton, role === 'cliente' && styles.roleButtonActive]}
onPress={() => setRole('cliente')}
>
<Text style={[styles.roleText, role === 'cliente' && styles.roleTextActive]}>
Cliente
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.roleButton, role === 'barbearia' && styles.roleButtonActive]}
onPress={() => setRole('barbearia')}
>
<Text style={[styles.roleText, role === 'barbearia' && styles.roleTextActive]}>
Barbearia
</Text>
</TouchableOpacity>
</View>
<Input
label="Nome completo"
value={name}
onChangeText={setName}
placeholder="João Silva"
/>
<Input
label="Email"
value={email}
onChangeText={(text) => {
setEmail(text);
setError('');
}}
keyboardType="email-address"
autoCapitalize="none"
placeholder="seu@email.com"
error={error}
/>
<Input
label="Senha"
value={password}
onChangeText={setPassword}
secureTextEntry
placeholder="••••••••"
/>
{role === 'barbearia' && (
<Input
label="Nome da barbearia"
value={shopName}
onChangeText={setShopName}
placeholder="Barbearia XPTO"
/>
)}
<Button onPress={handleSubmit} style={styles.submitButton} size="lg">
Criar conta
</Button>
<View style={styles.footer}>
<Text style={styles.footerText}> tem conta? </Text>
<Text
style={styles.footerLink}
onPress={() => navigation.navigate('Login' as never)}
>
Entrar
</Text>
</View>
</Card>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
content: {
padding: 16,
},
card: {
padding: 24,
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#0f172a',
marginBottom: 8,
textAlign: 'center',
},
subtitle: {
fontSize: 14,
color: '#64748b',
marginBottom: 24,
textAlign: 'center',
},
roleContainer: {
flexDirection: 'row',
gap: 12,
marginBottom: 20,
},
roleButton: {
flex: 1,
padding: 16,
borderRadius: 12,
borderWidth: 2,
borderColor: '#e2e8f0',
alignItems: 'center',
},
roleButtonActive: {
borderColor: '#f59e0b',
backgroundColor: '#fef3c7',
},
roleText: {
fontSize: 14,
fontWeight: '600',
color: '#64748b',
},
roleTextActive: {
color: '#f59e0b',
},
submitButton: {
width: '100%',
marginTop: 8,
},
footer: {
flexDirection: 'row',
justifyContent: 'center',
marginTop: 24,
paddingTop: 24,
borderTopWidth: 1,
borderTopColor: '#e2e8f0',
},
footerText: {
fontSize: 14,
color: '#64748b',
},
footerLink: {
fontSize: 14,
color: '#f59e0b',
fontWeight: '600',
},
});

298
src/pages/Booking.tsx Normal file
View File

@@ -0,0 +1,298 @@
import React, { useState, useMemo } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Alert } from 'react-native';
import { useRoute, useNavigation } from '@react-navigation/native';
import { useApp } from '../context/AppContext';
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 { currency } from '../lib/format';
export default function Booking() {
const route = useRoute();
const navigation = useNavigation();
const { shopId } = route.params as { shopId: string };
const { shops, createAppointment, user, appointments } = useApp();
const shop = useMemo(() => shops.find((s) => s.id === shopId), [shops, shopId]);
const [serviceId, setService] = useState('');
const [barberId, setBarber] = useState('');
const [date, setDate] = useState('');
const [slot, setSlot] = useState('');
if (!shop) {
return (
<View style={styles.container}>
<Text>Barbearia não encontrada</Text>
</View>
);
}
const selectedService = shop.services.find((s) => s.id === serviceId);
const selectedBarber = shop.barbers.find((b) => b.id === barberId);
const generateDefaultSlots = (): string[] => {
const slots: string[] = [];
for (let hour = 9; hour <= 18; hour++) {
slots.push(`${hour.toString().padStart(2, '0')}:00`);
}
return slots;
};
const availableSlots = useMemo(() => {
if (!selectedBarber || !date) return [];
const specificSchedule = selectedBarber.schedule.find((s) => s.day === date);
let slots = specificSchedule && specificSchedule.slots.length > 0
? [...specificSchedule.slots]
: generateDefaultSlots();
const bookedSlots = appointments
.filter((apt) =>
apt.barberId === barberId &&
apt.status !== 'cancelado' &&
apt.date.startsWith(date)
)
.map((apt) => {
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) {
Alert.alert('Login necessário', 'Faça login para agendar');
navigation.navigate('Login' as never);
return;
}
if (!canSubmit) return;
const appt = createAppointment({ shopId: shop.id, serviceId, barberId, customerId: user.id, date: `${date} ${slot}` });
if (appt) {
Alert.alert('Sucesso', 'Agendamento criado com sucesso!');
navigation.navigate('Profile' as never);
} else {
Alert.alert('Erro', 'Horário indisponível');
}
};
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>Agendar em {shop.name}</Text>
<Card style={styles.card}>
<Text style={styles.sectionTitle}>1. Escolha o serviço</Text>
<View style={styles.grid}>
{shop.services.map((s) => (
<TouchableOpacity
key={s.id}
style={[styles.serviceButton, serviceId === s.id && styles.serviceButtonActive]}
onPress={() => setService(s.id)}
>
<Text style={[styles.serviceText, serviceId === s.id && styles.serviceTextActive]}>{s.name}</Text>
<Text style={styles.servicePrice}>{currency(s.price)}</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.sectionTitle}>2. Escolha o barbeiro</Text>
<View style={styles.barberContainer}>
{shop.barbers.map((b) => (
<TouchableOpacity
key={b.id}
style={[styles.barberButton, barberId === b.id && styles.barberButtonActive]}
onPress={() => setBarber(b.id)}
>
<Text style={[styles.barberText, barberId === b.id && styles.barberTextActive]}>
{b.name}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.sectionTitle}>3. Escolha a data</Text>
<Input
value={date}
onChangeText={setDate}
placeholder="YYYY-MM-DD"
/>
<Text style={styles.sectionTitle}>4. Escolha o horário</Text>
<View style={styles.slotsContainer}>
{availableSlots.length > 0 ? (
availableSlots.map((h) => (
<TouchableOpacity
key={h}
style={[styles.slotButton, slot === h && styles.slotButtonActive]}
onPress={() => setSlot(h)}
>
<Text style={[styles.slotText, slot === h && styles.slotTextActive]}>{h}</Text>
</TouchableOpacity>
))
) : (
<Text style={styles.noSlots}>Escolha primeiro o barbeiro e a data</Text>
)}
</View>
{canSubmit && selectedService && (
<View style={styles.summary}>
<Text style={styles.summaryTitle}>Resumo</Text>
<Text style={styles.summaryText}>Serviço: {selectedService.name}</Text>
<Text style={styles.summaryText}>Barbeiro: {selectedBarber?.name}</Text>
<Text style={styles.summaryText}>Data: {date} às {slot}</Text>
<Text style={styles.summaryTotal}>Total: {currency(selectedService.price)}</Text>
</View>
)}
<Button onPress={submit} disabled={!canSubmit} style={styles.submitButton} size="lg">
{user ? 'Confirmar agendamento' : 'Entrar para agendar'}
</Button>
</Card>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
content: {
padding: 16,
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#0f172a',
marginBottom: 16,
},
card: {
padding: 20,
},
sectionTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#0f172a',
marginTop: 16,
marginBottom: 12,
},
grid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginBottom: 16,
},
serviceButton: {
flex: 1,
minWidth: '45%',
padding: 16,
borderRadius: 8,
borderWidth: 2,
borderColor: '#e2e8f0',
marginBottom: 8,
},
serviceButtonActive: {
borderColor: '#f59e0b',
backgroundColor: '#fef3c7',
},
serviceText: {
fontSize: 14,
fontWeight: '600',
color: '#0f172a',
marginBottom: 4,
},
serviceTextActive: {
color: '#f59e0b',
},
servicePrice: {
fontSize: 12,
color: '#64748b',
},
barberContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginBottom: 16,
},
barberButton: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 20,
borderWidth: 2,
borderColor: '#e2e8f0',
},
barberButtonActive: {
borderColor: '#f59e0b',
backgroundColor: '#f59e0b',
},
barberText: {
fontSize: 14,
fontWeight: '600',
color: '#64748b',
},
barberTextActive: {
color: '#fff',
},
slotsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginBottom: 16,
},
slotButton: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 8,
borderWidth: 2,
borderColor: '#e2e8f0',
},
slotButtonActive: {
borderColor: '#f59e0b',
backgroundColor: '#f59e0b',
},
slotText: {
fontSize: 14,
fontWeight: '600',
color: '#64748b',
},
slotTextActive: {
color: '#fff',
},
noSlots: {
fontSize: 14,
color: '#94a3b8',
fontStyle: 'italic',
},
summary: {
backgroundColor: '#f1f5f9',
padding: 16,
borderRadius: 8,
marginBottom: 16,
},
summaryTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#0f172a',
marginBottom: 8,
},
summaryText: {
fontSize: 14,
color: '#64748b',
marginBottom: 4,
},
summaryTotal: {
fontSize: 18,
fontWeight: 'bold',
color: '#f59e0b',
marginTop: 8,
},
submitButton: {
width: '100%',
marginTop: 16,
},
});

167
src/pages/Cart.tsx Normal file
View File

@@ -0,0 +1,167 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, Alert } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useApp } from '../context/AppContext';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { Badge } from '../components/ui/Badge';
import { currency } from '../lib/format';
export default function Cart() {
const navigation = useNavigation();
const { cart, shops, removeFromCart, placeOrder, user } = useApp();
if (!cart.length) {
return (
<View style={styles.container}>
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Carrinho vazio</Text>
</Card>
</View>
);
}
const grouped = cart.reduce<Record<string, typeof cart>>((acc, item) => {
acc[item.shopId] = acc[item.shopId] || [];
acc[item.shopId].push(item);
return acc;
}, {});
const handleCheckout = (shopId: string) => {
if (!user) {
Alert.alert('Login necessário', 'Faça login para finalizar o pedido');
navigation.navigate('Login' as never);
return;
}
const order = placeOrder(user.id, shopId);
if (order) {
Alert.alert('Sucesso', 'Pedido criado com sucesso!');
}
};
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.title}>Carrinho</Text>
{Object.entries(grouped).map(([shopId, items]) => {
const shop = shops.find((s) => s.id === shopId);
const total = items.reduce((sum, i) => {
const price =
i.type === 'service'
? shop?.services.find((s) => s.id === i.refId)?.price ?? 0
: shop?.products.find((p) => p.id === i.refId)?.price ?? 0;
return sum + price * i.qty;
}, 0);
return (
<Card key={shopId} style={styles.shopCard}>
<View style={styles.shopHeader}>
<View>
<Text style={styles.shopName}>{shop?.name ?? 'Barbearia'}</Text>
<Text style={styles.shopAddress}>{shop?.address}</Text>
</View>
<Text style={styles.total}>{currency(total)}</Text>
</View>
{items.map((i) => {
const ref =
i.type === 'service'
? shop?.services.find((s) => s.id === i.refId)
: shop?.products.find((p) => p.id === i.refId);
return (
<View key={i.refId} style={styles.item}>
<Text style={styles.itemText}>
{i.type === 'service' ? 'Serviço: ' : 'Produto: '}
{ref?.name ?? 'Item'} x{i.qty}
</Text>
<Button
onPress={() => removeFromCart(i.refId)}
variant="ghost"
size="sm"
>
Remover
</Button>
</View>
);
})}
{user ? (
<Button onPress={() => handleCheckout(shopId)} style={styles.checkoutButton}>
Finalizar pedido
</Button>
) : (
<Button
onPress={() => navigation.navigate('Login' as never)}
style={styles.checkoutButton}
>
Entrar para finalizar
</Button>
)}
</Card>
);
})}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
content: {
padding: 16,
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#0f172a',
marginBottom: 16,
},
emptyCard: {
padding: 32,
alignItems: 'center',
},
emptyText: {
fontSize: 16,
color: '#64748b',
},
shopCard: {
marginBottom: 16,
},
shopHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 12,
},
shopName: {
fontSize: 16,
fontWeight: 'bold',
color: '#0f172a',
},
shopAddress: {
fontSize: 12,
color: '#64748b',
},
total: {
fontSize: 18,
fontWeight: 'bold',
color: '#f59e0b',
},
item: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: '#e2e8f0',
},
itemText: {
fontSize: 14,
color: '#64748b',
flex: 1,
},
checkoutButton: {
width: '100%',
marginTop: 12,
},
});

541
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,541 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Alert } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useApp } from '../context/AppContext';
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 { currency } from '../lib/format';
export default function Dashboard() {
const navigation = useNavigation();
const {
user,
shops,
appointments,
orders,
updateAppointmentStatus,
updateOrderStatus,
addService,
addProduct,
addBarber,
updateProduct,
deleteProduct,
deleteService,
deleteBarber,
logout,
} = useApp();
const shop = shops.find((s) => s.id === user?.shopId);
const [activeTab, setActiveTab] = useState<'overview' | 'appointments' | 'orders' | 'services' | 'products' | 'barbers'>('overview');
const [svcName, setSvcName] = useState('');
const [svcPrice, setSvcPrice] = useState('50');
const [svcDuration, setSvcDuration] = useState('30');
const [prodName, setProdName] = useState('');
const [prodPrice, setProdPrice] = useState('30');
const [prodStock, setProdStock] = useState('10');
const [barberName, setBarberName] = useState('');
const [barberSpecs, setBarberSpecs] = useState('');
if (!user || user.role !== 'barbearia') {
return (
<View style={styles.container}>
<Text>Área exclusiva para barbearias</Text>
</View>
);
}
if (!shop) {
return (
<View style={styles.container}>
<Text>Barbearia não encontrada</Text>
</View>
);
}
const shopAppointments = appointments.filter((a) => a.shopId === shop.id);
const shopOrders = orders.filter((o) => o.shopId === shop.id);
const completedAppointments = shopAppointments.filter((a) => a.status === 'concluido');
const activeAppointments = shopAppointments.filter((a) => a.status !== 'concluido');
const productOrders = shopOrders.filter((o) => o.items.some((i) => i.type === 'product'));
const totalRevenue = shopOrders.reduce((s, o) => s + o.total, 0);
const lowStock = shop.products.filter((p) => p.stock <= 3);
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');
Alert.alert('Sucesso', 'Serviço adicionado');
};
const addNewProduct = () => {
if (!prodName.trim()) return;
addProduct(shop.id, { name: prodName, price: Number(prodPrice) || 0, stock: Number(prodStock) || 0 });
setProdName('');
setProdPrice('30');
setProdStock('10');
Alert.alert('Sucesso', 'Produto adicionado');
};
const addNewBarber = () => {
if (!barberName.trim()) return;
addBarber(shop.id, {
name: barberName,
specialties: barberSpecs.split(',').map((s) => s.trim()).filter(Boolean),
schedule: [],
});
setBarberName('');
setBarberSpecs('');
Alert.alert('Sucesso', 'Barbeiro adicionado');
};
const updateProductStock = (productId: string, delta: number) => {
const product = shop.products.find((p) => p.id === productId);
if (!product) return;
const next = { ...product, stock: Math.max(0, product.stock + delta) };
updateProduct(shop.id, next);
};
const tabs = [
{ id: 'overview', label: 'Visão Geral' },
{ id: 'appointments', label: 'Agendamentos' },
{ id: 'orders', label: 'Pedidos' },
{ id: 'services', label: 'Serviços' },
{ id: 'products', label: 'Produtos' },
{ id: 'barbers', label: 'Barbeiros' },
];
return (
<View style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>{shop.name}</Text>
<Button onPress={logout} variant="ghost" size="sm">
Sair
</Button>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.tabsContainer}>
{tabs.map((tab) => (
<TouchableOpacity
key={tab.id}
style={[styles.tab, activeTab === tab.id && styles.tabActive]}
onPress={() => setActiveTab(tab.id as any)}
>
<Text style={[styles.tabText, activeTab === tab.id && styles.tabTextActive]}>
{tab.label}
</Text>
</TouchableOpacity>
))}
</ScrollView>
<ScrollView style={styles.content} contentContainerStyle={styles.contentInner}>
{activeTab === 'overview' && (
<View>
<View style={styles.statsGrid}>
<Card style={styles.statCard}>
<Text style={styles.statLabel}>Faturamento</Text>
<Text style={styles.statValue}>{currency(totalRevenue)}</Text>
</Card>
<Card style={styles.statCard}>
<Text style={styles.statLabel}>Pendentes</Text>
<Text style={styles.statValue}>{activeAppointments.length}</Text>
</Card>
<Card style={styles.statCard}>
<Text style={styles.statLabel}>Concluídos</Text>
<Text style={styles.statValue}>{completedAppointments.length}</Text>
</Card>
<Card style={styles.statCard}>
<Text style={styles.statLabel}>Stock baixo</Text>
<Text style={[styles.statValue, lowStock.length > 0 && styles.statValueWarning]}>
{lowStock.length}
</Text>
</Card>
</View>
</View>
)}
{activeTab === 'appointments' && (
<View>
{activeAppointments.length > 0 ? (
activeAppointments.map((a) => {
const svc = shop.services.find((s) => s.id === a.serviceId);
const barber = shop.barbers.find((b) => b.id === a.barberId);
return (
<Card key={a.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<View>
<Text style={styles.itemName}>{svc?.name ?? 'Serviço'}</Text>
<Text style={styles.itemDesc}>{barber?.name} · {a.date}</Text>
</View>
<Badge color={a.status === 'pendente' ? 'amber' : a.status === 'confirmado' ? 'green' : 'red'}>
{a.status}
</Badge>
</View>
<View style={styles.statusSelector}>
<Text style={styles.selectorLabel}>Alterar status:</Text>
<View style={styles.statusButtons}>
{['pendente', 'confirmado', 'concluido', 'cancelado'].map((s) => (
<Button
key={s}
onPress={() => updateAppointmentStatus(a.id, s as any)}
variant={a.status === s ? 'solid' : 'outline'}
size="sm"
style={styles.statusButton}
>
{s}
</Button>
))}
</View>
</View>
</Card>
);
})
) : (
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Nenhum agendamento ativo</Text>
</Card>
)}
</View>
)}
{activeTab === 'orders' && (
<View>
{productOrders.length > 0 ? (
productOrders.map((o) => (
<Card key={o.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<View>
<Text style={styles.itemName}>{currency(o.total)}</Text>
<Text style={styles.itemDesc}>{new Date(o.createdAt).toLocaleString('pt-BR')}</Text>
</View>
<Badge color={o.status === 'pendente' ? 'amber' : o.status === 'confirmado' ? 'green' : 'red'}>
{o.status}
</Badge>
</View>
<View style={styles.statusButtons}>
{['pendente', 'confirmado', 'concluido', 'cancelado'].map((s) => (
<Button
key={s}
onPress={() => updateOrderStatus(o.id, s as any)}
variant={o.status === s ? 'solid' : 'outline'}
size="sm"
style={styles.statusButton}
>
{s}
</Button>
))}
</View>
</Card>
))
) : (
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Nenhum pedido de produtos</Text>
</Card>
)}
</View>
)}
{activeTab === 'services' && (
<View>
{shop.services.map((s) => (
<Card key={s.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<View>
<Text style={styles.itemName}>{s.name}</Text>
<Text style={styles.itemDesc}>Duração: {s.duration} min</Text>
</View>
<Text style={styles.itemPrice}>{currency(s.price)}</Text>
</View>
<Button
onPress={() => {
Alert.alert('Confirmar', 'Deseja remover este serviço?', [
{ text: 'Cancelar', style: 'cancel' },
{ text: 'Remover', style: 'destructive', onPress: () => deleteService(shop.id, s.id) },
]);
}}
variant="outline"
size="sm"
style={styles.deleteButton}
>
Remover
</Button>
</Card>
))}
<Card style={styles.formCard}>
<Text style={styles.formTitle}>Adicionar serviço</Text>
<Input label="Nome" value={svcName} onChangeText={setSvcName} placeholder="Ex: Corte Fade" />
<Input label="Preço" value={svcPrice} onChangeText={setSvcPrice} keyboardType="numeric" placeholder="50" />
<Input label="Duração (min)" value={svcDuration} onChangeText={setSvcDuration} keyboardType="numeric" placeholder="30" />
<Button onPress={addNewService} style={styles.addButton}>
Adicionar
</Button>
</Card>
</View>
)}
{activeTab === 'products' && (
<View>
{lowStock.length > 0 && (
<Card style={styles.alertCard}>
<Text style={styles.alertText}>
Atenção: {lowStock.length} {lowStock.length === 1 ? 'produto com stock baixo' : 'produtos com stock baixo'}
</Text>
</Card>
)}
{shop.products.map((p) => (
<Card key={p.id} style={[styles.itemCard, p.stock <= 3 && styles.itemCardWarning]}>
<View style={styles.itemHeader}>
<View>
<Text style={styles.itemName}>{p.name}</Text>
<Text style={styles.itemDesc}>Stock: {p.stock} unidades</Text>
</View>
<Text style={styles.itemPrice}>{currency(p.price)}</Text>
</View>
<View style={styles.stockControls}>
<Button onPress={() => updateProductStock(p.id, -1)} variant="outline" size="sm" style={styles.stockButton}>
-1
</Button>
<Button onPress={() => updateProductStock(p.id, 1)} variant="outline" size="sm" style={styles.stockButton}>
+1
</Button>
<Button
onPress={() => {
Alert.alert('Confirmar', 'Deseja remover este produto?', [
{ text: 'Cancelar', style: 'cancel' },
{ text: 'Remover', style: 'destructive', onPress: () => deleteProduct(shop.id, p.id) },
]);
}}
variant="outline"
size="sm"
style={styles.stockButton}
>
Remover
</Button>
</View>
</Card>
))}
<Card style={styles.formCard}>
<Text style={styles.formTitle}>Adicionar produto</Text>
<Input label="Nome" value={prodName} onChangeText={setProdName} placeholder="Ex: Pomada" />
<Input label="Preço" value={prodPrice} onChangeText={setProdPrice} keyboardType="numeric" placeholder="30" />
<Input label="Stock inicial" value={prodStock} onChangeText={setProdStock} keyboardType="numeric" placeholder="10" />
<Button onPress={addNewProduct} style={styles.addButton}>
Adicionar
</Button>
</Card>
</View>
)}
{activeTab === 'barbers' && (
<View>
{shop.barbers.map((b) => (
<Card key={b.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<View>
<Text style={styles.itemName}>{b.name}</Text>
<Text style={styles.itemDesc}>
Especialidades: {b.specialties.length > 0 ? b.specialties.join(', ') : 'Nenhuma'}
</Text>
</View>
</View>
<Button
onPress={() => {
Alert.alert('Confirmar', 'Deseja remover este barbeiro?', [
{ text: 'Cancelar', style: 'cancel' },
{ text: 'Remover', style: 'destructive', onPress: () => deleteBarber(shop.id, b.id) },
]);
}}
variant="outline"
size="sm"
style={styles.deleteButton}
>
Remover
</Button>
</Card>
))}
<Card style={styles.formCard}>
<Text style={styles.formTitle}>Adicionar barbeiro</Text>
<Input label="Nome" value={barberName} onChangeText={setBarberName} placeholder="Ex: João Silva" />
<Input label="Especialidades" value={barberSpecs} onChangeText={setBarberSpecs} placeholder="Fade, Navalha, Barba" />
<Button onPress={addNewBarber} style={styles.addButton}>
Adicionar
</Button>
</Card>
</View>
)}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e2e8f0',
},
title: {
fontSize: 20,
fontWeight: 'bold',
color: '#0f172a',
},
tabsContainer: {
backgroundColor: '#fff',
borderBottomWidth: 1,
borderBottomColor: '#e2e8f0',
},
tab: {
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: 2,
borderBottomColor: 'transparent',
},
tabActive: {
borderBottomColor: '#f59e0b',
},
tabText: {
fontSize: 14,
fontWeight: '600',
color: '#64748b',
},
tabTextActive: {
color: '#f59e0b',
},
content: {
flex: 1,
},
contentInner: {
padding: 16,
},
statsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
marginBottom: 16,
},
statCard: {
flex: 1,
minWidth: '45%',
padding: 16,
},
statLabel: {
fontSize: 12,
color: '#64748b',
marginBottom: 4,
},
statValue: {
fontSize: 20,
fontWeight: 'bold',
color: '#0f172a',
},
statValueWarning: {
color: '#f59e0b',
},
itemCard: {
marginBottom: 12,
padding: 16,
},
itemCardWarning: {
borderColor: '#fbbf24',
backgroundColor: '#fef3c7',
},
itemHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 12,
},
itemName: {
fontSize: 16,
fontWeight: 'bold',
color: '#0f172a',
flex: 1,
},
itemDesc: {
fontSize: 14,
color: '#64748b',
marginTop: 4,
},
itemPrice: {
fontSize: 18,
fontWeight: 'bold',
color: '#f59e0b',
},
statusSelector: {
marginTop: 8,
},
selectorLabel: {
fontSize: 12,
color: '#64748b',
marginBottom: 8,
},
statusButtons: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
statusButton: {
flex: 1,
minWidth: '22%',
},
emptyCard: {
padding: 32,
alignItems: 'center',
},
emptyText: {
fontSize: 14,
color: '#64748b',
},
alertCard: {
backgroundColor: '#fef3c7',
borderColor: '#fbbf24',
marginBottom: 16,
padding: 16,
},
alertText: {
fontSize: 14,
fontWeight: '600',
color: '#92400e',
},
formCard: {
marginTop: 16,
padding: 16,
},
formTitle: {
fontSize: 16,
fontWeight: 'bold',
color: '#0f172a',
marginBottom: 16,
},
addButton: {
width: '100%',
marginTop: 8,
},
deleteButton: {
width: '100%',
marginTop: 8,
},
stockControls: {
flexDirection: 'row',
gap: 8,
marginTop: 8,
},
stockButton: {
flex: 1,
},
});

109
src/pages/Explore.tsx Normal file
View File

@@ -0,0 +1,109 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, FlatList } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useApp } from '../context/AppContext';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { Badge } from '../components/ui/Badge';
import { currency } from '../lib/format';
export default function Explore() {
const navigation = useNavigation();
const { shops } = useApp();
return (
<View style={styles.container}>
<Text style={styles.title}>Explorar barbearias</Text>
<FlatList
data={shops}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
renderItem={({ item: shop }) => (
<Card style={styles.shopCard}>
<View style={styles.shopHeader}>
<Text style={styles.shopName}>{shop.name}</Text>
<Badge color="amber">{shop.rating.toFixed(1)} </Badge>
</View>
<Text style={styles.shopAddress}>{shop.address}</Text>
<View style={styles.shopInfo}>
<Text style={styles.shopInfoText}>{shop.services.length} serviços</Text>
<Text style={styles.shopInfoText}></Text>
<Text style={styles.shopInfoText}>{shop.barbers.length} barbeiros</Text>
</View>
<View style={styles.buttons}>
<Button
onPress={() => navigation.navigate('ShopDetails' as never, { shopId: shop.id } as never)}
variant="outline"
style={styles.button}
>
Ver detalhes
</Button>
<Button
onPress={() => navigation.navigate('Booking' as never, { shopId: shop.id } as never)}
style={styles.button}
>
Agendar
</Button>
</View>
</Card>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
padding: 16,
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#0f172a',
marginBottom: 16,
},
list: {
gap: 16,
},
shopCard: {
marginBottom: 12,
},
shopHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
shopName: {
fontSize: 18,
fontWeight: 'bold',
color: '#0f172a',
flex: 1,
},
shopAddress: {
fontSize: 14,
color: '#64748b',
marginBottom: 8,
},
shopInfo: {
flexDirection: 'row',
gap: 8,
marginBottom: 12,
},
shopInfoText: {
fontSize: 12,
color: '#94a3b8',
},
buttons: {
flexDirection: 'row',
gap: 8,
marginTop: 8,
},
button: {
flex: 1,
},
});

119
src/pages/Landing.tsx Normal file
View File

@@ -0,0 +1,119 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { Button } from '../components/ui/Button';
import { Card } from '../components/ui/Card';
export default function Landing() {
const navigation = useNavigation();
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.hero}>
<Text style={styles.heroTitle}>Smart Agenda</Text>
<Text style={styles.heroSubtitle}>
Agendamentos, produtos e gestão em um único lugar.
</Text>
<Text style={styles.heroDesc}>
Experiência mobile-first para clientes e painel completo para barbearias.
</Text>
<View style={styles.buttons}>
<Button
onPress={() => navigation.navigate('Explore' as never)}
style={styles.button}
size="lg"
>
Explorar barbearias
</Button>
<Button
onPress={() => navigation.navigate('Register' as never)}
variant="outline"
style={styles.button}
size="lg"
>
Criar conta
</Button>
</View>
</View>
<View style={styles.features}>
<Card style={styles.featureCard}>
<Text style={styles.featureTitle}>Agendamentos</Text>
<Text style={styles.featureDesc}>
Escolha serviço, barbeiro, data e horário com validação de slots.
</Text>
</Card>
<Card style={styles.featureCard}>
<Text style={styles.featureTitle}>Carrinho</Text>
<Text style={styles.featureDesc}>
Produtos e serviços agrupados por barbearia, pagamento rápido.
</Text>
</Card>
<Card style={styles.featureCard}>
<Text style={styles.featureTitle}>Painel</Text>
<Text style={styles.featureDesc}>
Faturamento, agendamentos, pedidos, barbearia no controle.
</Text>
</Card>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
content: {
padding: 16,
},
hero: {
backgroundColor: '#f59e0b',
borderRadius: 16,
padding: 24,
marginBottom: 24,
},
heroTitle: {
fontSize: 28,
fontWeight: 'bold',
color: '#fff',
marginBottom: 8,
},
heroSubtitle: {
fontSize: 20,
fontWeight: '600',
color: '#fff',
marginBottom: 8,
},
heroDesc: {
fontSize: 16,
color: '#fef3c7',
marginBottom: 20,
},
buttons: {
gap: 12,
},
button: {
width: '100%',
},
features: {
gap: 16,
},
featureCard: {
marginBottom: 12,
},
featureTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#0f172a',
marginBottom: 8,
},
featureDesc: {
fontSize: 14,
color: '#64748b',
lineHeight: 20,
},
});

165
src/pages/Profile.tsx Normal file
View File

@@ -0,0 +1,165 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useApp } from '../context/AppContext';
import { Card } from '../components/ui/Card';
import { Badge } from '../components/ui/Badge';
import { Button } from '../components/ui/Button';
import { currency } from '../lib/format';
const statusColor: Record<string, 'amber' | 'green' | 'slate' | 'red'> = {
pendente: 'amber',
confirmado: 'green',
concluido: 'green',
cancelado: 'red',
};
export default function Profile() {
const navigation = useNavigation();
const { user, appointments, orders, shops, logout } = useApp();
if (!user) {
return (
<View style={styles.container}>
<Text>Faça login para ver o perfil</Text>
</View>
);
}
const myAppointments = appointments.filter((a) => a.customerId === user.id);
const myOrders = orders.filter((o) => o.customerId === user.id);
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Card style={styles.profileCard}>
<Text style={styles.profileName}>Olá, {user.name}</Text>
<Text style={styles.profileEmail}>{user.email}</Text>
<Badge color="amber" style={styles.roleBadge}>
{user.role === 'cliente' ? 'Cliente' : 'Barbearia'}
</Badge>
<Button onPress={logout} variant="outline" style={styles.logoutButton}>
Sair
</Button>
</Card>
<Text style={styles.sectionTitle}>Agendamentos</Text>
{myAppointments.length > 0 ? (
myAppointments.map((a) => {
const shop = shops.find((s) => s.id === a.shopId);
return (
<Card key={a.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<Text style={styles.itemName}>{shop?.name}</Text>
<Badge color={statusColor[a.status]}>{a.status}</Badge>
</View>
<Text style={styles.itemDate}>{a.date}</Text>
<Text style={styles.itemTotal}>{currency(a.total)}</Text>
</Card>
);
})
) : (
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Nenhum agendamento ainda</Text>
</Card>
)}
<Text style={styles.sectionTitle}>Pedidos</Text>
{myOrders.length > 0 ? (
myOrders.map((o) => {
const shop = shops.find((s) => s.id === o.shopId);
return (
<Card key={o.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<Text style={styles.itemName}>{shop?.name}</Text>
<Badge color={statusColor[o.status]}>{o.status}</Badge>
</View>
<Text style={styles.itemDate}>
{new Date(o.createdAt).toLocaleString('pt-BR')}
</Text>
<Text style={styles.itemTotal}>{currency(o.total)}</Text>
</Card>
);
})
) : (
<Card style={styles.emptyCard}>
<Text style={styles.emptyText}>Nenhum pedido ainda</Text>
</Card>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
content: {
padding: 16,
},
profileCard: {
marginBottom: 24,
padding: 20,
},
profileName: {
fontSize: 24,
fontWeight: 'bold',
color: '#0f172a',
marginBottom: 4,
},
profileEmail: {
fontSize: 14,
color: '#64748b',
marginBottom: 12,
},
roleBadge: {
alignSelf: 'flex-start',
marginBottom: 16,
},
logoutButton: {
width: '100%',
},
sectionTitle: {
fontSize: 20,
fontWeight: 'bold',
color: '#0f172a',
marginBottom: 12,
marginTop: 8,
},
itemCard: {
marginBottom: 12,
padding: 16,
},
itemHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
},
itemName: {
fontSize: 16,
fontWeight: 'bold',
color: '#0f172a',
flex: 1,
},
itemDate: {
fontSize: 14,
color: '#64748b',
marginBottom: 4,
},
itemTotal: {
fontSize: 16,
fontWeight: 'bold',
color: '#f59e0b',
},
emptyCard: {
padding: 32,
alignItems: 'center',
},
emptyText: {
fontSize: 14,
color: '#64748b',
},
});

184
src/pages/ShopDetails.tsx Normal file
View File

@@ -0,0 +1,184 @@
import React, { useState, useMemo } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
import { useRoute, useNavigation } from '@react-navigation/native';
import { useApp } from '../context/AppContext';
import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { Badge } from '../components/ui/Badge';
import { currency } from '../lib/format';
export default function ShopDetails() {
const route = useRoute();
const navigation = useNavigation();
const { shopId } = route.params as { shopId: string };
const { shops, addToCart } = useApp();
const shop = useMemo(() => shops.find((s) => s.id === shopId), [shops, shopId]);
const [tab, setTab] = useState<'servicos' | 'produtos'>('servicos');
if (!shop) {
return (
<View style={styles.container}>
<Text>Barbearia não encontrada</Text>
</View>
);
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.header}>
<Text style={styles.title}>{shop.name}</Text>
<Text style={styles.address}>{shop.address}</Text>
<Button
onPress={() => navigation.navigate('Booking' as never, { shopId: shop.id } as never)}
style={styles.bookButton}
>
Agendar
</Button>
</View>
<View style={styles.tabs}>
<TouchableOpacity
style={[styles.tab, tab === 'servicos' && styles.tabActive]}
onPress={() => setTab('servicos')}
>
<Text style={[styles.tabText, tab === 'servicos' && styles.tabTextActive]}>Serviços</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.tab, tab === 'produtos' && styles.tabActive]}
onPress={() => setTab('produtos')}
>
<Text style={[styles.tabText, tab === 'produtos' && styles.tabTextActive]}>Produtos</Text>
</TouchableOpacity>
</View>
{tab === 'servicos' ? (
<View style={styles.list}>
{shop.services.map((service) => (
<Card key={service.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<Text style={styles.itemName}>{service.name}</Text>
<Text style={styles.itemPrice}>{currency(service.price)}</Text>
</View>
<Text style={styles.itemDesc}>Duração: {service.duration} min</Text>
<Button
onPress={() => addToCart({ shopId: shop.id, type: 'service', refId: service.id, qty: 1 })}
size="sm"
style={styles.addButton}
>
Adicionar ao carrinho
</Button>
</Card>
))}
</View>
) : (
<View style={styles.list}>
{shop.products.map((product) => (
<Card key={product.id} style={styles.itemCard}>
<View style={styles.itemHeader}>
<Text style={styles.itemName}>{product.name}</Text>
<Text style={styles.itemPrice}>{currency(product.price)}</Text>
</View>
<Text style={styles.itemDesc}>Stock: {product.stock} unidades</Text>
{product.stock <= 3 && <Badge color="amber" style={styles.stockBadge}>Stock baixo</Badge>}
<Button
onPress={() => addToCart({ shopId: shop.id, type: 'product', refId: product.id, qty: 1 })}
size="sm"
style={styles.addButton}
disabled={product.stock <= 0}
>
{product.stock > 0 ? 'Adicionar ao carrinho' : 'Sem stock'}
</Button>
</Card>
))}
</View>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f8fafc',
},
content: {
padding: 16,
},
header: {
marginBottom: 20,
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#0f172a',
marginBottom: 8,
},
address: {
fontSize: 14,
color: '#64748b',
marginBottom: 16,
},
bookButton: {
width: '100%',
},
tabs: {
flexDirection: 'row',
gap: 8,
marginBottom: 16,
},
tab: {
flex: 1,
padding: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: '#e2e8f0',
alignItems: 'center',
},
tabActive: {
borderColor: '#f59e0b',
backgroundColor: '#fef3c7',
},
tabText: {
fontSize: 14,
fontWeight: '600',
color: '#64748b',
},
tabTextActive: {
color: '#f59e0b',
},
list: {
gap: 12,
},
itemCard: {
marginBottom: 12,
},
itemHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 8,
},
itemName: {
fontSize: 16,
fontWeight: 'bold',
color: '#0f172a',
flex: 1,
},
itemPrice: {
fontSize: 16,
fontWeight: 'bold',
color: '#f59e0b',
},
itemDesc: {
fontSize: 14,
color: '#64748b',
marginBottom: 12,
},
stockBadge: {
marginBottom: 8,
},
addButton: {
width: '100%',
},
});

12
src/types.ts Normal file
View File

@@ -0,0 +1,12 @@
export type Barber = { id: string; name: string; specialties: string[]; schedule: { day: string; slots: string[] }[] };
export type Service = { id: string; name: string; price: number; duration: number; barberIds: string[] };
export type Product = { id: string; name: string; price: number; stock: number };
export type BarberShop = { id: string; name: string; address: string; rating: number; services: Service[]; products: Product[]; barbers: Barber[] };
export type AppointmentStatus = 'pendente' | 'confirmado' | 'concluido' | 'cancelado';
export type OrderStatus = 'pendente' | 'confirmado' | 'concluido' | 'cancelado';
export type Appointment = { id: string; shopId: string; serviceId: string; barberId: string; customerId: string; date: string; status: AppointmentStatus; total: number };
export type CartItem = { shopId: string; type: 'service' | 'product'; refId: string; qty: number };
export type Order = { id: string; shopId: string; customerId: string; items: CartItem[]; total: number; status: OrderStatus; createdAt: string };
export type User = { id: string; name: string; email: string; password: string; role: 'cliente' | 'barbearia'; shopId?: string };

162
web/OPCOES_CORES.md Normal file
View 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
View 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).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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