From c88e81f488c96a121a165f8d96802cc249e8a619 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Wed, 27 Aug 2025 11:33:07 +0100 Subject: [PATCH 1/6] feat: Enhance SignInPage with form validation and loading state - Implemented form validation using Zod and react-hook-form for email and password fields. - Added loading state to improve user experience during sign-in. - Integrated error handling for various sign-in scenarios, including unverified email and session management. - Updated LoginForm component to accept form props and handle submission logic. - Introduced Loading component for better visual feedback during loading states. - Refactored AuthLayout for improved layout and responsiveness. --- app/auth/signin/page.tsx | 108 +++++++++++++++- components/auth/AuthLayout.tsx | 164 +++++++++++-------------- components/auth/LoginForm.tsx | 109 ++++------------ components/auth/SignupForm.tsx | 6 +- components/loading/Loading.tsx | 12 ++ components/providers/auth-provider.tsx | 13 +- 6 files changed, 219 insertions(+), 193 deletions(-) create mode 100644 components/loading/Loading.tsx diff --git a/app/auth/signin/page.tsx b/app/auth/signin/page.tsx index 62d338d8a..ab1e5131a 100644 --- a/app/auth/signin/page.tsx +++ b/app/auth/signin/page.tsx @@ -1,11 +1,117 @@ 'use client'; import AuthLayout from '@/components/auth/AuthLayout'; import LoginForm from '@/components/auth/LoginForm'; +import { useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { getSession, signIn } from 'next-auth/react'; +import { toast } from 'sonner'; +import Cookies from 'js-cookie'; +import z from 'zod'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import Loading from '@/components/loading/Loading'; + +const formSchema = z.object({ + email: z.string().email({ + message: 'Invalid email address', + }), + password: z.string().min(8, { + message: 'Password must be at least 8 characters', + }), +}); export default function SignInPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const callbackUrl = searchParams.get('callbackUrl') || '/user'; + const [showPassword, setShowPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + email: '', + password: '', + }, + }); + + const onSubmit = async (values: z.infer) => { + setIsLoading(true); + try { + const result = await signIn('credentials', { + email: values.email, + password: values.password, + redirect: false, + }); + + if (result?.error) { + if (result.error === 'UNVERIFIED_EMAIL') { + toast.error( + 'Please verify your email before signing in. Check your inbox for a verification link.' + ); + setTimeout(() => { + router.push( + `/auth/verify-email?email=${encodeURIComponent(values.email)}` + ); + }, 3000); + } else { + form.setError('email', { + type: 'manual', + message: 'Invalid email or password', + }); + form.setError('password', { + type: 'manual', + message: 'Invalid email or password', + }); + } + } else if (result?.ok) { + const session = await getSession(); + + if (session) { + if (session.user.accessToken) { + Cookies.set('accessToken', session.user.accessToken); + } + if (session.user.refreshToken) { + Cookies.set('refreshToken', session.user.refreshToken); + } + router.push(callbackUrl); + } else { + form.setError('root', { + type: 'manual', + message: + 'Login successful but session not found. Please try again.', + }); + } + router.push(callbackUrl); + } else { + form.setError('root', { + type: 'manual', + message: 'An unexpected error occurred. Please try again.', + }); + } + } catch { + form.setError('root', { + type: 'manual', + message: 'An unexpected error occurred. Please try again.', + }); + } finally { + setIsLoading(false); + } + }; + + if (isLoading) { + return ; + } + return ( - + ); } diff --git a/components/auth/AuthLayout.tsx b/components/auth/AuthLayout.tsx index 8a3cd1e0b..4d9586074 100644 --- a/components/auth/AuthLayout.tsx +++ b/components/auth/AuthLayout.tsx @@ -1,8 +1,7 @@ 'use client'; import Image from 'next/image'; -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { Badge } from '../ui/badge'; -import { useState, useEffect } from 'react'; import { Carousel, CarouselContent, @@ -11,7 +10,6 @@ import { CarouselPrevious, type CarouselApi, } from '../ui/carousel'; -import Autoplay from 'embla-carousel-autoplay'; interface AuthLayoutProps { children: React.ReactNode; @@ -19,7 +17,6 @@ interface AuthLayoutProps { } const AuthLayout = ({ children, showCarousel = true }: AuthLayoutProps) => { - // Slide data const slides = [ { id: 1, @@ -67,136 +64,119 @@ const AuthLayout = ({ children, showCarousel = true }: AuthLayoutProps) => { const [api, setApi] = useState(); useEffect(() => { - if (!api) { - return; - } - - api.on('select', () => { - setCurrentSlide(api.selectedScrollSnap()); - }); + if (!api) return; + api.on('select', () => setCurrentSlide(api.selectedScrollSnap())); }, [api]); return ( -
-
- {/* Left Panel - Auth Form */} -
+
+
+ {/* Left Panel */} +
auth + + grid -
+ + {/* Form Content */} +
auth -
{children}
-
-
- auth +
{children}
{/* Right Panel - Carousel */} {showCarousel && ( -
-
-
+
+
+
- + {slides.map(slide => ( - -
- {/* Main card with glassmorphism effects */} -
+ +
+ {/* Image Card Container */} +
Glassmorphism card -
- logo +
+
+ {`${slide.title} +
- {/* Project information section */} -
- + {/* Content */} +
+ {slide.badge} - -
-

- {slide.title} -

-

- {slide.description} -

-
+

+ {slide.title} +

+

+ {slide.description} +

))} -
- -
- {slides.map((_, dotIndex) => ( + + {/* Controls */} +
+ + +
+ {slides.map((_, i) => (
- + +
diff --git a/components/auth/LoginForm.tsx b/components/auth/LoginForm.tsx index ecf184f0d..95503a1db 100644 --- a/components/auth/LoginForm.tsx +++ b/components/auth/LoginForm.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { useState } from 'react'; +import React from 'react'; import { BoundlessButton } from '../buttons'; import { Form, @@ -9,19 +9,14 @@ import { FormMessage, } from '../ui/form'; import { FormLabel } from '../ui/form'; -import z from 'zod'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; import { Input } from '../ui/input'; import { Eye, EyeOff, LockIcon, MailIcon } from 'lucide-react'; import { Checkbox } from '../ui/checkbox'; import { Label } from '../ui/label'; import Link from 'next/link'; -import { getSession, signIn } from 'next-auth/react'; -import { toast } from 'sonner'; -import { useRouter, useSearchParams } from 'next/navigation'; -import Cookies from 'js-cookie'; import Image from 'next/image'; +import { UseFormReturn } from 'react-hook-form'; +import z from 'zod'; const formSchema = z.object({ email: z.string().email({ @@ -32,88 +27,28 @@ const formSchema = z.object({ }), }); -const LoginForm = () => { - const router = useRouter(); - const searchParams = useSearchParams(); - const callbackUrl = searchParams.get('callbackUrl') || '/user'; - const [showPassword, setShowPassword] = useState(false); - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { - email: '', - password: '', - }, - }); - - const onSubmit = async (values: z.infer) => { - try { - const result = await signIn('credentials', { - email: values.email, - password: values.password, - redirect: false, - }); - - if (result?.error) { - if (result.error === 'UNVERIFIED_EMAIL') { - toast.error( - 'Please verify your email before signing in. Check your inbox for a verification link.' - ); - setTimeout(() => { - router.push( - `/auth/verify-email?email=${encodeURIComponent(values.email)}` - ); - }, 3000); - } else { - form.setError('email', { - type: 'manual', - message: 'Invalid email or password', - }); - form.setError('password', { - type: 'manual', - message: 'Invalid email or password', - }); - } - } else if (result?.ok) { - const session = await getSession(); - - if (session) { - if (session.user.accessToken) { - Cookies.set('accessToken', session.user.accessToken); - } - if (session.user.refreshToken) { - Cookies.set('refreshToken', session.user.refreshToken); - } - router.push(callbackUrl); - } else { - form.setError('root', { - type: 'manual', - message: - 'Login successful but session not found. Please try again.', - }); - } - router.push(callbackUrl); - } else { - form.setError('root', { - type: 'manual', - message: 'An unexpected error occurred. Please try again.', - }); - } - } catch { - form.setError('root', { - type: 'manual', - message: 'An unexpected error occurred. Please try again.', - }); - } - }; +interface LoginFormProps { + form: UseFormReturn>; + onSubmit: (values: z.infer) => Promise; + showPassword: boolean; + setShowPassword: (show: boolean) => void; + isLoading: boolean; +} +const LoginForm = ({ + form, + onSubmit, + showPassword, + setShowPassword, + isLoading, +}: LoginFormProps) => { return ( <> -
-

+
+

Sign in

-

+

Sign in to manage campaigns, apply for grants, and track your funding progress — all in one dashboard.

@@ -224,9 +159,9 @@ const LoginForm = () => { Sign in diff --git a/components/auth/SignupForm.tsx b/components/auth/SignupForm.tsx index 5e42fe321..3cecb3ef2 100644 --- a/components/auth/SignupForm.tsx +++ b/components/auth/SignupForm.tsx @@ -139,11 +139,11 @@ const SignupForm = () => { return ( <> -
-

+
+

Create account

-

+

Create an account to manage campaigns, apply for grants, and track your funding progress — all in one dashboard.

diff --git a/components/loading/Loading.tsx b/components/loading/Loading.tsx new file mode 100644 index 000000000..7150387b0 --- /dev/null +++ b/components/loading/Loading.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import LoadingSpinner from '../LoadingSpinner'; + +const Loading = () => { + return ( +
+ +
+ ); +}; + +export default Loading; diff --git a/components/providers/auth-provider.tsx b/components/providers/auth-provider.tsx index cf45b47e9..2a977db95 100644 --- a/components/providers/auth-provider.tsx +++ b/components/providers/auth-provider.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { useAuthStore } from '@/lib/stores/auth-store'; +import Loading from '../loading/Loading'; interface AuthProviderProps { children: React.ReactNode; @@ -63,11 +64,7 @@ export function AuthProvider({ children }: AuthProviderProps) { // Show loading state while hydrating if (!isHydrated) { - return ( -
-
-
- ); + return ; } return <>{children}; @@ -98,11 +95,7 @@ export function AuthLoadingProvider({ const { isLoading } = useAuthStore(); if (!isHydrated || isLoading) { - return ( -
-
-
- ); + return ; } return <>{children}; From 4e1f474ffe7b140860c3bf1760eacc378640e1fd Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Mon, 1 Sep 2025 13:48:24 +0100 Subject: [PATCH 2/6] feat: Revamp landing page structure and components - Removed the old Home component and replaced it with a new layout for the landing page. - Introduced a new LandingLayout component to encapsulate the Navbar and Footer. - Created multiple new pages for the landing section, including About, Contact, Disclaimer, Grants, Hackathons, Privacy, Projects, and Terms of Service. - Updated the Projects component to include pagination and improved project filtering based on user ownership and status. - Enhanced the ProjectCard component to conditionally display the ellipsis menu based on project ownership. - Added a Footer component for consistent navigation across the landing pages. --- app/(landing)/about/page.tsx | 11 ++ app/(landing)/code-of-conduct/page.tsx | 11 ++ app/(landing)/contact/page.tsx | 11 ++ app/(landing)/disclaimer/page.tsx | 11 ++ app/(landing)/grants/page.tsx | 11 ++ app/(landing)/hackathons/page.tsx | 11 ++ app/(landing)/layout.tsx | 44 +++++ app/(landing)/page.tsx | 9 + app/(landing)/privacy/page.tsx | 11 ++ app/(landing)/projects/page.tsx | 11 ++ app/(landing)/terms/page.tsx | 11 ++ app/page.tsx | 42 ----- components/Projects.tsx | 241 +++++++++++++++++++++---- components/auth/AuthLayout.tsx | 37 ++-- components/landing-page/footer.tsx | 40 ++++ components/landing-page/index.ts | 2 + components/landing-page/navbar.tsx | 84 +++++++++ components/overview/RecentProjects.tsx | 144 ++++++++++----- components/project-card.tsx | 17 +- lib/api/project.ts | 31 +++- 20 files changed, 638 insertions(+), 152 deletions(-) create mode 100644 app/(landing)/about/page.tsx create mode 100644 app/(landing)/code-of-conduct/page.tsx create mode 100644 app/(landing)/contact/page.tsx create mode 100644 app/(landing)/disclaimer/page.tsx create mode 100644 app/(landing)/grants/page.tsx create mode 100644 app/(landing)/hackathons/page.tsx create mode 100644 app/(landing)/layout.tsx create mode 100644 app/(landing)/page.tsx create mode 100644 app/(landing)/privacy/page.tsx create mode 100644 app/(landing)/projects/page.tsx create mode 100644 app/(landing)/terms/page.tsx delete mode 100644 app/page.tsx create mode 100644 components/landing-page/footer.tsx create mode 100644 components/landing-page/index.ts create mode 100644 components/landing-page/navbar.tsx diff --git a/app/(landing)/about/page.tsx b/app/(landing)/about/page.tsx new file mode 100644 index 000000000..316d4af44 --- /dev/null +++ b/app/(landing)/about/page.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const AboutPage = () => { + return ( +
+ About Page +
+ ); +}; + +export default AboutPage; diff --git a/app/(landing)/code-of-conduct/page.tsx b/app/(landing)/code-of-conduct/page.tsx new file mode 100644 index 000000000..111b92ea7 --- /dev/null +++ b/app/(landing)/code-of-conduct/page.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const CodeOfConductPage = () => { + return ( +
+ Code of Conduct Page +
+ ); +}; + +export default CodeOfConductPage; diff --git a/app/(landing)/contact/page.tsx b/app/(landing)/contact/page.tsx new file mode 100644 index 000000000..329b3f78e --- /dev/null +++ b/app/(landing)/contact/page.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const ContactPage = () => { + return ( +
+ Contact Page +
+ ); +}; + +export default ContactPage; diff --git a/app/(landing)/disclaimer/page.tsx b/app/(landing)/disclaimer/page.tsx new file mode 100644 index 000000000..a4bf39da8 --- /dev/null +++ b/app/(landing)/disclaimer/page.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const DisclaimerPage = () => { + return ( +
+ Disclaimer Page +
+ ); +}; + +export default DisclaimerPage; diff --git a/app/(landing)/grants/page.tsx b/app/(landing)/grants/page.tsx new file mode 100644 index 000000000..c8b907b18 --- /dev/null +++ b/app/(landing)/grants/page.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const GrantPage = () => { + return ( +
+ Grant Page +
+ ); +}; + +export default GrantPage; diff --git a/app/(landing)/hackathons/page.tsx b/app/(landing)/hackathons/page.tsx new file mode 100644 index 000000000..399bb6c39 --- /dev/null +++ b/app/(landing)/hackathons/page.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const HackathonPage = () => { + return ( +
+ Hackathon Page +
+ ); +}; + +export default HackathonPage; diff --git a/app/(landing)/layout.tsx b/app/(landing)/layout.tsx new file mode 100644 index 000000000..a1796000b --- /dev/null +++ b/app/(landing)/layout.tsx @@ -0,0 +1,44 @@ +import { Metadata } from 'next'; +import { ReactNode } from 'react'; +import { Navbar, Footer } from '@/components/landing-page'; + +export const metadata: Metadata = { + title: 'Boundless - Ideas Made Boundless', + description: + 'Validate, fund, and grow your project with milestone-based support on Stellar.', + openGraph: { + title: 'Boundless - Ideas Made Boundless', + description: + 'Validate, fund, and grow your project with milestone-based support on Stellar.', + type: 'website', + images: [ + { + url: '/og-image-placeholder.png', + width: 1200, + height: 630, + alt: 'Boundless', + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: 'Boundless - Ideas Made Boundless', + description: + 'Validate, fund, and grow your project with milestone-based support on Stellar.', + images: ['/og-image-placeholder.png'], + }, +}; + +interface LandingLayoutProps { + children: ReactNode; +} + +export default function LandingLayout({ children }: LandingLayoutProps) { + return ( +
+ +
{children}
+
+
+ ); +} diff --git a/app/(landing)/page.tsx b/app/(landing)/page.tsx new file mode 100644 index 000000000..9d92e08ec --- /dev/null +++ b/app/(landing)/page.tsx @@ -0,0 +1,9 @@ +export default function LandingPage() { + return ( +
+

+ Landing Page +

+
+ ); +} diff --git a/app/(landing)/privacy/page.tsx b/app/(landing)/privacy/page.tsx new file mode 100644 index 000000000..f6a3b2895 --- /dev/null +++ b/app/(landing)/privacy/page.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const PrivacyPage = () => { + return ( +
+ Privacy Page +
+ ); +}; + +export default PrivacyPage; diff --git a/app/(landing)/projects/page.tsx b/app/(landing)/projects/page.tsx new file mode 100644 index 000000000..7e1108ba0 --- /dev/null +++ b/app/(landing)/projects/page.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const ProjectsPage = () => { + return ( +
+ Projects Page +
+ ); +}; + +export default ProjectsPage; diff --git a/app/(landing)/terms/page.tsx b/app/(landing)/terms/page.tsx new file mode 100644 index 000000000..52f920548 --- /dev/null +++ b/app/(landing)/terms/page.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const TermsOfServicePage = () => { + return ( +
+ Terms of Service Page +
+ ); +}; + +export default TermsOfServicePage; diff --git a/app/page.tsx b/app/page.tsx deleted file mode 100644 index 6e2d67c69..000000000 --- a/app/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -'use client'; -import { BoundlessButton } from '@/components/buttons'; -import { AuthNav } from '@/components/auth/AuthNav'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import CommentModal from '@/components/comment/modal'; -import { motion } from 'framer-motion'; -import { - staggerContainer, - slideInFromLeft, - slideInFromRight, -} from '@/lib/motion'; -import PageTransition from '@/components/PageTransition'; -import Link from 'next/link'; - -export default function Home() { - return ( - - - -

Boundless Project

- -
- - - Go to Dashboard - - -
-
- ); -} diff --git a/components/Projects.tsx b/components/Projects.tsx index f86796acb..b2c60c609 100644 --- a/components/Projects.tsx +++ b/components/Projects.tsx @@ -15,6 +15,15 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from './ui/dropdown-menu'; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, + PaginationEllipsis, +} from './ui/pagination'; import { getProjects } from '@/lib/api/project'; import { toast } from 'sonner'; @@ -38,9 +47,14 @@ const Projects = () => { const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [totalItems, setTotalItems] = useState(0); const { user, isAuthenticated } = useAuth(false); const sheet = useProjectSheetStore(); + const ITEMS_PER_PAGE = 9; + const filterOptions = [ { value: 'all', label: 'All' }, { value: 'idea', label: 'Ideas' }, @@ -51,11 +65,24 @@ const Projects = () => { { value: 'completed', label: 'Completed' }, ]; - const fetchProjects = useCallback(async () => { + const fetchProjects = useCallback(async (pageNum = 1) => { try { setLoading(true); setError(null); - const response = await getProjects(); + + // Build filters based on current state + const filters: { status?: string; owner?: string } = {}; + + if (statusFilter !== 'all') { + filters.status = statusFilter; + } + + // Add owner filter for "mine" tab + if (tabFilter === 'mine' && isAuthenticated && user) { + filters.owner = user.id; + } + + const response = await getProjects(pageNum, ITEMS_PER_PAGE, filters); const transformedProjects = (response.projects || []).map( (project: any) => ({ @@ -82,7 +109,14 @@ const Projects = () => { ); setProjects(transformedProjects); - } catch { + + // Update pagination state + if (response.pagination) { + setTotalPages(response.pagination.totalPages || 1); + setTotalItems(response.pagination.totalItems || 0); + } + } catch (error) { + console.error('Error fetching projects:', error); setError('Failed to fetch projects'); toast.error('Failed to fetch projects'); } finally { @@ -91,8 +125,53 @@ const Projects = () => { }, []); useEffect(() => { - fetchProjects(); - }, [fetchProjects]); + setCurrentPage(1); + fetchProjects(1); + }, [statusFilter, tabFilter, isAuthenticated, user?.id]); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + fetchProjects(page); + }; + + const generatePaginationItems = () => { + const items = []; + const maxVisiblePages = 5; + + if (totalPages <= maxVisiblePages) { + // Show all pages if total is small + for (let i = 1; i <= totalPages; i++) { + items.push(i); + } + } else { + items.push(1); + + if (currentPage > 3) { + items.push('ellipsis-start'); + } + + // Show pages around current page + const start = Math.max(2, currentPage - 1); + const end = Math.min(totalPages - 1, currentPage + 1); + + for (let i = start; i <= end; i++) { + if (i !== 1 && i !== totalPages) { + items.push(i); + } + } + + if (currentPage < totalPages - 2) { + items.push('ellipsis-end'); + } + + // Show last page + if (totalPages > 1) { + items.push(totalPages); + } + } + + return items; + }; if (loading) { return ( @@ -119,28 +198,51 @@ const Projects = () => { variants={fadeInUp} >
-

- {tabFilter === 'mine' ? 'My Projects' : 'All Projects'} -

-
-
setTabFilter(value as TabFilter)} className='w-full sm:w-auto' > - + - In Progress + My Projects + Explore ({totalItems}) + + + +
+
+ setStatusFilter(value as StatusFilter)} + className='w-full sm:w-auto' + > + + + All + + - Active + Funding + + + Funded @@ -199,7 +301,7 @@ const Projects = () => {

{error}

+ + + + + +
+
+
+ + ); +} diff --git a/components/overview/RecentProjects.tsx b/components/overview/RecentProjects.tsx index d0e2b7cff..9473939b4 100644 --- a/components/overview/RecentProjects.tsx +++ b/components/overview/RecentProjects.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { useState, useEffect, useCallback } from 'react'; import { RecentProjectsProps, Project } from '@/types/project'; -import { Plus, ChevronRight, ChevronDown } from 'lucide-react'; +import { Plus, ChevronDown } from 'lucide-react'; import ProjectCard from '../project-card'; import EmptyState from '../EmptyState'; import { BoundlessButton } from '../buttons'; @@ -15,11 +15,11 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '../ui/dropdown-menu'; -import Link from 'next/link'; import { getProjects } from '@/lib/api/project'; import { toast } from 'sonner'; import { useAuth } from '@/hooks/use-auth'; import { RecentProjectsSkeleton } from '../skeleton/UserPageSkeleton'; +import Link from 'next/link'; type StatusFilter = | 'all' @@ -117,7 +117,7 @@ const RecentProjects = () => { useEffect(() => { fetchProjects(); - }, [fetchProjects]); + }, []); if (loading) { return ( @@ -144,40 +144,54 @@ const RecentProjects = () => { variants={fadeInUp} >
-

- {tabFilter === 'mine' ? 'My Projects' : 'All Projects'} -

- - - -
-
setTabFilter(value as TabFilter)} className='w-full sm:w-auto' > - + - Mine + My Projects - Others + Explore ({projects.length}) +
+
+ {/* setStatusFilter(value as StatusFilter)} + className='w-full sm:w-auto' + > + + + All + + + Funding + + + Funded + + + */} { let filteredProjects = projects; // Filter based on user ownership - if (!isAuthenticated) { - // If not authenticated, show all projects in both tabs - } else if (tabFilter === 'mine') { - // Show only user's own projects - filteredProjects = projects.filter( - project => - project.owner === user?.id || - project.ownerUsername === user?.email || - project.ownerName === user?.name - ); + if (tabFilter === 'mine') { + if (isAuthenticated && user) { + // Show only user's own projects when authenticated + filteredProjects = projects.filter( + project => + project.owner === user?.id || + project.ownerUsername === user?.email || + project.ownerName === user?.name + ); + } else { + // If not authenticated, show empty for "mine" tab + filteredProjects = []; + } } else { - // Show all projects except user's own - filteredProjects = projects.filter( - project => - project.owner !== user?.id && - project.ownerUsername !== user?.email && - project.ownerName !== user?.name - ); + // Show all projects except user's own in "explore" tab + if (isAuthenticated && user) { + filteredProjects = projects.filter( + project => + project.owner !== user?.id && + project.ownerUsername !== user?.email && + project.ownerName !== user?.name + ); + } else { + // If not authenticated, show all projects in explore tab + filteredProjects = projects; + } } - // Additional filtering based on status if needed + // Additional filtering based on status if (statusFilter !== 'all') { filteredProjects = filteredProjects.filter( project => project.status === statusFilter @@ -279,11 +301,33 @@ const RecentProjects = () => { filteredProjects = filteredProjects.slice(0, 3); if (filteredProjects.length > 0) { - return filteredProjects.map((project, index) => ( - - - - )); + return ( + <> + {filteredProjects.map((project, index) => ( + + + + ))} + {/* Show "View All" button when there are projects */} +
+ + + View All + + +
+ + ); } else { return ( @@ -291,17 +335,21 @@ const RecentProjects = () => { void; showCreatorName?: boolean; showEllipsisMenu?: boolean; + currentUserId?: string | null; + currentUserEmail?: string | null; + currentUserName?: string | null; } type StepState = 'active' | 'pending' | 'completed'; @@ -97,10 +100,22 @@ const ProjectCard: React.FC = ({ onVote, showCreatorName = false, showEllipsisMenu = false, + currentUserId, + currentUserEmail, + currentUserName, }) => { const [validationSheetOpen, setValidationSheetOpen] = useState(false); const [launchCampaignSheetOpen, setLaunchCampaignSheetOpen] = useState(false); + // Check if current user owns this project + const isProjectOwner = + (currentUserId && project.owner === currentUserId) || + (currentUserEmail && project.ownerUsername === currentUserEmail) || + (currentUserName && project.ownerName === currentUserName); + + // Only show ellipsis menu if user owns the project + const shouldShowEllipsisMenu = showEllipsisMenu && isProjectOwner; + // Wallet protection hook const { requireWallet, @@ -289,7 +304,7 @@ const ProjectCard: React.FC = ({ ? `${project.name} by Collins Odumeje` : project.name}

- {showEllipsisMenu && ( + {shouldShowEllipsisMenu && ( { return res; }; -export const getProjects = async (): Promise<{ +export const getProjects = async ( + page = 1, + limit = 9, + filters?: { + status?: string; + owner?: string; + } +): Promise<{ projects: RecentProjectsProps[]; + pagination: { + currentPage: number; + totalPages: number; + totalItems: number; + hasNextPage: boolean; + hasPrevPage: boolean; + }; }> => { - const res = await api.get('/projects'); + const params = new URLSearchParams({ + page: page.toString(), + limit: limit.toString(), + }); + + if (filters?.status && filters.status !== 'all') { + params.append('status', filters.status); + } + + if (filters?.owner) { + params.append('owner', filters.owner); + } + + const res = await api.get(`/projects?${params.toString()}`); return res.data.data; }; From e6bebb3e9b482e1f2f6638c656f30a9fc1a092e8 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Mon, 1 Sep 2025 16:10:01 +0100 Subject: [PATCH 3/6] feat: Update CampaignTable and pagination handling across user pages - Modified CampaignTable component to accept `limit` and `showPagination` props for flexible display. - Adjusted CampaignTable instances in UserPage and CampaignsPage to utilize new props. - Refactored pagination logic in Projects component to streamline page navigation and improve user experience. - Enhanced Pagination component for better integration and usability across different contexts. --- app/user/campaigns/page.tsx | 2 +- app/user/page.tsx | 2 +- components/Projects.tsx | 108 ++------------ components/campaigns/CampaignTable.tsx | 100 ++++--------- components/ui/pagination.tsx | 192 ++++++++++--------------- 5 files changed, 112 insertions(+), 292 deletions(-) diff --git a/app/user/campaigns/page.tsx b/app/user/campaigns/page.tsx index 179e4b795..f50499c11 100644 --- a/app/user/campaigns/page.tsx +++ b/app/user/campaigns/page.tsx @@ -33,7 +33,7 @@ export default function CampaignsPage() { {/* Campaign Table Section */}
- +
diff --git a/app/user/page.tsx b/app/user/page.tsx index 46f18f1d4..aa7914ea9 100644 --- a/app/user/page.tsx +++ b/app/user/page.tsx @@ -87,7 +87,7 @@ export default function UserPage() { {/* Recent Projects - Full Width */}
- +
{/* Recent Contributions and Grant History - Side by Side on larger screens */} diff --git a/components/Projects.tsx b/components/Projects.tsx index b2c60c609..a92bfed6a 100644 --- a/components/Projects.tsx +++ b/components/Projects.tsx @@ -15,15 +15,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from './ui/dropdown-menu'; -import { - Pagination, - PaginationContent, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, - PaginationEllipsis, -} from './ui/pagination'; +import Pagination from './ui/pagination'; import { getProjects } from '@/lib/api/project'; import { toast } from 'sonner'; @@ -134,45 +126,6 @@ const Projects = () => { fetchProjects(page); }; - const generatePaginationItems = () => { - const items = []; - const maxVisiblePages = 5; - - if (totalPages <= maxVisiblePages) { - // Show all pages if total is small - for (let i = 1; i <= totalPages; i++) { - items.push(i); - } - } else { - items.push(1); - - if (currentPage > 3) { - items.push('ellipsis-start'); - } - - // Show pages around current page - const start = Math.max(2, currentPage - 1); - const end = Math.min(totalPages - 1, currentPage + 1); - - for (let i = start; i <= end; i++) { - if (i !== 1 && i !== totalPages) { - items.push(i); - } - } - - if (currentPage < totalPages - 2) { - items.push('ellipsis-end'); - } - - // Show last page - if (totalPages > 1) { - items.push(totalPages); - } - } - - return items; - }; - if (loading) { return ( { {/* Pagination */} {totalPages > 1 && ( - - - - - handlePageChange(currentPage - 1)} - className={`cursor-pointer ${ - currentPage === 1 - ? 'text-[#484848] cursor-not-allowed' - : 'text-white hover:text-primary' - }`} - style={{ pointerEvents: currentPage === 1 ? 'none' : 'auto' }} - /> - - - {generatePaginationItems().map((item, index) => ( - - {item === 'ellipsis-start' || item === 'ellipsis-end' ? ( - - ) : ( - handlePageChange(item as number)} - isActive={currentPage === item} - className={`cursor-pointer ${ - currentPage === item - ? 'bg-[#2B2B2B] text-white border-[#2B2B2B]' - : 'text-[#B5B5B5] hover:text-white hover:bg-[#2B2B2B]' - }`} - > - {item} - - )} - - ))} - - - handlePageChange(currentPage + 1)} - className={`cursor-pointer ${ - currentPage === totalPages - ? 'text-[#484848] cursor-not-allowed' - : 'text-white hover:text-primary' - }`} - style={{ - pointerEvents: currentPage === totalPages ? 'none' : 'auto', - }} - /> - - - + + )} diff --git a/components/campaigns/CampaignTable.tsx b/components/campaigns/CampaignTable.tsx index 2276f3f66..6cfd18e7b 100644 --- a/components/campaigns/CampaignTable.tsx +++ b/components/campaigns/CampaignTable.tsx @@ -23,6 +23,7 @@ import { import BackingHistory from './backing-history'; import { sampleBackers } from '@/lib/data/backing-history-mock'; import { CampaignTableSkeleton } from '../skeleton/UserPageSkeleton'; +import Pagination from '../ui/pagination'; const CampaignRow = ({ campaign, @@ -417,7 +418,15 @@ const CampaignRow = ({ ); }; -const CampaignTable = () => { +interface CampaignTableProps { + limit?: number; + showPagination?: boolean; +} + +const CampaignTable = ({ + limit = 100, + showPagination = false, +}: CampaignTableProps) => { const [statusFilter, setStatusFilter] = useState('all'); const [tabFilter, setTabFilter] = useState('mine'); const [campaigns, setCampaigns] = useState([]); @@ -427,7 +436,7 @@ const CampaignTable = () => { const [backingHistoryOpen, setBackingHistoryOpen] = useState(false); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); - const itemsPerPage = 10; + const itemsPerPage = limit; // Quick filter options - keeping it simple for now const filterOptions = [ @@ -448,9 +457,10 @@ const CampaignTable = () => { page, itemsPerPage ); - // TODO: Handle empty state better - maybe add a refresh button? setCampaigns(response.data); - setTotalPages(Math.ceil(response.total / itemsPerPage)); + if (showPagination) { + setTotalPages(Math.ceil(response.total / itemsPerPage)); + } } catch { setError('Failed to fetch campaigns'); toast.error('Failed to fetch campaigns'); @@ -458,7 +468,7 @@ const CampaignTable = () => { setLoading(false); } }, - [statusFilter, tabFilter, itemsPerPage] + [statusFilter, tabFilter, itemsPerPage, showPagination] ); // Handle different campaign actions - TODO: extract this to a separate hook @@ -506,8 +516,10 @@ const CampaignTable = () => { }, [statusFilter, tabFilter, fetchCampaigns]); useEffect(() => { - fetchCampaigns(currentPage); - }, [currentPage, fetchCampaigns]); + if (showPagination) { + fetchCampaigns(currentPage); + } + }, [currentPage, fetchCampaigns, showPagination]); const handlePageChange = (page: number) => { setCurrentPage(page); @@ -650,74 +662,12 @@ const CampaignTable = () => { )) )} - {totalPages > 1 && campaigns.length > 0 && ( -
-
- - Page {currentPage} of {totalPages} - - -
- - - {/* Basic pagination numbers */} - {Array.from({ length: totalPages }, (_, i) => i + 1).map( - page => { - // Only show if we're within 2 pages or it's first/last - if ( - page === 1 || - page === totalPages || - Math.abs(page - currentPage) <= 1 - ) { - return ( - - ); - } else if ( - page === currentPage - 2 || - page === currentPage + 2 - ) { - return ( - - ... - - ); - } - return null; - } - )} - - -
-
-
+ {showPagination && campaigns.length > 0 && ( + )}

diff --git a/components/ui/pagination.tsx b/components/ui/pagination.tsx index 2923f78c3..ee55e9e1e 100644 --- a/components/ui/pagination.tsx +++ b/components/ui/pagination.tsx @@ -1,127 +1,85 @@ -import * as React from 'react'; -import { - ChevronLeftIcon, - ChevronRightIcon, - MoreHorizontalIcon, -} from 'lucide-react'; +import React from 'react'; +import { BoundlessButton } from '../buttons'; -import { cn } from '@/lib/utils'; -import { Button, buttonVariants } from '@/components/ui/button'; - -function Pagination({ className, ...props }: React.ComponentProps<'nav'>) { - return ( -