diff --git a/.env.example b/.env.example index 6fecbcaf..fc3c9b98 100644 --- a/.env.example +++ b/.env.example @@ -32,8 +32,8 @@ GITHUB_ID="YOUR_GITHUB_CLIENT_ID" #neccesary GITHUB_SECRET="YOUR_GITHUB_CLIENT_SECRET" #neccesary -PUBLIC_STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" -PUBLIC_STELLAR_RPC_URL="https://soroban-testnet.stellar.org:443" +STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" +STELLAR_RPC_URL="https://soroban-testnet.stellar.org:443" STELLAR_ACCOUNT="collins" STELLAR_NETWORK="testnet" diff --git a/app/(dashboard)/my-contributions/page.tsx b/app/(dashboard)/my-contributions/page.tsx new file mode 100644 index 00000000..b9a35a5a --- /dev/null +++ b/app/(dashboard)/my-contributions/page.tsx @@ -0,0 +1,319 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { ContributionFilters } from "@/components/contributions/contribution-filters"; +import { ActiveContributions } from "@/components/contributions/active-contribution"; +import { PastContributions } from "@/components/contributions/past-contributions"; +import { UserComments } from "@/components/contributions/user-comments"; +import { CallToAction } from "@/components/contributions/call-to-action"; +import { CommentEditModal } from "@/components/contributions/comment-edit-modal"; +import { DeleteConfirmationDialog } from "@/components/contributions/delete-confirmation-dialog"; +import { LoadingState } from "@/components/contributions/loading-state"; +import type { + ActiveProject, + ContributionStats, + PastProject, + SortOption, + TabOption, + UserComment, +} from "@/types/contributions"; +import { + fetchActiveProjects, + fetchCategories, + fetchContributionStats, + fetchPastProjects, + fetchUserComments, + editComment as apiEditComment, + deleteComment as apiDeleteComment, +} from "@/lib/actions/services"; +import { + sortActiveProjects, + sortComments, + sortPastProjects, +} from "@/lib/utils"; +import { ContributionStats as ContributionStatsComponent } from "@/components/contributions/contribution-stats"; + +export default function MyContributionsPage() { + const router = useRouter(); + const [activeTab, setActiveTab] = useState("all"); + const [searchQuery, setSearchQuery] = useState(""); + const [sortOption, setSortOption] = useState("newest"); + const [categoryFilter, setCategoryFilter] = useState("all"); + const [categories, setCategories] = useState([]); + + // Data states - Fix type definitions + const [stats, setStats] = useState(null); + const [activeProjects, setActiveProjects] = useState([]); + const [pastProjects, setPastProjects] = useState([]); + const [comments, setComments] = useState([]); + + // Loading states + const [isLoadingStats, setIsLoadingStats] = useState(true); + const [isLoadingActive, setIsLoadingActive] = useState(true); + const [isLoadingPast, setIsLoadingPast] = useState(true); + const [isLoadingComments, setIsLoadingComments] = useState(true); + + // Modal states + const [commentToEdit, setCommentToEdit] = useState(null); + const [commentToDelete, setCommentToDelete] = useState(null); + + // Fetch data on initial load + useEffect(() => { + const fetchData = async () => { + try { + // Fetch categories + const categoriesData = await fetchCategories(); + setCategories(categoriesData); + + // Fetch stats + setIsLoadingStats(true); + const statsData = await fetchContributionStats(); + setStats(statsData); + setIsLoadingStats(false); + + // Fetch active projects + setIsLoadingActive(true); + const activeData = await fetchActiveProjects(); + setActiveProjects(activeData); + setIsLoadingActive(false); + + // Fetch past projects + setIsLoadingPast(true); + const pastData = await fetchPastProjects(); + setPastProjects(pastData); + setIsLoadingPast(false); + + // Fetch comments + setIsLoadingComments(true); + const commentsData = await fetchUserComments(); + setComments(commentsData); + setIsLoadingComments(false); + } catch (error) { + console.error("Error fetching data:", error); + toast.error("Error", { + description: "Failed to load your contributions. Please try again.", + }); + } + }; + + fetchData(); + }, []); + + // Fetch data when category filter changes + useEffect(() => { + const fetchFilteredData = async () => { + try { + if (activeTab === "all" || activeTab === "votes") { + // Fetch active projects with category filter + setIsLoadingActive(true); + const activeData = await fetchActiveProjects(categoryFilter); + setActiveProjects(activeData); + setIsLoadingActive(false); + + // Fetch past projects with category filter + setIsLoadingPast(true); + const pastData = await fetchPastProjects(categoryFilter); + setPastProjects(pastData); + setIsLoadingPast(false); + } + } catch (error) { + console.error("Error fetching filtered data:", error); + toast.error("Error", { + description: "Failed to load filtered data. Please try again.", + }); + } + }; + + fetchFilteredData(); + }, [categoryFilter, activeTab]); + + // Fetch comments when search query changes + useEffect(() => { + const fetchFilteredComments = async () => { + if (activeTab === "all" || activeTab === "comments") { + try { + setIsLoadingComments(true); + const commentsData = await fetchUserComments(searchQuery); + setComments(commentsData); + setIsLoadingComments(false); + } catch (error) { + console.error("Error fetching comments:", error); + toast.error("Error", { + description: "Failed to load comments. Please try again.", + }); + } + } + }; + + // Debounce search to avoid too many requests + const debounceTimeout = setTimeout(() => { + fetchFilteredComments(); + }, 500); + + return () => clearTimeout(debounceTimeout); + }, [searchQuery, activeTab]); + + // Sort data based on selected option + const sortedActiveProjects = activeProjects + ? sortActiveProjects(activeProjects, sortOption) + : []; + const sortedPastProjects = pastProjects + ? sortPastProjects(pastProjects, sortOption) + : []; + const sortedComments = comments ? sortComments(comments, sortOption) : []; + + // Navigation and action handlers + const navigateToProject = (projectId: string) => { + router.push(`/projects/${projectId}`); + }; + + // Fix the type to match what UserComments component expects + const handleEditComment = (commentId: string) => { + const comment = comments.find((c) => c.id === commentId); + if (comment) { + setCommentToEdit(comment); + } + }; + + const handleDeleteComment = (commentId: string) => { + setCommentToDelete(commentId); + }; + + const saveEditedComment = async (id: string, content: string) => { + try { + await apiEditComment(id, content); + + // Update local state + setComments((prevComments) => + prevComments.map((comment) => + comment.id === id ? { ...comment, content } : comment, + ), + ); + + toast.success("Success", { + description: "Comment updated successfully", + }); + } catch (error) { + console.error("Error updating comment:", error); + toast.error("Error", { + description: "Failed to update comment. Please try again.", + }); + throw error; // Re-throw to handle in the modal + } + }; + + const confirmDeleteComment = async () => { + if (!commentToDelete) return; + + try { + await apiDeleteComment(commentToDelete); + + // Update local state + setComments((prevComments) => + prevComments.filter((comment) => comment.id !== commentToDelete), + ); + + toast.success("Success", { + description: "Comment deleted successfully", + }); + } catch (error) { + console.error("Error deleting comment:", error); + toast.error("Error", { + description: "Failed to delete comment. Please try again.", + }); + throw error; // Re-throw to handle in the dialog + } + }; + + return ( +
+

My Contributions

+ + {/* Summary Section */} + {isLoadingStats ? ( + + ) : stats ? ( + + ) : null} + + {/* Tabs and Filters */} + + + {/* Active Contributions Section */} + {(activeTab === "all" || activeTab === "votes") && ( + <> +

Ongoing Contributions

+ {isLoadingActive ? ( + + ) : ( + + )} + + )} + + {/* Past Contributions Section */} + {(activeTab === "all" || activeTab === "votes") && ( + <> +

Past Contributions

+ {isLoadingPast ? ( + + ) : ( + + )} + + )} + + {/* Comments Section */} + {(activeTab === "all" || activeTab === "comments") && ( + <> +

My Comments

+ {isLoadingComments ? ( + + ) : ( + + )} + + )} + + {/* Call-to-Action Section */} + + + {/* Modals */} + setCommentToEdit(null)} + onSave={saveEditedComment} + /> + + setCommentToDelete(null)} + onConfirm={confirmDeleteComment} + /> +
+ ); +} diff --git a/app/api/user/comments/route.ts b/app/api/user/comments/route.ts new file mode 100644 index 00000000..77649e9a --- /dev/null +++ b/app/api/user/comments/route.ts @@ -0,0 +1,171 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import prisma from "@/lib/prisma"; +import { authOptions } from "@/lib/auth.config"; + +export async function GET(request: Request) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const search = searchParams.get("search") || ""; + const userId = session.user.id; + + // Get user comments with project details and reaction counts + const comments = await prisma.comment.findMany({ + where: { + userId, + OR: search + ? [ + { content: { contains: search, mode: "insensitive" } }, + { project: { title: { contains: search, mode: "insensitive" } } }, + ] + : undefined, + }, + include: { + project: { + select: { + id: true, + title: true, + }, + }, + reactions: { + select: { + id: true, + type: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + // Transform data to match the expected format + const transformedComments = comments.map((comment) => { + const likes = comment.reactions.filter((r) => r.type === "LIKE").length; + const dislikes = comment.reactions.filter( + (r) => r.type === "DISLIKE", + ).length; + + return { + id: comment.id, + projectId: comment.projectId, + projectName: comment.project.title, + content: comment.content, + createdAt: comment.createdAt.toISOString(), + likes, + dislikes, + }; + }); + + return NextResponse.json(transformedComments); + } catch (error) { + console.error("Error fetching user comments:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} + +// API endpoint for editing a comment +export async function PUT(request: Request) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = session.user.id; + const body = await request.json(); + const { id, content } = body; + + if (!id || !content) { + return NextResponse.json( + { error: "Comment ID and content are required" }, + { status: 400 }, + ); + } + + // Check if the comment belongs to the user + const comment = await prisma.comment.findUnique({ + where: { id }, + }); + + if (!comment || comment.userId !== userId) { + return NextResponse.json( + { error: "Comment not found or you don't have permission to edit it" }, + { status: 403 }, + ); + } + + // Update the comment + const updatedComment = await prisma.comment.update({ + where: { id }, + data: { content, updatedAt: new Date() }, + }); + + return NextResponse.json(updatedComment); + } catch (error) { + console.error("Error updating comment:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} + +// API endpoint for deleting a comment +export async function DELETE(request: Request) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = session.user.id; + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return NextResponse.json( + { error: "Comment ID is required" }, + { status: 400 }, + ); + } + + // Check if the comment belongs to the user + const comment = await prisma.comment.findUnique({ + where: { id }, + }); + + if (!comment || comment.userId !== userId) { + return NextResponse.json( + { + error: "Comment not found or you don't have permission to delete it", + }, + { status: 403 }, + ); + } + + // Delete the comment + await prisma.comment.delete({ + where: { id }, + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Error deleting comment:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/user/contributions/route.ts b/app/api/user/contributions/route.ts new file mode 100644 index 00000000..47da0404 --- /dev/null +++ b/app/api/user/contributions/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import prisma from "@/lib/prisma"; +import { authOptions } from "@/lib/auth.config"; + +// This endpoint returns statistics about a user's contributions +export async function GET() { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userId = session.user.id; + + // Get vote count + const votesCount = await prisma.vote.count({ + where: { userId }, + }); + + // Get comment count + const commentsCount = await prisma.comment.count({ + where: { userId }, + }); + + // Get ongoing votes (projects still in PENDING validation) + const ongoingVotedProjects = await prisma.vote.count({ + where: { + userId, + project: { + ideaValidation: "PENDING", + }, + }, + }); + + // Get completed votes (projects that are VALIDATED or REJECTED) + const completedVotedProjects = await prisma.vote.count({ + where: { + userId, + project: { + OR: [{ ideaValidation: "VALIDATED" }, { ideaValidation: "REJECTED" }], + }, + }, + }); + + // Get successful projects (VALIDATED) the user voted on + const successfulProjects = await prisma.vote.count({ + where: { + userId, + project: { + ideaValidation: "VALIDATED", + }, + }, + }); + + // Get rejected projects the user voted on + const rejectedProjects = await prisma.vote.count({ + where: { + userId, + project: { + ideaValidation: "REJECTED", + }, + }, + }); + + // Get funded projects (those with blockchainTx) the user voted on + const fundedProjects = await prisma.vote.count({ + where: { + userId, + project: { + blockchainTx: { not: null }, + }, + }, + }); + + const stats = { + totalContributions: votesCount + commentsCount, + votesCount, + commentsCount, + ongoingVotes: ongoingVotedProjects, + completedVotes: completedVotedProjects, + successfulProjects, + rejectedProjects, + fundedProjects, + }; + + return NextResponse.json(stats); + } catch (error) { + console.error("Error fetching user contributions:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/user/votes/route.ts b/app/api/user/votes/route.ts new file mode 100644 index 00000000..db73a6e0 --- /dev/null +++ b/app/api/user/votes/route.ts @@ -0,0 +1,113 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth/next"; +import prisma from "@/lib/prisma"; +import { authOptions } from "@/lib/auth.config"; +import type { Prisma } from "@prisma/client"; + +export async function GET(request: Request) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const status = searchParams.get("status"); // 'active' or 'past' + const category = searchParams.get("category"); + const userId = session.user.id; + + // Define where conditions based on status + // Create a properly typed where condition + const whereCondition: Prisma.VoteFindManyArgs["where"] = { + userId, + }; + + if (status === "active") { + whereCondition.project = { + ideaValidation: "PENDING", + ...(category && category !== "all" ? { category } : {}), + }; + } else if (status === "past") { + whereCondition.project = { + OR: [{ ideaValidation: "VALIDATED" }, { ideaValidation: "REJECTED" }], + ...(category && category !== "all" ? { category } : {}), + }; + } else if (category && category !== "all") { + whereCondition.project = { category }; + } + + // Get votes with project details + const votes = await prisma.vote.findMany({ + where: whereCondition, + include: { + project: { + include: { + _count: { + select: { + votes: true, + comments: true, + }, + }, + comments: { + where: { + userId, + }, + select: { + id: true, + }, + }, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + const transformedData = votes.map((vote) => { + const project = vote.project; + const userComments = project.comments.length; + + if (project.ideaValidation === "PENDING") { + // Active project format + return { + id: project.id, + name: project.title, + category: project.category, + currentVotes: project._count.votes, + requiredVotes: 1000, // This should be a configurable value + userVoted: true, + userComments, + timeLeft: "N/A", // You might calculate this based on creation date + image: + project.profileUrl || + project.bannerUrl || + "/placeholder.svg?height=100&width=100", + }; + } + + // Past project format + return { + id: project.id, + name: project.title, + category: project.category, + finalVotes: project._count.votes, + requiredVotes: 1000, // This should be a configurable value + passed: project.ideaValidation === "VALIDATED", + userVoted: true, + userComments, + completedDate: project.createdAt.toISOString(), + funded: !!project.blockchainTx, + }; + }); + + return NextResponse.json(transformedData); + } catch (error) { + console.error("Error fetching user votes:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/components/contributions/active-contribution.tsx b/components/contributions/active-contribution.tsx new file mode 100644 index 00000000..7757ea0c --- /dev/null +++ b/components/contributions/active-contribution.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { Check, X } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import type { ActiveProject } from "@/types/contributions"; + +interface ActiveContributionsProps { + projects: ActiveProject[]; + navigateToProject: (projectId: string) => void; +} + +export function ActiveContributions({ + projects, + navigateToProject, +}: ActiveContributionsProps) { + if (projects.length === 0) { + return ( +
+

No active contributions found.

+
+ ); + } + + return ( +
+ {projects.map((project) => ( + + +
+
+ {project.name} + + + {project.category} + + +
+ +
+
+ +
+
+
+ Voting Progress + + {project.currentVotes}/{project.requiredVotes} + +
+ +
+
+
+ {project.userVoted && ( + + Voted + + )} + {project.userRejected && ( + + Rejected + + )} +
+ {project.userComments > 0 && ( + + {project.userComments} Comment + {project.userComments > 1 ? "s" : ""} + + )} +
+
+
+ +
+ + {project.timeLeft} left + + +
+
+
+ ))} +
+ ); +} diff --git a/components/contributions/call-to-action.tsx b/components/contributions/call-to-action.tsx new file mode 100644 index 00000000..d3f6a977 --- /dev/null +++ b/components/contributions/call-to-action.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export function CallToAction() { + const router = useRouter(); + + return ( +
+ + + Discover New Projects + + Find new projects to vote on and contribute to the community. + + + + + + + + + + Manage Your Projects + + View and manage the projects you've created. + + + + + + +
+ ); +} diff --git a/components/contributions/comment-edit-modal.tsx b/components/contributions/comment-edit-modal.tsx new file mode 100644 index 00000000..958a0e01 --- /dev/null +++ b/components/contributions/comment-edit-modal.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import type { UserComment } from "@/types/contributions"; + +interface CommentEditModalProps { + comment: UserComment | null; + isOpen: boolean; + onClose: () => void; + onSave: (id: string, content: string) => Promise; +} + +export function CommentEditModal({ + comment, + isOpen, + onClose, + onSave, +}: CommentEditModalProps) { + const [content, setContent] = useState(comment?.content || ""); + const [isSubmitting, setIsSubmitting] = useState(false); + + // Update content when comment changes + useState(() => { + if (comment) { + setContent(comment.content); + } + }); + + const handleSave = async () => { + if (!comment) return; + + setIsSubmitting(true); + try { + await onSave(comment.id, content); + onClose(); + } catch (error) { + console.error("Failed to save comment:", error); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + Edit Comment + + Edit your comment for {comment?.projectName} + + +
+