From 3c7190bca4838bc763c58d80a54a7bb1b8bc26d0 Mon Sep 17 00:00:00 2001 From: 230417 <230417@epvc.pt> Date: Wed, 7 Jan 2026 10:35:00 +0000 Subject: [PATCH] first commit --- App.tsx | 24 +- CONECTAR_ANDROID.md | 59 +++ README.md | 78 +++ app.json | 15 +- index.ts | 4 - package-lock.json | 426 +++++++++++++++- package.json | 10 +- src/components/ui/Badge.tsx | 51 ++ src/components/ui/Button.tsx | 103 ++++ src/components/ui/Card.tsx | 28 ++ src/components/ui/Input.tsx | 53 ++ src/context/AppContext.tsx | 323 ++++++++++++ src/data/mock.ts | 40 ++ src/lib/format.ts | 3 + src/lib/storage.ts | 23 + src/navigation/AppNavigator.tsx | 59 +++ src/navigation/types.ts | 19 + src/pages/AuthLogin.tsx | 148 ++++++ src/pages/AuthRegister.tsx | 182 +++++++ src/pages/Booking.tsx | 298 +++++++++++ src/pages/Cart.tsx | 167 +++++++ src/pages/Dashboard.tsx | 541 ++++++++++++++++++++ src/pages/Explore.tsx | 109 ++++ src/pages/Landing.tsx | 119 +++++ src/pages/Profile.tsx | 165 ++++++ src/pages/ShopDetails.tsx | 184 +++++++ src/types.ts | 12 + web/OPCOES_CORES.md | 162 ++++++ web/README.md | 31 ++ web/src/components/CartPanel.tsx | 8 +- web/src/components/DashboardCards.tsx | 24 +- web/src/components/ProductList.tsx | 52 +- web/src/components/ServiceList.tsx | 26 +- web/src/components/ShopCard.tsx | 46 +- web/src/components/layout/Header.tsx | 126 ++++- web/src/components/ui/badge.tsx | 28 +- web/src/components/ui/button.tsx | 21 +- web/src/components/ui/card.tsx | 14 +- web/src/components/ui/input.tsx | 43 +- web/src/components/ui/tabs.tsx | 10 +- web/src/context/AppContext.tsx | 302 +++++++++++ web/src/index.css | 44 +- web/src/main.tsx | 7 +- web/src/pages/AuthLogin.tsx | 87 +++- web/src/pages/AuthRegister.tsx | 147 ++++-- web/src/pages/Booking.tsx | 312 ++++++++++-- web/src/pages/Dashboard.tsx | 693 ++++++++++++++++++++++---- web/src/pages/Explore.tsx | 6 +- web/src/pages/Landing.tsx | 336 ++++++++++++- web/src/pages/Profile.tsx | 170 +++++-- web/src/pages/ShopDetails.tsx | 7 +- web/src/store/useAppStore.ts | 118 ----- web/vite.config.ts | 6 +- 53 files changed, 5538 insertions(+), 531 deletions(-) create mode 100644 CONECTAR_ANDROID.md create mode 100644 README.md create mode 100644 src/components/ui/Badge.tsx create mode 100644 src/components/ui/Button.tsx create mode 100644 src/components/ui/Card.tsx create mode 100644 src/components/ui/Input.tsx create mode 100644 src/context/AppContext.tsx create mode 100644 src/data/mock.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/storage.ts create mode 100644 src/navigation/AppNavigator.tsx create mode 100644 src/navigation/types.ts create mode 100644 src/pages/AuthLogin.tsx create mode 100644 src/pages/AuthRegister.tsx create mode 100644 src/pages/Booking.tsx create mode 100644 src/pages/Cart.tsx create mode 100644 src/pages/Dashboard.tsx create mode 100644 src/pages/Explore.tsx create mode 100644 src/pages/Landing.tsx create mode 100644 src/pages/Profile.tsx create mode 100644 src/pages/ShopDetails.tsx create mode 100644 src/types.ts create mode 100644 web/OPCOES_CORES.md create mode 100644 web/README.md create mode 100644 web/src/context/AppContext.tsx delete mode 100644 web/src/store/useAppStore.ts diff --git a/App.tsx b/App.tsx index 0329d0c..9b2795b 100644 --- a/App.tsx +++ b/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 ( - - Open up App.tsx to start working on your app! - - + + + + + + ); } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#fff', - alignItems: 'center', - justifyContent: 'center', - }, -}); diff --git a/CONECTAR_ANDROID.md b/CONECTAR_ANDROID.md new file mode 100644 index 0000000..50810fe --- /dev/null +++ b/CONECTAR_ANDROID.md @@ -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! + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..233672e --- /dev/null +++ b/README.md @@ -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. + + diff --git a/app.json b/app.json index ff9fa20..720f277 100644 --- a/app.json +++ b/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" + } } } } diff --git a/index.ts b/index.ts index 1d6e981..5fd059f 100644 --- a/index.ts +++ b/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); diff --git a/package-lock.json b/package-lock.json index 6aadd3f..992e44b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7f9a08a..9d885c1 100644 --- a/package.json +++ b/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 diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx new file mode 100644 index 0000000..8f95ec0 --- /dev/null +++ b/src/components/ui/Badge.tsx @@ -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 ( + + {children} + + ); +}; + +const styles = StyleSheet.create({ + badge: { + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 12, + alignSelf: 'flex-start', + }, + text: { + fontSize: 12, + fontWeight: '600', + }, +}); + + diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..49457b7 --- /dev/null +++ b/src/components/ui/Button.tsx @@ -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 ( + + {loading ? ( + + ) : ( + {children} + )} + + ); +}; + +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, + }, +}); + + diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx new file mode 100644 index 0000000..68ce083 --- /dev/null +++ b/src/components/ui/Card.tsx @@ -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 {children}; +}; + +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', + }, +}); + + diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx new file mode 100644 index 0000000..7141c74 --- /dev/null +++ b/src/components/ui/Input.tsx @@ -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 ( + + {label && {label}} + + {error && {error}} + + ); +}; + +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, + }, +}); + + diff --git a/src/context/AppContext.tsx b/src/context/AppContext.tsx new file mode 100644 index 0000000..6a4612c --- /dev/null +++ b/src/context/AppContext.tsx @@ -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 & { shopName?: string }) => boolean; + addToCart: (item: CartItem) => void; + removeFromCart: (refId: string) => void; + clearCart: () => void; + createAppointment: (input: Omit) => Appointment | null; + placeOrder: (customerId: string, shopId?: string) => Order | null; + updateAppointmentStatus: (id: string, status: Appointment['status']) => void; + updateOrderStatus: (id: string, status: Order['status']) => void; + addService: (shopId: string, service: Omit) => void; + updateService: (shopId: string, service: Service) => void; + deleteService: (shopId: string, serviceId: string) => void; + addProduct: (shopId: string, product: Omit) => void; + updateProduct: (shopId: string, product: Product) => void; + deleteProduct: (shopId: string, productId: string) => void; + addBarber: (shopId: string, barber: Omit) => void; + updateBarber: (shopId: string, barber: Barber) => void; + deleteBarber: (shopId: string, barberId: string) => void; +}; + +const initialState: State = { + user: undefined, + users: mockUsers, + shops: mockShops, + appointments: [], + orders: [], + cart: [], +}; + +const AppContext = createContext(undefined); + +export const AppProvider = ({ children }: { children: React.ReactNode }) => { + const [state, setState] = useState(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>((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 {children}; +}; + +export const useApp = () => { + const ctx = useContext(AppContext); + if (!ctx) throw new Error('useApp deve ser usado dentro de AppProvider'); + return ctx; +}; + + diff --git a/src/data/mock.ts b/src/data/mock.ts new file mode 100644 index 0000000..ae47ef5 --- /dev/null +++ b/src/data/mock.ts @@ -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 }], + }, +]; + + diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..a30df25 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,3 @@ +export const currency = (v: number) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); + + diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..b7a1524 --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,23 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export const storage = { + async get(key: string, fallback: T): Promise { + 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(key: string, value: T): Promise { + try { + await AsyncStorage.setItem(key, JSON.stringify(value)); + } catch (err) { + console.error('storage set error', err); + } + }, +}; + + diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx new file mode 100644 index 0000000..6fed13e --- /dev/null +++ b/src/navigation/AppNavigator.tsx @@ -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(); + +export default function AppNavigator() { + const { user } = useApp(); + + return ( + + + {!user ? ( + <> + + + + + + + + + ) : user.role === 'barbearia' ? ( + <> + + + + ) : ( + <> + + + + + + + )} + + + ); +} + + diff --git a/src/navigation/types.ts b/src/navigation/types.ts new file mode 100644 index 0000000..5c7cb1e --- /dev/null +++ b/src/navigation/types.ts @@ -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 {} + } +} + + diff --git a/src/pages/AuthLogin.tsx b/src/pages/AuthLogin.tsx new file mode 100644 index 0000000..2ef8dd8 --- /dev/null +++ b/src/pages/AuthLogin.tsx @@ -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 ( + + + Bem-vindo de volta + Entre na sua conta para continuar + + + 💡 Conta demo: + Cliente: cliente@demo.com / 123 + Barbearia: barber@demo.com / 123 + + + { + setEmail(text); + setError(''); + }} + keyboardType="email-address" + autoCapitalize="none" + placeholder="seu@email.com" + /> + + { + setPassword(text); + setError(''); + }} + secureTextEntry + placeholder="••••••••" + error={error} + /> + + + + + Não tem conta? + navigation.navigate('Register' as never)} + > + Criar conta + + + + + ); +} + +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', + }, +}); + + diff --git a/src/pages/AuthRegister.tsx b/src/pages/AuthRegister.tsx new file mode 100644 index 0000000..0040921 --- /dev/null +++ b/src/pages/AuthRegister.tsx @@ -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 ( + + + Criar conta + Escolha o tipo de acesso + + + setRole('cliente')} + > + + Cliente + + + setRole('barbearia')} + > + + Barbearia + + + + + + + { + setEmail(text); + setError(''); + }} + keyboardType="email-address" + autoCapitalize="none" + placeholder="seu@email.com" + error={error} + /> + + + + {role === 'barbearia' && ( + + )} + + + + + Já tem conta? + navigation.navigate('Login' as never)} + > + Entrar + + + + + ); +} + +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', + }, +}); + + diff --git a/src/pages/Booking.tsx b/src/pages/Booking.tsx new file mode 100644 index 0000000..8c2920b --- /dev/null +++ b/src/pages/Booking.tsx @@ -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 ( + + Barbearia não encontrada + + ); + } + + 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 ( + + Agendar em {shop.name} + + + 1. Escolha o serviço + + {shop.services.map((s) => ( + setService(s.id)} + > + {s.name} + {currency(s.price)} + + ))} + + + 2. Escolha o barbeiro + + {shop.barbers.map((b) => ( + setBarber(b.id)} + > + + {b.name} + + + ))} + + + 3. Escolha a data + + + 4. Escolha o horário + + {availableSlots.length > 0 ? ( + availableSlots.map((h) => ( + setSlot(h)} + > + {h} + + )) + ) : ( + Escolha primeiro o barbeiro e a data + )} + + + {canSubmit && selectedService && ( + + Resumo + Serviço: {selectedService.name} + Barbeiro: {selectedBarber?.name} + Data: {date} às {slot} + Total: {currency(selectedService.price)} + + )} + + + + + ); +} + +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, + }, +}); + + diff --git a/src/pages/Cart.tsx b/src/pages/Cart.tsx new file mode 100644 index 0000000..b8eda3a --- /dev/null +++ b/src/pages/Cart.tsx @@ -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 ( + + + Carrinho vazio + + + ); + } + + const grouped = cart.reduce>((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 ( + + Carrinho + {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 ( + + + + {shop?.name ?? 'Barbearia'} + {shop?.address} + + {currency(total)} + + {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 ( + + + {i.type === 'service' ? 'Serviço: ' : 'Produto: '} + {ref?.name ?? 'Item'} x{i.qty} + + + + ); + })} + {user ? ( + + ) : ( + + )} + + ); + })} + + ); +} + +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, + }, +}); + + diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx new file mode 100644 index 0000000..5255ddc --- /dev/null +++ b/src/pages/Dashboard.tsx @@ -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 ( + + Área exclusiva para barbearias + + ); + } + + if (!shop) { + return ( + + Barbearia não encontrada + + ); + } + + 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 ( + + + {shop.name} + + + + + {tabs.map((tab) => ( + setActiveTab(tab.id as any)} + > + + {tab.label} + + + ))} + + + + {activeTab === 'overview' && ( + + + + Faturamento + {currency(totalRevenue)} + + + Pendentes + {activeAppointments.length} + + + Concluídos + {completedAppointments.length} + + + Stock baixo + 0 && styles.statValueWarning]}> + {lowStock.length} + + + + + )} + + {activeTab === 'appointments' && ( + + {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 ( + + + + {svc?.name ?? 'Serviço'} + {barber?.name} · {a.date} + + + {a.status} + + + + Alterar status: + + {['pendente', 'confirmado', 'concluido', 'cancelado'].map((s) => ( + + ))} + + + + ); + }) + ) : ( + + Nenhum agendamento ativo + + )} + + )} + + {activeTab === 'orders' && ( + + {productOrders.length > 0 ? ( + productOrders.map((o) => ( + + + + {currency(o.total)} + {new Date(o.createdAt).toLocaleString('pt-BR')} + + + {o.status} + + + + {['pendente', 'confirmado', 'concluido', 'cancelado'].map((s) => ( + + ))} + + + )) + ) : ( + + Nenhum pedido de produtos + + )} + + )} + + {activeTab === 'services' && ( + + {shop.services.map((s) => ( + + + + {s.name} + Duração: {s.duration} min + + {currency(s.price)} + + + + ))} + + Adicionar serviço + + + + + + + )} + + {activeTab === 'products' && ( + + {lowStock.length > 0 && ( + + + ⚠️ Atenção: {lowStock.length} {lowStock.length === 1 ? 'produto com stock baixo' : 'produtos com stock baixo'} + + + )} + {shop.products.map((p) => ( + + + + {p.name} + Stock: {p.stock} unidades + + {currency(p.price)} + + + + + + + + ))} + + Adicionar produto + + + + + + + )} + + {activeTab === 'barbers' && ( + + {shop.barbers.map((b) => ( + + + + {b.name} + + Especialidades: {b.specialties.length > 0 ? b.specialties.join(', ') : 'Nenhuma'} + + + + + + ))} + + Adicionar barbeiro + + + + + + )} + + + ); +} + +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, + }, +}); + + diff --git a/src/pages/Explore.tsx b/src/pages/Explore.tsx new file mode 100644 index 0000000..3005442 --- /dev/null +++ b/src/pages/Explore.tsx @@ -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 ( + + Explorar barbearias + item.id} + contentContainerStyle={styles.list} + renderItem={({ item: shop }) => ( + + + {shop.name} + {shop.rating.toFixed(1)} ⭐ + + {shop.address} + + {shop.services.length} serviços + + {shop.barbers.length} barbeiros + + + + + + + )} + /> + + ); +} + +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, + }, +}); + + diff --git a/src/pages/Landing.tsx b/src/pages/Landing.tsx new file mode 100644 index 0000000..dd82819 --- /dev/null +++ b/src/pages/Landing.tsx @@ -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 ( + + + Smart Agenda + + Agendamentos, produtos e gestão em um único lugar. + + + Experiência mobile-first para clientes e painel completo para barbearias. + + + + + + + + + + Agendamentos + + Escolha serviço, barbeiro, data e horário com validação de slots. + + + + Carrinho + + Produtos e serviços agrupados por barbearia, pagamento rápido. + + + + Painel + + Faturamento, agendamentos, pedidos, barbearia no controle. + + + + + ); +} + +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, + }, +}); + + diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx new file mode 100644 index 0000000..6d7bd39 --- /dev/null +++ b/src/pages/Profile.tsx @@ -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 = { + 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 ( + + Faça login para ver o perfil + + ); + } + + const myAppointments = appointments.filter((a) => a.customerId === user.id); + const myOrders = orders.filter((o) => o.customerId === user.id); + + return ( + + + Olá, {user.name} + {user.email} + + {user.role === 'cliente' ? 'Cliente' : 'Barbearia'} + + + + + Agendamentos + {myAppointments.length > 0 ? ( + myAppointments.map((a) => { + const shop = shops.find((s) => s.id === a.shopId); + return ( + + + {shop?.name} + {a.status} + + {a.date} + {currency(a.total)} + + ); + }) + ) : ( + + Nenhum agendamento ainda + + )} + + Pedidos + {myOrders.length > 0 ? ( + myOrders.map((o) => { + const shop = shops.find((s) => s.id === o.shopId); + return ( + + + {shop?.name} + {o.status} + + + {new Date(o.createdAt).toLocaleString('pt-BR')} + + {currency(o.total)} + + ); + }) + ) : ( + + Nenhum pedido ainda + + )} + + ); +} + +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', + }, +}); + + diff --git a/src/pages/ShopDetails.tsx b/src/pages/ShopDetails.tsx new file mode 100644 index 0000000..06946d2 --- /dev/null +++ b/src/pages/ShopDetails.tsx @@ -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 ( + + Barbearia não encontrada + + ); + } + + return ( + + + {shop.name} + {shop.address} + + + + + setTab('servicos')} + > + Serviços + + setTab('produtos')} + > + Produtos + + + + {tab === 'servicos' ? ( + + {shop.services.map((service) => ( + + + {service.name} + {currency(service.price)} + + Duração: {service.duration} min + + + ))} + + ) : ( + + {shop.products.map((product) => ( + + + {product.name} + {currency(product.price)} + + Stock: {product.stock} unidades + {product.stock <= 3 && Stock baixo} + + + ))} + + )} + + ); +} + +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%', + }, +}); + + diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..a1c01d4 --- /dev/null +++ b/src/types.ts @@ -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 }; + + diff --git a/web/OPCOES_CORES.md b/web/OPCOES_CORES.md new file mode 100644 index 0000000..45a1850 --- /dev/null +++ b/web/OPCOES_CORES.md @@ -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) + diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..40d29c3 --- /dev/null +++ b/web/README.md @@ -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). + + diff --git a/web/src/components/CartPanel.tsx b/web/src/components/CartPanel.tsx index 1c49920..463eac5 100644 --- a/web/src/components/CartPanel.tsx +++ b/web/src/components/CartPanel.tsx @@ -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 Carrinho vazio; const grouped = cart.reduce>((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 = () => { + + diff --git a/web/src/components/DashboardCards.tsx b/web/src/components/DashboardCards.tsx index 73458de..13665c6 100644 --- a/web/src/components/DashboardCards.tsx +++ b/web/src/components/DashboardCards.tsx @@ -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 = () => { + + diff --git a/web/src/components/ProductList.tsx b/web/src/components/ProductList.tsx index a1e4bb6..54ce5bd 100644 --- a/web/src/components/ProductList.tsx +++ b/web/src/components/ProductList.tsx @@ -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; }) => ( -
- {products.map((p) => ( - -
-
{p.name}
-
{currency(p.price)}
-
-
Stock: {p.stock}
- {onAdd && ( -
- +
+ {products.map((p) => { + const lowStock = p.stock <= 3; + return ( + +
+
+
+

{p.name}

+ {lowStock && ( + + + Stock baixo + + )} +
+
+ + + {p.stock} {p.stock === 1 ? 'unidade' : 'unidades'} + +
+
+
{currency(p.price)}
- )} -
- ))} + {onAdd && ( + + )} + + ); + })}
); + + diff --git a/web/src/components/ServiceList.tsx b/web/src/components/ServiceList.tsx index 08972eb..f35b5c3 100644 --- a/web/src/components/ServiceList.tsx +++ b/web/src/components/ServiceList.tsx @@ -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; }) => ( -
+
{services.map((s) => ( - -
-
{s.name}
-
{currency(s.price)}
-
-
Duração: {s.duration} min
- {onSelect && ( -
- + +
+
+

{s.name}

+
+ + {s.duration} minutos +
+
{currency(s.price)}
+
+ {onSelect && ( + )}
))} diff --git a/web/src/components/ShopCard.tsx b/web/src/components/ShopCard.tsx index 919b9d3..c8667d0 100644 --- a/web/src/components/ShopCard.tsx +++ b/web/src/components/ShopCard.tsx @@ -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 }) => ( - -
-

{shop.name}

- - - {shop.rating} - + +
+
+
+
+ +
+

{shop.name}

+
+
+ + + {shop.address} + + + + {shop.rating.toFixed(1)} + +
+
+ {shop.services.length} serviços + + {shop.barbers.length} barbeiros +
+
-

- - {shop.address} -

-
- -
@@ -30,3 +44,5 @@ export const ShopCard = ({ shop }: { shop: BarberShop }) => ( + + diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index c5ab917..c1807b6 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -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 ( -
-
- +
+
+ Smart Agenda -
- + + {/* Desktop Navigation */} +
+ + + {/* Mobile Menu Button */} +
+ + {/* Mobile Menu */} + {mobileMenuOpen && ( +
+ +
+ )}
); }; + + diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx index f35e63f..ae346be 100644 --- a/web/src/components/ui/badge.tsx +++ b/web/src/components/ui/badge.tsx @@ -1,17 +1,31 @@ import { cn } from '../../lib/cn'; -type Props = { children: React.ReactNode; color?: 'amber' | 'slate' | 'green' | 'red'; className?: string }; +type Props = { children: React.ReactNode; color?: 'amber' | 'slate' | 'green' | 'red' | 'blue'; className?: string; variant?: 'solid' | 'soft' }; const colorMap = { - amber: 'bg-amber-100 text-amber-700 border border-amber-200', - slate: 'bg-slate-100 text-slate-700 border border-slate-200', - green: 'bg-emerald-100 text-emerald-700 border border-emerald-200', - red: 'bg-rose-100 text-rose-700 border border-rose-200', + solid: { + amber: 'bg-amber-500 text-white', + slate: 'bg-slate-600 text-white', + green: 'bg-emerald-500 text-white', + red: 'bg-rose-500 text-white', + blue: 'bg-blue-500 text-white', + }, + soft: { + amber: 'bg-amber-50 text-amber-700 border border-amber-200/60', + slate: 'bg-slate-50 text-slate-700 border border-slate-200/60', + green: 'bg-emerald-50 text-emerald-700 border border-emerald-200/60', + red: 'bg-rose-50 text-rose-700 border border-rose-200/60', + blue: 'bg-blue-50 text-blue-700 border border-blue-200/60', + }, }; -export const Badge = ({ children, color = 'amber', className }: Props) => ( - {children} +export const Badge = ({ children, color = 'amber', variant = 'soft', className }: Props) => ( + + {children} + ); + + diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index 57dc09f..825868f 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -2,18 +2,25 @@ import { cn } from '../../lib/cn'; import React from 'react'; type Props = React.ButtonHTMLAttributes & { - variant?: 'solid' | 'outline' | 'ghost'; + variant?: 'solid' | 'outline' | 'ghost' | 'danger'; + size?: 'sm' | 'md' | 'lg'; asChild?: boolean; }; -export const Button = ({ className, variant = 'solid', asChild, ...props }: Props) => { - const base = 'rounded-md px-4 py-2 text-sm font-semibold transition focus:outline-none focus:ring-2 focus:ring-amber-500/40'; +export const Button = ({ className, variant = 'solid', size = 'md', asChild, ...props }: Props) => { + const base = 'inline-flex items-center justify-center font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed'; const variants = { - solid: 'bg-amber-500 text-white hover:bg-amber-600', - outline: 'border border-amber-500 text-amber-700 hover:bg-amber-50', - ghost: 'text-amber-700 hover:bg-amber-50', + solid: 'bg-gradient-to-r from-indigo-500 to-blue-600 text-white hover:from-indigo-600 hover:to-blue-700 shadow-md hover:shadow-lg focus:ring-indigo-500/50 active:scale-[0.98]', + outline: 'border-2 border-indigo-500 text-indigo-700 bg-white hover:bg-indigo-50 hover:border-indigo-600 focus:ring-indigo-500/50 active:scale-[0.98]', + ghost: 'text-indigo-700 hover:bg-indigo-50 focus:ring-indigo-500/50 active:scale-[0.98]', + danger: 'bg-gradient-to-r from-rose-500 to-rose-600 text-white hover:from-rose-600 hover:to-rose-700 shadow-md hover:shadow-lg focus:ring-rose-500/50 active:scale-[0.98]', }; - const cls = cn(base, variants[variant], className); + const sizes = { + sm: 'text-xs px-3 py-1.5 rounded-lg', + md: 'text-sm px-4 py-2.5 rounded-lg', + lg: 'text-base px-6 py-3 rounded-xl', + }; + const cls = cn(base, variants[variant], sizes[size], className); if (asChild && React.isValidElement(props.children)) { return React.cloneElement(props.children, { ...props, diff --git a/web/src/components/ui/card.tsx b/web/src/components/ui/card.tsx index f543686..deda884 100644 --- a/web/src/components/ui/card.tsx +++ b/web/src/components/ui/card.tsx @@ -1,8 +1,18 @@ import { cn } from '../../lib/cn'; -export const Card = ({ children, className = '' }: { children: React.ReactNode; className?: string }) => ( -
{children}
+export const Card = ({ children, className = '', hover = false }: { children: React.ReactNode; className?: string; hover?: boolean }) => ( +
+ {children} +
); + + diff --git a/web/src/components/ui/input.tsx b/web/src/components/ui/input.tsx index ffc2745..631a405 100644 --- a/web/src/components/ui/input.tsx +++ b/web/src/components/ui/input.tsx @@ -1,17 +1,40 @@ import { cn } from '../../lib/cn'; import React from 'react'; -type Props = React.InputHTMLAttributes; +type Props = React.InputHTMLAttributes & { + label?: string; + error?: string; +}; + +export const Input = ({ className, label, error, ...props }: Props) => { + const input = ( + + ); + + if (label || error) { + return ( +
+ {label && } + {input} + {error &&

{error}

} +
+ ); + } + + return input; +}; + -export const Input = ({ className, ...props }: Props) => ( - -); diff --git a/web/src/components/ui/tabs.tsx b/web/src/components/ui/tabs.tsx index 7c0f726..e3815d8 100644 --- a/web/src/components/ui/tabs.tsx +++ b/web/src/components/ui/tabs.tsx @@ -1,13 +1,15 @@ type Tab = { id: string; label: string }; export const Tabs = ({ tabs, active, onChange }: { tabs: Tab[]; active: string; onChange: (id: string) => void }) => ( -
+
{tabs.map((t) => ( -

- Não tem conta? Registar -

+ +
+

+ Não tem conta?{' '} + + Criar conta + +

+
); @@ -53,3 +92,5 @@ export default function AuthLogin() { + + diff --git a/web/src/pages/AuthRegister.tsx b/web/src/pages/AuthRegister.tsx index d25cc0a..dabb667 100644 --- a/web/src/pages/AuthRegister.tsx +++ b/web/src/pages/AuthRegister.tsx @@ -1,62 +1,137 @@ -import { FormEvent, useState } from 'react'; +import { FormEvent, useEffect, useState } from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { Input } from '../components/ui/input'; import { Button } from '../components/ui/button'; import { Card } from '../components/ui/card'; -import { useAppStore } from '../store/useAppStore'; +import { useApp } from '../context/AppContext'; +import { UserPlus, User, Scissors } from 'lucide-react'; export default function AuthRegister() { const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [role, setRole] = useState<'cliente' | 'barbearia'>('cliente'); + const [shopName, setShopName] = useState(''); const [error, setError] = useState(''); - const register = useAppStore((s) => s.register); + const { register, user } = useApp(); const navigate = useNavigate(); + useEffect(() => { + if (!user) return; + const target = user.role === 'barbearia' ? '/painel' : '/explorar'; + navigate(target, { replace: true }); + }, [user, navigate]); + const onSubmit = (e: FormEvent) => { e.preventDefault(); - const ok = register({ name, email, password, role }); + const ok = register({ name, email, password, role, shopName }); if (!ok) setError('Email já registado'); - else navigate('/'); + else { + const target = role === 'barbearia' ? '/painel' : '/explorar'; + navigate(target); + } }; return ( -
- -
-

Criar conta

-

Escolha o tipo de acesso.

+
+ +
+
+ +
+

Criar conta

+

Escolha o tipo de acesso

-
-
- - setName(e.target.value)} required /> + + + {/* Role Selection */} +
+ +
+ {(['cliente', 'barbearia'] as const).map((r) => ( + + ))} +
-
- - setEmail(e.target.value)} type="email" required /> -
-
- - setPassword(e.target.value)} type="password" required /> -
-
- {(['cliente', 'barbearia'] as const).map((r) => ( - - ))} -
- {error &&

{error}

} - -

- Já tem conta? Entrar -

+ +
+

+ Já tem conta?{' '} + + Entrar + +

+
); @@ -64,3 +139,5 @@ export default function AuthRegister() { + + diff --git a/web/src/pages/Booking.tsx b/web/src/pages/Booking.tsx index 2160006..c9e442e 100644 --- a/web/src/pages/Booking.tsx +++ b/web/src/pages/Booking.tsx @@ -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
Barbearia não encontrada
; + if (!shop) return
Barbearia não encontrada
; + 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 ( -
-

Agendar em {shop.name}

- -
-

1. Serviço

-
- {shop.services.map((s) => ( - - ))} + {step.completed ? : } +
+ + {step.label} + +
+ {idx < steps.length - 1 && ( +
+ )}
-
-
-

2. Barbeiro

-
- {shop.barbers.map((b) => ( - - ))} -
-
-
-
-

3. Data

- setDate(e.target.value)} /> -
-
-

4. Horário

-
- {availableSlots.map((h) => ( + ))} +
+ + + {/* Step 1: Service - Só aparece se não tiver serviço selecionado */} + {currentStep === 1 && ( +
+
+ +

1. Escolha o serviço

+
+
+ {shop.services.map((s) => ( ))} - {!availableSlots.length &&

Escolha data e barbeiro.

}
-
- + )} + + {/* Step 2: Barber - Só aparece se tiver serviço mas não barbeiro */} + {currentStep === 2 && ( +
+
+ +
+
+ +

2. Escolha o barbeiro

+
+ {selectedService && ( +
+
Serviço selecionado:
+
{selectedService.name} - {currency(selectedService.price)}
+
+ )} +
+ {shop.barbers.map((b) => ( + + ))} +
+
+ )} + + {/* Step 3: Date & Time - Só aparece se tiver serviço e barbeiro */} + {currentStep === 3 && ( +
+
+ +
+
+ +

3. Escolha a data e horário

+
+ + {selectedService && selectedBarber && ( +
+
Serviço: {selectedService.name}
+
Barbeiro: {selectedBarber.name}
+
+ )} + +
+
+
+ +

Data

+
+ setDate(e.target.value)} + min={new Date().toISOString().split('T')[0]} + /> +
+
+
+ +

Horário

+
+
+ {!date ? ( +

Escolha primeiro a data.

+ ) : availableSlots.length > 0 ? ( + availableSlots.map((h) => ( + + )) + ) : ( +

Nenhum horário disponível para esta data.

+ )} +
+
+
+
+ )} + + {/* Summary */} + {canSubmit && selectedService && ( +
+

Resumo do agendamento

+
+
+ Serviço: + {selectedService.name} +
+
+ Barbeiro: + {selectedBarber?.name} +
+
+ Data e hora: + + {new Date(date).toLocaleDateString('pt-BR')} às {slot} + +
+
+ Total: + {currency(selectedService.price)} +
+
+
+ )} + +
); @@ -92,3 +296,5 @@ export default function Booking() { + + diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 2ac4f7c..e191a2a 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -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 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('overview'); + const [period, setPeriod] = useState('semana'); + + // Form states 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
Área exclusiva para barbearias.
; if (!shop) return
Barbearia não encontrada.
; - 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(); + 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(); + 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 (
-
+ {/* Header */} +
-

Painel da {shop.name}

+

{shop.name}

{shop.address}

- Role: barbearia +
+ {(['hoje', 'semana', 'mes', 'total'] as const).map((p) => ( + + ))} +
- + {/* Tabs */} + ({ id: t.id, label: t.label }))} active={activeTab} onChange={(v) => setActiveTab(v as TabId)} /> -
- -
-

Agendamentos

-
-
- {shopAppointments.map((a) => ( -
-
-

{a.date}

-

Serviço: {a.serviceId}

-
-
- {a.status} - + {/* Tab Content */} + {activeTab === 'overview' && ( +
+ {/* Stats Cards */} +
+ +
+
+
+ Período
- ))} - {!shopAppointments.length &&

Sem agendamentos.

} +

Faturamento

+

{currency(totalRevenue)}

+
+ + +
+
+ +
+ {pendingAppts} +
+

Pendentes

+

{pendingAppts}

+
+ + +
+
+ +
+ {confirmedAppts} +
+

Confirmados

+

{confirmedAppts}

+
+ + 0 ? 'bg-amber-50 border-amber-200' : ''}`}> +
+
0 ? 'bg-amber-500' : 'bg-slate-500'}`}> + +
+ {lowStock.length > 0 && {lowStock.length}} +
+

Stock baixo

+

0 ? 'text-amber-700' : 'text-slate-900'}`}>{lowStock.length}

+
+
+ + {/* Charts */} +
+ +

Serviços vs Produtos

+
+ + + + + + + + +
+
+ +
+ +

Top 5 Serviços

+
+ {topServices.length > 0 ? ( + topServices.map((s, idx) => ( +
+
+ #{idx + 1} + {s.name} +
+ {s.qty} vendas +
+ )) + ) : ( +

Sem vendas no período

+ )} +
+
+ + +

Top 5 Produtos

+
+ {topProducts.length > 0 ? ( + topProducts.map((p, idx) => ( +
+
+ #{idx + 1} + {p.name} +
+ {p.qty} vendas +
+ )) + ) : ( +

Sem vendas no período

+ )} +
+
+
+
+
+ )} + + {activeTab === 'appointments' && ( + +
+

Agendamentos

+ {shopAppointments.length} no período +
+
+ {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 ( +
+
+
+

{svc?.name ?? 'Serviço'}

+ + {a.status} + +
+

{barber?.name ?? 'Barbeiro'} · {a.date}

+

{currency(a.total)}

+
+ +
+ ); + }) + ) : ( +
+ +

Nenhum agendamento no período

+
+ )}
+ )} - -
-

Pedidos

+ {activeTab === 'history' && ( + +
+

Histórico de Agendamentos

+ {completedAppointments.length} concluídos
-
- {shopOrders.map((o) => ( -
-
-

{currency(o.total)}

-

{new Date(o.createdAt).toLocaleString('pt-BR')}

+
+ {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 ( +
+
+
+

{svc?.name ?? 'Serviço'}

+ Concluído +
+

{barber?.name ?? 'Barbeiro'} · {a.date}

+

{currency(a.total)}

+
+
+ ); + }) + ) : ( +
+ +

Nenhum agendamento concluído no período

+

Os agendamentos concluídos aparecerão aqui

+
+ )} +
+ + )} + + {activeTab === 'orders' && ( + +
+

Pedidos de Produtos

+ {shopOrders.length} no período +
+
+ {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 ( +
+
+
+

{currency(productTotal)}

+ + {o.status === 'pendente' ? 'Pendente' : o.status === 'confirmado' ? 'Confirmado' : o.status === 'concluido' ? 'Concluído' : 'Cancelado'} + +
+ +
+

{new Date(o.createdAt).toLocaleString('pt-BR')}

+
+ {productItems.map((item) => { + const prod = shop.products.find((p) => p.id === item.refId); + return ( +
+ {prod?.name ?? 'Produto'} x{item.qty} + {currency((prod?.price ?? 0) * item.qty)} +
+ ); + })} +
+
+ ); + }) + ) : ( +
+ +

Nenhum pedido de produtos no período

+

Apenas pedidos com produtos aparecem aqui

+
+ )} +
+
+ )} + + {activeTab === 'services' && ( +
+ +
+

Serviços

+ {shop.services.length} serviços +
+
+ {shop.services.map((s) => ( +
+
+

{s.name}

+

Duração: {s.duration} min

+
+
+ {currency(s.price)} + +
-
- {o.status} - + ))} +
+
+

Adicionar novo serviço

+
+ setSvcName(e.target.value)} /> + setSvcPrice(Number(e.target.value))} /> + setSvcDuration(Number(e.target.value))} /> +
+
- ))} - {!shopOrders.length &&

Sem pedidos.

} -
- -
+
+ +
+ )} -
- -

Serviços

-
- {shop.services.map((s) => ( -
- {s.name} - {currency(s.price)} + {activeTab === 'products' && ( +
+ +
+

Produtos

+ {shop.products.length} produtos +
+ {lowStock.length > 0 && ( +
+

+ + Atenção: {lowStock.length} {lowStock.length === 1 ? 'produto com stock baixo' : 'produtos com stock baixo'} +

- ))} -
-
- setSvcName(e.target.value)} /> - setSvcPrice(Number(e.target.value))} /> - -
- + )} +
+ {shop.products.map((p) => ( +
+
+
+

{p.name}

+ {p.stock <= 3 && Stock baixo} +
+

Stock: {p.stock} unidades

+
+
+ {currency(p.price)} +
+ + + +
+
+
+ ))} +
+
+

Adicionar novo produto

+
+ setProdName(e.target.value)} /> + setProdPrice(Number(e.target.value))} /> + setProdStock(Number(e.target.value))} /> +
+ +
+
+
+ +
+ )} - -

Produtos (stock)

-
- {shop.products.map((p) => ( -
- {p.name} - Stock: {p.stock} + {activeTab === 'barbers' && ( +
+ +
+

Barbeiros

+ {shop.barbers.length} barbeiros +
+
+ {shop.barbers.map((b) => ( +
+
+

{b.name}

+ +
+
+

+ Especialidades: {b.specialties.length > 0 ? b.specialties.join(', ') : 'Nenhuma'} +

+
+
+ ))} + {shop.barbers.length === 0 && ( +
+ +

Nenhum barbeiro registado

+
+ )} +
+
+

Adicionar novo barbeiro

+
+ setBarberName(e.target.value)} + /> + setBarberSpecs(e.target.value)} + /> +
+ +
- ))} -
-

CRUD simplificado; ajuste de stock pode ser adicionado.

-
-
+
+ +
+ )}
); } - diff --git a/web/src/pages/Explore.tsx b/web/src/pages/Explore.tsx index 5bafac4..31e1db9 100644 --- a/web/src/pages/Explore.tsx +++ b/web/src/pages/Explore.tsx @@ -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 (
@@ -18,3 +18,5 @@ export default function Explore() { + + diff --git a/web/src/pages/Landing.tsx b/web/src/pages/Landing.tsx index d031952..70eb943 100644 --- a/web/src/pages/Landing.tsx +++ b/web/src/pages/Landing.tsx @@ -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 ( -
-
-
-

Smart Agenda

-

Agendamentos, produtos e gestão em um único lugar.

-

Experiência mobile-first para clientes e painel completo para barbearias.

-
- -
+ + {/* Stats */} +
+
+
500+
+
Barbearias
+
+
+
10k+
+
Agendamentos
+
+
+
4.8
+
Avaliação média
+
+
-
- {[ - { 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) => ( -
-

{c.title}

-

{c.desc}

+ + {/* Features Grid */} +
+
+

+ Tudo que você precisa +

+

+ Funcionalidades poderosas para clientes e barbearias +

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

{feature.title}

+

{feature.desc}

+
+
+ ))} +
+
+ + {/* How it Works */} +
+
+

+ Como funciona +

+

+ Simples, rápido e eficiente em 3 passos +

+
+ +
+ {[ + { 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) => ( +
+
+ {item.step} +
+

{item.title}

+

{item.desc}

+
+ ))} +
+
+ + {/* Featured Shops */} +
+
+
+

+ Barbearias em destaque +

+

+ Conheça algumas das melhores barbearias da plataforma +

- ))} + +
+ +
+ {featuredShops.map((shop) => ( + + ))} +
+ +
+ +
+
+ + {/* Benefits */} +
+ +
+ +
+

+ Mobile-First +

+

+ Interface otimizada para dispositivos móveis. Agende de qualquer lugar, + a qualquer hora. Experiência fluida e responsiva. +

+
    + {['Design responsivo', 'Carregamento rápido', 'Interface intuitiva'].map((item) => ( +
  • + + {item} +
  • + ))} +
+
+ + +
+ +
+

+ Aumente sua Receita +

+

+ Ferramentas poderosas para gerenciar seu negócio. Análises detalhadas, + gestão de estoque e muito mais. +

+
    + {['Análises em tempo real', 'Gestão de estoque', 'Relatórios detalhados'].map((item) => ( +
  • + + {item} +
  • + ))} +
+
+
+ + {/* Testimonials */} +
+
+

+ O que nossos clientes dizem +

+

+ Depoimentos reais de quem usa a plataforma +

+
+ +
+ {[ + { + 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) => ( + +
+ {[...Array(testimonial.rating)].map((_, i) => ( + + ))} +
+ +

{testimonial.text}

+
+
{testimonial.name}
+
{testimonial.role}
+
+
+ ))} +
+
+ + {/* CTA Final */} +
+
+
+ +
+

+ Pronto para começar? +

+

+ Junte-se a centenas de barbearias que já estão usando a Smart Agenda + para revolucionar seus negócios. +

+
+ + +
+
); @@ -37,3 +327,5 @@ export default function Landing() { + + diff --git a/web/src/pages/Profile.tsx b/web/src/pages/Profile.tsx index ac29863..55474b0 100644 --- a/web/src/pages/Profile.tsx +++ b/web/src/pages/Profile.tsx @@ -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 = { pendente: 'amber', @@ -10,59 +11,138 @@ const statusColor: Record = { cancelado: 'red', }; +const statusLabel: Record = { + pendente: 'Pendente', + confirmado: 'Confirmado', + concluido: 'Concluído', + cancelado: 'Cancelado', +}; + export default function Profile() { - const { user, appointments, orders, shops } = useAppStore(); - if (!user) return
Faça login para ver o perfil.
; + const { user, appointments, orders, shops } = useApp(); + if (!user) { + return ( +
+

Faça login para ver o perfil.

+
+ ); + } const myAppointments = appointments.filter((a) => a.customerId === user.id); const myOrders = orders.filter((o) => o.customerId === user.id); return ( -
-
-

Olá, {user.name}

-

{user.email}

-
- -
-

Agendamentos

- {!myAppointments.length && Sem agendamentos.} -
- {myAppointments.map((a) => { - const shop = shops.find((s) => s.id === a.shopId); - return ( - -
-

{shop?.name}

-

{a.date}

-
- {a.status} -
- ); - })} +
+ {/* Profile Header */} + +
+
+ +
+
+

Olá, {user.name}

+

{user.email}

+ + {user.role === 'cliente' ? 'Cliente' : 'Barbearia'} + +
+
+ + {/* Appointments Section */} +
+
+ +

Agendamentos

+ {myAppointments.length} +
+ {!myAppointments.length ? ( + + +

Nenhum agendamento ainda

+

Explore barbearias e agende seu primeiro serviço!

+
+ ) : ( +
+ {myAppointments.map((a) => { + const shop = shops.find((s) => s.id === a.shopId); + const service = shop?.services.find((s) => s.id === a.serviceId); + return ( + +
+
+
+

{shop?.name}

+ + {statusLabel[a.status]} + +
+ {service && ( +

+ + {service.name} · {service.duration} min +

+ )} +

{a.date}

+
+
+

{currency(a.total)}

+
+
+
+ ); + })} +
+ )}
-
-

Pedidos

- {!myOrders.length && Sem pedidos.} -
- {myOrders.map((o) => { - const shop = shops.find((s) => s.id === o.shopId); - return ( - -
-

{shop?.name}

-

{new Date(o.createdAt).toLocaleString('pt-BR')}

-
-
- {currency(o.total)} - {o.status} -
-
- ); - })} + {/* Orders Section */} +
+
+ +

Pedidos

+ {myOrders.length}
+ {!myOrders.length ? ( + + +

Nenhum pedido ainda

+

Adicione produtos ao carrinho e finalize seu primeiro pedido!

+
+ ) : ( +
+ {myOrders.map((o) => { + const shop = shops.find((s) => s.id === o.shopId); + return ( + +
+
+
+

{shop?.name}

+ + {statusLabel[o.status]} + +
+

+ {new Date(o.createdAt).toLocaleDateString('pt-BR', { + day: '2-digit', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + })} +

+

{o.items.length} {o.items.length === 1 ? 'item' : 'itens'}

+
+
+

{currency(o.total)}

+
+
+
+ ); + })} +
+ )}
); @@ -70,3 +150,5 @@ export default function Profile() { + + diff --git a/web/src/pages/ShopDetails.tsx b/web/src/pages/ShopDetails.tsx index ec8b131..8cf89ac 100644 --- a/web/src/pages/ShopDetails.tsx +++ b/web/src/pages/ShopDetails.tsx @@ -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() { + + diff --git a/web/src/store/useAppStore.ts b/web/src/store/useAppStore.ts deleted file mode 100644 index 99aa252..0000000 --- a/web/src/store/useAppStore.ts +++ /dev/null @@ -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) => boolean; - addToCart: (item: CartItem) => void; - removeFromCart: (refId: string) => void; - clearCart: () => void; - createAppointment: (a: Omit) => 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()( - 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' } - ) -); - diff --git a/web/vite.config.ts b/web/vite.config.ts index ba5ea0c..970c384 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -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 + }, });