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;
|
return organization;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long countAll() {
|
||||||
|
return count();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||||
import jakarta.enterprise.context.ApplicationScoped;
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
@ -172,4 +173,18 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
||||||
emails, TaskStatus.COMPLETED, start, end)
|
emails, TaskStatus.COMPLETED, start, end)
|
||||||
.list().stream().map(TaskEntityMapper::toDomain).toList();
|
.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);
|
LOGGER.infof("Loaded %d active organizations for user %d", organizations.size(), userId);
|
||||||
return organizations;
|
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);
|
Organization saveOrganization(Organization organization);
|
||||||
List<Organization> search(String query);
|
List<Organization> search(String query);
|
||||||
Optional<Organization> findOrganizationById(Long id);
|
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> findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end);
|
||||||
List<Task> findTasksWorkedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end);
|
List<Task> findTasksWorkedByUserEmailsInPeriod(List<String> emails, LocalDateTime start, LocalDateTime end);
|
||||||
List<Task> findTasksFinishedByUserEmailsInPeriod(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> findByEmail(String email);
|
||||||
Optional<User> findUserById(Long id);
|
Optional<User> findUserById(Long id);
|
||||||
List<UserOrganization> findUserOrganizations(Long userId);
|
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 { DashboardPage } from "./pages/DashboardPage";
|
||||||
import { OrgDashboardPage } from "./pages/OrgDashboardPage";
|
import { OrgDashboardPage } from "./pages/OrgDashboardPage";
|
||||||
import { RoleGate } from "./components/RoleGate";
|
import { RoleGate } from "./components/RoleGate";
|
||||||
|
import { LandingPage } from "./pages/LandingPage";
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: "/landing",
|
||||||
|
element: <LandingPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/login",
|
path: "/login",
|
||||||
element: <LoginPage />,
|
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