Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions src/app/roadmap/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use server';

import fs from 'fs/promises';
import path from 'path';
import { revalidatePath } from 'next/cache';
import { Feature } from '@/components/FeatureCard';

const DATA_PATH = path.join(process.cwd(), 'src', 'app', 'roadmap', 'data', 'roadmap.json');

export async function incrementVote(featureId: string): Promise<Feature | null> {
try {
// Ler o arquivo JSON atual
const content = await fs.readFile(DATA_PATH, 'utf-8');
const features: Feature[] = JSON.parse(content);

// Encontrar e atualizar a feature
const featureIndex = features.findIndex(f => f.id === featureId);
if (featureIndex === -1) return null;

// Incrementar o contador de votos
features[featureIndex] = {
...features[featureIndex],
votesCount: features[featureIndex].votesCount + 1
};

// Salvar o arquivo atualizado
await fs.writeFile(DATA_PATH, JSON.stringify(features, null, 2), 'utf-8');

// Revalidar a página para atualizar os dados
revalidatePath('/roadmap');

// Retornar a feature atualizada
return features[featureIndex];
} catch (error) {
console.error('Erro ao atualizar voto:', error);
return null;
}
}
30 changes: 30 additions & 0 deletions src/app/roadmap/data/roadmap.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
[
{
"id": "feature-aprovada-abc",
"title": "Integração com Calendário Google",
"description": "Poder exportar nossos eventos para o Google Calendar com um clique.",
"status": "SUGGESTED",
"votesCount": 524
},
{
"id": "outra-sugestao-def",
"title": "Adicionar Tema Escuro",
"description": "Criar uma opção para alternar entre os modos claro e escuro no perfil.",
"status": "SUGGESTED",
"votesCount": 33
},
{
"id": "feature-em-progresso-123",
"title": "Exportar Relatórios em PDF",
"description": "Permitir gerar um PDF dos relatórios mensais diretamente da dashboard.",
"status": "IN_PROGRESS",
"votesCount": 399
},
{
"id": "feature-concluida-456",
"title": "Login Social com Google e GitHub",
"description": "Permitir que novos usuários se cadastrem e façam login usando suas contas sociais.",
"status": "DONE",
"votesCount": 3426
}
]
92 changes: 92 additions & 0 deletions src/app/roadmap/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import fs from 'fs/promises';
import path from 'path';
import RoadmapColumn from '@/components/RoadmapColumn';
import { Feature } from '@/components/FeatureCard';
import { FiTarget, FiClock, FiCheckCircle } from 'react-icons/fi';
import Container from '@/components/Container';

const DATA_PATH = path.join(process.cwd(), 'src', 'app', 'roadmap', 'data', 'roadmap.json');

// Definição das colunas com um estilo mais refinado
const columns = [
{
key: 'SUGGESTED',
title: 'Sugestões',
bg: 'bg-slate-50',
headerBg: 'bg-slate-100',
headerText: 'text-slate-800',
icon: <FiTarget className="w-5 h-5 text-slate-500" />,
borderColor: 'border-slate-200',
},
{
key: 'IN_PROGRESS',
title: 'Estamos Cozinhando',
bg: 'bg-blue-50',
headerBg: 'bg-blue-100',
headerText: 'text-blue-800',
icon: <FiClock className="w-5 h-5 text-blue-500" />,
borderColor: 'border-blue-200',
},
{
key: 'DONE',
title: 'Tudo Pronto!',
bg: 'bg-green-50',
headerBg: 'bg-green-100',
headerText: 'text-green-800',
icon: <FiCheckCircle className="w-5 h-5 text-green-500" />,
borderColor: 'border-green-200',
},
];

type FeaturesByStatus = {
[key: string]: Feature[];
};

export default async function RoadmapPage() {
const file = await fs.readFile(DATA_PATH, 'utf-8');
const features: Feature[] = JSON.parse(file);

// Agrupando features de forma mais eficiente com `reduce`
const featuresByStatus = features.reduce<FeaturesByStatus>((acc, feature) => {
if (!acc[feature.status]) {
acc[feature.status] = [];
}
acc[feature.status].push(feature);
return acc;
}, { SUGGESTED: [], IN_PROGRESS: [], DONE: [] });

return (
<Container className="py-24 sm:py-32">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16 sm:mb-20">
<h1 className="text-4xl sm:text-5xl font-bold tracking-tight text-gray-900">
Nosso Futuro
</h1>
<p className="mt-4 max-w-2xl mx-auto text-lg text-gray-600">
O Diaum está sempre em evolução e queremos que você faça parte disso. Confira as funcionalidades que estão no forno, nossos próximos passos e vote nas suas favoritas.
</p>
</div>

<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-8">
{columns.map(col => (
<div
key={col.key}
className={`${col.bg} rounded-xl border ${col.borderColor} shadow-md flex flex-col min-h-[200px] transition-all duration-300 hover:shadow-xl hover:-translate-y-1`}
>
<div className={`px-6 py-4 ${col.headerBg} rounded-t-xl flex items-center gap-3 border-b ${col.borderColor}`}>
{col.icon}
<span className={`${col.headerText} font-semibold text-base`}>
{col.title}
</span>
</div>
<div className="flex-1 p-4 sm:p-6">
{/* Garante que a coluna será renderizada mesmo sem features */}
<RoadmapColumn features={featuresByStatus[col.key] || []} />
</div>
</div>
))}
</div>
</div>
</Container>
);
}
97 changes: 97 additions & 0 deletions src/components/FeatureCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use client';

import { FaThumbsUp } from 'react-icons/fa';
import { FiExternalLink } from 'react-icons/fi';
import { incrementVote } from '@/app/roadmap/actions';
import { useState, useTransition, useEffect } from 'react';

export type Feature = {
id: string;
title: string;
description: string;
status: 'SUGGESTED' | 'IN_PROGRESS' | 'DONE';
votesCount: number;
};

export function FeatureCard({ feature: initialFeature }: { feature: Feature }) {
const [feature, setFeature] = useState(initialFeature);
const [isPending, startTransition] = useTransition();
const [optimisticVotes, setOptimisticVotes] = useState(feature.votesCount);
const [hasVoted, setHasVoted] = useState(false);

// Verificar se o usuário já votou nesta feature
useEffect(() => {
const votedFeatures = JSON.parse(localStorage.getItem('votedFeatures') || '[]');
setHasVoted(votedFeatures.includes(feature.id));
}, [feature.id]);

const handleVote = () => {
// Verificar se já votou
if (hasVoted) return;

// Salvar voto no localStorage
const votedFeatures = JSON.parse(localStorage.getItem('votedFeatures') || '[]');
localStorage.setItem('votedFeatures', JSON.stringify([...votedFeatures, feature.id]));
setHasVoted(true);

// Atualização otimista
setOptimisticVotes(prev => prev + 1);

// Server Action
startTransition(async () => {
const updatedFeature = await incrementVote(feature.id);
if (updatedFeature) {
setFeature(updatedFeature);
} else {
// Reverter em caso de erro
setOptimisticVotes(feature.votesCount);
// Remover do localStorage em caso de erro
const votedFeatures = JSON.parse(localStorage.getItem('votedFeatures') || '[]');
localStorage.setItem(
'votedFeatures',
JSON.stringify(votedFeatures.filter((id: string) => id !== feature.id))
);
setHasVoted(false);
}
});
};

return (
<div className="bg-white rounded-lg shadow-[0_1px_3px_0_rgb(0,0,0,0.1)] p-6 hover:shadow-[0_1px_3px_0_rgb(0,0,0,0.2)] transition-shadow duration-200">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-start justify-between gap-2">
<h3 className="text-base font-medium text-gray-900">
{feature.title}
</h3>
</div>
<p className="text-base text-gray-600 mt-2 leading-relaxed">{feature.description}</p>
</div>

<div className="flex-shrink-0">
<button
onClick={handleVote}
disabled={isPending || hasVoted}
className={`flex items-center gap-2 rounded-full px-3 py-1.5 ${
isPending
? 'bg-gray-100 text-gray-400'
: hasVoted
? 'bg-blue-50 text-blue-500 cursor-not-allowed'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100 hover:text-gray-900'
} transition-all duration-200`}
title={hasVoted ? 'Você já votou nesta feature' : 'Votar nesta feature'}
>
<FaThumbsUp
className={`w-4 h-4 ${
isPending ? 'animate-pulse' : ''
}`}
/>
<span className="text-sm font-medium tabular-nums">
{optimisticVotes}
</span>
</button>
</div>
</div>
</div>
);
}
15 changes: 15 additions & 0 deletions src/components/RoadmapColumn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Feature, FeatureCard } from './FeatureCard';

type RoadmapColumnProps = {
features: Feature[];
};

export default function RoadmapColumn({ features }: RoadmapColumnProps) {
return (
<div className="space-y-3">
{features.map((feature) => (
<FeatureCard key={feature.id} feature={feature} />
))}
</div>
);
}
Loading