feat: criar landing page pública com endpoint de estatísticas
Backend: - Adicionar métodos de contagem/agregação aos repositórios (UserRepository, OrganizationRepository, TaskRepository) - Implementar nova use case PublicStatsUseCase que retorna estatísticas públicas (usuários, organizações, tarefas completas, horas registradas) - Criar novo controller público GET /public/stats sem autenticação - Configurar CDI factory PublicStatsUseCaseConfig Frontend: - Criar nova landing page pública (LandingPage.tsx) com seções: * Hero com CTAs (Começar grátis / Ver demo) * 4 cards de funcionalidades principais (dashboard, produtividade, rastreamento, Kanban) * Seção "Como funciona" com 3 passos * Showcase visual do dashboard com leaderboard fake * Estatísticas reais da plataforma (consumidas do backend) * CTA final e footer - Criar serviço publicStatsService para consumir o endpoint público - Adicionar rota /landing na router - Design alinhado com o sistema de cores e tipografia existente (Plus Jakarta Sans, Tailwind v4) Stats reais são animadas ao fazer scroll (Framer Motion counter).
This commit is contained in:
parent
66782dda0c
commit
2d14ae480d
15 changed files with 788 additions and 0 deletions
|
|
@ -0,0 +1,19 @@
|
|||
package net.beetechgroup.beetask.frameworks.config;
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.enterprise.inject.Produces;
|
||||
import net.beetechgroup.beetask.usecase.publicstats.PublicStatsUseCase;
|
||||
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||
|
||||
@ApplicationScoped
|
||||
public class PublicStatsUseCaseConfig {
|
||||
@Produces
|
||||
public PublicStatsUseCase publicStatsUseCase(
|
||||
UserRepository userRepository,
|
||||
OrganizationRepository organizationRepository,
|
||||
TaskRepository taskRepository) {
|
||||
return new PublicStatsUseCase(userRepository, organizationRepository, taskRepository);
|
||||
}
|
||||
}
|
||||
|
|
@ -57,4 +57,9 @@ public class OrganizationRepositoryImpl implements OrganizationRepository, Panac
|
|||
}
|
||||
return organization;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countAll() {
|
||||
return count();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import java.util.ArrayList;
|
|||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.Duration;
|
||||
|
||||
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||
import jakarta.enterprise.context.ApplicationScoped;
|
||||
|
|
@ -172,4 +173,18 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
|||
emails, TaskStatus.COMPLETED, start, end)
|
||||
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countByStatus(TaskStatus status) {
|
||||
return count("status", status);
|
||||
}
|
||||
|
||||
@Override
|
||||
public long sumAllCompletedTrackedMinutes() {
|
||||
return findAllTasks().stream()
|
||||
.flatMap(task -> task.getHistory().stream())
|
||||
.filter(h -> Objects.nonNull(h.getEndAt()))
|
||||
.mapToLong(h -> Duration.between(h.getStartAt(), h.getEndAt()).toMinutes())
|
||||
.sum();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,4 +64,9 @@ public class UserRepositoryImpl implements UserRepository, PanacheRepository<Use
|
|||
LOGGER.infof("Loaded %d active organizations for user %d", organizations.size(), userId);
|
||||
return organizations;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long countAll() {
|
||||
return count();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.controllers.publicstats;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import net.beetechgroup.beetask.usecase.publicstats.PublicStatsUseCase;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.eclipse.microprofile.openapi.annotations.media.Content;
|
||||
import org.eclipse.microprofile.openapi.annotations.media.Schema;
|
||||
import org.eclipse.microprofile.openapi.annotations.responses.APIResponse;
|
||||
import org.eclipse.microprofile.openapi.annotations.tags.Tag;
|
||||
|
||||
@Path("/public/stats")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Tag(name = "Public Stats")
|
||||
public class PublicStatsController {
|
||||
@Inject
|
||||
PublicStatsUseCase publicStatsUseCase;
|
||||
|
||||
@GET
|
||||
@Operation(summary = "Get public platform statistics")
|
||||
@APIResponse(
|
||||
responseCode = "200",
|
||||
description = "Platform statistics",
|
||||
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = PublicStatsResponse.class))
|
||||
)
|
||||
public PublicStatsResponse getStats() {
|
||||
var output = publicStatsUseCase.execute();
|
||||
return PublicStatsControllerMapper.toPublicStatsResponse(output);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.controllers.publicstats;
|
||||
|
||||
import net.beetechgroup.beetask.usecase.publicstats.PublicStatsOutput;
|
||||
|
||||
public class PublicStatsControllerMapper {
|
||||
public static PublicStatsResponse toPublicStatsResponse(PublicStatsOutput output) {
|
||||
return new PublicStatsResponse(
|
||||
output.totalUsers(),
|
||||
output.totalOrganizations(),
|
||||
output.totalCompletedTasks(),
|
||||
output.totalTrackedHours()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.controllers.publicstats;
|
||||
|
||||
public record PublicStatsResponse(
|
||||
long totalUsers,
|
||||
long totalOrganizations,
|
||||
long totalCompletedTasks,
|
||||
double totalTrackedHours
|
||||
) {}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package net.beetechgroup.beetask.usecase.publicstats;
|
||||
|
||||
public record PublicStatsOutput(
|
||||
long totalUsers,
|
||||
long totalOrganizations,
|
||||
long totalCompletedTasks,
|
||||
double totalTrackedHours
|
||||
) {}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package net.beetechgroup.beetask.usecase.publicstats;
|
||||
|
||||
import net.beetechgroup.beetask.entities.task.TaskStatus;
|
||||
import net.beetechgroup.beetask.usecase.repository.OrganizationRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.TaskRepository;
|
||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||
|
||||
public class PublicStatsUseCase {
|
||||
private final UserRepository userRepository;
|
||||
private final OrganizationRepository organizationRepository;
|
||||
private final TaskRepository taskRepository;
|
||||
|
||||
public PublicStatsUseCase(UserRepository userRepository, OrganizationRepository organizationRepository, TaskRepository taskRepository) {
|
||||
this.userRepository = userRepository;
|
||||
this.organizationRepository = organizationRepository;
|
||||
this.taskRepository = taskRepository;
|
||||
}
|
||||
|
||||
public PublicStatsOutput execute() {
|
||||
long totalUsers = userRepository.countAll();
|
||||
long totalOrganizations = organizationRepository.countAll();
|
||||
long totalCompletedTasks = taskRepository.countByStatus(TaskStatus.COMPLETED);
|
||||
double totalTrackedHours = taskRepository.sumAllCompletedTrackedMinutes() / 60.0;
|
||||
|
||||
return new PublicStatsOutput(totalUsers, totalOrganizations, totalCompletedTasks, totalTrackedHours);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,4 +9,5 @@ public interface OrganizationRepository {
|
|||
Organization saveOrganization(Organization organization);
|
||||
List<Organization> search(String query);
|
||||
Optional<Organization> findOrganizationById(Long id);
|
||||
long countAll();
|
||||
}
|
||||
|
|
@ -21,4 +21,6 @@ public interface TaskRepository {
|
|||
List<Task> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
|
||||
List<Task> findTasksWorkedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end);
|
||||
List<Task> findTasksFinishedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end);
|
||||
long countByStatus(TaskStatus status);
|
||||
long sumAllCompletedTrackedMinutes();
|
||||
}
|
||||
|
|
@ -10,4 +10,5 @@ public interface UserRepository {
|
|||
Optional<User> findByEmail(String email);
|
||||
Optional<User> findUserById(Long id);
|
||||
List<UserOrganization> findUserOrganizations(Long userId);
|
||||
long countAll();
|
||||
}
|
||||
|
|
|
|||
628
frontend/src/pages/LandingPage.tsx
Normal file
628
frontend/src/pages/LandingPage.tsx
Normal file
|
|
@ -0,0 +1,628 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { motion, useInView } from 'framer-motion';
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Zap,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Trophy,
|
||||
ArrowRight,
|
||||
Menu,
|
||||
X,
|
||||
Layout,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { publicStatsService } from '../services/publicStatsService';
|
||||
import type { PublicStats } from '../services/publicStatsService';
|
||||
|
||||
export const LandingPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [stats, setStats] = useState<PublicStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const data = await publicStatsService.getStats();
|
||||
setStats(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch public stats:', err);
|
||||
setStats(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-b from-app-bg via-app-bg to-app-bg-strong text-text-main overflow-hidden">
|
||||
{/* Subtle radial gradient overlay */}
|
||||
<div className="fixed inset-0 pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-brand/5 rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/3 right-1/4 w-96 h-96 bg-accent/5 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="sticky top-0 z-40 backdrop-blur-xl bg-surface/80 border-b border-border-soft">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-2 group">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-brand to-brand-strong flex items-center justify-center shadow-lg shadow-brand/20 group-hover:shadow-xl group-hover:shadow-brand/30 transition-shadow">
|
||||
<Layout className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-lg font-black tracking-tight">BeeTask</span>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-text-muted">
|
||||
Plataforma
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Menu */}
|
||||
<div className="hidden md:flex gap-8 items-center">
|
||||
<a href="#features" className="text-sm font-medium hover:text-brand transition-colors">
|
||||
Funcionalidades
|
||||
</a>
|
||||
<a href="#how-it-works" className="text-sm font-medium hover:text-brand transition-colors">
|
||||
Como funciona
|
||||
</a>
|
||||
<a href="#stats" className="text-sm font-medium hover:text-brand transition-colors">
|
||||
Estatísticas
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Auth Buttons Desktop */}
|
||||
<div className="hidden md:flex gap-3 items-center">
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="px-4 py-2 text-sm font-semibold hover:text-brand transition-colors"
|
||||
>
|
||||
Entrar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/register')}
|
||||
className="px-6 py-2 text-sm font-semibold rounded-2xl bg-brand text-white hover:bg-brand-strong transition-colors shadow-lg shadow-brand/20"
|
||||
>
|
||||
Criar conta
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="md:hidden p-2 hover:bg-surface-muted rounded-xl transition-colors"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
>
|
||||
{isMenuOpen ? (
|
||||
<X className="w-6 h-6" />
|
||||
) : (
|
||||
<Menu className="w-6 h-6" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{isMenuOpen && (
|
||||
<motion.div
|
||||
className="md:hidden pb-4 border-t border-border-soft"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<div className="flex flex-col gap-3 pt-4">
|
||||
<a href="#features" className="text-sm font-medium hover:text-brand transition-colors p-2">
|
||||
Funcionalidades
|
||||
</a>
|
||||
<a href="#how-it-works" className="text-sm font-medium hover:text-brand transition-colors p-2">
|
||||
Como funciona
|
||||
</a>
|
||||
<a href="#stats" className="text-sm font-medium hover:text-brand transition-colors p-2">
|
||||
Estatísticas
|
||||
</a>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="flex-1 px-4 py-2 text-sm font-semibold border border-border-strong rounded-xl hover:bg-surface-muted transition-colors"
|
||||
>
|
||||
Entrar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/register')}
|
||||
className="flex-1 px-4 py-2 text-sm font-semibold rounded-xl bg-brand text-white hover:bg-brand-strong transition-colors"
|
||||
>
|
||||
Criar conta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 md:py-32">
|
||||
<motion.div
|
||||
className="text-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<h1 className="text-5xl md:text-7xl font-black tracking-tight mb-6 leading-tight">
|
||||
Gestão de equipes com{' '}
|
||||
<span className="bg-gradient-to-r from-brand to-accent bg-clip-text text-transparent">
|
||||
visibilidade real
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-lg md:text-xl text-text-muted max-w-2xl mx-auto mb-8 leading-relaxed">
|
||||
Organize tarefas, rastreie tempo e acompanhe a produtividade da sua equipe em tempo real.
|
||||
Tome decisões baseadas em dados.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-12">
|
||||
<button
|
||||
onClick={() => navigate('/register')}
|
||||
className="px-8 py-3 rounded-2xl bg-brand text-white font-bold text-lg hover:bg-brand-strong transition-all hover:scale-105 active:scale-95 shadow-lg shadow-brand/30"
|
||||
>
|
||||
Começar grátis
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const element = document.getElementById('showcase');
|
||||
element?.scrollIntoView({ behavior: 'smooth' });
|
||||
}}
|
||||
className="px-8 py-3 rounded-2xl border-2 border-brand text-brand font-bold text-lg hover:bg-brand-soft transition-all"
|
||||
>
|
||||
Ver demonstração
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hero Decoration - Simple SVG Dashboard Mock */}
|
||||
<motion.div
|
||||
className="relative mx-auto max-w-2xl mt-16"
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.6 }}
|
||||
>
|
||||
<div className="rounded-[2.5rem] bg-surface shadow-2xl shadow-brand/10 p-8 border border-border-soft">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm font-black uppercase tracking-widest text-text-muted">Dashboard</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-success" />
|
||||
<div className="w-2 h-2 rounded-full bg-accent/50" />
|
||||
<div className="w-2 h-2 rounded-full bg-warning/50" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-32 flex items-end justify-between gap-2">
|
||||
<div className="flex-1 h-20 bg-gradient-to-t from-brand/30 to-brand/10 rounded-xl" />
|
||||
<div className="flex-1 h-28 bg-gradient-to-t from-brand/30 to-brand/10 rounded-xl" />
|
||||
<div className="flex-1 h-24 bg-gradient-to-t from-brand/30 to-brand/10 rounded-xl" />
|
||||
<div className="flex-1 h-32 bg-gradient-to-t from-brand/30 to-brand/10 rounded-xl" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pt-4">
|
||||
<div className="h-12 bg-accent/20 rounded-lg border border-border-soft" />
|
||||
<div className="h-12 bg-brand/20 rounded-lg border border-border-soft" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating cards */}
|
||||
<motion.div
|
||||
className="absolute -top-8 -right-8 w-32 h-32 bg-surface rounded-[2rem] shadow-lg shadow-accent/20 border border-border-soft p-4 flex flex-col items-center justify-center"
|
||||
animate={{ y: [0, -10, 0] }}
|
||||
transition={{ duration: 3, repeat: Infinity }}
|
||||
>
|
||||
<Trophy className="w-8 h-8 text-accent mb-2" />
|
||||
<span className="text-xs font-black">TOP</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="absolute -bottom-8 -left-8 w-32 h-20 bg-surface rounded-[2rem] shadow-lg shadow-brand/20 border border-border-soft p-4 flex flex-col items-center justify-center"
|
||||
animate={{ y: [0, 10, 0] }}
|
||||
transition={{ duration: 3, repeat: Infinity, delay: 0.5 }}
|
||||
>
|
||||
<Clock className="w-8 h-8 text-brand mb-2" />
|
||||
<span className="text-xs font-black">8h 30m</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section id="features" className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
|
||||
<motion.div
|
||||
className="text-center mb-16"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tight mb-4">
|
||||
Funcionalidades principais
|
||||
</h2>
|
||||
<p className="text-lg text-text-muted">
|
||||
Tudo que sua equipe precisa para gerenciar tarefas e produtividade
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{[
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: 'Dashboard da Organização',
|
||||
description: 'Visualize a produtividade completa da sua equipe com gráficos intuitivos e análises em tempo real.',
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: 'Análise de Produtividade',
|
||||
description: 'Acompanhe o desempenho individual de cada membro da equipe com rankings e métricas detalhadas.',
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: 'Rastreamento de Tempo',
|
||||
description: 'Registre automaticamente o tempo gasto em cada tarefa com nosso cronômetro integrado.',
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: 'Gestão Visual',
|
||||
description: 'Organize tarefas em um quadro Kanban intuitivo e acompanhe o progresso do projeto.',
|
||||
},
|
||||
].map((feature, idx) => (
|
||||
<motion.div
|
||||
key={idx}
|
||||
className="group rounded-[2.5rem] bg-surface border border-border-soft p-8 hover:shadow-lg hover:shadow-brand/10 transition-all hover:-translate-y-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
>
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-0 bg-brand/10 rounded-2xl blur-2xl" />
|
||||
<feature.icon className="relative w-12 h-12 text-brand" />
|
||||
</div>
|
||||
<h3 className="text-xl font-black mb-3">{feature.title}</h3>
|
||||
<p className="text-text-muted leading-relaxed">{feature.description}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works Section */}
|
||||
<section id="how-it-works" className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
|
||||
<motion.div
|
||||
className="text-center mb-16"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tight mb-4">
|
||||
Como funciona
|
||||
</h2>
|
||||
<p className="text-lg text-text-muted">
|
||||
Comece em 3 simples passos
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{[
|
||||
{
|
||||
step: 1,
|
||||
title: 'Crie sua organização',
|
||||
description: 'Configure sua workspace em minutos e convide sua equipe.',
|
||||
},
|
||||
{
|
||||
step: 2,
|
||||
title: 'Adicione tarefas e projetos',
|
||||
description: 'Organize trabalho em projetos e categorize tarefas.',
|
||||
},
|
||||
{
|
||||
step: 3,
|
||||
title: 'Acompanhe produtividade',
|
||||
description: 'Visualize métricas em tempo real e tome melhores decisões.',
|
||||
},
|
||||
].map((item, idx) => (
|
||||
<motion.div
|
||||
key={idx}
|
||||
className="text-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: idx * 0.1 }}
|
||||
>
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-brand text-white font-black text-2xl mb-6">
|
||||
{item.step}
|
||||
</div>
|
||||
<h3 className="text-xl font-black mb-3">{item.title}</h3>
|
||||
<p className="text-text-muted">{item.description}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Showcase Section */}
|
||||
<section id="showcase" className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
|
||||
<motion.div
|
||||
className="text-center mb-16"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tight mb-4">
|
||||
Dashboard em ação
|
||||
</h2>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="rounded-[2.5rem] bg-surface border border-border-soft overflow-hidden shadow-2xl shadow-brand/10"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<div className="p-8 md:p-12">
|
||||
<div className="grid md:grid-cols-2 gap-12">
|
||||
{/* Left: Leaderboard */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-black uppercase tracking-widest text-text-muted mb-6">
|
||||
Leaderboard de produtividade
|
||||
</h3>
|
||||
|
||||
{['João Silva', 'Maria Santos', 'Pedro Costa', 'Ana Lima'].map((name, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-4 p-4 rounded-xl bg-app-bg border border-border-soft hover:border-border-strong transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-brand to-accent flex items-center justify-center text-white font-black text-sm">
|
||||
{name[0]}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-sm">{name}</p>
|
||||
<p className="text-xs text-text-muted">32.5 horas esta semana</p>
|
||||
</div>
|
||||
{idx === 0 && <Trophy className="w-5 h-5 text-accent" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right: Stats and Charts */}
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{[
|
||||
{ label: 'Tarefas', value: '124' },
|
||||
{ label: 'Concluídas', value: '89' },
|
||||
{ label: 'Em andamento', value: '28' },
|
||||
{ label: 'Tempo médio', value: '2h 30m' },
|
||||
].map((stat, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="rounded-2xl bg-app-bg border border-border-soft p-4 text-center"
|
||||
>
|
||||
<p className="text-2xl font-black text-brand">{stat.value}</p>
|
||||
<p className="text-xs text-text-muted font-semibold mt-1">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-black uppercase tracking-widest text-text-muted">
|
||||
Tempo por categoria
|
||||
</p>
|
||||
{[
|
||||
{ name: 'Desenvolvimento', percent: 45, color: 'bg-brand' },
|
||||
{ name: 'Design', percent: 25, color: 'bg-accent' },
|
||||
{ name: 'Reuniões', percent: 20, color: 'bg-warning' },
|
||||
{ name: 'Outros', percent: 10, color: 'bg-success' },
|
||||
].map((cat, idx) => (
|
||||
<div key={idx}>
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-xs font-semibold">{cat.name}</span>
|
||||
<span className="text-xs text-text-muted font-bold">{cat.percent}%</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-app-bg-strong overflow-hidden">
|
||||
<div className={cn('h-full transition-all', cat.color)} style={{ width: `${cat.percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section id="stats" className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
|
||||
<motion.div
|
||||
className="text-center mb-16"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tight mb-4">
|
||||
Usada por equipes em todo o Brasil
|
||||
</h2>
|
||||
<p className="text-lg text-text-muted">
|
||||
Estatísticas da plataforma em tempo real
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid md:grid-cols-4 gap-6">
|
||||
{loading ? (
|
||||
<div className="col-span-full text-center py-12">
|
||||
<p className="text-text-muted">Carregando estatísticas...</p>
|
||||
</div>
|
||||
) : !stats ? (
|
||||
<div className="col-span-full text-center py-12">
|
||||
<p className="text-text-muted">Não foi possível carregar as estatísticas</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<StatsCard
|
||||
icon={Users}
|
||||
label="Usuários Ativos"
|
||||
value={stats.totalUsers}
|
||||
delay={0}
|
||||
/>
|
||||
<StatsCard
|
||||
icon={TrendingUp}
|
||||
label="Organizações"
|
||||
value={stats.totalOrganizations}
|
||||
delay={0.1}
|
||||
/>
|
||||
<StatsCard
|
||||
icon={CheckCircle2}
|
||||
label="Tarefas Concluídas"
|
||||
value={stats.totalCompletedTasks}
|
||||
delay={0.2}
|
||||
/>
|
||||
<StatsCard
|
||||
icon={Clock}
|
||||
label="Horas Registradas"
|
||||
value={Math.round(stats.totalTrackedHours)}
|
||||
delay={0.3}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Final CTA */}
|
||||
<section className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
|
||||
<motion.div
|
||||
className="rounded-[2.5rem] bg-gradient-to-r from-brand to-brand-strong p-12 md:p-16 text-center text-white relative overflow-hidden"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<div className="absolute inset-0 opacity-10">
|
||||
<Zap className="absolute w-32 h-32 top-0 right-0" />
|
||||
<TrendingUp className="absolute w-32 h-32 bottom-0 left-0" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-4xl md:text-5xl font-black tracking-tight mb-4">
|
||||
Pronto para começar?
|
||||
</h2>
|
||||
<p className="text-lg text-white/80 mb-8 max-w-2xl mx-auto">
|
||||
Junte-se a centenas de equipes que já estão usando BeeTask para gerenciar suas tarefas
|
||||
e aumentar a produtividade.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => navigate('/register')}
|
||||
className="px-8 py-4 rounded-2xl bg-white text-brand font-bold text-lg hover:bg-brand-soft transition-all hover:scale-105 active:scale-95 flex items-center justify-center gap-2"
|
||||
>
|
||||
Criar conta grátis
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="px-8 py-4 rounded-2xl border-2 border-white text-white font-bold text-lg hover:bg-white/10 transition-all"
|
||||
>
|
||||
Já tenho conta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-border-soft">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<Link to="/" className="flex items-center gap-2 mb-6 md:mb-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand to-brand-strong flex items-center justify-center shadow-md shadow-brand/20">
|
||||
<Layout className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="font-black">BeeTask</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex gap-8">
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="text-sm font-medium hover:text-brand transition-colors"
|
||||
>
|
||||
Entrar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/register')}
|
||||
className="text-sm font-medium hover:text-brand transition-colors"
|
||||
>
|
||||
Criar conta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-soft mt-8 pt-8 text-center text-sm text-text-muted">
|
||||
<p>© 2026 BeeTask. Todos os direitos reservados.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StatsCardProps {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: number;
|
||||
delay: number;
|
||||
}
|
||||
|
||||
const StatsCard = ({ icon: Icon, label, value, delay }: StatsCardProps) => {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true });
|
||||
const [displayValue, setDisplayValue] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) return;
|
||||
|
||||
const duration = 2;
|
||||
const steps = 60;
|
||||
const increment = value / steps;
|
||||
let current = 0;
|
||||
let step = 0;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
current += increment;
|
||||
step++;
|
||||
if (step >= steps) {
|
||||
setDisplayValue(value);
|
||||
clearInterval(interval);
|
||||
} else {
|
||||
setDisplayValue(Math.floor(current));
|
||||
}
|
||||
}, (duration * 1000) / steps);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isInView, value]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className="rounded-[2.5rem] bg-surface border border-border-soft p-8 text-center hover:shadow-lg hover:shadow-brand/10 transition-all hover:-translate-y-2"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ delay }}
|
||||
>
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-brand/10 mb-6">
|
||||
<Icon className="w-7 h-7 text-brand" />
|
||||
</div>
|
||||
<div className="text-4xl md:text-5xl font-black text-brand mb-2">
|
||||
{displayValue.toLocaleString('pt-BR')}
|
||||
{label.includes('Horas') && 'h'}
|
||||
{label.includes('Usuários') && '+'}
|
||||
</div>
|
||||
<p className="text-text-muted font-semibold">{label}</p>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
@ -16,8 +16,13 @@ import { OrganizationsPage } from "./pages/OrganizationsPage";
|
|||
import { DashboardPage } from "./pages/DashboardPage";
|
||||
import { OrgDashboardPage } from "./pages/OrgDashboardPage";
|
||||
import { RoleGate } from "./components/RoleGate";
|
||||
import { LandingPage } from "./pages/LandingPage";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: "/landing",
|
||||
element: <LandingPage />,
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
element: <LoginPage />,
|
||||
|
|
|
|||
17
frontend/src/services/publicStatsService.ts
Normal file
17
frontend/src/services/publicStatsService.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { apiFetch } from "../lib/api";
|
||||
|
||||
export interface PublicStats {
|
||||
totalUsers: number;
|
||||
totalOrganizations: number;
|
||||
totalCompletedTasks: number;
|
||||
totalTrackedHours: number;
|
||||
}
|
||||
|
||||
export const publicStatsService = {
|
||||
getStats: async (): Promise<PublicStats> => {
|
||||
const response = await apiFetch("/public/stats", {
|
||||
method: "GET",
|
||||
}) as Response;
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
Loading…
Reference in a new issue