commit inicial

This commit is contained in:
Douglas Gonçalves 2026-03-12 17:09:56 -03:00
commit b273890441
50 changed files with 14786 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 que não temos o banco real ainda.
public Barbearia salvar(Barbearia barbearia) {
return repository.save(barbearia);
}
}

View File

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

41
barber-flow/.gitignore vendored Normal file
View File

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

83
barber-flow/GEMINI.md Normal file
View File

@ -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`).

33
barber-flow/README.md Normal file
View File

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

35
barber-flow/app.json Normal file
View File

@ -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"
]
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

80
barber-flow/app/index.tsx Normal file
View File

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

259
barber-flow/app/landing.tsx Normal file
View File

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

BIN
barber-flow/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

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

View File

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

View File

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

8930
barber-flow/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
barber-flow/package.json Normal file
View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}

8
barber-flow/vercel.json Normal file
View File

@ -0,0 +1,8 @@
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}