Skip to content
Merged
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
131 changes: 131 additions & 0 deletions client/src/components/ideas-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { motion } from "framer-motion";
import { TrendingUp, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { IdeaResponse } from "@shared/schema";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";

interface IdeasListProps {
ideas: IdeaResponse[];
onVote: (ideaId: number) => void;
isVoting: { [key: number]: boolean };
votedIdeas: Set<number>;
user: any;
startRank?: number;
}

export function IdeasList({
ideas,
onVote,
isVoting,
votedIdeas,
user,
startRank = 4
}: IdeasListProps) {
const { t } = useTranslation();

if (ideas.length === 0) {
return null;
}

const handleVoteClick = (ideaId: number) => {
if (!user) {
localStorage.setItem("redirectAfterAuth", window.location.pathname);
window.location.href = "/auth";
return;
}

if (!votedIdeas.has(ideaId) && !isVoting[ideaId]) {
onVote(ideaId);
}
};

const getBorderAccent = (rank: number) => {
if (rank <= 10) return "border-l-amber-400";
if (rank <= 20) return "border-l-blue-400";
return "border-l-gray-300 dark:border-l-gray-600";
};

return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-6">
{t("ideas.allIdeas", "Todas las Ideas")}
</h2>

<div className="space-y-3">
{ideas.map((idea, index) => {
const rank = startRank + index;
const hasVoted = votedIdeas.has(idea.id);
const voting = isVoting[idea.id];

return (
<motion.div
key={idea.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
whileHover={{ x: 4, transition: { duration: 0.2 } }}
className={cn(
"relative bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700",
"border-l-4 p-4 transition-shadow hover:shadow-md dark:hover:shadow-gray-800/50",
getBorderAccent(rank)
)}
data-testid={`card-idea-list-${idea.id}`}
>
<div className="flex items-start gap-4">
<div className="flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-lg bg-gray-100 dark:bg-gray-800 font-bold text-gray-600 dark:text-gray-400 text-sm">
#{rank}
</div>

<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-white text-sm line-clamp-1 mb-1">
{idea.title}
</h3>
<p className="text-xs text-gray-600 dark:text-gray-400 line-clamp-2 leading-relaxed">
{idea.description}
</p>
</div>

<div className="flex-shrink-0 flex items-center gap-3">
<div className="flex items-center gap-1 text-green-600 dark:text-green-400 font-semibold text-sm">
<TrendingUp className="w-4 h-4" />
{idea.votes}
</div>

<Button
onClick={() => handleVoteClick(idea.id)}
disabled={hasVoted || voting}
variant="outline"
size="sm"
className={cn(
"flex items-center gap-1.5 border-2 font-medium transition-all text-xs px-3",
hasVoted
? "bg-green-50 dark:bg-green-900/20 border-green-500 text-green-600 dark:text-green-400"
: "border-gray-800 dark:border-gray-200 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800"
)}
data-testid={`button-vote-list-${idea.id}`}
>
{voting ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : hasVoted ? (
t("common.voted", "Votado")
) : (
<>
<TrendingUp className="w-3 h-3" />
{t("common.vote", "Votar")}
</>
)}
</Button>
</div>
</div>
</motion.div>
);
})}
</div>
</motion.div>
);
}
165 changes: 165 additions & 0 deletions client/src/components/top3-cards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { motion } from "framer-motion";
import { Trophy, TrendingUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { IdeaResponse } from "@shared/schema";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";

interface Top3CardsProps {
ideas: IdeaResponse[];
onVote: (ideaId: number) => void;
isVoting: { [key: number]: boolean };
votedIdeas: Set<number>;
user: any;
}

export function Top3Cards({
ideas,
onVote,
isVoting,
votedIdeas,
user
}: Top3CardsProps) {
const { t } = useTranslation();

const top3Ideas = ideas.slice(0, 3);

if (top3Ideas.length === 0) {
return null;
}

const getBorderColor = (rank: number) => {
switch(rank) {
case 1: return "from-yellow-400 via-amber-400 to-yellow-500";
case 2: return "from-blue-400 via-cyan-400 to-blue-500";
case 3: return "from-orange-400 via-amber-500 to-orange-500";
default: return "from-gray-300 to-gray-400";
}
};

const getBadgeColor = (rank: number) => {
switch(rank) {
case 1: return "bg-gradient-to-r from-yellow-400 to-amber-500 text-yellow-900";
case 2: return "bg-gradient-to-r from-blue-400 to-cyan-500 text-blue-900";
case 3: return "bg-gradient-to-r from-orange-400 to-amber-500 text-orange-900";
default: return "bg-gray-200 text-gray-700";
}
};

const handleVoteClick = (ideaId: number) => {
if (!user) {
localStorage.setItem("redirectAfterAuth", window.location.pathname);
window.location.href = "/auth";
return;
}

if (!votedIdeas.has(ideaId) && !isVoting[ideaId]) {
onVote(ideaId);
}
};

return (
<motion.div
className="mb-10"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className="flex items-center gap-2 mb-6">
<Trophy className="w-6 h-6 text-primary" />
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
Top 3 Ideas
</h2>
</div>

<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{top3Ideas.map((idea, index) => {
const rank = index + 1;
const hasVoted = votedIdeas.has(idea.id);
const voting = isVoting[idea.id];

return (
<motion.div
key={idea.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={{ y: -4, transition: { duration: 0.2 } }}
className="relative"
data-testid={`card-idea-${idea.id}`}
>
<div className={cn(
"absolute inset-0 rounded-xl bg-gradient-to-br p-[2px]",
getBorderColor(rank)
)}>
<div className="h-full w-full rounded-xl bg-white dark:bg-gray-900" />
</div>

<div className="relative p-5 h-full flex flex-col min-h-[200px]">
<div className="flex items-start justify-between mb-3">
<div className={cn(
"flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-bold",
getBadgeColor(rank)
)}>
<Trophy className="w-3 h-3" />
#{rank}
</div>

<div className="flex items-center gap-1 text-green-600 dark:text-green-400 font-semibold text-sm">
<TrendingUp className="w-4 h-4" />
{idea.votes}
</div>
</div>

<div className="flex-1 mb-4">
<h3 className="font-bold text-gray-900 dark:text-white text-base leading-tight mb-2 line-clamp-2">
{idea.title}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-3 leading-relaxed">
{idea.description}
</p>
</div>

<div className="flex items-center justify-between mt-auto">
{idea.suggestedBy && (
<span className="text-xs text-gray-500 dark:text-gray-400">
{t("ideas.by", "Por")} {idea.suggestedBy}
</span>
)}
<Button
onClick={() => handleVoteClick(idea.id)}
disabled={hasVoted || voting}
variant="outline"
size="sm"
className={cn(
"ml-auto flex items-center gap-1.5 border-2 font-medium transition-all",
hasVoted
? "bg-green-50 dark:bg-green-900/20 border-green-500 text-green-600 dark:text-green-400"
: "border-gray-800 dark:border-gray-200 text-gray-800 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800"
)}
data-testid={`button-vote-${idea.id}`}
>
{voting ? (
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="w-3 h-3 border-2 border-current border-t-transparent rounded-full"
/>
) : hasVoted ? (
t("common.voted", "Votado")
) : (
<>
<TrendingUp className="w-3.5 h-3.5" />
{t("common.vote", "Votar")}
</>
)}
</Button>
</div>
</div>
</motion.div>
);
})}
</div>
</motion.div>
);
}
9 changes: 7 additions & 2 deletions client/src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,10 @@
"loadError": "Failed to load ideas",
"analyzed": "Analyzed",
"activeIdeas": "Active Ideas",
"totalVotes": "Total Votes"
"totalVotes": "Total Votes",
"allIdeas": "All Ideas",
"by": "By",
"empty": "No ideas yet"
},
"priority": {
"hybridScore": "Priority score (combines votes + YouTube opportunity)",
Expand Down Expand Up @@ -897,7 +900,9 @@
"watchDemo": "▶ Watch Demo"
},
"suggest": {
"idea": "Suggest Idea"
"idea": "Suggest Idea",
"notFound": "Didn't find what you were looking for?",
"helpCreator": "Help {{creator}} by suggesting an idea"
},
"redemptions": {
"short": "Redemptions",
Expand Down
9 changes: 7 additions & 2 deletions client/src/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,10 @@
"loadError": "Error al cargar ideas",
"analyzed": "Analizado",
"activeIdeas": "Ideas Activas",
"totalVotes": "Votos Totales"
"totalVotes": "Votos Totales",
"allIdeas": "Todas las Ideas",
"by": "Por",
"empty": "Aún no hay ideas"
},
"priority": {
"hybridScore": "Puntuación de prioridad (combina votos + oportunidad YouTube)",
Expand Down Expand Up @@ -896,7 +899,9 @@
"watchDemo": "▶ Ver Demo"
},
"suggest": {
"idea": "Sugerir Idea"
"idea": "Sugerir Idea",
"notFound": "¿No encontraste lo que buscabas?",
"helpCreator": "Ayuda a {{creator}} sugiriendo una idea"
},
"errors": {
"notEnoughPoints": "No tienes suficientes puntos para enviar una sugerencia",
Expand Down
Loading