commit inicial
This commit is contained in:
commit
b273890441
|
|
@ -0,0 +1,74 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.4</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
|
||||
<groupId>com.barberflow</groupId>
|
||||
<artifactId>backend</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>barber-flow-backend</name>
|
||||
<description>SaaS Multi-tenant BarberFlow</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<!-- Cria endpoints REST e embute o servidor Apache Tomcat -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Permite usar JPA (Hibernate) para conectar o código ao Banco de Dados -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Banco de dados H2 (Roda em memória, perde tudo ao reiniciar, ótimo para testes iniciais) -->
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok: Biblioteca que gera Getters, Setters e Construtores invisivelmente, economizando muitas linhas de código -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package com.barberflow.backend;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
// @SpringBootApplication diz ao Spring que essa é a classe principal que deve escanear todas as outras pastas e iniciar o servidor.
|
||||
@SpringBootApplication
|
||||
public class BarberFlowApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(BarberFlowApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.barberflow.backend.controller;
|
||||
|
||||
import com.barberflow.backend.entity.Barbearia;
|
||||
import com.barberflow.backend.service.BarbeariaService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
// @RestController diz ao Spring que essa classe vai receber as requisições HTTP e devolver objetos JSON.
|
||||
@RestController
|
||||
// @RequestMapping define que a URL base para todos os métodos aqui dentro será /api/barbearias
|
||||
@RequestMapping("/api/barbearias")
|
||||
// @CrossOrigin(*) permite que o seu Front-end no localhost chame esta API sem ser bloqueado pelos navegadores.
|
||||
@CrossOrigin(origins = "*")
|
||||
public class BarbeariaController {
|
||||
|
||||
private final BarbeariaService service;
|
||||
|
||||
@Autowired
|
||||
public BarbeariaController(BarbeariaService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
// @GetMapping indica que este método responde ao protocolo HTTP GET.
|
||||
// O {slug} na URL é uma variável dinâmica (ex: /api/barbearias/vintage-barber).
|
||||
@GetMapping("/{slug}")
|
||||
// @PathVariable pega o {slug} da URL e injeta na variável String slug.
|
||||
public ResponseEntity<Barbearia> getBarbeariaBySlug(@PathVariable String slug) {
|
||||
try {
|
||||
Barbearia barbearia = service.buscarPorSlug(slug);
|
||||
// Retorna um HTTP 200 OK junto com o objeto JSON da Barbearia
|
||||
return ResponseEntity.ok(barbearia);
|
||||
} catch (RuntimeException e) {
|
||||
// Se o Service lançar a exceção de que não achou, retorna um HTTP 404 Not Found
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package com.barberflow.backend.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.util.List;
|
||||
import java.util.ArrayList;
|
||||
|
||||
// @Entity avisa ao Spring/Hibernate que esta classe é uma Tabela no Banco de Dados.
|
||||
@Entity
|
||||
// @Data é do Lombok. Ele cria automaticamente os métodos getNome(), setNome(), equals() e toString() por trás dos panos.
|
||||
@Data
|
||||
// @NoArgsConstructor cria um construtor vazio obrigatório para o JPA.
|
||||
@NoArgsConstructor
|
||||
public class Barbearia {
|
||||
|
||||
// @Id indica que este campo será a Chave Primária (PK) da tabela.
|
||||
@Id
|
||||
// @GeneratedValue diz pro banco de dados gerar o ID automaticamente (autoincremento).
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String nome;
|
||||
|
||||
// unique = true indica que duas barbearias não podem ter o mesmo slug (o que garante a exclusividade do tenant).
|
||||
@Column(unique = true, nullable = false)
|
||||
private String slug;
|
||||
|
||||
private String logo; // Vamos salvar a URL ou base64 aqui
|
||||
private String corPrimaria;
|
||||
private String corSecundaria;
|
||||
|
||||
// Relacionamento 1 para Muitos (Uma Barbearia tem Muitos Serviços).
|
||||
// mappedBy = "barbearia" indica que a classe Servico é a "dona" do relacionamento.
|
||||
// cascade = CascadeType.ALL significa que, se você deletar a barbearia, todos os serviços dela também serão deletados no banco.
|
||||
@OneToMany(mappedBy = "barbearia", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private List<Servico> servicos = new ArrayList<>();
|
||||
|
||||
@OneToMany(mappedBy = "barbearia", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
private List<Barbeiro> barbeiros = new ArrayList<>();
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package com.barberflow.backend.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Entity
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class Barbeiro {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String nome;
|
||||
|
||||
@Column(columnDefinition = "TEXT")
|
||||
private String foto; // Como base64 pode ser muito grande, usamos columnDefinition TEXT
|
||||
|
||||
private Double comissao; // Ex: 50.0 para 50%
|
||||
|
||||
private String email;
|
||||
|
||||
// Ignoramos o password para que não vaze no JSON do Endpoint
|
||||
@JsonIgnore
|
||||
private String password;
|
||||
|
||||
private Boolean canViewFinance = false;
|
||||
private Boolean canEditConfig = false;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "barbearia_id", nullable = false)
|
||||
@JsonIgnore
|
||||
private Barbearia barbearia;
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.barberflow.backend.entity;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
@Entity
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class Servico {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
private String nomePt;
|
||||
private String nomeEs;
|
||||
|
||||
// BigDecimal é a melhor classe do Java para se trabalhar com dinheiro, evitando problemas de arredondamento
|
||||
private BigDecimal precoPt;
|
||||
private BigDecimal precoEs;
|
||||
|
||||
private Integer duracao; // Em minutos
|
||||
|
||||
// Relacionamento Muitos para 1 (Muitos Serviços pertencem a Uma Barbearia).
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
// @JoinColumn indica que no banco de dados na tabela Servico será criada uma coluna 'barbearia_id' que aponta pra Barbearia.
|
||||
@JoinColumn(name = "barbearia_id", nullable = false)
|
||||
// @JsonIgnore evita um Loop Infinito. Sem ele, ao retornar a Barbearia, ele retorna o Servico, que retorna a Barbearia de novo, quebrando o sistema.
|
||||
@JsonIgnore
|
||||
private Barbearia barbearia;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package com.barberflow.backend.repository;
|
||||
|
||||
import com.barberflow.backend.entity.Barbearia;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
// @Repository indica ao Spring que esta interface vai cuidar das queries ao Banco de Dados.
|
||||
@Repository
|
||||
// O JpaRepository traz todos os métodos prontos (save, findAll, deleteById, etc) para a entidade Barbearia.
|
||||
// O tipo da Chave Primária de Barbearia é Long, por isso passamos <Barbearia, Long>.
|
||||
public interface BarbeariaRepository extends JpaRepository<Barbearia, Long> {
|
||||
|
||||
// Apenas declarando o método seguindo a nomenclatura "findBy[Campo]", o Spring constrói a Query SQL automaticamente!
|
||||
// Equivalente a: SELECT * FROM barbearia WHERE slug = ?
|
||||
Optional<Barbearia> findBySlug(String slug);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package com.barberflow.backend.service;
|
||||
|
||||
import com.barberflow.backend.entity.Barbearia;
|
||||
import com.barberflow.backend.repository.BarbeariaRepository;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
// @Service indica que aqui fica a lógica de negócio do sistema (Validações, regras antes de salvar no banco, etc).
|
||||
@Service
|
||||
public class BarbeariaService {
|
||||
|
||||
private final BarbeariaRepository repository;
|
||||
|
||||
// @Autowired avisa ao Spring para injetar o Repositório aqui dentro sozinho, sem precisarmos fazer "new BarbeariaRepository()"
|
||||
@Autowired
|
||||
public BarbeariaService(BarbeariaRepository repository) {
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
public Barbearia buscarPorSlug(String slug) {
|
||||
// Busca a barbearia. Se não achar, lança uma exceção genérica.
|
||||
return repository.findBySlug(slug)
|
||||
.orElseThrow(() -> new RuntimeException("Barbearia não encontrada com o slug: " + slug));
|
||||
}
|
||||
|
||||
// Criamos esse método para popular nosso banco H2 em memória já que não temos o banco real ainda.
|
||||
public Barbearia salvar(Barbearia barbearia) {
|
||||
return repository.save(barbearia);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
spring.application.name=barber-flow-backend
|
||||
|
||||
# Porta onde o back-end vai rodar
|
||||
server.port=8080
|
||||
|
||||
# Configurações do Banco de Dados H2
|
||||
spring.datasource.url=jdbc:h2:mem:barberflowdb
|
||||
spring.datasource.driverClassName=org.h2.Driver
|
||||
spring.datasource.username=sa
|
||||
spring.datasource.password=password
|
||||
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
|
||||
|
||||
# Permite que o Hibernate crie e atualize as tabelas no banco automaticamente baseado nas nossas classes @Entity
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
|
||||
# Habilita o console do H2 no navegador (acesse: http://localhost:8080/h2-console)
|
||||
spring.h2.console.enabled=true
|
||||
spring.h2.console.path=/h2-console
|
||||
|
||||
# Permite que seu app React se comunique com o Spring sem erros de CORS
|
||||
spring.mvc.cors.allowed-origins=*
|
||||
spring.mvc.cors.allowed-methods=GET,POST,PUT,DELETE,OPTIONS
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
# BarberFlow SaaS - Project Context
|
||||
|
||||
Este documento serve como a fonte da verdade para a arquitetura, estrutura e regras de negócio do BarberFlow, um sistema SaaS Multi-Tenant para barbearias construído com Expo (React Native).
|
||||
|
||||
## 🚀 Tech Stack Principal
|
||||
- **Framework:** Expo / React Native (Foco em Web/Mobile First)
|
||||
- **Roteamento:** Expo Router (File-based routing)
|
||||
- **Animações & UI:** `react-native-reanimated` (UI/UX Pro Max)
|
||||
- **Armazenamento:** `@react-native-async-storage/async-storage` (Persistência local temporária/mock)
|
||||
- **Ícones:** `lucide-react-native`
|
||||
- **Mídia:** `expo-image-picker` (Imagens convertidas em Base64 para armazenamento)
|
||||
|
||||
## 📁 Estrutura de Diretórios (`app/`)
|
||||
O projeto utiliza um roteamento baseado no file-system do Expo Router, rigidamente dividido em duas áreas principais após a seleção global de idioma e fluxos de autenticação.
|
||||
|
||||
- `app/index.tsx`: **Raiz Absoluta**. A primeira tela do app. Redireciona para `/landing`.
|
||||
- `app/landing.tsx`: Landing page comercial do SaaS. Contém botões para acessar "Painel do Dono", "Área do Barbeiro" ou "Ver Demonstração" (Área do Cliente).
|
||||
- `app/admin/`: **Área Administrativa (O Painel do Dono e do Barbeiro)**
|
||||
- `_layout.tsx`: Define o Stack protegido do Admin.
|
||||
- `login.tsx`: Tela de autenticação para o dono da barbearia.
|
||||
- `barber-login.tsx`: Tela de autenticação separada para funcionários (barbeiros).
|
||||
- `register.tsx`: Tela de criação de conta (BarberFlow Pro).
|
||||
- `forgot-password.tsx`: Tela de recuperação de senha.
|
||||
- `dashboard.tsx`: Visão geral de agendamentos pendentes e confirmados. Permite aceitar, recusar ou cancelar. (A visão é filtrada com base em quem está logado).
|
||||
- `agenda.tsx`: Tabela visual (Daily Schedule) mostrando os horários de todos os barbeiros e seus status (Livre, Pendente, Confirmado, Bloqueado).
|
||||
- `finance.tsx`: Tela de gestão financeira, exibindo faturamento bruto e o cálculo de comissão exato para cada barbeiro.
|
||||
- `config.tsx`: **Wizard de 6 Passos** para configurar a barbearia (Identidade, Localização, Serviços, Barbeiros, Formas de Pagamento, Link Final).
|
||||
- `app/[slug]/`: **Área do Cliente (Multi-Tenant)**
|
||||
- `_layout.tsx`: Provedor de contexto específico do tenant (barbearia).
|
||||
- `index.tsx`: Tela inicial de redirecionamento do tenant para o fluxo de idioma/auth.
|
||||
- `(auth)/`: **Autenticação e Pré-requisitos do Cliente**
|
||||
- `language-selection.tsx`: O cliente escolhe o idioma (PT/ES) e a moeda (R$/GS) que prefere ser atendido.
|
||||
- `login.tsx`: Tela de login do cliente. Redireciona para o agendamento após sucesso.
|
||||
- `register.tsx`: Tela de cadastro do cliente.
|
||||
- `forgot-password.tsx`: Tela de recuperação de senha.
|
||||
- `(tabs)/`: **Área Interna do Cliente**
|
||||
- `_layout.tsx`: Tab bar com as opções principais.
|
||||
- `agendar.tsx`: O core do lado do cliente. Fluxo de 3 passos: Escolher Serviço/Barbeiro -> Escolher Data/Hora -> Pagamento & Confirmação.
|
||||
- `profile.tsx`: Perfil do cliente com histórico.
|
||||
|
||||
## 🧠 Gerenciamento de Estado (Contextos)
|
||||
|
||||
### 1. `BarbeariaContext.tsx`
|
||||
O "banco de dados" do sistema. Gerencia os dados da barbearia atual (Tenant).
|
||||
- **Resiliência do Slug:** Se o usuário acessa um `slug` e ele bate com o do AsyncStorage, os dados carregam.
|
||||
- **Role-Based Access Control (RBAC):** Armazena a variável `activeBarberId`. Se for `null`, o sistema entende que é o dono e libera tudo. Se tiver um ID de funcionário logado, restringe as views.
|
||||
- **Estruturas Principais:**
|
||||
- `services`: Contém nomes, preços bilíngues e duração (`nomePt`, `nomeEs`, `precoPt`, `precoEs`, `duracao`).
|
||||
- `barbers`: Lista de profissionais contendo `nome`, `foto`, `commission`, `email`, `password` e `permissions` (`canViewFinance`, `canEditConfig`).
|
||||
- `paymentMethods`: Array de strings (ex: `['pix', 'card', 'money', 'alias']`).
|
||||
- `appointments`: Lista de agendamentos com status e valor total do serviço.
|
||||
|
||||
### 2. `LanguageContext.tsx`
|
||||
Coração do sistema i18n e regras de moeda.
|
||||
- **Tradução:** A função `t(key)` é robusta contra chaves não encontradas ou parâmetros `undefined`.
|
||||
- **Preços (`formatPrice`):** Recebe dois valores e formata com base no idioma atual (Real R$ ou Guarani GS).
|
||||
|
||||
## ⚖️ Regras de Negócio Críticas
|
||||
|
||||
1. **Separação Dono vs Funcionário vs Cliente:**
|
||||
- **Dono:** Acessa via `/admin/login`. Visualiza e edita todas as configurações, visualiza a agenda de todos e acessa o balanço financeiro geral.
|
||||
- **Barbeiro (Funcionário):** Acessa via `/admin/barber-login`. Ao logar, vai para o Dashboard onde **só visualiza os seus próprios agendamentos**. Na agenda, não consegue clicar/bloquear a agenda de colegas. O acesso a configurações e financeiro depende de flags explícitas de permissão no cadastro dele.
|
||||
- **Cliente:** Acessa via `/[slug]`, faz autenticação e visualiza os horários disponíveis (área em `(tabs)`).
|
||||
|
||||
2. **Fluxo de Agendamento e Edição (Admin):**
|
||||
- Na tela `config.tsx`, o admin consegue criar e agora também **editar** informações de barbeiros e serviços sem precisar deletá-los.
|
||||
- A agenda do admin e do barbeiro permite selecionar múltiplos horários em massa (multi-select) e aplicar a ação "Bloquear" ou "Liberar".
|
||||
|
||||
3. **Sistema Financeiro e Comissionamento:**
|
||||
- O sistema rastreia o valor gerado nos agendamentos `accepted` e calcula de forma individual o repasse do barbeiro com base na porcentagem (`%`) cadastrada no seu perfil.
|
||||
- Se um barbeiro com permissão de ver as finanças acessar a tela, ele **só enxergará o valor do próprio caixa e o valor que ele vai receber**, enquanto o dono enxerga a somatória bruta de todos.
|
||||
|
||||
4. **Lógica de Formas de Pagamento Dinâmicas:**
|
||||
- O admin marca os métodos aceitos na aba de configurações. A lista sofre filtro dinâmico de moeda (O cliente que escolheu idioma Espanhol não vê PIX, e o cliente do Português não vê Alias).
|
||||
|
||||
5. **Tratamento de Imagens:**
|
||||
- O upload de logos e avatares converte o binário para `Base64` injetando diretamente no estado e no AsyncStorage.
|
||||
|
||||
## 🎨 Padrão de UI/UX (Pro Max)
|
||||
Todas as telas seguem o manual de referências UI/UX.
|
||||
- **Animações:** `FadeInUp`, `FadeInDown`, e `FadeInRight` usando `react-native-reanimated` para navegação suave.
|
||||
- **Feedback Tátil:** `expo-haptics` acionados ao confirmar botões de bloqueio e salvar perfis.
|
||||
- **Design Responsivo:** Uso do `useWindowDimensions()` nas telas web para garantir que, no iPhone ou Android, os painéis colapsem na vertical (flex-col) de ponta a ponta com segurança visual (`paddingHorizontal: 16`).
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
# BarberFlow - Agendamento Moderno
|
||||
|
||||
Um projeto de agendamento de barbearia moderno construído com React Native (Expo), TypeScript e Reanimated.
|
||||
|
||||
## Tecnologias Utilizadas
|
||||
- **Expo Router**: Navegação baseada em arquivos.
|
||||
- **Lucide Icons**: Ícones modernos e consistentes.
|
||||
- **Reanimated 3**: Animações fluidas e de alta performance.
|
||||
- **Safe Area Context**: Layout adaptável a diferentes telas.
|
||||
- **Haptics**: Feedback tátil para interações.
|
||||
|
||||
## Estrutura do Projeto
|
||||
- `app/`: Contém as rotas e telas (Início, Agendar, Perfil).
|
||||
- `components/ui/`: Componentes reutilizáveis (Button, Card).
|
||||
- `constants/theme.ts`: Definição de cores, espaçamento e tipografia.
|
||||
|
||||
## Como Executar
|
||||
O servidor já está sendo iniciado em segundo plano.
|
||||
|
||||
Para ver no navegador:
|
||||
1. Pressione `ctrl + f` para focar no terminal se necessário.
|
||||
2. Abra o link exibido no terminal (geralmente `http://localhost:8081`).
|
||||
|
||||
Para abrir no VS Code:
|
||||
```bash
|
||||
code .
|
||||
```
|
||||
|
||||
## Funcionalidades
|
||||
- [x] Home com serviços e barbeiros em destaque.
|
||||
- [x] Agendamento com seleção de data e hora.
|
||||
- [x] Perfil do usuário com estatísticas e menu.
|
||||
- [x] Design escuro (Gold & Dark) de luxo.
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "barber-flow",
|
||||
"slug": "barber-flow",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "dark",
|
||||
"scheme": "barber-flow",
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
"expo-font"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { Stack } from 'expo-router';
|
||||
import { COLORS } from '../../../constants/theme';
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: COLORS.background },
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="login" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="register" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="forgot-password" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="language-selection" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, TextInput, Pressable, KeyboardAvoidingView, Platform, Alert, Image, ScrollView } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../../../constants/theme';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { Mail, Scissors, ChevronLeft } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useLanguage } from '../../../stores/LanguageContext';
|
||||
import { useBarbearia } from '../../../stores/BarbeariaContext';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export default function ForgotPasswordScreen() {
|
||||
const { t } = useLanguage();
|
||||
const { barbearia } = useBarbearia();
|
||||
const colors = barbearia?.colors || COLORS;
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleRecover = async () => {
|
||||
if (!email) {
|
||||
if (Platform.OS === 'web') {
|
||||
window.alert(t('admin.config.fill_all') || 'Preencha o e-mail');
|
||||
} else {
|
||||
Alert.alert('Erro', t('admin.config.fill_all') || 'Preencha o e-mail');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
|
||||
// Simulação de recuperação
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
if (Platform.OS === 'web') {
|
||||
window.alert('Instruções enviadas para o seu e-mail.');
|
||||
} else {
|
||||
Alert.alert('Sucesso', 'Instruções enviadas para o seu e-mail.');
|
||||
}
|
||||
router.back();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={{flex: 1}}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<ChevronLeft color={colors.primary} size={24} />
|
||||
<Text style={[styles.backText, { color: colors.primary }]}>{t('book.back') || 'Voltar'}</Text>
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Animated.View entering={FadeInDown.duration(600)} style={styles.logoContainer}>
|
||||
<View style={[styles.logoCircle, { backgroundColor: colors.surface, borderColor: colors.primary }]}>
|
||||
{barbearia?.logo ? (
|
||||
<Image source={{ uri: barbearia.logo }} style={{ width: 64, height: 64, borderRadius: 32 }} />
|
||||
) : (
|
||||
<Scissors color={colors.primary} size={32} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.brandName, { color: colors.primary }]}>
|
||||
Recuperar Senha
|
||||
</Text>
|
||||
<Text style={[styles.brandTagline, { color: colors.textMuted }]}>Enviaremos as instruções para seu e-mail.</Text>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInUp.delay(200)} style={styles.form}>
|
||||
|
||||
<View style={[styles.inputContainer, { backgroundColor: colors.surface, borderColor: colors.divider }]}>
|
||||
<Mail size={20} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder={t('login.email')}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title="Enviar Instruções"
|
||||
onPress={handleRecover}
|
||||
isLoading={isLoading}
|
||||
style={[styles.loginButton, { backgroundColor: colors.primary, ...(SHADOWS.glow(colors.primary) as any) }]}
|
||||
textStyle={{ color: colors.background }}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: SPACING.lg,
|
||||
position: 'absolute',
|
||||
top: Platform.OS === 'web' ? 20 : 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
backText: {
|
||||
...TYPOGRAPHY.body,
|
||||
marginLeft: 4,
|
||||
fontWeight: '600'
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: SPACING.xl,
|
||||
paddingTop: Platform.OS === 'web' ? 80 : 60,
|
||||
paddingBottom: 60,
|
||||
justifyContent: 'center',
|
||||
maxWidth: 500,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.xxxl,
|
||||
},
|
||||
logoCircle: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING.md,
|
||||
borderWidth: 1,
|
||||
},
|
||||
brandName: {
|
||||
...TYPOGRAPHY.h1,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
brandTagline: {
|
||||
...TYPOGRAPHY.body,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
form: {
|
||||
gap: SPACING.lg,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: BORDER_RADIUS.md,
|
||||
paddingHorizontal: SPACING.md,
|
||||
height: 60,
|
||||
borderWidth: 1,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: SPACING.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
...TYPOGRAPHY.body,
|
||||
height: '100%',
|
||||
},
|
||||
loginButton: {
|
||||
marginTop: SPACING.md,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable, Image } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS } from '../../../constants/theme';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { Languages } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useLanguage } from '../../../stores/LanguageContext';
|
||||
import { useBarbearia } from '../../../stores/BarbeariaContext';
|
||||
|
||||
export default function LanguageSelectionScreen() {
|
||||
const { language, setLanguage, t } = useLanguage();
|
||||
const { barbearia } = useBarbearia();
|
||||
const colors = barbearia?.colors || COLORS;
|
||||
|
||||
const [selectedLanguage, setSelectedLanguage] = React.useState<'pt' | 'es' | null>(null);
|
||||
|
||||
const handleSelect = (lang: 'pt' | 'es') => {
|
||||
setSelectedLanguage(lang);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
if (selectedLanguage) {
|
||||
setLanguage(selectedLanguage);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
router.replace(`/${barbearia?.slug}/login`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.content}>
|
||||
<View style={styles.header}>
|
||||
<Languages size={48} color={colors.primary} />
|
||||
<Text style={[styles.title, { color: colors.text }]}>Idioma / Idioma</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.textMuted }]}>Como você gostaria de ser atendido?</Text>
|
||||
<Text style={[styles.subtitleSpanish, { color: colors.textMuted }]}>¿Cómo le gostaria ser atendido?</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.options}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.option,
|
||||
{ backgroundColor: colors.card, borderColor: colors.divider },
|
||||
selectedLanguage === 'pt' && { backgroundColor: colors.primary, borderColor: colors.primary }
|
||||
]}
|
||||
onPress={() => handleSelect('pt')}
|
||||
>
|
||||
<Text style={styles.flag}>🇧🇷</Text>
|
||||
<View>
|
||||
<Text style={[styles.optionTitle, { color: colors.text }, selectedLanguage === 'pt' && { color: colors.background }]}>Português</Text>
|
||||
<Text style={[styles.optionSubtitle, { color: colors.textMuted }, selectedLanguage === 'pt' && { color: 'rgba(0,0,0,0.5)' }]}>Brasil</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
style={[
|
||||
styles.option,
|
||||
{ backgroundColor: colors.card, borderColor: colors.divider },
|
||||
selectedLanguage === 'es' && { backgroundColor: colors.primary, borderColor: colors.primary }
|
||||
]}
|
||||
onPress={() => handleSelect('es')}
|
||||
>
|
||||
<Text style={styles.flag}>🇪🇸</Text>
|
||||
<View>
|
||||
<Text style={[styles.optionTitle, { color: colors.text }, selectedLanguage === 'es' && { color: colors.background }]}>Español</Text>
|
||||
<Text style={[styles.optionSubtitle, { color: colors.textMuted }, selectedLanguage === 'es' && { color: 'rgba(0,0,0,0.5)' }]}>España / Latam</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
title="Continuar / Continuar"
|
||||
onPress={handleContinue}
|
||||
disabled={!selectedLanguage}
|
||||
style={selectedLanguage ? { backgroundColor: colors.primary } : {}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: SPACING.xl,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.xxl,
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.h2,
|
||||
marginTop: SPACING.md,
|
||||
},
|
||||
subtitle: {
|
||||
...TYPOGRAPHY.body,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
subtitleSpanish: {
|
||||
...TYPOGRAPHY.body,
|
||||
fontStyle: 'italic',
|
||||
textAlign: 'center',
|
||||
},
|
||||
options: {
|
||||
gap: SPACING.md,
|
||||
},
|
||||
option: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: SPACING.lg,
|
||||
borderRadius: BORDER_RADIUS.lg,
|
||||
borderWidth: 1,
|
||||
gap: SPACING.md,
|
||||
},
|
||||
flag: {
|
||||
fontSize: 32,
|
||||
},
|
||||
optionTitle: {
|
||||
...TYPOGRAPHY.h3,
|
||||
},
|
||||
optionSubtitle: {
|
||||
...TYPOGRAPHY.caption,
|
||||
},
|
||||
footer: {
|
||||
padding: SPACING.xl,
|
||||
paddingBottom: SPACING.xxl,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, TextInput, Pressable, KeyboardAvoidingView, Platform, Alert, Image } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../../../constants/theme';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { Mail, Lock, Scissors } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useLanguage } from '../../../stores/LanguageContext';
|
||||
import { useBarbearia } from '../../../stores/BarbeariaContext';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { t } = useLanguage();
|
||||
const { barbearia } = useBarbearia();
|
||||
const colors = barbearia?.colors || COLORS;
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email || !password) {
|
||||
if (Platform.OS === 'web') {
|
||||
window.alert(t('login.email') + ' / ' + t('login.password'));
|
||||
} else {
|
||||
Alert.alert('Erro', t('login.email') + ' / ' + t('login.password'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
|
||||
// Simulação de autenticação
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
// Navega para a tela de agendamento dentro do slug da barbearia atual
|
||||
router.replace(`/${barbearia?.slug}/(tabs)/agendar`);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={[styles.container, { backgroundColor: colors.background }]}
|
||||
>
|
||||
<View style={styles.content}>
|
||||
<Animated.View entering={FadeInDown.duration(600)} style={styles.logoContainer}>
|
||||
<View style={[styles.logoCircle, { backgroundColor: colors.surface, borderColor: colors.primary }]}>
|
||||
{barbearia?.logo ? (
|
||||
<Image source={{ uri: barbearia.logo }} style={{ width: 80, height: 80, borderRadius: 40 }} />
|
||||
) : (
|
||||
<Scissors color={colors.primary} size={40} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.brandName, { color: colors.primary }]}>
|
||||
{barbearia?.nome || t('login.title')}
|
||||
</Text>
|
||||
<Text style={[styles.brandTagline, { color: colors.textMuted }]}>{t('login.tagline')}</Text>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInUp.delay(200)} style={styles.form}>
|
||||
<View style={[styles.inputContainer, { backgroundColor: colors.surface, borderColor: colors.divider }]}>
|
||||
<Mail size={20} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder={t('login.email')}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.inputContainer, { backgroundColor: colors.surface, borderColor: colors.divider }]}>
|
||||
<Lock size={20} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder={t('login.password')}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
router.push(`/${barbearia?.slug}/(auth)/forgot-password`);
|
||||
}}
|
||||
style={styles.forgotPassword}
|
||||
>
|
||||
<Text style={[styles.forgotPasswordText, { color: colors.primary }]}>{t('login.forgot')}</Text>
|
||||
</Pressable>
|
||||
|
||||
<Button
|
||||
title={t('login.submit')}
|
||||
onPress={handleLogin}
|
||||
isLoading={isLoading}
|
||||
style={[styles.loginButton, { backgroundColor: colors.primary, ...(SHADOWS.glow(colors.primary) as any) }]}
|
||||
textStyle={{ color: colors.background }}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInUp.delay(400)} style={styles.footer}>
|
||||
<Text style={[styles.footerText, { color: colors.textMuted }]}>{t('login.noAccount')} </Text>
|
||||
<Pressable onPress={() => router.push(`/${barbearia?.slug}/(auth)/register`)}>
|
||||
<Text style={[styles.footerLink, { color: colors.primary }]}>{t('login.register')}</Text>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: SPACING.xl,
|
||||
justifyContent: 'center',
|
||||
maxWidth: 500,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.xxxl,
|
||||
},
|
||||
logoCircle: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING.lg,
|
||||
borderWidth: 1,
|
||||
},
|
||||
brandName: {
|
||||
...TYPOGRAPHY.h1,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
brandTagline: {
|
||||
...TYPOGRAPHY.bodyLarge,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
form: {
|
||||
gap: SPACING.lg,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: BORDER_RADIUS.md,
|
||||
paddingHorizontal: SPACING.md,
|
||||
height: 60,
|
||||
borderWidth: 1,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: SPACING.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
...TYPOGRAPHY.body,
|
||||
height: '100%',
|
||||
},
|
||||
forgotPassword: {
|
||||
alignSelf: 'flex-end',
|
||||
marginTop: -SPACING.sm,
|
||||
},
|
||||
forgotPasswordText: {
|
||||
...TYPOGRAPHY.bodySmall,
|
||||
fontWeight: '700',
|
||||
},
|
||||
loginButton: {
|
||||
marginTop: SPACING.md,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
marginTop: SPACING.xxxl,
|
||||
},
|
||||
footerText: {
|
||||
...TYPOGRAPHY.body,
|
||||
},
|
||||
footerLink: {
|
||||
...TYPOGRAPHY.body,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, TextInput, Pressable, KeyboardAvoidingView, Platform, Alert, Image, ScrollView } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../../../constants/theme';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { Mail, Lock, Scissors, User, Phone, ChevronLeft } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useLanguage } from '../../../stores/LanguageContext';
|
||||
import { useBarbearia } from '../../../stores/BarbeariaContext';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const { t } = useLanguage();
|
||||
const { barbearia } = useBarbearia();
|
||||
const colors = barbearia?.colors || COLORS;
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!name || !email || !password || !phone) {
|
||||
if (Platform.OS === 'web') {
|
||||
window.alert(t('admin.config.fill_all') || 'Preencha todos os campos');
|
||||
} else {
|
||||
Alert.alert('Erro', t('admin.config.fill_all') || 'Preencha todos os campos');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
|
||||
// Simulação de cadastro
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
router.replace(`/${barbearia?.slug}/(auth)/login`);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={{flex: 1}}
|
||||
>
|
||||
<Pressable
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<ChevronLeft color={colors.primary} size={24} />
|
||||
<Text style={[styles.backText, { color: colors.primary }]}>{t('book.back') || 'Voltar'}</Text>
|
||||
</Pressable>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<Animated.View entering={FadeInDown.duration(600)} style={styles.logoContainer}>
|
||||
<View style={[styles.logoCircle, { backgroundColor: colors.surface, borderColor: colors.primary }]}>
|
||||
{barbearia?.logo ? (
|
||||
<Image source={{ uri: barbearia.logo }} style={{ width: 64, height: 64, borderRadius: 32 }} />
|
||||
) : (
|
||||
<Scissors color={colors.primary} size={32} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.brandName, { color: colors.primary }]}>
|
||||
Criar Conta
|
||||
</Text>
|
||||
<Text style={[styles.brandTagline, { color: colors.textMuted }]}>Cadastre-se para agendar seu horário.</Text>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInUp.delay(200)} style={styles.form}>
|
||||
|
||||
<View style={[styles.inputContainer, { backgroundColor: colors.surface, borderColor: colors.divider }]}>
|
||||
<User size={20} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder="Seu Nome Completo"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.inputContainer, { backgroundColor: colors.surface, borderColor: colors.divider }]}>
|
||||
<Mail size={20} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder={t('login.email')}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.inputContainer, { backgroundColor: colors.surface, borderColor: colors.divider }]}>
|
||||
<Phone size={20} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder="Seu Telefone"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
keyboardType="phone-pad"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.inputContainer, { backgroundColor: colors.surface, borderColor: colors.divider }]}>
|
||||
<Lock size={20} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder={t('login.password')}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title={t('login.register') || 'Cadastrar'}
|
||||
onPress={handleRegister}
|
||||
isLoading={isLoading}
|
||||
style={[styles.loginButton, { backgroundColor: colors.primary, ...(SHADOWS.glow(colors.primary) as any) }]}
|
||||
textStyle={{ color: colors.background }}
|
||||
/>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: SPACING.lg,
|
||||
position: 'absolute',
|
||||
top: Platform.OS === 'web' ? 20 : 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
backText: {
|
||||
...TYPOGRAPHY.body,
|
||||
marginLeft: 4,
|
||||
fontWeight: '600'
|
||||
},
|
||||
content: {
|
||||
padding: SPACING.xl,
|
||||
paddingTop: Platform.OS === 'web' ? 80 : 60,
|
||||
paddingBottom: 60,
|
||||
justifyContent: 'center',
|
||||
maxWidth: 500,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.xxxl,
|
||||
},
|
||||
logoCircle: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING.md,
|
||||
borderWidth: 1,
|
||||
},
|
||||
brandName: {
|
||||
...TYPOGRAPHY.h1,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
brandTagline: {
|
||||
...TYPOGRAPHY.body,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
form: {
|
||||
gap: SPACING.lg,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: BORDER_RADIUS.md,
|
||||
paddingHorizontal: SPACING.md,
|
||||
height: 60,
|
||||
borderWidth: 1,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: SPACING.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
...TYPOGRAPHY.body,
|
||||
height: '100%',
|
||||
},
|
||||
loginButton: {
|
||||
marginTop: SPACING.md,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { Tabs } from 'expo-router';
|
||||
import { Home, Calendar, User } from 'lucide-react-native';
|
||||
import { COLORS } from '../../../constants/theme';
|
||||
import { useLanguage } from '../../../stores/LanguageContext';
|
||||
import { useBarbearia } from '../../../stores/BarbeariaContext';
|
||||
|
||||
export default function TabLayout() {
|
||||
const { t } = useLanguage();
|
||||
const { barbearia } = useBarbearia();
|
||||
const colors = barbearia?.colors || COLORS;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: colors.primary,
|
||||
tabBarInactiveTintColor: colors.textMuted,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.card,
|
||||
borderTopWidth: 0,
|
||||
elevation: 0,
|
||||
height: 60,
|
||||
paddingBottom: 8,
|
||||
paddingTop: 8,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="agendar"
|
||||
options={{
|
||||
title: t('tab.book'),
|
||||
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: t('tab.profile'),
|
||||
tabBarIcon: ({ color, size }) => <User size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,549 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Pressable, Image, TextInput, ActivityIndicator } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import Animated, { FadeInRight, FadeInUp } from 'react-native-reanimated';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../../../constants/theme';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { Card } from '../../../components/ui/Card';
|
||||
import {
|
||||
format,
|
||||
addDays,
|
||||
startOfToday,
|
||||
isSunday,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
eachDayOfInterval,
|
||||
addMonths,
|
||||
isSameDay,
|
||||
isBefore,
|
||||
startOfDay
|
||||
} from 'date-fns';
|
||||
import { ptBR, es } from 'date-fns/locale';
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight as ChevronRightIcon,
|
||||
CreditCard,
|
||||
Wallet,
|
||||
Copy,
|
||||
Check,
|
||||
Smartphone
|
||||
} from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { useLanguage } from '../../../stores/LanguageContext';
|
||||
import { useBarbearia } from '../../../stores/BarbeariaContext';
|
||||
|
||||
const COMBO_IDS = ['1', '2', '3'];
|
||||
const MORNING_TIMES = ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30'];
|
||||
const AFTERNOON_TIMES = ['13:30', '14:00', '14:30', '15:00', '15:30', '16:00', '16:30', '17:00', '17:30', '18:00'];
|
||||
|
||||
export default function AgendarScreen() {
|
||||
const { language, t, formatPrice } = useLanguage();
|
||||
const { barbearia, addAppointment } = useBarbearia();
|
||||
const colors = barbearia?.colors || COLORS;
|
||||
|
||||
const services = barbearia?.services || [];
|
||||
const barbers = barbearia?.barbers || [];
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [clientName, setClientName] = useState('');
|
||||
const [selectedServices, setSelectedServices] = useState<string[]>([]);
|
||||
const [selectedBarber, setSelectedBarber] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [viewDate, setViewDate] = useState(startOfToday());
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(addDays(startOfToday(), isSunday(startOfToday()) ? 1 : 0));
|
||||
const [selectedTime, setSelectedTime] = useState('');
|
||||
const [paymentMethod, setPaymentMethod] = useState<string | null>(null);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const dateLocale = language === 'pt' ? ptBR : es;
|
||||
|
||||
const monthDays = useMemo(() => {
|
||||
const start = startOfMonth(viewDate);
|
||||
const end = endOfMonth(viewDate);
|
||||
return eachDayOfInterval({ start, end }).filter(date => !isSunday(date));
|
||||
}, [viewDate]);
|
||||
|
||||
const { totalPt, totalEs, isCombo } = useMemo(() => {
|
||||
const selected = services.filter(s => selectedServices.includes(s.id));
|
||||
const hasAllCombo = COMBO_IDS.every(id => selectedServices.includes(id)) && COMBO_IDS.length > 0;
|
||||
|
||||
let subtotalPt = 0;
|
||||
let subtotalEs = 0;
|
||||
|
||||
selected.forEach(s => {
|
||||
subtotalPt += s.precoPt;
|
||||
subtotalEs += s.precoEs;
|
||||
});
|
||||
|
||||
if (hasAllCombo) {
|
||||
return { totalPt: subtotalPt * 0.9, totalEs: subtotalEs * 0.9, isCombo: true };
|
||||
}
|
||||
return { totalPt: subtotalPt, totalEs: subtotalEs, isCombo: false };
|
||||
}, [selectedServices, services]);
|
||||
|
||||
const availablePaymentMethods = useMemo(() => {
|
||||
if (!barbearia) return [];
|
||||
|
||||
// Filtra pelos métodos habilitados pelo admin
|
||||
let methods = barbearia.paymentMethods || ['money'];
|
||||
|
||||
// Filtra por regra de negócio do idioma
|
||||
if (language === 'pt') {
|
||||
methods = methods.filter(m => m !== 'alias');
|
||||
} else {
|
||||
methods = methods.filter(m => m !== 'pix');
|
||||
}
|
||||
|
||||
return methods;
|
||||
}, [barbearia, language]);
|
||||
|
||||
const toggleService = (id: string) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setSelectedServices(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]);
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setStep(prev => prev + 1);
|
||||
};
|
||||
|
||||
const handleBackStep = () => setStep(prev => prev - 1);
|
||||
|
||||
const handleFinish = async () => {
|
||||
if (!selectedBarber || !selectedTime || !paymentMethod) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await addAppointment({
|
||||
clientName: clientName || 'Cliente',
|
||||
serviceIds: selectedServices,
|
||||
barberId: selectedBarber,
|
||||
date: format(selectedDate, 'dd/MM/yyyy'),
|
||||
time: selectedTime,
|
||||
totalPt: totalPt,
|
||||
totalEs: totalEs,
|
||||
});
|
||||
setStep(3);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyPixLink = () => {
|
||||
setIsCopied(true);
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
};
|
||||
|
||||
const isPastTime = (timeStr: string) => {
|
||||
if (!isSameDay(selectedDate, startOfToday())) return false;
|
||||
|
||||
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||
const now = new Date();
|
||||
|
||||
if (hours < now.getHours()) return true;
|
||||
if (hours === now.getHours() && minutes <= now.getMinutes()) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Step 0: Services & Barber
|
||||
if (step === 0) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<Animated.Text entering={FadeInUp} style={[styles.title, { color: colors.text }]}>{t('book.services')}</Animated.Text>
|
||||
<Animated.Text entering={FadeInUp.delay(100)} style={[styles.subtitle, { color: colors.textMuted }]}>{t('lang.subtitle')}</Animated.Text>
|
||||
</View>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 150 }}>
|
||||
|
||||
<Animated.View entering={FadeInRight.delay(200)} style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>Seu Nome</Text>
|
||||
<TextInput
|
||||
style={[styles.nameInput, { backgroundColor: colors.surface, color: colors.text, borderColor: colors.divider }]}
|
||||
placeholder="Digite seu nome"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={clientName}
|
||||
onChangeText={setClientName}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInRight.delay(300)} style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t('home.services')}</Text>
|
||||
{services.map((service, idx) => {
|
||||
const isSelected = selectedServices.includes(service.id);
|
||||
return (
|
||||
<Pressable
|
||||
key={service.id}
|
||||
onPress={() => toggleService(service.id)}
|
||||
style={[
|
||||
styles.serviceItem,
|
||||
{ backgroundColor: colors.surface, borderColor: colors.divider },
|
||||
isSelected && { backgroundColor: colors.primary, borderColor: colors.primary, ...(SHADOWS.glow(colors.primary) as any) }
|
||||
]}
|
||||
>
|
||||
<View style={styles.serviceInfo}>
|
||||
<Text style={[styles.serviceName, { color: colors.text }, isSelected && { color: colors.background }]}>
|
||||
{language === 'pt' ? service.nomePt : service.nomeEs}
|
||||
</Text>
|
||||
<Text style={[styles.serviceMeta, { color: colors.textMuted }, isSelected && { color: 'rgba(0,0,0,0.6)' }]}>{service.duracao} min</Text>
|
||||
</View>
|
||||
<Text style={[styles.servicePrice, { color: colors.primary }, isSelected && { color: colors.background }]}>
|
||||
{formatPrice(service.precoPt, service.precoEs)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInRight.delay(400)} style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t('home.barbers')}</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: SPACING.md }}>
|
||||
{barbers.map((barber) => (
|
||||
<Pressable
|
||||
key={barber.id}
|
||||
onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); setSelectedBarber(barber.id); }}
|
||||
style={[
|
||||
styles.barberItem,
|
||||
{ backgroundColor: colors.surface, borderColor: colors.divider },
|
||||
selectedBarber === barber.id && { backgroundColor: colors.primary, borderColor: colors.primary }
|
||||
]}
|
||||
>
|
||||
<Image source={{ uri: barber.foto }} style={styles.barberImg} />
|
||||
<Text style={[styles.barberName, { color: colors.text }, selectedBarber === barber.id && { color: colors.background }]}>{barber.nome}</Text>
|
||||
{selectedBarber === barber.id && <CheckCircle2 size={16} color={colors.background} style={styles.checkIcon} />}
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
|
||||
<Animated.View style={[styles.footer, { backgroundColor: colors.surface, borderTopColor: colors.divider }]}>
|
||||
<View style={styles.totalRow}>
|
||||
<Text style={[styles.totalLabel, { color: colors.textMuted }]}>{t('book.total')}:</Text>
|
||||
<Text style={[styles.totalValue, { color: colors.primary }]}>{formatPrice(totalPt, totalEs)}</Text>
|
||||
</View>
|
||||
<Button
|
||||
title={t('book.next')}
|
||||
onPress={handleNextStep}
|
||||
disabled={selectedServices.length === 0 || !selectedBarber || !clientName}
|
||||
/>
|
||||
</Animated.View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 1: Date & Time
|
||||
if (step === 1) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{t('book.dateTime')}</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.textMuted }]}>{t('lang.subtitle')}</Text>
|
||||
</View>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 150 }}>
|
||||
|
||||
<Animated.View entering={FadeInRight} style={styles.calendarHeader}>
|
||||
<Pressable onPress={() => setViewDate(prev => addMonths(prev, -1))} style={styles.monthNav} disabled={isBefore(startOfMonth(viewDate), startOfMonth(startOfToday()))}>
|
||||
<ChevronLeft size={24} color={isBefore(startOfMonth(viewDate), startOfMonth(startOfToday())) ? colors.divider : colors.primary} />
|
||||
</Pressable>
|
||||
<Text style={[styles.monthTitle, { color: colors.primary }]}>{format(viewDate, 'MMMM yyyy', { locale: dateLocale }).toUpperCase()}</Text>
|
||||
<Pressable onPress={() => setViewDate(prev => addMonths(prev, 1))} style={styles.monthNav}>
|
||||
<ChevronRightIcon size={24} color={colors.primary} />
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.dateList}>
|
||||
{monthDays.map((date) => {
|
||||
const isSelected = isSameDay(date, selectedDate);
|
||||
const isPast = isBefore(startOfDay(date), startOfDay(startOfToday()));
|
||||
return (
|
||||
<Pressable
|
||||
key={date.toString()}
|
||||
onPress={() => {
|
||||
if (!isPast) {
|
||||
setSelectedDate(date);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
}}
|
||||
disabled={isPast}
|
||||
style={[
|
||||
styles.dateItem,
|
||||
{ backgroundColor: colors.surface, borderColor: colors.divider },
|
||||
isSelected && { backgroundColor: colors.primary, borderColor: colors.primary },
|
||||
isPast && styles.dateItemDisabled
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.dateDay, { color: colors.textMuted }, isSelected && { color: colors.background }]}>
|
||||
{format(date, 'EEE', { locale: dateLocale }).toUpperCase()}
|
||||
</Text>
|
||||
<Text style={[styles.dateNumber, { color: colors.text }, isSelected && { color: colors.background }]}>
|
||||
{format(date, 'dd')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</Animated.ScrollView>
|
||||
|
||||
<Animated.View entering={FadeInRight.delay(100)} style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t('book.morning')}</Text>
|
||||
<View style={styles.timeGrid}>
|
||||
{MORNING_TIMES.map((time) => {
|
||||
const isBooked = barbearia?.appointments.some(a => a.date === format(selectedDate, 'dd/MM/yyyy') && a.time === time && a.barberId === selectedBarber && a.status !== 'rejected');
|
||||
const isBlocked = barbearia?.blockedSlots?.some(s => s.date === format(selectedDate, 'dd/MM/yyyy') && s.time === time && s.barberId === selectedBarber);
|
||||
const isUnavailable = isBooked || isBlocked || isPastTime(time);
|
||||
return (
|
||||
<Pressable
|
||||
key={time}
|
||||
onPress={() => !isUnavailable && setSelectedTime(time)}
|
||||
style={[
|
||||
styles.timeItem,
|
||||
{ backgroundColor: colors.surface, borderColor: colors.divider },
|
||||
selectedTime === time && { backgroundColor: colors.primary, borderColor: colors.primary },
|
||||
isUnavailable && { opacity: 0.3 }
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.timeText, { color: colors.text }, selectedTime === time && { color: colors.background }, isUnavailable && { textDecorationLine: 'line-through' }]}>
|
||||
{time}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<Text style={[styles.sectionTitle, { marginTop: SPACING.lg, color: colors.text }]}>{t('book.afternoon')}</Text>
|
||||
<View style={styles.timeGrid}>
|
||||
{AFTERNOON_TIMES.map((time) => {
|
||||
const isBooked = barbearia?.appointments.some(a => a.date === format(selectedDate, 'dd/MM/yyyy') && a.time === time && a.barberId === selectedBarber && a.status !== 'rejected');
|
||||
const isBlocked = barbearia?.blockedSlots?.some(s => s.date === format(selectedDate, 'dd/MM/yyyy') && s.time === time && s.barberId === selectedBarber);
|
||||
const isUnavailable = isBooked || isBlocked || isPastTime(time);
|
||||
return (
|
||||
<Pressable
|
||||
key={time}
|
||||
onPress={() => !isUnavailable && setSelectedTime(time)}
|
||||
style={[
|
||||
styles.timeItem,
|
||||
{ backgroundColor: colors.surface, borderColor: colors.divider },
|
||||
selectedTime === time && { backgroundColor: colors.primary, borderColor: colors.primary },
|
||||
isUnavailable && { opacity: 0.3 }
|
||||
]}
|
||||
>
|
||||
<Text style={[styles.timeText, { color: colors.text }, selectedTime === time && { color: colors.background }, isUnavailable && { textDecorationLine: 'line-through' }]}>
|
||||
{time}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={[styles.footer, { backgroundColor: colors.surface, borderTopColor: colors.divider }]}>
|
||||
<View style={styles.navButtons}>
|
||||
<Button title={t('book.back')} variant="ghost" onPress={handleBackStep} style={{ flex: 1 }} />
|
||||
<Button title={t('book.next')} onPress={handleNextStep} disabled={!selectedTime} style={{ flex: 2 }} />
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 2: Payment
|
||||
if (step === 2) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>{t('book.payment')}</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.textMuted }]}>{t('lang.subtitle')}</Text>
|
||||
</View>
|
||||
<Animated.View entering={FadeInRight} style={styles.content}>
|
||||
{availablePaymentMethods.map((method) => (
|
||||
<Pressable
|
||||
key={method}
|
||||
style={[
|
||||
styles.paymentOption,
|
||||
{ backgroundColor: colors.surface, borderColor: colors.divider },
|
||||
paymentMethod === method && { backgroundColor: colors.primary, borderColor: colors.primary }
|
||||
]}
|
||||
onPress={() => setPaymentMethod(method)}
|
||||
>
|
||||
{method === 'pix' ? <Smartphone size={28} color={paymentMethod === method ? colors.background : colors.primary} /> :
|
||||
method === 'card' ? <CreditCard size={28} color={paymentMethod === method ? colors.background : colors.primary} /> :
|
||||
method === 'money' ? <Wallet size={28} color={paymentMethod === method ? colors.background : colors.primary} /> :
|
||||
<Smartphone size={28} color={paymentMethod === method ? colors.background : colors.primary} />}
|
||||
<Text style={[styles.paymentText, { color: colors.text }, paymentMethod === method && { color: colors.background }]}>
|
||||
{t(`book.${method}`)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
|
||||
{paymentMethod === 'pix' && (
|
||||
<Animated.View entering={FadeInUp}>
|
||||
<Card style={[styles.pixCard, { backgroundColor: colors.surface }]}>
|
||||
<Text style={[styles.pixLabel, { color: colors.textMuted }]}>{t('book.pixCopy')}:</Text>
|
||||
<View style={styles.pixCopyRow}>
|
||||
<TextInput
|
||||
value="00020126580014br.gov.bcb.pix..."
|
||||
editable={false}
|
||||
style={[styles.pixInput, { backgroundColor: `${colors.background}80`, color: colors.text }]}
|
||||
/>
|
||||
<Pressable onPress={copyPixLink} style={[styles.copyBtn, { backgroundColor: `${colors.background}80` }]}>
|
||||
{isCopied ? <Check size={20} color={COLORS.success} /> : <Copy size={20} color={colors.primary} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{paymentMethod === 'alias' && (
|
||||
<Animated.View entering={FadeInUp}>
|
||||
<Card style={[styles.pixCard, { backgroundColor: colors.surface }]}>
|
||||
<Text style={[styles.pixLabel, { color: colors.textMuted }]}>{t('book.alias')}:</Text>
|
||||
<View style={styles.pixCopyRow}>
|
||||
<TextInput
|
||||
value="ALIAS-BARBER-GS-9283"
|
||||
editable={false}
|
||||
style={[styles.pixInput, { backgroundColor: `${colors.background}80`, color: colors.text }]}
|
||||
/>
|
||||
<Pressable onPress={copyPixLink} style={[styles.copyBtn, { backgroundColor: `${colors.background}80` }]}>
|
||||
{isCopied ? <Check size={20} color={COLORS.success} /> : <Copy size={20} color={colors.primary} />}
|
||||
</Pressable>
|
||||
</View>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{paymentMethod === 'card' && (
|
||||
<Animated.View entering={FadeInUp}>
|
||||
<Card style={[styles.pixCard, { backgroundColor: colors.surface }]}>
|
||||
<Text style={[styles.pixLabel, { color: colors.text }]}>{t('book.cardMsg') || 'O pagamento será realizado presencialmente na maquininha.'}</Text>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{paymentMethod === 'money' && (
|
||||
<Animated.View entering={FadeInUp}>
|
||||
<Card style={[styles.pixCard, { backgroundColor: colors.surface }]}>
|
||||
<Text style={[styles.pixLabel, { color: colors.text }]}>{t('book.moneyMsg')}</Text>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
<View style={[styles.footer, { backgroundColor: colors.surface, borderTopColor: colors.divider }]}>
|
||||
<View style={styles.navButtons}>
|
||||
<Button title={t('book.back')} variant="ghost" onPress={handleBackStep} style={{ flex: 1 }} />
|
||||
<Button title={t('book.finish')} onPress={handleFinish} isLoading={isLoading} disabled={!paymentMethod} style={{ flex: 2 }} />
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Success
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<Animated.View entering={FadeInUp.springify()} style={styles.successContainer}>
|
||||
<CheckCircle2 size={100} color={colors.primary} />
|
||||
<Text style={[styles.successTitle, { color: colors.primary }]}>
|
||||
{paymentMethod === 'money' ? t('book.waiting') : t('book.success')}
|
||||
</Text>
|
||||
|
||||
<Card style={[styles.receiptCard, { backgroundColor: colors.surface }]}>
|
||||
<View style={styles.receiptHeader}>
|
||||
<Text style={[styles.receiptBrand, { color: colors.primary }]}>{barbearia?.nome || "BarberFlow"}</Text>
|
||||
<Text style={[styles.receiptId, { color: colors.textMuted }]}>#BF-9482</Text>
|
||||
</View>
|
||||
<View style={[styles.receiptDivider, { backgroundColor: colors.divider }]}/>
|
||||
<View style={styles.receiptRow}>
|
||||
<Text style={[styles.receiptLabel, { color: colors.textMuted }]}>{t('home.barbers')}:</Text>
|
||||
<Text style={[styles.receiptValue, { color: colors.text }]}>{barbers.find(b => b.id === selectedBarber)?.nome}</Text>
|
||||
</View>
|
||||
<View style={styles.receiptRow}>
|
||||
<Text style={[styles.receiptLabel, { color: colors.textMuted }]}>{t('lang.title')}:</Text>
|
||||
<Text style={[styles.receiptValue, { color: colors.text }]}>{format(selectedDate, "dd 'de' MMMM", { locale: dateLocale })}</Text>
|
||||
</View>
|
||||
<View style={styles.receiptRow}>
|
||||
<Text style={[styles.receiptLabel, { color: colors.textMuted }]}>{t('book.time')}:</Text>
|
||||
<Text style={[styles.receiptValue, { color: colors.text }]}>{selectedTime}</Text>
|
||||
</View>
|
||||
<View style={[styles.receiptDivider, { backgroundColor: colors.divider }]} />
|
||||
<View style={styles.receiptRow}>
|
||||
<Text style={[styles.receiptLabel, { color: colors.text, fontWeight: '700' }]}>Total:</Text>
|
||||
<Text style={[styles.receiptTotal, { color: colors.primary }]}>{formatPrice(totalPt, totalEs)}</Text>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Button title={t('book.back')} onPress={() => setStep(0)} style={{ width: '100%', marginTop: SPACING.xl }} />
|
||||
</Animated.View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
header: { padding: SPACING.xl },
|
||||
content: { padding: SPACING.xl, flex: 1 },
|
||||
title: { ...TYPOGRAPHY.h1, letterSpacing: -0.5 },
|
||||
subtitle: { ...TYPOGRAPHY.body, marginTop: 4 },
|
||||
section: { marginBottom: SPACING.xl, paddingHorizontal: SPACING.xl },
|
||||
sectionTitle: { ...TYPOGRAPHY.h3, marginBottom: SPACING.md },
|
||||
nameInput: { padding: SPACING.lg, borderRadius: BORDER_RADIUS.md, borderWidth: 1, ...TYPOGRAPHY.body },
|
||||
|
||||
calendarHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: SPACING.xl, marginBottom: SPACING.md },
|
||||
monthTitle: { ...TYPOGRAPHY.bodyLarge, fontWeight: '700', letterSpacing: 1 },
|
||||
monthNav: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: BORDER_RADIUS.md, backgroundColor: 'rgba(255,255,255,0.05)' },
|
||||
|
||||
serviceItem: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: SPACING.lg, borderRadius: BORDER_RADIUS.md, marginBottom: SPACING.sm, borderWidth: 1 },
|
||||
serviceInfo: { flex: 1 },
|
||||
serviceName: { ...TYPOGRAPHY.bodyLarge, fontWeight: '600' },
|
||||
serviceMeta: { ...TYPOGRAPHY.caption, marginTop: 4 },
|
||||
servicePrice: { ...TYPOGRAPHY.h4 },
|
||||
|
||||
barberItem: { width: 140, padding: SPACING.lg, borderRadius: BORDER_RADIUS.lg, alignItems: 'center', borderWidth: 1 },
|
||||
barberImg: { width: 70, height: 70, borderRadius: 35, marginBottom: SPACING.md },
|
||||
barberName: { ...TYPOGRAPHY.bodySmall, fontWeight: '700', textAlign: 'center' },
|
||||
checkIcon: { position: 'absolute', top: 12, right: 12 },
|
||||
|
||||
footer: { position: 'absolute', bottom: 0, left: 0, right: 0, padding: SPACING.xl, borderTopWidth: 1, ...(SHADOWS.large as any) },
|
||||
totalRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: SPACING.md, alignItems: 'center' },
|
||||
totalLabel: { ...TYPOGRAPHY.bodyLarge },
|
||||
totalValue: { ...TYPOGRAPHY.h1 },
|
||||
|
||||
dateList: { gap: SPACING.sm, paddingHorizontal: SPACING.xl, marginBottom: SPACING.xxl },
|
||||
dateItem: { width: 72, height: 90, borderRadius: BORDER_RADIUS.lg, alignItems: 'center', justifyContent: 'center', borderWidth: 1 },
|
||||
dateItemDisabled: { opacity: 0.3 },
|
||||
dateDay: { ...TYPOGRAPHY.caption, fontWeight: '700', marginBottom: 4 },
|
||||
dateNumber: { ...TYPOGRAPHY.h2 },
|
||||
|
||||
timeGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: SPACING.sm },
|
||||
timeItem: { width: '31%', paddingVertical: SPACING.lg, borderRadius: BORDER_RADIUS.md, alignItems: 'center', borderWidth: 1 },
|
||||
timeText: { ...TYPOGRAPHY.bodyLarge, fontWeight: '600' },
|
||||
|
||||
navButtons: { flexDirection: 'row', gap: SPACING.md },
|
||||
|
||||
paymentOption: { flexDirection: 'row', alignItems: 'center', gap: SPACING.md, padding: SPACING.xl, borderRadius: BORDER_RADIUS.xl, marginBottom: SPACING.md, borderWidth: 2 },
|
||||
paymentText: { ...TYPOGRAPHY.h4 },
|
||||
pixCard: { padding: SPACING.lg, marginTop: SPACING.md },
|
||||
pixLabel: { ...TYPOGRAPHY.caption, marginBottom: SPACING.xs },
|
||||
pixCopyRow: { flexDirection: 'row', gap: SPACING.sm, marginBottom: SPACING.md },
|
||||
pixInput: { flex: 1, padding: SPACING.md, borderRadius: BORDER_RADIUS.md, ...TYPOGRAPHY.bodySmall },
|
||||
copyBtn: { width: 48, height: 48, minHeight: 48, justifyContent: 'center', alignItems: 'center', borderRadius: BORDER_RADIUS.sm },
|
||||
|
||||
successContainer: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: SPACING.xl },
|
||||
successTitle: { ...TYPOGRAPHY.h1, marginTop: SPACING.xl, textAlign: 'center' },
|
||||
receiptCard: { width: '100%', padding: SPACING.xl, marginTop: SPACING.xxl },
|
||||
receiptHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
||||
receiptBrand: { ...TYPOGRAPHY.h3 },
|
||||
receiptId: { ...TYPOGRAPHY.caption },
|
||||
receiptDivider: { height: 1, marginVertical: SPACING.lg },
|
||||
receiptRow: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: SPACING.md },
|
||||
receiptLabel: { ...TYPOGRAPHY.bodySmall },
|
||||
receiptValue: { ...TYPOGRAPHY.body, fontWeight: '600' },
|
||||
receiptTotal: { ...TYPOGRAPHY.h2 },
|
||||
});
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, Image, ScrollView, TouchableOpacity, TextInput, Platform, Alert, Switch } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import Animated, { FadeInUp, FadeInDown, FadeIn, FadeOut } from 'react-native-reanimated';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../../../constants/theme';
|
||||
import { Card } from '../../../components/ui/Card';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { User, Settings, CreditCard, Bell, LogOut, ChevronRight, ChevronDown, Camera, Check, X, Plus, Languages, RefreshCw, FileText } from 'lucide-react-native';
|
||||
import { useLanguage } from '../../../stores/LanguageContext';
|
||||
import { useBarbearia } from '../../../stores/BarbeariaContext';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const { t, language, setLanguage } = useLanguage();
|
||||
const { barbearia } = useBarbearia();
|
||||
const { slug } = useLocalSearchParams();
|
||||
const colors = barbearia?.colors || COLORS;
|
||||
const primaryColor = colors.primary;
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [name, setName] = useState('Cliente VIP');
|
||||
const [email, setEmail] = useState('cliente@email.com');
|
||||
const [phone, setPhone] = useState('(00) 00000-0000');
|
||||
const [photo, setPhoto] = useState('https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=400');
|
||||
const [cards, setCards] = useState([{ id: '1', last4: '4242', brand: 'Visa' }]);
|
||||
|
||||
// Accordion State
|
||||
const [expandedSection, setExpandedSection] = useState<string | null>(null);
|
||||
|
||||
// Notification States
|
||||
const [notifySms, setNotifySms] = useState(true);
|
||||
const [notifyEmail, setNotifyEmail] = useState(true);
|
||||
const [notifyReminder, setNotifyReminder] = useState(true);
|
||||
|
||||
// Carregar dados salvos localmente
|
||||
useEffect(() => {
|
||||
AsyncStorage.getItem('@barber_client_profile').then(data => {
|
||||
if (data) {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.name) setName(parsed.name);
|
||||
if (parsed.email) setEmail(parsed.email);
|
||||
if (parsed.phone) setPhone(parsed.phone);
|
||||
if (parsed.photo) setPhoto(parsed.photo);
|
||||
if (parsed.cards) setCards(parsed.cards);
|
||||
if (parsed.notifications) {
|
||||
setNotifySms(parsed.notifications.sms);
|
||||
setNotifyEmail(parsed.notifications.email);
|
||||
setNotifyReminder(parsed.notifications.reminder);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const saveProfile = async (updates: any) => {
|
||||
const current = {
|
||||
name, email, phone, photo, cards,
|
||||
notifications: { sms: notifySms, email: notifyEmail, reminder: notifyReminder },
|
||||
...updates
|
||||
};
|
||||
await AsyncStorage.setItem('@barber_client_profile', JSON.stringify(current));
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
setIsEditing(false);
|
||||
saveProfile({ name, email, phone });
|
||||
};
|
||||
|
||||
const pickImage = async () => {
|
||||
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('Permissão necessária', 'Precisamos de acesso à sua galeria.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.5,
|
||||
base64: true,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0].base64) {
|
||||
const newPhoto = `data:image/jpeg;base64,${result.assets[0].base64}`;
|
||||
setPhoto(newPhoto);
|
||||
saveProfile({ photo: newPhoto });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCard = () => {
|
||||
if (Platform.OS === 'web') {
|
||||
const last4 = window.prompt('Digite os 4 últimos dígitos do cartão:');
|
||||
if (last4 && last4.length === 4) {
|
||||
const newCard = { id: Math.random().toString(), last4, brand: 'Mastercard' };
|
||||
const newCards = [...cards, newCard];
|
||||
setCards(newCards);
|
||||
saveProfile({ cards: newCards });
|
||||
}
|
||||
} else {
|
||||
Alert.prompt('Novo Cartão', 'Digite os 4 últimos dígitos:', [
|
||||
{ text: 'Cancelar', style: 'cancel' },
|
||||
{
|
||||
text: 'Adicionar',
|
||||
onPress: (text) => {
|
||||
if (text && text.length >= 4) {
|
||||
const newCard = { id: Math.random().toString(), last4: text.slice(-4), brand: 'Mastercard' };
|
||||
const newCards = [...cards, newCard];
|
||||
setCards(newCards);
|
||||
saveProfile({ cards: newCards });
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const removeCard = (id: string) => {
|
||||
const newCards = cards.filter(c => c.id !== id);
|
||||
setCards(newCards);
|
||||
saveProfile({ cards: newCards });
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
router.replace(`/${slug}/(auth)/login`);
|
||||
};
|
||||
|
||||
const handleClearCache = () => {
|
||||
if (Platform.OS === 'web') {
|
||||
window.alert('Dados sincronizados com sucesso!');
|
||||
} else {
|
||||
Alert.alert('Sucesso', 'Aplicativo sincronizado e cache limpo.');
|
||||
}
|
||||
};
|
||||
|
||||
// Calcula estatísticas reais baseadas no histórico do barbearia (simulação usando appointments globais)
|
||||
const myAppointments = barbearia?.appointments?.filter(a => a.status === 'accepted') || [];
|
||||
const cutsCount = myAppointments.length;
|
||||
|
||||
let points = 0;
|
||||
myAppointments.forEach(a => {
|
||||
points += (a.serviceIds.length * 5);
|
||||
});
|
||||
|
||||
const displayCuts = cutsCount > 0 ? cutsCount : 0;
|
||||
const displayPoints = points > 0 ? points : 0;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: SPACING.xxl }}>
|
||||
|
||||
{/* Profile Header */}
|
||||
<Animated.View entering={FadeInDown.duration(600)} style={styles.header}>
|
||||
<TouchableOpacity onPress={pickImage} style={[styles.avatarContainer, { borderColor: primaryColor, ...(SHADOWS.glow(primaryColor) as any) }]}>
|
||||
<Image source={{ uri: photo }} style={styles.avatar} />
|
||||
<View style={[styles.editPhotoBadge, { backgroundColor: primaryColor }]}>
|
||||
<Camera size={14} color={colors.background} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{isEditing ? (
|
||||
<Animated.View entering={FadeIn} style={styles.editForm}>
|
||||
<TextInput style={[styles.input, { color: colors.text, borderColor: colors.divider, backgroundColor: colors.surface }]} value={name} onChangeText={setName} placeholder="Seu Nome" placeholderTextColor={colors.textMuted} />
|
||||
<TextInput style={[styles.input, { color: colors.text, borderColor: colors.divider, backgroundColor: colors.surface }]} value={email} onChangeText={setEmail} placeholder="E-mail" keyboardType="email-address" placeholderTextColor={colors.textMuted} />
|
||||
<TextInput style={[styles.input, { color: colors.text, borderColor: colors.divider, backgroundColor: colors.surface }]} value={phone} onChangeText={setPhone} placeholder="Telefone" placeholderTextColor={colors.textMuted} />
|
||||
<View style={styles.editActions}>
|
||||
<Button title="Cancelar" variant="ghost" onPress={() => setIsEditing(false)} style={{flex: 1}} textStyle={{color: colors.textMuted}} />
|
||||
<Button title="Salvar" onPress={handleSaveEdit} style={{flex: 1, backgroundColor: primaryColor}} textStyle={{color: colors.background}} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Animated.View entering={FadeIn} style={{ alignItems: 'center' }}>
|
||||
<Text style={[styles.userName, { color: colors.text }]}>{name}</Text>
|
||||
<Text style={[styles.userEmail, { color: colors.textMuted }]}>{email}</Text>
|
||||
<Text style={[styles.userEmail, { color: colors.textMuted, marginTop: -4 }]}>{phone}</Text>
|
||||
<Button
|
||||
title={t('profile.edit')}
|
||||
variant="outline"
|
||||
style={[styles.editButton, { borderColor: primaryColor }]}
|
||||
textStyle={{ color: primaryColor }}
|
||||
onPress={() => setIsEditing(true)}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Stats */}
|
||||
<Animated.View entering={FadeInUp.delay(200)} style={styles.statsRow}>
|
||||
<Card style={[styles.statCard, { backgroundColor: colors.surface }]} variant="elevated">
|
||||
<Text style={[styles.statNumber, { color: primaryColor }]}>{displayCuts}</Text>
|
||||
<Text style={[styles.statLabel, { color: colors.textMuted }]}>{t('profile.cuts')}</Text>
|
||||
</Card>
|
||||
<Card style={[styles.statCard, { backgroundColor: colors.surface }]} variant="elevated">
|
||||
<Text style={[styles.statNumber, { color: primaryColor }]}>{displayPoints}</Text>
|
||||
<Text style={[styles.statLabel, { color: colors.textMuted }]}>{t('profile.points')}</Text>
|
||||
<Text style={{fontSize: 9, color: primaryColor, marginTop: 4}}>+5 por serviço</Text>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
|
||||
{/* Payment Methods Section */}
|
||||
<Animated.View entering={FadeInUp.delay(300)} style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: primaryColor }]}>Meus Cartões</Text>
|
||||
{cards.map(card => (
|
||||
<Card key={card.id} style={[styles.cardItem, { backgroundColor: colors.surface, borderColor: colors.divider }]} variant="outline">
|
||||
<View style={styles.cardInfo}>
|
||||
<CreditCard size={24} color={colors.textMuted} />
|
||||
<View style={{ marginLeft: 12 }}>
|
||||
<Text style={[styles.cardBrand, { color: colors.text }]}>{card.brand}</Text>
|
||||
<Text style={[styles.cardNumber, { color: colors.textMuted }]}>**** **** **** {card.last4}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => removeCard(card.id)} style={{ padding: 8 }}>
|
||||
<X size={20} color={COLORS.error} />
|
||||
</TouchableOpacity>
|
||||
</Card>
|
||||
))}
|
||||
<TouchableOpacity style={[styles.addCardBtn, { borderColor: primaryColor, backgroundColor: `${primaryColor}10` }]} onPress={handleAddCard}>
|
||||
<Plus size={20} color={primaryColor} />
|
||||
<Text style={{ color: primaryColor, fontWeight: 'bold', marginLeft: 8 }}>Adicionar Cartão</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Menu Accordions */}
|
||||
<View style={styles.menuSection}>
|
||||
<Text style={[styles.sectionTitle, { color: primaryColor }]}>{t('profile.settings')}</Text>
|
||||
|
||||
{/* Notificações Accordion */}
|
||||
<Animated.View entering={FadeInUp.delay(400)}>
|
||||
<Card style={[styles.menuItem, { backgroundColor: colors.surface, borderColor: colors.divider }]} variant="outline">
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
style={styles.menuContent}
|
||||
onPress={() => setExpandedSection(expandedSection === 'notifications' ? null : 'notifications')}
|
||||
>
|
||||
<View style={[styles.menuIconContainer, { backgroundColor: `${primaryColor}15` }]}>
|
||||
<Bell size={20} color={primaryColor} />
|
||||
</View>
|
||||
<Text style={[styles.menuTitle, { color: colors.text }]}>{t('profile.notifications')}</Text>
|
||||
{expandedSection === 'notifications' ? <ChevronDown size={20} color={colors.textMuted} /> : <ChevronRight size={20} color={colors.textMuted} />}
|
||||
</TouchableOpacity>
|
||||
|
||||
{expandedSection === 'notifications' && (
|
||||
<Animated.View entering={FadeInDown} style={[styles.accordionContent, { borderTopColor: colors.divider }]}>
|
||||
<View style={styles.settingRow}>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Lembrete de Agendamento</Text>
|
||||
<Switch value={notifyReminder} onValueChange={(v) => { setNotifyReminder(v); saveProfile({ notifications: { sms: notifySms, email: notifyEmail, reminder: v }}); }} trackColor={{ true: primaryColor }} />
|
||||
</View>
|
||||
<View style={styles.settingRow}>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Avisos via WhatsApp/SMS</Text>
|
||||
<Switch value={notifySms} onValueChange={(v) => { setNotifySms(v); saveProfile({ notifications: { sms: v, email: notifyEmail, reminder: notifyReminder }}); }} trackColor={{ true: primaryColor }} />
|
||||
</View>
|
||||
<View style={styles.settingRow}>
|
||||
<Text style={[styles.settingLabel, { color: colors.text }]}>Avisos via E-mail</Text>
|
||||
<Switch value={notifyEmail} onValueChange={(v) => { setNotifyEmail(v); saveProfile({ notifications: { sms: notifySms, email: v, reminder: notifyReminder }}); }} trackColor={{ true: primaryColor }} />
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</Card>
|
||||
</Animated.View>
|
||||
|
||||
{/* Configurações Accordion */}
|
||||
<Animated.View entering={FadeInUp.delay(500)}>
|
||||
<Card style={[styles.menuItem, { backgroundColor: colors.surface, borderColor: colors.divider, marginTop: SPACING.sm }]} variant="outline">
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
style={styles.menuContent}
|
||||
onPress={() => setExpandedSection(expandedSection === 'settings' ? null : 'settings')}
|
||||
>
|
||||
<View style={[styles.menuIconContainer, { backgroundColor: `${primaryColor}15` }]}>
|
||||
<Settings size={20} color={primaryColor} />
|
||||
</View>
|
||||
<Text style={[styles.menuTitle, { color: colors.text }]}>{t('profile.settings')}</Text>
|
||||
{expandedSection === 'settings' ? <ChevronDown size={20} color={colors.textMuted} /> : <ChevronRight size={20} color={colors.textMuted} />}
|
||||
</TouchableOpacity>
|
||||
|
||||
{expandedSection === 'settings' && (
|
||||
<Animated.View entering={FadeInDown} style={[styles.accordionContent, { borderTopColor: colors.divider }]}>
|
||||
|
||||
<Text style={[styles.settingGroupTitle, { color: colors.textMuted }]}>Idioma do Aplicativo</Text>
|
||||
<View style={styles.langRow}>
|
||||
<TouchableOpacity style={[styles.langBtn, language === 'pt' ? { backgroundColor: primaryColor } : { backgroundColor: colors.surfaceLight }]} onPress={() => setLanguage('pt')}>
|
||||
<Text style={[styles.langBtnText, language === 'pt' ? { color: colors.background } : { color: colors.text }]}>Português</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.langBtn, language === 'es' ? { backgroundColor: primaryColor } : { backgroundColor: colors.surfaceLight }]} onPress={() => setLanguage('es')}>
|
||||
<Text style={[styles.langBtnText, language === 'es' ? { color: colors.background } : { color: colors.text }]}>Español</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.actionRow} onPress={handleClearCache}>
|
||||
<RefreshCw size={20} color={colors.textMuted} />
|
||||
<Text style={[styles.actionRowText, { color: colors.text }]}>Sincronizar Dados / Limpar Cache</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.actionRow} onPress={() => { if(Platform.OS === 'web') window.alert('Termos de Uso e Privacidade'); else Alert.alert('Termos', '...'); }}>
|
||||
<FileText size={20} color={colors.textMuted} />
|
||||
<Text style={[styles.actionRowText, { color: colors.text }]}>Termos de Uso e Privacidade</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
</Animated.View>
|
||||
)}
|
||||
</Card>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* Logout */}
|
||||
<Animated.View entering={FadeInUp.delay(600)} style={styles.footer}>
|
||||
<TouchableOpacity style={styles.logoutBtn} onPress={handleLogout}>
|
||||
<LogOut size={20} color={COLORS.error} />
|
||||
<Text style={[styles.logoutText, { color: COLORS.error }]}>{t('profile.logoutConfirm')}</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
header: { alignItems: 'center', padding: SPACING.xl, paddingTop: SPACING.xxl },
|
||||
avatarContainer: { width: 104, height: 104, borderRadius: 52, borderWidth: 2, padding: 2, marginBottom: SPACING.lg },
|
||||
avatar: { width: '100%', height: '100%', borderRadius: 50 },
|
||||
editPhotoBadge: { position: 'absolute', bottom: 0, right: 0, width: 28, height: 28, borderRadius: 14, alignItems: 'center', justifyContent: 'center', borderWidth: 2, borderColor: COLORS.surface },
|
||||
userName: { ...TYPOGRAPHY.h2, marginBottom: 4 },
|
||||
userEmail: { ...TYPOGRAPHY.body, marginBottom: SPACING.xl },
|
||||
editButton: { width: 160, minHeight: 44, borderWidth: 2 },
|
||||
editForm: { width: '100%', maxWidth: 400, gap: SPACING.sm },
|
||||
input: { padding: SPACING.md, borderRadius: BORDER_RADIUS.md, borderWidth: 1, ...TYPOGRAPHY.body },
|
||||
editActions: { flexDirection: 'row', gap: SPACING.md, marginTop: SPACING.sm },
|
||||
statsRow: { flexDirection: 'row', paddingHorizontal: SPACING.xl, gap: SPACING.md, marginBottom: SPACING.xxl },
|
||||
statCard: { flex: 1, alignItems: 'center', padding: SPACING.lg },
|
||||
statNumber: { ...TYPOGRAPHY.h1, marginBottom: 4 },
|
||||
statLabel: { ...TYPOGRAPHY.caption },
|
||||
section: { paddingHorizontal: SPACING.xl, marginBottom: SPACING.xl },
|
||||
sectionTitle: { ...TYPOGRAPHY.h4, marginBottom: SPACING.md, fontWeight: '700' },
|
||||
cardItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: SPACING.md, marginBottom: SPACING.sm, borderRadius: BORDER_RADIUS.md },
|
||||
cardInfo: { flexDirection: 'row', alignItems: 'center' },
|
||||
cardBrand: { ...TYPOGRAPHY.body, fontWeight: 'bold' },
|
||||
cardNumber: { ...TYPOGRAPHY.caption, marginTop: 2 },
|
||||
addCardBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', padding: SPACING.md, borderRadius: BORDER_RADIUS.md, borderWidth: 1, borderStyle: 'dashed' },
|
||||
menuSection: { paddingHorizontal: SPACING.xl, gap: SPACING.sm },
|
||||
menuItem: { paddingVertical: SPACING.md, paddingHorizontal: SPACING.lg, borderRadius: BORDER_RADIUS.lg, overflow: 'hidden' },
|
||||
menuContent: { flexDirection: 'row', alignItems: 'center' },
|
||||
menuIconContainer: { width: 44, height: 44, borderRadius: BORDER_RADIUS.md, alignItems: 'center', justifyContent: 'center', marginRight: SPACING.md },
|
||||
menuTitle: { flex: 1, ...TYPOGRAPHY.bodyLarge, fontWeight: '600' },
|
||||
accordionContent: { marginTop: SPACING.md, paddingTop: SPACING.md, borderTopWidth: 1 },
|
||||
settingRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: SPACING.sm },
|
||||
settingLabel: { ...TYPOGRAPHY.body },
|
||||
settingGroupTitle: { ...TYPOGRAPHY.caption, marginBottom: SPACING.sm, marginTop: SPACING.xs },
|
||||
langRow: { flexDirection: 'row', gap: SPACING.sm, marginBottom: SPACING.lg },
|
||||
langBtn: { flex: 1, paddingVertical: SPACING.sm, alignItems: 'center', borderRadius: BORDER_RADIUS.sm },
|
||||
langBtnText: { ...TYPOGRAPHY.body, fontWeight: 'bold' },
|
||||
actionRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: SPACING.md, gap: SPACING.md },
|
||||
actionRowText: { ...TYPOGRAPHY.body },
|
||||
footer: { padding: SPACING.xl, alignItems: 'center', marginTop: SPACING.lg },
|
||||
logoutBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: SPACING.sm, paddingVertical: SPACING.md, paddingHorizontal: SPACING.xl, backgroundColor: 'rgba(239, 68, 68, 0.1)', borderRadius: BORDER_RADIUS.full, borderWidth: 1, borderColor: 'rgba(239, 68, 68, 0.3)' },
|
||||
logoutText: { ...TYPOGRAPHY.body, fontWeight: '700' },
|
||||
});
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Stack } from 'expo-router';
|
||||
import { ActivityIndicator, View, Text } from 'react-native';
|
||||
import { useBarbearia } from '../../stores/BarbeariaContext';
|
||||
import { COLORS } from '../../constants/theme';
|
||||
|
||||
export default function TenantLayout() {
|
||||
const { barbearia, isLoading, error } = useBarbearia();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: COLORS.background }}>
|
||||
<ActivityIndicator size="large" color="#D4AF37" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !barbearia) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: COLORS.background }}>
|
||||
<Text style={{ color: 'white', fontSize: 18 }}>{error || 'Barbearia não encontrada'}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: barbearia.colors.background },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Image } from 'react-native';
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../../constants/theme';
|
||||
import { useLanguage } from '../../stores/LanguageContext';
|
||||
import { useBarbearia } from '../../stores/BarbeariaContext';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Scissors, Languages } from 'lucide-react-native';
|
||||
|
||||
export default function TenantLanguageSelection() {
|
||||
const { setLanguage, t } = useLanguage();
|
||||
const { barbearia, isLoading } = useBarbearia();
|
||||
const { slug } = useLocalSearchParams();
|
||||
|
||||
const handleSelect = (lang: 'pt' | 'es') => {
|
||||
setLanguage(lang);
|
||||
router.push(`/${slug}/(auth)/login`);
|
||||
};
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
const colors = barbearia?.colors || COLORS;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.background }]}>
|
||||
<View style={styles.content}>
|
||||
|
||||
{/* Logo da Barbearia */}
|
||||
<Animated.View entering={FadeInDown.duration(800)} style={styles.logoContainer}>
|
||||
<View style={[styles.logoCircle, { backgroundColor: colors.surface, borderColor: colors.primary }]}>
|
||||
{barbearia?.logo ? (
|
||||
<Image source={{ uri: barbearia.logo }} style={styles.logoImage} />
|
||||
) : (
|
||||
<Scissors color={colors.primary} size={40} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.brandName, { color: colors.text }]}>
|
||||
{barbearia?.nome || "BarberFlow"}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.delay(200)} style={styles.header}>
|
||||
<Text style={[styles.title, { color: colors.text }]}>Selecione seu idioma</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.textMuted }]}>Como deseja continuar?</Text>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.options}>
|
||||
<Animated.View entering={FadeInUp.delay(400)}>
|
||||
<TouchableOpacity
|
||||
style={[styles.langCard, { backgroundColor: colors.surface, borderColor: colors.divider }]}
|
||||
onPress={() => handleSelect('pt')}
|
||||
>
|
||||
<Text style={styles.flag}>🇧🇷</Text>
|
||||
<View style={styles.langInfo}>
|
||||
<Text style={[styles.langName, { color: colors.text }]}>Português</Text>
|
||||
<Text style={[styles.langDesc, { color: colors.textMuted }]}>Preços em Real (R$)</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInUp.delay(500)}>
|
||||
<TouchableOpacity
|
||||
style={[styles.langCard, { backgroundColor: colors.surface, borderColor: colors.divider }]}
|
||||
onPress={() => handleSelect('es')}
|
||||
>
|
||||
<Text style={styles.flag}>🇪🇸</Text>
|
||||
<View style={styles.langInfo}>
|
||||
<Text style={[styles.langName, { color: colors.text }]}>Español</Text>
|
||||
<Text style={[styles.langDesc, { color: colors.textMuted }]}>Precios en Guaraníes (GS)</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
content: { flex: 1, padding: SPACING.xl, justifyContent: 'center', maxWidth: 500, width: '100%', alignSelf: 'center' },
|
||||
logoContainer: { alignItems: 'center', marginBottom: SPACING.xxxl },
|
||||
logoCircle: { width: 100, height: 100, borderRadius: 50, alignItems: 'center', justifyContent: 'center', marginBottom: SPACING.md, borderWidth: 2 },
|
||||
logoImage: { width: 100, height: 100, borderRadius: 50 },
|
||||
brandName: { ...TYPOGRAPHY.h2, letterSpacing: 1, fontWeight: '800' },
|
||||
header: { alignItems: 'center', marginBottom: SPACING.xxl },
|
||||
title: { ...TYPOGRAPHY.h3, marginBottom: 8 },
|
||||
subtitle: { ...TYPOGRAPHY.bodySmall },
|
||||
options: { gap: SPACING.md },
|
||||
langCard: { flexDirection: 'row', alignItems: 'center', padding: SPACING.xl, borderRadius: BORDER_RADIUS.xl, borderWidth: 1, ...(SHADOWS.medium as any) },
|
||||
flag: { fontSize: 32, marginRight: SPACING.lg },
|
||||
langInfo: { flex: 1 },
|
||||
langName: { ...TYPOGRAPHY.h4, fontWeight: '700' },
|
||||
langDesc: { ...TYPOGRAPHY.caption }
|
||||
});
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { COLORS } from '../constants/theme';
|
||||
import { LanguageProvider } from '../stores/LanguageContext';
|
||||
import { BarbeariaProvider } from '../stores/BarbeariaContext';
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<BarbeariaProvider>
|
||||
<LanguageProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: COLORS.background },
|
||||
animation: 'fade_from_bottom',
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="landing" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="admin" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="[slug]" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</GestureHandlerRootView>
|
||||
</LanguageProvider>
|
||||
</BarbeariaProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { Stack } from 'expo-router';
|
||||
import { COLORS } from '../../constants/theme';
|
||||
import { useLanguage } from '../../stores/LanguageContext';
|
||||
|
||||
export default function AdminLayout() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: COLORS.surface,
|
||||
},
|
||||
headerTintColor: COLORS.primary,
|
||||
headerTitleStyle: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
contentStyle: {
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="login"
|
||||
options={{
|
||||
title: t('admin.login'),
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="barber-login"
|
||||
options={{
|
||||
title: 'Área do Barbeiro',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="dashboard"
|
||||
options={{
|
||||
title: 'Dashboard',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="agenda"
|
||||
options={{
|
||||
title: t('admin.agenda.title'),
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="finance"
|
||||
options={{
|
||||
title: 'Financeiro',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="config"
|
||||
options={{
|
||||
title: t('profile.settings'),
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Alert, Platform } from 'react-native';
|
||||
import Animated, { FadeInUp, FadeInDown, SlideInDown, SlideOutDown } from 'react-native-reanimated';
|
||||
import { useBarbearia } from '../../stores/BarbeariaContext';
|
||||
import { useLanguage } from '../../stores/LanguageContext';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../../constants/theme';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { format, startOfToday, addDays } from 'date-fns';
|
||||
import { ptBR, es } from 'date-fns/locale';
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Lock, CheckCircle2, X } from 'lucide-react-native';
|
||||
|
||||
const ALL_TIMES = [
|
||||
'09:00', '09:30', '10:00', '10:30', '11:00', '11:30',
|
||||
'13:30', '14:00', '14:30', '15:00', '15:30', '16:00', '16:30', '17:00', '17:30', '18:00'
|
||||
];
|
||||
|
||||
interface SelectedSlot {
|
||||
barberId: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
export default function AdminAgenda() {
|
||||
const { barbearia, updateBlockedSlots, activeBarberId } = useBarbearia();
|
||||
const { t, language } = useLanguage();
|
||||
const [selectedDate, setSelectedDate] = useState(startOfToday());
|
||||
const [selectedSlots, setSelectedSlots] = useState<SelectedSlot[]>([]);
|
||||
|
||||
const isOwner = !activeBarberId;
|
||||
|
||||
const appointments = barbearia?.appointments || [];
|
||||
const barbers = barbearia?.barbers || [];
|
||||
const blockedSlots = barbearia?.blockedSlots || [];
|
||||
const themeColors = barbearia?.colors || COLORS;
|
||||
const primaryColor = themeColors.primary;
|
||||
|
||||
const dateFormatted = format(selectedDate, 'dd/MM/yyyy');
|
||||
const dateLocale = language === 'pt' ? ptBR : es;
|
||||
|
||||
const changeDate = (amount: number) => {
|
||||
setSelectedDate(prev => addDays(prev, amount));
|
||||
setSelectedSlots([]); // Clear selection when changing days
|
||||
};
|
||||
|
||||
const handleSlotPress = (barberId: string, time: string, hasAppointment: boolean) => {
|
||||
if (!isOwner && barberId !== activeBarberId) {
|
||||
if (Platform.OS === 'web') window.alert('Você só pode alterar a sua própria agenda.');
|
||||
else Alert.alert('Acesso Negado', 'Você só pode alterar a sua própria agenda.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasAppointment) {
|
||||
if (Platform.OS === 'web') window.alert('Horário ocupado por agendamento.');
|
||||
else Alert.alert('Aviso', 'Este horário já possui um agendamento.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedSlots(prev => {
|
||||
const exists = prev.find(s => s.barberId === barberId && s.time === time);
|
||||
if (exists) {
|
||||
return prev.filter(s => !(s.barberId === barberId && s.time === time));
|
||||
} else {
|
||||
return [...prev, { barberId, time }];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const applyAction = async (action: 'block' | 'unblock') => {
|
||||
if (selectedSlots.length === 0) return;
|
||||
|
||||
const slotsToUpdate = selectedSlots.map(s => ({
|
||||
barberId: s.barberId,
|
||||
time: s.time,
|
||||
date: dateFormatted
|
||||
}));
|
||||
|
||||
await updateBlockedSlots(slotsToUpdate, action);
|
||||
setSelectedSlots([]); // Limpa a seleção após aplicar
|
||||
};
|
||||
|
||||
const isSlotSelected = (barberId: string, time: string) => {
|
||||
return selectedSlots.some(s => s.barberId === barberId && s.time === time);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: themeColors.background }]}>
|
||||
<Animated.View entering={FadeInUp.duration(600)} style={[styles.header, { backgroundColor: themeColors.surface }]}>
|
||||
<Text style={[styles.title, { color: themeColors.text }]}>{t('admin.agenda.title')}</Text>
|
||||
<View style={[styles.dateSelector, { backgroundColor: `${primaryColor}10` }]}>
|
||||
<TouchableOpacity onPress={() => changeDate(-1)} style={styles.navBtn}>
|
||||
<ChevronLeft color={primaryColor} size={24} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.dateInfo}>
|
||||
<CalendarIcon size={18} color={primaryColor} />
|
||||
<Text style={[styles.dateText, { color: themeColors.text }]}>
|
||||
{format(selectedDate, "EEEE, dd 'de' MMMM", { locale: dateLocale })}
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => changeDate(1)} style={styles.navBtn}>
|
||||
<ChevronRight color={primaryColor} size={24} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
<View style={styles.tableContainer}>
|
||||
<View style={styles.tableHeader}>
|
||||
<View style={[styles.cell, styles.timeColumn, { backgroundColor: themeColors.surfaceLight }]}>
|
||||
<Text style={[styles.headerText, { color: primaryColor }]}>{t('admin.agenda.time')}</Text>
|
||||
</View>
|
||||
{barbers.map(barber => (
|
||||
<View key={barber.id} style={[styles.cell, styles.barberColumn, { backgroundColor: themeColors.surface }]}>
|
||||
<Text style={[styles.headerText, { color: primaryColor }]} numberOfLines={1}>{barber.nome}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.tableBody} contentContainerStyle={{ paddingBottom: 280 }} showsVerticalScrollIndicator={false}>
|
||||
{ALL_TIMES.map(time => (
|
||||
<View key={time} style={styles.tableRow}>
|
||||
<View style={[styles.cell, styles.timeColumn, { backgroundColor: themeColors.surfaceLight }]}>
|
||||
<Text style={[styles.timeText, { color: themeColors.text }]}>{time}</Text>
|
||||
</View>
|
||||
|
||||
{barbers.map(barber => {
|
||||
const appointment = appointments.find(a =>
|
||||
a.date === dateFormatted &&
|
||||
a.time === time &&
|
||||
a.barberId === barber.id &&
|
||||
a.status !== 'rejected'
|
||||
);
|
||||
|
||||
const isBlocked = blockedSlots.some(s => s.barberId === barber.id && s.date === dateFormatted && s.time === time);
|
||||
const isSelected = isSlotSelected(barber.id, time);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`${barber.id}-${time}`}
|
||||
onPress={() => handleSlotPress(barber.id, time, !!appointment)}
|
||||
activeOpacity={0.7}
|
||||
style={[
|
||||
styles.cell,
|
||||
styles.barberColumn,
|
||||
{ borderColor: themeColors.divider },
|
||||
appointment
|
||||
? (appointment.status === 'accepted' ? styles.busyCell : styles.pendingCell)
|
||||
: (isBlocked ? styles.blockedCell : styles.freeCell),
|
||||
isSelected && { borderColor: primaryColor, borderWidth: 2, backgroundColor: `${primaryColor}20` }
|
||||
]}
|
||||
>
|
||||
{isSelected && !appointment && (
|
||||
<View style={[styles.selectedOverlay, { backgroundColor: `${primaryColor}15` }]}>
|
||||
<CheckCircle2 size={24} color={primaryColor} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{appointment ? (
|
||||
<Text style={[styles.appointmentText, { color: themeColors.text }]} numberOfLines={1}>
|
||||
{appointment.clientName}
|
||||
</Text>
|
||||
) : isBlocked ? (
|
||||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 4}}>
|
||||
<Lock size={12} color={themeColors.textMuted} />
|
||||
<Text style={[styles.freeText, { color: themeColors.textMuted }]}>Bloqueado</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={[styles.freeText, { color: themeColors.textMuted }]}>{t('admin.agenda.free')}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* Action Bar (Multi-Select) */}
|
||||
{selectedSlots.length > 0 && (
|
||||
<Animated.View entering={SlideInDown} exiting={SlideOutDown} style={[styles.actionBar, { backgroundColor: themeColors.surface, borderTopColor: primaryColor }]}>
|
||||
<View style={styles.actionBarInfo}>
|
||||
<Text style={[styles.actionBarText, { color: themeColors.text }]}>
|
||||
{`${selectedSlots.length} horário${selectedSlots.length > 1 ? 's' : ''} selecionado${selectedSlots.length > 1 ? 's' : ''}`}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => setSelectedSlots([])} style={styles.clearBtn}>
|
||||
<X size={20} color={themeColors.textMuted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.actionButtons}>
|
||||
<Button
|
||||
title="Liberar"
|
||||
variant="outline"
|
||||
onPress={() => applyAction('unblock')}
|
||||
style={{ flex: 1, borderColor: primaryColor }}
|
||||
textStyle={{ color: primaryColor }}
|
||||
/>
|
||||
<Button
|
||||
title="Bloquear"
|
||||
onPress={() => applyAction('block')}
|
||||
style={{ flex: 1, backgroundColor: primaryColor }}
|
||||
textStyle={{ color: themeColors.background }}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Legenda (Oculta se houver seleção para dar espaço à Action Bar) */}
|
||||
{selectedSlots.length === 0 && (
|
||||
<Animated.View entering={FadeInDown} style={[styles.legend, { backgroundColor: themeColors.surface }]}>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.dot, { backgroundColor: COLORS.success }]} />
|
||||
<Text style={[styles.legendText, { color: themeColors.textMuted }]}>{t('admin.agenda.confirmed')}</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.dot, { backgroundColor: '#EAB308' }]} />
|
||||
<Text style={[styles.legendText, { color: themeColors.textMuted }]}>{t('admin.dashboard.pending_badge') || 'Pendente'}</Text>
|
||||
</View>
|
||||
<View style={styles.legendItem}>
|
||||
<View style={[styles.dot, { backgroundColor: COLORS.secondary }]} />
|
||||
<Text style={[styles.legendText, { color: themeColors.textMuted }]}>Bloqueado</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: COLORS.background },
|
||||
header: {
|
||||
padding: SPACING.xl,
|
||||
paddingTop: 60,
|
||||
backgroundColor: COLORS.surface,
|
||||
borderBottomLeftRadius: BORDER_RADIUS.xl,
|
||||
borderBottomRightRadius: BORDER_RADIUS.xl,
|
||||
...(SHADOWS.medium as any),
|
||||
zIndex: 10,
|
||||
},
|
||||
title: { ...TYPOGRAPHY.h2, color: COLORS.text, marginBottom: SPACING.lg },
|
||||
dateSelector: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', backgroundColor: `${COLORS.primary}10`, padding: SPACING.md, borderRadius: BORDER_RADIUS.md },
|
||||
dateInfo: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||
dateText: { color: COLORS.text, ...TYPOGRAPHY.bodySmall, fontWeight: '700', textTransform: 'capitalize' },
|
||||
navBtn: { padding: 5 },
|
||||
tableContainer: { padding: SPACING.lg },
|
||||
tableHeader: { flexDirection: 'row', marginBottom: SPACING.xs },
|
||||
tableRow: { flexDirection: 'row', marginBottom: SPACING.xs },
|
||||
cell: { padding: 12, justifyContent: 'center', alignItems: 'center', borderRadius: BORDER_RADIUS.sm, marginRight: SPACING.xs },
|
||||
timeColumn: { width: 70, backgroundColor: COLORS.surfaceLight },
|
||||
barberColumn: { width: 140, backgroundColor: COLORS.surface, position: 'relative', overflow: 'hidden' },
|
||||
headerText: { color: COLORS.primary, ...TYPOGRAPHY.caption, fontWeight: '800' },
|
||||
timeText: { color: COLORS.text, ...TYPOGRAPHY.bodySmall, fontWeight: '700' },
|
||||
freeCell: { backgroundColor: 'rgba(255,255,255,0.02)', borderStyle: 'dashed', borderWidth: 1, borderColor: COLORS.divider },
|
||||
blockedCell: { backgroundColor: 'rgba(255, 255, 255, 0.05)', borderWidth: 1, borderColor: COLORS.divider, borderStyle: 'dashed' },
|
||||
busyCell: { backgroundColor: `${COLORS.success}20`, borderWidth: 1, borderColor: `${COLORS.success}40` },
|
||||
pendingCell: { backgroundColor: 'rgba(234, 179, 8, 0.2)', borderWidth: 1, borderColor: 'rgba(234, 179, 8, 0.4)' },
|
||||
appointmentText: { color: COLORS.text, ...TYPOGRAPHY.caption, fontWeight: 'bold' },
|
||||
freeText: { color: COLORS.textMuted, fontSize: 10 },
|
||||
selectedOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center', alignItems: 'center', zIndex: 5 },
|
||||
tableBody: { height: 500 },
|
||||
actionBar: { position: 'absolute', bottom: 0, left: 0, right: 0, padding: SPACING.xl, paddingBottom: SPACING.xxl, borderTopWidth: 4, borderTopLeftRadius: BORDER_RADIUS.xl, borderTopRightRadius: BORDER_RADIUS.xl, ...(SHADOWS.large as any) },
|
||||
actionBarInfo: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: SPACING.lg },
|
||||
actionBarText: { ...TYPOGRAPHY.h4 },
|
||||
clearBtn: { padding: SPACING.sm, backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: BORDER_RADIUS.full },
|
||||
actionButtons: { flexDirection: 'row', gap: SPACING.md },
|
||||
legend: { flexDirection: 'row', padding: SPACING.xl, gap: 20, justifyContent: 'center', backgroundColor: COLORS.surface, borderTopLeftRadius: BORDER_RADIUS.xl, borderTopRightRadius: BORDER_RADIUS.xl },
|
||||
legendItem: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
dot: { width: 12, height: 12, borderRadius: 6 },
|
||||
legendText: { color: COLORS.textMuted, ...TYPOGRAPHY.caption, fontWeight: '600' }
|
||||
});
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Alert,
|
||||
TouchableOpacity,
|
||||
useWindowDimensions
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../../constants/theme';
|
||||
import { useLanguage } from '../../stores/LanguageContext';
|
||||
import { useBarbearia } from '../../stores/BarbeariaContext';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { Mail, Lock, User, ChevronLeft } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
export default function BarberLogin() {
|
||||
const { t } = useLanguage();
|
||||
const { barbearia, loginBarber } = useBarbearia();
|
||||
const { width } = useWindowDimensions();
|
||||
const isMobile = width < 768;
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const themeColors = barbearia?.colors || COLORS;
|
||||
const primaryColor = themeColors.primary;
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email || !password) {
|
||||
if (Platform.OS === 'web') {
|
||||
window.alert('Preencha seu e-mail e senha');
|
||||
} else {
|
||||
Alert.alert('Erro', 'Preencha seu e-mail e senha');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
// Busca o barbeiro na lista
|
||||
const foundBarber = barbearia?.barbers?.find(b => b.email === email && b.password === password);
|
||||
|
||||
if (foundBarber) {
|
||||
loginBarber(foundBarber.id);
|
||||
router.replace('/admin/dashboard');
|
||||
} else {
|
||||
if (Platform.OS === 'web') {
|
||||
window.alert('Credenciais inválidas');
|
||||
} else {
|
||||
Alert.alert('Erro', 'Credenciais inválidas');
|
||||
}
|
||||
}
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={[styles.container, { backgroundColor: themeColors.background }]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<ChevronLeft color={primaryColor} size={24} />
|
||||
<Text style={[styles.backText, { color: primaryColor }]}>{t('admin.config.back')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={[styles.content, isMobile && { paddingHorizontal: 16 }]}>
|
||||
<Animated.View entering={FadeInDown.duration(600)} style={styles.logoContainer}>
|
||||
<View style={[styles.logoCircle, { backgroundColor: `${primaryColor}15`, ...(SHADOWS.glow(primaryColor) as any) }]}>
|
||||
<User color={primaryColor} size={40} />
|
||||
</View>
|
||||
<Text style={[styles.brandName, { color: themeColors.text }]}>Área do Barbeiro</Text>
|
||||
<Text style={[styles.brandTagline, { color: themeColors.textMuted }]}>Gerencie sua agenda e horários</Text>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInUp.delay(200)}>
|
||||
<Card style={[styles.loginCard, { backgroundColor: themeColors.surface }]}>
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.label, { color: themeColors.text }]}>{t('admin.email')}</Text>
|
||||
<View style={[styles.inputContainer, { backgroundColor: themeColors.surfaceLight, borderColor: themeColors.divider }]}>
|
||||
<Mail size={20} color={themeColors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder="barbeiro@email.com"
|
||||
placeholderTextColor={themeColors.textMuted}
|
||||
style={[styles.input, { color: themeColors.text }]}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.label, { color: themeColors.text }]}>{t('admin.password')}</Text>
|
||||
<View style={[styles.inputContainer, { backgroundColor: themeColors.surfaceLight, borderColor: themeColors.divider }]}>
|
||||
<Lock size={20} color={themeColors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder="••••••••"
|
||||
placeholderTextColor={themeColors.textMuted}
|
||||
style={[styles.input, { color: themeColors.text }]}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title={t('admin.login')}
|
||||
onPress={handleLogin}
|
||||
isLoading={isLoading}
|
||||
style={[styles.loginButton, { backgroundColor: primaryColor }]}
|
||||
textStyle={{ color: themeColors.background }}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
backButton: { flexDirection: 'row', alignItems: 'center', padding: SPACING.lg, position: 'absolute', top: Platform.OS === 'web' ? 20 : 40, zIndex: 10 },
|
||||
backText: { ...TYPOGRAPHY.body, marginLeft: 4 },
|
||||
content: { flex: 1, padding: SPACING.xl, justifyContent: 'center', maxWidth: 500, width: '100%', alignSelf: 'center' },
|
||||
logoContainer: { alignItems: 'center', marginBottom: SPACING.xxl },
|
||||
logoCircle: { width: 80, height: 80, borderRadius: 40, alignItems: 'center', justifyContent: 'center', marginBottom: SPACING.md },
|
||||
brandName: { ...TYPOGRAPHY.h2, letterSpacing: 1 },
|
||||
brandTagline: { ...TYPOGRAPHY.body, marginTop: 4 },
|
||||
loginCard: { padding: SPACING.xl },
|
||||
form: { gap: SPACING.lg },
|
||||
inputGroup: { gap: SPACING.sm },
|
||||
label: { ...TYPOGRAPHY.bodySmall, fontWeight: '600' },
|
||||
inputContainer: { flexDirection: 'row', alignItems: 'center', borderRadius: BORDER_RADIUS.md, paddingHorizontal: SPACING.md, height: 56, borderWidth: 1 },
|
||||
inputIcon: { marginRight: SPACING.sm },
|
||||
input: { flex: 1, ...TYPOGRAPHY.body },
|
||||
loginButton: { marginTop: SPACING.md },
|
||||
});
|
||||
|
|
@ -0,0 +1,622 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
Linking,
|
||||
Platform,
|
||||
Image
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import Animated, { FadeIn, FadeOut, FadeInUp } from 'react-native-reanimated';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { useBarbearia } from '../../stores/BarbeariaContext';
|
||||
import { useLanguage } from '../../stores/LanguageContext';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../../constants/theme';
|
||||
import {
|
||||
Trash2,
|
||||
Pencil,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
CheckCircle2,
|
||||
Globe,
|
||||
Scissors,
|
||||
Layout,
|
||||
MapPin,
|
||||
Users,
|
||||
Camera,
|
||||
CreditCard,
|
||||
Wallet,
|
||||
Smartphone,
|
||||
Check
|
||||
} from 'lucide-react-native';
|
||||
|
||||
const DEFAULT_COLORS = {
|
||||
primary: '#EAB308',
|
||||
secondary: '#1A1A1A',
|
||||
accent: '#8B4513',
|
||||
background: '#0F0F0F',
|
||||
card: '#1E1E1E',
|
||||
text: '#FFFFFF',
|
||||
textMuted: '#A0A0A0',
|
||||
};
|
||||
|
||||
export default function ConfigPage() {
|
||||
const { barbearia, updateBarbearia, addService, updateService, removeService, addBarber, updateBarber, removeBarber, isLoading } = useBarbearia();
|
||||
const { t, language, formatPrice } = useLanguage();
|
||||
|
||||
// Use colors do context ou theme default
|
||||
const themeColors = barbearia?.colors || COLORS;
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
|
||||
// Form estados
|
||||
const [nome, setNome] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [logo, setLogo] = useState('');
|
||||
const [endereco, setEndereco] = useState('');
|
||||
const [cidade, setCidade] = useState('');
|
||||
const [numero, setNumero] = useState('');
|
||||
const [paymentMethods, setPaymentMethods] = useState<string[]>(['money', 'pix', 'card', 'alias']);
|
||||
const [primaryColor, setPrimaryColor] = useState(themeColors.primary);
|
||||
|
||||
// Novo/Editar serviço estado
|
||||
const [editingServiceId, setEditingServiceId] = useState<string | null>(null);
|
||||
const [newServiceNamePt, setNewServiceNamePt] = useState('');
|
||||
const [newServiceNameEs, setNewServiceNameEs] = useState('');
|
||||
const [newServicePricePt, setNewServicePricePt] = useState('');
|
||||
const [newServicePriceEs, setNewServicePriceEs] = useState('');
|
||||
const [newServiceDuration, setNewServiceDuration] = useState('');
|
||||
|
||||
// Novo/Editar barbeiro estado
|
||||
const [editingBarberId, setEditingBarberId] = useState<string | null>(null);
|
||||
const [newBarberName, setNewBarberName] = useState('');
|
||||
const [newBarberPhoto, setNewBarberPhoto] = useState('');
|
||||
const [newBarberCommission, setNewBarberCommission] = useState('50');
|
||||
const [newBarberEmail, setNewBarberEmail] = useState('');
|
||||
const [newBarberPassword, setNewBarberPassword] = useState('');
|
||||
const [canViewFinance, setCanViewFinance] = useState(false);
|
||||
const [canEditConfig, setCanEditConfig] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (barbearia) {
|
||||
setNome(barbearia.nome || '');
|
||||
setSlug(barbearia.slug || '');
|
||||
setLogo(barbearia.logo || '');
|
||||
setEndereco(barbearia.endereco || '');
|
||||
setCidade(barbearia.cidade || '');
|
||||
setNumero(barbearia.numero || '');
|
||||
if (barbearia.paymentMethods && barbearia.paymentMethods.length > 0) {
|
||||
setPaymentMethods(barbearia.paymentMethods);
|
||||
}
|
||||
if (barbearia.colors && barbearia.colors.primary) {
|
||||
setPrimaryColor(barbearia.colors.primary);
|
||||
}
|
||||
}
|
||||
}, [barbearia]);
|
||||
|
||||
const togglePaymentMethod = (method: string) => {
|
||||
setPaymentMethods(prev =>
|
||||
prev.includes(method)
|
||||
? prev.filter(m => m !== method)
|
||||
: [...prev, method]
|
||||
);
|
||||
};
|
||||
|
||||
const pickImage = async (setter: (uri: string) => void) => {
|
||||
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('Permissão necessária', 'Precisamos de acesso à sua galeria para escolher a foto.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.5,
|
||||
base64: true,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0].base64) {
|
||||
setter(`data:image/jpeg;base64,${result.assets[0].base64}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const colors = { ...DEFAULT_COLORS, primary: primaryColor };
|
||||
await updateBarbearia({ nome, slug, logo, endereco, cidade, numero, paymentMethods, colors });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const nextStep = async () => {
|
||||
await handleSave();
|
||||
setCurrentStep(prev => Math.min(prev + 1, 7));
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
setCurrentStep(prev => Math.max(prev - 1, 1));
|
||||
};
|
||||
|
||||
const handleSaveService = async () => {
|
||||
if (!newServiceNamePt || !newServiceNameEs || !newServicePricePt || !newServicePriceEs || !newServiceDuration) {
|
||||
if(Platform.OS === 'web') window.alert(t('admin.config.fill_all') || 'Preencha todos os campos');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const serviceData = {
|
||||
nomePt: newServiceNamePt,
|
||||
nomeEs: newServiceNameEs,
|
||||
precoPt: Number(newServicePricePt),
|
||||
precoEs: Number(newServicePriceEs),
|
||||
duracao: Number(newServiceDuration),
|
||||
};
|
||||
|
||||
if (editingServiceId) {
|
||||
await updateService(editingServiceId, serviceData);
|
||||
} else {
|
||||
await addService(serviceData);
|
||||
}
|
||||
|
||||
setEditingServiceId(null);
|
||||
setNewServiceNamePt('');
|
||||
setNewServiceNameEs('');
|
||||
setNewServicePricePt('');
|
||||
setNewServicePriceEs('');
|
||||
setNewServiceDuration('');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditService = (service: any) => {
|
||||
setEditingServiceId(service.id);
|
||||
setNewServiceNamePt(service.nomePt);
|
||||
setNewServiceNameEs(service.nomeEs);
|
||||
setNewServicePricePt(String(service.precoPt));
|
||||
setNewServicePriceEs(String(service.precoEs));
|
||||
setNewServiceDuration(String(service.duracao));
|
||||
};
|
||||
|
||||
const cancelEditService = () => {
|
||||
setEditingServiceId(null);
|
||||
setNewServiceNamePt('');
|
||||
setNewServiceNameEs('');
|
||||
setNewServicePricePt('');
|
||||
setNewServicePriceEs('');
|
||||
setNewServiceDuration('');
|
||||
};
|
||||
|
||||
const handleSaveBarber = async () => {
|
||||
if (!newBarberName || !newBarberCommission || !newBarberEmail || !newBarberPassword) {
|
||||
if(Platform.OS === 'web') window.alert('Preencha nome, e-mail, senha e comissão');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const barberData = {
|
||||
nome: newBarberName,
|
||||
foto: newBarberPhoto || 'https://images.unsplash.com/photo-1503443207922-dff7d543fd0e?w=400',
|
||||
commission: Number(newBarberCommission) || 0,
|
||||
email: newBarberEmail,
|
||||
password: newBarberPassword,
|
||||
permissions: {
|
||||
canViewFinance,
|
||||
canEditConfig,
|
||||
canEditAgenda: true
|
||||
}
|
||||
};
|
||||
|
||||
if (editingBarberId) {
|
||||
await updateBarber(editingBarberId, barberData);
|
||||
} else {
|
||||
await addBarber(barberData);
|
||||
}
|
||||
|
||||
setEditingBarberId(null);
|
||||
setNewBarberName('');
|
||||
setNewBarberPhoto('');
|
||||
setNewBarberCommission('50');
|
||||
setNewBarberEmail('');
|
||||
setNewBarberPassword('');
|
||||
setCanViewFinance(false);
|
||||
setCanEditConfig(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditBarber = (barber: any) => {
|
||||
setEditingBarberId(barber.id);
|
||||
setNewBarberName(barber.nome);
|
||||
setNewBarberPhoto(barber.foto);
|
||||
setNewBarberCommission(String(barber.commission || 50));
|
||||
setNewBarberEmail(barber.email || '');
|
||||
setNewBarberPassword(barber.password || '');
|
||||
setCanViewFinance(barber.permissions?.canViewFinance || false);
|
||||
setCanEditConfig(barber.permissions?.canEditConfig || false);
|
||||
};
|
||||
|
||||
const cancelEditBarber = () => {
|
||||
setEditingBarberId(null);
|
||||
setNewBarberName('');
|
||||
setNewBarberPhoto('');
|
||||
setNewBarberCommission('50');
|
||||
setNewBarberEmail('');
|
||||
setNewBarberPassword('');
|
||||
setCanViewFinance(false);
|
||||
setCanEditConfig(false);
|
||||
};
|
||||
|
||||
const handleViewApp = () => {
|
||||
const url = `${window.location.origin}/${slug}`;
|
||||
if (Platform.OS === 'web') {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
Linking.openURL(url);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <View style={[styles.center, { backgroundColor: themeColors.background }]}><Text style={{color: themeColors.text}}>Carregando...</Text></View>;
|
||||
|
||||
const renderStepIndicator = () => (
|
||||
<View style={styles.indicatorContainer}>
|
||||
{[1, 2, 3, 4, 5, 6, 7].map((step) => (
|
||||
<View
|
||||
key={step}
|
||||
style={[
|
||||
styles.indicator,
|
||||
{ backgroundColor: step <= currentStep ? primaryColor : themeColors.divider }
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: themeColors.background }]}>
|
||||
<Animated.View entering={FadeInUp} style={[styles.header, { backgroundColor: themeColors.surface }]}>
|
||||
<Text style={[styles.headerTitle, { color: themeColors.text }]}>{t('profile.settings')}</Text>
|
||||
{renderStepIndicator()}
|
||||
</Animated.View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<Text style={[styles.stepTitle, { color: primaryColor }]}>
|
||||
{currentStep === 1 ? t('admin.config.identity') :
|
||||
currentStep === 2 ? t('admin.config.location') :
|
||||
currentStep === 3 ? t('admin.config.services') :
|
||||
currentStep === 4 ? t('admin.config.barbers') :
|
||||
currentStep === 5 ? (t('admin.config.payments') || 'Formas de Recebimento') :
|
||||
currentStep === 6 ? (t('admin.config.colors') || 'Cores do App') :
|
||||
t('admin.config.ready')}
|
||||
</Text>
|
||||
|
||||
{currentStep === 1 && (
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||||
<Card style={[styles.stepCard, { backgroundColor: themeColors.surface }]}>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.label, { color: themeColors.textMuted }]}>Nome da Barbearia</Text>
|
||||
<TextInput style={[styles.input, { backgroundColor: themeColors.surfaceLight, color: themeColors.text, borderColor: themeColors.divider }]} value={nome} onChangeText={setNome} placeholder="Ex: Barber Shop VIP" placeholderTextColor={themeColors.textMuted} />
|
||||
</View>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.label, { color: themeColors.textMuted }]}>Slug da URL</Text>
|
||||
<TextInput style={[styles.input, { backgroundColor: themeColors.surfaceLight, color: themeColors.text, borderColor: themeColors.divider }]} value={slug} onChangeText={setSlug} placeholder="Ex: minha-barbearia" placeholderTextColor={themeColors.textMuted} />
|
||||
</View>
|
||||
|
||||
<Text style={[styles.label, { color: themeColors.textMuted }]}>Logo da Barbearia</Text>
|
||||
<TouchableOpacity style={[styles.imagePicker, { backgroundColor: themeColors.surfaceLight, borderColor: themeColors.divider }]} onPress={() => pickImage(setLogo)}>
|
||||
{logo ? (
|
||||
<Image source={{ uri: logo }} style={styles.pickedLogo} />
|
||||
) : (
|
||||
<View style={styles.imagePlaceholder}>
|
||||
<Camera color={themeColors.textMuted} size={32} />
|
||||
<Text style={[styles.imagePlaceholderText, { color: themeColors.textMuted }]}>Selecionar Logo</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||||
<Card style={[styles.stepCard, { backgroundColor: themeColors.surface }]}>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.label, { color: themeColors.textMuted }]}>Cidade</Text>
|
||||
<TextInput style={[styles.input, { backgroundColor: themeColors.surfaceLight, color: themeColors.text, borderColor: themeColors.divider }]} value={cidade} onChangeText={setCidade} placeholder="Ex: São Paulo" placeholderTextColor={themeColors.textMuted} />
|
||||
</View>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.label, { color: themeColors.textMuted }]}>Endereço</Text>
|
||||
<TextInput style={[styles.input, { backgroundColor: themeColors.surfaceLight, color: themeColors.text, borderColor: themeColors.divider }]} value={endereco} onChangeText={setEndereco} placeholder="Rua..." placeholderTextColor={themeColors.textMuted} />
|
||||
</View>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.label, { color: themeColors.textMuted }]}>Número</Text>
|
||||
<TextInput style={[styles.input, { backgroundColor: themeColors.surfaceLight, color: themeColors.text, borderColor: themeColors.divider }]} value={numero} onChangeText={setNumero} placeholder="123" placeholderTextColor={themeColors.textMuted} />
|
||||
</View>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||||
<Card style={[styles.stepCard, { backgroundColor: themeColors.surface }]}>
|
||||
{barbearia?.services.map((service) => (
|
||||
<View key={service.id} style={[styles.listItem, { borderBottomColor: themeColors.divider }]}>
|
||||
<View style={{flex:1}}>
|
||||
<Text style={[styles.itemName, { color: themeColors.text }]}>{language === 'pt' ? service.nomePt : service.nomeEs}</Text>
|
||||
<Text style={[styles.itemMeta, { color: themeColors.textMuted }]}>
|
||||
{`${formatPrice(service.precoPt, service.precoEs)} • ${service.duracao} min`}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
<TouchableOpacity onPress={() => handleEditService(service)} style={styles.actionIconBtn}>
|
||||
<Pencil color="#3B82F6" size={20} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => removeService(service.id)} style={styles.actionIconBtn}>
|
||||
<Trash2 color="#FF4444" size={20} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View style={[styles.addBox, { backgroundColor: `${primaryColor}10`, borderColor: primaryColor }]}>
|
||||
{editingServiceId && <Text style={[styles.label, {color: primaryColor, marginBottom: 10}]}>Editando Serviço...</Text>}
|
||||
<TextInput style={[styles.inputSmall, { backgroundColor: themeColors.surface, color: themeColors.text, borderColor: themeColors.divider }]} value={newServiceNamePt} onChangeText={setNewServiceNamePt} placeholder="Nome em Português" placeholderTextColor={themeColors.textMuted} />
|
||||
<TextInput style={[styles.inputSmall, { backgroundColor: themeColors.surface, color: themeColors.text, borderColor: themeColors.divider }]} value={newServiceNameEs} onChangeText={setNewServiceNameEs} placeholder="Nombre en Español" placeholderTextColor={themeColors.textMuted} />
|
||||
<View style={styles.row}>
|
||||
<TextInput style={[styles.inputSmall, { flex: 1, marginRight: 8, backgroundColor: themeColors.surface, color: themeColors.text, borderColor: themeColors.divider }]} value={newServicePricePt} onChangeText={setNewServicePricePt} placeholder="Preço (R$)" keyboardType="numeric" placeholderTextColor={themeColors.textMuted} />
|
||||
<TextInput style={[styles.inputSmall, { flex: 1, backgroundColor: themeColors.surface, color: themeColors.text, borderColor: themeColors.divider }]} value={newServicePriceEs} onChangeText={setNewServicePriceEs} placeholder="Preço (GS)" keyboardType="numeric" placeholderTextColor={themeColors.textMuted} />
|
||||
</View>
|
||||
<TextInput style={[styles.inputSmall, { backgroundColor: themeColors.surface, color: themeColors.text, borderColor: themeColors.divider }]} value={newServiceDuration} onChangeText={setNewServiceDuration} placeholder="Minutos de Duração" keyboardType="numeric" placeholderTextColor={themeColors.textMuted} />
|
||||
|
||||
<View style={{ flexDirection: 'row', gap: 10, marginTop: 8 }}>
|
||||
{editingServiceId && (
|
||||
<Button title="Cancelar" onPress={cancelEditService} variant="outline" style={{ flex: 1, borderColor: themeColors.textMuted }} textStyle={{ color: themeColors.textMuted }} />
|
||||
)}
|
||||
<Button title={editingServiceId ? "Salvar" : "Adicionar"} onPress={handleSaveService} variant="outline" style={{ flex: editingServiceId ? 1 : undefined, width: editingServiceId ? undefined : '100%', borderColor: primaryColor }} textStyle={{ color: primaryColor }} />
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{currentStep === 4 && (
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||||
<Card style={[styles.stepCard, { backgroundColor: themeColors.surface }]}>
|
||||
<View style={styles.list}>
|
||||
{barbearia?.barbers?.map((barber) => (
|
||||
<View key={barber.id} style={[styles.listItem, { borderBottomColor: themeColors.divider }]}>
|
||||
<Image source={{ uri: barber.foto }} style={styles.avatar} />
|
||||
<View style={{flex:1, marginLeft: 12}}>
|
||||
<Text style={[styles.itemName, { color: themeColors.text }]}>{barber.nome}</Text>
|
||||
<Text style={[styles.itemMeta, { color: themeColors.textMuted }]}>{`${barber.commission}% de Comissão`}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
<TouchableOpacity onPress={() => handleEditBarber(barber)} style={styles.actionIconBtn}>
|
||||
<Pencil color="#3B82F6" size={20} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={() => removeBarber(barber.id)} style={styles.actionIconBtn}>
|
||||
<Trash2 color="#FF4444" size={20} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={[styles.addBox, { backgroundColor: `${primaryColor}10`, borderColor: primaryColor }]}>
|
||||
{editingBarberId && <Text style={[styles.label, {color: primaryColor, marginBottom: 10}]}>Editando Barbeiro...</Text>}
|
||||
<TextInput style={[styles.inputSmall, { backgroundColor: themeColors.surface, color: themeColors.text, borderColor: themeColors.divider }]} value={newBarberName} onChangeText={setNewBarberName} placeholder="Nome do Barbeiro" placeholderTextColor={themeColors.textMuted} />
|
||||
<TextInput style={[styles.inputSmall, { backgroundColor: themeColors.surface, color: themeColors.text, borderColor: themeColors.divider }]} value={newBarberCommission} onChangeText={setNewBarberCommission} placeholder="% de Comissão (Ex: 50)" keyboardType="numeric" placeholderTextColor={themeColors.textMuted} />
|
||||
<TextInput style={[styles.inputSmall, { backgroundColor: themeColors.surface, color: themeColors.text, borderColor: themeColors.divider }]} value={newBarberEmail} onChangeText={setNewBarberEmail} placeholder="E-mail de Login" keyboardType="email-address" autoCapitalize="none" placeholderTextColor={themeColors.textMuted} />
|
||||
<TextInput style={[styles.inputSmall, { backgroundColor: themeColors.surface, color: themeColors.text, borderColor: themeColors.divider }]} value={newBarberPassword} onChangeText={setNewBarberPassword} placeholder="Senha de Acesso" secureTextEntry placeholderTextColor={themeColors.textMuted} />
|
||||
|
||||
<Text style={[styles.label, {marginTop: 10, color: themeColors.textMuted}]}>Permissões Extras (Além da Agenda):</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 10, marginBottom: 15, flexWrap: 'wrap' }}>
|
||||
<TouchableOpacity
|
||||
style={[styles.permissionBtn, canViewFinance && { backgroundColor: primaryColor, borderColor: primaryColor }]}
|
||||
onPress={() => setCanViewFinance(!canViewFinance)}
|
||||
>
|
||||
<Text style={[styles.permissionText, canViewFinance && { color: themeColors.background }]}>Ver Financeiro</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.permissionBtn, canEditConfig && { backgroundColor: primaryColor, borderColor: primaryColor }]}
|
||||
onPress={() => setCanEditConfig(!canEditConfig)}
|
||||
>
|
||||
<Text style={[styles.permissionText, canEditConfig && { color: themeColors.background }]}>Alterar Configurações</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.label, {marginTop: 10, color: themeColors.textMuted}]}>Foto do Barbeiro</Text>
|
||||
<TouchableOpacity style={[styles.imagePickerSmall, { backgroundColor: themeColors.surface, borderColor: themeColors.divider }]} onPress={() => pickImage(setNewBarberPhoto)}>
|
||||
{newBarberPhoto ? (
|
||||
<Image source={{ uri: newBarberPhoto }} style={styles.pickedAvatar} />
|
||||
) : (
|
||||
<View style={styles.imagePlaceholderSmall}>
|
||||
<Users color={themeColors.textMuted} size={24} />
|
||||
<Text style={[styles.imagePlaceholderTextSmall, { color: themeColors.textMuted }]}>Escolher Foto</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ flexDirection: 'row', gap: 10, marginTop: 15 }}>
|
||||
{editingBarberId && (
|
||||
<Button title="Cancelar" onPress={cancelEditBarber} variant="outline" style={{ flex: 1, borderColor: themeColors.textMuted }} textStyle={{ color: themeColors.textMuted }} />
|
||||
)}
|
||||
<Button title={editingBarberId ? "Salvar" : "Adicionar Barbeiro"} onPress={handleSaveBarber} variant="outline" style={{ flex: editingBarberId ? 1 : undefined, width: editingBarberId ? undefined : '100%', borderColor: primaryColor }} textStyle={{ color: primaryColor }} />
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{currentStep === 5 && (
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||||
<Card style={[styles.stepCard, { backgroundColor: themeColors.surface }]}>
|
||||
<Text style={[styles.cardInfo, { color: themeColors.textMuted }]}>{t('admin.config.payments_desc') || 'Selecione quais formas de pagamento sua barbearia aceita.'}</Text>
|
||||
|
||||
<View style={styles.paymentGrid}>
|
||||
{[
|
||||
{ id: 'pix', label: 'PIX (Brasil)', icon: <Smartphone size={24} color={primaryColor} /> },
|
||||
{ id: 'card', label: 'Cartão / Tarjeta', icon: <CreditCard size={24} color={primaryColor} /> },
|
||||
{ id: 'money', label: 'Dinheiro / Efectivo', icon: <Wallet size={24} color={primaryColor} /> },
|
||||
{ id: 'alias', label: 'Alias (Paraguay)', icon: <Smartphone size={24} color={primaryColor} /> },
|
||||
].map((method) => {
|
||||
const isSelected = paymentMethods.includes(method.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={method.id}
|
||||
style={[
|
||||
styles.paymentMethodCard,
|
||||
{ backgroundColor: themeColors.surfaceLight, borderColor: themeColors.divider },
|
||||
isSelected && { borderColor: primaryColor, backgroundColor: `${primaryColor}10` }
|
||||
]}
|
||||
onPress={() => togglePaymentMethod(method.id)}
|
||||
>
|
||||
<View style={styles.paymentMethodHeader}>
|
||||
{method.icon}
|
||||
{isSelected && <Check size={16} color={primaryColor} />}
|
||||
</View>
|
||||
<Text style={[styles.paymentMethodLabel, { color: themeColors.text }, isSelected && { color: primaryColor }]}>{method.label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{currentStep === 6 && (
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut}>
|
||||
<Card style={[styles.stepCard, { backgroundColor: themeColors.surface }]}>
|
||||
<Text style={[styles.cardInfo, { color: themeColors.textMuted }]}>{t('admin.config.colors_desc') || 'Selecione a cor principal da sua barbearia para personalizar o aplicativo.'}</Text>
|
||||
|
||||
<View style={styles.paymentGrid}>
|
||||
{[
|
||||
{ id: '#EAB308', label: 'Gold Premium' },
|
||||
{ id: '#3B82F6', label: 'Azul Moderno' },
|
||||
{ id: '#EF4444', label: 'Vermelho Forte' },
|
||||
{ id: '#22C55E', label: 'Verde Natural' },
|
||||
{ id: '#A855F7', label: 'Roxo Royal' },
|
||||
{ id: '#F97316', label: 'Laranja Vivo' },
|
||||
].map((colorOpt) => {
|
||||
const isSelected = primaryColor === colorOpt.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={colorOpt.id}
|
||||
style={[
|
||||
styles.paymentMethodCard,
|
||||
{ backgroundColor: themeColors.surfaceLight, borderColor: themeColors.divider },
|
||||
isSelected && { borderColor: colorOpt.id, backgroundColor: `${colorOpt.id}10` }
|
||||
]}
|
||||
onPress={() => setPrimaryColor(colorOpt.id)}
|
||||
>
|
||||
<View style={styles.paymentMethodHeader}>
|
||||
<View style={[styles.colorPreview, { backgroundColor: colorOpt.id }]} />
|
||||
{isSelected && <Check size={16} color={colorOpt.id} />}
|
||||
</View>
|
||||
<Text style={[styles.paymentMethodLabel, { color: themeColors.text }, isSelected && { color: colorOpt.id }]}>{colorOpt.label}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{currentStep === 7 && (
|
||||
<Animated.View entering={FadeIn} exiting={FadeOut} style={styles.finishContainer}>
|
||||
<CheckCircle2 color={primaryColor} size={100} />
|
||||
<Text style={[styles.finishTitle, { color: themeColors.text }]}>{t('admin.config.ready')}</Text>
|
||||
|
||||
<TouchableOpacity style={[styles.linkCard, { backgroundColor: themeColors.surface, borderColor: primaryColor }]} onPress={handleViewApp}>
|
||||
<Globe color={primaryColor} size={24} />
|
||||
<Text style={[styles.linkText, { color: primaryColor }]}>{`${window.location.origin}/${slug}`}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Button title="Dashboard" onPress={() => router.replace('/admin/dashboard')} style={{width: '100%', marginTop: 40, backgroundColor: primaryColor}} textStyle={{color: themeColors.background}} />
|
||||
</Animated.View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<View style={[styles.footerNav, { backgroundColor: themeColors.background, borderTopColor: themeColors.divider }]}>
|
||||
{currentStep > 1 && currentStep < 7 && (
|
||||
<Button title={t('admin.config.back')} variant="ghost" onPress={prevStep} style={{flex: 1}} textStyle={{color: themeColors.textMuted}} />
|
||||
)}
|
||||
{currentStep < 7 && (
|
||||
<Button title={t('admin.config.next')} onPress={nextStep} style={{flex: 2, backgroundColor: primaryColor}} textStyle={{color: themeColors.background}} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
|
||||
header: {
|
||||
padding: SPACING.xl,
|
||||
paddingTop: 60,
|
||||
borderBottomLeftRadius: BORDER_RADIUS.xl,
|
||||
borderBottomRightRadius: BORDER_RADIUS.xl,
|
||||
...(SHADOWS.medium as any),
|
||||
},
|
||||
headerTitle: { ...TYPOGRAPHY.h3, marginBottom: SPACING.md },
|
||||
indicatorContainer: { flexDirection: 'row', gap: 6 },
|
||||
indicator: { flex: 1, height: 4, borderRadius: 2 },
|
||||
content: { padding: SPACING.xl, paddingBottom: 120 },
|
||||
stepTitle: { ...TYPOGRAPHY.h2, marginBottom: SPACING.xl },
|
||||
stepCard: { padding: SPACING.lg },
|
||||
cardInfo: { ...TYPOGRAPHY.bodySmall, marginBottom: SPACING.lg },
|
||||
inputGroup: { marginBottom: SPACING.lg },
|
||||
label: { ...TYPOGRAPHY.caption, marginBottom: 8 },
|
||||
input: {
|
||||
borderRadius: BORDER_RADIUS.md,
|
||||
padding: SPACING.md,
|
||||
borderWidth: 1,
|
||||
...TYPOGRAPHY.body
|
||||
},
|
||||
inputSmall: {
|
||||
borderRadius: BORDER_RADIUS.sm,
|
||||
padding: 12,
|
||||
borderWidth: 1,
|
||||
marginBottom: 10,
|
||||
...TYPOGRAPHY.bodySmall
|
||||
},
|
||||
row: { flexDirection: 'row' },
|
||||
imagePicker: { width: '100%', height: 150, borderRadius: BORDER_RADIUS.lg, borderWidth: 1, borderStyle: 'dashed', justifyContent: 'center', alignItems: 'center', marginBottom: SPACING.lg, overflow: 'hidden' },
|
||||
pickedLogo: { width: '100%', height: '100%', resizeMode: 'contain' },
|
||||
imagePlaceholder: { alignItems: 'center', gap: 8 },
|
||||
imagePlaceholderText: { ...TYPOGRAPHY.caption },
|
||||
imagePickerSmall: { width: 100, height: 100, borderRadius: 50, borderWidth: 1, borderStyle: 'dashed', justifyContent: 'center', alignItems: 'center', overflow: 'hidden' },
|
||||
pickedAvatar: { width: '100%', height: '100%' },
|
||||
imagePlaceholderSmall: { alignItems: 'center', gap: 4 },
|
||||
imagePlaceholderTextSmall: { fontSize: 10, textAlign: 'center' },
|
||||
permissionBtn: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: BORDER_RADIUS.sm, borderWidth: 1, borderColor: COLORS.textMuted },
|
||||
permissionText: { ...TYPOGRAPHY.caption, color: COLORS.textMuted },
|
||||
addBox: { marginTop: 20, padding: 15, borderRadius: BORDER_RADIUS.lg, borderStyle: 'dashed', borderWidth: 1 },
|
||||
listItem: { flexDirection: 'row', alignItems: 'center', paddingVertical: 15, borderBottomWidth: 1 },
|
||||
itemName: { ...TYPOGRAPHY.body, fontWeight: '700' },
|
||||
itemMeta: { ...TYPOGRAPHY.caption, marginTop: 4 },
|
||||
actionIconBtn: { padding: 8, marginLeft: 4 },
|
||||
avatar: { width: 50, height: 50, borderRadius: 25 },
|
||||
list: { marginBottom: 10 },
|
||||
paymentGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: SPACING.md, justifyContent: 'space-between' },
|
||||
paymentMethodCard: { width: '47%', padding: SPACING.lg, borderRadius: BORDER_RADIUS.lg, borderWidth: 1 },
|
||||
paymentMethodHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: SPACING.md },
|
||||
paymentMethodLabel: { ...TYPOGRAPHY.bodySmall, fontWeight: '700', marginTop: SPACING.xs },
|
||||
colorPreview: { width: 24, height: 24, borderRadius: 12 },
|
||||
finishContainer: { alignItems: 'center', marginTop: 40 },
|
||||
finishTitle: { ...TYPOGRAPHY.h1, marginTop: 20 },
|
||||
linkCard: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 20, borderRadius: BORDER_RADIUS.xl, marginTop: 40, borderWidth: 1, ...(SHADOWS.medium as any) },
|
||||
linkText: { fontWeight: 'bold', ...TYPOGRAPHY.body },
|
||||
footerNav: { position: 'absolute', bottom: 0, left: 0, right: 0, padding: SPACING.xl, flexDirection: 'row', gap: SPACING.md, borderTopWidth: 1 }
|
||||
});
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Image, Platform } from 'react-native';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
import { useBarbearia, Appointment } from '../../stores/BarbeariaContext';
|
||||
import { useLanguage } from '../../stores/LanguageContext';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../../constants/theme';
|
||||
import { Check, X, Calendar, Clock, User, Scissors, Settings, DollarSign } from 'lucide-react-native';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const { barbearia, updateAppointmentStatus, activeBarberId, loginBarber } = useBarbearia();
|
||||
const { language, t, formatPrice } = useLanguage();
|
||||
const appointments = barbearia?.appointments || [];
|
||||
|
||||
// Use theme colors
|
||||
const themeColors = barbearia?.colors || COLORS;
|
||||
const primaryColor = themeColors.primary;
|
||||
|
||||
const activeBarber = barbearia?.barbers?.find(b => b.id === activeBarberId);
|
||||
const isOwner = !activeBarberId;
|
||||
const canViewFinance = isOwner || activeBarber?.permissions?.canViewFinance;
|
||||
const canEditConfig = isOwner || activeBarber?.permissions?.canEditConfig;
|
||||
|
||||
// Filtra agendamentos: o dono vê todos, o barbeiro vê apenas os dele
|
||||
const visibleAppointments = isOwner
|
||||
? appointments
|
||||
: appointments.filter(a => a.barberId === activeBarberId);
|
||||
|
||||
const pendingAppointments = visibleAppointments.filter(a => a.status === 'pending');
|
||||
const acceptedAppointments = visibleAppointments.filter(a => a.status === 'accepted');
|
||||
|
||||
const getServiceName = (id: string) => {
|
||||
const s = barbearia?.services.find(service => service.id === id);
|
||||
if (!s) return 'Serviço';
|
||||
return language === 'pt' ? s.nomePt : s.nomeEs;
|
||||
};
|
||||
const getBarberName = (id: string) => barbearia?.barbers.find(b => b.id === id)?.nome || 'Barbeiro';
|
||||
|
||||
const renderAppointment = (item: Appointment, index: number) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
animated
|
||||
delay={index * 100}
|
||||
style={[styles.appointmentCard, { backgroundColor: themeColors.surface }]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.userInfo}>
|
||||
<View style={[styles.avatarPlaceholder, { backgroundColor: `${primaryColor}20` }]}>
|
||||
<User color={primaryColor} size={18} />
|
||||
</View>
|
||||
<Text style={[styles.clientName, { color: themeColors.text }]} numberOfLines={1}>{item.clientName}</Text>
|
||||
</View>
|
||||
<View style={[styles.statusBadge, item.status === 'accepted' ? styles.statusAccepted : styles.statusPending]}>
|
||||
<Text style={[styles.statusText, item.status === 'accepted' ? { color: '#22C55E' } : { color: '#EAB308' }]}>
|
||||
{item.status === 'pending' ? t('admin.dashboard.pending_badge') || 'Pendente' : t('admin.dashboard.confirmed_badge') || 'Confirmado'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.details}>
|
||||
<View style={styles.detailItem}>
|
||||
<Calendar size={14} color={themeColors.textMuted} />
|
||||
<Text style={[styles.detailText, { color: themeColors.textMuted }]}>{item.date}</Text>
|
||||
</View>
|
||||
<View style={styles.detailItem}>
|
||||
<Clock size={14} color={themeColors.textMuted} />
|
||||
<Text style={[styles.detailText, { color: themeColors.textMuted }]}>{item.time}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.servicesList}>
|
||||
<Scissors size={14} color={primaryColor} />
|
||||
<Text style={[styles.servicesText, { color: primaryColor }]} numberOfLines={1}>
|
||||
{item.serviceIds.map(getServiceName).join(', ')} • {getBarberName(item.barberId)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.priceInfo}>
|
||||
<Text style={[styles.priceText, { color: themeColors.text }]}>
|
||||
Total: {formatPrice(item.totalPt, item.totalEs)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{item.status === 'pending' ? (
|
||||
<View style={[styles.actions, { borderTopColor: themeColors.divider }]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, styles.rejectBtn]}
|
||||
onPress={() => updateAppointmentStatus(item.id, 'rejected')}
|
||||
>
|
||||
<X color="#FF4444" size={18} />
|
||||
<Text style={styles.actionTextReject}>{t('admin.dashboard.reject')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, styles.acceptBtn]}
|
||||
onPress={() => updateAppointmentStatus(item.id, 'accepted')}
|
||||
>
|
||||
<Check color="#22C55E" size={18} />
|
||||
<Text style={styles.actionTextAccept}>{t('admin.dashboard.accept')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.actions, { borderTopColor: themeColors.divider }]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, styles.cancelBtn, { borderColor: themeColors.divider }]}
|
||||
onPress={() => {
|
||||
const confirmCancel = Platform.OS === 'web'
|
||||
? window.confirm(t('admin.dashboard.cancel_confirm') || 'Deseja realmente cancelar este agendamento?')
|
||||
: true;
|
||||
|
||||
if (confirmCancel) {
|
||||
updateAppointmentStatus(item.id, 'rejected');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X color={themeColors.textMuted} size={16} />
|
||||
<Text style={[styles.actionTextCancel, { color: themeColors.textMuted }]}>{t('admin.dashboard.cancel') || 'Cancelar'}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: themeColors.background }]}>
|
||||
<Animated.View entering={FadeInUp.duration(600)} style={[styles.header, { backgroundColor: themeColors.surface }]}>
|
||||
<View style={styles.headerTextContainer}>
|
||||
<Text style={[styles.title, { color: themeColors.text }]} numberOfLines={1}>{t('admin.dashboard.title', { name: barbearia?.nome || 'Barbeiro' })}</Text>
|
||||
<Text style={[styles.subtitle, { color: themeColors.textMuted }]} numberOfLines={1}>{t('admin.dashboard.pending', { count: pendingAppointments.length })}</Text>
|
||||
</View>
|
||||
<View style={styles.headerActions}>
|
||||
{canViewFinance && (
|
||||
<TouchableOpacity style={[styles.iconButton, { backgroundColor: `${primaryColor}15` }]} onPress={() => router.push('/admin/finance')}>
|
||||
<DollarSign color={primaryColor} size={20} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={[styles.iconButton, { backgroundColor: `${primaryColor}15` }]} onPress={() => router.push('/admin/agenda')}>
|
||||
<Calendar color={primaryColor} size={20} />
|
||||
</TouchableOpacity>
|
||||
{canEditConfig && (
|
||||
<TouchableOpacity style={[styles.iconButton, { backgroundColor: `${primaryColor}15` }]} onPress={() => router.push('/admin/config')}>
|
||||
<Settings color={primaryColor} size={20} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<TouchableOpacity style={[styles.iconButton, { backgroundColor: 'rgba(239, 68, 68, 0.1)' }]} onPress={() => {
|
||||
if (!isOwner) {
|
||||
loginBarber(null);
|
||||
}
|
||||
router.replace('/landing');
|
||||
}}>
|
||||
<X color="#EF4444" size={20} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
{pendingAppointments.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: primaryColor }]}>{t('admin.dashboard.waiting')}</Text>
|
||||
{pendingAppointments.map((item, i) => renderAppointment(item, i))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={[styles.sectionTitle, { color: primaryColor }]}>{t('admin.dashboard.upcoming')}</Text>
|
||||
{acceptedAppointments.length > 0 ? acceptedAppointments.map((item, i) => renderAppointment(item, i + pendingAppointments.length)) : (
|
||||
<Text style={[styles.emptyText, { color: themeColors.textMuted }]}>{t('admin.dashboard.empty')}</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
header: {
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.xl,
|
||||
paddingTop: Platform.OS === 'ios' ? 60 : 40,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
borderBottomLeftRadius: BORDER_RADIUS.lg,
|
||||
borderBottomRightRadius: BORDER_RADIUS.lg,
|
||||
...(SHADOWS.medium as any),
|
||||
zIndex: 10,
|
||||
},
|
||||
headerTextContainer: {
|
||||
flex: 1,
|
||||
marginRight: SPACING.md,
|
||||
},
|
||||
title: { ...TYPOGRAPHY.h3, marginBottom: 2 },
|
||||
subtitle: { ...TYPOGRAPHY.bodySmall },
|
||||
headerActions: { flexDirection: 'row', gap: SPACING.xs },
|
||||
iconButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
content: { padding: SPACING.md, paddingBottom: 100 },
|
||||
section: { marginBottom: SPACING.xl },
|
||||
sectionTitle: { ...TYPOGRAPHY.h4, marginBottom: SPACING.md },
|
||||
appointmentCard: { marginBottom: SPACING.sm, padding: SPACING.md },
|
||||
cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: SPACING.sm },
|
||||
userInfo: { flexDirection: 'row', alignItems: 'center', gap: SPACING.xs, flex: 1 },
|
||||
avatarPlaceholder: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
clientName: { ...TYPOGRAPHY.body, fontWeight: '700', flex: 1 },
|
||||
statusBadge: { paddingHorizontal: 8, paddingVertical: 4, borderRadius: BORDER_RADIUS.sm },
|
||||
statusText: { fontSize: 10, fontWeight: '800', textTransform: 'uppercase' },
|
||||
statusPending: { backgroundColor: 'rgba(234, 179, 8, 0.15)' },
|
||||
statusAccepted: { backgroundColor: 'rgba(34, 197, 94, 0.15)' },
|
||||
details: { flexDirection: 'row', gap: SPACING.md, marginBottom: 4 },
|
||||
detailItem: { flexDirection: 'row', alignItems: 'center', gap: 4 },
|
||||
detailText: { ...TYPOGRAPHY.caption, fontSize: 11 },
|
||||
servicesList: { flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: SPACING.md },
|
||||
servicesText: { ...TYPOGRAPHY.caption, fontWeight: '600', flex: 1 },
|
||||
priceInfo: { marginBottom: SPACING.sm },
|
||||
priceText: { ...TYPOGRAPHY.bodySmall, fontWeight: '700' },
|
||||
actions: { flexDirection: 'row', gap: SPACING.sm, borderTopWidth: 1, paddingTop: SPACING.sm },
|
||||
actionBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: 8, borderRadius: BORDER_RADIUS.md, borderWidth: 1 },
|
||||
actionTextReject: { color: '#FF4444', fontWeight: 'bold', fontSize: 12 },
|
||||
actionTextAccept: { color: '#22C55E', fontWeight: 'bold', fontSize: 12 },
|
||||
actionTextCancel: { fontWeight: '600', fontSize: 12 },
|
||||
rejectBtn: { borderColor: 'rgba(255, 68, 68, 0.3)', backgroundColor: 'rgba(255, 68, 68, 0.05)' },
|
||||
acceptBtn: { borderColor: 'rgba(34, 197, 94, 0.3)', backgroundColor: 'rgba(34, 197, 94, 0.05)' },
|
||||
cancelBtn: { borderStyle: 'dashed' },
|
||||
emptyText: { ...TYPOGRAPHY.bodySmall, textAlign: 'center', marginTop: SPACING.lg }
|
||||
});
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Platform } from 'react-native';
|
||||
import Animated, { FadeInUp, FadeInDown } from 'react-native-reanimated';
|
||||
import { useBarbearia } from '../../stores/BarbeariaContext';
|
||||
import { useLanguage } from '../../stores/LanguageContext';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../../constants/theme';
|
||||
import { ChevronLeft, DollarSign, Calendar, TrendingUp, Scissors } from 'lucide-react-native';
|
||||
import { router } from 'expo-router';
|
||||
|
||||
export default function AdminFinance() {
|
||||
const { barbearia, activeBarberId } = useBarbearia();
|
||||
const { language, formatPrice, t } = useLanguage();
|
||||
|
||||
const themeColors = barbearia?.colors || COLORS;
|
||||
const primaryColor = themeColors.primary;
|
||||
|
||||
const [filter, setFilter] = useState<'today' | 'week' | 'month'>('today');
|
||||
|
||||
const appointments = barbearia?.appointments || [];
|
||||
const acceptedAppointments = appointments.filter(a => a.status === 'accepted');
|
||||
|
||||
const isOwner = !activeBarberId;
|
||||
|
||||
// Simplificação para o escopo atual: consideramos todos os finalizados.
|
||||
// Numa implementação com banco de dados real, aqui faríamos um filtro pelas datas (hoje, semana, mês).
|
||||
// Se for dono, vê tudo. Se for barbeiro, vê apenas os dele.
|
||||
const filteredAppointments = isOwner
|
||||
? acceptedAppointments
|
||||
: acceptedAppointments.filter(a => a.barberId === activeBarberId);
|
||||
|
||||
const totalRevenuePt = filteredAppointments.reduce((acc, curr) => acc + curr.totalPt, 0);
|
||||
const totalRevenueEs = filteredAppointments.reduce((acc, curr) => acc + curr.totalEs, 0);
|
||||
const totalCortes = filteredAppointments.length;
|
||||
|
||||
const barbersToRender = isOwner
|
||||
? barbearia?.barbers || []
|
||||
: barbearia?.barbers.filter(b => b.id === activeBarberId) || [];
|
||||
|
||||
const barbersStats = barbersToRender.map(barber => {
|
||||
const barberAppointments = filteredAppointments.filter(a => a.barberId === barber.id);
|
||||
const revenuePt = barberAppointments.reduce((acc, curr) => acc + curr.totalPt, 0);
|
||||
const revenueEs = barberAppointments.reduce((acc, curr) => acc + curr.totalEs, 0);
|
||||
|
||||
// Calcula a comissão
|
||||
const commissionRate = barber.commission ? (barber.commission / 100) : 0.5; // fallback para 50%
|
||||
const commissionPt = revenuePt * commissionRate;
|
||||
const commissionEs = revenueEs * commissionRate;
|
||||
|
||||
return {
|
||||
...barber,
|
||||
totalCortes: barberAppointments.length,
|
||||
revenuePt,
|
||||
revenueEs,
|
||||
commissionPt,
|
||||
commissionEs,
|
||||
commissionRate: barber.commission || 50
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: themeColors.background }]}>
|
||||
<Animated.View entering={FadeInUp.duration(600)} style={[styles.header, { backgroundColor: themeColors.surface }]}>
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft color={primaryColor} size={28} />
|
||||
</TouchableOpacity>
|
||||
<View style={styles.headerTextContainer}>
|
||||
<Text style={[styles.title, { color: themeColors.text }]}>Financeiro & Comissões</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
|
||||
{/* Filtros de Tempo (Visual/Mock) */}
|
||||
<Animated.View entering={FadeInDown.delay(100)} style={styles.filterContainer}>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterBtn, filter === 'today' && { backgroundColor: primaryColor }]}
|
||||
onPress={() => setFilter('today')}
|
||||
>
|
||||
<Text style={[styles.filterText, filter === 'today' && { color: themeColors.background }]}>Hoje</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterBtn, filter === 'week' && { backgroundColor: primaryColor }]}
|
||||
onPress={() => setFilter('week')}
|
||||
>
|
||||
<Text style={[styles.filterText, filter === 'week' && { color: themeColors.background }]}>Semana</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.filterBtn, filter === 'month' && { backgroundColor: primaryColor }]}
|
||||
onPress={() => setFilter('month')}
|
||||
>
|
||||
<Text style={[styles.filterText, filter === 'month' && { color: themeColors.background }]}>Mês</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Resumo da Barbearia */}
|
||||
<Animated.View entering={FadeInDown.delay(200)}>
|
||||
<Text style={[styles.sectionTitle, { color: themeColors.text }]}>Resumo da Barbearia</Text>
|
||||
<View style={styles.statsGrid}>
|
||||
<Card style={[styles.statCard, { backgroundColor: themeColors.surface }]}>
|
||||
<View style={[styles.iconBox, { backgroundColor: `${primaryColor}20` }]}>
|
||||
<DollarSign size={24} color={primaryColor} />
|
||||
</View>
|
||||
<Text style={[styles.statLabel, { color: themeColors.textMuted }]}>Faturamento Bruto</Text>
|
||||
<Text style={[styles.statValue, { color: themeColors.text }]} numberOfLines={1}>
|
||||
{formatPrice(totalRevenuePt, totalRevenueEs)}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card style={[styles.statCard, { backgroundColor: themeColors.surface }]}>
|
||||
<View style={[styles.iconBox, { backgroundColor: `${primaryColor}20` }]}>
|
||||
<Scissors size={24} color={primaryColor} />
|
||||
</View>
|
||||
<Text style={[styles.statLabel, { color: themeColors.textMuted }]}>Cortes Realizados</Text>
|
||||
<Text style={[styles.statValue, { color: themeColors.text }]}>{totalCortes}</Text>
|
||||
</Card>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Relatório por Barbeiro */}
|
||||
<Animated.View entering={FadeInDown.delay(300)}>
|
||||
<Text style={[styles.sectionTitle, { color: themeColors.text, marginTop: SPACING.xl }]}>Comissões dos Barbeiros</Text>
|
||||
|
||||
{barbersStats.map((stat, index) => (
|
||||
<Card key={stat.id} style={[styles.barberCard, { backgroundColor: themeColors.surface, borderColor: themeColors.divider }]} animated delay={400 + (index * 100)}>
|
||||
<View style={styles.barberHeader}>
|
||||
<Text style={[styles.barberName, { color: primaryColor }]}>{stat.nome}</Text>
|
||||
<View style={[styles.commissionBadge, { backgroundColor: `${primaryColor}15` }]}>
|
||||
<Text style={[styles.commissionText, { color: primaryColor }]}>{stat.commissionRate}%</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.barberDataRow}>
|
||||
<View>
|
||||
<Text style={[styles.dataLabel, { color: themeColors.textMuted }]}>Cortes</Text>
|
||||
<Text style={[styles.dataValue, { color: themeColors.text }]}>{stat.totalCortes}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={[styles.dataLabel, { color: themeColors.textMuted }]}>Bruto Gerado</Text>
|
||||
<Text style={[styles.dataValue, { color: themeColors.text }]}>{formatPrice(stat.revenuePt, stat.revenueEs)}</Text>
|
||||
</View>
|
||||
<View style={styles.highlightData}>
|
||||
<Text style={[styles.dataLabel, { color: themeColors.textMuted }]}>Receber (Comissão)</Text>
|
||||
<Text style={[styles.dataValueHighlight, { color: '#22C55E' }]}>{formatPrice(stat.commissionPt, stat.commissionEs)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
))}
|
||||
</Animated.View>
|
||||
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1 },
|
||||
header: {
|
||||
paddingHorizontal: SPACING.lg,
|
||||
paddingVertical: SPACING.xl,
|
||||
paddingTop: Platform.OS === 'ios' ? 60 : 40,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderBottomLeftRadius: BORDER_RADIUS.lg,
|
||||
borderBottomRightRadius: BORDER_RADIUS.lg,
|
||||
...(SHADOWS.medium as any),
|
||||
zIndex: 10,
|
||||
},
|
||||
backButton: { marginRight: SPACING.md },
|
||||
headerTextContainer: { flex: 1 },
|
||||
title: { ...TYPOGRAPHY.h2 },
|
||||
content: { padding: SPACING.lg, paddingBottom: 100 },
|
||||
filterContainer: { flexDirection: 'row', gap: 10, marginBottom: SPACING.xl, backgroundColor: 'rgba(255,255,255,0.05)', padding: 6, borderRadius: BORDER_RADIUS.full },
|
||||
filterBtn: { flex: 1, paddingVertical: 10, alignItems: 'center', borderRadius: BORDER_RADIUS.full },
|
||||
filterText: { color: COLORS.textMuted, fontWeight: 'bold' },
|
||||
sectionTitle: { ...TYPOGRAPHY.h4, marginBottom: SPACING.md },
|
||||
statsGrid: { flexDirection: 'row', gap: SPACING.md },
|
||||
statCard: { flex: 1, padding: SPACING.lg, alignItems: 'center', justifyContent: 'center' },
|
||||
iconBox: { width: 48, height: 48, borderRadius: 24, alignItems: 'center', justifyContent: 'center', marginBottom: SPACING.md },
|
||||
statLabel: { ...TYPOGRAPHY.caption, marginBottom: 4 },
|
||||
statValue: { ...TYPOGRAPHY.h3 },
|
||||
barberCard: { padding: SPACING.lg, marginBottom: SPACING.md, borderWidth: 1 },
|
||||
barberHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: SPACING.lg },
|
||||
barberName: { ...TYPOGRAPHY.h4 },
|
||||
commissionBadge: { paddingHorizontal: 10, paddingVertical: 4, borderRadius: BORDER_RADIUS.sm },
|
||||
commissionText: { fontWeight: 'bold', fontSize: 12 },
|
||||
barberDataRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end' },
|
||||
dataLabel: { ...TYPOGRAPHY.caption, marginBottom: 4 },
|
||||
dataValue: { ...TYPOGRAPHY.body, fontWeight: '700' },
|
||||
highlightData: { alignItems: 'flex-end' },
|
||||
dataValueHighlight: { ...TYPOGRAPHY.h4, fontWeight: '800' }
|
||||
});
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Alert,
|
||||
TouchableOpacity
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../../constants/theme';
|
||||
import { useLanguage } from '../../stores/LanguageContext';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { Mail, Scissors, ChevronLeft } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export default function AdminForgotPassword() {
|
||||
const { t } = useLanguage();
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleRecover = async () => {
|
||||
if (!email) {
|
||||
if (Platform.OS === 'web') {
|
||||
window.alert(t('admin.config.fill_all') || 'Preencha o e-mail');
|
||||
} else {
|
||||
Alert.alert('Erro', t('admin.config.fill_all') || 'Preencha o e-mail');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
|
||||
// Mock recover
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
if (Platform.OS === 'web') {
|
||||
window.alert('Instruções enviadas para o seu e-mail.');
|
||||
} else {
|
||||
Alert.alert('Sucesso', 'Instruções enviadas para o seu e-mail.');
|
||||
}
|
||||
router.back();
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={{flex: 1}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<ChevronLeft color={COLORS.primary} size={24} />
|
||||
<Text style={styles.backText}>{t('admin.config.back') || 'Voltar'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Animated.View entering={FadeInDown.duration(600)} style={styles.header}>
|
||||
<View style={styles.logoCircle}>
|
||||
<Scissors color={COLORS.primary} size={32} />
|
||||
</View>
|
||||
<Text style={styles.title}>Recuperar Senha</Text>
|
||||
<Text style={styles.subtitle}>Digite seu e-mail para receber as instruções de recuperação de senha.</Text>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInUp.delay(200)}>
|
||||
<Card style={styles.formCard}>
|
||||
<View style={styles.form}>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>{t('admin.email') || 'E-mail'}</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<Mail size={20} color={COLORS.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder="seu@email.com"
|
||||
placeholderTextColor={COLORS.textMuted}
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title="Enviar Instruções"
|
||||
onPress={handleRecover}
|
||||
isLoading={isLoading}
|
||||
style={styles.submitButton}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: SPACING.lg,
|
||||
position: 'absolute',
|
||||
top: Platform.OS === 'web' ? 20 : 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
backText: {
|
||||
color: COLORS.primary,
|
||||
...TYPOGRAPHY.body,
|
||||
marginLeft: 4,
|
||||
fontWeight: '600'
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: SPACING.xl,
|
||||
justifyContent: 'center',
|
||||
maxWidth: 500,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.xxl,
|
||||
},
|
||||
logoCircle: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: `${COLORS.primary}15`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING.md,
|
||||
...(SHADOWS.glow(COLORS.primary) as any),
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.h1,
|
||||
color: COLORS.text,
|
||||
marginBottom: SPACING.xs,
|
||||
},
|
||||
subtitle: {
|
||||
...TYPOGRAPHY.bodySmall,
|
||||
color: COLORS.textMuted,
|
||||
textAlign: 'center',
|
||||
maxWidth: '80%',
|
||||
},
|
||||
formCard: {
|
||||
padding: SPACING.xl,
|
||||
},
|
||||
form: {
|
||||
gap: SPACING.lg,
|
||||
},
|
||||
inputGroup: {
|
||||
gap: SPACING.xs,
|
||||
},
|
||||
label: {
|
||||
...TYPOGRAPHY.bodySmall,
|
||||
color: COLORS.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: BORDER_RADIUS.md,
|
||||
paddingHorizontal: SPACING.md,
|
||||
height: 56,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.divider,
|
||||
backgroundColor: COLORS.surfaceLight,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: SPACING.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
color: COLORS.text,
|
||||
...TYPOGRAPHY.body,
|
||||
height: '100%',
|
||||
},
|
||||
submitButton: {
|
||||
marginTop: SPACING.md,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Alert,
|
||||
TouchableOpacity,
|
||||
Pressable,
|
||||
useWindowDimensions
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../../constants/theme';
|
||||
import { useLanguage } from '../../stores/LanguageContext';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { Mail, Lock, Scissors, ChevronLeft } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
|
||||
export default function AdminLogin() {
|
||||
const { t } = useLanguage();
|
||||
const { width } = useWindowDimensions();
|
||||
const isMobile = width < 768;
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email || !password) {
|
||||
if (Platform.OS === 'web') {
|
||||
window.alert(t('admin.login.error') || 'Preencha seu e-mail e senha');
|
||||
} else {
|
||||
Alert.alert('Erro', t('admin.login.error') || 'Preencha seu e-mail e senha');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
router.replace('/admin/dashboard');
|
||||
}, 1200);
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<ChevronLeft color={COLORS.primary} size={24} />
|
||||
<Text style={styles.backText}>{t('admin.config.back')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={[styles.content, isMobile && { paddingHorizontal: 16 }]}>
|
||||
<Animated.View entering={FadeInDown.duration(600)} style={styles.logoContainer}>
|
||||
<View style={styles.logoCircle}>
|
||||
<Scissors color={COLORS.primary} size={40} />
|
||||
</View>
|
||||
<Text style={styles.brandName}>BarberFlow</Text>
|
||||
<Text style={styles.brandTagline}>{t('admin.welcome')}</Text>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInUp.delay(200)}>
|
||||
<Card style={styles.loginCard}>
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>{t('admin.email')}</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<Mail size={20} color={COLORS.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder="seu@email.com"
|
||||
placeholderTextColor={COLORS.textMuted}
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>{t('admin.password')}</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<Lock size={20} color={COLORS.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder="••••••••"
|
||||
placeholderTextColor={COLORS.textMuted}
|
||||
style={styles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
router.push('/admin/forgot-password');
|
||||
}}
|
||||
style={styles.forgotPassword}
|
||||
>
|
||||
<Text style={styles.forgotPasswordText}>Esqueceu a senha?</Text>
|
||||
</Pressable>
|
||||
|
||||
<Button
|
||||
title={t('admin.login')}
|
||||
onPress={handleLogin}
|
||||
isLoading={isLoading}
|
||||
style={styles.loginButton}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInUp.delay(400)} style={styles.footer}>
|
||||
<Text style={styles.footerText}>{t('admin.noAccount')} </Text>
|
||||
<TouchableOpacity onPress={() => router.push('/admin/register')}>
|
||||
<Text style={styles.footerLink}>{t('admin.register')}</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View> </View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: SPACING.lg,
|
||||
position: 'absolute',
|
||||
top: Platform.OS === 'web' ? 20 : 40,
|
||||
zIndex: 10,
|
||||
},
|
||||
backText: {
|
||||
color: COLORS.primary,
|
||||
...TYPOGRAPHY.body,
|
||||
marginLeft: 4,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
padding: SPACING.xl,
|
||||
justifyContent: 'center',
|
||||
maxWidth: 500,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
logoContainer: {
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.xxl,
|
||||
},
|
||||
logoCircle: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: `${COLORS.primary}15`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING.md,
|
||||
...(SHADOWS.glow(COLORS.primary) as any),
|
||||
},
|
||||
brandName: {
|
||||
...TYPOGRAPHY.h1,
|
||||
color: COLORS.text,
|
||||
letterSpacing: 2,
|
||||
},
|
||||
brandTagline: {
|
||||
...TYPOGRAPHY.body,
|
||||
color: COLORS.textMuted,
|
||||
marginTop: 4,
|
||||
},
|
||||
loginCard: {
|
||||
padding: SPACING.xl,
|
||||
},
|
||||
form: {
|
||||
gap: SPACING.lg,
|
||||
},
|
||||
inputGroup: {
|
||||
gap: SPACING.sm,
|
||||
},
|
||||
label: {
|
||||
...TYPOGRAPHY.bodySmall,
|
||||
color: COLORS.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: BORDER_RADIUS.md,
|
||||
paddingHorizontal: SPACING.md,
|
||||
height: 56,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.divider,
|
||||
backgroundColor: COLORS.surfaceLight,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: SPACING.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
color: COLORS.text,
|
||||
...TYPOGRAPHY.body,
|
||||
},
|
||||
loginButton: {
|
||||
marginTop: SPACING.md,
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
marginTop: SPACING.xxl,
|
||||
},
|
||||
footerText: {
|
||||
...TYPOGRAPHY.bodySmall,
|
||||
color: COLORS.textMuted,
|
||||
},
|
||||
footerLink: {
|
||||
...TYPOGRAPHY.bodySmall,
|
||||
color: COLORS.primary,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Alert,
|
||||
TouchableOpacity,
|
||||
ScrollView
|
||||
} from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../../constants/theme';
|
||||
import { useLanguage } from '../../stores/LanguageContext';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { Card } from '../../components/ui/Card';
|
||||
import { Mail, Lock, Scissors, ChevronLeft, User, Phone, Store } from 'lucide-react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export default function AdminRegister() {
|
||||
const { t } = useLanguage();
|
||||
const [shopName, setShopName] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!shopName || !name || !email || !password) {
|
||||
if (Platform.OS === 'web') {
|
||||
window.alert(t('admin.config.fill_all') || 'Preencha todos os campos');
|
||||
} else {
|
||||
Alert.alert('Erro', t('admin.config.fill_all') || 'Preencha todos os campos');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
|
||||
// Mock register
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
if (Platform.OS === 'web') {
|
||||
window.alert('Cadastro realizado com sucesso! Faça login.');
|
||||
} else {
|
||||
Alert.alert('Sucesso', 'Cadastro realizado com sucesso! Faça login.');
|
||||
}
|
||||
router.replace('/admin/login');
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={{flex: 1}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<ChevronLeft color={COLORS.primary} size={24} />
|
||||
<Text style={styles.backText}>{t('admin.config.back') || 'Voltar'}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
|
||||
<Animated.View entering={FadeInDown.duration(600)} style={styles.header}>
|
||||
<View style={styles.logoCircle}>
|
||||
<Scissors color={COLORS.primary} size={32} />
|
||||
</View>
|
||||
<Text style={styles.title}>Criar Conta</Text>
|
||||
<Text style={styles.subtitle}>Junte-se ao BarberFlow Pro e digitalize sua barbearia.</Text>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInUp.delay(200)}>
|
||||
<Card style={styles.formCard}>
|
||||
<View style={styles.form}>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Nome da Barbearia</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<Store size={20} color={COLORS.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder="Sua Barbearia"
|
||||
placeholderTextColor={COLORS.textMuted}
|
||||
style={styles.input}
|
||||
value={shopName}
|
||||
onChangeText={setShopName}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Seu Nome</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<User size={20} color={COLORS.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder="João da Silva"
|
||||
placeholderTextColor={COLORS.textMuted}
|
||||
style={styles.input}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>{t('admin.email') || 'E-mail'}</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<Mail size={20} color={COLORS.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder="seu@email.com"
|
||||
placeholderTextColor={COLORS.textMuted}
|
||||
style={styles.input}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Telefone (WhatsApp)</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<Phone size={20} color={COLORS.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder="(00) 00000-0000"
|
||||
placeholderTextColor={COLORS.textMuted}
|
||||
style={styles.input}
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
keyboardType="phone-pad"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>{t('admin.password') || 'Senha'}</Text>
|
||||
<View style={styles.inputContainer}>
|
||||
<Lock size={20} color={COLORS.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
placeholder="••••••••"
|
||||
placeholderTextColor={COLORS.textMuted}
|
||||
style={styles.input}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title="Cadastrar e Continuar"
|
||||
onPress={handleRegister}
|
||||
isLoading={isLoading}
|
||||
style={styles.submitButton}
|
||||
/>
|
||||
</View>
|
||||
</Card>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: SPACING.lg,
|
||||
position: 'absolute',
|
||||
top: Platform.OS === 'web' ? 20 : 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
backText: {
|
||||
color: COLORS.primary,
|
||||
...TYPOGRAPHY.body,
|
||||
marginLeft: 4,
|
||||
fontWeight: '600'
|
||||
},
|
||||
content: {
|
||||
padding: SPACING.xl,
|
||||
paddingTop: Platform.OS === 'web' ? 80 : 60,
|
||||
paddingBottom: 60,
|
||||
justifyContent: 'center',
|
||||
maxWidth: 500,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: SPACING.xxl,
|
||||
},
|
||||
logoCircle: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: `${COLORS.primary}15`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING.md,
|
||||
...(SHADOWS.glow(COLORS.primary) as any),
|
||||
},
|
||||
title: {
|
||||
...TYPOGRAPHY.h1,
|
||||
color: COLORS.text,
|
||||
marginBottom: SPACING.xs,
|
||||
},
|
||||
subtitle: {
|
||||
...TYPOGRAPHY.bodySmall,
|
||||
color: COLORS.textMuted,
|
||||
textAlign: 'center',
|
||||
maxWidth: '80%',
|
||||
},
|
||||
formCard: {
|
||||
padding: SPACING.xl,
|
||||
},
|
||||
form: {
|
||||
gap: SPACING.lg,
|
||||
},
|
||||
inputGroup: {
|
||||
gap: SPACING.xs,
|
||||
},
|
||||
label: {
|
||||
...TYPOGRAPHY.bodySmall,
|
||||
color: COLORS.text,
|
||||
fontWeight: '600',
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: BORDER_RADIUS.md,
|
||||
paddingHorizontal: SPACING.md,
|
||||
height: 56,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.divider,
|
||||
backgroundColor: COLORS.surfaceLight,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: SPACING.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
color: COLORS.text,
|
||||
...TYPOGRAPHY.body,
|
||||
height: '100%',
|
||||
},
|
||||
submitButton: {
|
||||
marginTop: SPACING.md,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../constants/theme';
|
||||
import { useLanguage } from '../stores/LanguageContext';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Languages, Scissors } from 'lucide-react-native';
|
||||
|
||||
export default function RootLanguageSelection() {
|
||||
const { setLanguage } = useLanguage();
|
||||
|
||||
const handleSelect = (lang: 'pt' | 'es') => {
|
||||
setLanguage(lang);
|
||||
router.replace('/landing');
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.content}>
|
||||
<Animated.View entering={FadeInDown.duration(800)} style={styles.logoContainer}>
|
||||
<View style={styles.logoCircle}>
|
||||
<Scissors color={COLORS.primary} size={40} />
|
||||
</View>
|
||||
<Text style={styles.brandName}>BarberFlow</Text>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInDown.delay(200).duration(600)} style={styles.header}>
|
||||
<Text style={styles.title}>Selecione seu idioma</Text>
|
||||
<Text style={styles.subtitle}>Seleccione su idioma para continuar</Text>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.options}>
|
||||
<Animated.View entering={FadeInUp.delay(400)}>
|
||||
<TouchableOpacity
|
||||
style={styles.langCard}
|
||||
onPress={() => handleSelect('pt')}
|
||||
>
|
||||
<Text style={styles.flag}>🇧🇷</Text>
|
||||
<View style={styles.langInfo}>
|
||||
<Text style={styles.langName}>Português</Text>
|
||||
<Text style={styles.langDesc}>Bem-vindo ao sistema</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View entering={FadeInUp.delay(500)}>
|
||||
<TouchableOpacity
|
||||
style={styles.langCard}
|
||||
onPress={() => handleSelect('es')}
|
||||
>
|
||||
<Text style={styles.flag}>🇪🇸</Text>
|
||||
<View style={styles.langInfo}>
|
||||
<Text style={styles.langName}>Español</Text>
|
||||
<Text style={styles.langDesc}>Bienvenido al sistema</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: COLORS.background },
|
||||
content: { flex: 1, padding: SPACING.xl, justifyContent: 'center', maxWidth: 500, width: '100%', alignSelf: 'center' },
|
||||
logoContainer: { alignItems: 'center', marginBottom: SPACING.xxxl },
|
||||
logoCircle: { width: 80, height: 80, borderRadius: 40, backgroundColor: `${COLORS.primary}15`, alignItems: 'center', justifyContent: 'center', marginBottom: SPACING.md, borderWidth: 1, borderColor: COLORS.primary },
|
||||
brandName: { ...TYPOGRAPHY.h1, color: COLORS.text, letterSpacing: 2 },
|
||||
header: { alignItems: 'center', marginBottom: SPACING.xxl },
|
||||
title: { ...TYPOGRAPHY.h2, color: COLORS.text, marginBottom: 8 },
|
||||
subtitle: { ...TYPOGRAPHY.body, color: COLORS.textMuted },
|
||||
options: { gap: SPACING.md },
|
||||
langCard: { flexDirection: 'row', alignItems: 'center', padding: SPACING.xl, backgroundColor: COLORS.surface, borderRadius: BORDER_RADIUS.xl, borderWidth: 1, borderColor: COLORS.divider, ...(SHADOWS.medium as any) },
|
||||
flag: { fontSize: 32, marginRight: SPACING.lg },
|
||||
langInfo: { flex: 1 },
|
||||
langName: { ...TYPOGRAPHY.h3, color: COLORS.text },
|
||||
langDesc: { ...TYPOGRAPHY.caption, color: COLORS.textMuted }
|
||||
});
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, Platform, useWindowDimensions } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import Animated, { FadeInDown, FadeInUp } from 'react-native-reanimated';
|
||||
import { COLORS, SPACING, TYPOGRAPHY, BORDER_RADIUS, SHADOWS } from '../constants/theme';
|
||||
import { useLanguage } from '../stores/LanguageContext';
|
||||
import { Scissors, Calendar, Layout, Smartphone, MessageCircle, Palette, CreditCard, Lock } from 'lucide-react-native';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
export default function LandingPage() {
|
||||
const { t } = useLanguage();
|
||||
const { width } = useWindowDimensions();
|
||||
const isMobile = width < 768;
|
||||
|
||||
const handleViewDemo = () => {
|
||||
router.push('/vintage-barber');
|
||||
};
|
||||
|
||||
const handleAdmin = () => {
|
||||
router.push('/admin/login');
|
||||
};
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: <Layout color={COLORS.primary} size={28} />,
|
||||
title: t('landing.feature1.title'),
|
||||
desc: t('landing.feature1.desc')
|
||||
},
|
||||
{
|
||||
icon: <Palette color={COLORS.primary} size={28} />,
|
||||
title: t('landing.feature2.title'),
|
||||
desc: t('landing.feature2.desc')
|
||||
},
|
||||
{
|
||||
icon: <CreditCard color={COLORS.primary} size={28} />,
|
||||
title: t('landing.feature3.title'),
|
||||
desc: t('landing.feature3.desc')
|
||||
},
|
||||
{
|
||||
icon: <MessageCircle color={COLORS.primary} size={28} />,
|
||||
title: t('landing.feature4.title'),
|
||||
desc: t('landing.feature4.desc')
|
||||
},
|
||||
{
|
||||
icon: <Calendar color={COLORS.primary} size={28} />,
|
||||
title: t('landing.feature5.title'),
|
||||
desc: t('landing.feature5.desc')
|
||||
},
|
||||
{
|
||||
icon: <Smartphone color={COLORS.primary} size={28} />,
|
||||
title: t('landing.feature6.title'),
|
||||
desc: t('landing.feature6.desc')
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
|
||||
{/* Hero Section */}
|
||||
<Animated.View entering={FadeInDown.duration(800).springify()} style={[styles.hero, isMobile && styles.heroMobile]}>
|
||||
<View style={styles.heroLogo}>
|
||||
<Scissors color={COLORS.background} size={40} />
|
||||
</View>
|
||||
<Text style={[styles.heroTitle, isMobile && styles.heroTitleMobile]}>BarberFlow <Text style={styles.highlight}>Pro</Text></Text>
|
||||
<Text style={[styles.heroSubtitle, isMobile && styles.heroSubtitleMobile]}>
|
||||
{t('landing.subtitle')}
|
||||
</Text>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
<Button
|
||||
title={t('landing.demo') || "Ver Demonstração"}
|
||||
onPress={handleViewDemo}
|
||||
style={styles.ctaButton}
|
||||
textStyle={{ color: COLORS.background }}
|
||||
/>
|
||||
<Button
|
||||
title={t('landing.admin') || "Painel do Dono"}
|
||||
variant="outline"
|
||||
onPress={handleAdmin}
|
||||
style={styles.ctaButton}
|
||||
/>
|
||||
<Button
|
||||
title="Área do Barbeiro"
|
||||
variant="ghost"
|
||||
onPress={() => router.push('/admin/barber-login')}
|
||||
style={styles.ctaButton}
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Features */}
|
||||
<View style={styles.section}>
|
||||
<Animated.Text entering={FadeInUp.delay(300).duration(600)} style={[styles.sectionTitle, isMobile && styles.sectionTitleMobile]}>
|
||||
{t('landing.why') || "Tudo que sua barbearia precisa"}
|
||||
</Animated.Text>
|
||||
|
||||
<View style={[styles.grid, isMobile && styles.gridMobile]}>
|
||||
{features.map((item, index) => (
|
||||
<Card key={index} animated delay={400 + (index * 100)} style={[styles.featureCard, isMobile && styles.featureCardMobile]}>
|
||||
<View style={styles.iconBox}>
|
||||
{item.icon}
|
||||
</View>
|
||||
<Text style={[styles.featureTitle, isMobile && styles.textCenter]}>{item.title}</Text>
|
||||
<Text style={[styles.featureDescription, isMobile && styles.textCenter]}>{item.desc}</Text>
|
||||
</Card>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Footer */}
|
||||
<Animated.View entering={FadeInUp.delay(1000)} style={styles.footer}>
|
||||
<Text style={styles.footerText}>{t('landing.footer')}</Text>
|
||||
</Animated.View>
|
||||
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: COLORS.background,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
hero: {
|
||||
paddingHorizontal: SPACING.xl,
|
||||
paddingTop: 80,
|
||||
paddingBottom: SPACING.xxxl,
|
||||
alignItems: 'center',
|
||||
backgroundColor: COLORS.surface,
|
||||
borderBottomLeftRadius: BORDER_RADIUS.xl * 2,
|
||||
borderBottomRightRadius: BORDER_RADIUS.xl * 2,
|
||||
...(SHADOWS.large as any),
|
||||
},
|
||||
heroMobile: {
|
||||
paddingTop: SPACING.xxl,
|
||||
},
|
||||
heroLogo: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: COLORS.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING.lg,
|
||||
...(SHADOWS.glow(COLORS.primary) as any),
|
||||
},
|
||||
heroTitle: {
|
||||
...TYPOGRAPHY.h1,
|
||||
color: COLORS.text,
|
||||
marginBottom: SPACING.md,
|
||||
textAlign: 'center',
|
||||
fontSize: 48,
|
||||
},
|
||||
heroTitleMobile: {
|
||||
fontSize: 32,
|
||||
},
|
||||
highlight: {
|
||||
color: COLORS.primary,
|
||||
},
|
||||
heroSubtitle: {
|
||||
...TYPOGRAPHY.bodyLarge,
|
||||
color: COLORS.textMuted,
|
||||
textAlign: 'center',
|
||||
marginBottom: SPACING.xl,
|
||||
maxWidth: 600,
|
||||
},
|
||||
heroSubtitleMobile: {
|
||||
paddingHorizontal: SPACING.sm,
|
||||
},
|
||||
buttonContainer: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
gap: SPACING.md,
|
||||
paddingHorizontal: SPACING.md,
|
||||
},
|
||||
ctaButton: {
|
||||
width: '100%',
|
||||
},
|
||||
section: {
|
||||
padding: SPACING.lg,
|
||||
paddingHorizontal: 16, // px-4
|
||||
paddingTop: SPACING.xxxl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
sectionTitle: {
|
||||
...TYPOGRAPHY.h2,
|
||||
color: COLORS.text,
|
||||
marginBottom: SPACING.xxl,
|
||||
textAlign: 'center',
|
||||
fontSize: 32,
|
||||
},
|
||||
sectionTitleMobile: {
|
||||
fontSize: 24,
|
||||
},
|
||||
grid: {
|
||||
width: '100%',
|
||||
maxWidth: 1000,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
gap: SPACING.lg,
|
||||
},
|
||||
gridMobile: {
|
||||
flexDirection: 'column', // flex-col para mobile
|
||||
alignItems: 'center',
|
||||
},
|
||||
featureCard: {
|
||||
width: '30%',
|
||||
minWidth: 280,
|
||||
padding: SPACING.xl,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
featureCardMobile: {
|
||||
width: '100%', // w-full
|
||||
minWidth: '100%', // overwrite minWidth on mobile
|
||||
alignItems: 'center', // centralizar os itens (ícone, título, etc) no mobile
|
||||
},
|
||||
textCenter: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
iconBox: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: BORDER_RADIUS.md,
|
||||
backgroundColor: `${COLORS.primary}15`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: SPACING.lg,
|
||||
},
|
||||
featureTitle: {
|
||||
...TYPOGRAPHY.h3,
|
||||
color: COLORS.text,
|
||||
marginBottom: SPACING.sm,
|
||||
fontSize: 20,
|
||||
},
|
||||
featureDescription: {
|
||||
...TYPOGRAPHY.body,
|
||||
color: COLORS.textMuted,
|
||||
fontSize: 16,
|
||||
},
|
||||
footer: {
|
||||
padding: SPACING.xl,
|
||||
alignItems: 'center',
|
||||
marginTop: 'auto',
|
||||
},
|
||||
footerText: {
|
||||
...TYPOGRAPHY.caption,
|
||||
color: COLORS.textMuted,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 384 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -0,0 +1,125 @@
|
|||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, ActivityIndicator, ViewStyle, TextStyle } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withSpring, withTiming } from 'react-native-reanimated';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { COLORS, SPACING, BORDER_RADIUS, TYPOGRAPHY, SHADOWS } from '../../constants/theme';
|
||||
import { useBarbearia } from '../../stores/BarbeariaContext';
|
||||
|
||||
interface ButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: 'primary' | 'outline' | 'ghost';
|
||||
isLoading?: boolean;
|
||||
disabled?: boolean;
|
||||
style?: ViewStyle | ViewStyle[];
|
||||
textStyle?: TextStyle | TextStyle[];
|
||||
}
|
||||
|
||||
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
|
||||
|
||||
export function Button({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
isLoading = false,
|
||||
disabled = false,
|
||||
style,
|
||||
textStyle
|
||||
}: ButtonProps) {
|
||||
const { barbearia } = useBarbearia();
|
||||
const primaryColor = barbearia?.colors?.primary || COLORS.primary;
|
||||
const backgroundColor = barbearia?.colors?.background || COLORS.background;
|
||||
|
||||
const scale = useSharedValue(1);
|
||||
const opacity = useSharedValue(1);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ scale: scale.value }],
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
const handlePressIn = () => {
|
||||
if (disabled || isLoading) return;
|
||||
scale.value = withSpring(0.95, { damping: 15, stiffness: 200 });
|
||||
opacity.value = withTiming(0.8, { duration: 100 });
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
};
|
||||
|
||||
const handlePressOut = () => {
|
||||
if (disabled || isLoading) return;
|
||||
scale.value = withSpring(1, { damping: 15, stiffness: 200 });
|
||||
opacity.value = withTiming(1, { duration: 150 });
|
||||
};
|
||||
|
||||
const getVariantStyles = () => {
|
||||
switch (variant) {
|
||||
case 'outline':
|
||||
return [styles.outline, { borderColor: primaryColor }];
|
||||
case 'ghost':
|
||||
return styles.ghost;
|
||||
default:
|
||||
return [styles.primary, { backgroundColor: primaryColor }];
|
||||
}
|
||||
};
|
||||
|
||||
const getTextStyles = () => {
|
||||
switch (variant) {
|
||||
case 'outline':
|
||||
return { color: primaryColor };
|
||||
case 'ghost':
|
||||
return { color: COLORS.textMuted };
|
||||
default:
|
||||
return { color: backgroundColor };
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatedPressable
|
||||
onPress={onPress}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
disabled={disabled || isLoading}
|
||||
style={[
|
||||
styles.button,
|
||||
getVariantStyles(),
|
||||
(disabled || isLoading) && styles.disabled,
|
||||
animatedStyle,
|
||||
style
|
||||
]}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={variant === 'primary' ? backgroundColor : primaryColor} />
|
||||
) : (
|
||||
<Text style={[styles.text, getTextStyles(), textStyle]}>{title}</Text>
|
||||
)}
|
||||
</AnimatedPressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
paddingVertical: SPACING.md,
|
||||
paddingHorizontal: SPACING.lg,
|
||||
borderRadius: BORDER_RADIUS.full,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 56,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
primary: {
|
||||
...(SHADOWS.medium as any),
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
},
|
||||
ghost: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.4,
|
||||
},
|
||||
text: {
|
||||
...TYPOGRAPHY.button,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle } from 'react-native';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
import { COLORS, SPACING, BORDER_RADIUS, SHADOWS } from '../../constants/theme';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
style?: ViewStyle | ViewStyle[];
|
||||
variant?: 'elevated' | 'flat' | 'outline';
|
||||
animated?: boolean;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function Card({ children, style, variant = 'elevated', animated = false, delay = 0 }: CardProps) {
|
||||
const getVariantStyles = () => {
|
||||
switch (variant) {
|
||||
case 'flat':
|
||||
return styles.flat;
|
||||
case 'outline':
|
||||
return styles.outline;
|
||||
default:
|
||||
return styles.elevated;
|
||||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<View style={[styles.card, getVariantStyles(), style]}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (animated) {
|
||||
return (
|
||||
<Animated.View entering={FadeInUp.delay(delay).springify().damping(18)}>
|
||||
{content}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
padding: SPACING.lg,
|
||||
borderRadius: BORDER_RADIUS.xl,
|
||||
backgroundColor: COLORS.surface,
|
||||
},
|
||||
elevated: {
|
||||
...(SHADOWS.medium as any),
|
||||
},
|
||||
flat: {
|
||||
backgroundColor: COLORS.surfaceLight,
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.divider,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { Platform } from 'react-native';
|
||||
|
||||
export const COLORS = {
|
||||
background: '#0F0F13',
|
||||
surface: '#1C1C22',
|
||||
surfaceLight: '#282830',
|
||||
primary: '#EAB308', // Premium Gold
|
||||
secondary: '#888899',
|
||||
text: '#FAFAFA',
|
||||
textMuted: '#9CA3AF',
|
||||
error: '#EF4444',
|
||||
success: '#22C55E',
|
||||
accent: '#FACC15',
|
||||
divider: 'rgba(255, 255, 255, 0.08)',
|
||||
};
|
||||
|
||||
export const SPACING = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 16,
|
||||
lg: 24,
|
||||
xl: 32,
|
||||
xxl: 48,
|
||||
xxxl: 64,
|
||||
};
|
||||
|
||||
export const BORDER_RADIUS = {
|
||||
sm: 6,
|
||||
md: 12,
|
||||
lg: 20,
|
||||
xl: 30,
|
||||
full: 999,
|
||||
};
|
||||
|
||||
export const TYPOGRAPHY = {
|
||||
h1: { fontSize: 36, fontWeight: '800' as const, letterSpacing: -0.5 },
|
||||
h2: { fontSize: 28, fontWeight: '700' as const, letterSpacing: -0.5 },
|
||||
h3: { fontSize: 22, fontWeight: '600' as const, letterSpacing: -0.3 },
|
||||
h4: { fontSize: 18, fontWeight: '600' as const, letterSpacing: -0.2 },
|
||||
bodyLarge: { fontSize: 18, fontWeight: '400' as const },
|
||||
body: { fontSize: 16, fontWeight: '400' as const, lineHeight: 24 },
|
||||
bodySmall: { fontSize: 14, fontWeight: '400' as const, lineHeight: 20 },
|
||||
caption: { fontSize: 12, fontWeight: '500' as const, letterSpacing: 0.5, textTransform: 'uppercase' as const },
|
||||
button: { fontSize: 16, fontWeight: '700' as const, letterSpacing: 0.3 },
|
||||
};
|
||||
|
||||
export const SHADOWS = {
|
||||
small: Platform.select({
|
||||
ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.15, shadowRadius: 4 },
|
||||
android: { elevation: 3 },
|
||||
web: { boxShadow: '0 2px 4px rgba(0,0,0,0.15)' },
|
||||
}),
|
||||
medium: Platform.select({
|
||||
ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.25, shadowRadius: 10 },
|
||||
android: { elevation: 8 },
|
||||
web: { boxShadow: '0 6px 12px rgba(0,0,0,0.25)' },
|
||||
}),
|
||||
large: Platform.select({
|
||||
ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 12 }, shadowOpacity: 0.35, shadowRadius: 16 },
|
||||
android: { elevation: 12 },
|
||||
web: { boxShadow: '0 12px 24px rgba(0,0,0,0.35)' },
|
||||
}),
|
||||
glow: (color: string) => Platform.select({
|
||||
ios: { shadowColor: color, shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.4, shadowRadius: 12 },
|
||||
android: { elevation: 8 },
|
||||
web: { boxShadow: `0 0 16px ${color}66` },
|
||||
}),
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "barber-flow",
|
||||
"version": "1.0.0",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"build": "expo export -p web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~55.0.6",
|
||||
"@react-native-async-storage/async-storage": "^3.0.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~55.0.5",
|
||||
"expo-constants": "~55.0.7",
|
||||
"expo-font": "~55.0.4",
|
||||
"expo-haptics": "~55.0.8",
|
||||
"expo-image-picker": "~55.0.11",
|
||||
"expo-linking": "~55.0.7",
|
||||
"expo-router": "~55.0.4",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
"lucide-react-native": "^0.577.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-reanimated": "4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-web": "^0.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export interface Service {
|
||||
id: string;
|
||||
nomePt: string;
|
||||
nomeEs: string;
|
||||
precoPt: number;
|
||||
precoEs: number;
|
||||
duracao: number; // em minutos
|
||||
}
|
||||
|
||||
export interface Barber {
|
||||
id: string;
|
||||
nome: string;
|
||||
foto: string;
|
||||
commission: number;
|
||||
email?: string;
|
||||
password?: string;
|
||||
permissions?: {
|
||||
canViewFinance: boolean;
|
||||
canEditConfig: boolean;
|
||||
canEditAgenda: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Appointment {
|
||||
id: string;
|
||||
clientName: string;
|
||||
serviceIds: string[];
|
||||
barberId: string;
|
||||
date: string;
|
||||
time: string;
|
||||
status: 'pending' | 'accepted' | 'rejected';
|
||||
totalPt: number;
|
||||
totalEs: number;
|
||||
}
|
||||
|
||||
export interface BlockedSlot {
|
||||
id: string;
|
||||
barberId: string;
|
||||
date: string;
|
||||
time: string; // Pode ser 'all-day' ou um horário específico ex: '09:00'
|
||||
}
|
||||
|
||||
export interface BarbeariaData {
|
||||
id: string;
|
||||
nome: string;
|
||||
slug: string;
|
||||
logo: string;
|
||||
endereco: string;
|
||||
cidade: string;
|
||||
numero: string;
|
||||
services: Service[];
|
||||
barbers: Barber[];
|
||||
appointments: Appointment[];
|
||||
paymentMethods: string[];
|
||||
blockedSlots: BlockedSlot[];
|
||||
colors: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
card: string;
|
||||
text: string;
|
||||
textMuted: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface BarbeariaContextType {
|
||||
barbearia: BarbeariaData | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
activeBarberId: string | null;
|
||||
loginBarber: (id: string | null) => void;
|
||||
updateBarbearia: (data: Partial<BarbeariaData>) => Promise<void>;
|
||||
addService: (service: Omit<Service, 'id'>) => Promise<void>;
|
||||
updateService: (id: string, service: Omit<Service, 'id'>) => Promise<void>;
|
||||
removeService: (id: string) => Promise<void>;
|
||||
addBarber: (barber: Omit<Barber, 'id'>) => Promise<void>;
|
||||
updateBarber: (id: string, barber: Omit<Barber, 'id'>) => Promise<void>;
|
||||
removeBarber: (id: string) => Promise<void>;
|
||||
addAppointment: (appointment: Omit<Appointment, 'id' | 'status'>) => Promise<void>;
|
||||
updateAppointmentStatus: (id: string, status: 'accepted' | 'rejected') => Promise<void>;
|
||||
updateBlockedSlots: (slots: {barberId: string, date: string, time: string}[], action: 'block' | 'unblock') => Promise<void>;
|
||||
}
|
||||
|
||||
const BarbeariaContext = createContext<BarbeariaContextType | undefined>(undefined);
|
||||
|
||||
const STORAGE_KEY = '@barber_flow_barbearia_data';
|
||||
|
||||
const DEFAULT_COLORS = {
|
||||
primary: '#EAB308',
|
||||
secondary: '#1A1A1A',
|
||||
accent: '#8B4513',
|
||||
background: '#0F0F0F',
|
||||
card: '#1E1E1E',
|
||||
text: '#FFFFFF',
|
||||
textMuted: '#A0A0A0',
|
||||
};
|
||||
|
||||
export function BarbeariaProvider({ children }: { children: ReactNode }) {
|
||||
const { slug } = useLocalSearchParams<{ slug: string }>();
|
||||
const [barbearia, setBarbearia] = useState<BarbeariaData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeBarberId, setActiveBarberId] = useState<string | null>(null);
|
||||
|
||||
const loginBarber = (id: string | null) => {
|
||||
setActiveBarberId(id);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as BarbeariaData;
|
||||
if (!slug || parsed.slug === slug) {
|
||||
setBarbearia(parsed);
|
||||
} else if (slug === 'vintage-barber') {
|
||||
setBarbearia(parsed);
|
||||
} else {
|
||||
// Se o slug for diferente, podemos recriar um mock com esse slug para fins de demonstração
|
||||
const newMock = { ...parsed, slug: slug };
|
||||
setBarbearia(newMock);
|
||||
}
|
||||
} else {
|
||||
// MOCK INICIAL (Primeira vez que o app abre em um dispositivo novo)
|
||||
// Isso garante que o link funcione em qualquer celular mesmo sem banco de dados real
|
||||
const mock: BarbeariaData = {
|
||||
id: '1',
|
||||
nome: slug ? slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') : 'Barbearia Modelo',
|
||||
slug: slug || 'vintage-barber',
|
||||
logo: 'https://images.unsplash.com/photo-1503951914875-452162b0f3f1?q=80&w=200&h=200&auto=format&fit=crop',
|
||||
endereco: 'Rua das Flores, 123',
|
||||
cidade: 'São Paulo',
|
||||
numero: 'Sede',
|
||||
services: [
|
||||
{ id: '1', nomePt: 'Corte de Cabelo', nomeEs: 'Corte de Cabello', precoPt: 50, precoEs: 70000, duracao: 30 },
|
||||
{ id: '2', nomePt: 'Barba Completa', nomeEs: 'Barba Completa', precoPt: 35, precoEs: 50000, duracao: 20 },
|
||||
{ id: '3', nomePt: 'Combo (Corte + Barba)', nomeEs: 'Combo (Corte + Barba)', precoPt: 75, precoEs: 100000, duracao: 50 },
|
||||
],
|
||||
barbers: [
|
||||
{
|
||||
id: '1',
|
||||
nome: 'Marcus Silva',
|
||||
foto: 'https://images.unsplash.com/photo-1503443207922-dff7d543fd0e?w=400',
|
||||
commission: 50,
|
||||
email: 'marcus@barber.com',
|
||||
password: '123',
|
||||
permissions: { canViewFinance: false, canEditConfig: false, canEditAgenda: true }
|
||||
},
|
||||
],
|
||||
appointments: [],
|
||||
paymentMethods: ['money', 'pix', 'card', 'alias'],
|
||||
blockedSlots: [],
|
||||
colors: DEFAULT_COLORS,
|
||||
};
|
||||
setBarbearia(mock);
|
||||
// Salva no novo dispositivo para que ele também tenha uma base de dados local
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(mock));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro ao carregar dados:', err);
|
||||
setError('Erro ao carregar dados');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadData();
|
||||
}, [slug]);
|
||||
|
||||
const updateBarbearia = async (data: Partial<BarbeariaData>) => {
|
||||
const updated = barbearia
|
||||
? { ...barbearia, ...data }
|
||||
: {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
nome: '',
|
||||
slug: '',
|
||||
logo: '',
|
||||
endereco: '',
|
||||
cidade: '',
|
||||
numero: '',
|
||||
services: [],
|
||||
barbers: [],
|
||||
appointments: [],
|
||||
colors: DEFAULT_COLORS,
|
||||
...data
|
||||
} as BarbeariaData;
|
||||
|
||||
setBarbearia(updated);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
};
|
||||
|
||||
const addService = async (service: Omit<Service, 'id'>) => {
|
||||
const newService = { ...service, id: Math.random().toString(36).substr(2, 9) };
|
||||
if (!barbearia) return;
|
||||
const updated = {
|
||||
...barbearia,
|
||||
services: [...(barbearia.services || []), newService]
|
||||
} as BarbeariaData;
|
||||
setBarbearia(updated);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
};
|
||||
|
||||
const updateService = async (id: string, service: Omit<Service, 'id'>) => {
|
||||
if (!barbearia) return;
|
||||
const updated = {
|
||||
...barbearia,
|
||||
services: barbearia.services.map(s => s.id === id ? { ...service, id } : s)
|
||||
};
|
||||
setBarbearia(updated);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
};
|
||||
|
||||
const removeService = async (id: string) => {
|
||||
if (!barbearia) return;
|
||||
const updated = {
|
||||
...barbearia,
|
||||
services: barbearia.services.filter(s => s.id !== id)
|
||||
};
|
||||
setBarbearia(updated);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
};
|
||||
|
||||
const addBarber = async (barber: Omit<Barber, 'id'>) => {
|
||||
const newBarber = { ...barber, id: Math.random().toString(36).substr(2, 9) };
|
||||
if (!barbearia) return;
|
||||
const updated = {
|
||||
...barbearia,
|
||||
barbers: [...(barbearia.barbers || []), newBarber]
|
||||
};
|
||||
setBarbearia(updated);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
};
|
||||
|
||||
const updateBarber = async (id: string, barber: Omit<Barber, 'id'>) => {
|
||||
if (!barbearia) return;
|
||||
const updated = {
|
||||
...barbearia,
|
||||
barbers: barbearia.barbers.map(b => b.id === id ? { ...barber, id } : b)
|
||||
};
|
||||
setBarbearia(updated);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
};
|
||||
|
||||
const removeBarber = async (id: string) => {
|
||||
if (!barbearia) return;
|
||||
const updated = {
|
||||
...barbearia,
|
||||
barbers: barbearia.barbers.filter(b => b.id !== id)
|
||||
};
|
||||
setBarbearia(updated);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
};
|
||||
|
||||
const addAppointment = async (appointment: Omit<Appointment, 'id' | 'status'>) => {
|
||||
const newAppointment: Appointment = {
|
||||
...appointment,
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
status: 'pending'
|
||||
};
|
||||
if (!barbearia) return;
|
||||
const updated = {
|
||||
...barbearia,
|
||||
appointments: [...(barbearia.appointments || []), newAppointment]
|
||||
};
|
||||
setBarbearia(updated);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
};
|
||||
|
||||
const updateAppointmentStatus = async (id: string, status: 'accepted' | 'rejected') => {
|
||||
if (!barbearia) return;
|
||||
const updated = {
|
||||
...barbearia,
|
||||
appointments: barbearia.appointments.map(a => a.id === id ? { ...a, status } : a)
|
||||
};
|
||||
setBarbearia(updated);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
};
|
||||
|
||||
const updateBlockedSlots = async (slots: {barberId: string, date: string, time: string}[], action: 'block' | 'unblock') => {
|
||||
if (!barbearia) return;
|
||||
|
||||
let newBlockedSlots = [...(barbearia.blockedSlots || [])];
|
||||
|
||||
slots.forEach(slot => {
|
||||
const existingIndex = newBlockedSlots.findIndex(
|
||||
s => s.barberId === slot.barberId && s.date === slot.date && s.time === slot.time
|
||||
);
|
||||
|
||||
if (action === 'block' && existingIndex === -1) {
|
||||
newBlockedSlots.push({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
...slot
|
||||
});
|
||||
} else if (action === 'unblock' && existingIndex >= 0) {
|
||||
newBlockedSlots.splice(existingIndex, 1);
|
||||
}
|
||||
});
|
||||
|
||||
const updated = {
|
||||
...barbearia,
|
||||
blockedSlots: newBlockedSlots
|
||||
};
|
||||
setBarbearia(updated);
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
};
|
||||
|
||||
return (
|
||||
<BarbeariaContext.Provider value={{
|
||||
barbearia,
|
||||
isLoading,
|
||||
error,
|
||||
activeBarberId,
|
||||
loginBarber,
|
||||
updateBarbearia,
|
||||
addService,
|
||||
updateService,
|
||||
removeService,
|
||||
addBarber,
|
||||
updateBarber,
|
||||
removeBarber,
|
||||
addAppointment,
|
||||
updateAppointmentStatus,
|
||||
updateBlockedSlots
|
||||
}}>
|
||||
{children}
|
||||
</BarbeariaContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useBarbearia = () => {
|
||||
const context = useContext(BarbeariaContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useBarbearia deve ser usado dentro de um BarbeariaProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
import React, { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
type Language = 'pt' | 'es';
|
||||
|
||||
interface LanguageContextType {
|
||||
language: Language;
|
||||
setLanguage: (lang: Language) => void;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
currency: string;
|
||||
formatPrice: (ptPrice?: number, esPrice?: number) => string;
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||
|
||||
const TRANSLATIONS = {
|
||||
pt: {
|
||||
'login.title': 'BarberFlow',
|
||||
'login.tagline': 'Sua melhor versão começa aqui',
|
||||
'login.email': 'E-mail',
|
||||
'login.password': 'Senha',
|
||||
'login.forgot': 'Esqueceu a senha?',
|
||||
'login.submit': 'Entrar',
|
||||
'login.noAccount': 'Não tem uma conta?',
|
||||
'login.register': 'Cadastre-se',
|
||||
'lang.title': 'Idioma',
|
||||
'lang.subtitle': 'Como você gostaria de ser atendido?',
|
||||
'lang.continue': 'Continuar',
|
||||
'home.greeting': 'Olá, Douglas!',
|
||||
'home.subtitle': 'Onde vamos cortar hoje?',
|
||||
'home.services': 'Nossos Serviços',
|
||||
'home.barbers': 'Barbeiros',
|
||||
'home.view': 'Ver',
|
||||
'home.location': 'Foz do Iguaçu, BR',
|
||||
'home.bannerTitle': '30% OFF',
|
||||
'home.bannerSubtitle': 'Na sua primeira visita com o barbeiro João!',
|
||||
'home.avail': 'Aproveitar',
|
||||
'book.new': 'Novo Agendamento',
|
||||
'book.choose': 'Escolha o melhor momento para você',
|
||||
'book.services': 'Serviços & Barbeiro',
|
||||
'book.dateTime': 'Data & Horário',
|
||||
'book.payment': 'Pagamento',
|
||||
'book.confirm': 'Confirmar Agendamento',
|
||||
'book.finish': 'Finalizar',
|
||||
'book.back': 'Voltar',
|
||||
'book.next': 'Continuar',
|
||||
'book.morning': 'Manhã',
|
||||
'book.afternoon': 'Tarde',
|
||||
'book.total': 'Total Estimado',
|
||||
'book.combo': 'Combo Master Ativado! (Cabelo + Barba + Sobrancelha) - 10% OFF',
|
||||
'book.pix': 'PIX (Confirmação Imediata)',
|
||||
'book.card': 'Cartão de Débito/Crédito',
|
||||
'book.alias': 'Alias (Transferência)',
|
||||
'book.money': 'Dinheiro (Pagar no Local)',
|
||||
'book.pixCopy': 'Copia e Cola PIX',
|
||||
'book.transferCopy': 'Dados para Transferência',
|
||||
'book.moneyMsg': 'Seu horário ficará como "Aguardando aprovação" até que nossa equipe confirme manualmente.',
|
||||
'book.success': 'Horário Confirmado!',
|
||||
'book.waiting': 'Aguardando Aprovação',
|
||||
'book.receipt': 'Resumo da Reserva',
|
||||
'book.service': 'Serviço',
|
||||
'book.date': 'Data',
|
||||
'book.time': 'Horário',
|
||||
'book.notification': '* Enviaremos uma notificação 1h antes.',
|
||||
'profile.edit': 'Editar Perfil',
|
||||
'profile.cuts': 'Cortes',
|
||||
'profile.points': 'Pontos',
|
||||
'profile.personal': 'Dados Pessoais',
|
||||
'profile.payments': 'Pagamentos',
|
||||
'profile.notifications': 'Notificações',
|
||||
'profile.settings': 'Configurações',
|
||||
'profile.logout': 'Sair da Conta',
|
||||
'profile.logoutConfirm': 'Encerrar Sessão',
|
||||
'tab.home': 'Início',
|
||||
'tab.book': 'Agendar',
|
||||
'tab.profile': 'Perfil',
|
||||
'landing.title': 'BarberFlow SaaS',
|
||||
'landing.subtitle': 'Transforme sua barbearia com o sistema de agendamento mais completo e personalizado.',
|
||||
'landing.demo': 'Ver Demonstração',
|
||||
'landing.admin': 'Painel do Barbeiro',
|
||||
'landing.why': 'Por que escolher o BarberFlow?',
|
||||
'landing.feature1.title': 'Sistema Multi-Tenant',
|
||||
'landing.feature1.desc': 'Link exclusivo para sua barbearia (ex: app.com/sua-marca) sem concorrência.',
|
||||
'landing.feature2.title': 'Personalização de Cores',
|
||||
'landing.feature2.desc': 'Deixe o aplicativo do cliente com as cores e a logomarca exatas da sua empresa.',
|
||||
'landing.feature3.title': 'Moeda Dupla e Pagamentos',
|
||||
'landing.feature3.desc': 'Suporte automático para Real (R$) e Guarani (GS), além de PIX, Cartão e Alias.',
|
||||
'landing.feature4.title': 'Avisos Automáticos',
|
||||
'landing.feature4.desc': 'Notificações e lembretes para os clientes via WhatsApp, SMS e E-mail.',
|
||||
'landing.feature5.title': 'Agenda Inteligente',
|
||||
'landing.feature5.desc': 'Gestão completa com a possibilidade de bloquear e liberar horários em massa.',
|
||||
'landing.feature6.title': 'Experiência App Nativo',
|
||||
'landing.feature6.desc': 'Rápido, fluido e sem necessidade de baixar nada na loja de aplicativos.',
|
||||
'landing.footer': '© 2026 BarberFlow Pro. Sistema SaaS Multi-Tenant.',
|
||||
'admin.welcome': 'Bem-vindo ao Painel',
|
||||
'admin.login': 'Entrar no Painel',
|
||||
'admin.email': 'E-mail',
|
||||
'admin.password': 'Senha',
|
||||
'admin.noAccount': 'Ainda não tem conta?',
|
||||
'admin.register': 'Cadastre sua barbearia',
|
||||
'admin.dashboard.title': 'Olá, {name}',
|
||||
'admin.dashboard.pending': 'Você tem {count} agendamentos pendentes',
|
||||
'admin.dashboard.waiting': 'Aguardando Confirmação',
|
||||
'admin.dashboard.upcoming': 'Próximos Agendamentos',
|
||||
'admin.dashboard.empty': 'Nenhum agendamento confirmado ainda.',
|
||||
'admin.dashboard.accept': 'Aceitar',
|
||||
'admin.dashboard.reject': 'Recusar',
|
||||
'admin.dashboard.cancel': 'Cancelar Horário',
|
||||
'admin.dashboard.cancel_confirm': 'Deseja realmente cancelar este agendamento?',
|
||||
'admin.dashboard.pending_badge': 'Pendente',
|
||||
'admin.dashboard.confirmed_badge': 'Confirmado',
|
||||
'admin.agenda.title': 'Agenda do Dia',
|
||||
'admin.agenda.time': 'Hora',
|
||||
'admin.agenda.free': 'Livre',
|
||||
'admin.agenda.confirmed': 'Confirmado',
|
||||
'admin.config.identity': 'Identidade da sua Marca',
|
||||
'admin.config.location': 'Localização da Barbearia',
|
||||
'admin.config.services': 'Seus Serviços',
|
||||
'admin.config.barbers': 'Seus Barbeiros',
|
||||
'admin.config.payments': 'Formas de Recebimento',
|
||||
'admin.config.payments_desc': 'Selecione quais formas de pagamento sua barbearia aceita.',
|
||||
'admin.config.colors': 'Cores do App',
|
||||
'admin.config.colors_desc': 'Selecione a cor principal da sua barbearia para personalizar o aplicativo.',
|
||||
'admin.config.ready': 'Tudo Pronto!',
|
||||
'admin.config.save': 'Salvar Alterações',
|
||||
'admin.config.next': 'Próximo',
|
||||
'admin.config.back': 'Voltar',
|
||||
'admin.config.viewApp': 'Ver meu App',
|
||||
'admin.config.fill_all': 'Preencha todos os campos',
|
||||
},
|
||||
es: {
|
||||
'login.title': 'BarberFlow',
|
||||
'login.tagline': 'Tu mejor versión comienza aquí',
|
||||
'login.email': 'Correo electrónico',
|
||||
'login.password': 'Contraseña',
|
||||
'login.forgot': '¿Olvidaste tu contraseña?',
|
||||
'login.submit': 'Iniciar sesión',
|
||||
'login.noAccount': '¿No tienes una cuenta?',
|
||||
'login.register': 'Regístrate',
|
||||
'lang.title': 'Idioma',
|
||||
'lang.subtitle': '¿Cómo le gustaría ser atendido?',
|
||||
'lang.continue': 'Continuar',
|
||||
'home.greeting': '¡Hola, Douglas!',
|
||||
'home.subtitle': '¿Dónde vamos a cortar hoy?',
|
||||
'home.services': 'Nuestros Servicios',
|
||||
'home.barbers': 'Barberos',
|
||||
'home.view': 'Ver',
|
||||
'home.location': 'Ciudad del Este, PY',
|
||||
'home.bannerTitle': '30% DESC',
|
||||
'home.bannerSubtitle': '¡En tu primera visita con el barbero João!',
|
||||
'home.avail': 'Aprovechar',
|
||||
'book.new': 'Nueva Cita',
|
||||
'book.choose': 'Elige el mejor momento para ti',
|
||||
'book.services': 'Servicios y Barbero',
|
||||
'book.dateTime': 'Fecha y Horario',
|
||||
'book.payment': 'Pago',
|
||||
'book.confirm': 'Confirmar Cita',
|
||||
'book.finish': 'Finalizar',
|
||||
'book.back': 'Volver',
|
||||
'book.next': 'Continuar',
|
||||
'book.morning': 'Mañana',
|
||||
'book.afternoon': 'Tarde',
|
||||
'book.total': 'Total Estimado',
|
||||
'book.combo': '¡Combo Master Activado! (Cabello + Barba + Cejas) - 10% DESC',
|
||||
'book.pix': 'Transferencia Inmediata',
|
||||
'book.card': 'Tarjeta de Débito/Crédito',
|
||||
'book.alias': 'Alias (Transferencia)',
|
||||
'book.money': 'Efectivo (Pagar en el Local)',
|
||||
'book.pixCopy': 'Enlace de Transferencia',
|
||||
'book.transferCopy': 'Datos de Transferencia',
|
||||
'book.moneyMsg': 'Su cita quedará como "Esperando aprobación" hasta que nuestro equipo la confirme manualmente.',
|
||||
'book.success': '¡Cita Confirmada!',
|
||||
'book.waiting': 'Esperando Aprobación',
|
||||
'book.receipt': 'Resumen de la Reserva',
|
||||
'book.service': 'Servicio',
|
||||
'book.date': 'Fecha',
|
||||
'book.time': 'Horario',
|
||||
'book.notification': '* Enviaremos una notificación 1h antes.',
|
||||
'profile.edit': 'Editar Perfil',
|
||||
'profile.cuts': 'Cortes',
|
||||
'profile.points': 'Puntos',
|
||||
'profile.personal': 'Datos Personales',
|
||||
'profile.payments': 'Pagos',
|
||||
'profile.notifications': 'Notificaciones',
|
||||
'profile.settings': 'Configuración',
|
||||
'profile.logout': 'Cerrar Sesión',
|
||||
'profile.logoutConfirm': 'Cerrar Sesión',
|
||||
'tab.home': 'Inicio',
|
||||
'tab.book': 'Agendar',
|
||||
'tab.profile': 'Perfil',
|
||||
'landing.title': 'BarberFlow SaaS',
|
||||
'landing.subtitle': 'Transforma tu barbería con el sistema de citas más completo y personalizado.',
|
||||
'landing.demo': 'Ver Demostración',
|
||||
'landing.admin': 'Panel del Barbero',
|
||||
'landing.why': '¿Por qué elegir BarberFlow?',
|
||||
'landing.feature1.title': 'Sistema Multi-Tenant',
|
||||
'landing.feature1.desc': 'Enlace exclusivo para tu barbería (ej: app.com/tu-marca) sin competencia.',
|
||||
'landing.feature2.title': 'Personalización de Colores',
|
||||
'landing.feature2.desc': 'Deja la aplicación del cliente con los colores y el logotipo exactos de tu empresa.',
|
||||
'landing.feature3.title': 'Doble Moneda y Pagos',
|
||||
'landing.feature3.desc': 'Soporte automático para Real (R$) y Guaraní (GS), además de transferencias y tarjeta.',
|
||||
'landing.feature4.title': 'Avisos Automáticos',
|
||||
'landing.feature4.desc': 'Notificaciones y recordatorios para los clientes vía WhatsApp, SMS y Email.',
|
||||
'landing.feature5.title': 'Agenda Inteligente',
|
||||
'landing.feature5.desc': 'Gestión completa con la posibilidad de bloquear y liberar horarios de forma masiva.',
|
||||
'landing.feature6.title': 'Experiencia App Nativa',
|
||||
'landing.feature6.desc': 'Rápida, fluida y sin necesidad de descargar nada en la tienda de aplicaciones.',
|
||||
'landing.footer': '© 2026 BarberFlow Pro. Sistema SaaS Multi-Tenant.',
|
||||
'admin.welcome': 'Bienvenido al Panel',
|
||||
'admin.login': 'Entrar al Panel',
|
||||
'admin.email': 'Correo electrónico',
|
||||
'admin.password': 'Contraseña',
|
||||
'admin.noAccount': '¿Aún no tienes cuenta?',
|
||||
'admin.register': 'Registra tu barbería',
|
||||
'admin.dashboard.title': 'Hola, {name}',
|
||||
'admin.dashboard.pending': 'Tienes {count} citas pendientes',
|
||||
'admin.dashboard.waiting': 'Esperando Confirmación',
|
||||
'admin.dashboard.upcoming': 'Próximas Citas',
|
||||
'admin.dashboard.empty': 'Ninguna cita confirmada aún.',
|
||||
'admin.dashboard.accept': 'Aceptar',
|
||||
'admin.dashboard.reject': 'Rechazar',
|
||||
'admin.dashboard.cancel': 'Cancelar Cita',
|
||||
'admin.dashboard.cancel_confirm': '¿Realmente desea cancelar esta cita?',
|
||||
'admin.dashboard.pending_badge': 'Pendiente',
|
||||
'admin.dashboard.confirmed_badge': 'Confirmado',
|
||||
'admin.agenda.title': 'Agenda del Día',
|
||||
'admin.agenda.time': 'Hora',
|
||||
'admin.agenda.free': 'Libre',
|
||||
'admin.agenda.confirmed': 'Confirmado',
|
||||
'admin.config.identity': 'Identidad de tu Marca',
|
||||
'admin.config.location': 'Ubicación de la Barbería',
|
||||
'admin.config.services': 'Tus Servicios',
|
||||
'admin.config.barbers': 'Tus Barberos',
|
||||
'admin.config.payments': 'Métodos de Pago',
|
||||
'admin.config.payments_desc': 'Selecciona qué formas de pago acepta tu barbería.',
|
||||
'admin.config.colors': 'Colores de la App',
|
||||
'admin.config.colors_desc': 'Selecciona el color principal de tu barbería para personalizar la aplicación.',
|
||||
'admin.config.ready': '¡Todo Listo!',
|
||||
'admin.config.save': 'Guardar Cambios',
|
||||
'admin.config.next': 'Siguiente',
|
||||
'admin.config.back': 'Volver',
|
||||
'admin.config.viewApp': 'Ver mi App',
|
||||
'admin.config.fill_all': 'Llena todos los campos',
|
||||
}
|
||||
};
|
||||
|
||||
export function LanguageProvider({ children }: { children: ReactNode }) {
|
||||
const [language, setLanguage] = useState<Language>('pt');
|
||||
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
let translation = TRANSLATIONS[language][key as keyof typeof TRANSLATIONS['pt']] || key;
|
||||
|
||||
if (params && typeof translation === 'string') {
|
||||
Object.keys(params).forEach(param => {
|
||||
const value = params[param] !== undefined ? String(params[param]) : '';
|
||||
translation = translation.replace(`{${param}}`, value);
|
||||
});
|
||||
}
|
||||
|
||||
return translation;
|
||||
};
|
||||
|
||||
const currency = language === 'pt' ? 'R$' : 'GS';
|
||||
|
||||
const formatPrice = (ptPrice: number = 0, esPrice: number = 0) => {
|
||||
const pt = ptPrice || 0;
|
||||
const es = esPrice || 0;
|
||||
|
||||
if (language === 'pt') {
|
||||
return `R$ ${pt.toFixed(2).replace('.', ',')}`;
|
||||
} else {
|
||||
// Formata guaraníes com separador de milhar (ponto)
|
||||
return `${Math.floor(es).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".")} GS`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ language, setLanguage, t, currency, formatPrice }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useLanguage = () => {
|
||||
const context = useContext(LanguageContext);
|
||||
if (!context) throw new Error('useLanguage must be used within LanguageProvider');
|
||||
return context;
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue