From 8d42bf8cd9df60e211687b6ba57fd7b5bc957d8b Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Tue, 18 Feb 2025 14:53:52 +0100 Subject: [PATCH 01/11] fix: callback urls for google auth and github auth --- app/api/auth/[...nextauth]/auth.ts | 84 ++++++++++++++++++------------ components/email-template.tsx | 5 +- components/signin-buttons.tsx | 2 +- 3 files changed, 56 insertions(+), 35 deletions(-) diff --git a/app/api/auth/[...nextauth]/auth.ts b/app/api/auth/[...nextauth]/auth.ts index 64ee8aeb..018e7c37 100644 --- a/app/api/auth/[...nextauth]/auth.ts +++ b/app/api/auth/[...nextauth]/auth.ts @@ -1,16 +1,29 @@ -import type { NextAuthOptions } from "next-auth" -import CredentialsProvider from "next-auth/providers/credentials" -import { PrismaAdapter } from "@next-auth/prisma-adapter" -import GoogleProvider from "next-auth/providers/google" -import GitHubProvider from "next-auth/providers/github" -import { compare } from "bcrypt" -import { PrismaClient } from "@prisma/client" +import { NextAuthOptions } from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; +import GoogleProvider from "next-auth/providers/google"; +import GitHubProvider from "next-auth/providers/github"; +import { PrismaAdapter } from "@next-auth/prisma-adapter"; +import { PrismaClient } from "@prisma/client"; +import { compare } from "bcrypt"; -const prisma = new PrismaClient() +const prisma = new PrismaClient(); export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(prisma), providers: [ + // Google OAuth Provider + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + }), + + // GitHub OAuth Provider + GitHubProvider({ + clientId: process.env.GITHUB_ID!, + clientSecret: process.env.GITHUB_SECRET!, + }), + + // Credentials Provider (Email/Password) CredentialsProvider({ name: "Credentials", credentials: { @@ -19,24 +32,32 @@ export const authOptions: NextAuthOptions = { }, async authorize(credentials) { if (!credentials?.email || !credentials?.password) { - throw new Error("Please provide both email and password") + throw new Error("Please enter both email and password"); } + // Check if user exists const user = await prisma.user.findUnique({ where: { email: credentials.email }, - }) + }); + + if (!user) { + throw new Error("No user found with this email"); + } - if (!user || !user.password) { - throw new Error("Invalid credentials") + // Ensure password exists (OAuth users don’t have one) + if (!user.password) { + throw new Error("Please sign in using Google or GitHub"); } - const isPasswordValid = await compare(credentials.password, user.password) + // Validate password + const isPasswordValid = await compare(credentials.password, user.password); if (!isPasswordValid) { - throw new Error("Invalid credentials") + throw new Error("Invalid password"); } + // Ensure email is verified before login if (!user.emailVerified) { - throw new Error("UNVERIFIED_EMAIL") + throw new Error("UNVERIFIED_EMAIL"); } return { @@ -46,38 +67,37 @@ export const authOptions: NextAuthOptions = { image: user.image, role: user.role, emailVerified: user.emailVerified, - } + }; }, }), - GoogleProvider({ - clientId: process.env.GOOGLE_CLIENT_ID!, - clientSecret: process.env.GOOGLE_CLIENT_SECRET!, - }), - GitHubProvider({ - clientId: process.env.GITHUB_ID!, - clientSecret: process.env.GITHUB_SECRET!, - }), ], + + session: { + strategy: "jwt", + }, + callbacks: { + async redirect({ url, baseUrl }) { + return url.startsWith(baseUrl) ? url : `${baseUrl}/projects/12`; + }, async jwt({ token, user }) { if (user) { - token.role = user.role + token.role = user.role; } - return token + return token; }, async session({ session, token }) { if (session.user) { - session.user.role = token.role + session.user.role = token.role; } - return session + return session; }, }, + pages: { signIn: "/auth/signin", error: "/auth/error", }, - session: { - strategy: "jwt", - }, -} + secret: process.env.NEXTAUTH_SECRET, +}; diff --git a/components/email-template.tsx b/components/email-template.tsx index 567ea34d..b4554ce6 100644 --- a/components/email-template.tsx +++ b/components/email-template.tsx @@ -1,4 +1,5 @@ -import Image from "next/image"; +// "use server" +// import Image from "next/image"; import * as React from "react"; interface EmailTemplateProps { @@ -22,7 +23,7 @@ export const EmailTemplate: React.FC> = ({ }}>
- Boundless Logo + Boundless Logo

Hello, {name || "Boundless User"}

{otp && ( diff --git a/components/signin-buttons.tsx b/components/signin-buttons.tsx index 17715211..bc2a5fc8 100644 --- a/components/signin-buttons.tsx +++ b/components/signin-buttons.tsx @@ -15,7 +15,7 @@ export default function SignInButtons({ providers }: SignInButtonsProps) { return (
+
+ ); }; -type ExploreFilter = "newest" | "popular" | "ending"; -type CompletedSort = "date" | "size" | "category"; - -export default function Page() { - const [exploreFilter, setExploreFilter] = useState("newest"); - const [completedSort, setCompletedSort] = useState("date"); - - const allExploreProjects = [...myProjects, ...trendingProjects, ...exploreProjects]; - - const getFilteredExploreProjects = () => { - if (exploreFilter === "newest") { - return exploreProjects; - } else if (exploreFilter === "popular") { - return [...allExploreProjects] - .sort((a, b) => b.progress - a.progress) - .slice(0, 3); - } else if (exploreFilter === "ending") { - const projectsWithDays = [...myProjects, ...trendingProjects] - .sort((a, b) => a.daysLeft - b.daysLeft) - .slice(0, 3); - return projectsWithDays; - } - - return exploreProjects; - }; - - const filteredExploreProjects = getFilteredExploreProjects(); - - const sortedCompletedProjects = [...completedProjects].sort((a, b) => { - if (completedSort === "date") { - return new Date(b.completionDate).getTime() - new Date(a.completionDate).getTime(); - } else if (completedSort === "size") { - const aValue = parseFloat(a.totalRaised.replace(/[^0-9.-]+/g, "")); - const bValue = parseFloat(b.totalRaised.replace(/[^0-9.-]+/g, "")); - return bValue - aValue; - } else if (completedSort === "category") { - return a.category.localeCompare(b.category); - } - return 0; - }); - - const isProjectWithDays = (project: BaseProject): project is ProjectWithDays => { - return 'daysLeft' in project; - }; - - return ( -
-
-
-

Dashboard

- -
- -
- - - Total Contributions - - -
$24,780
-

+12% from last month

-
-
- - - Active Projects - - -
7
-

3 pending milestone approval

-
-
- - - Successful Exits - - -
12
-

Total value: $3.2M

-
-
- - - ROI - - -
+32%
-

Avg. across all projects

-
-
-
- -
- - - Activity Overview - - -
- - - - - - - - - - - - - - - value >= 1000 ? `${value/1000}k` : value} - /> - } /> - - - - - -
-
-
- - - Recent Activity - - -
-
-
-
-

Milestone approved

-

2 hours ago

-
-
-
-
-
-

New contribution

-

5 hours ago

-
-
-
-
-
-

Project update

-

Yesterday

-
-
-
-
-
-

Voted on proposal

-

2 days ago

-
-
-
-
-
-
- - - - - - My Projects - - - - Trending - - - - Explore - - - - Completed - - - - -
- {myProjects.map((project) => ( - - -
- {project.title} - {project.category} -
-
- -
-
-
- Progress - {project.progress}% -
- -
-
-
-

Raised

-

{project.raised}

-
-
-

Goal

-

{project.goal}

-
-
-
- - {project.daysLeft} days remaining -
- - - -
-
-
- ))} -
-
- - -
-

Highest Engagement Past 7 Days

-
-
- {trendingProjects.map((project) => ( - - -
- {project.title} - {project.category} -
-
- -
-
-
- Progress - {project.progress}% -
- -
-
-
-

Raised

-

{project.raised}

-
-
-

Goal

-

{project.goal}

-
-
-
- Engagement - {project.engagementChange} -
- - - -
-
-
- ))} -
-
- - -
-

- {exploreFilter === "newest" ? "Newest Projects" : - exploreFilter === "popular" ? "Most Popular Projects" : - "Projects Ending Soon"} -

- - - - - - setExploreFilter("newest")}> - Newest - - setExploreFilter("popular")}> - Most Popular - - setExploreFilter("ending")}> - Ending Soon - - - -
-
- {filteredExploreProjects.map((project) => ( - - -
- {project.title} - {project.category} -
-
- -
-
-
- Progress - {project.progress}% -
- -
-
-
-

Raised

-

{project.raised}

-
-
-

Goal

-

{project.goal}

-
-
- {isProjectWithDays(project) && ( -
- - - {project.daysLeft} days remaining - -
- )} - - - -
-
-
- ))} -
-
- - -
-

Successfully Completed Projects

-
- - - -
-
-
- {sortedCompletedProjects.map((project) => ( - - -
- {project.title} - {project.category} -
-
- -
-
-
-

Total Raised

-

{project.totalRaised}

-
-
-

Contributors

-

{project.contributors}

-
-
-
- - Completed on {project.completionDate} -
- - - -
-
-
- ))} -
-
-
-
-
- ); -} \ No newline at end of file +export default Page; diff --git a/app/projects/new/page.tsx b/app/projects/new/page.tsx index 8e18cb4c..15d47ba7 100644 --- a/app/projects/new/page.tsx +++ b/app/projects/new/page.tsx @@ -1,7 +1,7 @@ import { ProjectForm } from "@/components/project-form"; import React from "react"; import { getServerSession } from "next-auth/next"; -import { authOptions } from "@/app/api/auth/[...nextauth]/auth"; +import { authOptions } from "@/lib/auth.config"; const Page = async () => { const session = await getServerSession(authOptions); diff --git a/components/dashboard/activity-overview.tsx b/components/dashboard/activity-overview.tsx new file mode 100644 index 00000000..c03e5daa --- /dev/null +++ b/components/dashboard/activity-overview.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts"; +import { activityData } from "@/data/mock-data"; +import CustomTooltip from "./custom-tooltip"; +import RecentActivity from "./recent-activity"; + +export default function ActivityOverview() { + return ( +
+ + + Activity Overview + + +
+ + + + + + + + + + + + + + + + value >= 1000 ? `${value / 1000}k` : value + } + /> + } /> + + + + + +
+
+
+ + + Recent Activity + + + + + +
+ ); +} diff --git a/components/dashboard/completed-project-card.tsx b/components/dashboard/completed-project-card.tsx new file mode 100644 index 00000000..a2af0136 --- /dev/null +++ b/components/dashboard/completed-project-card.tsx @@ -0,0 +1,59 @@ +"use client"; + +import Link from "next/link"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { CheckCircle } from "lucide-react"; +import type { CompletedProject } from "@/types/project"; +import { motion } from "framer-motion"; + +interface CompletedProjectCardProps { + project: CompletedProject; +} + +export default function CompletedProjectCard({ + project, +}: CompletedProjectCardProps) { + return ( + + + +
+ {project.title} + {project.category} +
+
+ +
+
+
+

Total Raised

+

{project.totalRaised}

+
+
+

Contributors

+

{project.contributors}

+
+
+
+ + + Completed on {project.completionDate} + +
+ + + +
+
+
+
+ ); +} diff --git a/components/dashboard/custom-tooltip.tsx b/components/dashboard/custom-tooltip.tsx new file mode 100644 index 00000000..604562d0 --- /dev/null +++ b/components/dashboard/custom-tooltip.tsx @@ -0,0 +1,31 @@ +import type { TooltipProps } from "recharts"; + +const CustomTooltip = ({ + active, + payload, + label, +}: TooltipProps) => { + if (active && payload && payload.length) { + return ( +
+

{label}

+ {payload.map((entry) => ( +
+
+ {entry.dataKey}: + {entry.value?.toLocaleString()} +
+ ))} +
+ ); + } + return null; +}; + +export default CustomTooltip; diff --git a/components/dashboard/index.tsx b/components/dashboard/index.tsx new file mode 100644 index 00000000..10a671c4 --- /dev/null +++ b/components/dashboard/index.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { useState } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { Wallet, TrendingUp, Compass, CheckCircle } from "lucide-react"; +import { AnimatePresence } from "framer-motion"; + +import type { CompletedSort, ExploreFilter } from "@/types/project"; +import { + myProjects, + trendingProjects, + exploreProjects, + completedProjects, +} from "@/data/mock-data"; + +import StatsCards from "./stats-cards"; +import ActivityOverview from "./activity-overview"; +import MyProjectsTab from "./tabs/my-projects-tab"; +import TrendingTab from "./tabs/trending-tab"; +import ExploreTab from "./tabs/explore-tab"; +import CompletedTab from "./tabs/completed-tab"; + +export default function Dashboard() { + const [exploreFilter, setExploreFilter] = useState("newest"); + const [completedSort, setCompletedSort] = useState("date"); + const [activeTab, setActiveTab] = useState("myprojects"); + + const allExploreProjects = [ + ...myProjects, + ...trendingProjects, + ...exploreProjects, + ]; + + const getFilteredExploreProjects = () => { + if (exploreFilter === "newest") { + return exploreProjects; + } + + if (exploreFilter === "popular") { + return [...allExploreProjects] + .sort((a, b) => b.progress - a.progress) + .slice(0, 3); + } + + if (exploreFilter === "ending") { + const projectsWithDays = [...myProjects, ...trendingProjects] + .sort((a, b) => a.daysLeft - b.daysLeft) + .slice(0, 3); + return projectsWithDays; + } + + return exploreProjects; + }; + + const filteredExploreProjects = getFilteredExploreProjects(); + + const sortedCompletedProjects = [...completedProjects].sort((a, b) => { + if (completedSort === "date") { + return ( + new Date(b.completionDate).getTime() - + new Date(a.completionDate).getTime() + ); + } + + if (completedSort === "size") { + const aValue = Number.parseFloat(a.totalRaised.replace(/[^0-9.-]+/g, "")); + const bValue = Number.parseFloat(b.totalRaised.replace(/[^0-9.-]+/g, "")); + return bValue - aValue; + } + + if (completedSort === "category") { + return a.category.localeCompare(b.category); + } + + return 0; + }); + + return ( +
+
+
+

Dashboard

+ +
+ + + + + + + + + My Projects + + + + Trending + + + + Explore + + + + Completed + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} diff --git a/components/dashboard/project-card.tsx b/components/dashboard/project-card.tsx new file mode 100644 index 00000000..4af12d33 --- /dev/null +++ b/components/dashboard/project-card.tsx @@ -0,0 +1,71 @@ +"use client"; + +import Link from "next/link"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { Clock } from "lucide-react"; +import type { BaseProject, ProjectWithDays } from "@/types/project"; +import { motion } from "framer-motion"; + +interface ProjectCardProps { + project: BaseProject; +} + +export default function ProjectCard({ project }: ProjectCardProps) { + const isProjectWithDays = ( + project: BaseProject, + ): project is ProjectWithDays => { + return "daysLeft" in project; + }; + + return ( + + + +
+ {project.title} + {project.category} +
+
+ +
+
+
+ Progress + {project.progress}% +
+ +
+
+
+

Raised

+

{project.raised}

+
+
+

Goal

+

{project.goal}

+
+
+ {isProjectWithDays(project) && ( +
+ + + {project.daysLeft} days remaining + +
+ )} + + + +
+
+
+
+ ); +} diff --git a/components/dashboard/recent-activity.tsx b/components/dashboard/recent-activity.tsx new file mode 100644 index 00000000..e3458c42 --- /dev/null +++ b/components/dashboard/recent-activity.tsx @@ -0,0 +1,34 @@ +export default function RecentActivity() { + return ( +
+
+
+
+

Milestone approved

+

2 hours ago

+
+
+
+
+
+

New contribution

+

5 hours ago

+
+
+
+
+
+

Project update

+

Yesterday

+
+
+
+
+
+

Voted on proposal

+

2 days ago

+
+
+
+ ); +} diff --git a/components/dashboard/stats-cards.tsx b/components/dashboard/stats-cards.tsx new file mode 100644 index 00000000..69e81e64 --- /dev/null +++ b/components/dashboard/stats-cards.tsx @@ -0,0 +1,52 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; + +export default function StatsCards() { + return ( +
+ + + + Total Contributions + + + +
$24,780
+

+12% from last month

+
+
+ + + Active Projects + + +
7
+

+ 3 pending milestone approval +

+
+
+ + + + Successful Exits + + + +
12
+

Total value: $3.2M

+
+
+ + + ROI + + +
+32%
+

+ Avg. across all projects +

+
+
+
+ ); +} diff --git a/components/dashboard/tabs/completed-tab.tsx b/components/dashboard/tabs/completed-tab.tsx new file mode 100644 index 00000000..fa48f36b --- /dev/null +++ b/components/dashboard/tabs/completed-tab.tsx @@ -0,0 +1,64 @@ +"use client"; + +import type { Dispatch, SetStateAction } from "react"; +import { Button } from "@/components/ui/button"; +import { ArrowUpDown } from "lucide-react"; +import type { CompletedProject, CompletedSort } from "@/types/project"; +import CompletedProjectCard from "../completed-project-card"; +import { motion } from "framer-motion"; + +interface CompletedTabProps { + projects: CompletedProject[]; + sort: CompletedSort; + setSort: Dispatch>; +} + +export default function CompletedTab({ + projects, + sort, + setSort, +}: CompletedTabProps) { + return ( + +
+

+ Successfully Completed Projects +

+
+ + + +
+
+
+ {projects.map((project) => ( + + ))} +
+
+ ); +} diff --git a/components/dashboard/tabs/explore-tab.tsx b/components/dashboard/tabs/explore-tab.tsx new file mode 100644 index 00000000..97b0c0d7 --- /dev/null +++ b/components/dashboard/tabs/explore-tab.tsx @@ -0,0 +1,74 @@ +"use client"; + +import type { Dispatch, SetStateAction } from "react"; +import { Button } from "@/components/ui/button"; +import { Filter } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { BaseProject, ExploreFilter } from "@/types/project"; +import ProjectCard from "../project-card"; +import { motion } from "framer-motion"; + +interface ExploreTabProps { + projects: BaseProject[]; + filter: ExploreFilter; + setFilter: Dispatch>; +} + +export default function ExploreTab({ + projects, + filter, + setFilter, +}: ExploreTabProps) { + return ( + +
+

+ {filter === "newest" + ? "Newest Projects" + : filter === "popular" + ? "Most Popular Projects" + : "Projects Ending Soon"} +

+ + + + + + setFilter("newest")}> + Newest + + setFilter("popular")}> + Most Popular + + setFilter("ending")}> + Ending Soon + + + +
+
+ {projects.map((project) => ( + + ))} +
+
+ ); +} diff --git a/components/dashboard/tabs/my-projects-tab.tsx b/components/dashboard/tabs/my-projects-tab.tsx new file mode 100644 index 00000000..6e02c1ea --- /dev/null +++ b/components/dashboard/tabs/my-projects-tab.tsx @@ -0,0 +1,25 @@ +"use client"; + +import type { ProjectWithDays } from "@/types/project"; +import ProjectCard from "../project-card"; +import { motion } from "framer-motion"; + +interface MyProjectsTabProps { + projects: ProjectWithDays[]; +} + +export default function MyProjectsTab({ projects }: MyProjectsTabProps) { + return ( + + {projects.map((project) => ( + + ))} + + ); +} diff --git a/components/dashboard/tabs/trending-tab.tsx b/components/dashboard/tabs/trending-tab.tsx new file mode 100644 index 00000000..d517a881 --- /dev/null +++ b/components/dashboard/tabs/trending-tab.tsx @@ -0,0 +1,31 @@ +"use client"; + +import type { TrendingProject } from "@/types/project"; +import TrendingProjectCard from "../trending-projects-card"; +import { motion } from "framer-motion"; + +interface TrendingTabProps { + projects: TrendingProject[]; +} + +export default function TrendingTab({ projects }: TrendingTabProps) { + return ( + +
+

+ Highest Engagement Past 7 Days +

+
+
+ {projects.map((project) => ( + + ))} +
+
+ ); +} diff --git a/components/dashboard/trending-projects-card.tsx b/components/dashboard/trending-projects-card.tsx new file mode 100644 index 00000000..ffc22ee1 --- /dev/null +++ b/components/dashboard/trending-projects-card.tsx @@ -0,0 +1,64 @@ +"use client"; + +import Link from "next/link"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import type { TrendingProject } from "@/types/project"; +import { motion } from "framer-motion"; + +interface TrendingProjectCardProps { + project: TrendingProject; +} + +export default function TrendingProjectCard({ + project, +}: TrendingProjectCardProps) { + return ( + + + +
+ {project.title} + {project.category} +
+
+ +
+
+
+ Progress + {project.progress}% +
+ +
+
+
+

Raised

+

{project.raised}

+
+
+

Goal

+

{project.goal}

+
+
+
+ Engagement + + {project.engagementChange} + +
+ + + +
+
+
+
+ ); +} diff --git a/data/mock-data.ts b/data/mock-data.ts new file mode 100644 index 00000000..c544d9ac --- /dev/null +++ b/data/mock-data.ts @@ -0,0 +1,146 @@ +import type { + ActivityDataPoint, + BaseProject, + CompletedProject, + ProjectWithDays, + TrendingProject, +} from "@/types/project"; + +export const activityData: ActivityDataPoint[] = [ + { name: "Jan", contributions: 400, participants: 250 }, + { name: "Feb", contributions: 300, participants: 220 }, + { name: "Mar", contributions: 600, participants: 320 }, + { name: "Apr", contributions: 800, participants: 480 }, + { name: "May", contributions: 1500, participants: 520 }, + { name: "Jun", contributions: 2000, participants: 590 }, + { name: "Jul", contributions: 2400, participants: 650 }, +]; + +export const myProjects: ProjectWithDays[] = [ + { + id: 1, + title: "Decentralized Finance Platform", + progress: 70, + raised: "$70,000", + goal: "$100,000", + daysLeft: 12, + category: "Finance", + href: "/projects/my-projects", + }, + { + id: 2, + title: "Sustainable Energy Marketplace", + progress: 45, + raised: "$22,500", + goal: "$50,000", + daysLeft: 18, + category: "Environment", + href: "/projects/my-projects", + }, + { + id: 3, + title: "Community-Owned Media Network", + progress: 90, + raised: "$180,000", + goal: "$200,000", + daysLeft: 3, + category: "Media", + href: "/projects/my-projects", + }, +]; + +export const trendingProjects: TrendingProject[] = [ + { + id: 1, + title: "AI-Powered Healthcare Assistant", + progress: 85, + raised: "$425,000", + goal: "$500,000", + engagementChange: "+27%", + category: "Healthcare", + daysLeft: 21, + href: "/projects/explore", + }, + { + id: 2, + title: "Decentralized Social Network", + progress: 62, + raised: "$310,000", + goal: "$500,000", + engagementChange: "+18%", + category: "Social", + daysLeft: 14, + href: "/projects/explore", + }, + { + id: 3, + title: "Regenerative Agriculture Platform", + progress: 54, + raised: "$108,000", + goal: "$200,000", + engagementChange: "+14%", + category: "Agriculture", + daysLeft: 30, + href: "/projects/explore", + }, +]; + +export const exploreProjects: BaseProject[] = [ + { + id: 4, + title: "Open Source Educational Platform", + progress: 35, + raised: "$70,000", + goal: "$200,000", + category: "Education", + href: "/projects/explore", + }, + { + id: 5, + title: "Community Art Initiative", + progress: 25, + raised: "$12,500", + goal: "$50,000", + category: "Art", + href: "/projects/explore", + }, + { + id: 6, + title: "Mental Health Resources Network", + progress: 45, + raised: "$90,000", + goal: "$200,000", + category: "Health", + href: "/projects/explore", + }, +]; + +export const completedProjects: CompletedProject[] = [ + { + id: 1, + title: "Community Solar Initiative", + totalRaised: "$750,000", + contributors: 1253, + completionDate: "Jan 15, 2024", + category: "Energy", + href: "/projects/funded", + }, + { + id: 2, + title: "Open Source Governance Tools", + totalRaised: "$320,000", + contributors: 784, + completionDate: "Feb 02, 2024", + category: "Governance", + href: "/projects/funded", + }, + { + id: 3, + title: "Urban Vertical Farming Network", + totalRaised: "$1,200,000", + contributors: 3214, + completionDate: "Feb 10, 2024", + category: "Agriculture", + href: "/projects/funded", + }, +]; diff --git a/package-lock.json b/package-lock.json index 01806a43..6d53d986 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,6 @@ "": { "name": "boundless", "version": "0.1.0", - "hasInstallScript": true, "dependencies": { "@auth/prisma-adapter": "^2.7.4", "@creit.tech/stellar-wallets-kit": "^1.4.1", diff --git a/types/project.ts b/types/project.ts new file mode 100644 index 00000000..82942c5d --- /dev/null +++ b/types/project.ts @@ -0,0 +1,36 @@ +export interface BaseProject { + id: number; + title: string; + progress: number; + raised: string; + goal: string; + category: string; + href: string; +} + +export interface ProjectWithDays extends BaseProject { + daysLeft: number; +} + +export interface TrendingProject extends ProjectWithDays { + engagementChange: string; +} + +export interface CompletedProject { + id: number; + title: string; + totalRaised: string; + contributors: number; + completionDate: string; + category: string; + href: string; +} + +export interface ActivityDataPoint { + name: string; + contributions: number; + participants: number; +} + +export type ExploreFilter = "newest" | "popular" | "ending"; +export type CompletedSort = "date" | "size" | "category"; From dac20e49e4c10a6c89109746d12fda10bdbfcd58 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Mon, 3 Mar 2025 02:10:33 +0100 Subject: [PATCH 06/11] refactor: refactor dashboard --- app/api/projects/[id]/vote/route.ts | 63 ----------------------------- 1 file changed, 63 deletions(-) delete mode 100644 app/api/projects/[id]/vote/route.ts diff --git a/app/api/projects/[id]/vote/route.ts b/app/api/projects/[id]/vote/route.ts deleted file mode 100644 index f7ab733b..00000000 --- a/app/api/projects/[id]/vote/route.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server" -import { getServerSession } from "next-auth" -import { authOptions } from "@/lib/auth.config" -import { prisma } from "@/lib/prisma" - -export async function POST(request: NextRequest, { params }: { params: { id: string } }) { - try { - const session = await getServerSession(authOptions) - - if (!session?.user?.email) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) - } - - // Get user ID from email since we're using JWT strategy - const user = await prisma.user.findUnique({ - where: { - email: session.user.email, - }, - select: { - id: true, - }, - }) - - if (!user) { - return NextResponse.json({ error: "User not found" }, { status: 404 }) - } - - // Check if user has already voted - const existingVote = await prisma.vote.findUnique({ - where: { - projectId_userId: { - projectId: params.id, - userId: user.id, - }, - }, - }) - - if (existingVote) { - // Remove vote if it exists - await prisma.vote.delete({ - where: { - id: existingVote.id, - }, - }) - - return NextResponse.json({ message: "Vote removed" }) - } - - // Add new vote - await prisma.vote.create({ - data: { - projectId: params.id, - userId: user.id, - }, - }) - - return NextResponse.json({ message: "Vote added" }) - } catch (error) { - console.error("Vote error:", error) - return NextResponse.json({ error: "Internal Server Error" }, { status: 500 }) - } -} - From bc2344f3f0e4cee7c085b3132801bf47fb133f99 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Mon, 3 Mar 2025 02:11:16 +0100 Subject: [PATCH 07/11] refactor: refactor dashboard --- app/api/projects/route.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index 227c9f34..f1f24e68 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -27,7 +27,10 @@ export async function POST(request: Request) { ); } - const project = await prisma.project.create({ + const project = await prisma.pconsole.log("Received POST request to create project"); +console.log("Session:", session); +console.log("Request JSON:", title, description, fundingGoal, category, bannerPath, profilePath); +console.log("Created project:", project);roject.create({ data: { userId: session.user.id, title, From 419d0c7cb8083e38631784a841c3afe21380b1f1 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Mon, 3 Mar 2025 02:11:48 +0100 Subject: [PATCH 08/11] refactor: refactor dashboard --- app/api/projects/route.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index f1f24e68..227c9f34 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -27,10 +27,7 @@ export async function POST(request: Request) { ); } - const project = await prisma.pconsole.log("Received POST request to create project"); -console.log("Session:", session); -console.log("Request JSON:", title, description, fundingGoal, category, bannerPath, profilePath); -console.log("Created project:", project);roject.create({ + const project = await prisma.project.create({ data: { userId: session.user.id, title, From a83d6fb5fb6dc9a176aeaf0da8c13c7c7e25e2b2 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Sat, 8 Mar 2025 14:15:23 +0100 Subject: [PATCH 09/11] implement voting mechanism --- .env.example | 6 + app/(dashboard)/projects/[id]/page.tsx | 240 ++++++++++++------ .../projects/[id]/project-actions.tsx | 11 +- .../projects/[id]/team-section.tsx | 177 +++++++++++++ .../projects/[id]/voting-section.tsx | 166 ++++++++++++ app/(dashboard)/projects/page.tsx | 15 +- app/api/projects/[id]/route.ts | 69 +++++ app/api/projects/[id]/vote/route.ts | 120 +++++++++ app/api/projects/create/route.ts | 90 +++++++ app/api/projects/route.ts | 70 ++--- app/dashboard/page.tsx | 8 - components/connect-wallet.tsx | 79 ++++-- components/project-form.tsx | 86 ++++++- components/projects/project-list.tsx | 182 +++++++++++++ components/registeration-form.tsx | 183 ++++++------- components/shared/vote-button.tsx | 87 +++++++ components/sidebar.tsx | 233 +++++++++-------- lib/actions/vote.ts | 154 ++++++----- lib/utils.ts | 14 +- next.config.ts | 8 +- package-lock.json | 150 +++++------ package.json | 3 +- .../20250211123141_boundless/migration.sql | 88 ------- .../20250212211904_boundless/migration.sql | 2 - .../20250220000001_boundless/migration.sql | 35 --- .../20250222130151_benji/migration.sql | 18 -- .../migration.sql | 170 +++++++++++++ prisma/schema.prisma | 64 +++-- prisma/seed.js | 107 ++++++++ prisma/seed.ts | 29 --- tsconfig.json | 3 +- types/project.ts | 43 ++++ 32 files changed, 2010 insertions(+), 700 deletions(-) create mode 100644 app/(dashboard)/projects/[id]/team-section.tsx create mode 100644 app/(dashboard)/projects/[id]/voting-section.tsx create mode 100644 app/api/projects/[id]/route.ts create mode 100644 app/api/projects/[id]/vote/route.ts create mode 100644 app/api/projects/create/route.ts delete mode 100644 app/dashboard/page.tsx create mode 100644 components/projects/project-list.tsx create mode 100644 components/shared/vote-button.tsx delete mode 100644 prisma/migrations/20250211123141_boundless/migration.sql delete mode 100644 prisma/migrations/20250212211904_boundless/migration.sql delete mode 100644 prisma/migrations/20250220000001_boundless/migration.sql delete mode 100644 prisma/migrations/20250222130151_benji/migration.sql create mode 100644 prisma/migrations/20250307015524_add_idea_validation_and_team_members/migration.sql create mode 100644 prisma/seed.js delete mode 100644 prisma/seed.ts diff --git a/.env.example b/.env.example index 82b3af2d..6fecbcaf 100644 --- a/.env.example +++ b/.env.example @@ -36,3 +36,9 @@ PUBLIC_STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" PUBLIC_STELLAR_RPC_URL="https://soroban-testnet.stellar.org:443" STELLAR_ACCOUNT="collins" STELLAR_NETWORK="testnet" + +# Cloudinary +CLOUDINARY_CLOUD_NAME= #neccesary +CLOUDINARY_API_KEY= #neccesary +CLOUDINARY_API_SECRET= #neccesary +CLOUDINARY_URL= #neccesary \ No newline at end of file diff --git a/app/(dashboard)/projects/[id]/page.tsx b/app/(dashboard)/projects/[id]/page.tsx index 770c155f..507dc3b8 100644 --- a/app/(dashboard)/projects/[id]/page.tsx +++ b/app/(dashboard)/projects/[id]/page.tsx @@ -1,33 +1,149 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Users, Wallet, Plus } from "lucide-react"; +import { Users, Wallet } from "lucide-react"; import Image from "next/image"; import { ProjectActions } from "./project-actions"; import { MilestoneTracker } from "./milestone-tracker"; -// import { VotingSection } from "./voting-section" import { FundingSection } from "./funding-section"; +import { VotingSection } from "./voting-section"; +import { useSession } from "next-auth/react"; +import { TeamSection } from "./team-section"; +import type { Vote } from "@prisma/client"; + +type ValidationStatus = "PENDING" | "REJECTED" | "VALIDATED"; -// Note this should be uncommented when project id is provided. Blocked by DB creation and priject creation -// interface ProjectPageProps { -// params: { -// id: string -// } -// } +type Project = { + id: string; + userId: string; + title: string; + description: string; + fundingGoal: number; + category: string; + bannerUrl: string | null; + profileUrl: string | null; + blockchainTx: string | null; + ideaValidation: ValidationStatus; + createdAt: string; + user: { + id: string; + name: string | null; + image: string | null; + }; + votes: Vote[]; + teamMembers: { + id: string; + fullName: string; + role: string; + bio: string | null; + profileImage: string | null; + github: string | null; + twitter: string | null; + discord: string | null; + linkedin: string | null; + userId: string | null; + }[]; + _count: { + votes: number; + teamMembers: number; + }; +}; -// export default function ProjectPage({ params }: ProjectPageProps) { export default function ProjectPage() { - const isTeamMember = true; // This would come from your auth logic to be handled by Benjamin + const params = useParams(); + const router = useRouter(); + const { data: session } = useSession(); + const [project, setProject] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Check if user is a team member + const isTeamMember = + project?.userId === session?.user?.id || + project?.teamMembers.some((member) => member.userId === session?.user?.id); + + useEffect(() => { + async function fetchProject() { + try { + const id = params?.id as string; + if (!id) return; + + const response = await fetch(`/api/projects/${id}`); + + if (response.status === 404) { + router.push("/projects"); + return; + } + + if (!response.ok) { + throw new Error("Failed to fetch project"); + } + + const data = await response.json(); + setProject(data); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + console.error(err); + } finally { + setLoading(false); + } + } + + fetchProject(); + }, [params, router]); + + if (loading) { + return
Loading project...
; + } + + if (error) { + return ( +
+ Error: {error} +
+ ); + } + + if (!project) { + return
Project not found
; + } + + // Calculate validation progress - this is just an example + const validationProgress = + project.ideaValidation === "VALIDATED" + ? 100 + : project.ideaValidation === "REJECTED" + ? 0 + : Math.min(project._count.votes, 100); + + // Determine validation phase + const getValidationPhase = () => { + switch (project.ideaValidation) { + case "VALIDATED": + return "Phase 4 of 4"; + case "REJECTED": + return "Rejected"; + case "PENDING": + if (project._count.votes >= 75) return "Phase 3 of 4"; + if (project._count.votes >= 50) return "Phase 2 of 4"; + if (project._count.votes >= 25) return "Phase 1 of 4"; + return "Initial Phase"; + } + }; return (
Project Banner
- - PJ + + + {project.title.substring(0, 2).toUpperCase()} +
-

Boundless

+

+ {project.title} +

- 50 Supporters + {project._count.votes}{" "} + Supporters - {/* - 1.5k Points - */} - 50 Funded + $ + {project.fundingGoal.toLocaleString()} Goal
- +
{/* Progress Section */}

Validation Progress

- Phase 4 of 4 + {getValidationPhase()}
- +

- Currently in Technical Review Phase + {project.ideaValidation === "VALIDATED" + ? "Project has been validated and is now in funding stage" + : project.ideaValidation === "REJECTED" + ? "Project did not receive enough community support" + : "Currently in community validation phase"}

@@ -96,18 +222,9 @@ export default function ProjectPage() {
-

About the Project

-

Detailed project description...

- -

Project Goals

-
    -
  • Goal 1
  • -
  • Goal 2
  • -
  • Goal 3
  • -
- -

Resources

-
+

About the Project

+

{project.description}

+
@@ -121,11 +238,18 @@ export default function ProjectPage() { - + - {/* */} + vote.userId === session?.user?.id, + )} + ideaValidation={project.ideaValidation} + /> @@ -133,39 +257,11 @@ export default function ProjectPage() { - - -
- Team Members - {isTeamMember && ( - - )} -
-
- -
- {Array.from({ length: 3 }).map((_, i) => ( -
- - - TM - -
-
Team Member Name
-
- Role -
-
-
- ))} -
-
-
+
diff --git a/app/(dashboard)/projects/[id]/project-actions.tsx b/app/(dashboard)/projects/[id]/project-actions.tsx index 75a24795..0c9da922 100644 --- a/app/(dashboard)/projects/[id]/project-actions.tsx +++ b/app/(dashboard)/projects/[id]/project-actions.tsx @@ -8,7 +8,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Edit, MoreVertical, Plus, Share2, Flag } from "lucide-react"; +import { Edit, MoreVertical, Share2, Flag, Plus } from "lucide-react"; +import Link from "next/link"; interface ProjectActionsProps { isTeamMember: boolean; @@ -22,9 +23,11 @@ export function ProjectActions({ isTeamMember }: ProjectActionsProps) { - + + + ) : ( diff --git a/app/(dashboard)/projects/[id]/team-section.tsx b/app/(dashboard)/projects/[id]/team-section.tsx new file mode 100644 index 00000000..a47a2298 --- /dev/null +++ b/app/(dashboard)/projects/[id]/team-section.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useState } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { + Plus, + Github, + Twitter, + Linkedin, + MessageSquare, + ChevronDown, + ChevronUp, +} from "lucide-react"; +import Link from "next/link"; +import type { TeamMember } from "@/types/project"; + +interface TeamSectionProps { + teamMembers: TeamMember[]; + isTeamMember: boolean; + projectId: string; +} + +export function TeamSection({ + teamMembers, + isTeamMember, + projectId, +}: TeamSectionProps) { + return ( + + +
+ Team Members + {isTeamMember && ( + + )} +
+
+ +
+ {teamMembers.length > 0 ? ( + teamMembers.map((member) => ( + + )) + ) : ( +
+ No team members added yet +
+ )} +
+
+
+ ); +} + +function TeamMemberCard({ + member, + isTeamMember, +}: { member: TeamMember; isTeamMember: boolean }) { + const [expanded, setExpanded] = useState(false); + + // Check if member has any social links + const hasSocialLinks = + member.github || member.twitter || member.discord || member.linkedin; + + return ( +
+
+ + + + {member.fullName.substring(0, 2).toUpperCase()} + + +
+
{member.fullName}
+
{member.role}
+ + {/* Social Links */} + {hasSocialLinks && ( +
+ {member.github && ( + + + + )} + {member.twitter && ( + + + + )} + {member.discord && ( +
+ +
+ )} + {member.linkedin && ( + + + + )} +
+ )} +
+ + {/* Expand/Collapse button for bio */} + {member.bio && ( + + )} +
+ + {/* Bio section (expandable) */} + {member.bio && expanded && ( +
+

{member.bio}

+
+ )} + + {/* Edit button for team members */} + {isTeamMember && ( +
+ +
+ )} +
+ ); +} diff --git a/app/(dashboard)/projects/[id]/voting-section.tsx b/app/(dashboard)/projects/[id]/voting-section.tsx new file mode 100644 index 00000000..267315fe --- /dev/null +++ b/app/(dashboard)/projects/[id]/voting-section.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { Users, AlertTriangle } from "lucide-react"; +import { VoteButton } from "@/components/shared/vote-button"; + +interface VotingSectionProps { + projectId: string; + initialVoteCount: number; + initialUserVoted: boolean; + ideaValidation: "PENDING" | "REJECTED" | "VALIDATED"; +} + +export function VotingSection({ + projectId, + initialVoteCount, + initialUserVoted, + ideaValidation, +}: VotingSectionProps) { + // Determine if voting is allowed based on validation status + const votingEnabled = ideaValidation === "PENDING"; + + // Calculate progress - this is just an example, adjust as needed + // Assuming 100 votes is the goal for validation + const votingGoal = 100; + const progressPercentage = Math.min( + Math.round((initialVoteCount / votingGoal) * 100), + 100, + ); + + return ( +
+ + + Project Validation Voting + + +
+ {/* Validation Status */} +
+
+ + {ideaValidation.charAt(0) + + ideaValidation.slice(1).toLowerCase()} + + + {ideaValidation === "PENDING" + ? "This project is awaiting community validation" + : ideaValidation === "VALIDATED" + ? "This project has been validated by the community" + : "This project was rejected by the community"} + +
+
+ + {/* Voting Progress */} + {ideaValidation === "PENDING" && ( +
+
+ Validation Progress + + {initialVoteCount} / {votingGoal} votes + +
+ +

+ This project needs {votingGoal - initialVoteCount} more votes + to reach the validation threshold +

+
+ )} + + {/* Vote Button */} +
+
+ + + Community support helps projects move to the funding stage + +
+ + {votingEnabled ? ( + + ) : ( +
+ Voting is{" "} + {ideaValidation === "VALIDATED" ? "complete" : "closed"} for + this project +
+ )} +
+ + {/* Info Box */} + {ideaValidation !== "PENDING" && ( +
+ +
+

+ Voting is{" "} + {ideaValidation === "VALIDATED" ? "complete" : "closed"} +

+

+ {ideaValidation === "VALIDATED" + ? "This project has been validated and has moved to the funding stage." + : "This project did not receive enough votes for validation."} +

+
+
+ )} +
+
+
+ + {/* Recent Voters */} + {initialVoteCount > 0 && ( + + + Recent Supporters + + +
+ {/* This would ideally be populated with actual voter data */} + {Array.from({ length: Math.min(3, initialVoteCount) }).map( + (_, i) => { + const placeholderKey = `voter-placeholder-${i}-${projectId}`; + return ( +
+
+
Community Member
+
+ {i === 0 + ? "Just now" + : i === 1 + ? "2 hours ago" + : "1 day ago"} +
+
+ Supporter +
+ ); + }, + )} +
+
+
+ )} +
+ ); +} diff --git a/app/(dashboard)/projects/page.tsx b/app/(dashboard)/projects/page.tsx index 1adfb171..638a37f8 100644 --- a/app/(dashboard)/projects/page.tsx +++ b/app/(dashboard)/projects/page.tsx @@ -1,7 +1,12 @@ -import React from "react"; +import { ProjectsList } from "@/components/projects/project-list"; -const page = () => { - return
page
; -}; +export const dynamic = "force-dynamic"; -export default page; +export default function ProjectsPage() { + return ( +
+

Projects

+ +
+ ); +} diff --git a/app/api/projects/[id]/route.ts b/app/api/projects/[id]/route.ts new file mode 100644 index 00000000..05ce1b82 --- /dev/null +++ b/app/api/projects/[id]/route.ts @@ -0,0 +1,69 @@ +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import type { Project, TeamMember, Vote } from "@prisma/client"; + +// Define a type for the project with included relations +type ProjectWithRelations = Project & { + user: { + id: string; + name: string | null; + image: string | null; + }; + votes: Vote[]; + teamMembers: TeamMember[]; + _count: { + votes: number; + }; +}; + +// Only use the request parameter and extract the ID from the URL +export async function GET(request: Request) { + try { + // Extract the ID from the URL path + const url = new URL(request.url); + const pathParts = url.pathname.split("/"); + const id = pathParts[pathParts.length - 1]; // Get the last segment of the path + + const project = (await prisma.project.findUnique({ + where: { id }, + include: { + user: { + select: { + id: true, + name: true, + image: true, + }, + }, + votes: true, + teamMembers: true, + _count: { + select: { + votes: true, + }, + }, + }, + })) as ProjectWithRelations | null; + + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + // Calculate team members count manually + const teamMembersCount = project.teamMembers.length; + + // Return the project with the manually calculated team members count + return NextResponse.json({ + ...project, + _count: { + ...project._count, + teamMembers: teamMembersCount, + }, + }); + } catch (error) { + console.error("Error fetching project:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/app/api/projects/[id]/vote/route.ts b/app/api/projects/[id]/vote/route.ts new file mode 100644 index 00000000..cb20cedf --- /dev/null +++ b/app/api/projects/[id]/vote/route.ts @@ -0,0 +1,120 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth.config"; + +export async function POST(request: Request) { + try { + const url = new URL(request.url); + const pathParts = url.pathname.split("/"); + const id = pathParts[pathParts.length - 1]; + const session = await getServerSession(authOptions); + + if (!session || !session.user || !session.user.id) { + return NextResponse.json( + { error: "You must be logged in to vote" }, + { status: 401 }, + ); + } + + const userId = session.user.id; + const projectId = id; + + // Check if project exists + const project = await prisma.project.findUnique({ + where: { id: projectId }, + }); + + if (!project) { + return NextResponse.json({ error: "Project not found" }, { status: 404 }); + } + + // Check if user has already voted + const existingVote = await prisma.vote.findUnique({ + where: { + projectId_userId: { + projectId, + userId, + }, + }, + }); + + if (existingVote) { + // User has already voted, so remove their vote + await prisma.vote.delete({ + where: { + id: existingVote.id, + }, + }); + + const count = await prisma.vote.count({ + where: { projectId }, + }); + + return NextResponse.json({ success: true, voted: false, count }); + } + + // Create a new vote + await prisma.vote.create({ + data: { + project: { + connect: { id: projectId }, + }, + user: { + connect: { id: userId }, + }, + }, + }); + + const count = await prisma.vote.count({ + where: { projectId }, + }); + + return NextResponse.json({ success: true, voted: true, count }); + } catch (error) { + console.error("Error voting for project:", error); + return NextResponse.json( + { error: "Failed to register vote" }, + { status: 500 }, + ); + } +} + +export async function GET(request: NextRequest) { + try { + const url = new URL(request.url); + const pathParts = url.pathname.split("/"); + const id = pathParts[pathParts.length - 1]; + const projectId = id; + + // Get vote count + const count = await prisma.vote.count({ + where: { projectId }, + }); + + // Check if current user has voted + const session = await getServerSession(authOptions); + let hasVoted = false; + + if (session?.user?.id) { + const vote = await prisma.vote.findUnique({ + where: { + projectId_userId: { + projectId, + userId: session.user.id, + }, + }, + }); + + hasVoted = !!vote; + } + + return NextResponse.json({ count, hasVoted }); + } catch (error) { + console.error("Error getting vote data:", error); + return NextResponse.json( + { error: "Failed to get vote data" }, + { status: 500 }, + ); + } +} diff --git a/app/api/projects/create/route.ts b/app/api/projects/create/route.ts new file mode 100644 index 00000000..6d2c6b0e --- /dev/null +++ b/app/api/projects/create/route.ts @@ -0,0 +1,90 @@ +import { getServerSession } from "next-auth/next"; +import { NextResponse } from "next/server"; +import prisma from "@/lib/prisma"; +import { authOptions } from "@/lib/auth.config"; +import { v2 as cloudinary } from "cloudinary"; + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}); +export async function POST(request: Request) { + const session = await getServerSession(authOptions); + + if (!session || !session.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + try { + const formData = await request.formData(); + const title = formData.get("title") as string; + const description = formData.get("description") as string; + const fundingGoal = Number(formData.get("fundingGoal")); + const category = formData.get("category") as string; + const bannerImage = formData.get("bannerImage") as File | null; + const bannerImageUrl = formData.get("bannerImageUrl") as string | null; + const profileImage = formData.get("profileImage") as File | null; + const profileImageUrl = formData.get("profileImageUrl") as string | null; + + if (!title || !description || !fundingGoal || !category) { + return NextResponse.json( + { error: "Missing required fields" }, + { status: 400 }, + ); + } + + let bannerUrl = bannerImageUrl; + let profileUrl = profileImageUrl; + + if (bannerImage) { + const bannerResult = await uploadToCloudinary(bannerImage); + bannerUrl = bannerResult.secure_url; + } + + if (profileImage) { + const profileResult = await uploadToCloudinary(profileImage); + profileUrl = profileResult.secure_url; + } + + const project = await prisma.project.create({ + data: { + userId: session.user.id, + title, + description, + fundingGoal, + category, + bannerUrl, + profileUrl, + blockchainTx: null, + }, + }); + + return NextResponse.json({ success: true, project }, { status: 201 }); + } catch (error) { + console.error("Project creation error:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} + +async function uploadToCloudinary(file: File) { + const buffer = await file.arrayBuffer(); + const base64 = Buffer.from(buffer).toString("base64"); + const dataURI = `data:${file.type};base64,${base64}`; + + return new Promise<{ secure_url: string }>((resolve, reject) => { + cloudinary.uploader.upload( + dataURI, + { + resource_type: "auto", + folder: "project_images", + }, + (error, result) => { + if (error) reject(error); + else resolve(result as { secure_url: string }); + }, + ); + }); +} diff --git a/app/api/projects/route.ts b/app/api/projects/route.ts index 227c9f34..a614de84 100644 --- a/app/api/projects/route.ts +++ b/app/api/projects/route.ts @@ -1,48 +1,52 @@ -import { getServerSession } from "next-auth/next"; import { NextResponse } from "next/server"; import prisma from "@/lib/prisma"; -import { authOptions } from "@/lib/auth.config"; +import type { Prisma, ValidationStatus } from "@prisma/client"; -export async function POST(request: Request) { - const session = await getServerSession(authOptions); +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const category = searchParams.get("category"); + const status = searchParams.get("status"); - if (!session || !session.user?.id) { - return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - } + const where: Prisma.ProjectWhereInput = {}; - try { - const { - title, - description, - fundingGoal, - category, - bannerPath, - profilePath, - } = await request.json(); + if (category) { + where.category = category; + } - if (!title || !description || !fundingGoal || !category) { - return NextResponse.json( - { error: "Missing required fields" }, - { status: 400 }, - ); + if (status) { + where.ideaValidation = status as ValidationStatus; } - const project = await prisma.project.create({ - data: { - userId: session.user.id, - title, - description, - fundingGoal, - category, - bannerUrl: bannerPath || null, - profileUrl: profilePath || null, - blockchainTx: null, + const projects = await prisma.project.findMany({ + include: { + user: { + select: { + id: true, + name: true, + image: true, + }, + }, + votes: { + select: { + id: true, + userId: true, + }, + }, + _count: { + select: { + votes: true, + }, + }, + }, + orderBy: { + createdAt: "desc", }, }); - return NextResponse.json({ success: true, project }, { status: 201 }); + return NextResponse.json(projects); } catch (error) { - console.error("Project creation error:", error); + console.error("Error fetching projects:", error); return NextResponse.json( { error: "Internal Server Error" }, { status: 500 }, diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx deleted file mode 100644 index 412e5f4b..00000000 --- a/app/dashboard/page.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import Dashboard from "@/components/dashboard"; -import React from "react"; - -const page = () => { - return ; -}; - -export default page; diff --git a/components/connect-wallet.tsx b/components/connect-wallet.tsx index a86d6479..3aa983e4 100644 --- a/components/connect-wallet.tsx +++ b/components/connect-wallet.tsx @@ -1,8 +1,8 @@ "use client"; -import React, { useState, useEffect } from "react"; +import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; -import { Loader2, LogOut } from "lucide-react"; +import { Loader2, LogOut, Copy, Check } from "lucide-react"; import { toast } from "sonner"; import { connect, disconnect, getPublicKey } from "@/hooks/useStellarWallet"; @@ -10,6 +10,7 @@ const ConnectWalletButton = ({ className = "" }) => { const [isChecking, setIsChecking] = useState(true); const [isConnecting, setIsConnecting] = useState(false); const [walletAddress, setWalletAddress] = useState(null); + const [isCopied, setIsCopied] = useState(false); useEffect(() => { const checkConnection = async () => { @@ -18,7 +19,7 @@ const ConnectWalletButton = ({ className = "" }) => { if (address) { setWalletAddress(address); toast.success("Wallet Reconnected", { - description: `Welcome back! Address: ${address}`, + description: "Welcome back!", }); } } catch (error) { @@ -40,7 +41,7 @@ const ConnectWalletButton = ({ className = "" }) => { setWalletAddress(address); toast.success("Wallet Connected", { - description: `Connected. Address: ${address}`, + description: "Successfully connected to wallet", }); }); } catch (error) { @@ -61,27 +62,59 @@ const ConnectWalletButton = ({ className = "" }) => { }); }; + const copyToClipboard = async () => { + if (!walletAddress) return; + + try { + await navigator.clipboard.writeText(walletAddress); + setIsCopied(true); + toast.success("Address Copied", { + description: "Wallet address copied to clipboard", + }); + + // Reset the copied state after 2 seconds + setTimeout(() => { + setIsCopied(false); + }, 2000); + } catch (error) { + toast.error(error as string, { + description: "Failed to copy address to clipboard", + }); + } + }; + + const formatAddress = (address: string) => { + return `${address.slice(0, 6)}...${address.slice(-4)}`; + }; + return (
- + {isChecking ? ( + + ) : isConnecting ? ( + + ) : walletAddress ? ( + + ) : ( + + )} {walletAddress && ( diff --git a/components/projects/project-list.tsx b/components/projects/project-list.tsx new file mode 100644 index 00000000..bdd057a0 --- /dev/null +++ b/components/projects/project-list.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { Card, CardFooter, CardHeader } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import Image from "next/image"; +import { VoteButton } from "../shared/vote-button"; +import { useSession } from "next-auth/react"; + +type ValidationStatus = "PENDING" | "REJECTED" | "VALIDATED"; + +type Project = { + id: string; + userId: string; + title: string; + description: string; + fundingGoal: number; + category: string; + bannerUrl: string | null; + profileUrl: string | null; + blockchainTx: string | null; + ideaValidation: ValidationStatus; + createdAt: string; + user: { + id: string; + name: string | null; + image: string | null; + }; + votes: { + id: string; + userId: string; + }[]; + _count: { + votes: number; + }; +}; + +export function ProjectsList() { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const { data: session } = useSession(); + + useEffect(() => { + async function fetchProjects() { + try { + const response = await fetch("/api/projects"); + + if (!response.ok) { + throw new Error("Failed to fetch projects"); + } + + const data = await response.json(); + setProjects(data); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + console.error(err); + } finally { + setLoading(false); + } + } + + fetchProjects(); + }, []); + + if (loading) { + return
Loading projects...
; + } + + if (error) { + return ( +
Error: {error}
+ ); + } + + if (!projects.length) { + return
No projects found
; + } + + return ( +
+ {projects.map((project) => { + // Check if the current user has voted for this project + const userVoted = session?.user?.id + ? project.votes.some((vote) => vote.userId === session.user?.id) + : false; + + return ( + + + {project.bannerUrl ? ( +
+ {project.title} +
+ ) : ( +
+ No banner image +
+ )} + + + {project.profileUrl && ( +
+ {`${project.title} +
+ )} +
+
+

+ {project.title} +

+
+

+ {project.description} +

+
+
+ + + +
+ + {project.category.slice(0, 1).toUpperCase() + + project.category.slice(1).toLowerCase()} + + + {formatValidationStatus(project.ideaValidation)} + +
+
+ + {project.ideaValidation === "VALIDATED" + ? "FUNDING STAGE" + : "IDEA VALIDATION STAGE"} + +
+ + {/* Vote Button - only show if project is in PENDING state */} + {project.ideaValidation === "PENDING" && ( +
+ +
+ )} +
+
+ ); + })} +
+ ); +} + +function formatValidationStatus(status: ValidationStatus | null | undefined) { + if (!status) return "Unknown"; + return status.charAt(0) + status.slice(1).toLowerCase(); +} diff --git a/components/registeration-form.tsx b/components/registeration-form.tsx index cf5dd0dd..03350525 100644 --- a/components/registeration-form.tsx +++ b/components/registeration-form.tsx @@ -1,101 +1,102 @@ -"use client" +"use client"; -import { useState } from "react" -import { useRouter } from "next/navigation" +import { useState } from "react"; +import { useRouter } from "next/navigation"; export default function RegistrationForm() { - const [name, setName] = useState("") - const [email, setEmail] = useState("") - const [password, setPassword] = useState("") - const [error, setError] = useState("") - const router = useRouter() + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const router = useRouter(); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - setError("") + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); - try { - const response = await fetch("/api/auth/register", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, email, password }), - }) + try { + const response = await fetch("/api/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, email, password }), + }); - if (response.ok) { - // Redirect to OTP verification page with the user's email - router.push(`/auth/verify-otp?email=${encodeURIComponent(email)}`) - } else { - const data = await response.json() - setError(data.message || "An error occurred during registration.") - } - } catch (error) { - setError(`An error occurred during registration: ${error instanceof Error ? error.message : String(error)}`) - } - } + if (response.ok) { + // Redirect to OTP verification page with the user's email + router.push(`/auth/verify-otp?email=${encodeURIComponent(email)}`); + } else { + const data = await response.json(); + setError(data.message || "An error occurred during registration."); + } + } catch (error) { + setError( + `An error occurred during registration: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }; - return ( -
-
-
- - setName(e.target.value)} - /> -
-
- - setEmail(e.target.value)} - /> -
-
- - setPassword(e.target.value)} - /> -
-
+ return ( + +
+
+ + setName(e.target.value)} + /> +
+
+ + setEmail(e.target.value)} + /> +
+
+ + setPassword(e.target.value)} + /> +
+
- {error &&

{error}

} + {error &&

{error}

} -
- -
-
- ) +
+ +
+ + ); } - diff --git a/components/shared/vote-button.tsx b/components/shared/vote-button.tsx new file mode 100644 index 00000000..be9295bd --- /dev/null +++ b/components/shared/vote-button.tsx @@ -0,0 +1,87 @@ +"use client"; + +import type React from "react"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { ThumbsUp } from "lucide-react"; +import { voteForProject } from "@/lib/actions/vote"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; + +interface VoteButtonProps { + projectId: string; + initialVoteCount: number; + initialUserVoted: boolean; + compact?: boolean; +} + +export function VoteButton({ + projectId, + initialVoteCount, + initialUserVoted, + compact = false, +}: VoteButtonProps) { + const [voteCount, setVoteCount] = useState( + initialVoteCount, + ); + const [userVoted, setUserVoted] = useState( + initialUserVoted, + ); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + + const handleVote = async (e: React.MouseEvent) => { + // Prevent the click from navigating to the project page if inside a link + e.stopPropagation(); + + try { + setIsLoading(true); + const result = await voteForProject(projectId); + + if (result.success) { + setVoteCount(result.count); + setUserVoted(result.voted); + + toast.success(result.voted ? "Vote registered!" : "Vote removed!", { + description: result.voted + ? "Thank you for supporting this project." + : "Your vote has been removed.", + }); + + // Refresh the page to update data + router.refresh(); + } else { + toast.error("Error", { + description: result.error || "Something went wrong", + }); + } + } catch (error) { + toast.error(`Error:${error}`, { + description: "Failed to register your vote", + }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + {voteCount} {voteCount === 1 ? "vote" : "votes"} + +
+ ); +} diff --git a/components/sidebar.tsx b/components/sidebar.tsx index f6ce1764..a3b6c485 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -2,136 +2,147 @@ import * as React from "react"; import { - Home, - Briefcase, - Bell, - BarChart2, - Cpu, - Crown, - Settings, - Sun, - Moon, + Home, + Briefcase, + Bell, + BarChart2, + Cpu, + Crown, + Settings, + Sun, + Moon, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardFooter } from "@/components/ui/card"; import Image from "next/image"; +import { useRouter, usePathname } from "next/navigation"; interface NavItem { - icon: React.ElementType; - label: string; - href: string; - isActive?: boolean; + icon: React.ElementType; + label: string; + href: string; + isActive?: boolean; } const navItems: NavItem[] = [ - { icon: Home, label: "Dashboard", href: "/", isActive: true }, - { icon: Briefcase, label: "My Projects", href: "projects/my-projects" }, - { icon: Bell, label: "Explore", href: "projects/explore" }, - { icon: BarChart2, label: "Funded Projects", href: "/projects/funded" }, - { icon: Cpu, label: "Profile", href: "/profile" }, - { icon: Crown, label: "My Votes", href: "/votes" }, - { icon: Settings, label: "Settings", href: "/settings" }, + { icon: Home, label: "Dashboard", href: "/dashboard", isActive: true }, + { icon: Briefcase, label: "My Projects", href: "projects/my-projects" }, + { icon: Bell, label: "Explore", href: "/projects" }, + { icon: BarChart2, label: "Funded Projects", href: "/projects/funded" }, + { icon: Cpu, label: "Profile", href: "/profile" }, + { icon: Crown, label: "My Votes", href: "/votes" }, + { icon: Settings, label: "Settings", href: "/settings" }, ]; interface SidebarProps extends React.HTMLAttributes { - className?: string; + className?: string; } export function Sidebar({ className, ...props }: SidebarProps) { - const [theme, setTheme] = React.useState<"light" | "dark">("light"); + const [theme, setTheme] = React.useState<"light" | "dark">("light"); + const router = useRouter(); + const pathname = usePathname(); - const toggleTheme = () => { - const newTheme = theme === "light" ? "dark" : "light"; - setTheme(newTheme); - document.documentElement.classList.toggle("dark"); - }; + const toggleTheme = () => { + const newTheme = theme === "light" ? "dark" : "light"; + setTheme(newTheme); + document.documentElement.classList.toggle("dark"); + }; - return ( - + ); } diff --git a/lib/actions/vote.ts b/lib/actions/vote.ts index d654139f..87a46ff0 100644 --- a/lib/actions/vote.ts +++ b/lib/actions/vote.ts @@ -1,59 +1,99 @@ -"use server" - -import { getServerSession } from "next-auth" -import { authOptions } from "@/lib/auth.config" -import { prisma } from "@/lib/prisma" -import { revalidatePath } from "next/cache" - -export async function toggleVote(projectId: string) { - const session = await getServerSession(authOptions) - - if (!session?.user?.email) { - throw new Error("Unauthorized") - } - - // Get user ID from email since we're using JWT strategy - const user = await prisma.user.findUnique({ - where: { - email: session.user.email, - }, - select: { - id: true, - }, - }) - - if (!user) { - throw new Error("User not found") - } - - // Check if user has already voted - const existingVote = await prisma.vote.findUnique({ - where: { - projectId_userId: { - projectId, - userId: user.id, - }, - }, - }) - - if (existingVote) { - // Remove vote if it exists - await prisma.vote.delete({ - where: { - id: existingVote.id, - }, - }) - } else { - // Add new vote - await prisma.vote.create({ - data: { - projectId, - userId: user.id, - }, - }) - } - - revalidatePath(`/projects/${projectId}`) - return { success: true } +"use server"; + +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth.config"; +import { prisma } from "@/lib/prisma"; +import { revalidatePath } from "next/cache"; + +export async function voteForProject(projectId: string) { + try { + // Get the session using your authOptions + const session = await getServerSession(authOptions); + + // Check if user is authenticated + if (!session || !session.user || !session.user.id) { + return { success: false, error: "You must be logged in to vote" }; + } + + const userId = session.user.id; + + // Check if user has already voted for this project + const existingVote = await prisma.vote.findUnique({ + where: { + projectId_userId: { + projectId, + userId, + }, + }, + }); + + if (existingVote) { + // User has already voted, so remove their vote (toggle functionality) + await prisma.vote.delete({ + where: { + id: existingVote.id, + }, + }); + + revalidatePath(`/projects/${projectId}`); + return { + success: true, + voted: false, + count: await getVoteCount(projectId), + }; + } + + // Create a new vote + await prisma.vote.create({ + data: { + project: { + connect: { id: projectId }, + }, + user: { + connect: { id: userId }, + }, + }, + }); + + revalidatePath(`/projects/${projectId}`); + return { success: true, voted: true, count: await getVoteCount(projectId) }; + } catch (error) { + console.error("Error voting for project:", error); + return { success: false, error: "Failed to register vote" }; + } } +export async function getVoteCount(projectId: string): Promise { + const count = await prisma.vote.count({ + where: { + projectId, + }, + }); + + return count; +} + +export async function hasUserVoted(projectId: string): Promise { + try { + const session = await getServerSession(authOptions); + + if (!session || !session.user || !session.user.id) { + return false; + } + + const userId = session.user.id; + + const vote = await prisma.vote.findUnique({ + where: { + projectId_userId: { + projectId, + userId, + }, + }, + }); + + return !!vote; + } catch { + return false; + } +} diff --git a/lib/utils.ts b/lib/utils.ts index bd0c391d..4b138ca6 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,6 +1,14 @@ -import { clsx, type ClassValue } from "clsx" -import { twMerge } from "tailwind-merge" +import type { ValidationStatus } from "@/types/project"; +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); +} + +export function formatValidationStatus( + status: ValidationStatus | null | undefined, +) { + if (!status) return "Unknown"; + return status.charAt(0) + status.slice(1).toLowerCase(); } diff --git a/next.config.ts b/next.config.ts index 5dde93d2..47e84bd5 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,10 +1,10 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ - images: { - domains: ['i.postimg.cc'], - } + /* config options here */ + images: { + domains: ["i.postimg.cc", "res.cloudinary.com", "example.com"], + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index ac95697f..4c3f2350 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@vercel/blob": "^0.27.1", "bcrypt": "^5.1.1", "class-variance-authority": "^0.7.1", + "cloudinary": "^2.5.1", "clsx": "^2.1.1", "dotenv": "^16.4.7", "framer-motion": "^12.4.2", @@ -1892,9 +1893,9 @@ } }, "node_modules/@next/env": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.6.tgz", - "integrity": "sha512-d9AFQVPEYNr+aqokIiPLNK/MTyt3DWa/dpKveiAaVccUadFbhFEvY6FXYX2LJO2Hv7PHnLBu2oWwB4uBuHjr/w==", + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.1.tgz", + "integrity": "sha512-JmY0qvnPuS2NCWOz2bbby3Pe0VzdAQ7XpEB6uLIHmtXNfAsAO0KLQLkuAoc42Bxbo3/jMC3dcn9cdf+piCcG2Q==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1908,9 +1909,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.6.tgz", - "integrity": "sha512-u7lg4Mpl9qWpKgy6NzEkz/w0/keEHtOybmIl0ykgItBxEM5mYotS5PmqTpo+Rhg8FiOiWgwr8USxmKQkqLBCrw==", + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.2.1.tgz", + "integrity": "sha512-aWXT+5KEREoy3K5AKtiKwioeblmOvFFjd+F3dVleLvvLiQ/mD//jOOuUcx5hzcO9ISSw4lrqtUPntTpK32uXXQ==", "cpu": [ "arm64" ], @@ -1924,9 +1925,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.6.tgz", - "integrity": "sha512-x1jGpbHbZoZ69nRuogGL2MYPLqohlhnT9OCU6E6QFewwup+z+M6r8oU47BTeJcWsF2sdBahp5cKiAcDbwwK/lg==", + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.2.1.tgz", + "integrity": "sha512-E/w8ervu4fcG5SkLhvn1NE/2POuDCDEy5gFbfhmnYXkyONZR68qbUlJlZwuN82o7BrBVAw+tkR8nTIjGiMW1jQ==", "cpu": [ "x64" ], @@ -1940,9 +1941,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.6.tgz", - "integrity": "sha512-jar9sFw0XewXsBzPf9runGzoivajeWJUc/JkfbLTC4it9EhU8v7tCRLH7l5Y1ReTMN6zKJO0kKAGqDk8YSO2bg==", + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.2.1.tgz", + "integrity": "sha512-gXDX5lIboebbjhiMT6kFgu4svQyjoSed6dHyjx5uZsjlvTwOAnZpn13w9XDaIMFFHw7K8CpBK7HfDKw0VZvUXQ==", "cpu": [ "arm64" ], @@ -1956,9 +1957,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.6.tgz", - "integrity": "sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ==", + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.2.1.tgz", + "integrity": "sha512-3v0pF/adKZkBWfUffmB/ROa+QcNTrnmYG4/SS+r52HPwAK479XcWoES2I+7F7lcbqc7mTeVXrIvb4h6rR/iDKg==", "cpu": [ "arm64" ], @@ -1972,9 +1973,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.6.tgz", - "integrity": "sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ==", + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.2.1.tgz", + "integrity": "sha512-RbsVq2iB6KFJRZ2cHrU67jLVLKeuOIhnQB05ygu5fCNgg8oTewxweJE8XlLV+Ii6Y6u4EHwETdUiRNXIAfpBww==", "cpu": [ "x64" ], @@ -1988,9 +1989,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.6.tgz", - "integrity": "sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ==", + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.2.1.tgz", + "integrity": "sha512-QHsMLAyAIu6/fWjHmkN/F78EFPKmhQlyX5C8pRIS2RwVA7z+t9cTb0IaYWC3EHLOTjsU7MNQW+n2xGXr11QPpg==", "cpu": [ "x64" ], @@ -2004,9 +2005,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.6.tgz", - "integrity": "sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg==", + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.2.1.tgz", + "integrity": "sha512-Gk42XZXo1cE89i3hPLa/9KZ8OuupTjkDmhLaMKFohjf9brOeZVEa3BQy1J9s9TWUqPhgAEbwv6B2+ciGfe54Vw==", "cpu": [ "arm64" ], @@ -2020,9 +2021,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.6.tgz", - "integrity": "sha512-6xomMuu54FAFxttYr5PJbEfu96godcxBTRk1OhAvJq0/EnmFU/Ybiax30Snis4vdWZ9LGpf7Roy5fSs7v/5ROQ==", + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.2.1.tgz", + "integrity": "sha512-YjqXCl8QGhVlMR8uBftWk0iTmvtntr41PhG1kvzGp0sUP/5ehTM+cwx25hKE54J0CRnHYjSGjSH3gkHEaHIN9g==", "cpu": [ "x64" ], @@ -6773,6 +6774,19 @@ "node": ">=8" } }, + "node_modules/cloudinary": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.5.1.tgz", + "integrity": "sha512-CNg6uU53Hl4FEVynkTGpt5bQEAQWDHi3H+Sm62FzKf5uQHipSN2v7qVqS8GRVqeb0T1WNV+22+75DOJeRXYeSQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "q": "^1.5.1" + }, + "engines": { + "node": ">=9" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -10936,12 +10950,12 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.1.6", - "resolved": "https://registry.npmjs.org/next/-/next-15.1.6.tgz", - "integrity": "sha512-Hch4wzbaX0vKQtalpXvUiw5sYivBy4cm5rzUKrBnUB/y436LGrvOUqYvlSeNVCWFO/770gDlltR9gqZH62ct4Q==", + "version": "15.2.1", + "resolved": "https://registry.npmjs.org/next/-/next-15.2.1.tgz", + "integrity": "sha512-zxbsdQv3OqWXybK5tMkPCBKyhIz63RstJ+NvlfkaLMc/m5MwXgz2e92k+hSKcyBpyADhMk2C31RIiaDjUZae7g==", "license": "MIT", "dependencies": { - "@next/env": "15.1.6", + "@next/env": "15.2.1", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", @@ -10956,14 +10970,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.1.6", - "@next/swc-darwin-x64": "15.1.6", - "@next/swc-linux-arm64-gnu": "15.1.6", - "@next/swc-linux-arm64-musl": "15.1.6", - "@next/swc-linux-x64-gnu": "15.1.6", - "@next/swc-linux-x64-musl": "15.1.6", - "@next/swc-win32-arm64-msvc": "15.1.6", - "@next/swc-win32-x64-msvc": "15.1.6", + "@next/swc-darwin-arm64": "15.2.1", + "@next/swc-darwin-x64": "15.2.1", + "@next/swc-linux-arm64-gnu": "15.2.1", + "@next/swc-linux-arm64-musl": "15.2.1", + "@next/swc-linux-x64-gnu": "15.2.1", + "@next/swc-linux-x64-musl": "15.2.1", + "@next/swc-win32-arm64-msvc": "15.2.1", + "@next/swc-win32-x64-msvc": "15.2.1", "sharp": "^0.33.5" }, "peerDependencies": { @@ -12118,10 +12132,6 @@ "integrity": "sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==", "license": "MIT" }, - "node_modules/project_contract": { - "resolved": "packages/project_contract", - "link": true - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -12200,6 +12210,17 @@ "bitcoin-ops": "^1.3.0" } }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "license": "MIT", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, "node_modules/qrcode": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.3.tgz", @@ -15131,6 +15152,7 @@ }, "packages/project_contract": { "version": "0.0.0", + "extraneous": true, "dependencies": { "@stellar/stellar-sdk": "13.0.0", "buffer": "6.0.3" @@ -15138,48 +15160,6 @@ "devDependencies": { "typescript": "^5.6.2" } - }, - "packages/project_contract/node_modules/@stellar/stellar-base": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-13.0.1.tgz", - "integrity": "sha512-Xbd12mc9Oj/130Tv0URmm3wXG77XMshZtZ2yNCjqX5ZbMD5IYpbBs3DVCteLU/4SLj/Fnmhh1dzhrQXnk4r+pQ==", - "license": "Apache-2.0", - "dependencies": { - "@stellar/js-xdr": "^3.1.2", - "base32.js": "^0.1.0", - "bignumber.js": "^9.1.2", - "buffer": "^6.0.3", - "sha.js": "^2.3.6", - "tweetnacl": "^1.0.3" - }, - "optionalDependencies": { - "sodium-native": "^4.3.0" - } - }, - "packages/project_contract/node_modules/@stellar/stellar-sdk": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-13.0.0.tgz", - "integrity": "sha512-+wvmKi+XWwu27nLYTM17EgBdpbKohEkOfCIK4XKfsI4WpMXAqvnqSm98i9h5dAblNB+w8BJqzGs1JY0PtTGm4A==", - "license": "Apache-2.0", - "dependencies": { - "@stellar/stellar-base": "^13.0.1", - "axios": "^1.7.7", - "bignumber.js": "^9.1.2", - "eventsource": "^2.0.2", - "feaxios": "^0.0.20", - "randombytes": "^2.1.0", - "toml": "^3.0.0", - "urijs": "^1.19.1" - } - }, - "packages/project_contract/node_modules/feaxios": { - "version": "0.0.20", - "resolved": "https://registry.npmjs.org/feaxios/-/feaxios-0.0.20.tgz", - "integrity": "sha512-g3hm2YDNffNxA3Re3Hd8ahbpmDee9Fv1Pb1C/NoWsjY7mtD8nyNeJytUzn+DK0Hyl9o6HppeWOrtnqgmhOYfWA==", - "license": "MIT", - "dependencies": { - "is-retry-allowed": "^3.0.0" - } } } } diff --git a/package.json b/package.json index 6d666004..a1c59999 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ ] }, "prisma": { - "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" + "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.js" }, "dependencies": { "@auth/prisma-adapter": "^2.7.4", @@ -49,6 +49,7 @@ "@vercel/blob": "^0.27.1", "bcrypt": "^5.1.1", "class-variance-authority": "^0.7.1", + "cloudinary": "^2.5.1", "clsx": "^2.1.1", "dotenv": "^16.4.7", "framer-motion": "^12.4.2", diff --git a/prisma/migrations/20250211123141_boundless/migration.sql b/prisma/migrations/20250211123141_boundless/migration.sql deleted file mode 100644 index cbbf8be4..00000000 --- a/prisma/migrations/20250211123141_boundless/migration.sql +++ /dev/null @@ -1,88 +0,0 @@ --- CreateEnum -CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN'); - --- CreateTable -CREATE TABLE "Account" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "type" TEXT NOT NULL, - "provider" TEXT NOT NULL, - "providerAccountId" TEXT NOT NULL, - "refresh_token" TEXT, - "access_token" TEXT, - "expires_at" INTEGER, - "token_type" TEXT, - "scope" TEXT, - "id_token" TEXT, - "session_state" TEXT, - - CONSTRAINT "Account_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Session" ( - "id" TEXT NOT NULL, - "sessionToken" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "expires" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Session_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "User" ( - "id" TEXT NOT NULL, - "name" TEXT, - "email" TEXT, - "emailVerified" TIMESTAMP(3), - "image" TEXT, - "password" TEXT, - "role" "Role" NOT NULL DEFAULT 'USER', - - CONSTRAINT "User_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "VerificationToken" ( - "id" TEXT NOT NULL, - "identifier" TEXT NOT NULL, - "token" TEXT NOT NULL, - "expires" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "VerificationToken_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "OTP" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "token" TEXT NOT NULL, - "expires" TIMESTAMP(3) NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "OTP_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); - --- CreateIndex -CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); - --- CreateIndex -CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); - --- CreateIndex -CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); - --- CreateIndex -CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); - --- CreateIndex -CREATE UNIQUE INDEX "OTP_token_key" ON "OTP"("token"); - --- AddForeignKey -ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250212211904_boundless/migration.sql b/prisma/migrations/20250212211904_boundless/migration.sql deleted file mode 100644 index 947d6f19..00000000 --- a/prisma/migrations/20250212211904_boundless/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AddForeignKey -ALTER TABLE "OTP" ADD CONSTRAINT "OTP_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250220000001_boundless/migration.sql b/prisma/migrations/20250220000001_boundless/migration.sql deleted file mode 100644 index f40057f9..00000000 --- a/prisma/migrations/20250220000001_boundless/migration.sql +++ /dev/null @@ -1,35 +0,0 @@ --- CreateTable -CREATE TABLE "Project" ( - "id" TEXT NOT NULL, - "title" TEXT NOT NULL, - "description" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - CONSTRAINT "Project_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Vote" ( - "id" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "projectId" TEXT NOT NULL, - "userId" TEXT NOT NULL, - - CONSTRAINT "Vote_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "Vote_projectId_idx" ON "Vote"("projectId"); - --- CreateIndex -CREATE INDEX "Vote_userId_idx" ON "Vote"("userId"); - --- CreateIndex -CREATE UNIQUE INDEX "Vote_projectId_userId_key" ON "Vote"("projectId", "userId"); - --- AddForeignKey -ALTER TABLE "Vote" ADD CONSTRAINT "Vote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Vote" ADD CONSTRAINT "Vote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250222130151_benji/migration.sql b/prisma/migrations/20250222130151_benji/migration.sql deleted file mode 100644 index f1f7b3a0..00000000 --- a/prisma/migrations/20250222130151_benji/migration.sql +++ /dev/null @@ -1,18 +0,0 @@ --- CreateTable -CREATE TABLE "Project" ( - "id" TEXT NOT NULL, - "userId" TEXT NOT NULL, - "title" TEXT NOT NULL, - "description" TEXT NOT NULL, - "fundingGoal" INTEGER NOT NULL, - "category" TEXT NOT NULL, - "bannerUrl" TEXT, - "profileUrl" TEXT, - "blockchainTx" TEXT, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "Project_pkey" PRIMARY KEY ("id") -); - --- AddForeignKey -ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20250307015524_add_idea_validation_and_team_members/migration.sql b/prisma/migrations/20250307015524_add_idea_validation_and_team_members/migration.sql new file mode 100644 index 00000000..da31214e --- /dev/null +++ b/prisma/migrations/20250307015524_add_idea_validation_and_team_members/migration.sql @@ -0,0 +1,170 @@ +-- CreateEnum +CREATE TYPE "ValidationStatus" AS ENUM ('PENDING', 'REJECTED', 'VALIDATED'); + +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN'); + +-- CreateTable +CREATE TABLE "Account" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL, + "sessionToken" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Session_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "name" TEXT, + "email" TEXT, + "emailVerified" TIMESTAMP(3), + "image" TEXT, + "password" TEXT, + "role" "Role" NOT NULL DEFAULT 'USER', + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VerificationToken" ( + "id" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "VerificationToken_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OTP" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expires" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "OTP_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Project" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "fundingGoal" INTEGER NOT NULL, + "category" TEXT NOT NULL, + "bannerUrl" TEXT, + "profileUrl" TEXT, + "blockchainTx" TEXT, + "ideaValidation" "ValidationStatus" NOT NULL DEFAULT 'PENDING', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "TeamMember" ( + "id" TEXT NOT NULL, + "fullName" TEXT NOT NULL, + "role" TEXT NOT NULL, + "bio" TEXT, + "profileImage" TEXT, + "github" TEXT, + "twitter" TEXT, + "discord" TEXT, + "linkedin" TEXT, + "projectId" TEXT NOT NULL, + "userId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TeamMember_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Vote" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "projectId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "Vote_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token"); + +-- CreateIndex +CREATE UNIQUE INDEX "OTP_token_key" ON "OTP"("token"); + +-- CreateIndex +CREATE INDEX "TeamMember_projectId_idx" ON "TeamMember"("projectId"); + +-- CreateIndex +CREATE INDEX "TeamMember_userId_idx" ON "TeamMember"("userId"); + +-- CreateIndex +CREATE INDEX "Vote_projectId_idx" ON "Vote"("projectId"); + +-- CreateIndex +CREATE INDEX "Vote_userId_idx" ON "Vote"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Vote_projectId_userId_key" ON "Vote"("projectId", "userId"); + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OTP" ADD CONSTRAINT "OTP_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TeamMember" ADD CONSTRAINT "TeamMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Vote" ADD CONSTRAINT "Vote_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Vote" ADD CONSTRAINT "Vote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 932852ac..bf0e07ac 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,18 +35,19 @@ model Session { } model User { - id String @id @default(cuid()) + id String @id @default(cuid()) name String? - email String? @unique + email String? @unique emailVerified DateTime? image String? password String? - role Role @default(USER) + role Role @default(USER) accounts Account[] sessions Session[] OTP OTP[] projects Project[] Vote Vote[] + TeamMember TeamMember[] } model VerificationToken { @@ -67,29 +68,50 @@ model OTP { user User @relation(fields: [userId], references: [id], onDelete: Cascade) } -// model Project { -// id String @id @default(cuid()) -// title String -// description String -// createdAt DateTime @default(now()) -// updatedAt DateTime @updatedAt -// votes Vote[] -// } +enum ValidationStatus { + PENDING + REJECTED + VALIDATED +} model Project { + id String @id @default(cuid()) + userId String + title String + description String + fundingGoal Int + category String + bannerUrl String? + profileUrl String? + blockchainTx String? + ideaValidation ValidationStatus @default(PENDING) + createdAt DateTime @default(now()) + votes Vote[] + teamMembers TeamMember[] + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model TeamMember { id String @id @default(cuid()) - userId String - title String - description String - fundingGoal Int - category String - bannerUrl String? - profileUrl String? - blockchainTx String? + fullName String + role String + bio String? @db.Text + profileImage String? + github String? + twitter String? + discord String? + linkedin String? + projectId String + userId String? createdAt DateTime @default(now()) - votes Vote[] + updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([projectId]) + @@index([userId]) } model Vote { diff --git a/prisma/seed.js b/prisma/seed.js new file mode 100644 index 00000000..bbcd97ee --- /dev/null +++ b/prisma/seed.js @@ -0,0 +1,107 @@ +import { PrismaClient } from "@prisma/client"; +import { hash } from "bcrypt"; + +const prisma = new PrismaClient(); + +async function main() { + const password = await hash("adminpassword", 12); + const admin = await prisma.user.upsert({ + where: { email: "admin@example.com" }, + update: {}, + create: { + email: "admin@example.com", + name: "Admin", + password, + role: "ADMIN", + }, + }); + console.log({ admin }); + + // Seed Projects + const projects = await Promise.all([ + prisma.project.create({ + data: { + userId: "cm7y4qb140007rqxnx1zfd8pb", + title: "Eco-Friendly Water Purifier", + description: + "A sustainable water purification system for developing countries.", + fundingGoal: 50000, + category: "Environment", + bannerUrl: + "https://res.cloudinary.com/dmsphf4d3/image/upload/v1741345548/project_images/uq7sf4tjkckc62mgohic.avif", + profileUrl: + "https://res.cloudinary.com/dmsphf4d3/image/upload/v1741345550/project_images/arnuhsp5cct8js56zq01.png", + ideaValidation: "PENDING", + }, + }), + prisma.project.create({ + data: { + userId: "cm7y4qb140007rqxnx1zfd8pb", + title: "AI-Powered Education Platform", + description: + "An adaptive learning platform using artificial intelligence.", + fundingGoal: 75000, + category: "Education", + bannerUrl: + "https://res.cloudinary.com/dmsphf4d3/image/upload/v1741345548/project_images/uq7sf4tjkckc62mgohic.avif", + profileUrl: + "https://res.cloudinary.com/dmsphf4d3/image/upload/v1741345550/project_images/arnuhsp5cct8js56zq01.png", + ideaValidation: "VALIDATED", + }, + }), + ]); + console.log({ projects }); + + // Seed TeamMembers + const teamMembers = await Promise.all([ + prisma.teamMember.create({ + data: { + fullName: "John Doe", + role: "Project Lead", + bio: "Experienced environmental engineer with a passion for clean water.", + profileImage: + "https://res.cloudinary.com/dmsphf4d3/image/upload/v1741345550/project_images/arnuhsp5cct8js56zq01.png", + github: "johndoe", + twitter: "johndoe_eco", + linkedin: "johndoe-eco", + projectId: projects[0].id, + }, + }), + prisma.teamMember.create({ + data: { + fullName: "Jane Smith", + role: "AI Specialist", + bio: "Machine learning expert with a focus on educational technology.", + profileImage: + "https://res.cloudinary.com/dmsphf4d3/image/upload/v1741345550/project_images/arnuhsp5cct8js56zq01.png", + github: "janesmith", + twitter: "janesmith_ai", + linkedin: "janesmith-ai", + projectId: projects[1].id, + }, + }), + prisma.teamMember.create({ + data: { + fullName: "Alex Johnson", + role: "UX Designer", + bio: "User experience designer with a knack for creating intuitive interfaces.", + profileImage: + "https://res.cloudinary.com/dmsphf4d3/image/upload/v1741345550/project_images/arnuhsp5cct8js56zq01.png", + github: "alexj", + twitter: "alexj_design", + linkedin: "alexjohnson-ux", + projectId: projects[1].id, + }, + }), + ]); + console.log({ teamMembers }); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/prisma/seed.ts b/prisma/seed.ts deleted file mode 100644 index 37a96efd..00000000 --- a/prisma/seed.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { PrismaClient } from "@prisma/client" -import { hash } from "bcrypt" - -const prisma = new PrismaClient() - -async function main() { - const password = await hash("adminpassword", 12) - const admin = await prisma.user.upsert({ - where: { email: "admin@example.com" }, - update: {}, - create: { - email: "admin@example.com", - name: "Admin", - password, - role: "ADMIN", - }, - }) - console.log({ admin }) -} - -main() - .catch((e) => { - console.error(e) - process.exit(1) - }) - .finally(async () => { - await prisma.$disconnect() - }) - diff --git a/tsconfig.json b/tsconfig.json index eedb14e2..7fa8619b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,8 @@ "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", - "initialize.js" + "initialize.js", + "prisma/seed.js" ], "exclude": ["node_modules", "packages"] } diff --git a/types/project.ts b/types/project.ts index 82942c5d..df40fdae 100644 --- a/types/project.ts +++ b/types/project.ts @@ -32,5 +32,48 @@ export interface ActivityDataPoint { participants: number; } +export type Project = { + id: string; + userId: string; + title: string; + description: string; + fundingGoal: number; + category: string; + bannerUrl: string | null; + profileUrl: string | null; + blockchainTx: string | null; + ideaValidation: ValidationStatus; + createdAt: string; + user: { + id: string; + name: string | null; + image: string | null; + }; + votes: { + id: string; + userId: string; + }[]; + _count: { + votes: number; + }; +}; + +export interface TeamMember { + id: string; + fullName: string; + role: string; + bio: string | null; + profileImage: string | null; + github: string | null; + twitter: string | null; + discord: string | null; + linkedin: string | null; + userId?: string | null; // Optional + projectId?: string; // Optional + createdAt?: string | Date; // Optional + updatedAt?: string | Date; // Optional +} + export type ExploreFilter = "newest" | "popular" | "ending"; export type CompletedSort = "date" | "size" | "category"; +export type ValidationStatus = "PENDING" | "REJECTED" | "VALIDATED"; From 5ee18abced1fdba00ee1511fba7537cb84acec90 Mon Sep 17 00:00:00 2001 From: Benjtalkshow Date: Mon, 10 Mar 2025 13:29:54 +0100 Subject: [PATCH 10/11] Implement contribution page --- app/(dashboard)/my-contributions/page.tsx | 319 ++++++++++++++++++ app/api/user/comments/route.ts | 171 ++++++++++ app/api/user/contributions/route.ts | 96 ++++++ app/api/user/votes/route.ts | 113 +++++++ .../contributions/active-contribution.tsx | 114 +++++++ components/contributions/call-to-action.tsx | 47 +++ .../contributions/comment-edit-modal.tsx | 84 +++++ .../contributions/contribution-filters.tsx | 102 ++++++ .../contributions/contribution-stats.tsx | 67 ++++ .../delete-confirmation-dialog.tsx | 64 ++++ components/contributions/loading-state.tsx | 151 +++++++++ components/contributions/loading.tsx | 25 ++ .../contributions/past-contributions.tsx | 106 ++++++ components/contributions/user-comments.tsx | 108 ++++++ components/sidebar.tsx | 7 +- components/ui/alert-dialog.tsx | 141 ++++++++ components/ui/dialog.tsx | 122 +++++++ components/ui/table.tsx | 120 +++++++ lib/actions/services.ts | 115 +++++++ lib/utils.ts | 154 +++++++++ package-lock.json | 165 ++++++++- package.json | 3 +- .../20250309212553_boundless/migration.sql | 59 ++++ prisma/schema.prisma | 47 +++ types/contributions.ts | 57 ++++ 25 files changed, 2538 insertions(+), 19 deletions(-) create mode 100644 app/(dashboard)/my-contributions/page.tsx create mode 100644 app/api/user/comments/route.ts create mode 100644 app/api/user/contributions/route.ts create mode 100644 app/api/user/votes/route.ts create mode 100644 components/contributions/active-contribution.tsx create mode 100644 components/contributions/call-to-action.tsx create mode 100644 components/contributions/comment-edit-modal.tsx create mode 100644 components/contributions/contribution-filters.tsx create mode 100644 components/contributions/contribution-stats.tsx create mode 100644 components/contributions/delete-confirmation-dialog.tsx create mode 100644 components/contributions/loading-state.tsx create mode 100644 components/contributions/loading.tsx create mode 100644 components/contributions/past-contributions.tsx create mode 100644 components/contributions/user-comments.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/table.tsx create mode 100644 lib/actions/services.ts create mode 100644 prisma/migrations/20250309212553_boundless/migration.sql create mode 100644 types/contributions.ts 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} + + +
+