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